Deterministic <name-slug>-<4char> key per sub-region: name via NFKD strip + fold table (ə ø ð đ œ ł) + a-z0-9 collapse; 4-char from FNV-1a of the stable feature id, salted on within-slug collision so all 1,796 keys are unique. Builtins only (no @turf). Idempotent (byte-identical on re-run).
159 lines
6.1 KiB
JavaScript
159 lines
6.1 KiB
JavaScript
/**
|
|
* 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:
|
|
*
|
|
* <name-slug>-<4char> e.g. brittany-xk2a, western-alun
|
|
*
|
|
* where:
|
|
* - <name-slug> = 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,796 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 !== 1796) {
|
|
throw new Error(`expected 1,796 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,796 expected)`);
|
|
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|