CI / build + unit + e2e (pull_request) Successful in 2m36s
- shared: 8 MarkerTypes + MARKER_TYPE_INFO (label/color/glyph); MapMarker;
GameState.markers (required, [] at init); TurnResult.markers {add,remove};
MarkerSpec/MarkerInfo; FNV-1a 4-char markerId; MAX_MARKERS=400.
- data: new public/region-centroids.json (1,800 anchors = largest-part centroid
+ spread ~0.1x min-bbox) built by new idempotent scripts/build-region-centroids.mjs.
- server: regions.ts centroid loader (public/ then dist/); engine applyTurnResult
marker ops (remove -> owner-recompute -> add, jitter, cap, never throws);
prompt documents the contract; buildUserPrompt carries markers; stub marker-free.
- client: WorldMap symbol layer rendered as canvas per-type icons (glyph CDN dead);
hover tooltip shows name; click focuses owning nation.
- tests: 168 -> 189 (centroids script, regions centroids, db legacy, engine markers).
133 lines
5.9 KiB
JavaScript
133 lines
5.9 KiB
JavaScript
/**
|
||
* scripts/build-region-centroids.mjs — build the precomputed region-centroid
|
||
* manifest: `public/region-centroids.json` = `{ "<regionKey>": { "lat": <number>, "lon": <number>, "spread": <number> }, ... }`.
|
||
*
|
||
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||
* the point the LLM's map markers (v0.27, #44) will be anchored to: the
|
||
* **centroid of the region's largest-area part** (a MultiPolygon's biggest
|
||
* island/lake-piece rather than an area-weighted average of all parts), plus
|
||
* a `spread` value ≈ 0.1 × the smallest bbox dimension of that part (in
|
||
* degrees) — the engine scatters markers of one region within ±(spread/2)
|
||
* around the centroid using a deterministic per-marker offset, so several
|
||
* markers of the same region do not stack on one pixel.
|
||
*
|
||
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||
* v0.17 note). For each feature we (1) split a MultiPolygon into its parts
|
||
* (a plain Polygon is its own single part), (2) pick the part with the
|
||
* greatest `@turf/area`, (3) take `@turf/centroid` of that part and
|
||
* `@turf/bbox` of that part, and (4) round everything to 6 decimal places
|
||
* (≈ 0.1 m at this scale — far below the map's visual precision).
|
||
*
|
||
* Output contract (asserted by scripts/region-centroids.test.ts):
|
||
* - one entry for **every** region key, keyed identically to
|
||
* `public/region-keys.json` (same key set, 1,800 entries);
|
||
* - each centroid lies inside its region's (largest part's) bbox;
|
||
* - `spread` is a non-negative number of degrees (0 for degenerate parts);
|
||
* - values are rounded to at most 6 decimal places.
|
||
*
|
||
* Deterministic + idempotent: the input files are fixed, turf is a pure
|
||
* geometric computation, the output keeps the geo file's (stable) feature
|
||
* order and rounds to fixed precision — so a re-run rewrites a byte-identical
|
||
* file (the test re-runs this script and compares bytes).
|
||
*
|
||
* Usage: `node scripts/build-region-centroids.mjs` (or `npm run build:region-centroids`).
|
||
*/
|
||
import { readFileSync, writeFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import area from '@turf/area';
|
||
import bbox from '@turf/bbox';
|
||
import centroid from '@turf/centroid';
|
||
import { feature } from '@turf/helpers';
|
||
|
||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||
const root = path.resolve(here, '..');
|
||
|
||
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
||
|
||
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
||
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
||
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
||
|
||
/**
|
||
* The polygon parts of a geometry: a MultiPolygon's individual polygons, a
|
||
* plain Polygon as its own single part, anything else as an empty list (the
|
||
* caller falls back to the whole-feature centroid).
|
||
*/
|
||
function partsOf(geometry) {
|
||
if (geometry?.type === 'MultiPolygon') return geometry.coordinates ?? [];
|
||
if (geometry?.type === 'Polygon') return [geometry.coordinates];
|
||
return [];
|
||
}
|
||
|
||
const manifest = {};
|
||
let skipped = 0;
|
||
for (const f of geo.features ?? []) {
|
||
const id = f.id == null ? '' : String(f.id);
|
||
const key = idToKey[id];
|
||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||
const parts = partsOf(f.geometry);
|
||
if (parts.length === 0) {
|
||
// Degenerate geometry (a point/line region): anchor at the feature's own
|
||
// centroid. Can't happen with the current 1,800-polygon dataset, but the
|
||
// fallback keeps the manifest complete if the generator ever changes.
|
||
skipped += 1;
|
||
const c = centroid(feature(f.geometry));
|
||
const b = bbox(feature(f.geometry));
|
||
manifest[key] = {
|
||
lat: round6(c.geometry.coordinates[1]),
|
||
lon: round6(c.geometry.coordinates[0]),
|
||
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||
};
|
||
continue;
|
||
}
|
||
// Pick the part with the greatest area (km²) and anchor to IT, so a
|
||
// multi-part region's markers cluster on its main mass, not between parts.
|
||
let best = 0;
|
||
let bestArea = -1;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const a = area(feature({ type: 'Polygon', coordinates: parts[i] }));
|
||
if (a > bestArea) {
|
||
bestArea = a;
|
||
best = i;
|
||
}
|
||
}
|
||
const part = feature({ type: 'Polygon', coordinates: parts[best] });
|
||
const c = centroid(part);
|
||
const b = bbox(part);
|
||
manifest[key] = {
|
||
lat: round6(c.geometry.coordinates[1]),
|
||
lon: round6(c.geometry.coordinates[0]),
|
||
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||
};
|
||
}
|
||
|
||
// --- hard assertions (the test re-checks these against the committed file) ---
|
||
const expectedKeys = Object.values(idToKey);
|
||
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||
throw new Error(
|
||
`expected ${expectedKeys.length} centroid entries (one per region key), got ${Object.keys(manifest).length}`,
|
||
);
|
||
}
|
||
for (const k of expectedKeys) {
|
||
if (!(k in manifest)) throw new Error(`missing centroid for region key ${JSON.stringify(k)}`);
|
||
}
|
||
for (const [k, v] of Object.entries(manifest)) {
|
||
if (!Number.isFinite(v.lat) || !Number.isFinite(v.lon) || !Number.isFinite(v.spread)) {
|
||
throw new Error(`non-finite centroid for ${k}: ${JSON.stringify(v)}`);
|
||
}
|
||
if (v.spread < 0) throw new Error(`negative spread for ${k}: ${v.spread}`);
|
||
}
|
||
|
||
// --- write idempotently (stable order = insertion order = geo file order) ---
|
||
writeFileSync(path.join(root, 'public', 'region-centroids.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||
|
||
// --- summary ------------------------------------------------------------------
|
||
const spreads = Object.values(manifest).map((v) => v.spread);
|
||
console.log(
|
||
`[build-region-centroids] wrote public/region-centroids.json — ${Object.keys(manifest).length} region keys` +
|
||
(skipped ? ` (${skipped} non-polygon fallbacks)` : '') +
|
||
`, spread min/max ${Math.min(...spreads)} / ${Math.max(...spreads)} deg`,
|
||
);
|