/** * scripts/build-region-keys.mjs — build the deterministic region-key manifest. * * Region ownership flips from whole-country (ISO_A3) to individual sub-region * granularity, but the LLM and the persisted `Nation.territory` list need a * *stable, human-readable, globally unique* identifier per sub-region. Raw * feature ids (`FR-57`) are opaque to the model, so we mint a readable key of * the form: * * -<4char> e.g. brittany-xk2a, western-alun * * where: * - = properties.name → NFKD → strip combining marks → lowercase * → a tiny fold table for the handful of letters NFKD does NOT decompose * (ə ø ð đ œ ł, the only non-folding letters present in this dataset) * → every non-alphanumeric run collapsed to a single '-' → leading/trailing * '-' trimmed. * - <4char> = a deterministic 4-letter lowercase code derived from the * feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable * (same id → same code on every run) because the key is persisted in * SQLite and shown to the LLM. Within any name-slug group (e.g. the six * regions all named "Western") the 4-char codes are made distinct by * re-deriving with a deterministic salt on collision, which guarantees the * full key is unique across all 1,800 regions. * * Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }` * object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map * is looked up by the client (`WorldMap`) and the server (`map`/`presets`/ * `engine`) to join a sub-region's stable id to its readable key. * * Builtins only — deliberately NO `@turf`/geojson import (importing one pulls * `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the * v0.17 changelog note). Reads the geo file with plain `JSON.parse`. * * Idempotent: a re-run rewrites a byte-identical file (no timestamps, no * randomness, stable feature order, deterministic hash). * * Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`). */ 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, '..'); /** * Letters present in the dataset that NFKD does NOT decompose, and their * single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup. * Kept deliberately tiny + deterministic (only the six that actually occur). */ const FOLD = { 'ə': 'a', // schwa 'ø': 'o', // o-stroke 'ð': 'd', // eth 'đ': 'dj', // d-stroke 'œ': 'oe', // oe ligature 'ł': 'l', // l-stroke }; /** * Readable slug from a region name. NFKD decomposes + we strip the combining * marks, lowercase, fold the non-decomposing letters, collapse every other * non-alphanumeric run to one hyphen, and trim leading/trailing hyphens. */ function slug(name) { let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); s = s.toLowerCase(); s = [...s].map((ch) => FOLD[ch] ?? ch).join(''); s = s.replace(/[^a-z0-9]+/g, '-'); s = s.replace(/^-+|-+$/g, ''); return s; } /** FNV-1a 32-bit — a tiny, stable, pure string hash. */ function fnv1a32(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h * 0x01000193) >>> 0; } return h >>> 0; } /** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */ function code4(h) { let out = ''; for (let i = 0; i < 4; i++) { out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26))); } return out; } /** The 4-char code for an id, deterministically salted on within-group collision. */ function codeFor(id, used) { let code = code4(fnv1a32(id)); let attempt = 0; while (used.has(code)) { attempt += 1; code = code4(fnv1a32(`${id}#${attempt}`)); } return code; } // --- load the region geo (plain parse; no geojson types) --- const geoFile = path.join(root, 'public', 'regions.geo.json'); const geo = JSON.parse(readFileSync(geoFile, 'utf8')); const features = Array.isArray(geo?.features) ? geo.features : []; if (features.length !== 1800) { throw new Error(`expected 1,800 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`); } // Group features by their (computed) name-slug, preserving file order, so the // within-slug 4-char uniqueness resolution below is deterministic. const groups = new Map(); // slug -> array of { id } for (const f of features) { const id = f.id == null ? '' : String(f.id); const name = f.properties?.name; if (!id || typeof name !== 'string') { throw new Error(`region with id ${JSON.stringify(id)} has no usable name`); } const s = slug(name); if (!groups.has(s)) groups.set(s, []); groups.get(s).push({ id }); } // Assign keys, resolving 4-char collisions within each slug group. const manifest = {}; for (const [s, ids] of groups) { const used = new Set(); for (const { id } of ids) { manifest[id] = `${s}-${codeFor(id, used)}`; used.add(manifest[id].slice(-4)); } } // --- hard assertions (the test re-checks these against the committed file) --- const keys = Object.values(manifest); if (new Set(keys).size !== keys.length) { throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`); } const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/; for (const k of keys) { if (!KEY_RE.test(k) || !k.includes('-')) { throw new Error(`malformed region key: ${JSON.stringify(k)}`); } } // --- write idempotently (stable order = insertion order = file order) --- const out = JSON.stringify(manifest, null, 2) + '\n'; writeFileSync(path.join(root, 'public', 'region-keys.json'), out); const western = Object.entries(manifest) .filter(([, k]) => k.startsWith('western-')) .sort((a, b) => a[0] < b[0] ? -1 : 1); console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,800 expected)`); console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);