@bongos/core 1.19.334 → 1.19.336

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,322 +0,0 @@
1
- // scripts/snapshots/render-world.mjs
2
- //
3
- // Server-side world compositor. Walks INITIAL_MAP.ground + INITIAL_MAP.objects,
4
- // applies the same autotile picker logic as the browser client, and composites
5
- // all tile PNGs into a single top-down PNG for the public status dashboard.
6
- //
7
- // Snapshots are EVENT-BASED, not daily: one image per graphics-changing commit.
8
- // The manifest is a chronological list of `{ id, sha, timestamp, label, image }`.
9
- //
10
- // CLI flags (all optional — defaults to "now, no sha"):
11
- // --sha <hash> Full commit sha; first 7 chars become the id + filename.
12
- // --timestamp <iso> ISO 8601 timestamp for the snapshot. Default: now.
13
- // --label "<text>" Caption shown under the snapshot in the timeline.
14
- // --id <slug> Override the filename id. Default: short sha or date.
15
- // --out <path> Override the output PNG path. Default: <id>.png.
16
- // --no-manifest Render the PNG but skip updating manifest.json.
17
- //
18
- // Examples:
19
- // node scripts/snapshots/render-world.mjs # quick "now" snapshot
20
- // node scripts/snapshots/render-world.mjs --sha 5354daa1c1... # tagged commit
21
- // node scripts/snapshots/render-world.mjs --id pre-grass --label "Before the grass turned green"
22
-
23
- import { createRequire } from 'node:module';
24
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
25
- import { resolve, dirname } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
- import sharp from 'sharp';
28
-
29
- // ESM imports — these resolve relative to the modules themselves, so nested
30
- // imports (terrain.js → autotile.js) work correctly.
31
- import { selectBlob47, selectPath16 } from '../../public/game/world/autotile.js';
32
- import { TERRAIN_RENDER, OBJECT_RENDER, neighborsAt } from '../../public/game/world/terrain.js';
33
-
34
- // CJS map + terrain live in src/ (server-side). Load via createRequire.
35
- const require = createRequire(import.meta.url);
36
- const { INITIAL_MAP, WORLD_COLS, WORLD_ROWS } = require('../../src/world/map.js');
37
-
38
- // Canonical NPC + player positions. These mirror what the game actually
39
- // renders into the world so the snapshot matches what a player sees.
40
- const VENDOR_GRID = { x: 13, y: 9 };
41
- const PLAYER_START_GRID = { x: 10, y: 10 };
42
-
43
- // Preferred sprite game_keys per character. We try in order; first match
44
- // in the manifest wins. This lets the renderer stay correct across the
45
- // commits where the vendor went idle-only → idle/talk family, etc.
46
- const VENDOR_SPRITE_KEYS = ['vendor_idle', 'vendor', 'vendor_hyperion_idle', 'vendor_hyperion'];
47
- const PLAYER_SPRITE_KEYS = ['player_down', 'player', 'player_down_idle'];
48
-
49
- const HERE = dirname(fileURLToPath(import.meta.url));
50
- const ROOT = resolve(HERE, '../..');
51
-
52
- const TILE_SIZE = 32;
53
- const MANIFEST_PATH = resolve(ROOT, 'public/assets/tiles/manifest.json');
54
- // The status surface moved to modules/status-ui/public/ in BV1.R84 (task 1635).
55
- const SNAPSHOTS_DIR = resolve(ROOT, 'modules/status-ui/public/snapshots');
56
- const SNAPSHOTS_MANIFEST = resolve(SNAPSHOTS_DIR, 'manifest.json');
57
-
58
- // ---------- arg parsing ----------------------------------------------------
59
-
60
- function parseArgs(argv) {
61
- const args = {};
62
- for (let i = 2; i < argv.length; i++) {
63
- const a = argv[i];
64
- if (a === '--no-manifest') { args.noManifest = true; continue; }
65
- if (a.startsWith('--')) {
66
- const key = a.slice(2);
67
- const val = argv[i + 1];
68
- args[key] = val;
69
- i++;
70
- }
71
- }
72
- return args;
73
- }
74
-
75
- // ---------- tile path resolution ------------------------------------------
76
-
77
- function loadTileManifest() {
78
- return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
79
- }
80
-
81
- // game_key → absolute path to the PNG file. Walks BOTH tiles and sprites
82
- // so character art (vendor, player) can be looked up the same way.
83
- //
84
- // Also indexes by file_id (the manifest key) as a fallback, because older
85
- // commits (notably 6abe01c) shipped the sprite PNGs with game_key=null —
86
- // the canonical name on those is the file id (`vendor_hyperion`).
87
- function buildGameKeyMap(manifest) {
88
- const m = new Map();
89
- for (const bucket of ['tiles', 'sprites']) {
90
- const entries = manifest[bucket] || {};
91
- for (const [fileId, entry] of Object.entries(entries)) {
92
- if (!entry.path) continue;
93
- const abs = resolve(ROOT, 'public' + entry.path);
94
- if (entry.game_key && !m.has(entry.game_key)) m.set(entry.game_key, abs);
95
- if (!m.has(fileId)) m.set(fileId, abs);
96
- }
97
- }
98
- return m;
99
- }
100
-
101
- // Try a list of game_keys; return the first one that resolves to an
102
- // existing PNG. Used for character sprites whose game_key changed across
103
- // commits (vendor → vendor_idle).
104
- function firstExistingSprite(gameKeyMap, keys) {
105
- for (const k of keys) {
106
- const p = gameKeyMap.get(k);
107
- if (p && existsSync(p)) return p;
108
- }
109
- return null;
110
- }
111
-
112
- // Returns an absolute path for an autotile family tile by index.
113
- function buildFamilyResolver(manifest) {
114
- const families = manifest.autotile_families || {};
115
- return function resolveFamilyTile(family, idx) {
116
- const fam = families[family];
117
- if (!fam || !fam.path_template) return null;
118
- const relPath = fam.path_template.replace('{idx:02}', String(idx).padStart(2, '0'));
119
- return resolve(ROOT, 'public' + relPath);
120
- };
121
- }
122
-
123
- // ---------- per-cell tile pickers -----------------------------------------
124
-
125
- function groundTilePath(x, y, ground, gameKeyMap, familyResolver) {
126
- const terrainId = ground[y][x];
127
- const cfg = TERRAIN_RENDER[terrainId];
128
- if (!cfg) return gameKeyMap.get(terrainId) || null;
129
-
130
- // Short-form { tile: 'X' } (pre-ADR-0013 commits) means single-tile.
131
- if (cfg.strategy === 'single' || (!cfg.strategy && cfg.tile)) {
132
- return gameKeyMap.get(cfg.tile) || null;
133
- }
134
-
135
- const neighbors = neighborsAt(ground, x, y);
136
- let idx;
137
- if (cfg.strategy === 'blob47') {
138
- idx = selectBlob47(terrainId, neighbors);
139
- } else if (cfg.strategy === 'path16') {
140
- idx = selectPath16({
141
- N: neighbors.N === terrainId ? 1 : 0,
142
- W: neighbors.W === terrainId ? 1 : 0,
143
- E: neighbors.E === terrainId ? 1 : 0,
144
- S: neighbors.S === terrainId ? 1 : 0,
145
- });
146
- } else {
147
- return gameKeyMap.get(cfg.fallbackTile) || null;
148
- }
149
-
150
- const p = familyResolver(cfg.family, idx);
151
- if (p && existsSync(p)) return p;
152
- return gameKeyMap.get(cfg.fallbackTile) || null;
153
- }
154
-
155
- function objectTilePath(x, y, objects, gameKeyMap, familyResolver) {
156
- const objId = objects[y][x];
157
- if (!objId) return null;
158
- const cfg = OBJECT_RENDER[objId];
159
- if (!cfg) return null;
160
-
161
- // Short-form { tile: 'X' } (pre-ADR-0013 commits) means single-tile.
162
- if (cfg.strategy === 'single' || (!cfg.strategy && cfg.tile)) {
163
- return gameKeyMap.get(cfg.tile) || null;
164
- }
165
-
166
- const neighbors = neighborsAt(objects, x, y);
167
- let idx;
168
- if (cfg.strategy === 'blob47') {
169
- idx = selectBlob47(objId, neighbors);
170
- } else if (cfg.strategy === 'path16') {
171
- idx = selectPath16({
172
- N: neighbors.N === objId ? 1 : 0,
173
- W: neighbors.W === objId ? 1 : 0,
174
- E: neighbors.E === objId ? 1 : 0,
175
- S: neighbors.S === objId ? 1 : 0,
176
- });
177
- } else {
178
- return gameKeyMap.get(cfg.fallbackTile) || null;
179
- }
180
-
181
- const p = familyResolver(cfg.family, idx);
182
- if (p && existsSync(p)) return p;
183
- return gameKeyMap.get(cfg.fallbackTile) || null;
184
- }
185
-
186
- // ---------- compositing ----------------------------------------------------
187
-
188
- async function renderWorld(outPath) {
189
- const manifest = loadTileManifest();
190
- const gameKeyMap = buildGameKeyMap(manifest);
191
- const familyResolver = buildFamilyResolver(manifest);
192
-
193
- const { ground, objects } = INITIAL_MAP;
194
- const width = WORLD_COLS * TILE_SIZE;
195
- const height = WORLD_ROWS * TILE_SIZE;
196
-
197
- const composites = [];
198
-
199
- // Ground layer first, then object layer on top.
200
- for (let pass = 0; pass < 2; pass++) {
201
- for (let y = 0; y < WORLD_ROWS; y++) {
202
- for (let x = 0; x < WORLD_COLS; x++) {
203
- const tilePath = pass === 0
204
- ? groundTilePath(x, y, ground, gameKeyMap, familyResolver)
205
- : objectTilePath(x, y, objects, gameKeyMap, familyResolver);
206
- if (!tilePath || !existsSync(tilePath)) continue;
207
- composites.push({
208
- input: tilePath,
209
- left: x * TILE_SIZE,
210
- top: y * TILE_SIZE,
211
- blend: 'over',
212
- });
213
- }
214
- }
215
- }
216
-
217
- // NPCs / player — composited last so they paint over ground + objects.
218
- // We deliberately stay silent when a sprite is missing (older commits
219
- // before the art existed) rather than fail the render.
220
- const vendorPath = firstExistingSprite(gameKeyMap, VENDOR_SPRITE_KEYS);
221
- if (vendorPath) {
222
- composites.push({
223
- input: vendorPath,
224
- left: VENDOR_GRID.x * TILE_SIZE,
225
- top: VENDOR_GRID.y * TILE_SIZE,
226
- blend: 'over',
227
- });
228
- }
229
- const playerPath = firstExistingSprite(gameKeyMap, PLAYER_SPRITE_KEYS);
230
- if (playerPath) {
231
- composites.push({
232
- input: playerPath,
233
- left: PLAYER_START_GRID.x * TILE_SIZE,
234
- top: PLAYER_START_GRID.y * TILE_SIZE,
235
- blend: 'over',
236
- });
237
- }
238
-
239
- mkdirSync(dirname(outPath), { recursive: true });
240
-
241
- await sharp({
242
- create: {
243
- width,
244
- height,
245
- channels: 3,
246
- background: { r: 0, g: 0, b: 0 },
247
- },
248
- })
249
- .composite(composites)
250
- .png({ compressionLevel: 9 })
251
- .toFile(outPath);
252
-
253
- console.log(`[snapshot] wrote ${outPath} (${width}×${height})`);
254
- return outPath;
255
- }
256
-
257
- // ---------- manifest -------------------------------------------------------
258
-
259
- // New manifest shape:
260
- // { version: 2, snapshots: [{ id, sha?, timestamp, label?, image }, ...] }
261
- // Backwards-compat: if we encounter the v1 `{ dates: [...] }` shape we migrate
262
- // the entries (with no sha / label) by date-stamp.
263
- function readSnapshotManifest() {
264
- if (!existsSync(SNAPSHOTS_MANIFEST)) return { version: 2, snapshots: [] };
265
- let data;
266
- try {
267
- data = JSON.parse(readFileSync(SNAPSHOTS_MANIFEST, 'utf8'));
268
- } catch {
269
- return { version: 2, snapshots: [] };
270
- }
271
- if (Array.isArray(data.snapshots)) return data; // already v2
272
- if (Array.isArray(data.dates)) {
273
- const snapshots = data.dates.map((d) => ({
274
- id: d,
275
- timestamp: `${d}T00:00:00Z`,
276
- image: `${d}.png`,
277
- }));
278
- return { version: 2, snapshots };
279
- }
280
- return { version: 2, snapshots: [] };
281
- }
282
-
283
- function upsertSnapshot(manifest, entry) {
284
- const idx = manifest.snapshots.findIndex((s) => s.id === entry.id);
285
- if (idx >= 0) {
286
- manifest.snapshots[idx] = { ...manifest.snapshots[idx], ...entry };
287
- } else {
288
- manifest.snapshots.push(entry);
289
- }
290
- manifest.snapshots.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
291
- manifest.version = 2;
292
- return manifest;
293
- }
294
-
295
- function writeSnapshotManifest(manifest) {
296
- writeFileSync(SNAPSHOTS_MANIFEST, JSON.stringify(manifest, null, 2) + '\n');
297
- }
298
-
299
- // ---------- main -----------------------------------------------------------
300
-
301
- const args = parseArgs(process.argv);
302
-
303
- const sha = args.sha || null;
304
- const shortSha = sha ? sha.slice(0, 7) : null;
305
- const timestamp = args.timestamp || new Date().toISOString();
306
- const id = args.id || shortSha || timestamp.slice(0, 10);
307
- const image = args.out ? args.out : `${id}.png`;
308
- const outPath = resolve(SNAPSHOTS_DIR, image);
309
-
310
- await renderWorld(outPath);
311
-
312
- if (!args.noManifest) {
313
- const manifest = readSnapshotManifest();
314
- const entry = { id, timestamp, image };
315
- if (sha) entry.sha = sha;
316
- if (args.label) entry.label = args.label;
317
- upsertSnapshot(manifest, entry);
318
- writeSnapshotManifest(manifest);
319
- console.log(`[snapshot] manifest updated (${manifest.snapshots.length} entries)`);
320
- }
321
-
322
- console.log(`[snapshot] done — id=${id}${sha ? ` sha=${shortSha}` : ''}`);
@@ -1,89 +0,0 @@
1
- // tests/anti_grid.mjs
2
- //
3
- // Smoke wrapper for the anti-grid evaluation harness (task D.4-T5).
4
- //
5
- // Shells out to art/pipeline/anti_grid_eval.py, which renders a 16x16 cell
6
- // expanse of each autotile family, runs 2D autocorrelation, and computes
7
- // the peak-to-mean ratio at grid-aligned lags vs non-grid lags. A clean,
8
- // seam-free family scores near 1.0; one with a visible 32-px periodicity
9
- // scores well above the absolute target of 1.5.
10
- //
11
- // The Python tool has two modes:
12
- // - regression-only (default) -- fail if any ratio > baseline * 1.30.
13
- // This is what this smoke test checks. It catches future regressions
14
- // where a tile or compositor change reintroduces a grid pattern.
15
- // - --strict -- also fail if ratio >= 1.5 (the task spec's absolute
16
- // target). New families may not meet this yet; the smoke test does
17
- // NOT run strict by default so it doesn't block builds on tiles that
18
- // are working-as-currently-generated.
19
- //
20
- // Run: node tests/anti_grid.mjs
21
- //
22
- // Exit 0 = all checks passed; 1 = regression detected; 2 = setup error
23
- // (Python missing, no tiles built, etc.).
24
-
25
- import { spawnSync } from 'node:child_process';
26
- import { readFileSync, existsSync } from 'node:fs';
27
- import { dirname, resolve } from 'node:path';
28
- import { fileURLToPath } from 'node:url';
29
-
30
- const HERE = dirname(fileURLToPath(import.meta.url));
31
- const REPO = resolve(HERE, '..');
32
- const EVAL_SCRIPT = resolve(REPO, 'art', 'pipeline', 'anti_grid_eval.py');
33
- const BASELINES = resolve(REPO, 'art', 'iterations', 'anti_grid_baselines.json');
34
-
35
- function fail(msg, code = 1) {
36
- console.error(`\n[anti-grid] ${msg}`);
37
- process.exit(code);
38
- }
39
-
40
- if (!existsSync(EVAL_SCRIPT)) {
41
- fail(`evaluator missing: ${EVAL_SCRIPT}`, 2);
42
- }
43
- if (!existsSync(BASELINES)) {
44
- fail(
45
- `baselines missing: ${BASELINES}\n` +
46
- `Run: python3 art/pipeline/anti_grid_eval.py --update-baselines`,
47
- 2
48
- );
49
- }
50
-
51
- // Sanity check on the baselines file -- make sure it actually has entries.
52
- let baselines;
53
- try {
54
- baselines = JSON.parse(readFileSync(BASELINES, 'utf8'));
55
- } catch (e) {
56
- fail(`cannot parse baselines: ${e.message}`, 2);
57
- }
58
- const families = Object.keys(baselines).filter((k) => k !== '_meta');
59
- if (families.length === 0) {
60
- fail('baselines file has no families recorded', 2);
61
- }
62
-
63
- console.log(`[anti-grid] checking ${families.length} families against baselines: ` +
64
- families.join(', '));
65
-
66
- const result = spawnSync('python3', [EVAL_SCRIPT, '-v'], {
67
- cwd: REPO,
68
- encoding: 'utf8',
69
- stdio: ['ignore', 'pipe', 'pipe'],
70
- });
71
-
72
- if (result.error) {
73
- fail(`failed to spawn python3: ${result.error.message}`, 2);
74
- }
75
-
76
- // Forward Python output so any failures are immediately visible.
77
- if (result.stdout) process.stdout.write(result.stdout);
78
- if (result.stderr) process.stderr.write(result.stderr);
79
-
80
- const code = result.status ?? 1;
81
- if (code === 0) {
82
- console.log('\n[anti-grid] all families within baseline tolerance -- ok');
83
- process.exit(0);
84
- }
85
- if (code === 2) {
86
- fail('evaluator reported a setup error (see stderr above)', 2);
87
- }
88
- fail('regression detected: one or more families scored ' +
89
- `worse than baseline * 1.30. See output above.`, 1);
@@ -1,256 +0,0 @@
1
- // tests/autotile_variants.mjs
2
- //
3
- // GDS #144 (D.4-T4): Edge-variant matching for boundary tiles.
4
- //
5
- // D.4-T1 introduced multi-variant interior primitives so the all-interior
6
- // blob47 tile (index 46) doesn't visibly repeat. Each variant is produced
7
- // by sampling the seamless interior primitive at a different offset — which
8
- // leaves variants with slightly different outermost-pixel rings. Boundary
9
- // tiles (concave / convex / horiz / vert patches) only encode the canonical
10
- // interior's edge pixels, so a variant whose edges drift can produce a
11
- // 1-pixel seam against any boundary tile.
12
- //
13
- // Fix (autotile_orchestrator.py::_force_edge_match_to_canonical): after the
14
- // compositor returns its tile list, overwrite each variant's outermost-pixel
15
- // ring (top row, bottom row, left column, right column) with the canonical
16
- // all-interior tile's. The interior pixel-noise keeps the visual variety;
17
- // the edges become byte-identical to the canonical so the variant is seam-
18
- // compatible everywhere the canonical is.
19
- //
20
- // This test:
21
- // 1. Drives a tiny Python helper that builds a deterministic synthetic
22
- // interior primitive (no AI calls — no cost), runs the orchestrator's
23
- // compose-and-edge-fix flow, and saves the canonical + variants to a
24
- // tmp directory as PNGs.
25
- // 2. Loads each PNG with sharp.
26
- // 3. Asserts each variant's edge ring equals the canonical's, byte-for-byte.
27
- // 4. Asserts the variant interiors are NOT identical to the canonical
28
- // (otherwise the variants would be useless — proves the fix preserved
29
- // the variety inside the ring).
30
- //
31
- // Run: node tests/autotile_variants.mjs
32
-
33
- import { strict as assert } from 'node:assert';
34
- import { spawnSync } from 'node:child_process';
35
- import { mkdtempSync, rmSync, existsSync } from 'node:fs';
36
- import { tmpdir } from 'node:os';
37
- import { join, dirname, resolve } from 'node:path';
38
- import { fileURLToPath } from 'node:url';
39
- import sharp from 'sharp';
40
-
41
- const HERE = dirname(fileURLToPath(import.meta.url));
42
- const REPO_ROOT = resolve(HERE, '..');
43
-
44
- let passed = 0;
45
- let failed = 0;
46
- function test(name, fn) {
47
- try {
48
- fn();
49
- console.log(` ok ${name}`);
50
- passed++;
51
- } catch (e) {
52
- console.error(` FAIL ${name}`);
53
- console.error(` ${e.message}`);
54
- failed++;
55
- }
56
- }
57
-
58
- // Python helper: build a synthetic 32x32 interior primitive whose pixels
59
- // vary across the texture (so variants produced by offset-cropping actually
60
- // differ), compose blob47-with-variants, apply the edge-fix step, save PNGs.
61
- //
62
- // Inlined here (rather than as a separate file) so this test owns its
63
- // fixture generation end-to-end — touches[] is just orchestrator + this
64
- // test file.
65
- const PY_FIXTURE = `
66
- import sys, os
67
- from pathlib import Path
68
- import numpy as np
69
- from PIL import Image
70
-
71
- sys.path.insert(0, str(Path(${JSON.stringify(REPO_ROOT)}) / "art" / "pipeline"))
72
- import autotile_compositor
73
- import autotile_orchestrator as orch
74
-
75
- # Build a 32x32 interior whose pixels are a deterministic per-coordinate
76
- # gradient — guarantees that offset-sampled variants differ from each other
77
- # AND from the canonical at non-edge positions.
78
- arr = np.zeros((32, 32, 3), dtype=np.uint8)
79
- for y in range(32):
80
- for x in range(32):
81
- # Three independent channels so variants are easy to tell apart;
82
- # values stay in [0,255].
83
- arr[y, x, 0] = (x * 7 + y * 3) % 256
84
- arr[y, x, 1] = (x * 5 + y * 11) % 256
85
- arr[y, x, 2] = (x * 13 + y * 17) % 256
86
- interior = Image.fromarray(arr, "RGB")
87
-
88
- # Build a minimal exterior + patch set so compose_blob47 runs end-to-end.
89
- # The boundary tiles' content doesn't matter for THIS test; only the
90
- # canonical (index 46) and the appended variants (47..) are inspected.
91
- exterior = Image.new("RGB", (32, 32), (200, 130, 80))
92
- # Synthesize concave/convex patches from interior+exterior using the
93
- # orchestrator's own helpers — keeps this test independent of upstream
94
- # patch generation.
95
- patch_concave = orch._synth_concave(interior, exterior)
96
- patch_convex = orch._synth_convex(interior, exterior)
97
- # Horizontal patch: top half exterior, bottom half interior (16x16).
98
- ph = Image.new("RGB", (16, 16))
99
- arr_ph = np.array(ph)
100
- ext16 = np.array(exterior.resize((16, 16), Image.NEAREST))
101
- int16 = np.array(interior.resize((16, 16), Image.NEAREST))
102
- arr_ph[:8, :] = ext16[:8, :]
103
- arr_ph[8:, :] = int16[8:, :]
104
- patch_horiz = Image.fromarray(arr_ph, "RGB")
105
- patch_vert = patch_horiz.transpose(Image.ROTATE_90)
106
-
107
- bundle = {
108
- "interior": interior,
109
- "exterior": exterior,
110
- "patch_concave_NW": patch_concave,
111
- "patch_convex_NW": patch_convex,
112
- "patch_horiz_NW": patch_horiz,
113
- "patch_vert_NW": patch_vert,
114
- }
115
-
116
- VARIANT_COUNT = 4
117
- tiles = autotile_compositor.compose_blob47_with_variants(bundle, VARIANT_COUNT)
118
- assert len(tiles) == 47 + (VARIANT_COUNT - 1), \\
119
- f"expected {47 + VARIANT_COUNT - 1} tiles, got {len(tiles)}"
120
-
121
- # Apply the edge-fix (mirror of the production logic in orch.run_family).
122
- canonical = tiles[orch._BLOB47_ALL_INTERIOR_INDEX]
123
- for v_idx in range(orch._BLOB47_ALL_INTERIOR_INDEX + 1, len(tiles)):
124
- tiles[v_idx] = orch._force_edge_match_to_canonical(canonical, tiles[v_idx])
125
-
126
- out_dir = Path(os.environ["OUT_DIR"])
127
- out_dir.mkdir(parents=True, exist_ok=True)
128
- # Save only the tiles this test inspects.
129
- canonical.save(out_dir / "canonical.png")
130
- for v in range(1, VARIANT_COUNT):
131
- tiles[orch._BLOB47_ALL_INTERIOR_INDEX + v].save(out_dir / f"variant_{v}.png")
132
-
133
- print(f"OK wrote {VARIANT_COUNT} files to {out_dir}")
134
- `;
135
-
136
- async function runFixture(outDir) {
137
- const result = spawnSync('python3', ['-c', PY_FIXTURE], {
138
- cwd: REPO_ROOT,
139
- env: { ...process.env, OUT_DIR: outDir },
140
- encoding: 'utf8',
141
- });
142
- if (result.status !== 0) {
143
- const msg = (result.stderr || result.stdout || '').trim();
144
- throw new Error(
145
- `fixture generation failed (exit ${result.status}):\n${msg}\n` +
146
- `If 'python3' is missing or numpy/Pillow aren't installed, the autotile ` +
147
- `pipeline can't run — same dependency the orchestrator needs in CI.`
148
- );
149
- }
150
- }
151
-
152
- async function loadPng(path) {
153
- const { data, info } = await sharp(path)
154
- .raw()
155
- .toBuffer({ resolveWithObject: true });
156
- return { data, width: info.width, height: info.height, channels: info.channels };
157
- }
158
-
159
- function row(img, y) {
160
- const stride = img.width * img.channels;
161
- return img.data.subarray(y * stride, (y + 1) * stride);
162
- }
163
-
164
- function col(img, x) {
165
- const out = new Uint8Array(img.height * img.channels);
166
- const stride = img.width * img.channels;
167
- for (let y = 0; y < img.height; y++) {
168
- for (let c = 0; c < img.channels; c++) {
169
- out[y * img.channels + c] = img.data[y * stride + x * img.channels + c];
170
- }
171
- }
172
- return out;
173
- }
174
-
175
- function buffersEqual(a, b) {
176
- if (a.length !== b.length) return false;
177
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
178
- return true;
179
- }
180
-
181
- function interiorDiffers(canon, variant) {
182
- // Count pixels that differ inside the 1-pixel border.
183
- const stride = canon.width * canon.channels;
184
- let diffs = 0;
185
- for (let y = 1; y < canon.height - 1; y++) {
186
- for (let x = 1; x < canon.width - 1; x++) {
187
- for (let c = 0; c < canon.channels; c++) {
188
- if (canon.data[y * stride + x * canon.channels + c] !==
189
- variant.data[y * stride + x * canon.channels + c]) {
190
- diffs++;
191
- break;
192
- }
193
- }
194
- }
195
- }
196
- return diffs;
197
- }
198
-
199
- async function main() {
200
- const out = mkdtempSync(join(tmpdir(), 'otb-autotile-variants-'));
201
- try {
202
- await runFixture(out);
203
-
204
- const canonPath = join(out, 'canonical.png');
205
- assert.ok(existsSync(canonPath), `fixture did not produce ${canonPath}`);
206
- const canon = await loadPng(canonPath);
207
- assert.equal(canon.width, 32, 'canonical width should be 32');
208
- assert.equal(canon.height, 32, 'canonical height should be 32');
209
-
210
- const variantPaths = [1, 2, 3].map(v => join(out, `variant_${v}.png`));
211
-
212
- for (const vp of variantPaths) {
213
- const name = vp.split('/').pop();
214
- assert.ok(existsSync(vp), `fixture did not produce ${vp}`);
215
- const variant = await loadPng(vp);
216
-
217
- test(`${name} top row matches canonical`, () => {
218
- assert.ok(buffersEqual(row(variant, 0), row(canon, 0)),
219
- 'top row drifted from canonical');
220
- });
221
- test(`${name} bottom row matches canonical`, () => {
222
- assert.ok(buffersEqual(row(variant, variant.height - 1),
223
- row(canon, canon.height - 1)),
224
- 'bottom row drifted from canonical');
225
- });
226
- test(`${name} left column matches canonical`, () => {
227
- assert.ok(buffersEqual(col(variant, 0), col(canon, 0)),
228
- 'left column drifted from canonical');
229
- });
230
- test(`${name} right column matches canonical`, () => {
231
- assert.ok(buffersEqual(col(variant, variant.width - 1),
232
- col(canon, canon.width - 1)),
233
- 'right column drifted from canonical');
234
- });
235
- test(`${name} interior still differs from canonical (variety preserved)`, () => {
236
- const diffs = interiorDiffers(canon, variant);
237
- assert.ok(diffs > 0,
238
- `variant interior is identical to canonical — the edge-fix wiped the ` +
239
- `variant's pixel-noise, defeating the purpose of variants. (diffs=${diffs})`);
240
- });
241
- }
242
-
243
- if (failed > 0) {
244
- console.error(`\n[autotile_variants] FAIL — ${failed} of ${passed + failed} assertions failed.`);
245
- process.exit(1);
246
- }
247
- console.log(`\n[autotile_variants] OK — ${passed} edge-match + interior-variety assertions across ${variantPaths.length} variants.`);
248
- } finally {
249
- rmSync(out, { recursive: true, force: true });
250
- }
251
- }
252
-
253
- main().catch(e => {
254
- console.error(e.stack || e.message);
255
- process.exit(1);
256
- });