// 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 region keys (see `public/region-keys.json`) this turn. */ export interface TerritoryChange { nation: string; gain: string[]; lose: string[]; } // --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM // proposes, player confirms" model) ------------------------------------------ // // Unlike stat / relation / territory deltas, a NationOp proposed in a turn is // NOT applied by that turn. It is stored as a *pending proposal* in // `GameState.pendingNationOps` and only takes effect when the player confirms // it (optionally editing it) or is dropped when the player rejects it. All // three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b). /** * The identity reference a nation is addressed by inside a {@link NationOp}. * The LLM may use either the nation's stable `id` (the `nations` record key, * e.g. `f`) or its human-readable `name` (e.g. `France`); the engine resolves * it to a concrete `nationId` (id first, then a unique name match — see the * engine's `resolveNationRef`). */ /** * A **rename** proposal: change a nation's human-readable `name`. Only the * `name` changes — the stable `id`, its territory keys, its relations (keyed * by id), and the player pointer are all left untouched, so nothing downstream * breaks. `to` is the new display name. */ export interface RenameOp { kind: 'rename'; /** The target nation's id or current name (see the note above). */ nationRef: string; /** The new display name to apply when the player confirms. */ to: string; } /** * A **split** proposal (v0.24, #16 P4b). Describes the new nations a source * nation is divided into. On confirm the source is dissolved: each part gets a * fresh id, the region keys it was given (restricted to keys the source * actually owns — first claim wins), fresh baseline stats, and a copy of the * source's relations; any of the source's unclaimed keys go to `parts[0]`. * When the source is the player's own nation the player chooses which part * they lead (the confirm request's `edits.playerPart`, default `parts[0]`). */ export interface SplitOp { kind: 'split'; /** The id/name of the nation being split. */ nationRef: string; /** The new nations created from the split. */ parts: Array<{ name: string; territory?: string[] }>; } /** * A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single * surviving nation. On confirm the survivor keeps its id (renamed to `into`, * or to the confirm request's `edits.name`), its territory becomes the union * of all inputs' territories, it keeps its own stats, and it inherits the * union of all inputs' relations (its own winning per third party, else the * first input's); every non-survivor input is removed. If the player's nation * was a non-survivor input the player pointer is re-pointed to the survivor. */ export interface MergeOp { kind: 'merge'; /** The id/name of every nation folded into the result. */ nationRefs: string[]; /** The id/name of the nation that survives and keeps its identity. */ survivingRef: string; /** The name to give the merged nation. */ into: string; } /** * A structural operation on the set of nations that the LLM may propose in a * turn (`TurnResult.nationOps`). See the section comment above for the hybrid * propose→confirm semantics. All three kinds are implemented: rename (P4a), * and split / merge (P4b, v0.24). */ export type NationOp = RenameOp | SplitOp | MergeOp; /** 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[]; /** * (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" — * this turn. The engine removes each key from its current (sole) owner so it * ends the turn ownerless and marks it in `GameState.unowned`; the LLM * narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is * NOT expressed here: a nation re-claims an unowned region with an ordinary * `gain` on that key. Only keys a nation currently owns can be released; * empty on normal turns. */ unowned: string[]; /** * (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this * turn. These are NOT applied by the turn: each is appended to * `GameState.pendingNationOps` as a proposal awaiting the player's * confirm/reject. All three kinds (rename, split, merge) are implemented — * a confirmed rename changes `Nation.name`; a confirmed split dissolves the * source into its parts; a confirmed merge folds its inputs into the * survivor. Empty on turns where the model proposes none. */ nationOps: NationOp[]; } /** Per-region map payload: region key -> owning nation + its stable color. */ export interface RegionOwner { color: string; owner: 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; /** * Look up the owner record from the authoritative RegionMap by a RegionMap key. * The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one * per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as * `moselle-rygy`, never a raw feature id such as `FR-57` and never a * 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* * resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below, * which reads the feature's stamped `properties.regionKey` — 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, and the * RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1), * so a feature's owner is found via its `properties.regionKey` — the region key * the client stamps from `public/region-keys.json` by the feature's stable id * (see WorldMap) — and then `regionOwner`. This is the single shared source of * truth for that resolution, so the "use `regionKey`, 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/regionKey or an unowned region. */ export function mapFeatureOwner( regions: RegionMap, feature: { properties?: { regionKey?: unknown } } | null | undefined, ): RegionOwner | undefined { const regionKey = feature?.properties?.regionKey; return regionOwner(regions, typeof regionKey === 'string' ? regionKey : ''); } /** 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; } /** * (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its * index in `game.pendingNationOps`. A rename takes effect (the player may * override the proposed name via `edits.name`); a split and a merge are fully * applied (v0.24 P4b). No LLM call, no turn advance. */ export interface ConfirmNationOpRequest { index: number; /** * Optional player edits applied on confirm. * - `name`: the final display name — overrides a rename's `to`, a merge's * `into`, or (for a split) the name of the part the player leads. * - `playerPart`: for a split of the player's own nation, which part (index * into the op's `parts`) the player will lead (default `0` when missing or * out of range, so the player is never orphaned). */ edits?: { name?: string; playerPart?: number }; } export interface ConfirmNationOpResponse { game: GameState; } /** * (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index * in `game.pendingNationOps` without applying it. No LLM call, no turn advance. */ export interface RejectNationOpRequest { index: number; } export interface RejectNationOpResponse { 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%)`; } // --- No man's land (v0.22, #16 P3) — the map presentation contract --- // // An unowned region has two distinct looks, and the difference is NOT in the // RegionMap (a key absent there is simply unowned): it is decided by the // client from `GameState.unowned` (the keys released mid-game). The constants // and the one decision function live here — shared and pure — so the // client's choropleth is the only consumer and the color contract is testable // from the server-side tests (shared/*.test.ts is not in the vitest glob). /** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */ export const NO_MANS_LAND_FILL = '#3f3f46'; /** The border of a no-man's-land region (brighter than a plain unowned one). */ export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)'; /** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */ export const UNOWNED_FILL = 'rgba(148,163,184,0.05)'; /** The border of a plain unowned region. */ export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)'; /** The border of an owned region (brighter than unowned). */ export const OWNED_STROKE = 'rgba(255,255,255,0.30)'; /** The hover-tooltip sub-line for a region with no owning nation. */ export function unownedTooltipLabel(isNoMansLand: boolean): string { return isNoMansLand ? "No man's land" : 'No owner'; } /** * The fill + border for one sub-region, given its RegionMap entry * (`undefined` = unowned) and whether its key is no man's land (released * mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section * comment above for why the distinction is not encoded in the RegionMap. */ export function regionFillStyle( owner: RegionOwner | undefined, isNoMansLand: boolean, ): { fill: string; stroke: string } { if (owner) return { fill: owner.color, stroke: OWNED_STROKE }; return isNoMansLand ? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE } : { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE }; } /** 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, }; }