Move territory from whole-country (ISO_A3) to individual sub-region id granularity (PR 3 of #16): - new server/regions.ts (turf-free): loadRegions (boot), expandTerritory (game creation), migrateTerritoryToRegions (legacy games on db.load), regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback - game creation expands each preset nation's ISO_A3 list to its sub-region ids; pre-v0.19 saved games migrate transparently on read (idempotent) - LLM contract: territoryChanges gain/lose sub-region ids (one region at a time; gain moves out of the prior owner; lost-without-gainer = unowned); user prompt carries a top-level regions reference table ({id: 'name (parent)'} over the state's territory ids) - WorldMap: choropleth fill per sub-region (countries can split between nations), ownership via properties.regionId through the shared mapFeatureOwner, flash lights exactly the changed features (featuresByRegionIds) - deterministic stub: one region-level move per non-empty round (player annexes the lexicographically-first region of the first other nation) - tests: new server/regions.test.ts (real geo + fallback + seams); map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k tokens — far under the 80k frontier limit, so the full regions table is always sent (no frontier-limited mode needed). Part of #16
247 lines
7.6 KiB
TypeScript
247 lines
7.6 KiB
TypeScript
// Client <-> server protocol types + a few shared pure helpers (single source of
|
||
// truth). The LLM is asked to return exactly the `TurnResult` shape; the engine
|
||
// validates/clamps it before applying it to a GameState.
|
||
import type {
|
||
EventEntry,
|
||
EventOutcome,
|
||
EventType,
|
||
GameState,
|
||
Nation,
|
||
RelationStatus,
|
||
StatKey,
|
||
} from './types';
|
||
|
||
/** A numeric change to one of a nation's 0–100 stats. */
|
||
export type StateChange =
|
||
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
||
| { nation: string; stat: StatKey; op: 'add'; value: number };
|
||
|
||
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
|
||
export interface RelationChange {
|
||
a: string;
|
||
b: string;
|
||
set: RelationStatus;
|
||
}
|
||
|
||
/**
|
||
* A nation gains/loses **sub-region ids** this turn (e.g. `FR-57`), not
|
||
* whole-country ISO_A3 codes. Each id identifies a single sub-region, so a
|
||
* border can shift one region at a time; a gained region is moved out of
|
||
* whichever nation currently owns it, and a lost region with no matching
|
||
* gainer simply becomes unowned (no-man's-land).
|
||
*/
|
||
export interface TerritoryChange {
|
||
nation: string;
|
||
gain: string[];
|
||
lose: string[];
|
||
}
|
||
|
||
/** One narrative event the LLM proposes for this turn. */
|
||
export interface TurnEventSpec {
|
||
nation: string;
|
||
type: EventType;
|
||
title: string;
|
||
text: string;
|
||
outcome: EventOutcome;
|
||
}
|
||
|
||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||
export interface TurnResult {
|
||
date: string;
|
||
events: TurnEventSpec[];
|
||
stateChanges: StateChange[];
|
||
relations: RelationChange[];
|
||
territoryChanges: TerritoryChange[];
|
||
}
|
||
|
||
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
|
||
export interface RegionOwner {
|
||
color: string;
|
||
owner: string;
|
||
ownerId: string;
|
||
}
|
||
/**
|
||
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
|
||
* Every sub-region any nation owns appears here; a sub-region in no nation's
|
||
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
|
||
*/
|
||
export type RegionMap = Record<string, RegionOwner>;
|
||
|
||
/**
|
||
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
||
* The RegionMap is keyed by the **sub-region's own id** (e.g. `FR-57` — never a
|
||
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
|
||
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
|
||
* key or an unowned region (one in no nation's territory).
|
||
*
|
||
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
||
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner}
|
||
* below, which reads the feature's `properties.regionId` — never the repacked
|
||
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
|
||
* #6).
|
||
*/
|
||
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||
if (!key) return undefined;
|
||
return regions[key];
|
||
}
|
||
|
||
/**
|
||
* Resolve the owning-nation record for a **rendered map feature** from the
|
||
* authoritative RegionMap. Ownership is now at **sub-region** granularity, so a
|
||
* feature's owner is found via its **own sub-region id** — the client stamps
|
||
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
|
||
* we look that up with `regionOwner`. This is the single shared source of truth
|
||
* for that resolution, so the "use `properties.regionId`, never the repacked
|
||
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" rule
|
||
* lives in one tested place rather than in the React component.
|
||
*
|
||
* Returns `undefined` for a missing feature/regionId or an unowned region.
|
||
*/
|
||
export function mapFeatureOwner(
|
||
regions: RegionMap,
|
||
feature: { properties?: { regionId?: unknown } } | null | undefined,
|
||
): RegionOwner | undefined {
|
||
const regionId = feature?.properties?.regionId;
|
||
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
|
||
}
|
||
|
||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||
export type SseMessage =
|
||
| { type: 'state'; state: GameState }
|
||
| { type: 'turn'; state: GameState; result: TurnResult }
|
||
| { type: 'error'; message: string };
|
||
|
||
// --- API request / response shapes (REST) ---
|
||
|
||
export interface CreateGameRequest {
|
||
presetId: string;
|
||
playerNationId: string;
|
||
}
|
||
export interface CreateGameResponse {
|
||
game: GameState;
|
||
}
|
||
|
||
/** Queue a free-text player action onto the pending round (no turn advance). */
|
||
export interface AddActionRequest {
|
||
text: string;
|
||
}
|
||
export interface AddActionResponse {
|
||
game: GameState;
|
||
}
|
||
|
||
/** Queue a diplomatic message onto the pending round (no turn advance). */
|
||
export interface AddDiplomacyRequest {
|
||
to: string;
|
||
message: string;
|
||
}
|
||
export interface AddDiplomacyResponse {
|
||
game: GameState;
|
||
}
|
||
|
||
/**
|
||
* "Jump forward" — commit the whole pending round to the model in a single
|
||
* turn. The body is empty; everything being committed lives in `game.pending`.
|
||
*/
|
||
export type JumpRequest = Record<string, never>;
|
||
export interface TurnResponse {
|
||
state: GameState;
|
||
result: TurnResult;
|
||
}
|
||
|
||
/** Remove one queued item from the pending round, by its index in `game.pending`. */
|
||
export interface RemoveQueueItemRequest {
|
||
index: number;
|
||
}
|
||
export interface RemoveQueueItemResponse {
|
||
game: GameState;
|
||
}
|
||
|
||
export interface AdvisorRequest {
|
||
/** Optional prompt to steer the brainstorm. */
|
||
prompt?: string;
|
||
}
|
||
export interface AdvisorResponse {
|
||
suggestions: string[];
|
||
}
|
||
|
||
export interface GameSummary {
|
||
id: string;
|
||
presetId: string;
|
||
presetTitle: string;
|
||
playerNationId: string;
|
||
turn: number;
|
||
date: string;
|
||
}
|
||
|
||
/** Response for deleting a saved game (see `DELETE /api/games/:id`). */
|
||
export interface DeleteGameResponse {
|
||
deleted: boolean;
|
||
}
|
||
|
||
/**
|
||
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
||
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
||
* the server's RegionMap and any client-side coloring always agree.
|
||
*/
|
||
export function nationColor(id: string): string {
|
||
let h = 2166136261;
|
||
for (let i = 0; i < id.length; i++) {
|
||
h = Math.imul(h ^ id.charCodeAt(i), 16777619) >>> 0;
|
||
}
|
||
const hue = h % 360;
|
||
return `hsl(${hue} 58% 46%)`;
|
||
}
|
||
|
||
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
|
||
export function makeEvent(
|
||
spec: TurnEventSpec,
|
||
turn: number,
|
||
date: string,
|
||
): EventEntry {
|
||
return {
|
||
id: `${turn}-${spec.nation}-${spec.type}-${Math.random().toString(36).slice(2, 8)}`,
|
||
turn,
|
||
date,
|
||
nation: spec.nation,
|
||
type: spec.type,
|
||
title: spec.title,
|
||
text: spec.text,
|
||
outcome: spec.outcome,
|
||
};
|
||
}
|
||
|
||
/** Defensive helpers used by both the engine (server) and tests. */
|
||
export function clampStat(v: number): number {
|
||
if (!Number.isFinite(v)) return 0;
|
||
return Math.max(0, Math.min(100, Math.round(v)));
|
||
}
|
||
|
||
/** True if the string looks like a `YYYY-MM-DD` date. */
|
||
export function isDateLike(s: string): boolean {
|
||
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
|
||
}
|
||
|
||
/** Advance a `YYYY-MM-DD` string by N days (UTC). Invalid input returns as-is. */
|
||
export function addDays(date: string, days: number): string {
|
||
if (!isDateLike(date)) return date;
|
||
const d = new Date(`${date}T00:00:00Z`);
|
||
d.setUTCDate(d.getUTCDate() + days);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
export function deepClone<T>(v: T): T {
|
||
return JSON.parse(JSON.stringify(v)) as T;
|
||
}
|
||
|
||
/** The subset of a Nation the prompt needs (keeps the LLM context small). */
|
||
export function nationForPrompt(n: Nation) {
|
||
return {
|
||
id: n.id,
|
||
name: n.name,
|
||
type: n.type,
|
||
stats: n.stats,
|
||
territory: n.territory,
|
||
relations: n.relations,
|
||
};
|
||
}
|