// 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; /** * 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; 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(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, }; }