/** * scripts/build-region-resources.mjs — build the region **resources** manifest: * `public/region-resources.json` = `{ "": { "oil": <0-1>, "iron": <0-1>, "food": <0-1>, "industry": <0-1> }, ... }`. * * Every sub-region (region key, see `public/region-keys.json`) gets a * synthesized four-value resource profile. For each resource dimension a * value in [0, 1] (2 decimal places) is derived from the FNV-1a 32-bit hash * of the region key (salted per resource — the same tiny, stable, pure * string hash the region keys themselves use), then scaled MODESTLY by the * region's area (from the committed `public/region-geo.json`, when present): * larger regions carry a slightly higher value (a log-scaled factor of at * most ~1.2), so a big region reads as holding more of each resource. The * geo file is OPTIONAL — without it (or for a zero-area region) the factor * is exactly 1 and the profile is pure hash. * * This 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, see * `deriveNationResources` in shared/protocol.ts). The profiles are * synthesized (there is no real per-sub-region resource census) — they exist * to give the model a stable, deterministic world fact to reason about. * * Output contract (asserted by scripts/build-region-resources.test.ts): * - one entry for **every** region key, keyed identically to * `public/region-keys.json` (same key set, 1,800 entries); * - each entry is `{ oil, iron, food, industry }`, every value a finite * number in [0, 1] with at most 2 decimal places; * - keys are in sorted (deterministic) order. * * Deterministic + idempotent: fixed inputs (the key manifest, and the * committed geo file when present), pure string hashing, fixed 2-dp * rounding, and stable sorted key order → a re-run rewrites a byte-identical * file (the test re-runs this script and compares bytes). * * Usage: `node scripts/build-region-resources.mjs` (or `npm run build:region-resources`). */ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); const round2 = (v) => Math.round(v * 100) / 100; /** FNV-1a 32-bit — the same tiny, stable, pure hash the region keys use. */ function fnv1a32(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h >>> 0; } const RESOURCES = ['oil', 'iron', 'food', 'industry']; // --- load the inputs (plain parse) ------------------------------------------ const keysPath = path.join(root, 'public', 'region-keys.json'); const geoPath = path.join(root, 'public', 'region-geo.json'); let idToKey; try { idToKey = JSON.parse(readFileSync(keysPath, 'utf8')); } catch (err) { console.error(`[build-region-resources] could not load ${keysPath}.`); throw err; } // region-geo is OPTIONAL: a missing/broken file means "no area scaling" // (factor 1 for every region) — the profile is then pure hash. let areaByKey = {}; try { const parsed = JSON.parse(readFileSync(geoPath, 'utf8')); for (const [k, v] of Object.entries(parsed)) { if (v && typeof v.area === 'number' && Number.isFinite(v.area) && v.area >= 0) { areaByKey[k] = v.area; } } } catch { areaByKey = {}; } /** * The base value in [0, 1) for one (region key, resource): the low 14 bits of * the salted FNV-1a hash, normalized. Stable across runs and platforms; a * uniform-enough spread for a synthesized profile. */ const base = (key, resource) => (fnv1a32(`${key}\u0000${resource}`) & 0x3fff) / 0x4000; /** * The area scaling factor: 1.0 for a region with no geo entry (or zero area), * rising on a log scale (clamped) to at most ~1.2 for the largest regions — * bigger regions hold proportionally more of each resource. */ const areaScale = (key) => { const a = areaByKey[key]; if (!a || a <= 0) return 1; // log10(1 + area) runs ~0.004 (tiny slivers) to ~5.5 (a 300k km² region); // /8 + clamp keeps the factor in [1, 1.2]. const t = Math.min(1, Math.log10(1 + a) / 8); return 1 + 0.2 * t; }; // --- assemble the manifest (sorted keys) ------------------------------------- const expectedKeys = Object.values(idToKey); const manifest = {}; for (const k of [...expectedKeys].sort()) { const scale = areaScale(k); manifest[k] = { oil: round2(Math.min(1, base(k, 'oil') * scale)), iron: round2(Math.min(1, base(k, 'iron') * scale)), food: round2(Math.min(1, base(k, 'food') * scale)), industry: round2(Math.min(1, base(k, 'industry') * scale)), }; } // --- hard assertions (the test re-checks these against the committed file) ---- if (Object.keys(manifest).length !== expectedKeys.length) { throw new Error( `expected ${expectedKeys.length} region-resources entries (one per region key), got ${Object.keys(manifest).length}`, ); } let scaled = 0; for (const [k, v] of Object.entries(manifest)) { if (areaByKey[k]) scaled += 1; for (const r of RESOURCES) { const val = v[r]; if (typeof val !== 'number' || !Number.isFinite(val) || val < 0 || val > 1) { throw new Error(`bad ${r} value for ${k}: ${JSON.stringify(val)}`); } if (Math.round(val * 100) / 100 !== val) { throw new Error(`${r} over 2dp for ${k}: ${val}`); } } } // --- write idempotently (sorted keys, 2-space indent, trailing newline) ------ writeFileSync( path.join(root, 'public', 'region-resources.json'), JSON.stringify(manifest, null, 2) + '\n', ); // --- summary ------------------------------------------------------------------ console.log( `[build-region-resources] wrote public/region-resources.json — ${Object.keys(manifest).length} region keys` + ` (${scaled} area-scaled, ${Object.keys(manifest).length - scaled} pure-hash)`, );