From 0bd9af70d680be2e2c893b23f401c87584fe9e9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:43:44 +0000 Subject: [PATCH 1/2] fix(sim): transfer subject/vassal bonds during empire merges mergeEmpires transferred fleets, artifacts, factions, alliances, and relationships from the absorbed empire to the dominant one, but never touched state.subjects. A merged empire that was a vassal/tributary (or an overlord of others) left dangling subject records, which the next stepSubjects pass would either silently drop or resolve into a spurious liberation event instead of correctly transferring the bond. Subject ties now transfer to the dominant empire, mirroring the cleanup removeEmpireFromGalaxy already does on empire death. A tie directly between the absorbed and dominant empire collapses (both sides are now one empire); a transfer that would violate the one-overlord/no-cycle invariant falls back to liberation. Declared build/lint/headless-report checks in .noet/VERIFICATION.md and recorded evidence via noet verify. Also recorded a constraint: new per-empire relational state needs transfer logic in both mergeEmpires and removeEmpireFromGalaxy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018o55CaUYZM5bNccWjsctnc --- .noet/CONSTRAINTS.md | 1 + .noet/VERIFICATION.md | 6 ++++++ .noet/verification/VER-1.json | 15 +++++++++++++++ .noet/verification/VER-2.json | 15 +++++++++++++++ .noet/verification/VER-3.json | 16 ++++++++++++++++ src/sim/Merge.ts | 27 +++++++++++++++++++++++++++ 6 files changed, 80 insertions(+) create mode 100644 .noet/VERIFICATION.md create mode 100644 .noet/verification/VER-1.json create mode 100644 .noet/verification/VER-2.json create mode 100644 .noet/verification/VER-3.json diff --git a/.noet/CONSTRAINTS.md b/.noet/CONSTRAINTS.md index 0d2bec3..f8a7a8d 100644 --- a/.noet/CONSTRAINTS.md +++ b/.noet/CONSTRAINTS.md @@ -8,6 +8,7 @@ - Prefer one coherent mechanic over a pile of unrelated features. - New mechanics must create observable history, not just extra stats. - Build must pass with `npm run build`. +- New per-empire relational state (subjects, alliances, factions) needs transfer logic in both mergeEmpires and removeEmpireFromGalaxy. — mergeEmpires silently dropped subject/vassal bonds on merge; only removeEmpireFromGalaxy handled subject cleanup, so merges left stale/dangling ids. ## Must-nots - Do not add a backend. diff --git a/.noet/VERIFICATION.md b/.noet/VERIFICATION.md new file mode 100644 index 0000000..16c27d0 --- /dev/null +++ b/.noet/VERIFICATION.md @@ -0,0 +1,6 @@ +# VERIFICATION + +## Commands +- `npm run build` +- `npm run lint` +- `npm run report` diff --git a/.noet/verification/VER-1.json b/.noet/verification/VER-1.json new file mode 100644 index 0000000..c67d456 --- /dev/null +++ b/.noet/verification/VER-1.json @@ -0,0 +1,15 @@ +{ + "id": "VER-1", + "command": "npm run build", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T00:41:07.257Z", + "finishedAt": "2026-07-15T00:41:13.081Z", + "durationMs": 5823, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 build\n> tsc -b && vite build\n\nvite v8.0.16 building client environment for production...\n\u001b[2K\rtransforming...✓ 47 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB │ gzip: 0.29 kB\ndist/assets/index-Hx8UZdve.css 15.95 kB │ gzip: 3.40 kB\ndist/assets/index-Puboqa6e.js 470.97 kB │ gzip: 138.61 kB\n\n✓ built in 304ms\n", + "codeState": { + "head": "d3a915b3e9449a13e1e9ee0754c54989091d0c71", + "diffHash": "c696deb40b9fb1ca139373d3da1d53610a0403074f4af89b196bce333b0fe644" + } +} diff --git a/.noet/verification/VER-2.json b/.noet/verification/VER-2.json new file mode 100644 index 0000000..df2c0e1 --- /dev/null +++ b/.noet/verification/VER-2.json @@ -0,0 +1,15 @@ +{ + "id": "VER-2", + "command": "npm run lint", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T00:41:13.108Z", + "finishedAt": "2026-07-15T00:41:25.115Z", + "durationMs": 12007, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 lint\n> eslint .\n\n", + "codeState": { + "head": "d3a915b3e9449a13e1e9ee0754c54989091d0c71", + "diffHash": "c696deb40b9fb1ca139373d3da1d53610a0403074f4af89b196bce333b0fe644" + } +} diff --git a/.noet/verification/VER-3.json b/.noet/verification/VER-3.json new file mode 100644 index 0000000..e5af1ba --- /dev/null +++ b/.noet/verification/VER-3.json @@ -0,0 +1,16 @@ +{ + "id": "VER-3", + "command": "npm run report", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T00:41:25.139Z", + "finishedAt": "2026-07-15T00:43:02.553Z", + "durationMs": 97414, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 report\n> tsx scripts/headless-report.ts\n\n# galimulator-ng headless report\n\nSeed: 42 · Stars: 400 · Starting empires: 12\nShape: spiral · Lanes: standard · Layout: classic\nInitial graph: avg degree 3.84 · max degree 7 · avg lane length 18.8\nMilestones: 1000, 3000 ticks\n\n## Tick 1000\n\n- Living empires: 36 (8/12 original founders survive — 67% survival)\n- Owned systems: 346/400\n- Largest power: Dark Dominion (36 systems, 9.0% of map)\n- Active wars now: 10 · cumulative wars declared: 105\n- Alliances: 2 blocs · Trade routes: 25\n- Artifacts: 21 · Markers/scars: 425\n- Dynasties: 56 (53 living) · People: 241 · Deepest ruler chain: 4 — Solsorlar Order (House of Belcer)\n- Grievances: 8 active across 6 feuds (5 empires) · avg +30 tension, peak +30 · 5% of historical modifiers · oldest 373t\n- Graph now: avg degree 3.84 · max degree 7 · avg lane length 18.8\n- Churn since last milestone: 31 empire births+deaths\n- Cumulative: 5 founded, 26 collapsed, 1 merged, 43 rebellions, 22 coups, 0 transcended\n- Religion: 6 faiths · 1 founded · 18 state conversions\n - Spread: Loraz Mysteries (51), Elcerdor Communion (50), Disciples of Solsol (36)\n- Monsters: 1 at large · 2 spawned · 1 slain · Oddities roaming: 0 · Crises/oddity events: 1\n- Factions: 10 active (33 worlds, 2 near uprising) · 25 formed · 4 uprisings\n- Subjects: 2 active (1 protectorate, 1 tributary) · 4 created · 0 rebellions · 0 integrations · 0 liberations\n- Quests: 5 launched · 2 completed\n- Specialist ships: 1 missionary, 2 dropship, 1 gunstation active\n- Careers: 17 promotions · 11 falls · top house: Jarax (prestige 51, legitimacy +1.00)\n\n## Tick 3000\n\n- Living empires: 42 (0/12 original founders survive — 0% survival)\n- Owned systems: 292/400\n- Largest power: Lost Independence Movement (39 systems, 9.8% of map)\n- Active wars now: 14 · cumulative wars declared: 780\n- Alliances: 3 blocs · Trade routes: 37\n- Artifacts: 22 · Markers/scars: 1412\n- Dynasties: 176 (169 living) · People: 687 · Deepest ruler chain: 6 — Rogue Resistance (House of Zanpri)\n- Grievances: 14 active across 9 feuds (8 empires) · avg +30 tension, peak +30 · 6% of historical modifiers · oldest 304t\n- Graph now: avg degree 3.84 · max degree 7 · avg lane length 18.8\n- Churn since last milestone: 211 empire births+deaths\n- Cumulative: 34 founded, 208 collapsed, 9 merged, 145 rebellions, 88 coups, 0 transcended\n- Religion: 10 faiths · 5 founded · 74 state conversions\n - Spread: Elcerdor Communion (151), Loraz Mysteries (87), Song of Xardenvor (79)\n- Monsters: 2 at large · 9 spawned · 7 slain · Oddities roaming: 0 · Crises/oddity events: 8\n- Factions: 11 active (24 worlds, 2 near uprising) · 173 formed · 72 uprisings\n- Subjects: 5 active (2 vassal, 2 tributary, 1 protectorate) · 28 created · 4 rebellions · 0 integrations · 9 liberations\n- Quests: 23 launched · 7 completed\n- Specialist ships: 2 science, 2 gunstation, 2 dropship, 1 disruptor, 1 missionary active\n- Careers: 25 promotions · 48 falls · top house: Varar (prestige 49, legitimacy +0.20)\n\n", + "stderrTail": "\n✓ Determinism check passed — identical replay from the same seed.\n", + "codeState": { + "head": "d3a915b3e9449a13e1e9ee0754c54989091d0c71", + "diffHash": "c696deb40b9fb1ca139373d3da1d53610a0403074f4af89b196bce333b0fe644" + } +} diff --git a/src/sim/Merge.ts b/src/sim/Merge.ts index eef0e1f..5121199 100644 --- a/src/sim/Merge.ts +++ b/src/sim/Merge.ts @@ -1,5 +1,6 @@ import type { EmpireRelationship, GalaxyState, Id, RelationModifier } from "../types/sim"; import { createEvent } from "./Events"; +import { breakSubjectRelation, subjectOf, subjectsOf } from "./Subjects"; function mergeModifiers(a: RelationModifier[] = [], b: RelationModifier[] = []): RelationModifier[] { const byLabel = new Map(); @@ -89,6 +90,32 @@ export function mergeEmpires(state: GalaxyState, dominantId: Id, absorbedId: Id, dominant.allianceIds = unique((dominant.allianceIds ?? []).filter(id => state.alliances[id])); delete dominant.relationshipByEmpireId[absorbed.id]; + + // Subject bonds: the absorbed empire keeps existing, just under a new ruler, so its + // subject/overlord ties transfer to the dominant empire rather than dangling or + // silently dissolving. A tie to the dominant itself collapses (both sides are now one + // empire); a transfer that would break the one-overlord/no-cycle invariant is liberated + // instead, same as a dying overlord freeing its subjects in removeEmpireFromGalaxy. + for (const sr of Object.values(state.subjects ?? {})) { + if (sr.subjectEmpireId === absorbed.id && sr.overlordEmpireId === dominant.id) { + delete state.subjects![sr.id]; + } else if (sr.overlordEmpireId === absorbed.id && sr.subjectEmpireId === dominant.id) { + delete state.subjects![sr.id]; + } else if (sr.subjectEmpireId === absorbed.id) { + if (subjectOf(state, dominant.id) || subjectsOf(state, dominant.id).length > 0) { + breakSubjectRelation(state, sr.id, "liberation"); + } else { + sr.subjectEmpireId = dominant.id; + } + } else if (sr.overlordEmpireId === absorbed.id) { + if (subjectOf(state, dominant.id)) { + breakSubjectRelation(state, sr.id, "liberation"); + } else { + sr.overlordEmpireId = dominant.id; + } + } + } + delete state.empires[absorbed.id]; if (state.playerControl.controlledEmpireId === absorbed.id) state.playerControl.controlledEmpireId = dominant.id; From 29ef41467ae2e55f88e97de055c7b5495a5a4ee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:03:47 +0000 Subject: [PATCH 2/2] fix(sim): break Merge/Subjects import cycle, add merge subject-transfer tests Address review feedback on PR #39: - Extract subject-relation primitives (subjectOf, subjectsOf, isSubjectPair, createSubjectRelation, breakSubjectRelation, SUBJECT_STATUS_LABEL, STATUS_PROFILE) into a new SubjectRelations.ts with no dependency on Merge.ts. Merge.ts now imports from there instead of from Subjects.ts, breaking the Merge <-> Subjects cycle (Subjects.ts still imports mergeEmpires for peaceful integration). Subjects.ts re-exports the primitives so existing call sites (Diplomacy, Tick, Simulation, UI panels) are unaffected. - Add src/sim/Merge.test.ts using Node's built-in test runner, covering the five subject-transfer branches introduced by the merge fix: absorbed's subject status transfers, absorbed's own subjects transfer, a direct dominant<->absorbed tie collapses, and conflicting transfers on both the subject and overlord side liberate instead of violating the one-overlord/ no-cycle invariant. Verified these tests fail against the pre-fix code and pass against the fix. - New `npm run test:unit` script and CI step; exclude *.test.ts from the browser tsconfig project (it has no Node types) and note the new unit tests in CLAUDE.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018o55CaUYZM5bNccWjsctnc --- .github/workflows/ci.yml | 3 + .noet/VERIFICATION.md | 1 + .noet/verification/VER-4.json | 15 +++++ .noet/verification/VER-5.json | 15 +++++ .noet/verification/VER-6.json | 15 +++++ .noet/verification/VER-7.json | 16 ++++++ CLAUDE.md | 11 +++- package.json | 3 +- src/sim/Merge.test.ts | 105 ++++++++++++++++++++++++++++++++++ src/sim/Merge.ts | 2 +- src/sim/SubjectRelations.ts | 89 ++++++++++++++++++++++++++++ src/sim/Subjects.ts | 89 +++------------------------- tsconfig.app.json | 3 +- 13 files changed, 282 insertions(+), 85 deletions(-) create mode 100644 .noet/verification/VER-4.json create mode 100644 .noet/verification/VER-5.json create mode 100644 .noet/verification/VER-6.json create mode 100644 .noet/verification/VER-7.json create mode 100644 src/sim/Merge.test.ts create mode 100644 src/sim/SubjectRelations.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e15b2e9..ad6c701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,8 @@ jobs: - name: Lint run: npm run lint + - name: Unit tests + run: npm run test:unit + - name: Headless simulation (determinism gate) run: npm test diff --git a/.noet/VERIFICATION.md b/.noet/VERIFICATION.md index 16c27d0..4e40a30 100644 --- a/.noet/VERIFICATION.md +++ b/.noet/VERIFICATION.md @@ -3,4 +3,5 @@ ## Commands - `npm run build` - `npm run lint` +- `npm run test:unit` - `npm run report` diff --git a/.noet/verification/VER-4.json b/.noet/verification/VER-4.json new file mode 100644 index 0000000..dc7ada8 --- /dev/null +++ b/.noet/verification/VER-4.json @@ -0,0 +1,15 @@ +{ + "id": "VER-4", + "command": "npm run build", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T01:01:29.541Z", + "finishedAt": "2026-07-15T01:01:35.075Z", + "durationMs": 5534, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 build\n> tsc -b && vite build\n\nvite v8.0.16 building client environment for production...\n\u001b[2K\rtransforming...✓ 48 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB │ gzip: 0.29 kB\ndist/assets/index-Hx8UZdve.css 15.95 kB │ gzip: 3.40 kB\ndist/assets/index-Bc5jnuWb.js 470.96 kB │ gzip: 138.61 kB\n\n✓ built in 270ms\n", + "codeState": { + "head": "0bd9af70d680be2e2c893b23f401c87584fe9e9b", + "diffHash": "b885d7b8fcba382df51077505b210f40d3e6d694973d8723ac2dbb067700b8e8" + } +} diff --git a/.noet/verification/VER-5.json b/.noet/verification/VER-5.json new file mode 100644 index 0000000..0acb609 --- /dev/null +++ b/.noet/verification/VER-5.json @@ -0,0 +1,15 @@ +{ + "id": "VER-5", + "command": "npm run lint", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T01:01:35.104Z", + "finishedAt": "2026-07-15T01:01:46.594Z", + "durationMs": 11490, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 lint\n> eslint .\n\n", + "codeState": { + "head": "0bd9af70d680be2e2c893b23f401c87584fe9e9b", + "diffHash": "b885d7b8fcba382df51077505b210f40d3e6d694973d8723ac2dbb067700b8e8" + } +} diff --git a/.noet/verification/VER-6.json b/.noet/verification/VER-6.json new file mode 100644 index 0000000..9679832 --- /dev/null +++ b/.noet/verification/VER-6.json @@ -0,0 +1,15 @@ +{ + "id": "VER-6", + "command": "npm run test:unit", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T01:01:46.618Z", + "finishedAt": "2026-07-15T01:01:47.222Z", + "durationMs": 604, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 test:unit\n> node --import tsx --test src/sim/*.test.ts\n\nTAP version 13\n# Subtest: absorbed empire's subject status transfers to the dominant empire\nok 1 - absorbed empire's subject status transfers to the dominant empire\n ---\n duration_ms: 10.796552\n type: 'test'\n ...\n# Subtest: absorbed empire's own subjects transfer to the dominant empire as their new overlord\nok 2 - absorbed empire's own subjects transfer to the dominant empire as their new overlord\n ---\n duration_ms: 3.407577\n type: 'test'\n ...\n# Subtest: a direct subject tie between the dominant and absorbed empire collapses\nok 3 - a direct subject tie between the dominant and absorbed empire collapses\n ---\n duration_ms: 2.027519\n type: 'test'\n ...\n# Subtest: a conflicting subject transfer is liberated instead of creating a second overlord\nok 4 - a conflicting subject transfer is liberated instead of creating a second overlord\n ---\n duration_ms: 2.768959\n type: 'test'\n ...\n# Subtest: a conflicting overlord transfer is liberated instead of creating a subject chain\nok 5 - a conflicting overlord transfer is liberated instead of creating a subject chain\n ---\n duration_ms: 1.938849\n type: 'test'\n ...\n1..5\n# tests 5\n# suites 0\n# pass 5\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 371.44327\n", + "codeState": { + "head": "0bd9af70d680be2e2c893b23f401c87584fe9e9b", + "diffHash": "b885d7b8fcba382df51077505b210f40d3e6d694973d8723ac2dbb067700b8e8" + } +} diff --git a/.noet/verification/VER-7.json b/.noet/verification/VER-7.json new file mode 100644 index 0000000..c79d86a --- /dev/null +++ b/.noet/verification/VER-7.json @@ -0,0 +1,16 @@ +{ + "id": "VER-7", + "command": "npm run report", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T01:01:47.249Z", + "finishedAt": "2026-07-15T01:03:24.869Z", + "durationMs": 97620, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 report\n> tsx scripts/headless-report.ts\n\n# galimulator-ng headless report\n\nSeed: 42 · Stars: 400 · Starting empires: 12\nShape: spiral · Lanes: standard · Layout: classic\nInitial graph: avg degree 3.84 · max degree 7 · avg lane length 18.8\nMilestones: 1000, 3000 ticks\n\n## Tick 1000\n\n- Living empires: 36 (8/12 original founders survive — 67% survival)\n- Owned systems: 346/400\n- Largest power: Dark Dominion (36 systems, 9.0% of map)\n- Active wars now: 10 · cumulative wars declared: 105\n- Alliances: 2 blocs · Trade routes: 25\n- Artifacts: 21 · Markers/scars: 425\n- Dynasties: 56 (53 living) · People: 241 · Deepest ruler chain: 4 — Solsorlar Order (House of Belcer)\n- Grievances: 8 active across 6 feuds (5 empires) · avg +30 tension, peak +30 · 5% of historical modifiers · oldest 373t\n- Graph now: avg degree 3.84 · max degree 7 · avg lane length 18.8\n- Churn since last milestone: 31 empire births+deaths\n- Cumulative: 5 founded, 26 collapsed, 1 merged, 43 rebellions, 22 coups, 0 transcended\n- Religion: 6 faiths · 1 founded · 18 state conversions\n - Spread: Loraz Mysteries (51), Elcerdor Communion (50), Disciples of Solsol (36)\n- Monsters: 1 at large · 2 spawned · 1 slain · Oddities roaming: 0 · Crises/oddity events: 1\n- Factions: 10 active (33 worlds, 2 near uprising) · 25 formed · 4 uprisings\n- Subjects: 2 active (1 protectorate, 1 tributary) · 4 created · 0 rebellions · 0 integrations · 0 liberations\n- Quests: 5 launched · 2 completed\n- Specialist ships: 1 missionary, 2 dropship, 1 gunstation active\n- Careers: 17 promotions · 11 falls · top house: Jarax (prestige 51, legitimacy +1.00)\n\n## Tick 3000\n\n- Living empires: 42 (0/12 original founders survive — 0% survival)\n- Owned systems: 292/400\n- Largest power: Lost Independence Movement (39 systems, 9.8% of map)\n- Active wars now: 14 · cumulative wars declared: 780\n- Alliances: 3 blocs · Trade routes: 37\n- Artifacts: 22 · Markers/scars: 1412\n- Dynasties: 176 (169 living) · People: 687 · Deepest ruler chain: 6 — Rogue Resistance (House of Zanpri)\n- Grievances: 14 active across 9 feuds (8 empires) · avg +30 tension, peak +30 · 6% of historical modifiers · oldest 304t\n- Graph now: avg degree 3.84 · max degree 7 · avg lane length 18.8\n- Churn since last milestone: 211 empire births+deaths\n- Cumulative: 34 founded, 208 collapsed, 9 merged, 145 rebellions, 88 coups, 0 transcended\n- Religion: 10 faiths · 5 founded · 74 state conversions\n - Spread: Elcerdor Communion (151), Loraz Mysteries (87), Song of Xardenvor (79)\n- Monsters: 2 at large · 9 spawned · 7 slain · Oddities roaming: 0 · Crises/oddity events: 8\n- Factions: 11 active (24 worlds, 2 near uprising) · 173 formed · 72 uprisings\n- Subjects: 5 active (2 vassal, 2 tributary, 1 protectorate) · 28 created · 4 rebellions · 0 integrations · 9 liberations\n- Quests: 23 launched · 7 completed\n- Specialist ships: 2 science, 2 gunstation, 2 dropship, 1 disruptor, 1 missionary active\n- Careers: 25 promotions · 48 falls · top house: Varar (prestige 49, legitimacy +0.20)\n\n", + "stderrTail": "\n✓ Determinism check passed — identical replay from the same seed.\n", + "codeState": { + "head": "0bd9af70d680be2e2c893b23f401c87584fe9e9b", + "diffHash": "b885d7b8fcba382df51077505b210f40d3e6d694973d8723ac2dbb067700b8e8" + } +} diff --git a/CLAUDE.md b/CLAUDE.md index c64bf30..24cb995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,15 @@ npm run preview # preview the production build locally npm run report # run the headless simulation report from the terminal (tsx) ``` -There is no unit-test framework. Simulation correctness is validated via -`runHeadlessReport()` in `src/sim/Headless.ts`, which is reachable two ways: +There is no third-party test framework, but `npm run test:unit` runs focused +correctness tests via Node's built-in test runner (`node --import tsx --test +src/sim/*.test.ts`) — currently covering the empire-merge subject/vassal +transfer branches in `src/sim/Merge.test.ts`. Prefer this for logic with +distinct branches that are cheap to hit directly (an invariant, an edge case) +rather than reachable only by chance in a long run. + +Broader simulation correctness is validated via `runHeadlessReport()` in +`src/sim/Headless.ts`, which is reachable two ways: - the UI's "Headless report" button, and - `npm run report` — a scriptable, no-render run of the same function diff --git a/package.json b/package.json index bb09612..db92b8a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "eslint .", "preview": "vite preview", "report": "tsx scripts/headless-report.ts", - "test": "tsx scripts/headless-report.ts --assert-health" + "test": "tsx scripts/headless-report.ts --assert-health", + "test:unit": "node --import tsx --test src/sim/*.test.ts" }, "dependencies": { "react": "^19.2.6", diff --git a/src/sim/Merge.test.ts b/src/sim/Merge.test.ts new file mode 100644 index 0000000..f0e742d --- /dev/null +++ b/src/sim/Merge.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { GalaxyState, Id } from "../types/sim"; +import { SeededRandom } from "./Random"; +import { generateGalaxy } from "./Galaxy"; +import { mergeEmpires } from "./Merge"; +import { createSubjectRelation, subjectOf, subjectsOf } from "./SubjectRelations"; + +/** A small deterministic galaxy with enough empires to wire up subject ties by hand. */ +function makeState(): GalaxyState { + const rng = new SeededRandom(1337); + return generateGalaxy(1337, 40, 6, rng); +} + +function empireIds(state: GalaxyState, count: number): Id[] { + const ids = Object.keys(state.empires); + assert.ok(ids.length >= count, `fixture needs at least ${count} empires, got ${ids.length}`); + return ids.slice(0, count); +} + +function assertNoDanglingReference(state: GalaxyState, removedId: Id): void { + for (const sr of Object.values(state.subjects ?? {})) { + assert.notEqual(sr.subjectEmpireId, removedId, `relation ${sr.id} still references removed empire as subject`); + assert.notEqual(sr.overlordEmpireId, removedId, `relation ${sr.id} still references removed empire as overlord`); + } +} + +test("absorbed empire's subject status transfers to the dominant empire", () => { + const state = makeState(); + const [dominant, absorbed, overlord] = empireIds(state, 3); + const sr = createSubjectRelation(state, absorbed, overlord, "vassal", state.tick); + assert.ok(sr, "setup: absorbed should become overlord's vassal"); + + mergeEmpires(state, dominant, absorbed); + + const rel = state.subjects![sr!.id]; + assert.ok(rel, "relation should survive the merge"); + assert.equal(rel.subjectEmpireId, dominant); + assert.equal(rel.overlordEmpireId, overlord); + assertNoDanglingReference(state, absorbed); +}); + +test("absorbed empire's own subjects transfer to the dominant empire as their new overlord", () => { + const state = makeState(); + const [dominant, absorbed, vassal] = empireIds(state, 3); + const sr = createSubjectRelation(state, vassal, absorbed, "tributary", state.tick); + assert.ok(sr, "setup: absorbed should become vassal's overlord"); + + mergeEmpires(state, dominant, absorbed); + + const rel = state.subjects![sr!.id]; + assert.ok(rel, "relation should survive the merge"); + assert.equal(rel.subjectEmpireId, vassal); + assert.equal(rel.overlordEmpireId, dominant); + assertNoDanglingReference(state, absorbed); +}); + +test("a direct subject tie between the dominant and absorbed empire collapses", () => { + const state = makeState(); + const [dominant, absorbed] = empireIds(state, 2); + const sr = createSubjectRelation(state, absorbed, dominant, "client-state", state.tick); + assert.ok(sr, "setup: absorbed should become dominant's client-state"); + + mergeEmpires(state, dominant, absorbed); + + assert.equal(state.subjects![sr!.id], undefined, "self-tie should be removed, not transferred"); + assertNoDanglingReference(state, absorbed); + assert.equal(subjectOf(state, dominant), null, "dominant must not end up subject of itself"); + assert.equal(subjectsOf(state, dominant).length, 0); +}); + +test("a conflicting subject transfer is liberated instead of creating a second overlord", () => { + const state = makeState(); + const [dominant, absorbed, overlord, thirdParty] = empireIds(state, 4); + // Dominant is already someone else's subject, so absorbed's own subject status cannot transfer. + const dominantTie = createSubjectRelation(state, dominant, thirdParty, "vassal", state.tick); + assert.ok(dominantTie, "setup: dominant should already be thirdParty's vassal"); + const absorbedTie = createSubjectRelation(state, absorbed, overlord, "tributary", state.tick); + assert.ok(absorbedTie, "setup: absorbed should be overlord's tributary"); + + mergeEmpires(state, dominant, absorbed); + + assert.equal(state.subjects![absorbedTie!.id], undefined, "conflicting tie should be liberated, not transferred"); + assertNoDanglingReference(state, absorbed); + // Dominant's own pre-existing subject relation must be untouched. + assert.ok(state.subjects![dominantTie!.id], "dominant's unrelated subject tie must survive"); + assert.equal(subjectsOf(state, dominant).length, 0, "dominant must not end up with two overlords or a subject of its own"); +}); + +test("a conflicting overlord transfer is liberated instead of creating a subject chain", () => { + const state = makeState(); + const [dominant, absorbed, vassal, thirdParty] = empireIds(state, 4); + // Dominant is already someone else's subject, so it cannot also become an overlord (no chains). + const dominantTie = createSubjectRelation(state, dominant, thirdParty, "vassal", state.tick); + assert.ok(dominantTie, "setup: dominant should already be thirdParty's vassal"); + const absorbedTie = createSubjectRelation(state, vassal, absorbed, "vassal", state.tick); + assert.ok(absorbedTie, "setup: absorbed should be vassal's overlord"); + + mergeEmpires(state, dominant, absorbed); + + assert.equal(state.subjects![absorbedTie!.id], undefined, "conflicting tie should be liberated, not transferred"); + assertNoDanglingReference(state, absorbed); + assert.ok(state.subjects![dominantTie!.id], "dominant's unrelated subject tie must survive"); + assert.equal(subjectOf(state, dominant)!.overlordEmpireId, thirdParty, "dominant must keep its one overlord, not gain a second role"); +}); diff --git a/src/sim/Merge.ts b/src/sim/Merge.ts index 5121199..8d830c7 100644 --- a/src/sim/Merge.ts +++ b/src/sim/Merge.ts @@ -1,6 +1,6 @@ import type { EmpireRelationship, GalaxyState, Id, RelationModifier } from "../types/sim"; import { createEvent } from "./Events"; -import { breakSubjectRelation, subjectOf, subjectsOf } from "./Subjects"; +import { breakSubjectRelation, subjectOf, subjectsOf } from "./SubjectRelations"; function mergeModifiers(a: RelationModifier[] = [], b: RelationModifier[] = []): RelationModifier[] { const byLabel = new Map(); diff --git a/src/sim/SubjectRelations.ts b/src/sim/SubjectRelations.ts new file mode 100644 index 0000000..a38f0f5 --- /dev/null +++ b/src/sim/SubjectRelations.ts @@ -0,0 +1,89 @@ +import type { GalaxyState, Id, SubjectRelation, SubjectStatus } from "../types/sim"; +import { createEvent } from "./Events"; + +/** + * Pure subject/vassal relation primitives: creation, queries, and dissolution. + * Kept free of any dependency on Merge.ts or Subjects.ts's per-tick stepping so + * that both can import these without forming a module cycle (Subjects.ts calls + * mergeEmpires for peaceful integration; Merge.ts needs these primitives to + * transfer subject bonds during a merge). + */ + +export const SUBJECT_STATUS_LABEL: Record = { + "vassal": "Vassal", + "protectorate": "Protectorate", + "tributary": "Tributary", + "client-state": "Client State", +}; + +/** Per-status defaults: where autonomy drifts, what tribute flows, and what the subject may still do. */ +export const STATUS_PROFILE: Record = { + "vassal": { autonomy: 0.4, tribute: 0.1, protection: true, wars: false, alliances: false }, + "protectorate": { autonomy: 0.75, tribute: 0.04, protection: true, wars: true, alliances: false }, + "tributary": { autonomy: 0.85, tribute: 0.12, protection: false, wars: true, alliances: true }, + "client-state": { autonomy: 0.35, tribute: 0.08, protection: true, wars: false, alliances: false }, +}; + +export function subjectOf(state: GalaxyState, empireId: Id): SubjectRelation | null { + for (const sr of Object.values(state.subjects ?? {})) if (sr.subjectEmpireId === empireId) return sr; + return null; +} + +export function subjectsOf(state: GalaxyState, overlordEmpireId: Id): SubjectRelation[] { + return Object.values(state.subjects ?? {}).filter(sr => sr.overlordEmpireId === overlordEmpireId); +} + +/** True when a subject tie binds the two empires in either direction. */ +export function isSubjectPair(state: GalaxyState, aId: Id, bId: Id): boolean { + return Object.values(state.subjects ?? {}).some(sr => + (sr.subjectEmpireId === aId && sr.overlordEmpireId === bId) || + (sr.subjectEmpireId === bId && sr.overlordEmpireId === aId)); +} + +export function createSubjectRelation( + state: GalaxyState, + subjectEmpireId: Id, + overlordEmpireId: Id, + status: SubjectStatus, + tick: number, + startLoyalty?: number +): SubjectRelation | null { + state.subjects ??= {}; + if (subjectEmpireId === overlordEmpireId) return null; + const subject = state.empires[subjectEmpireId]; + const overlord = state.empires[overlordEmpireId]; + if (!subject || !overlord) return null; + // one overlord per subject; no chains where the new overlord is itself a subject + if (subjectOf(state, subjectEmpireId) || subjectOf(state, overlordEmpireId)) return null; + // no cycles: the would-be subject must not already hold the overlord as its own subject + if (subjectsOf(state, subjectEmpireId).length > 0) return null; + const profile = STATUS_PROFILE[status]; + const id = `subject-${tick}-${subjectEmpireId}`; + const rel: SubjectRelation = { + id, subjectEmpireId, overlordEmpireId, status, createdTick: tick, + autonomy: profile.autonomy, loyalty: startLoyalty ?? 0.55, tributeRate: profile.tribute, + protection: profile.protection, canDeclareWars: profile.wars, canJoinAlliances: profile.alliances, + historicalEventIds: [], + }; + state.subjects[id] = rel; + const ev = createEvent(state, tick, "subject-created", + `${subject.name} became a ${SUBJECT_STATUS_LABEL[status].toLowerCase()} of ${overlord.name}`, + `${subject.name} now stands as a ${SUBJECT_STATUS_LABEL[status].toLowerCase()} under ${overlord.name}${profile.protection ? ", under its protection" : ""}${profile.tribute > 0 ? `, paying ${Math.round(profile.tribute * 100)}% tribute` : ""}.`, + 4, [subjectEmpireId, overlordEmpireId], []); + rel.historicalEventIds.push(ev.id); + return rel; +} + +export function breakSubjectRelation(state: GalaxyState, relationId: Id, reason: "rebellion" | "liberation" | "integration" | "collapse"): void { + const rel = state.subjects?.[relationId]; + if (!rel) return; + delete state.subjects![relationId]; + if (reason === "liberation") { + const subject = state.empires[rel.subjectEmpireId]; + if (subject) { + createEvent(state, state.tick, "subject-liberated", `${subject.name} regained independence`, + `With its overlord broken, ${subject.name} cast off its ${SUBJECT_STATUS_LABEL[rel.status].toLowerCase()} bonds and stands free.`, + 4, [rel.subjectEmpireId], []); + } + } +} diff --git a/src/sim/Subjects.ts b/src/sim/Subjects.ts index 0a370ee..a734372 100644 --- a/src/sim/Subjects.ts +++ b/src/sim/Subjects.ts @@ -1,87 +1,16 @@ -import type { GalaxyState, Id, Empire, PRNG, SubjectRelation, SubjectStatus } from "../types/sim"; +import type { GalaxyState, Empire, PRNG } from "../types/sim"; import { createEvent } from "./Events"; import { addRelationModifier, effectiveOpinion } from "./Relations"; import { getNeighboringEmpires } from "./Diplomacy"; import { mergeEmpires } from "./Merge"; - -export const SUBJECT_STATUS_LABEL: Record = { - "vassal": "Vassal", - "protectorate": "Protectorate", - "tributary": "Tributary", - "client-state": "Client State", -}; - -/** Per-status defaults: where autonomy drifts, what tribute flows, and what the subject may still do. */ -const STATUS_PROFILE: Record = { - "vassal": { autonomy: 0.4, tribute: 0.1, protection: true, wars: false, alliances: false }, - "protectorate": { autonomy: 0.75, tribute: 0.04, protection: true, wars: true, alliances: false }, - "tributary": { autonomy: 0.85, tribute: 0.12, protection: false, wars: true, alliances: true }, - "client-state": { autonomy: 0.35, tribute: 0.08, protection: true, wars: false, alliances: false }, -}; - -export function subjectOf(state: GalaxyState, empireId: Id): SubjectRelation | null { - for (const sr of Object.values(state.subjects ?? {})) if (sr.subjectEmpireId === empireId) return sr; - return null; -} - -export function subjectsOf(state: GalaxyState, overlordEmpireId: Id): SubjectRelation[] { - return Object.values(state.subjects ?? {}).filter(sr => sr.overlordEmpireId === overlordEmpireId); -} - -/** True when a subject tie binds the two empires in either direction. */ -export function isSubjectPair(state: GalaxyState, aId: Id, bId: Id): boolean { - return Object.values(state.subjects ?? {}).some(sr => - (sr.subjectEmpireId === aId && sr.overlordEmpireId === bId) || - (sr.subjectEmpireId === bId && sr.overlordEmpireId === aId)); -} - -export function createSubjectRelation( - state: GalaxyState, - subjectEmpireId: Id, - overlordEmpireId: Id, - status: SubjectStatus, - tick: number, - startLoyalty?: number -): SubjectRelation | null { - state.subjects ??= {}; - if (subjectEmpireId === overlordEmpireId) return null; - const subject = state.empires[subjectEmpireId]; - const overlord = state.empires[overlordEmpireId]; - if (!subject || !overlord) return null; - // one overlord per subject; no chains where the new overlord is itself a subject - if (subjectOf(state, subjectEmpireId) || subjectOf(state, overlordEmpireId)) return null; - // no cycles: the would-be subject must not already hold the overlord as its own subject - if (subjectsOf(state, subjectEmpireId).length > 0) return null; - const profile = STATUS_PROFILE[status]; - const id = `subject-${tick}-${subjectEmpireId}`; - const rel: SubjectRelation = { - id, subjectEmpireId, overlordEmpireId, status, createdTick: tick, - autonomy: profile.autonomy, loyalty: startLoyalty ?? 0.55, tributeRate: profile.tribute, - protection: profile.protection, canDeclareWars: profile.wars, canJoinAlliances: profile.alliances, - historicalEventIds: [], - }; - state.subjects[id] = rel; - const ev = createEvent(state, tick, "subject-created", - `${subject.name} became a ${SUBJECT_STATUS_LABEL[status].toLowerCase()} of ${overlord.name}`, - `${subject.name} now stands as a ${SUBJECT_STATUS_LABEL[status].toLowerCase()} under ${overlord.name}${profile.protection ? ", under its protection" : ""}${profile.tribute > 0 ? `, paying ${Math.round(profile.tribute * 100)}% tribute` : ""}.`, - 4, [subjectEmpireId, overlordEmpireId], []); - rel.historicalEventIds.push(ev.id); - return rel; -} - -export function breakSubjectRelation(state: GalaxyState, relationId: Id, reason: "rebellion" | "liberation" | "integration" | "collapse"): void { - const rel = state.subjects?.[relationId]; - if (!rel) return; - delete state.subjects![relationId]; - if (reason === "liberation") { - const subject = state.empires[rel.subjectEmpireId]; - if (subject) { - createEvent(state, state.tick, "subject-liberated", `${subject.name} regained independence`, - `With its overlord broken, ${subject.name} cast off its ${SUBJECT_STATUS_LABEL[rel.status].toLowerCase()} bonds and stands free.`, - 4, [rel.subjectEmpireId], []); - } - } -} +import { SUBJECT_STATUS_LABEL, STATUS_PROFILE, subjectOf, subjectsOf, createSubjectRelation, breakSubjectRelation } from "./SubjectRelations"; + +// Re-exported so existing call sites (Diplomacy.ts, Tick.ts, Simulation.ts, UI panels) +// can keep importing subject primitives from "./Subjects". The primitives themselves +// live in SubjectRelations.ts, which has no dependency on Merge.ts — Merge.ts imports +// them from there instead of from this file, breaking what would otherwise be a +// Merge.ts <-> Subjects.ts import cycle (this file already imports mergeEmpires). +export { SUBJECT_STATUS_LABEL, subjectOf, subjectsOf, isSubjectPair, createSubjectRelation, breakSubjectRelation } from "./SubjectRelations"; /** Force a war between two empires (used for subject rebellions and protective interventions). */ function setAtWar(a: Empire, b: Empire): void { diff --git a/tsconfig.app.json b/tsconfig.app.json index 7f42e5f..1546319 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,5 +21,6 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] }