The v0.30.0 stats work made stability a soft lever: the system prompt tells the model that a nation whose stability is below 30 and that hosts a gathering marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with a political/social success event and a stability/economy shock. But the model had no field to express the regime change itself — the takeover was narrated yet the nation's name never changed, so it was invisible in the world (the dossier, the map, and the relation names all kept the old regime's name).
This issue closes that gap: a new TurnResult.takeovers field carries non-military regime changes, and the engine applies them as an automatic, in-world rename of the target nation.
1. Protocol (shared/protocol.ts)
New Takeover = { nationRef: string; newRegimeName: string } — the target named by its id or current name (resolved by the existing resolveNationRef), and the name of the new regime.
TurnResult.takeovers: Takeover[] — required (default []), placed between nationOps and markers.
2. Engine (server/engine.ts)
validateTakeovers(raw) — new exported defensive validator (same style as validateNationOps; never throws): non-array → []; each entry needs a non-empty, whitespace-trimmed nationRefandnewRegimeName (each capped at 200 chars); anything else is dropped. nationRef resolution (id first, then case-insensitive unique name) happens at apply time — an unknown or ambiguous ref is a no-op, never a throw.
Apply step in applyTurnResult — placed after the per-region stats recompute and before the markers block (the rename is order-independent of territory/stats; marker owners are nation ids, not names, so the rename never disturbs the owner recompute). Each resolved takeover sets n.name = newRegimeName — only the display name changes: the stable id, territory, relations (keyed by id), stats, and playerNationId are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
isMeaningful — a turn that only performs a takeover is meaningful (survives the retry/nudge path).
buildSystemPrompt — the JSON shape gains "takeovers": [{…}] and a new HARD CONSTRAINTS bullet: a takeover is automatic, NOT player-confirmed — fire it when a low-stability nation hosting a gathering marker is overthrown (giving the v0.30 stability/gathering mechanic a concrete consequence), narrate it with a political/social success event + a stability/economy shock, and use nationOps rename only for the player-confirmed kind of name change.
The generic "unclear" fallback in runTurn carries takeovers: [].
3. Stub + client
server/stub.ts — deterministicTurn returns takeovers: [] (the stub never performs a takeover); its one-conquest behavior and every e2e assertion stay untouched (the regime-rename path is covered by unit tests only).
Client — no change: a takeover is just a nation name change, which the dossier / event feed / map already render (the nation keeps its stable color — keyed by id).
4. Tests + version
15 new unit tests in server/engine.test.ts (apply: rename-with-preservation, player takeover, unknown ref, ambiguous ref, empty name, multiple takeovers, single version bump, case-insensitive name; isMeaningful; system-prompt contract in both modes; validateTakeovers; stub takeovers: []) + takeovers: [] added to every existing TurnResult literal.
Bump package.json → 0.32.0` + changelog entry.
## Summary
The v0.30.0 stats work made **stability** a *soft* lever: the system prompt tells the model that a nation whose stability is below 30 **and** that hosts a `gathering` marker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with a political/social success event and a stability/economy shock. But the model had **no field to express the regime change itself** — the takeover was narrated yet the nation's name never changed, so it was invisible in the world (the dossier, the map, and the relation names all kept the old regime's name).
This issue closes that gap: a new **`TurnResult.takeovers`** field carries non-military regime changes, and the engine applies them as an **automatic, in-world** rename of the target nation.
### 1. Protocol (`shared/protocol.ts`)
- New **`Takeover = { nationRef: string; newRegimeName: string }`** — the target named by its id or current name (resolved by the existing `resolveNationRef`), and the name of the new regime.
- **`TurnResult.takeovers: Takeover[]`** — **required** (default `[]`), placed between `nationOps` and `markers`.
### 2. Engine (`server/engine.ts`)
- **`validateTakeovers(raw)`** — new exported defensive validator (same style as `validateNationOps`; never throws): non-array → `[]`; each entry needs a non-empty, whitespace-trimmed `nationRef` **and** `newRegimeName` (each capped at 200 chars); anything else is dropped. `nationRef` resolution (id first, then case-insensitive unique name) happens at **apply** time — an unknown or ambiguous ref is a no-op, never a throw.
- **Apply step in `applyTurnResult`** — placed **after** the per-region stats recompute and **before** the markers block (the rename is order-independent of territory/stats; marker owners are nation **ids**, not names, so the rename never disturbs the owner recompute). Each resolved takeover sets `n.name = newRegimeName` — **only the display name changes**: the stable `id`, territory, relations (keyed by id), stats, and `playerNationId` are all preserved, so a player whose nation is overthrown keeps leading it under the new regime.
- **`isMeaningful`** — a turn that only performs a takeover is meaningful (survives the retry/nudge path).
- **`buildSystemPrompt`** — the JSON shape gains `"takeovers": [{…}]` and a new HARD CONSTRAINTS bullet: a takeover is **automatic, NOT player-confirmed** — fire it when a low-stability nation hosting a `gathering` marker is overthrown (giving the v0.30 stability/gathering mechanic a concrete consequence), narrate it with a political/social success event + a stability/economy shock, and use `nationOps` rename only for the player-confirmed kind of name change.
- The generic "unclear" fallback in `runTurn` carries `takeovers: []`.
### 3. Stub + client
- **`server/stub.ts`** — `deterministicTurn` returns `takeovers: []` (the stub never performs a takeover); its one-conquest behavior and every e2e assertion stay untouched (the regime-rename path is covered by unit tests only).
- **Client** — no change: a takeover is just a nation `name` change, which the dossier / event feed / map already render (the nation keeps its stable color — keyed by id).
### 4. Tests + version
- 15 new unit tests in `server/engine.test.ts` (apply: rename-with-preservation, player takeover, unknown ref, ambiguous ref, empty name, multiple takeovers, single version bump, case-insensitive name; `isMeaningful`; system-prompt contract in both modes; `validateTakeovers`; stub `takeovers: []`) + `takeovers: []` added to every existing `TurnResult` literal.
- Bump **`package.json` → 0.32.0`** + changelog entry.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
The v0.30.0 stats work made stability a soft lever: the system prompt tells the model that a nation whose stability is below 30 and that hosts a
gatheringmarker "can suffer a non-military takeover — a coup, revolution or faction seizure of power", narrated with a political/social success event and a stability/economy shock. But the model had no field to express the regime change itself — the takeover was narrated yet the nation's name never changed, so it was invisible in the world (the dossier, the map, and the relation names all kept the old regime's name).This issue closes that gap: a new
TurnResult.takeoversfield carries non-military regime changes, and the engine applies them as an automatic, in-world rename of the target nation.1. Protocol (
shared/protocol.ts)Takeover = { nationRef: string; newRegimeName: string }— the target named by its id or current name (resolved by the existingresolveNationRef), and the name of the new regime.TurnResult.takeovers: Takeover[]— required (default[]), placed betweennationOpsandmarkers.2. Engine (
server/engine.ts)validateTakeovers(raw)— new exported defensive validator (same style asvalidateNationOps; never throws): non-array →[]; each entry needs a non-empty, whitespace-trimmednationRefandnewRegimeName(each capped at 200 chars); anything else is dropped.nationRefresolution (id first, then case-insensitive unique name) happens at apply time — an unknown or ambiguous ref is a no-op, never a throw.applyTurnResult— placed after the per-region stats recompute and before the markers block (the rename is order-independent of territory/stats; marker owners are nation ids, not names, so the rename never disturbs the owner recompute). Each resolved takeover setsn.name = newRegimeName— only the display name changes: the stableid, territory, relations (keyed by id), stats, andplayerNationIdare all preserved, so a player whose nation is overthrown keeps leading it under the new regime.isMeaningful— a turn that only performs a takeover is meaningful (survives the retry/nudge path).buildSystemPrompt— the JSON shape gains"takeovers": [{…}]and a new HARD CONSTRAINTS bullet: a takeover is automatic, NOT player-confirmed — fire it when a low-stability nation hosting agatheringmarker is overthrown (giving the v0.30 stability/gathering mechanic a concrete consequence), narrate it with a political/social success event + a stability/economy shock, and usenationOpsrename only for the player-confirmed kind of name change.runTurncarriestakeovers: [].3. Stub + client
server/stub.ts—deterministicTurnreturnstakeovers: [](the stub never performs a takeover); its one-conquest behavior and every e2e assertion stay untouched (the regime-rename path is covered by unit tests only).namechange, which the dossier / event feed / map already render (the nation keeps its stable color — keyed by id).4. Tests + version
server/engine.test.ts(apply: rename-with-preservation, player takeover, unknown ref, ambiguous ref, empty name, multiple takeovers, single version bump, case-insensitive name;isMeaningful; system-prompt contract in both modes;validateTakeovers; stubtakeovers: []) +takeovers: []added to every existingTurnResultliteral.package.json→ 0.32.0` + changelog entry.