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).
159 lines
6.8 KiB
TypeScript
159 lines
6.8 KiB
TypeScript
/**
|
||
* scripts/region-centroids.test.ts — validation for the committed
|
||
* `public/region-centroids.json` (built by `scripts/build-region-centroids.mjs`).
|
||
*
|
||
* The manifest maps each region key to the anchor point a map marker (v0.27,
|
||
* #44) is placed at: the centroid of the region's largest-area part, plus a
|
||
* `spread` (≈ 0.1 × that part's smallest bbox dimension, in degrees) that the
|
||
* engine uses to scatter multiple markers of one region around the centroid.
|
||
* It is a load-bearing data artifact for the marker placement path — this test
|
||
* pins its contract.
|
||
*
|
||
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
|
||
* and computes bbox checks by iterating coordinates directly (no `@turf`/
|
||
* geojson types, which would pull `@types/geojson` into the tsc program and
|
||
* break `WorldMap.tsx` — see the v0.17 note). Mirrors the style of
|
||
* `scripts/region-adjacency.test.ts`; the determinism test re-runs the (turf
|
||
* `.mjs`) builder and compares bytes, matching that the builder is idempotent.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { execFileSync } from 'node:child_process';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||
const root = path.resolve(here, '..');
|
||
|
||
function readJson(rel: string): Record<string, unknown> {
|
||
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||
}
|
||
|
||
interface CentroidEntry {
|
||
lat: number;
|
||
lon: number;
|
||
spread: number;
|
||
}
|
||
|
||
/** All [lon, lat] coordinate pairs of a Polygon / MultiPolygon geometry. */
|
||
function allCoords(geometry: { type?: string; coordinates?: unknown }): Array<[number, number]> {
|
||
const c = geometry?.coordinates as
|
||
| Array<Array<[number, number]>>
|
||
| Array<Array<Array<[number, number]>>>
|
||
| undefined;
|
||
if (!c) return [];
|
||
if (geometry?.type === 'Polygon') {
|
||
const rings = c as Array<Array<[number, number]>>;
|
||
const out: Array<[number, number]> = [];
|
||
for (const ring of rings) for (const p of ring) out.push(p);
|
||
return out;
|
||
}
|
||
if (geometry?.type === 'MultiPolygon') {
|
||
const polys = c as Array<Array<Array<[number, number]>>>;
|
||
const out: Array<[number, number]> = [];
|
||
for (const poly of polys) for (const ring of poly) for (const p of ring) out.push(p);
|
||
return out;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/** The bounding box [minLon, minLat, maxLon, maxLat] of a geometry. */
|
||
function bboxOf(geometry: { type?: string; coordinates?: unknown }): [number, number, number, number] {
|
||
let minLon = Infinity;
|
||
let minLat = Infinity;
|
||
let maxLon = -Infinity;
|
||
let maxLat = -Infinity;
|
||
for (const [x, y] of allCoords(geometry)) {
|
||
if (x < minLon) minLon = x;
|
||
if (y < minLat) minLat = y;
|
||
if (x > maxLon) maxLon = x;
|
||
if (y > maxLat) maxLat = y;
|
||
}
|
||
return [minLon, minLat, maxLon, maxLat];
|
||
}
|
||
|
||
describe('region-centroids.json', () => {
|
||
const centroids = readJson('public/region-centroids.json') as Record<string, CentroidEntry>;
|
||
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||
const geo = readJson('public/regions.geo.json') as {
|
||
features?: Array<{ id?: string | number; geometry?: { type?: string; coordinates?: unknown } }>;
|
||
};
|
||
const keys = Object.values(manifest);
|
||
|
||
// featureId -> feature, then regionKey -> feature (via the key manifest).
|
||
const featureByKey = new Map<string, { geometry?: { type?: string; coordinates?: unknown } }>();
|
||
for (const f of geo.features ?? []) {
|
||
const key = manifest[String(f.id)];
|
||
if (key) featureByKey.set(key, f);
|
||
}
|
||
|
||
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||
expect(new Set(Object.keys(centroids))).toEqual(new Set(keys));
|
||
expect(Object.keys(centroids)).toHaveLength(1800);
|
||
});
|
||
|
||
it('every entry is a finite { lat, lon, spread } with a non-negative spread, in world bounds', () => {
|
||
for (const [k, v] of Object.entries(centroids)) {
|
||
expect(Number.isFinite(v.lat), `non-finite lat for ${k}`).toBe(true);
|
||
expect(Number.isFinite(v.lon), `non-finite lon for ${k}`).toBe(true);
|
||
expect(Number.isFinite(v.spread), `non-finite spread for ${k}`).toBe(true);
|
||
expect(v.spread >= 0, `negative spread for ${k}`).toBe(true);
|
||
expect(v.lat >= -90 && v.lat <= 90, `lat out of range for ${k}: ${v.lat}`).toBe(true);
|
||
expect(v.lon >= -180 && v.lon <= 180, `lon out of range for ${k}: ${v.lon}`).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('each centroid lies inside its region feature bounding box', () => {
|
||
// The recorded point is the centroid of the LARGEST part, which is a subset
|
||
// of the whole feature, so it must fall inside the feature's overall bbox.
|
||
let checked = 0;
|
||
for (const [key, c] of Object.entries(centroids)) {
|
||
const feat = featureByKey.get(key);
|
||
if (!feat?.geometry) continue;
|
||
const [minLon, minLat, maxLon, maxLat] = bboxOf(feat.geometry);
|
||
const eps = 1e-4; // allow for the 6-dp rounding in the builder
|
||
expect(
|
||
c.lat >= minLat - eps && c.lat <= maxLat + eps,
|
||
`lat ${c.lat} outside bbox [${minLat}, ${maxLat}] for ${key}`,
|
||
).toBe(true);
|
||
expect(
|
||
c.lon >= minLon - eps && c.lon <= maxLon + eps,
|
||
`lon ${c.lon} outside bbox [${minLon}, ${maxLon}] for ${key}`,
|
||
).toBe(true);
|
||
checked++;
|
||
}
|
||
expect(checked).toBe(1800);
|
||
});
|
||
|
||
it('spot-checks a couple of well-known anchors land in the right place', () => {
|
||
// Region keys are sub-national (states/provinces), so we spot-check a few
|
||
// whose geography is unambiguous. A gross error in the centroid method
|
||
// (e.g. averaging across all parts of a large multi-part region, or a
|
||
// lon/lat swap) would land one of these far off.
|
||
const near = (k: string, lat: number, lon: number, tol: number) => {
|
||
const c = centroids[k];
|
||
expect(c, `missing centroid for ${k}`).toBeDefined();
|
||
expect(Math.abs(c!.lat - lat)).toBeLessThan(tol);
|
||
expect(Math.abs(c!.lon - lon)).toBeLessThan(tol);
|
||
};
|
||
near('ain-lqtt', 46.16, 4.8, 0.5); // FR-01 — eastern France
|
||
near('california-kikh', 36.87, -120.67, 0.7); // US-CA — central California
|
||
near('alberta-zopq', 52.09, -116.94, 1.5); // CA-AB — Alberta (large province)
|
||
near('acre-ekzw', -9.49, -71.11, 0.5); // BR-AC — western Brazil
|
||
});
|
||
|
||
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||
const file = path.join(root, 'public', 'region-centroids.json');
|
||
const before = readFileSync(file, 'utf8');
|
||
// The builder is cwd-independent (it resolves the repo root from its own
|
||
// location) and idempotent: same input files + pure turf + fixed rounding
|
||
// + stable insertion order → identical bytes.
|
||
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-centroids.mjs')], {
|
||
cwd: root,
|
||
stdio: 'pipe',
|
||
});
|
||
const after = readFileSync(file, 'utf8');
|
||
expect(after).toBe(before);
|
||
});
|
||
});
|