@omercnet/paseo-queens 0.1.0-next.126.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/client/completion-feedback.tsx +55 -0
- package/client/composer-pill.tsx +74 -0
- package/client/contribute.tsx +34 -0
- package/client/game/curated.ts +91 -0
- package/client/game/engine.ts +199 -0
- package/client/game/index.ts +5 -0
- package/client/game/puzzles.ts +100 -0
- package/client/game/state.ts +348 -0
- package/client/game/types.ts +7 -0
- package/client/game-controls.tsx +199 -0
- package/client/game-mark.tsx +54 -0
- package/client/puzzle-selector.tsx +187 -0
- package/client/queens-board.tsx +592 -0
- package/client/queens-popover.tsx +349 -0
- package/client/queens-surface.tsx +761 -0
- package/client/use-persisted-game.ts +769 -0
- package/client/use-puzzle-catalog.ts +112 -0
- package/index.client.tsx +6 -0
- package/index.server.ts +10 -0
- package/package.json +58 -0
- package/paseo-plugin.json +4 -0
- package/scripts/import-curated-puzzles.mjs +231 -0
- package/server/curated-manifest.ts +334 -0
- package/server/puzzle-catalog.ts +70 -0
- package/shared/game-settings.ts +114 -0
- package/shared/puzzle-catalog.ts +29 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { DEFAULT_BOARD_SIZE, DEFAULT_DIFFICULTY, gameSettings } from "../shared/game-settings";
|
|
4
|
+
import { loadPuzzleDeck, type PuzzleDifficulty } from "../shared/puzzle-catalog";
|
|
5
|
+
import { type CuratedPuzzleDeck, decodePuzzleDeck } from "./game/curated";
|
|
6
|
+
|
|
7
|
+
const DECK_PROMISES = new Map<string, Promise<CuratedPuzzleDeck>>();
|
|
8
|
+
const MAX_CACHED_DECKS = 3;
|
|
9
|
+
|
|
10
|
+
export type PuzzleCatalogState = {
|
|
11
|
+
readonly size: number;
|
|
12
|
+
readonly difficulty: PuzzleDifficulty;
|
|
13
|
+
readonly deck: CuratedPuzzleDeck | null;
|
|
14
|
+
readonly loading: boolean;
|
|
15
|
+
readonly error: string | null;
|
|
16
|
+
readonly retry: () => void;
|
|
17
|
+
readonly select: (size: number, difficulty: PuzzleDifficulty) => Promise<boolean>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function usePuzzleCatalog(): PuzzleCatalogState {
|
|
21
|
+
const settings = useSettings(gameSettings);
|
|
22
|
+
const loadDeck = useRpc(loadPuzzleDeck);
|
|
23
|
+
const size = settings.status === "ready" ? settings.values.boardSize : DEFAULT_BOARD_SIZE;
|
|
24
|
+
const difficulty = settings.status === "ready" ? settings.values.difficulty : DEFAULT_DIFFICULTY;
|
|
25
|
+
const [deck, setDeck] = useState<CuratedPuzzleDeck | null>(null);
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
const getDeck = useCallback(
|
|
30
|
+
(nextSize: number, nextDifficulty: PuzzleDifficulty) => {
|
|
31
|
+
const key = `${nextSize}-${nextDifficulty}`;
|
|
32
|
+
const existing = DECK_PROMISES.get(key);
|
|
33
|
+
if (existing) return existing;
|
|
34
|
+
if (DECK_PROMISES.size >= MAX_CACHED_DECKS) {
|
|
35
|
+
const oldestKey = DECK_PROMISES.keys().next().value;
|
|
36
|
+
if (oldestKey !== undefined) DECK_PROMISES.delete(oldestKey);
|
|
37
|
+
}
|
|
38
|
+
const request = loadDeck({ size: nextSize, difficulty: nextDifficulty })
|
|
39
|
+
.then(decodePuzzleDeck)
|
|
40
|
+
.catch((loadError: unknown) => {
|
|
41
|
+
DECK_PROMISES.delete(key);
|
|
42
|
+
throw loadError;
|
|
43
|
+
});
|
|
44
|
+
DECK_PROMISES.set(key, request);
|
|
45
|
+
return request;
|
|
46
|
+
},
|
|
47
|
+
[loadDeck],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const requestRef = useRef(0);
|
|
51
|
+
const refresh = useCallback(async () => {
|
|
52
|
+
const request = ++requestRef.current;
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
try {
|
|
56
|
+
const nextDeck = await getDeck(size, difficulty);
|
|
57
|
+
if (request !== requestRef.current) return;
|
|
58
|
+
setDeck(nextDeck);
|
|
59
|
+
setLoading(false);
|
|
60
|
+
} catch (loadError) {
|
|
61
|
+
if (request !== requestRef.current) return;
|
|
62
|
+
setError(loadError instanceof Error ? loadError.message : "Puzzle deck failed to load.");
|
|
63
|
+
setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}, [difficulty, getDeck, size]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
void refresh();
|
|
69
|
+
return () => {
|
|
70
|
+
requestRef.current += 1;
|
|
71
|
+
};
|
|
72
|
+
}, [refresh]);
|
|
73
|
+
|
|
74
|
+
const select = useCallback(
|
|
75
|
+
async (nextSize: number, nextDifficulty: PuzzleDifficulty) => {
|
|
76
|
+
if (settings.status !== "ready") return false;
|
|
77
|
+
setLoading(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
try {
|
|
80
|
+
const nextDeck = await getDeck(nextSize, nextDifficulty);
|
|
81
|
+
const firstPuzzle = nextDeck.puzzles[0];
|
|
82
|
+
if (!firstPuzzle) throw new RangeError("The curated puzzle deck is empty.");
|
|
83
|
+
const saved = await settings.save(
|
|
84
|
+
{
|
|
85
|
+
...settings.values,
|
|
86
|
+
boardSize: nextSize,
|
|
87
|
+
difficulty: nextDifficulty,
|
|
88
|
+
currentPuzzleId: firstPuzzle.id,
|
|
89
|
+
},
|
|
90
|
+
settings.revision,
|
|
91
|
+
);
|
|
92
|
+
if (!saved) throw new Error("Puzzle selection was not saved.");
|
|
93
|
+
return true;
|
|
94
|
+
} catch (selectionError) {
|
|
95
|
+
setError(
|
|
96
|
+
selectionError instanceof Error ? selectionError.message : "Puzzle selection failed.",
|
|
97
|
+
);
|
|
98
|
+
setLoading(false);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[getDeck, settings],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const retry = useCallback(() => {
|
|
106
|
+
DECK_PROMISES.delete(`${size}-${difficulty}`);
|
|
107
|
+
setDeck(null);
|
|
108
|
+
void refresh();
|
|
109
|
+
}, [difficulty, refresh, size]);
|
|
110
|
+
|
|
111
|
+
return { size, difficulty, deck, loading, error, retry, select };
|
|
112
|
+
}
|
package/index.client.tsx
ADDED
package/index.server.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { getPuzzleDeck } from "./server/puzzle-catalog";
|
|
3
|
+
import { gameSettings } from "./shared/game-settings";
|
|
4
|
+
import { loadPuzzleDeck } from "./shared/puzzle-catalog";
|
|
5
|
+
|
|
6
|
+
export default function contribute(server: PluginServerContext) {
|
|
7
|
+
server.registerSettings(gameSettings);
|
|
8
|
+
server.handle(loadPuzzleDeck, getPuzzleDeck);
|
|
9
|
+
return () => {};
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omercnet/paseo-queens",
|
|
3
|
+
"version": "0.1.0-next.126.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A cross-platform Queens logic puzzle for Paseo.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Omer Cohen",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/omercnet/paseo-plugins.git",
|
|
11
|
+
"directory": "queens"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/omercnet/paseo-plugins/tree/main/queens#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/omercnet/paseo-plugins/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"packageManager": "npm@11.19.1",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"paseo",
|
|
26
|
+
"paseo-plugin",
|
|
27
|
+
"queens",
|
|
28
|
+
"logic-puzzle",
|
|
29
|
+
"game"
|
|
30
|
+
],
|
|
31
|
+
"files": [
|
|
32
|
+
"index.client.tsx",
|
|
33
|
+
"index.server.ts",
|
|
34
|
+
"client",
|
|
35
|
+
"shared",
|
|
36
|
+
"server",
|
|
37
|
+
"scripts",
|
|
38
|
+
"paseo-plugin.json"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"check": "biome check .",
|
|
42
|
+
"import:puzzles": "node scripts/import-curated-puzzles.mjs",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"verify:package": "npm pack --dry-run"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@biomejs/biome": "^2.5.10",
|
|
49
|
+
"@getpaseo/plugin": "0.9.0-beta.1",
|
|
50
|
+
"@types/node": "^24.5.2",
|
|
51
|
+
"@types/react": "~19.2.0",
|
|
52
|
+
"react": "19.1.0",
|
|
53
|
+
"react-native": "0.81.5",
|
|
54
|
+
"typescript": "^7.0.0",
|
|
55
|
+
"vitest": "^5.0.0",
|
|
56
|
+
"zod": "^4.4.3"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const BASE_URL = "https://queensultimate.com/puzzles";
|
|
6
|
+
const SIZES = Array.from({ length: 10 }, (_, index) => index + 5);
|
|
7
|
+
const DIFFICULTIES = ["beginner", "easy", "medium", "hard"];
|
|
8
|
+
const CONCURRENCY = 12;
|
|
9
|
+
|
|
10
|
+
function chunkRanges(size, maxId) {
|
|
11
|
+
const ranges = [];
|
|
12
|
+
const addPhase = (first, last) => {
|
|
13
|
+
for (let start = first; start <= last; start += 100) {
|
|
14
|
+
ranges.push([start, Math.min(start + 99, last)]);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
if (size === 13) {
|
|
18
|
+
addPhase(1, 2699);
|
|
19
|
+
addPhase(2700, maxId);
|
|
20
|
+
} else if (size === 14) {
|
|
21
|
+
addPhase(1, 3437);
|
|
22
|
+
addPhase(3438, maxId);
|
|
23
|
+
} else {
|
|
24
|
+
addPhase(1, maxId);
|
|
25
|
+
}
|
|
26
|
+
return ranges;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchText(url) {
|
|
30
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
31
|
+
const response = await fetch(url);
|
|
32
|
+
if (response.ok) return await response.text();
|
|
33
|
+
if (attempt === 2) throw new Error(`${url} returned ${response.status}`);
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`${url} failed`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parsePuzzle(line, expectedSize) {
|
|
40
|
+
const parts = line.split(":");
|
|
41
|
+
if (parts.length !== 7) throw new Error(`Malformed puzzle line: ${line.slice(0, 80)}`);
|
|
42
|
+
const id = Number(parts[0]);
|
|
43
|
+
const size = Number(parts[1]);
|
|
44
|
+
const difficulty = parts[3].split("@")[0].toLowerCase();
|
|
45
|
+
if (size !== expectedSize) throw new Error(`Size mismatch for ${id}`);
|
|
46
|
+
|
|
47
|
+
const regions = new Uint8Array(size * size);
|
|
48
|
+
regions.fill(255);
|
|
49
|
+
const encodedRegions = parts[4].split("|");
|
|
50
|
+
if (Number(encodedRegions[0]) !== size || encodedRegions.length !== size + 1) {
|
|
51
|
+
throw new Error(`Region header mismatch ${size}-${id}`);
|
|
52
|
+
}
|
|
53
|
+
for (const encodedRegion of encodedRegions.slice(1)) {
|
|
54
|
+
const match = /^(\d+)#(\d+)@(.+)$/.exec(encodedRegion);
|
|
55
|
+
if (!match) throw new Error(`Malformed region ${size}-${id}`);
|
|
56
|
+
const region = Number(match[1]);
|
|
57
|
+
const indexes = match[3].split(",").map(Number);
|
|
58
|
+
if (indexes.length !== Number(match[2])) throw new Error(`Region count mismatch ${size}-${id}`);
|
|
59
|
+
for (const index of indexes) {
|
|
60
|
+
if (index < 0 || index >= regions.length || regions[index] !== 255) {
|
|
61
|
+
throw new Error(`Region coverage error ${size}-${id}`);
|
|
62
|
+
}
|
|
63
|
+
regions[index] = region;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (regions.some((region) => region === 255 || region >= size)) {
|
|
67
|
+
throw new Error(`Incomplete regions ${size}-${id}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const solutionMask = parts[6];
|
|
71
|
+
if (solutionMask.length !== size * size)
|
|
72
|
+
throw new Error(`Solution length mismatch ${size}-${id}`);
|
|
73
|
+
const solutionColumns = new Uint8Array(size);
|
|
74
|
+
const usedColumns = new Set();
|
|
75
|
+
const usedRegions = new Set();
|
|
76
|
+
let previousColumn = -2;
|
|
77
|
+
for (let row = 0; row < size; row += 1) {
|
|
78
|
+
const columns = [];
|
|
79
|
+
for (let column = 0; column < size; column += 1) {
|
|
80
|
+
if (solutionMask[row * size + column] !== "0") columns.push(column);
|
|
81
|
+
}
|
|
82
|
+
if (columns.length !== 1) throw new Error(`Solution row mismatch ${size}-${id}`);
|
|
83
|
+
const column = columns[0];
|
|
84
|
+
const region = regions[row * size + column];
|
|
85
|
+
if (
|
|
86
|
+
usedColumns.has(column) ||
|
|
87
|
+
usedRegions.has(region) ||
|
|
88
|
+
Math.abs(column - previousColumn) <= 1
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(`Invalid solution ${size}-${id}`);
|
|
91
|
+
}
|
|
92
|
+
solutionColumns[row] = column;
|
|
93
|
+
usedColumns.add(column);
|
|
94
|
+
usedRegions.add(region);
|
|
95
|
+
previousColumn = column;
|
|
96
|
+
}
|
|
97
|
+
return { id, size, difficulty, regions, solutionColumns };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const indexes = new Map();
|
|
101
|
+
for (const size of SIZES) {
|
|
102
|
+
for (const difficulty of DIFFICULTIES) {
|
|
103
|
+
const text = await fetchText(`${BASE_URL}/random-index/${size}x${size}-${difficulty}.json`);
|
|
104
|
+
indexes.set(`${size}-${difficulty}`, JSON.parse(text));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const jobs = [];
|
|
109
|
+
for (const size of SIZES) {
|
|
110
|
+
const ids = DIFFICULTIES.flatMap((difficulty) => indexes.get(`${size}-${difficulty}`).puzzles);
|
|
111
|
+
const maxId = Math.max(...ids);
|
|
112
|
+
for (const [start, end] of chunkRanges(size, maxId)) {
|
|
113
|
+
jobs.push({ size, url: `${BASE_URL}/puzzles-${size}x${size}-Q1-${start}-${end}.txt` });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const puzzlesBySize = new Map();
|
|
118
|
+
let cursor = 0;
|
|
119
|
+
async function worker() {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const job = jobs[cursor++];
|
|
122
|
+
if (!job) return;
|
|
123
|
+
const text = await fetchText(job.url);
|
|
124
|
+
const puzzles = puzzlesBySize.get(job.size) ?? new Map();
|
|
125
|
+
for (const line of text.trim().split(/\r?\n/)) {
|
|
126
|
+
const puzzle = parsePuzzle(line, job.size);
|
|
127
|
+
if (puzzles.has(puzzle.id)) throw new Error(`Duplicate puzzle ${job.size}-${puzzle.id}`);
|
|
128
|
+
puzzles.set(puzzle.id, puzzle);
|
|
129
|
+
}
|
|
130
|
+
puzzlesBySize.set(job.size, puzzles);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
|
|
134
|
+
|
|
135
|
+
const groups = {};
|
|
136
|
+
let total = 0;
|
|
137
|
+
for (const size of SIZES) {
|
|
138
|
+
const puzzles = puzzlesBySize.get(size);
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const difficulty of DIFFICULTIES) {
|
|
141
|
+
const ids = indexes.get(`${size}-${difficulty}`).puzzles;
|
|
142
|
+
const regionBytes = Math.ceil((size * size) / 2);
|
|
143
|
+
const recordBytes = 2 + regionBytes + size;
|
|
144
|
+
const bytes = new Uint8Array(ids.length * recordBytes);
|
|
145
|
+
ids.forEach((id, recordIndex) => {
|
|
146
|
+
if (seen.has(id)) throw new Error(`Difficulty overlap ${size}-${id}`);
|
|
147
|
+
seen.add(id);
|
|
148
|
+
const puzzle = puzzles.get(id);
|
|
149
|
+
if (!puzzle || puzzle.difficulty !== difficulty)
|
|
150
|
+
throw new Error(`Missing curated puzzle ${size}-${id}`);
|
|
151
|
+
let offset = recordIndex * recordBytes;
|
|
152
|
+
bytes[offset++] = id >> 8;
|
|
153
|
+
bytes[offset++] = id & 255;
|
|
154
|
+
for (let cell = 0; cell < size * size; cell += 2) {
|
|
155
|
+
bytes[offset++] = (puzzle.regions[cell] << 4) | (puzzle.regions[cell + 1] ?? 0);
|
|
156
|
+
}
|
|
157
|
+
bytes.set(puzzle.solutionColumns, offset);
|
|
158
|
+
});
|
|
159
|
+
groups[`${size}-${difficulty}`] = {
|
|
160
|
+
size,
|
|
161
|
+
difficulty,
|
|
162
|
+
count: ids.length,
|
|
163
|
+
recordBytes,
|
|
164
|
+
data: Buffer.from(bytes).toString("base64"),
|
|
165
|
+
};
|
|
166
|
+
total += ids.length;
|
|
167
|
+
}
|
|
168
|
+
if (seen.size !== puzzles.size) throw new Error(`Unindexed puzzles for ${size}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const outputDirectory = path.resolve(process.argv[2] ?? "/tmp/queens-curated-gist");
|
|
172
|
+
await rm(outputDirectory, { recursive: true, force: true });
|
|
173
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
174
|
+
const manifest = {};
|
|
175
|
+
for (const [key, group] of Object.entries(groups)) {
|
|
176
|
+
const fileName = `${key}.b64`;
|
|
177
|
+
await writeFile(path.join(outputDirectory, fileName), `${group.data}\n`);
|
|
178
|
+
manifest[key] = {
|
|
179
|
+
size: group.size,
|
|
180
|
+
difficulty: group.difficulty,
|
|
181
|
+
count: group.count,
|
|
182
|
+
recordBytes: group.recordBytes,
|
|
183
|
+
sha256: createHash("sha256").update(group.data).digest("hex"),
|
|
184
|
+
fileName,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
await writeFile(
|
|
188
|
+
path.join(outputDirectory, "manifest.json"),
|
|
189
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const gistId = process.env.QUEENS_GIST_ID;
|
|
193
|
+
const gistRevision = process.env.QUEENS_GIST_REVISION;
|
|
194
|
+
if ((gistId && !gistRevision) || (!gistId && gistRevision)) {
|
|
195
|
+
throw new Error("QUEENS_GIST_ID and QUEENS_GIST_REVISION must be supplied together.");
|
|
196
|
+
}
|
|
197
|
+
if (gistId && gistRevision) {
|
|
198
|
+
const gistOwner = process.env.QUEENS_GIST_OWNER ?? "omercnet";
|
|
199
|
+
const lines = [
|
|
200
|
+
'import type { PuzzleDifficulty } from "../shared/puzzle-catalog";',
|
|
201
|
+
"",
|
|
202
|
+
"/** Pinned, integrity-checked files in an unlisted GitHub Gist. */",
|
|
203
|
+
"export type RemotePuzzleGroup = {",
|
|
204
|
+
" readonly size: number;",
|
|
205
|
+
" readonly difficulty: PuzzleDifficulty;",
|
|
206
|
+
" readonly count: number;",
|
|
207
|
+
" readonly recordBytes: number;",
|
|
208
|
+
" readonly sha256: string;",
|
|
209
|
+
" readonly url: string;",
|
|
210
|
+
"};",
|
|
211
|
+
"",
|
|
212
|
+
"export const REMOTE_PUZZLE_GROUPS: Readonly<Record<string, RemotePuzzleGroup>> = {",
|
|
213
|
+
];
|
|
214
|
+
for (const [key, entry] of Object.entries(manifest)) {
|
|
215
|
+
lines.push(` ${JSON.stringify(key)}: {`);
|
|
216
|
+
lines.push(` size: ${entry.size},`);
|
|
217
|
+
lines.push(` difficulty: ${JSON.stringify(entry.difficulty)},`);
|
|
218
|
+
lines.push(` count: ${entry.count},`);
|
|
219
|
+
lines.push(` recordBytes: ${entry.recordBytes},`);
|
|
220
|
+
lines.push(` sha256: ${JSON.stringify(entry.sha256)},`);
|
|
221
|
+
lines.push(
|
|
222
|
+
` url: ${JSON.stringify(`https://gist.githubusercontent.com/${gistOwner}/${gistId}/raw/${gistRevision}/${entry.fileName}`)},`,
|
|
223
|
+
);
|
|
224
|
+
lines.push(" },");
|
|
225
|
+
}
|
|
226
|
+
lines.push("};", "");
|
|
227
|
+
await writeFile(path.join(process.cwd(), "server", "curated-manifest.ts"), lines.join("\n"));
|
|
228
|
+
}
|
|
229
|
+
console.log(
|
|
230
|
+
`Stored ${total} curated puzzles from ${jobs.length} source chunks in ${outputDirectory}.`,
|
|
231
|
+
);
|