@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.
- package/.bongos-core.json +25 -60
- package/.devcontainer/post-create.sh +3 -7
- package/docs/adr/0011-terrain-id-rendering-model.md +2 -1
- package/docs/adr/0012-ground-object-cell-split.md +2 -1
- package/modules/economy/achievements.js +5 -4
- package/modules/economy/credits.js +3 -1
- package/modules/economy/policy.js +22 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-identity.js +0 -1
- package/scripts/gds/fitness-ratchets.js +13 -3
- package/scripts/gds/run-unit-tests.js +10 -10
- package/src/module-api.js +5 -1
- package/tests/lib_sh_resolution.mjs +142 -0
- package/tests/search_isolation.mjs +4 -1
- package/scripts/gds/seed-v3-tasks.js +0 -1981
- package/scripts/snapshots/backfill-history.mjs +0 -292
- package/scripts/snapshots/maybe-render-on-deploy.sh +0 -66
- package/scripts/snapshots/render-pre-art-eras.mjs +0 -329
- package/scripts/snapshots/render-world.mjs +0 -322
- package/tests/anti_grid.mjs +0 -89
- package/tests/autotile_variants.mjs +0 -256
- package/tests/autotile_xverify.mjs +0 -133
|
@@ -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}` : ''}`);
|
package/tests/anti_grid.mjs
DELETED
|
@@ -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
|
-
});
|