feat(#56): War as a first-class state — declare/end ops, mutual-defense drag-in, relation sync
CI / build + unit + e2e (pull_request) Successful in 2m32s
CI / build + unit + e2e (pull_request) Successful in 2m32s
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
This commit is contained in:
+251
-3
@@ -15,6 +15,7 @@ import type {
|
||||
RegionStats,
|
||||
RoundItem,
|
||||
StatKey,
|
||||
War,
|
||||
} from '../shared/types';
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
import {
|
||||
@@ -36,6 +37,9 @@ import {
|
||||
type Takeover,
|
||||
type TurnEventSpec,
|
||||
type TurnResult,
|
||||
type WarDeclareOp,
|
||||
type WarEndOp,
|
||||
type WarInfo,
|
||||
} from '../shared/protocol';
|
||||
import {
|
||||
DEFAULT_MAX_TOKENS,
|
||||
@@ -201,6 +205,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
' {"kind":"merge","nationRefs":["<id>","<id>"],"survivingRef":"<id of the surviving nation>","into":"<new name>"}',
|
||||
' ],',
|
||||
' "takeovers": [{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}],',
|
||||
' "wars": {"declare":[{"sides":["<nationId>","<nationId>"],"aims":["<short aim>"]}],"end":[{"id":"<war id>","outcome":"<armistice|capitulation>","terms":"<short terms>"}]},',
|
||||
' "markers": {"add":[{"type":"<city|factory|army|port|capital|fleet|rocket|gathering>","name":"<short name>","regionKey":"<region key>"}],"remove":["<marker id>"]}',
|
||||
'}',
|
||||
'',
|
||||
@@ -219,6 +224,7 @@ export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): s
|
||||
'- Split: add {"kind":"split","nationRef":"<nation id or name>","parts":[{"name":"<new name>","territory":["<region key>"]}, ...]} when a state breaks up (dissolution, secession). Use at least 2 parts and assign each of the nation\'s region keys to at most one part — any key you leave unassigned goes to the first part.',
|
||||
'- Merge: add {"kind":"merge","nationRefs":["<id or name>","<id or name>"],"survivingRef":"<id or name of the surviving nation>","into":"<new name>"} when states reunify or one is absorbed. Use at least 2 input nations and "survivingRef" must name one of them — the survivor keeps its id and its stats.',
|
||||
'- Takeover (automatic, NOT player-confirmed): add {"takeovers":[{"nationRef":"<nation id or name>","newRegimeName":"<name of the new regime>"}]} when a low-stability nation (e.g. stability < 30) hosting a "gathering" marker is overthrown in a coup or revolution. This renames the nation to the new regime (e.g. "France" → "Vichy Regime"). Also emit a narrative "event" (political or social, outcome "success") and a stability/economy shock via "stateChanges". A takeover is an automatic in-world regime change — use "nationOps" rename only for a diplomatic/political name change that the player must confirm.',
|
||||
'- Wars ("wars") are named conflicts, each with a stable id. To DECLARE one, add {"declare":[{"sides":["<id>","<id>"],"aims":[...]}]} with at least 2 distinct belligerents: while it is active the engine AUTOMATICALLY sets both directions of every warring pair\'s relation to "war" and drags in the mutual-defense allies of each belligerent, so do NOT also emit a "war" relation for those pairs (you can never "make peace" between them without ending the war). To END an active war, add {"end":[{"id":"<war id from the user message\'s "wars" field>","outcome":"<armistice|capitulation>"}]} — only active wars can be ended, and when one ends its belligerent pairs fall back to "hostile". Propose at most 1-2 declarations per turn and narrate each declaration and each ending in "events".',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
@@ -263,6 +269,13 @@ export function buildUserPrompt(
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
markers?: MarkerInfo[],
|
||||
/**
|
||||
* (v0.33.0, #56) The game's currently ACTIVE wars, as the model sees them:
|
||||
* stable id, sides and aims. Emitted verbatim (even when empty) so the
|
||||
* model can reference a war's id in `TurnResult.wars.end` to end it.
|
||||
* Omitted entirely when undefined.
|
||||
*/
|
||||
wars?: WarInfo[],
|
||||
): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
@@ -375,6 +388,7 @@ export function buildUserPrompt(
|
||||
if (takeoverRisks !== undefined) payload.takeoverRisks = takeoverRisks;
|
||||
if (noMansLand !== undefined) payload.unowned = noMansLand;
|
||||
if (markers !== undefined) payload.markers = markers;
|
||||
if (wars !== undefined) payload.wars = wars;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -513,6 +527,7 @@ export function validateTurnResult(
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
|
||||
@@ -607,6 +622,10 @@ export function validateTurnResult(
|
||||
// by applyTurnResult (automatic, not player-confirmed).
|
||||
result.takeovers = validateTakeovers(o.takeovers);
|
||||
|
||||
// (v0.33.0, #56) War declare/end ops: creating and ending named Wars.
|
||||
// Applied directly by applyTurnResult (after takeovers, before markers).
|
||||
result.wars = validateWarOps(o.wars);
|
||||
|
||||
// (v0.27, #44) Map-marker ops: new placements (validated here) and
|
||||
// removals by stable id. Applied by applyTurnResult after the territory
|
||||
// moves (a new marker's owner is the region's post-turn owner).
|
||||
@@ -736,13 +755,138 @@ export function validateTakeovers(raw: unknown): Takeover[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Validate the raw `wars` object of a turn into a safe
|
||||
* `{ declare, end }` shape. Defensive like the rest of the parser — never
|
||||
* throws. `declare`: each entry must be an object whose `sides` is an array
|
||||
* of non-empty strings (trimmed, capped at 200 chars each, deduped) that
|
||||
* yields at least TWO distinct sides — anything else (a one-sided or
|
||||
* empty-sided "war") is dropped; `aims`, when an array, is kept only as
|
||||
* non-empty strings (capped at 200 chars, at most 5). `end`: each entry must
|
||||
* be an object with a non-empty `id` (capped at 200 chars) and an `outcome`
|
||||
* of exactly `armistice` or `capitulation` (anything else is dropped);
|
||||
* `terms`, when a non-empty string, is capped at 400 chars. A non-object
|
||||
* input yields `{ declare: [], end: [] }`.
|
||||
*/
|
||||
export function validateWarOps(raw: unknown): { declare: WarDeclareOp[]; end: WarEndOp[] } {
|
||||
const out: { declare: WarDeclareOp[]; end: WarEndOp[] } = { declare: [], end: [] };
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
const o = raw as Record<string, unknown>;
|
||||
if (Array.isArray(o.declare)) {
|
||||
for (const e of o.declare) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const d = e as Record<string, unknown>;
|
||||
const sides = (Array.isArray(d.sides) ? d.sides : [])
|
||||
.map((s) => (typeof s === 'string' ? s.trim().slice(0, 200) : ''))
|
||||
.filter((s) => s !== '')
|
||||
// dedupe case-insensitively? No — sides are nation ids: keep exact
|
||||
// strings, drop exact duplicates only (resolution is later).
|
||||
.filter((s, i, arr) => arr.indexOf(s) === i);
|
||||
if (sides.length < 2) continue; // a war needs at least two belligerents
|
||||
const aims = Array.isArray(d.aims)
|
||||
? (d.aims as unknown[])
|
||||
.map((a) => (typeof a === 'string' ? a.trim().slice(0, 200) : ''))
|
||||
.filter((a) => a !== '')
|
||||
.slice(0, 5)
|
||||
: undefined;
|
||||
out.declare.push({ sides, ...(aims && aims.length > 0 ? { aims } : {}) });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(o.end)) {
|
||||
for (const e of o.end) {
|
||||
if (!e || typeof e !== 'object') continue;
|
||||
const w = e as Record<string, unknown>;
|
||||
const id = typeof w.id === 'string' ? w.id.trim().slice(0, 200) : '';
|
||||
if (!id) continue;
|
||||
if (w.outcome !== 'armistice' && w.outcome !== 'capitulation') continue;
|
||||
const terms = typeof w.terms === 'string' ? w.terms.trim().slice(0, 400) : '';
|
||||
out.end.push({ id, outcome: w.outcome, ...(terms ? { terms } : {}) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) The UNORDERED pairs of belligerents of a set of ACTIVE
|
||||
* wars: a war with sides [a, b, c] contributes the pairs {a,b}, {a,c},
|
||||
* {b,c} (any two distinct belligerents of the same active war are at war —
|
||||
* see the War type's semantics). Pure; exported for the unit tests.
|
||||
*/
|
||||
export function activeWarPairs(wars: War[]): Set<string> {
|
||||
const pairs = new Set<string>();
|
||||
const key = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
||||
for (const w of wars) {
|
||||
if (w.status !== 'active') continue;
|
||||
for (let i = 0; i < w.sides.length; i++) {
|
||||
for (let j = i + 1; j < w.sides.length; j++) {
|
||||
pairs.add(key(w.sides[i], w.sides[j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** True when nations `a` and `b` are both belligerents of ≥1 active war. */
|
||||
export function atWar(wars: War[], a: string, b: string): boolean {
|
||||
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||
return activeWarPairs(wars).has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* (v0.33.0, #56) Derive the `war` relation from the set of ACTIVE wars,
|
||||
* MUTATING the (cloned) `next` state in place (the caller passes a clone —
|
||||
* applyTurnResult does) and never touching `prev`. For every unordered pair
|
||||
* of distinct existing nations:
|
||||
*
|
||||
* - the pair is on the sides of ≥1 ACTIVE war in `next` → BOTH directions
|
||||
* of the pair's relation are set to `war` (the engine owns this relation
|
||||
* while the war is active — a manual `relations` entry to a lesser status
|
||||
* on a warring pair is overridden, so the two can never "make peace"
|
||||
* without the war being ended);
|
||||
* - the pair is NOT at war in `next` but WAS at war in `prev` (one of the
|
||||
* wars that tied them ended this turn) AND the current relation is
|
||||
* `war` → both directions become `hostile` (a war is the strongest
|
||||
* hostility — when it stops, the nations stay hostile, not suddenly
|
||||
* neutral);
|
||||
* - otherwise (not at war now, and the relation is not `war`, or the pair
|
||||
* was never tied by an active war) → untouched: the LLM owns
|
||||
* ally/neutral/rival/hostile freely, and a **bare** `war` relation that
|
||||
* no active war ever covered (a preset leftover) stays as-is — an
|
||||
* implicit, unnamed war, never force-created into a War entity and never
|
||||
* clobbered by the sync.
|
||||
*
|
||||
* Never throws.
|
||||
*/
|
||||
export function syncWarRelations(prev: GameState, next: GameState): void {
|
||||
const prevAtWar = activeWarPairs(prev.wars ?? []);
|
||||
const nextAtWar = activeWarPairs(next.wars ?? []);
|
||||
const ids = Object.keys(next.nations);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const a = ids[i];
|
||||
const b = ids[j];
|
||||
const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
|
||||
const na = next.nations[a];
|
||||
const nb = next.nations[b];
|
||||
if (nextAtWar.has(key)) {
|
||||
na.relations[b] = 'war';
|
||||
nb.relations[a] = 'war';
|
||||
} else if (prevAtWar.has(key) && (na.relations[b] === 'war' || nb.relations[a] === 'war')) {
|
||||
na.relations[b] = 'hostile';
|
||||
nb.relations[a] = 'hostile';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn is "meaningful" if it produced at least an event, a stat change, a
|
||||
* no-man's-land release (v0.22 #16 P3: a release is a real world change even
|
||||
* when the model under-narrates it), or a nation-op proposal (v0.23 #16 P4a:
|
||||
* when the model under-narrates it), a nation-op proposal (v0.23 #16 P4a:
|
||||
* a proposal is a real pending-world change the player will resolve, so it
|
||||
* must survive the retry/nudge path instead of being replaced by the generic
|
||||
* "unclear" result).
|
||||
* "unclear" result), a takeover (v0.32.0 #54), or a war declared or ended
|
||||
* (v0.33.0 #56: both are real world changes).
|
||||
*/
|
||||
export function isMeaningful(result: TurnResult): boolean {
|
||||
return (
|
||||
@@ -754,6 +898,9 @@ export function isMeaningful(result: TurnResult): boolean {
|
||||
result.nationOps.length > 0 ||
|
||||
// (v0.32.0, #54) a takeover is a real world change (regime rename).
|
||||
result.takeovers.length > 0 ||
|
||||
// (v0.33.0, #56) a war declared or ended is a real world change.
|
||||
result.wars.declare.length > 0 ||
|
||||
result.wars.end.length > 0 ||
|
||||
// (v0.27, #44) a marker placed or removed this turn is a real world change.
|
||||
result.markers.add.length > 0 ||
|
||||
result.markers.remove.length > 0
|
||||
@@ -1030,6 +1177,75 @@ export function applyTurnResult(
|
||||
n.name = t.newRegimeName;
|
||||
}
|
||||
|
||||
// --- Wars (v0.33.0, #56: war as a first-class state) ---
|
||||
//
|
||||
// Wars are named entities (`GameState.wars`), not just a `war` relation.
|
||||
// Two kinds of change, applied in this order against the (cloned) `next`:
|
||||
//
|
||||
// (1) DECLARE: each validated declare is resolved to concrete nation ids
|
||||
// (`resolveNationRef`, so the LLM may use an id OR a unique name) and
|
||||
// becomes a new active War with a fresh id (`w1`, `w2`, ...), stamped
|
||||
// with the acting turn and the turn's new date. Mutually-defensive
|
||||
// allies of the belligerents are DRAGGED IN: any nation that is an
|
||||
// `ally` of a belligerent (per the current, post-relations relations)
|
||||
// and is not already a side joins the war's `sides`. A declare with
|
||||
// fewer than two DISTINCT resolved sides is dropped; a declare whose
|
||||
// sides are all already contained in an existing ACTIVE war is a
|
||||
// duplicate and is skipped (the existing war stands).
|
||||
// (2) END: each validated end targets a war BY ID and sets its status to
|
||||
// the outcome (`armistice` / `capitulation`). Only ACTIVE wars are
|
||||
// affected — an unknown id or an already-ended war is a no-op.
|
||||
// (A war may be declared and ended in the SAME turn: declare runs
|
||||
// first, so the just-declared id is a valid end target.)
|
||||
// (3) RELATION SYNC: `syncWarRelations` derives the `war` relation from
|
||||
// the ACTIVE wars (see its doc) — the load-bearing step that keeps
|
||||
// the relation table honest with the war entities.
|
||||
//
|
||||
// Nothing here throws: every unresolvable / invalid op is dropped.
|
||||
{
|
||||
const takenWarIds = new Set(next.wars.map((w) => w.id));
|
||||
// (1) DECLARE
|
||||
for (const d of result.wars.declare) {
|
||||
const resolved: string[] = [];
|
||||
for (const ref of d.sides) {
|
||||
const id = resolveNationRef(next, ref);
|
||||
if (id !== null && !resolved.includes(id)) resolved.push(id);
|
||||
}
|
||||
if (resolved.length < 2) continue; // not enough distinct belligerents
|
||||
// Duplicate: an active war already contains all of these sides.
|
||||
const dup = next.wars.some(
|
||||
(w) => w.status === 'active' && resolved.every((s) => w.sides.includes(s)),
|
||||
);
|
||||
if (dup) continue;
|
||||
const sides = [...resolved];
|
||||
// Mutual-defense drag-in: each belligerent's allies join the war.
|
||||
for (const s of resolved) {
|
||||
const rels = next.nations[s]?.relations ?? {};
|
||||
for (const ally of Object.keys(rels).filter((x) => rels[x] === 'ally').sort()) {
|
||||
if (!sides.includes(ally)) sides.push(ally);
|
||||
}
|
||||
}
|
||||
const id = freshWarId(takenWarIds);
|
||||
takenWarIds.add(id);
|
||||
next.wars.push({
|
||||
id,
|
||||
sides,
|
||||
declaredTurn: actingTurn,
|
||||
declaredDate: next.date,
|
||||
...(d.aims && d.aims.length > 0 ? { aims: d.aims } : {}),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
// (2) END (by id; active only)
|
||||
for (const e of result.wars.end) {
|
||||
const w = next.wars.find((w) => w.id === e.id && w.status === 'active');
|
||||
if (!w) continue; // unknown id / already ended: no-op
|
||||
w.status = e.outcome;
|
||||
}
|
||||
// (3) RELATION SYNC (prev = the state this turn started from)
|
||||
syncWarRelations(state, next);
|
||||
}
|
||||
|
||||
// --- Map markers (v0.27, #44) ---
|
||||
//
|
||||
// Markers are anchored to a region key and are processed against the FINAL
|
||||
@@ -1215,6 +1431,19 @@ export function freshNationId(taken: ReadonlySet<string>): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh, unused war id for a newly declared {@link War}
|
||||
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
|
||||
* it creates to `taken` as it goes, so ids stay unique within a turn and are
|
||||
* never reused from an ended war.
|
||||
*/
|
||||
export function freshWarId(taken: ReadonlySet<string>): string {
|
||||
for (let i = 1; ; i++) {
|
||||
const id = `w${i}`;
|
||||
if (!taken.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline stats a freshly created nation starts with (split parts,
|
||||
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
|
||||
@@ -1505,7 +1734,25 @@ export async function runTurn(opts: {
|
||||
regionKey: m.regionKey,
|
||||
owner: m.ownerNationId,
|
||||
}));
|
||||
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? [], markers);
|
||||
// (v0.33.0, #56) the model always sees the currently ACTIVE wars (empty list
|
||||
// on a fresh game) so it can reference a war's id in `wars.end` to end it;
|
||||
// ended wars stay in GameState.wars as history but are not surfaced.
|
||||
const wars = (state.wars ?? [])
|
||||
.filter((w) => w.status === 'active')
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
sides: w.sides,
|
||||
...(w.aims && w.aims.length > 0 ? { aims: w.aims } : {}),
|
||||
}));
|
||||
const user = buildUserPrompt(
|
||||
state,
|
||||
items,
|
||||
regionReference,
|
||||
frontier,
|
||||
state.unowned ?? [],
|
||||
markers,
|
||||
wars,
|
||||
);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
|
||||
@@ -1558,6 +1805,7 @@ export async function runTurn(opts: {
|
||||
unowned: [],
|
||||
nationOps: [],
|
||||
takeovers: [],
|
||||
wars: { declare: [], end: [] },
|
||||
markers: { add: [], remove: [] },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user