// Domain types — the single source of truth shared by the frontend (src/), the // backend (server/), and the LLM prompt contract. No runtime dependencies here. // (The single `import type` below erases at compile time, so no runtime // dependency on protocol.ts is introduced — see the GameState.pendingNationOps // doc comment.) import type { NationOp } from './protocol'; /** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */ export interface NationStats { military: number; economy: number; stability: number; population: number; } /** * (v0.28, #46) The 0–100 status indices of a **single region**. Per-region stats * are the unit of record: a nation's aggregate {@link NationStats} is derived as * the area×population-weighted median of the stats of the regions it owns (see * `protocol.deriveNationStats`). Every value is clamped to 0–100 by the engine. */ export interface RegionStats { military: number; economy: number; stability: number; population: number; } export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const; export type StatKey = (typeof STAT_KEYS)[number]; export type NationType = 'major' | 'minor' | 'power' | 'alliance' | 'faction'; /** Direction of a bilateral diplomatic relationship. */ export type RelationStatus = 'ally' | 'neutral' | 'rival' | 'hostile' | 'war'; export const RELATION_STATUSES: RelationStatus[] = [ 'ally', 'neutral', 'rival', 'hostile', 'war', ]; /** * A single nation in the world. `territory` holds **region keys** (see * `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 { id: string; name: string; type: NationType; stats: NationStats; territory: string[]; relations: Record; } /** Categories a narrative event can take. */ export type EventType = | 'military' | 'diplomatic' | 'economic' | 'political' | 'social' | 'disaster' | 'scientific' | 'cultural'; export const EVENT_TYPES: EventType[] = [ 'military', 'diplomatic', 'economic', 'political', 'social', 'disaster', 'scientific', 'cultural', ]; /** * The outcome of a narrative event. `success` / `failure` / `partial` / * `neutral` are the four the LLM is asked to emit. `repelled` (v0.30, #50) is * reserved for the engine: it is emitted automatically when the military hard * gate rejects a contested conquest (an invasion whose attacker was more than * `CONQUEST_MILITARY_MARGIN` below the defender) — the LLM never produces it, * and the client renders it in a distinct muted-red failure tone. */ export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral' | 'repelled'; // --- Map markers (v0.27, #44) ------------------------------------------------- // // Named point markers the LLM can place on the map (cities, factories, armies, // ports, capitals, fleets, rocket launch sites, political/social gatherings). // A marker is anchored to a **region key** (see `public/region-keys.json`): the // engine positions it at that region's precomputed centroid (with a small // deterministic offset) and its `ownerNationId` follows the region's current // owner (recomputed on every turn; `null` when the region is unowned / no // man's land). See protocol.ts for the LLM-facing `TurnResult.markers` // contract and the `markerId` derivation. /** The eight marker categories the model may place (v0.27, #44). */ export type MarkerType = | 'city' | 'factory' | 'army' | 'port' | 'capital' | 'fleet' | 'rocket' | 'gathering'; /** All marker types, in canonical order (used by the LLM contract + map layer). */ export const MARKER_TYPES: MarkerType[] = [ 'city', 'factory', 'army', 'port', 'capital', 'fleet', 'rocket', 'gathering', ]; /** * A named point marker on the map (v0.27, #44). `id` is a stable, * deterministic 4-character code derived from `type + name + regionKey` * (see protocol.markerId) — re-adding the same marker is idempotent and * removing by id is unambiguous. `lat`/`lon` are filled in by the engine from * the region's centroid manifest (never by the model); `ownerNationId` is the * marker's region's current owner (`null` when unowned) and is recomputed by * the engine on every committed turn, so a marker always follows its region. */ export interface MapMarker { id: string; type: MarkerType; name: string; regionKey: string; lat: number; lon: number; ownerNationId: string | null; placedTurn: number; } /** * (v0.33.0, #56) A first-class **war** — a named, ongoing armed conflict * between two or more nations. Unlike the boolean `war` relation (which a * preset or a bare `relations` entry can carry without any structure behind * it), a `War` is an explicit world entity the engine tracks: the LLM * declares it (a turn's `wars.declare`), the engine assigns it a stable id * (`w1`, `w2`, …), the turn and in-game date it was declared, and it stays in * `GameState.wars` for the rest of the game as history after it ends. * * **Semantics of `sides`:** the flat list of every belligerent in the war * (≥2 distinct nation ids). Any two distinct members of an active war's * sides are at war with each other — the engine derives the symmetric `war` * relation for every such pair on every turn (see the engine's * `syncWarRelations`), and downgrades the relation to `hostile` when the war * ends. A mutual-defense ally of a belligerent is dragged into the same list * on declaration (the engine does this automatically). */ export interface War { /** Stable id (`w1`, `w2`, …) assigned by the engine on declaration. */ id: string; /** The belligerent nations (≥2 distinct ids; see the note above). */ sides: string[]; /** The turn number the war was declared on. */ declaredTurn: number; /** The in-game date the war took effect on (`YYYY-MM-DD`). */ declaredDate?: string; /** Optional short war aims the LLM names at declaration. */ aims?: string[]; /** * Lifecycle: `active` until a turn ends the war, then the end outcome — * `armistice` (a negotiated stop) or `capitulation` (one side surrenders) — * or the generic `ended`. Ended wars stay in `GameState.wars` as history. */ status: 'active' | 'armistice' | 'capitulation' | 'ended'; /** Optional region keys the war's front line runs through (reserved). */ front?: string[]; } /** A narrative event the simulation emitted on some turn. */ export interface EventEntry { id: string; turn: number; date: string; nation: string; // nation id the event concerns type: EventType; title: string; text: string; outcome: EventOutcome; } /** * A single item queued in the current round, waiting to be committed by a * "Jump forward". `kind: 'action'` is a free-text player move; `kind: * 'diplomacy'` is a message to a specific nation (`to`). */ export type RoundItem = { kind: 'action' | 'diplomacy'; text: string; to?: string; }; /** * Monotonic revision counter for a game, bumped by the server on every * mutation (queue add/remove, turn commit) and persisted with the state. * Clients use it to tell a fresh state from a stale one: an SSE `state`/`turn` * snapshot (e.g. the on-connect resync) that arrives *after* a fresher REST * response carries a lower version and must be ignored, never applied * (see the client's "never go backwards" rule in src/App.tsx). Legacy games * saved before this field existed are treated as version 0 by consumers. */ export type GameVersion = number; /** The full, persisted state of one game. Saved to SQLite by id. */ export interface GameState { id: string; presetId: string; presetTitle: string; playerNationId: string; turn: number; date: string; nations: Record; events: EventEntry[]; /** * Actions + diplomatic messages queued this round but not yet committed. * Adding an item never advances the turn; a "Jump forward" processes the whole * batch in a single turn and clears this back to `[]`. */ pending: RoundItem[]; /** * Monotonic revision counter (see GameVersion). Bumped on every mutation so * clients can reject stale SSE snapshots; see the "never go backwards" rule * in src/App.tsx. Starts at 1 on game creation. */ version: GameVersion; /** * (v0.22, #16 P3) Region keys that became **unowned** — "no man's land" — * during play: a region that was owned and was released to no owner by a * committed turn (the LLM narrates the cause in that turn's events). This is * deliberately distinct from the many regions a preset never assigns, which * are also unowned but were never *released* mid-game; only the keys in this * list render as the distinct visible no-man's-land grey (see * protocol.regionFillStyle). A region leaves the list when any nation * re-claims it (an ordinary `gain`). Always `[]` on a fresh game — no * man's land can only arise mid-game. Rows saved before v0.22 load with * this defaulted to `[]` (see server/db.ts). */ unowned: string[]; /** * (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has * proposed but the player has not yet resolved: a *pending proposal* list, * as opposed to `pending` (the current round's queued player actions). A * turn NEVER applies one — `applyTurnResult` appends the turn's * `nationOps` to this list, and each proposal survives across turns until * the player resolves it: confirming applies it (a rename changes * `Nation.name` only; a split partitions the nation's region-key territory * into the new nations' territories; a merge folds the inputs' territory * into the survivor) and removes it; rejecting removes it without * applying. Always `[]` on a fresh game; rows saved before v0.23 load with * this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in * protocol.ts; the import there is type-only, so this file still has no * runtime dependencies.) */ pendingNationOps: NationOp[]; /** * (v0.27, #44) Named point markers on the map: the LLM places them (via a * turn's `markers.add`) and removes them (`markers.remove`, by id). The * engine positions each at its region's centroid (deterministic small * offset) and recomputes `ownerNationId` from the region's current owner on * EVERY committed turn (a released region → `null`). Always `[]` on a fresh * game; rows saved before v0.27 load with this defaulted to `[]` (see * server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS. */ markers: MapMarker[]; /** * (v0.28, #46) Per-region stats, keyed by **region key** (see * `public/region-keys.json`): each owned region carries its own four 0–100 * stats, and a nation's aggregate `Nation.stats` is the area×population- * weighted median of the regions it owns (see `protocol.deriveNationStats`). * A region that ends a turn **unowned** (released to no man's land) has NO * entry, and a preset-unassigned region (never owned) has none until first * claimed (re-claiming re-inits it to the gainer's stats). Always present on a * fresh game (every owned region seeded with its owner's initial stats); a * legacy SQLite row without the field is migrated at load (see server/db.ts) * — additive, no data wipe. */ regionStats: Record; /** * (v0.33.0, #56) First-class war entities (see {@link War}): declared by a * turn's `wars.declare` (the engine assigns the id, declared turn/date, and * drags in mutual-defense allies) and closed by `wars.end` (armistice / * capitulation). The engine DERIVES the `war` relation from the ACTIVE * wars on every turn (two nations are in a `war` relation iff they are both * belligerents of ≥1 active war), so the boolean relation and the war * entities can never drift apart. A bare preset `war` relation with no * active war behind it stays as-is (an implicit, unnamed war — never * force-created into an entity). Always `[]` on a fresh game; rows saved * before v0.33 load with this defaulted to `[]` (see server/db.ts) — * additive, no data wipe. */ wars: War[]; } /** * A scenario the player can start. One JSON file per preset (presets/.json). * `worldBackground` + `simulationRules` become the LLM system prompt. */ export interface Preset { id: string; title: string; startDate: string; summary: string; worldBackground: string; simulationRules: string[]; nations: Nation[]; } /** The lightweight manifest entry used to render the preset picker cards. */ export interface PresetMeta { id: string; title: string; startDate: string; summary: string; }