// 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 ISO_A3 regions this turn. */ 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: ISO_A3 -> owning nation + its stable color. */ export interface RegionOwner { color: string; owner: string; ownerId: string; } export type RegionMap = Record; /** * 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 LLM stay at country granularity — see #16), so `key` is a country code such * as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an * empty/missing key or an unowned country. * * 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.parent` — never the repacked top-level * `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never * the sub-region id. */ 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. The choropleth renders sub-national regions, but the * RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own * id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the * client stamps from the GeoJSON `parent` property — see WorldMap) and then * `regionOwner`. This is the single shared source of truth for that resolution, * so the "use `parent`, never the sub-region `id`/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. */ export function mapFeatureOwner( regions: RegionMap, feature: { properties?: { parent?: unknown } } | null | undefined, ): RegionOwner | undefined { const parent = feature?.properties?.parent; return regionOwner(regions, typeof parent === 'string' ? parent : ''); } /** 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, }; }