Files
History/shared/protocol.ts
T
kamilkrzywda 06cdbf3d81
CI / build + unit + e2e (pull_request) Successful in 1m18s
Switch map to sub-national regions (v0.12.0)
Part of #16
2026-10-12 01:07:33 +00:00

236 lines
7.0 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.
// 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<string, RegionOwner>;
/**
* 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<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;
}
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<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,
};
}