Add region-key manifest (build:region-keys) + public/region-keys.json (v0.19 P1 of #16)
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).
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
||||
"smooth:regions": "node scripts/smooth-regions.mjs",
|
||||
"fill:regions": "node scripts/fill-gaps.mjs",
|
||||
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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(', ')}`);
|
||||
Reference in New Issue
Block a user