@bongos/core 1.19.335 → 1.19.337

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.
@@ -1,292 +0,0 @@
1
- // scripts/snapshots/backfill-history.mjs
2
- //
3
- // One-shot historical backfill: walk every commit that touched graphics-
4
- // affecting files and render a snapshot from each commit's tree.
5
- //
6
- // Strategy:
7
- // 1. Ask git for every commit touching `public/assets/tiles/`, `public/game/world/`,
8
- // or `src/world/{map,terrain}.js` (in chronological order, oldest first).
9
- // 2. For each commit, `git archive` the working tree into a temp dir.
10
- // The renderer + its node_modules come from the CURRENT worktree (the
11
- // renderer didn't exist in old commits) — only the world DATA comes
12
- // from the historical commit.
13
- // 3. Run the renderer with --sha + --timestamp + --label so each snapshot
14
- // is stamped correctly.
15
- //
16
- // Pre-Phase-D commits (before INITIAL_MAP existed in its two-grid form) are
17
- // skipped — they would crash the renderer. We start the timeline at Phase D
18
- // and note the gap in CURATED_LABELS below.
19
- //
20
- // Usage:
21
- // node scripts/snapshots/backfill-history.mjs # render all known visual milestones
22
- // node scripts/snapshots/backfill-history.mjs --dry-run # just list what would be rendered
23
-
24
- import { execSync, spawnSync } from 'node:child_process';
25
- import { createHash } from 'node:crypto';
26
- import { mkdtempSync, rmSync, mkdirSync, existsSync, cpSync, symlinkSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
27
- import { tmpdir } from 'node:os';
28
- import { resolve, dirname } from 'node:path';
29
- import { fileURLToPath } from 'node:url';
30
-
31
- const HERE = dirname(fileURLToPath(import.meta.url));
32
- const ROOT = resolve(HERE, '../..');
33
- // The status surface moved to modules/status-ui/public/ in BV1.R84 (task 1635).
34
- const SNAPSHOTS_DIR = resolve(ROOT, 'modules/status-ui/public/snapshots');
35
-
36
- function sh(cmd, opts = {}) {
37
- return execSync(cmd, { cwd: ROOT, encoding: 'utf8', ...opts }).trim();
38
- }
39
-
40
- // Curated labels for known visual milestones. Anything not listed gets the
41
- // commit subject as its label.
42
- const CURATED_LABELS = {
43
- '5354daa': 'Phase D — the first world',
44
- '6abe01c': 'The coast is surveyed — sand meets sea',
45
- '865d64c': 'A door appears, and a path laid to it',
46
- '7ac06df': 'A wanderer arrives — first sight of Hyperion at his door',
47
- '13b796d': 'The grass turns green',
48
- '358f2cd': 'Rocks scattered through the meadow; the container takes its full form',
49
- '86cee70': 'Container wall refined (7.78 → 8.9)',
50
- '0e630c8': 'Multi-variant interior tiles',
51
- '6fc7c09': 'Hyperion stands taller — vendor poses refined',
52
- '1c6654a': 'Container wall regenerated (atlas drift fix)',
53
- 'd438644': 'The container door painted solid',
54
- };
55
-
56
- // Pre-Phase-D commits to NOT attempt — these crash the renderer (no two-grid map yet).
57
- const SKIP_BEFORE = '5354daa'; // Phase D — first renderable commit.
58
-
59
- function isAncestor(maybeAncestor, descendant) {
60
- try {
61
- execSync(`git merge-base --is-ancestor ${maybeAncestor} ${descendant}`, { cwd: ROOT, stdio: 'ignore' });
62
- return true;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
- function listGraphicsCommits() {
69
- // %H short SHA + ISO date + subject. Reverse so oldest first.
70
- const raw = sh(
71
- `git log --reverse --format='%H%x09%aI%x09%s' --no-merges -- ` +
72
- `'public/assets/tiles/' 'public/game/world/' 'src/world/map.js' 'src/world/terrain.js'`
73
- );
74
- return raw.split('\n').filter(Boolean).map((line) => {
75
- const [sha, timestamp, subject] = line.split('\t');
76
- return { sha, timestamp, subject };
77
- });
78
- }
79
-
80
- function commitChangedGraphics(sha) {
81
- // Did THIS commit change any tile PNGs or world code?
82
- // For some commits the path filter above matches a config-only change
83
- // (e.g. manifest tweak) that doesn't actually re-render differently. We
84
- // still render — let's accept a few duplicates rather than over-filter.
85
- try {
86
- const out = sh(`git diff-tree --no-commit-id --name-only -r ${sha} -- public/assets/tiles/ public/game/world/ src/world/map.js src/world/terrain.js`);
87
- return out.length > 0;
88
- } catch {
89
- return false;
90
- }
91
- }
92
-
93
- function setupRenderTree(sha, tmpRoot) {
94
- // 1. Extract historical world data from the commit.
95
- mkdirSync(tmpRoot, { recursive: true });
96
- // git archive paths: tile assets + world code on both sides + the manifest.
97
- // Use a wide net so we cover any restructuring across the history.
98
- const archivePaths = [
99
- 'public/assets/tiles',
100
- 'public/game/world',
101
- 'src/world',
102
- ].filter((p) => {
103
- // Skip non-existent paths at this commit (some commits predate src/world).
104
- try {
105
- execSync(`git ls-tree ${sha} -- ${p}`, { cwd: ROOT, stdio: 'ignore' });
106
- return true;
107
- } catch {
108
- return false;
109
- }
110
- });
111
- if (archivePaths.length === 0) return false;
112
-
113
- sh(`git archive ${sha} ${archivePaths.map((p) => `'${p}'`).join(' ')} | tar -x -C '${tmpRoot}'`);
114
-
115
- // 2. Copy renderer + helpers from CURRENT worktree.
116
- const rendererSrc = resolve(ROOT, 'scripts/snapshots/render-world.mjs');
117
- const rendererDst = resolve(tmpRoot, 'scripts/snapshots/render-world.mjs');
118
- mkdirSync(dirname(rendererDst), { recursive: true });
119
- cpSync(rendererSrc, rendererDst);
120
-
121
- // 3. Symlink node_modules from current worktree (sharp is required).
122
- const nmSrc = resolve(ROOT, 'node_modules');
123
- const nmDst = resolve(tmpRoot, 'node_modules');
124
- if (!existsSync(nmDst)) symlinkSync(nmSrc, nmDst);
125
-
126
- // 4. Some historical commits may have a different map.js shape (pre-ADR-0012)
127
- // that crashes the renderer. We don't try to compensate — those commits
128
- // are filtered upstream by SKIP_BEFORE.
129
- return true;
130
- }
131
-
132
- function shortLabelFor(entry) {
133
- const short = entry.sha.slice(0, 7);
134
- return CURATED_LABELS[short] || entry.subject;
135
- }
136
-
137
- async function main() {
138
- const dryRun = process.argv.includes('--dry-run');
139
-
140
- // Resolve the canonical short SHA of SKIP_BEFORE for ancestor checks.
141
- const skipBeforeFull = sh(`git rev-parse ${SKIP_BEFORE}`);
142
-
143
- const commits = listGraphicsCommits();
144
- console.log(`[backfill] found ${commits.length} commits touching graphics paths`);
145
-
146
- const candidates = [];
147
- for (const c of commits) {
148
- if (!isAncestor(c.sha, 'HEAD')) {
149
- // Not in current main lineage (e.g. a branch that was never merged).
150
- continue;
151
- }
152
- if (c.sha !== skipBeforeFull && !isAncestor(skipBeforeFull, c.sha)) {
153
- // Earlier than Phase D — renderer won't work.
154
- continue;
155
- }
156
- if (!commitChangedGraphics(c.sha)) continue;
157
- candidates.push(c);
158
- }
159
-
160
- // Always include current HEAD as the final snapshot if it isn't in the list.
161
- const head = sh('git rev-parse HEAD');
162
- const headTs = sh('git log -1 --format=%aI HEAD');
163
- const headSubj = sh('git log -1 --format=%s HEAD');
164
- if (!candidates.find((c) => c.sha === head)) {
165
- candidates.push({ sha: head, timestamp: headTs, subject: headSubj });
166
- }
167
-
168
- console.log(`[backfill] will render ${candidates.length} historical snapshots:`);
169
- for (const c of candidates) {
170
- const lbl = shortLabelFor(c);
171
- console.log(` ${c.sha.slice(0, 7)} ${c.timestamp.slice(0, 16)} ${lbl}`);
172
- }
173
- if (dryRun) {
174
- console.log('[backfill] --dry-run: not rendering');
175
- return;
176
- }
177
-
178
- mkdirSync(SNAPSHOTS_DIR, { recursive: true });
179
-
180
- let rendered = 0;
181
- let failed = 0;
182
- for (const c of candidates) {
183
- const short = c.sha.slice(0, 7);
184
- const label = shortLabelFor(c);
185
-
186
- // Skip work if the snapshot already exists in the manifest with the same sha.
187
- // (Re-runs are idempotent.)
188
- const manifestPath = resolve(SNAPSHOTS_DIR, 'manifest.json');
189
- if (existsSync(manifestPath)) {
190
- try {
191
- const m = JSON.parse(readFileSync(manifestPath, 'utf8'));
192
- if (Array.isArray(m.snapshots) && m.snapshots.find((s) => s.sha === c.sha)) {
193
- console.log(`[backfill] skip ${short} (already in manifest)`);
194
- continue;
195
- }
196
- } catch { /* ignore */ }
197
- }
198
-
199
- const tmp = mkdtempSync(resolve(tmpdir(), `otb-snap-${short}-`));
200
- console.log(`\n[backfill] rendering ${short} → ${tmp}`);
201
- try {
202
- const ok = setupRenderTree(c.sha, tmp);
203
- if (!ok) {
204
- console.log(`[backfill] skip ${short} — no archivable paths`);
205
- continue;
206
- }
207
- const result = spawnSync('node', [
208
- resolve(tmp, 'scripts/snapshots/render-world.mjs'),
209
- '--sha', c.sha,
210
- '--timestamp', c.timestamp,
211
- '--label', label,
212
- '--id', short,
213
- '--no-manifest',
214
- '--out', resolve(SNAPSHOTS_DIR, `${short}.png`),
215
- ], { cwd: tmp, stdio: 'inherit' });
216
- if (result.status !== 0) {
217
- throw new Error(`renderer exit ${result.status}`);
218
- }
219
-
220
- // Update the canonical manifest in the main worktree directly.
221
- let m = { version: 2, snapshots: [] };
222
- if (existsSync(manifestPath)) {
223
- try { m = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { /* fall back */ }
224
- }
225
- if (!Array.isArray(m.snapshots)) m.snapshots = [];
226
- const entry = {
227
- id: short,
228
- sha: c.sha,
229
- timestamp: c.timestamp,
230
- label,
231
- image: `${short}.png`,
232
- };
233
- const i = m.snapshots.findIndex((s) => s.id === entry.id);
234
- if (i >= 0) m.snapshots[i] = entry;
235
- else m.snapshots.push(entry);
236
- m.snapshots.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
237
- m.version = 2;
238
- writeFileSync(manifestPath, JSON.stringify(m, null, 2) + '\n');
239
- console.log(`[backfill] manifest: ${m.snapshots.length} entries`);
240
-
241
- rendered++;
242
- } catch (e) {
243
- console.error(`[backfill] FAIL ${short}: ${e.message}`);
244
- failed++;
245
- } finally {
246
- rmSync(tmp, { recursive: true, force: true });
247
- }
248
- }
249
-
250
- console.log(`\n[backfill] done — ${rendered} rendered, ${failed} failed`);
251
-
252
- // ---- Dedup pass ----
253
- // Many commits change graphics code or atlas plumbing without changing the
254
- // rendered output. Group snapshots by PNG hash; keep only the earliest
255
- // commit (by timestamp) in each group. The later identical entries get
256
- // their PNG file removed and their manifest entry dropped.
257
- const manifestPath = resolve(SNAPSHOTS_DIR, 'manifest.json');
258
- if (!existsSync(manifestPath)) return;
259
- const m = JSON.parse(readFileSync(manifestPath, 'utf8'));
260
- if (!Array.isArray(m.snapshots)) return;
261
-
262
- const byHash = new Map();
263
- for (const snap of m.snapshots) {
264
- const imgPath = resolve(SNAPSHOTS_DIR, snap.image);
265
- if (!existsSync(imgPath)) continue;
266
- const hash = createHash('md5').update(readFileSync(imgPath)).digest('hex');
267
- const existing = byHash.get(hash);
268
- if (!existing || (snap.timestamp || '') < (existing.timestamp || '')) {
269
- byHash.set(hash, snap);
270
- }
271
- }
272
- const keepIds = new Set(Array.from(byHash.values()).map((s) => s.id));
273
- const dropped = [];
274
- for (const snap of m.snapshots) {
275
- if (!keepIds.has(snap.id)) {
276
- dropped.push(snap.id);
277
- const imgPath = resolve(SNAPSHOTS_DIR, snap.image);
278
- if (existsSync(imgPath)) {
279
- try { unlinkSync(imgPath); } catch { /* ignore */ }
280
- }
281
- }
282
- }
283
- m.snapshots = m.snapshots.filter((s) => keepIds.has(s.id));
284
- m.snapshots.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
285
- writeFileSync(manifestPath, JSON.stringify(m, null, 2) + '\n');
286
- if (dropped.length > 0) {
287
- console.log(`[backfill] dedup: removed ${dropped.length} duplicates: ${dropped.join(', ')}`);
288
- }
289
- console.log(`[backfill] manifest now has ${m.snapshots.length} unique snapshots`);
290
- }
291
-
292
- await main();
@@ -1,66 +0,0 @@
1
- #!/usr/bin/env bash
2
- # scripts/snapshots/maybe-render-on-deploy.sh
3
- #
4
- # Called from ~/deploy.sh on the droplet. Decides whether the just-pulled
5
- # commit changed the world's visible graphics; if so, renders a new
6
- # snapshot tagged with the commit sha + author timestamp + subject.
7
- #
8
- # State: the previous deployed sha is stored at ~/.amazonprimea-last-deploy.
9
- # If it's missing (first run), we still render a snapshot of HEAD so the
10
- # timeline isn't empty.
11
- #
12
- # Add to ~/deploy.sh right after `sudo systemctl restart amazonprimea`:
13
- #
14
- # bash scripts/snapshots/maybe-render-on-deploy.sh "$PREV_SHA"
15
- #
16
- # where $PREV_SHA is captured BEFORE the git fetch + reset --hard. A simple
17
- # pattern that works inside deploy.sh:
18
- #
19
- # PREV_SHA="$(git rev-parse HEAD)"
20
- # git fetch origin && git reset --hard origin/main
21
- # ...
22
- # bash scripts/snapshots/maybe-render-on-deploy.sh "$PREV_SHA"
23
-
24
- set -euo pipefail
25
-
26
- GRAPHICS_PATHS=(
27
- 'public/assets/tiles'
28
- 'public/game/world'
29
- 'src/world/map.js'
30
- 'src/world/terrain.js'
31
- )
32
-
33
- REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
34
- cd "$REPO_ROOT"
35
-
36
- PREV="${1:-}"
37
- NEW="$(git rev-parse HEAD)"
38
- SHORT="$(git rev-parse --short=7 HEAD)"
39
- SUBJECT="$(git log -1 --format=%s HEAD)"
40
- TIMESTAMP="$(git log -1 --format=%aI HEAD)"
41
-
42
- if [ -z "$PREV" ] || [ "$PREV" = "$NEW" ]; then
43
- # First run, or no commits since last deploy.
44
- if [ -z "$PREV" ]; then
45
- echo "[snapshot] first run — rendering HEAD snapshot"
46
- else
47
- echo "[snapshot] no commits since $PREV — skipping"
48
- exit 0
49
- fi
50
- else
51
- if git diff --name-only "$PREV..$NEW" -- "${GRAPHICS_PATHS[@]}" | read -r _; then
52
- echo "[snapshot] graphics changed between $PREV..$SHORT — rendering"
53
- else
54
- echo "[snapshot] no graphics changes between $PREV..$SHORT — skipping"
55
- exit 0
56
- fi
57
- fi
58
-
59
- node scripts/snapshots/render-world.mjs \
60
- --sha "$NEW" \
61
- --timestamp "$TIMESTAMP" \
62
- --label "$SUBJECT" \
63
- --id "$SHORT" \
64
- --out "modules/status-ui/public/snapshots/$SHORT.png"
65
-
66
- echo "[snapshot] done — $SHORT"
@@ -1,329 +0,0 @@
1
- // scripts/snapshots/render-pre-art-eras.mjs
2
- //
3
- // Captures the two snapshot-worthy moments BEFORE the pixel-art pipeline
4
- // existed, which the main renderer cannot reach:
5
- //
6
- // 1. "Before the container washed ashore" — the hello-world page that
7
- // lived at amazonprimea.com from 2026-05-08 19:16 (commit faa8c77)
8
- // until Phase 1 replaced it ~44 minutes later. Black + gold,
9
- // monospace, three lines of text.
10
- //
11
- // 2. "The first playable world" — the Phaser scaffold from Phase 7
12
- // (commit 0b13bb4, 2026-05-08 20:21). No real tile art yet —
13
- // procedural-canvas tiles painted in tilePalette.js at boot. We
14
- // replicate those colors here so the snapshot matches what a player
15
- // would have seen.
16
- //
17
- // Output: writes 2 PNGs into modules/status-ui/public/snapshots/ and inserts both
18
- // into manifest.json (alongside whatever's already there). Idempotent.
19
- // (The status surface moved to modules/status-ui/public/ in BV1.R84 / task 1635.)
20
-
21
- import sharp from 'sharp';
22
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
23
- import { resolve, dirname } from 'node:path';
24
- import { fileURLToPath } from 'node:url';
25
-
26
- const HERE = dirname(fileURLToPath(import.meta.url));
27
- const ROOT = resolve(HERE, '../..');
28
- const SNAPSHOTS_DIR = resolve(ROOT, 'modules/status-ui/public/snapshots');
29
- const MANIFEST_PATH = resolve(SNAPSHOTS_DIR, 'manifest.json');
30
-
31
- const TILE_SIZE = 32;
32
- const WORLD_COLS = 30;
33
- const WORLD_ROWS = 18;
34
- const WIDTH = WORLD_COLS * TILE_SIZE; // 960
35
- const HEIGHT = WORLD_ROWS * TILE_SIZE; // 576
36
-
37
- // ---------- Phase 7 procedural-canvas tile generators -----------------------
38
- //
39
- // Mirrors the original public/game/world/tilePalette.js from commit 0b13bb4.
40
- // Each generator returns a 32×32 PNG Buffer. We hand-build them via sharp
41
- // composites of small colored rectangles, matching the original colors.
42
-
43
- function rect(x, y, w, h, color) {
44
- return {
45
- input: {
46
- create: {
47
- width: w,
48
- height: h,
49
- channels: 4,
50
- background: color,
51
- },
52
- },
53
- left: x,
54
- top: y,
55
- };
56
- }
57
-
58
- function speckle(color, count) {
59
- // Original used (i*7)%32, (i*13)%32 — 1×1 pixels at deterministic spots.
60
- const pieces = [];
61
- for (let i = 0; i < count; i++) {
62
- pieces.push(rect((i * 7) % TILE_SIZE, (i * 13) % TILE_SIZE, 1, 1, color));
63
- }
64
- return pieces;
65
- }
66
-
67
- async function makeTile(layers) {
68
- return sharp({
69
- create: { width: TILE_SIZE, height: TILE_SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
70
- })
71
- .composite(layers)
72
- .png()
73
- .toBuffer();
74
- }
75
-
76
- async function buildPhase7Tiles() {
77
- return {
78
- sand: await makeTile([
79
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#e6c97a'),
80
- ...speckle('#c8a85a', 18),
81
- ...speckle('#f3dfa1', 10),
82
- ]),
83
- wet_sand: await makeTile([
84
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#a88947'),
85
- ...speckle('#7a6230', 22),
86
- ...speckle('#c5a25a', 8),
87
- ]),
88
- sea: await makeTile([
89
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#2b6da3'),
90
- rect(0, 8, TILE_SIZE, 4, '#3884bd'),
91
- rect(0, 20, TILE_SIZE, 2, '#4ea0d3'),
92
- ...speckle('#cce8f7', 6),
93
- ]),
94
- grass: await makeTile([
95
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#5b7a3a'),
96
- ...speckle('#3f5828', 16),
97
- ...speckle('#7f9c50', 8),
98
- ]),
99
- rock: await makeTile([
100
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#5b7a3a'),
101
- rect(6, 6, 22, 22, '#8c8c8c'),
102
- rect(6, 22, 22, 6, '#6e6e6e'),
103
- rect(8, 8, 8, 4, '#a8a8a8'),
104
- ]),
105
- container: await makeTile([
106
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#8a3a1f'),
107
- // Original drew vertical ridges every 4 pixels via two thin rects.
108
- ...[0, 4, 8, 12, 16, 20, 24, 28].flatMap((x) => [
109
- rect(x, 0, 1, TILE_SIZE, '#5e2511'),
110
- rect(x + 1, 0, 1, TILE_SIZE, '#a8482a'),
111
- ]),
112
- rect(0, 0, TILE_SIZE, 2, '#3e1a0c'),
113
- rect(0, TILE_SIZE - 2, TILE_SIZE, 2, '#3e1a0c'),
114
- ]),
115
- container_door: await makeTile([
116
- rect(0, 0, TILE_SIZE, TILE_SIZE, '#8a3a1f'),
117
- rect(6, 4, 20, 26, '#1a0e06'),
118
- rect(8, 6, 16, 22, '#3a2010'),
119
- rect(20, 16, 2, 4, '#d9b34a'), // door handle
120
- ]),
121
- };
122
- }
123
-
124
- // Phase 7 map layout (mirrors public/game/world/map.js at commit 0b13bb4).
125
- function phase7TileAt(x, y) {
126
- if (y >= WORLD_ROWS - 4) return 'sea';
127
- if (y === WORLD_ROWS - 5) return 'wet_sand';
128
- if (y >= 6) return 'sand';
129
- // Top band: scattered grass + occasional rocks (deterministic).
130
- if ((x * 37 + y * 53) % 11 === 0) return 'rock';
131
- return 'grass';
132
- }
133
-
134
- // Procedural player sprite (down-facing) — mirrors playerSprite.js paintDown
135
- // at commit 0b13bb4. Returned as a 32×32 PNG buffer.
136
- async function buildPhase7Player() {
137
- const svg = Buffer.from(`<?xml version="1.0"?>
138
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
139
- <rect x="6" y="28" width="20" height="3" fill="#0e0c08" fill-opacity="0.4"/>
140
- <rect x="11" y="22" width="4" height="7" fill="#4a3618"/>
141
- <rect x="17" y="22" width="4" height="7" fill="#4a3618"/>
142
- <rect x="9" y="14" width="14" height="9" fill="#7a5a2a"/>
143
- <rect x="9" y="22" width="14" height="1" fill="#c8a85a"/>
144
- <rect x="11" y="6" width="10" height="8" fill="#e8c39a"/>
145
- <rect x="12" y="13" width="8" height="1" fill="#b08858"/>
146
- <rect x="10" y="5" width="12" height="4" fill="#3a2918"/>
147
- <rect x="10" y="8" width="2" height="2" fill="#3a2918"/>
148
- <rect x="20" y="8" width="2" height="2" fill="#3a2918"/>
149
- <rect x="13" y="10" width="2" height="2" fill="#1a1208"/>
150
- <rect x="17" y="10" width="2" height="2" fill="#1a1208"/>
151
- <rect x="7" y="16" width="2" height="5" fill="#e8c39a"/>
152
- <rect x="23" y="16" width="2" height="5" fill="#e8c39a"/>
153
- </svg>`);
154
- return sharp(svg).png().toBuffer();
155
- }
156
-
157
- // Procedural vendor sprite — mirrors buildVendorSprite at commit 0b13bb4.
158
- async function buildPhase7Vendor() {
159
- const svg = Buffer.from(`<?xml version="1.0"?>
160
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
161
- <rect x="8" y="4" width="16" height="26" fill="#3a2914" fill-opacity="0.67"/>
162
- <rect x="7" y="3" width="18" height="28" fill="#8a6a3a" fill-opacity="0.33"/>
163
- <rect x="7" y="28" width="18" height="3" fill="#0e0c08" fill-opacity="0.53"/>
164
- <rect x="9" y="14" width="14" height="14" fill="#3a2814"/>
165
- <rect x="11" y="16" width="10" height="10" fill="#1f1407"/>
166
- <rect x="9" y="14" width="14" height="1" fill="#c8a85a"/>
167
- <rect x="9" y="27" width="14" height="1" fill="#c8a85a"/>
168
- <rect x="14" y="22" width="4" height="3" fill="#d8b890"/>
169
- <rect x="11" y="12" width="10" height="5" fill="#d6cfb6"/>
170
- <rect x="12" y="16" width="8" height="2" fill="#a8a08a"/>
171
- <rect x="12" y="7" width="8" height="6" fill="#e8c39a"/>
172
- <rect x="13" y="9" width="2" height="2" fill="#0e0c08"/>
173
- <rect x="17" y="9" width="2" height="2" fill="#0e0c08"/>
174
- <rect x="14" y="9" width="1" height="1" fill="#f5e8b8"/>
175
- <rect x="18" y="9" width="1" height="1" fill="#f5e8b8"/>
176
- </svg>`);
177
- return sharp(svg).png().toBuffer();
178
- }
179
-
180
- async function renderPhase7Placeholder(outPath) {
181
- const tiles = await buildPhase7Tiles();
182
- const playerPng = await buildPhase7Player();
183
- const vendorPng = await buildPhase7Vendor();
184
-
185
- const composites = [];
186
- for (let y = 0; y < WORLD_ROWS; y++) {
187
- for (let x = 0; x < WORLD_COLS; x++) {
188
- const key = phase7TileAt(x, y);
189
- composites.push({
190
- input: tiles[key],
191
- left: x * TILE_SIZE,
192
- top: y * TILE_SIZE,
193
- });
194
- }
195
- }
196
-
197
- // Container overlay: 4 wide × 2 tall at (13, 8). Door at (13, 9).
198
- for (let dy = 0; dy < 2; dy++) {
199
- for (let dx = 0; dx < 4; dx++) {
200
- composites.push({
201
- input: tiles.container,
202
- left: (13 + dx) * TILE_SIZE,
203
- top: (8 + dy) * TILE_SIZE,
204
- });
205
- }
206
- }
207
- composites.push({
208
- input: tiles.container_door,
209
- left: 13 * TILE_SIZE,
210
- top: 9 * TILE_SIZE,
211
- });
212
-
213
- // Vendor at his canonical (13, 9) door cell; player at spawn (10, 10).
214
- composites.push({ input: vendorPng, left: 13 * TILE_SIZE, top: 9 * TILE_SIZE });
215
- composites.push({ input: playerPng, left: 10 * TILE_SIZE, top: 10 * TILE_SIZE });
216
-
217
- await sharp({
218
- create: { width: WIDTH, height: HEIGHT, channels: 3, background: { r: 0, g: 0, b: 0 } },
219
- })
220
- .composite(composites)
221
- .png({ compressionLevel: 9 })
222
- .toFile(outPath);
223
- console.log(`[pre-art] wrote ${outPath} (Phase 7 procedural placeholder)`);
224
- }
225
-
226
- // ---------- Hello-world page → PNG ------------------------------------------
227
- //
228
- // Mirrors server.js at commit faa8c77 — black bg, gold-tinted serif/mono
229
- // type, three lines of copy. We render to PNG via sharp's SVG support.
230
-
231
- async function renderHelloWorld(outPath) {
232
- // Mirror server.js at commit faa8c77 — original body CSS was:
233
- // max-width: 640px; margin: 80px auto; padding: 24px;
234
- // font-family: ui-monospace, "Courier New", monospace;
235
- // background: #0e0c08; color: #e8d59a; line-height: 1.6;
236
- // h1 { font-size: 2.4em; letter-spacing: 0.05em; color: #f5e8b8; }
237
- // .meta { margin-top: 56px; font-size: 0.82em; color: #6b5d3d; }
238
- //
239
- // Layout: 640-wide content block centered in viewport, text LEFT-aligned
240
- // inside it. 80px top margin + 24px padding from the content frame.
241
- const contentW = 640;
242
- const x0 = (WIDTH - contentW) / 2 + 24; // left edge of text inside the padded frame
243
- const baseFs = 16; // browser default 1em
244
- const h1Fs = baseFs * 2.4; // ~38px
245
- const bodyFs = baseFs; // 16px
246
- const metaFs = baseFs * 0.82; // ~13px
247
- const line = baseFs * 1.6; // 26px (line-height)
248
- const top = 80 + 24; // body margin-top + padding-top
249
- const h1Y = top + h1Fs; // baseline of h1
250
- const gap = h1Fs * 0.4; // h1 margin-bottom
251
- const p1Y = h1Y + gap + bodyFs;
252
- const p2Y = p1Y + line;
253
- const metaY = p2Y + 56 + bodyFs; // .meta has margin-top: 56px
254
-
255
- const svg = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
256
- <svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
257
- <rect width="${WIDTH}" height="${HEIGHT}" fill="#0e0c08"/>
258
- <g font-family="ui-monospace, 'Courier New', 'Menlo', monospace">
259
- <text x="${x0}" y="${h1Y}"
260
- fill="#f5e8b8" font-size="${h1Fs}"
261
- letter-spacing="0.05em">Amazonprimea</text>
262
- <text x="${x0}" y="${p1Y}"
263
- fill="#e8d59a" font-size="${bodyFs}">The container has not yet washed ashore.</text>
264
- <text x="${x0}" y="${p2Y}"
265
- fill="#e8d59a" font-size="${bodyFs}">Check back soon.</text>
266
- <text x="${x0}" y="${metaY}"
267
- fill="#6b5d3d" font-size="${metaFs}">— Off the Boats / Hermeslines</text>
268
- </g>
269
- </svg>`);
270
-
271
- await sharp(svg)
272
- .png({ compressionLevel: 9 })
273
- .toFile(outPath);
274
- console.log(`[pre-art] wrote ${outPath} (hello-world page)`);
275
- }
276
-
277
- // ---------- manifest -------------------------------------------------------
278
-
279
- function upsertSnapshot(manifest, entry) {
280
- if (!Array.isArray(manifest.snapshots)) manifest.snapshots = [];
281
- const idx = manifest.snapshots.findIndex((s) => s.id === entry.id);
282
- if (idx >= 0) manifest.snapshots[idx] = { ...manifest.snapshots[idx], ...entry };
283
- else manifest.snapshots.push(entry);
284
- manifest.snapshots.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
285
- manifest.version = 2;
286
- return manifest;
287
- }
288
-
289
- // ---------- main -----------------------------------------------------------
290
-
291
- const PRE_ART_SNAPSHOTS = [
292
- {
293
- id: 'hello-world',
294
- sha: 'faa8c77e5342b1baa4fc6cbb0a83e7561d303840',
295
- timestamp: '2026-05-08T19:16:33-04:00',
296
- label: 'The container has not yet washed ashore',
297
- image: 'hello-world.png',
298
- render: renderHelloWorld,
299
- },
300
- {
301
- id: 'phase7-placeholder',
302
- sha: '0b13bb44100cead07eb780998c71be6b06f51cc8',
303
- timestamp: '2026-05-08T20:21:06-04:00',
304
- label: 'The first playable world — colored tiles, no art yet',
305
- image: 'phase7-placeholder.png',
306
- render: renderPhase7Placeholder,
307
- },
308
- ];
309
-
310
- mkdirSync(SNAPSHOTS_DIR, { recursive: true });
311
-
312
- let manifest = { version: 2, snapshots: [] };
313
- if (existsSync(MANIFEST_PATH)) {
314
- try { manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')); } catch { /* ignore */ }
315
- }
316
-
317
- for (const snap of PRE_ART_SNAPSHOTS) {
318
- await snap.render(resolve(SNAPSHOTS_DIR, snap.image));
319
- upsertSnapshot(manifest, {
320
- id: snap.id,
321
- sha: snap.sha,
322
- timestamp: snap.timestamp,
323
- label: snap.label,
324
- image: snap.image,
325
- });
326
- }
327
-
328
- writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
329
- console.log(`[pre-art] manifest now has ${manifest.snapshots.length} entries`);