412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
// 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<string, RegionOwner>;
|
||
|
||
/**
|
||
* 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<string, never>;
|
||
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<T>(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,
|
||
};
|
||
}
|