Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .noet/CONSTRAINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions .noet/VERIFICATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# VERIFICATION

## Commands
- `npm run build`
- `npm run lint`
- `npm run test:unit`
- `npm run report`
15 changes: 15 additions & 0 deletions .noet/verification/VER-1.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions .noet/verification/VER-2.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 16 additions & 0 deletions .noet/verification/VER-3.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions .noet/verification/VER-4.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions .noet/verification/VER-5.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions .noet/verification/VER-6.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 16 additions & 0 deletions .noet/verification/VER-7.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions src/sim/Merge.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading
Loading