Part of #16 — major nation changes, P4b: splits & merges ("LLM proposes, player confirms").
P4a (#38) stabilized the NationOp contract with rename applied on confirm; split and merge were accepted-and-stored but no-ops. P4b makes applyNationOpactually apply both — this is the last piece of #16: all three op kinds are now applied on player confirm.
What changed
shared/protocol.ts — ConfirmNationOpRequest.edits is now { name?: string; playerPart?: number }. The SplitOp / MergeOp / NationOp / TurnResult.nationOps doc comments now describe the applied semantics (no longer "a no-op until P4b").
server/engine.ts (all pure; all ops never throw — an invalid op is a safe no-op on the world, and the proposal is still resolved and removed on confirm):
Split — the parent nationRef must resolve and there must be ≥2 named parts (fewer → no-op). The parent's region-key territory is partitioned across the parts in order: each part gets the keys it names that the parent actually owns and that no earlier part claimed (first part to name a key wins, so a key is never held twice); keys the parent owns that no part claimed go to part 0 (the parent's territory order), so nothing is orphaned. Each part gets a fresh id (freshNationId — first free of n1, n2, …) and the baseline NEW_NATION_STATS (a neutral 50/50/50/50 — not a copy of the parent's), a copy of the parent's relations, and the parent's type. The parent is removed and its id is dropped from every remaining nation's relations map (dropRelationsTo). If the parent was the player's nation, playerNationId moves to the chosen part (edits.playerPart, clamped, default 0) — that same part also receives the edits.name override.
Merge — every input ref and the survivor ref must resolve, at least two distinct inputs, and the survivor must be one of the inputs (any failure → no-op). The survivor keeps its id and its own stats; it gains the union of the inputs' territories (the survivor's keys first, then each other input's unclaimed keys in its own order) and a union of the inputs' relations (the survivor's own view of a third party wins, else the first input's); it is renamed edits.name → into → its own name (in that precedence). The other inputs are removed and their ids are dropped from every remaining nation's relations maps; if the player led a removed input, playerNationId moves to the survivor.
Prompt — the system prompt's JSON contract now shows all three nationOps shapes (rename / split / merge), and the HARD CONSTRAINTS gain one line per op: at most 1–2 proposals per turn, each narrated in events; split requires ≥2 parts (unassigned keys → part 0); merge's survivor must name one of the inputs (the survivor keeps its id and its stats).
Client — src/lib/api.tsconfirmNationOp(index, edits?); src/components/NationOpDialog.tsx now renders all three kinds with a Confirm button (no more dismiss-only split/merge rows): a split lists each part (name + the number of regions it was assigned) and, when the parent is the player's own nation, shows a successor radio (preselected 0 — the choice is always valid); a merge shows the input nations as chips (the survivor highlighted) and an editable name (defaulting to into); rename keeps its editable name. ActionPanel / App.tsx thread onConfirm(index, edits?) through the version-guarded applyState.
Stub + e2e unchanged. The stub still returns nationOps: [] (it never proposes any op kind) — so, like P4a, split/merge is covered by unit tests only; the e2e suite (CI, LLM_STUB=1) is unaffected.
Tests (server/engine.test.ts). New freshNationId, applyNationOp (split), and applyNationOp (merge) suites (the partition: first-wins, leftovers-to-part-0, sole ownership, fresh ids/stats/relations/type, parent removed + dropRelationsTo, player-pointer move, clamped playerPart, edits.name on the chosen part; the merge: union territory, survivor stats, relations union with survivor-wins, removed ids dropped from everyone else, into-name, player-pointer move, and the four no-op guards) + an updated prompt-contract test; the old "split/merge is a no-op" P4a test is retargeted to a 1-part split. The unit suite grows 151 → 164.
Docs / version — AGENTS.md refreshed; CHANGELOG.md v0.24.0 entry; version bumped to 0.24.0.
Verification
npm run typecheck, npm run lint, npm test (164 unit tests, up from 151), and npm run build all pass locally.
The e2e suite (CI, LLM_STUB=1) has no nation-op assertions and stays green with the unchanged stub.
Deploy: manual docker compose up -d --build after merge (no down -v — GameState gains no new required field; pendingNationOps already exists since P4a).
Related: #16 (major nation changes — this is P4b, the final part of the epic; #16 was already closed after P3, so — like P4a's PR #38 — this references it for traceability rather than auto-closing).
Part of #16 — **major nation changes, P4b: splits & merges ("LLM proposes, player confirms").**
P4a (#38) stabilized the `NationOp` contract with rename applied on confirm; split and merge were accepted-and-stored but no-ops. P4b makes `applyNationOp` **actually apply** both — this is the last piece of #16: all three op kinds are now applied on player confirm.
## What changed
- **`shared/protocol.ts`** — `ConfirmNationOpRequest.edits` is now `{ name?: string; playerPart?: number }`. The `SplitOp` / `MergeOp` / `NationOp` / `TurnResult.nationOps` doc comments now describe the *applied* semantics (no longer "a no-op until P4b").
- **`server/engine.ts`** (all pure; all ops **never throw** — an invalid op is a safe no-op on the world, and the proposal is still resolved and removed on confirm):
- **Split** — the parent `nationRef` must resolve and there must be **≥2** named parts (fewer → no-op). The parent's **region-key** territory is partitioned across the parts **in order**: each part gets the keys it names that the parent actually owns and that no earlier part claimed (**first part to name a key wins**, so a key is never held twice); keys the parent owns that no part claimed go to **part 0** (the parent's territory order), so nothing is orphaned. Each part gets a **fresh id** (`freshNationId` — first free of `n1`, `n2`, …) and the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 — *not* a copy of the parent's), a copy of the parent's relations, and the parent's `type`. The parent is removed and its id is dropped from every remaining nation's relations map (`dropRelationsTo`). If the **parent was the player's** nation, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped, default 0) — that same part also receives the `edits.name` override.
- **Merge** — every input ref and the survivor ref must resolve, at least **two distinct** inputs, and the **survivor must be one of the inputs** (any failure → no-op). The survivor keeps its **id** and its **own stats**; it gains the **union** of the inputs' territories (the survivor's keys first, then each other input's unclaimed keys in its own order) and a **union of the inputs' relations** (the survivor's own view of a third party wins, else the first input's); it is renamed `edits.name` → `into` → its own name (in that precedence). The other inputs are removed and their ids are dropped from every remaining nation's relations maps; if the **player led a removed input**, `playerNationId` moves to the survivor.
- **Prompt** — the system prompt's JSON contract now shows all three `nationOps` shapes (rename / split / merge), and the HARD CONSTRAINTS gain one line per op: at most 1–2 proposals per turn, each narrated in `events`; split requires ≥2 parts (unassigned keys → part 0); merge's survivor must name one of the inputs (the survivor keeps its id and its stats).
- **Client** — `src/lib/api.ts` `confirmNationOp(index, edits?)`; `src/components/NationOpDialog.tsx` now renders **all three** kinds with a Confirm button (no more dismiss-only split/merge rows): a **split** lists each part (name + the number of regions it was assigned) and, when the parent is the **player's own** nation, shows a **successor radio** (preselected 0 — the choice is always valid); a **merge** shows the input nations as chips (the **survivor** highlighted) and an **editable name** (defaulting to `into`); rename keeps its editable name. `ActionPanel` / `App.tsx` thread `onConfirm(index, edits?)` through the version-guarded `applyState`.
- **Stub + e2e unchanged.** The stub still returns `nationOps: []` (it never proposes any op kind) — so, like P4a, split/merge is covered by **unit tests only**; the e2e suite (CI, `LLM_STUB=1`) is unaffected.
- **Tests (`server/engine.test.ts`).** New `freshNationId`, `applyNationOp (split)`, and `applyNationOp (merge)` suites (the partition: first-wins, leftovers-to-part-0, sole ownership, fresh ids/stats/relations/type, parent removed + `dropRelationsTo`, player-pointer move, clamped `playerPart`, `edits.name` on the chosen part; the merge: union territory, survivor stats, relations union with survivor-wins, removed ids dropped from everyone else, into-name, player-pointer move, and the four no-op guards) + an updated prompt-contract test; the old "split/merge is a no-op" P4a test is retargeted to a 1-part split. The unit suite grows **151 → 164**.
- **Docs / version** — `AGENTS.md` refreshed; `CHANGELOG.md` v0.24.0 entry; version bumped to **0.24.0**.
## Verification
- `npm run typecheck`, `npm run lint`, `npm test` (**164** unit tests, up from 151), and `npm run build` all pass locally.
- The e2e suite (CI, `LLM_STUB=1`) has no nation-op assertions and stays green with the unchanged stub.
- Deploy: manual `docker compose up -d --build` after merge (no `down -v` — `GameState` gains no new required field; `pendingNationOps` already exists since P4a).
Related: #16 (major nation changes — this is P4b, the final part of the epic; #16 was already closed after P3, so — like P4a's PR #38 — this references it for traceability rather than auto-closing).
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.
Part of #16 — major nation changes, P4b: splits & merges ("LLM proposes, player confirms").
P4a (#38) stabilized the
NationOpcontract with rename applied on confirm; split and merge were accepted-and-stored but no-ops. P4b makesapplyNationOpactually apply both — this is the last piece of #16: all three op kinds are now applied on player confirm.What changed
shared/protocol.ts—ConfirmNationOpRequest.editsis now{ name?: string; playerPart?: number }. TheSplitOp/MergeOp/NationOp/TurnResult.nationOpsdoc comments now describe the applied semantics (no longer "a no-op until P4b").server/engine.ts(all pure; all ops never throw — an invalid op is a safe no-op on the world, and the proposal is still resolved and removed on confirm):nationRefmust resolve and there must be ≥2 named parts (fewer → no-op). The parent's region-key territory is partitioned across the parts in order: each part gets the keys it names that the parent actually owns and that no earlier part claimed (first part to name a key wins, so a key is never held twice); keys the parent owns that no part claimed go to part 0 (the parent's territory order), so nothing is orphaned. Each part gets a fresh id (freshNationId— first free ofn1,n2, …) and the baselineNEW_NATION_STATS(a neutral 50/50/50/50 — not a copy of the parent's), a copy of the parent's relations, and the parent'stype. The parent is removed and its id is dropped from every remaining nation's relations map (dropRelationsTo). If the parent was the player's nation,playerNationIdmoves to the chosen part (edits.playerPart, clamped, default 0) — that same part also receives theedits.nameoverride.edits.name→into→ its own name (in that precedence). The other inputs are removed and their ids are dropped from every remaining nation's relations maps; if the player led a removed input,playerNationIdmoves to the survivor.nationOpsshapes (rename / split / merge), and the HARD CONSTRAINTS gain one line per op: at most 1–2 proposals per turn, each narrated inevents; split requires ≥2 parts (unassigned keys → part 0); merge's survivor must name one of the inputs (the survivor keeps its id and its stats).src/lib/api.tsconfirmNationOp(index, edits?);src/components/NationOpDialog.tsxnow renders all three kinds with a Confirm button (no more dismiss-only split/merge rows): a split lists each part (name + the number of regions it was assigned) and, when the parent is the player's own nation, shows a successor radio (preselected 0 — the choice is always valid); a merge shows the input nations as chips (the survivor highlighted) and an editable name (defaulting tointo); rename keeps its editable name.ActionPanel/App.tsxthreadonConfirm(index, edits?)through the version-guardedapplyState.nationOps: [](it never proposes any op kind) — so, like P4a, split/merge is covered by unit tests only; the e2e suite (CI,LLM_STUB=1) is unaffected.server/engine.test.ts). NewfreshNationId,applyNationOp (split), andapplyNationOp (merge)suites (the partition: first-wins, leftovers-to-part-0, sole ownership, fresh ids/stats/relations/type, parent removed +dropRelationsTo, player-pointer move, clampedplayerPart,edits.nameon the chosen part; the merge: union territory, survivor stats, relations union with survivor-wins, removed ids dropped from everyone else, into-name, player-pointer move, and the four no-op guards) + an updated prompt-contract test; the old "split/merge is a no-op" P4a test is retargeted to a 1-part split. The unit suite grows 151 → 164.AGENTS.mdrefreshed;CHANGELOG.mdv0.24.0 entry; version bumped to 0.24.0.Verification
npm run typecheck,npm run lint,npm test(164 unit tests, up from 151), andnpm run buildall pass locally.LLM_STUB=1) has no nation-op assertions and stays green with the unchanged stub.docker compose up -d --buildafter merge (nodown -v—GameStategains no new required field;pendingNationOpsalready exists since P4a).Related: #16 (major nation changes — this is P4b, the final part of the epic; #16 was already closed after P3, so — like P4a's PR #38 — this references it for traceability rather than auto-closing).