/** * scripts/build-region-resources.test.ts — validation for the committed * `public/region-resources.json` (built by `scripts/build-region-resources.mjs`). * * The manifest maps each region key to a synthesized four-value resource * profile `{ oil, iron, food, industry }` — each a finite number in [0, 1] * (2 decimal places). It is the v0.36 (#59) "Resources & Industry" data * source: the engine scales each region's per-turn industry boost by its * `industry` value, and the LLM prompt carries each nation's aggregate * profile (the area×population-weighted mean of the owned regions' profiles, * `deriveNationResources` in shared/protocol.ts). * * Like the region-geo manifest, this is a load-bearing data artifact — the * test pins its contract. It is dependency-free: it reads the JSON with plain * `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull `@types/geojson` * into the tsc program and break `WorldMap.tsx` — see the v0.17 note). The * determinism test re-runs the (turf-free) `.mjs` builder and compares bytes, * matching that the builder is idempotent. */ import { readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); function readJson(rel: string): Record { return JSON.parse(readFileSync(path.join(root, rel), 'utf8')); } interface ResourceEntry { oil: number; iron: number; food: number; industry: number; } const RESOURCES = ['oil', 'iron', 'food', 'industry'] as const; // feature id -> region key const manifest = readJson('public/region-keys.json') as Record; const resources = readJson('public/region-resources.json') as Record; const keys = Object.values(manifest); describe('region-resources.json', () => { it('has exactly one entry per region key (same key set as region-keys.json)', () => { expect(new Set(Object.keys(resources))).toEqual(new Set(keys)); expect(Object.keys(resources)).toHaveLength(1800); }); it('is keyed in sorted (deterministic) order', () => { const ks = Object.keys(resources); expect(ks).toEqual([...ks].sort()); }); it('every entry is a finite { oil, iron, food, industry } in [0,1] with ≤ 2 dp', () => { for (const [k, v] of Object.entries(resources)) { for (const r of RESOURCES) { const val = v[r]; expect(typeof val, `non-number ${r} for ${k}`).toBe('number'); expect(Number.isFinite(val), `non-finite ${r} for ${k}`).toBe(true); expect(val >= 0, `negative ${r} for ${k}: ${val}`).toBe(true); expect(val <= 1, `${r} above 1 for ${k}: ${val}`).toBe(true); expect( Math.round(val * 100) / 100 === val, `${r} over 2dp for ${k}: ${val}`, ).toBe(true); } } }); it('spot-checks well-known regions for exact profiles', () => { const is = (k: string, oil: number, iron: number, food: number, industry: number) => { const v = resources[k]; expect(v, `missing resources for ${k}`).toBeDefined(); expect(v!.oil, `oil for ${k}`).toBe(oil); expect(v!.iron, `iron for ${k}`).toBe(iron); expect(v!.food, `food for ${k}`).toBe(food); expect(v!.industry, `industry for ${k}`).toBe(industry); }; is('california-kikh', 0.31, 0.1, 0.82, 0.97); // US-CA is('arkhangel-sk-lhbk', 0.49, 0.52, 0.98, 0.82); // RU-ARK mainland core is('pechora-qwhs', 0.11, 0.16, 1, 0.04); // RU-PEO (Arctic-coast band, #41) is('yamal-gosw', 0.64, 1, 1, 0.49); // RU-YAM (Arctic-coast band, #41) is('new-earth-eikt', 0.6, 0.56, 0.91, 0.66); // RU-ARK-NE (island group, #31) is('severnaya-zemlya-awqp', 0.1, 0.48, 1, 0.3); // RU-ARK-SZ (island group, #31) is('ain-lqtt', 0.94, 0.92, 0.51, 0.4); // FR-01 (Ain) }); it('is deterministic: re-running the builder rewrites a byte-identical file', () => { const file = path.join(root, 'public', 'region-resources.json'); const before = readFileSync(file, 'utf8'); // The builder is cwd-independent (it resolves the repo root from its own // location) and idempotent: same input files (region-keys.json + the // committed region-geo.json when present) + pure string hashing + fixed // 2-dp rounding + stable (sorted) key order → identical bytes. execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-resources.mjs')], { cwd: root, stdio: 'pipe', }); const after = readFileSync(file, 'utf8'); expect(after).toBe(before); }); });