Skip to content

Commit 31c8914

Browse files
committed
Implement basic functionality
1 parent 9fecf6e commit 31c8914

11 files changed

+421
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,5 @@ dist
128128
.yarn/build-state.yml
129129
.yarn/install-state.gz
130130
.pnp.*
131+
132+
output/

package.json

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@snakeroom/field-screenshot",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "Captures images of r/Field.",
6+
"main": "./src/index.js",
7+
"type": "module",
8+
"scripts": {
9+
"dev": "node --watch .",
10+
"lint": "eslint \"./**/*.js\" --ignore-path .gitignore",
11+
"start": "node ."
12+
},
13+
"keywords": [
14+
"field",
15+
"reddit",
16+
"image",
17+
"canvas"
18+
],
19+
"author": "haykam821",
20+
"license": "MIT",
21+
"dependencies": {
22+
"@devvit/protos": "^0.11.11",
23+
"canvas": "^3.1.0",
24+
"debug": "^4.4.0",
25+
"supports-color": "^10.0.0"
26+
},
27+
"devDependencies": {
28+
"@types/debug": "^4.1.12",
29+
"@types/node": "^22.13.17",
30+
"eslint": "^8.57.1",
31+
"eslint-config-haykam": "^1.24.0"
32+
},
33+
"repository": {
34+
"type": "git",
35+
"url": "git+https://github.com/Snakeroom/Field-Capture.git"
36+
},
37+
"bugs": {
38+
"url": "https://github.com/Snakeroom/Field-Capture/issues"
39+
},
40+
"homepage": "https://github.com/Snakeroom/Field-Capture#readme",
41+
"eslintConfig": {
42+
"extends": "eslint-config-haykam",
43+
"parserOptions": {
44+
"sourceType": "module"
45+
}
46+
}
47+
}

src/config.js

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { decodeGrpc, encodeGrpc } from "./utils/grpc.js";
2+
3+
import { CustomPostDefinition } from "@devvit/protos";
4+
import { USER_AGENT } from "./utils/constants.js";
5+
import { log } from "./utils/log.js";
6+
7+
const RENDER_POST_CONTENT_URL = "https://devvit-gateway.reddit.com/devvit.reddit.custom_post.v1alpha.CustomPost/RenderPostContent";
8+
const GRPC_CONTENT_TYPE = "application/grpc-web+proto";
9+
10+
const GRPC_MESSAGE_HEADER = "grpc-message";
11+
12+
export async function getFieldConfig(installation, postId, cookie) {
13+
log("fetching post data for installation %s", installation);
14+
15+
const body = encodeGrpc(CustomPostDefinition.methods.renderPostContent.requestType, {
16+
events: [],
17+
props: {
18+
postId,
19+
},
20+
});
21+
22+
const response = await fetch(RENDER_POST_CONTENT_URL, {
23+
body,
24+
headers: {
25+
"content-type": GRPC_CONTENT_TYPE,
26+
cookie,
27+
"devvit-installation": installation,
28+
"user-agent": USER_AGENT,
29+
},
30+
method: "post",
31+
});
32+
33+
if (!response.ok) {
34+
throw new Error("Failed to fetch post data: " + response.statusText);
35+
}
36+
37+
const data = await response.arrayBuffer();
38+
39+
if (data.byteLength === 0) {
40+
if (response.headers.has(GRPC_MESSAGE_HEADER)) {
41+
throw new Error("Failed to fetch post data: " + response.headers.get(GRPC_MESSAGE_HEADER));
42+
} else {
43+
throw new Error("No post data found");
44+
}
45+
}
46+
47+
log("fetched post data");
48+
49+
const buffer = Buffer.from(data);
50+
const post = decodeGrpc(CustomPostDefinition.methods.renderPostContent.responseType, buffer);
51+
52+
const state = Object.values(post.state).find(statex => {
53+
return statex?.value?.appConfig !== undefined;
54+
});
55+
56+
const config = state?.value;
57+
58+
if (!config) {
59+
throw new Error("No config found in post data");
60+
}
61+
62+
log("decoded config");
63+
return state?.value;
64+
}

src/index.js

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { FIELDS, OUTPUT_DIR } from "./utils/constants.js";
2+
3+
import { createWriteStream } from "node:fs";
4+
import { getFieldConfig } from "./config.js";
5+
import { log } from "./utils/log.js";
6+
import { mkdir } from "node:fs/promises";
7+
import { renderPartition } from "./render.js";
8+
9+
await mkdir(OUTPUT_DIR, {
10+
recursive: true,
11+
});
12+
13+
for (const { cookie, id, installation, overrideConfig, post } of FIELDS) {
14+
try {
15+
const config = overrideConfig ?? await getFieldConfig(installation, post, cookie);
16+
17+
const subreddit = config.level.subredditId;
18+
const name = config.level.title;
19+
20+
const challenge = config.challengeNumber;
21+
const sequence = config.initialMapKey.sequenceNumber;
22+
23+
const totalSize = config.challengeConfig.size;
24+
const partitionSize = config.challengeConfig.partitionSize;
25+
26+
const canvas = await renderPartition(totalSize, partitionSize, subreddit, challenge, sequence, name);
27+
28+
const writeStream = createWriteStream(`${OUTPUT_DIR}/${id}.png`);
29+
canvas.createPNGStream().pipe(writeStream);
30+
} catch (error) {
31+
log("failed to capture screenshot for %s", installation, error);
32+
}
33+
}

src/partition.js

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { USER_AGENT } from "./utils/constants.js";
2+
import { log } from "./utils/log.js";
3+
4+
function getPartitionName(partitionX, partitionY, subreddit, challenge, sequence) {
5+
return `[${subreddit} / challenge ${challenge} / (${partitionX}, ${partitionY}) @ ${sequence}]`;
6+
}
7+
8+
function getPartitionUrl(partitionX, partitionY, subreddit, challenge, sequence) {
9+
return `https://webview.devvit.net/a1/field-app/px_${partitionX}__py_${partitionY}/${subreddit}/p/${challenge}/${sequence}`;
10+
}
11+
12+
export async function fetchPartition(partitionX, partitionY, subreddit, challenge, sequence) {
13+
const name = getPartitionName(partitionX, partitionY, subreddit, challenge, sequence);
14+
const url = getPartitionUrl(partitionX, partitionY, subreddit, challenge, sequence);
15+
16+
const response = await fetch(url, {
17+
headers: {
18+
"user-agent": USER_AGENT,
19+
},
20+
});
21+
22+
if (!response.ok) {
23+
log("partition %s is blank", name);
24+
return null;
25+
}
26+
27+
log("fetched partition %s", name);
28+
29+
const blob = await response.blob();
30+
return blob.bytes();
31+
}

src/render.js

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { log, statsLog } from "./utils/log.js";
2+
3+
import { createCanvas } from "canvas";
4+
import { decodePartition } from "./utils/decode.js";
5+
import { fetchPartition } from "./partition.js";
6+
import { getCellColor } from "./utils/color.js";
7+
8+
export async function renderPartition(totalSize, partitionSize, subreddit, challenge, sequence, name) {
9+
log("capturing screenshot for %s", name);
10+
11+
const partitionCount = Math.floor(totalSize / partitionSize);
12+
13+
const canvas = createCanvas(totalSize, totalSize);
14+
const context = canvas.getContext("2d");
15+
16+
const imageData = context.createImageData(partitionSize, partitionSize);
17+
18+
let total = 0;
19+
let bans = 0;
20+
21+
for (let partitionX = 0; partitionX < partitionCount; partitionX += 1) {
22+
for (let partitionY = 0; partitionY < partitionCount; partitionY += 1) {
23+
const partition = await fetchPartition(partitionX, partitionY, subreddit, challenge, sequence);
24+
25+
if (partition !== null) {
26+
const cells = decodePartition(partition);
27+
28+
let index = 0;
29+
30+
for (const cell of cells) {
31+
const color = getCellColor(cell);
32+
33+
imageData.data[index * 4] = (color >> 16) & 0xFF;
34+
imageData.data[index * 4 + 1] = (color >> 8) & 0xFF;
35+
imageData.data[index * 4 + 2] = color & 0xFF;
36+
imageData.data[index * 4 + 3] = 255;
37+
38+
index += 1;
39+
total += 1;
40+
41+
if (cell?.ban) {
42+
bans += 1;
43+
}
44+
}
45+
46+
context.putImageData(imageData, partitionX * partitionSize, partitionY * partitionSize);
47+
}
48+
}
49+
}
50+
51+
statsLog("%s% of claimed cells on %s are bans", (bans / Math.max(total, 1) * 100).toFixed(2), name);
52+
53+
return canvas;
54+
}

src/utils/color.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const TEAM_COLORS = [
2+
/* eslint-disable no-inline-comments */
3+
0xCC3A83, // Flamingo
4+
0x6B92F2, // Juicebox
5+
0xEA6126, // Lasagna
6+
0xEDA635, // Sunshine
7+
/* eslint-enable no-inline-comments */
8+
];
9+
10+
const BAN_COLOR = 0x7DFF00;
11+
const UNCLAIMED_COLOR = 0x000000;
12+
13+
/**
14+
* Gets an RGB color representing a given cell.
15+
* @param {import("./decode").Cell | null} cell the cell to get the color for
16+
* @returns {number} the color
17+
*/
18+
export function getCellColor(cell) {
19+
if (cell?.ban) {
20+
return BAN_COLOR;
21+
} else if (cell?.team !== undefined) {
22+
return TEAM_COLORS[cell.team];
23+
}
24+
25+
return UNCLAIMED_COLOR;
26+
}

src/utils/constants.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const OUTPUT_DIR = "./output";
2+
3+
export const USER_AGENT = "Field Screenshot v1.0.0";
4+
5+
/**
6+
* @typedef {Object} FieldSource
7+
* @property {string?} cookie the cookies to fetch the post with
8+
* @property {string} id the id to write the image to
9+
* @property {string} installation the Devvit installation ID
10+
* @property {unknown} overrideConfig the config to override the fetched config with
11+
* @property {string} post the post ID
12+
*/
13+
14+
/**
15+
* @type {FieldSource[]}
16+
*/
17+
export const FIELDS = [{
18+
cookie: process.env.FIELD_COOKIE,
19+
id: "field",
20+
installation: "d705e66b-39d3-4568-b08b-b11655c064c3",
21+
post: "t3_1jkhl1m",
22+
}, {
23+
cookie: process.env.BANNED_FIELD_COOKIE,
24+
id: "bannedfield",
25+
installation: "076afb2c-8aa3-4bd8-8d68-973c0169974a",
26+
post: "t3_1jkhly8",
27+
}, {
28+
cookie: process.env.BANANA_FIELD_COOKIE,
29+
id: "bananafield",
30+
installation: "233820d3-60e3-48de-94ee-8853227c003c",
31+
post: "t3_1jkhmx9",
32+
}, {
33+
cookie: process.env.WHAT_IS_FIELD_COOKIE,
34+
id: "whatisfield",
35+
installation: "8b81865e-e76b-4d51-b1d9-30e4043e0a38",
36+
post: "t3_1jmw52r",
37+
}];

0 commit comments

Comments
 (0)