Files
History/shared/protocol.ts
T
2026-10-11 14:36:35 +00:00

185 lines
4.8 KiB
TypeScript
Raw Permalink 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>;
/** 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;
}
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;
}
/**
* 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,
};
}