Nation-op proposals (P4a, #16): protocol types, pendingNationOps state, engine propose/resolve (stage 1)

- shared/protocol.ts: RenameOp/SplitOp/MergeOp + NationOp union; TurnResult.nationOps (required); ConfirmNationOp/RejectNationOp request+response types
- shared/types.ts: GameState.pendingNationOps (type-only import of NationOp — no runtime cycle)
- server/engine.ts: validateNationOps parser wired into validateTurnResult; isMeaningful counts proposals; applyTurnResult appends to the surviving pending list (never applies); runTurn fallback literal; system prompt gains the nationOps JSON field + rename proposal rule (both modes); new resolveNationRef/applyNationOp/confirmNationOp/rejectNationOp (id-first then unique case-insensitive name; rename changes Nation.name only; split/merge no-op until P4b; index validation returns null for 400)
- server/db.ts: legacy rows load with pendingNationOps defaulted to []
- server/index.ts: fresh games start with pendingNationOps: []
- server/stub.ts: deterministicTurn returns nationOps: [] (the stub never proposes — unit-only coverage, matching P3)
- tests: fixture updates (engine/db/map/regions) + new suites for resolveNationRef, validateNationOps, proposal lifecycle, confirm/reject (151 tests total)
This commit is contained in:
2026-10-14 12:47:58 +00:00
parent edaa8f55d1
commit e9fa267fd0
10 changed files with 615 additions and 12 deletions
+103
View File
@@ -30,6 +30,74 @@ export interface TerritoryChange {
lose: string[];
}
// --- Nation-management ops (v0.23, #16 P4a: 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. Rename
// is implemented in v0.23 (P4a); split and merge are declared here so the union
// is stable for P4b, and are defensively accepted-but-not-applied until then.
/**
* 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 (declared for #16 P4b; **not** applied by the engine yet
* — it is accepted into the pending list, but confirming it is a no-op until
* P4b lands). Describes the new nations a source nation is divided into.
*/
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 (declared for #16 P4b; **not** applied by the engine yet
* — accepted into the pending list, no-op on confirm until P4b). Folds several
* nations into a single surviving nation.
*/
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. Rename is implemented (P4a); split/merge are
* declared for P4b and are a safe no-op until then.
*/
export type NationOp = RenameOp | SplitOp | MergeOp;
/** One narrative event the LLM proposes for this turn. */
export interface TurnEventSpec {
nation: string;
@@ -56,6 +124,15 @@ export interface TurnResult {
* empty on normal turns.
*/
unowned: string[];
/**
* (v0.23, #16 P4a) 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. Rename is the only kind the engine currently applies;
* split/merge are declared for P4b and are accepted-but-dropped. Empty on
* turns where the model proposes none.
*/
nationOps: NationOp[];
}
/** Per-region map payload: region key -> owning nation + its stable color. */
@@ -162,6 +239,32 @@ export interface RemoveQueueItemResponse {
game: GameState;
}
/**
* (v0.23, #16 P4a) 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`); split/merge proposals are a no-op on
* confirm until P4b. No LLM call, no turn advance.
*/
export interface ConfirmNationOpRequest {
index: number;
/** Optional player edits applied on confirm (rename: the final display name). */
edits?: { name?: string };
}
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;