Files
History/shared/types.ts
T
kamilkrzywda b5e2229ee2
CI / build + unit + e2e (pull_request) Successful in 2m32s
feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
Promote the bare 'war' relation to a named War entity in GameState.wars:
- War: { id, sides[], declaredTurn, declaredDate?, aims?, status, front? }
- TurnResult.wars: { declare: WarDeclareOp[], end: WarEndOp[] }
- syncWarRelations: derive 'war' from active wars, fallback to 'hostile' on end
- Mutual-defense drag-in: belligerent allies added to sides (one level)
- Bare 'war' back-compat: never downgraded, never force-created
- Stub: empty wars (e2e unaffected); ~30 new unit tests

v0.33.0
2026-10-15 15:47:06 +00:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, RelationStatus>;
}
/** 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<string, Nation>;
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<string, RegionStats>;
/**
* (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/<id>.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;
}