Region-level ownership foundation (Part 1 of #16) (v0.19.0) #34
+26
-13
@@ -53,11 +53,12 @@ export function buildSystemPrompt(preset: Preset): string {
|
|||||||
' ],',
|
' ],',
|
||||||
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
|
||||||
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
|
||||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
|
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'HARD CONSTRAINTS',
|
'HARD CONSTRAINTS',
|
||||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
'- Use only nation ids and region keys that exist in the provided state.',
|
||||||
|
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
|
||||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||||
'- Keep every stat value between 0 and 100.',
|
'- Keep every stat value between 0 and 100.',
|
||||||
@@ -75,7 +76,11 @@ export function buildSystemPrompt(preset: Preset): string {
|
|||||||
* model to simulate the combined consequences of the whole batch (or to let the
|
* model to simulate the combined consequences of the whole batch (or to let the
|
||||||
* world advance on its own when the batch is empty) and return one TurnResult.
|
* world advance on its own when the batch is empty) and return one TurnResult.
|
||||||
*/
|
*/
|
||||||
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
export function buildUserPrompt(
|
||||||
|
state: GameState,
|
||||||
|
items: RoundItem[],
|
||||||
|
regionReference?: Record<string, string>,
|
||||||
|
): string {
|
||||||
const player = state.nations[state.playerNationId];
|
const player = state.nations[state.playerNationId];
|
||||||
const playerName = player?.name ?? state.playerNationId;
|
const playerName = player?.name ?? state.playerNationId;
|
||||||
const playerActions = items.map((i) =>
|
const playerActions = items.map((i) =>
|
||||||
@@ -88,7 +93,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
|||||||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||||||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||||||
|
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
state: {
|
state: {
|
||||||
turn: state.turn,
|
turn: state.turn,
|
||||||
date: state.date,
|
date: state.date,
|
||||||
@@ -110,6 +115,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
|||||||
pending: items,
|
pending: items,
|
||||||
roundInstruction,
|
roundInstruction,
|
||||||
};
|
};
|
||||||
|
if (regionReference) payload.regions = regionReference;
|
||||||
return JSON.stringify(payload, null, 2);
|
return JSON.stringify(payload, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +289,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
|||||||
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
|
||||||
* events, apply stat changes (clamped), symmetric relations, and territory moves.
|
* events, apply stat changes (clamped), symmetric relations, and territory moves.
|
||||||
*/
|
*/
|
||||||
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
|
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
|
||||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||||
const actingTurn = state.turn; // this action resolves the current turn
|
const actingTurn = state.turn; // this action resolves the current turn
|
||||||
@@ -309,15 +315,18 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
|
|||||||
for (const t of result.territoryChanges) {
|
for (const t of result.territoryChanges) {
|
||||||
const n = next.nations[t.nation];
|
const n = next.nations[t.nation];
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
for (const code of t.gain) {
|
const valid = (k: string) => !validKeys || validKeys.has(k);
|
||||||
|
for (const key of t.gain) {
|
||||||
|
if (!valid(key)) continue; // ignore a key that is not a real region
|
||||||
for (const other of Object.values(next.nations)) {
|
for (const other of Object.values(next.nations)) {
|
||||||
const i = other.territory.indexOf(code);
|
const i = other.territory.indexOf(key);
|
||||||
if (i !== -1) other.territory.splice(i, 1);
|
if (i !== -1) other.territory.splice(i, 1);
|
||||||
}
|
}
|
||||||
if (!n.territory.includes(code)) n.territory.push(code);
|
if (!n.territory.includes(key)) n.territory.push(key);
|
||||||
}
|
}
|
||||||
for (const code of t.lose) {
|
for (const key of t.lose) {
|
||||||
const i = n.territory.indexOf(code);
|
if (!valid(key)) continue;
|
||||||
|
const i = n.territory.indexOf(key);
|
||||||
if (i !== -1) n.territory.splice(i, 1);
|
if (i !== -1) n.territory.splice(i, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,10 +376,14 @@ export async function runTurn(opts: {
|
|||||||
preset: Preset;
|
preset: Preset;
|
||||||
state: GameState;
|
state: GameState;
|
||||||
items: RoundItem[];
|
items: RoundItem[];
|
||||||
|
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
|
||||||
|
regionReference?: Record<string, string>;
|
||||||
|
/** Set of valid region keys; territory changes with other keys are ignored. */
|
||||||
|
validRegionKeys?: ReadonlySet<string>;
|
||||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, items } = opts;
|
const { llm, preset, state, items, regionReference, validRegionKeys } = opts;
|
||||||
const system = buildSystemPrompt(preset);
|
const system = buildSystemPrompt(preset);
|
||||||
const user = buildUserPrompt(state, items);
|
const user = buildUserPrompt(state, items, regionReference);
|
||||||
const nudge =
|
const nudge =
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||||
|
|
||||||
@@ -398,7 +411,7 @@ export async function runTurn(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state: applyTurnResult(state, result), result };
|
return { state: applyTurnResult(state, result, validRegionKeys), result };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { createLlmClient } from './llm';
|
|||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
|
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
@@ -49,6 +50,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
|||||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
const presetStore = createPresetStore(presetsDir);
|
||||||
|
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||||
|
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||||
|
loadRegions(root);
|
||||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
const useStub = process.env.LLM_STUB === '1';
|
const useStub = process.env.LLM_STUB === '1';
|
||||||
@@ -121,6 +125,10 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
pending: [],
|
pending: [],
|
||||||
version: 1, // the first saved revision; every later mutation bumps it
|
version: 1, // the first saved revision; every later mutation bumps it
|
||||||
};
|
};
|
||||||
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
|
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||||
|
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||||||
db.save(state);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
});
|
});
|
||||||
@@ -227,6 +235,8 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
preset,
|
preset,
|
||||||
state: game,
|
state: game,
|
||||||
items: game.pending,
|
items: game.pending,
|
||||||
|
regionReference: referenceForKeys(allTerritoryKeys(game)),
|
||||||
|
validRegionKeys: validKeys(),
|
||||||
});
|
});
|
||||||
db.save(state);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
+7
-5
@@ -1,6 +1,8 @@
|
|||||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
// Build the choropleth payload from a GameState: every region key (see
|
||||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
// public/region-keys.json) maps to its owning nation plus that nation's stable
|
||||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
// color (see shared.nationColor). The client merges this onto the sub-national
|
||||||
|
// regions.geo.json (resolving each feature by its own region key) and calls
|
||||||
|
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
|
||||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
@@ -8,8 +10,8 @@ export function buildRegionMap(state: GameState): RegionMap {
|
|||||||
const map: RegionMap = {};
|
const map: RegionMap = {};
|
||||||
for (const [id, nation] of Object.entries(state.nations)) {
|
for (const [id, nation] of Object.entries(state.nations)) {
|
||||||
const color = nationColor(id);
|
const color = nationColor(id);
|
||||||
for (const code of nation.territory) {
|
for (const key of nation.territory) {
|
||||||
map[code] = { color, owner: nation.name, ownerId: id };
|
map[key] = { color, owner: nation.name, ownerId: id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||||
|
// (public/region-keys.json: stable feature id -> region key) and the sub-national
|
||||||
|
// geo (public/regions.geo.json: feature id -> { name, parent }), then joins them
|
||||||
|
// into the lookups the region-level ownership model (v0.19, #16 P1) needs:
|
||||||
|
//
|
||||||
|
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions
|
||||||
|
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||||||
|
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
|
||||||
|
//
|
||||||
|
// Deliberately turf-free: it reads the two JSON files with plain JSON.parse and
|
||||||
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into the
|
||||||
|
// tsc program and breaks WorldMap.tsx; see the v0.17 changelog note). Loaded once
|
||||||
|
// at boot; the accessors below are pure data reads thereafter.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
|
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||||
|
interface RegionInfo {
|
||||||
|
name: string;
|
||||||
|
parent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegionData {
|
||||||
|
/** stable feature id -> region key (from public/region-keys.json). */
|
||||||
|
idToKey: Record<string, string>;
|
||||||
|
/** region key -> { name, parent }. */
|
||||||
|
keyToInfo: Record<string, RegionInfo>;
|
||||||
|
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
|
||||||
|
keysByCountry: Record<string, string[]>;
|
||||||
|
/** the set of all region keys. */
|
||||||
|
validKeys: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: RegionData | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load (or reload) the region-key manifest + geo from `root`. Idempotent.
|
||||||
|
* Returns true when both files loaded cleanly. A missing/broken file clears the
|
||||||
|
* data and returns false, so the server still boots and degrades gracefully
|
||||||
|
* (expansion becomes a no-op, ownership falls back to the raw input keys).
|
||||||
|
*/
|
||||||
|
export function loadRegions(root: string): boolean {
|
||||||
|
const keyPath = path.join(root, 'public', 'region-keys.json');
|
||||||
|
const geoPath = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
if (!existsSync(keyPath) || !existsSync(geoPath)) {
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||||
|
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
|
};
|
||||||
|
const keyToInfo: Record<string, RegionInfo> = {};
|
||||||
|
const keysByCountry: Record<string, string[]> = {};
|
||||||
|
const validKeys = new Set<string>();
|
||||||
|
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 name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
||||||
|
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
||||||
|
keyToInfo[key] = { name, parent };
|
||||||
|
validKeys.add(key);
|
||||||
|
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||||
|
}
|
||||||
|
data = { idToKey, keyToInfo, keysByCountry, validKeys };
|
||||||
|
return validKeys.size > 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region data', err);
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */
|
||||||
|
export function resetRegions(): void {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the manifest + geo loaded cleanly. */
|
||||||
|
export function hasRegionData(): boolean {
|
||||||
|
return data !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a nation's ISO_A3 country list to the region keys of all its sub-regions
|
||||||
|
* (file order, deduped). This is what turns a preset's country-level territory
|
||||||
|
* into region-level ownership at game creation. Returns the input unchanged when
|
||||||
|
* no region data is loaded (graceful degradation — behaves like the old model).
|
||||||
|
*/
|
||||||
|
export function expandCountriesToKeys(countries: string[]): string[] {
|
||||||
|
if (!data) return countries;
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of countries) {
|
||||||
|
for (const key of data.keysByCountry[c] ?? []) {
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
|
||||||
|
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
|
||||||
|
* passes the game's owned-region universe) so the prompt stays bounded.
|
||||||
|
*/
|
||||||
|
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (!data) return out;
|
||||||
|
for (const key of keys) {
|
||||||
|
const info = data.keyToInfo[key];
|
||||||
|
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The region key's human name (for the dossier), or undefined if unknown. */
|
||||||
|
export function regionName(key: string): string | undefined {
|
||||||
|
return data?.keyToInfo[key]?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The union of every nation's territory region keys (the game's region universe). */
|
||||||
|
export function allTerritoryKeys(state: GameState): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const n of Object.values(state.nations)) {
|
||||||
|
for (const key of n.territory) out.add(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
|
||||||
|
export function validKeys(): ReadonlySet<string> {
|
||||||
|
return data ? data.validKeys : new Set<string>();
|
||||||
|
}
|
||||||
+27
-20
@@ -23,7 +23,7 @@ export interface RelationChange {
|
|||||||
set: RelationStatus;
|
set: RelationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A nation gains/loses ISO_A3 regions this turn. */
|
/** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */
|
||||||
export interface TerritoryChange {
|
export interface TerritoryChange {
|
||||||
nation: string;
|
nation: string;
|
||||||
gain: string[];
|
gain: string[];
|
||||||
@@ -48,26 +48,32 @@ export interface TurnResult {
|
|||||||
territoryChanges: TerritoryChange[];
|
territoryChanges: TerritoryChange[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
|
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||||
export interface RegionOwner {
|
export interface RegionOwner {
|
||||||
color: string;
|
color: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* The authoritative ownership map: a **region key** (see `public/region-keys.json`)
|
||||||
|
* -> the owning nation + its stable color. Owned at sub-region granularity (v0.19,
|
||||||
|
* #16 P1); a key absent from this map is unowned (rendered grey).
|
||||||
|
*/
|
||||||
export type RegionMap = Record<string, RegionOwner>;
|
export type RegionMap = Record<string, RegionOwner>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
||||||
* The RegionMap is keyed by the **parent country's** ISO_A3 code (ownership and
|
* The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one
|
||||||
* the LLM stay at country granularity — see #16), so `key` is a country code such
|
* per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as
|
||||||
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
|
* `moselle-rygy`, never a raw feature id such as `FR-57` and never a
|
||||||
* empty/missing key or an unowned country.
|
* parent-country ISO_A3. Returns `undefined` for an empty/missing key or an
|
||||||
|
* unowned region.
|
||||||
*
|
*
|
||||||
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
||||||
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
||||||
* which reads the feature's `properties.parent` — never the repacked top-level
|
* which reads the feature's stamped `properties.regionKey` — never the repacked
|
||||||
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
|
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6)
|
||||||
* the sub-region id.
|
* and never the sub-region id.
|
||||||
*/
|
*/
|
||||||
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||||||
if (!key) return undefined;
|
if (!key) return undefined;
|
||||||
@@ -76,22 +82,23 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined):
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the owning-nation record for a **rendered map feature** from the
|
* Resolve the owning-nation record for a **rendered map feature** from the
|
||||||
* authoritative RegionMap. The choropleth renders sub-national regions, but the
|
* authoritative RegionMap. The choropleth renders sub-national regions, and the
|
||||||
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
|
* RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1),
|
||||||
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
|
* so a feature's owner is found via its `properties.regionKey` — the region key
|
||||||
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
|
* the client stamps from `public/region-keys.json` by the feature's stable id
|
||||||
* `regionOwner`. This is the single shared source of truth for that resolution,
|
* (see WorldMap) — and then `regionOwner`. This is the single shared source of
|
||||||
* so the "use `parent`, never the sub-region `id`/repacked `feature.id`" rule
|
* truth for that resolution, so the "use `regionKey`, never the sub-region `id`
|
||||||
* lives in one tested place rather than in the React component.
|
* / repacked `feature.id`" rule lives in one tested place rather than in the
|
||||||
|
* React component.
|
||||||
*
|
*
|
||||||
* Returns `undefined` for a missing feature/parent or an unowned country.
|
* Returns `undefined` for a missing feature/regionKey or an unowned region.
|
||||||
*/
|
*/
|
||||||
export function mapFeatureOwner(
|
export function mapFeatureOwner(
|
||||||
regions: RegionMap,
|
regions: RegionMap,
|
||||||
feature: { properties?: { parent?: unknown } } | null | undefined,
|
feature: { properties?: { regionKey?: unknown } } | null | undefined,
|
||||||
): RegionOwner | undefined {
|
): RegionOwner | undefined {
|
||||||
const parent = feature?.properties?.parent;
|
const regionKey = feature?.properties?.regionKey;
|
||||||
return regionOwner(regions, typeof parent === 'string' ? parent : '');
|
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||||
|
|||||||
+4
-2
@@ -26,8 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
|
* A single nation in the world. `territory` holds **region keys** (see
|
||||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
|
||||||
|
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
|
||||||
|
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
|
||||||
*/
|
*/
|
||||||
export interface Nation {
|
export interface Nation {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user