diff --git a/.noet/CONSTRAINTS.md b/.noet/CONSTRAINTS.md index f8a7a8d..c9bacc1 100644 --- a/.noet/CONSTRAINTS.md +++ b/.noet/CONSTRAINTS.md @@ -9,6 +9,7 @@ - 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. +- Any new Id reference into state.events (modifiers, factions, subjects) must be added to gcEvents()'s referenced set in Events.ts. — gcEvents missed sourceEventId/historicalEventIds on modifiers, factions, subjects, silently corrupting grievance-age and legitimacy read-outs. ## Must-nots - Do not add a backend. diff --git a/.noet/verification/VER-10.json b/.noet/verification/VER-10.json new file mode 100644 index 0000000..4a47853 --- /dev/null +++ b/.noet/verification/VER-10.json @@ -0,0 +1,15 @@ +{ + "id": "VER-10", + "command": "npm run test:unit", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T14:59:24.410Z", + "finishedAt": "2026-07-15T14:59:25.139Z", + "durationMs": 729, + "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: gcEvents keeps an event still pointed at by a relationship modifier's sourceEventId\nok 1 - gcEvents keeps an event still pointed at by a relationship modifier's sourceEventId\n ---\n duration_ms: 9.907447\n type: 'test'\n ...\n# Subtest: gcEvents keeps an event still pointed at by a faction's historicalEventIds\nok 2 - gcEvents keeps an event still pointed at by a faction's historicalEventIds\n ---\n duration_ms: 15.200138\n type: 'test'\n ...\n# Subtest: gcEvents keeps an event still pointed at by a subject relation's historicalEventIds\nok 3 - gcEvents keeps an event still pointed at by a subject relation's historicalEventIds\n ---\n duration_ms: 3.427037\n type: 'test'\n ...\n# Subtest: gcEvents still drops events with no surviving reference at all\nok 4 - gcEvents still drops events with no surviving reference at all\n ---\n duration_ms: 3.126353\n type: 'test'\n ...\n# Subtest: absorbed empire's subject status transfers to the dominant empire\nok 5 - absorbed empire's subject status transfers to the dominant empire\n ---\n duration_ms: 12.717783\n type: 'test'\n ...\n# Subtest: absorbed empire's own subjects transfer to the dominant empire as their new overlord\nok 6 - absorbed empire's own subjects transfer to the dominant empire as their new overlord\n ---\n duration_ms: 5.005493\n type: 'test'\n ...\n# Subtest: a direct subject tie between the dominant and absorbed empire collapses\nok 7 - a direct subject tie between the dominant and absorbed empire collapses\n ---\n duration_ms: 5.138644\n type: 'test'\n ...\n# Subtest: a conflicting subject transfer is liberated instead of creating a second overlord\nok 8 - a conflicting subject transfer is liberated instead of creating a second overlord\n ---\n duration_ms: 4.709811\n type: 'test'\n ...\n# Subtest: a conflicting overlord transfer is liberated instead of creating a subject chain\nok 9 - a conflicting overlord transfer is liberated instead of creating a subject chain\n ---\n duration_ms: 2.806086\n type: 'test'\n ...\n1..9\n# tests 9\n# suites 0\n# pass 9\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 438.890456\n", + "codeState": { + "head": "f498391857766766a1ab9f4fb4bfdf9f5c7f6b0a", + "diffHash": "8adb2e39dd2f0354a3134159aa17a4298b84a8afe695bab4929c0f8738984ef9" + } +} diff --git a/.noet/verification/VER-11.json b/.noet/verification/VER-11.json new file mode 100644 index 0000000..9c7da85 --- /dev/null +++ b/.noet/verification/VER-11.json @@ -0,0 +1,16 @@ +{ + "id": "VER-11", + "command": "npm run report", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T14:59:25.164Z", + "finishedAt": "2026-07-15T15:01:09.011Z", + "durationMs": 103847, + "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 637t\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": "f498391857766766a1ab9f4fb4bfdf9f5c7f6b0a", + "diffHash": "8adb2e39dd2f0354a3134159aa17a4298b84a8afe695bab4929c0f8738984ef9" + } +} diff --git a/.noet/verification/VER-8.json b/.noet/verification/VER-8.json new file mode 100644 index 0000000..a1a24c9 --- /dev/null +++ b/.noet/verification/VER-8.json @@ -0,0 +1,15 @@ +{ + "id": "VER-8", + "command": "npm run build", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T14:59:06.043Z", + "finishedAt": "2026-07-15T14:59:11.887Z", + "durationMs": 5844, + "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-CeK-oW-A.js 471.27 kB │ gzip: 138.65 kB\n\n✓ built in 284ms\n", + "codeState": { + "head": "f498391857766766a1ab9f4fb4bfdf9f5c7f6b0a", + "diffHash": "8adb2e39dd2f0354a3134159aa17a4298b84a8afe695bab4929c0f8738984ef9" + } +} diff --git a/.noet/verification/VER-9.json b/.noet/verification/VER-9.json new file mode 100644 index 0000000..9d111c7 --- /dev/null +++ b/.noet/verification/VER-9.json @@ -0,0 +1,15 @@ +{ + "id": "VER-9", + "command": "npm run lint", + "exitCode": 0, + "source": "recorded", + "startedAt": "2026-07-15T14:59:11.912Z", + "finishedAt": "2026-07-15T14:59:24.366Z", + "durationMs": 12454, + "summary": "exit 0", + "stdoutTail": "\n> galimulator-ng@0.0.0 lint\n> eslint .\n\n", + "codeState": { + "head": "f498391857766766a1ab9f4fb4bfdf9f5c7f6b0a", + "diffHash": "8adb2e39dd2f0354a3134159aa17a4298b84a8afe695bab4929c0f8738984ef9" + } +} diff --git a/src/sim/Events.test.ts b/src/sim/Events.test.ts new file mode 100644 index 0000000..4a08ec9 --- /dev/null +++ b/src/sim/Events.test.ts @@ -0,0 +1,94 @@ +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 { createEvent, gcEvents } from "./Events"; +import { createSubjectRelation } from "./SubjectRelations"; +import { formFaction } from "./Tick"; + +function makeState(): GalaxyState { + const rng = new SeededRandom(2024); + return generateGalaxy(2024, 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); +} + +/** Push enough throwaway events, touching the given empires/systems, to age a prior + * event out of the eventLog and every empire/system history tail list. Isolates + * whichever longer-lived reference (modifier, faction, subject relation) we're + * testing gcEvents against as the *only* thing still pointing at it. */ +function flushWithFillerEvents(state: GalaxyState, relatedEmpireIds: Id[] = [], relatedSystemIds: Id[] = []): void { + for (let i = 0; i < 300; i++) { + createEvent(state, state.tick, "empire-founded", "filler", "filler", 1, relatedEmpireIds, relatedSystemIds); + } +} + +test("gcEvents keeps an event still pointed at by a relationship modifier's sourceEventId", () => { + const state = makeState(); + const [a, b] = empireIds(state, 2); + const empA = state.empires[a]; + + const modEvent = createEvent(state, state.tick, "diplomatic-incident", "incident", "d", 1, [], []); + empA.relationshipByEmpireId[b] = { + targetEmpireId: b, tension: 0, opinion: 50, atWar: false, + modifiers: [{ id: "relmod-test", kind: "grievance", label: "test grievance", opinionDelta: -10, tensionDelta: 20, sourceEventId: modEvent.id }], + }; + + flushWithFillerEvents(state); + gcEvents(state); + + assert.ok(state.events[modEvent.id], "event referenced only by a modifier's sourceEventId must survive GC"); +}); + +test("gcEvents keeps an event still pointed at by a faction's historicalEventIds", () => { + const state = makeState(); + const [a] = empireIds(state, 1); + const emp = state.empires[a]; + const sys = state.systems[emp.capitalSystemId]; + const rng = new SeededRandom(7); + + formFaction(state, emp, sys, rng); + const faction = Object.values(state.factions ?? {})[0]; + assert.ok(faction, "setup: formFaction should create a faction"); + const factionEventId = faction.historicalEventIds[0]; + assert.ok(factionEventId, "setup: faction should record its formation event"); + + // Evict the formation event from emp.historicalEventIds and sys.recentEventIds so + // the faction's own historicalEventIds is the only thing still pointing at it. + flushWithFillerEvents(state, [a], [sys.id]); + gcEvents(state); + + assert.ok(state.events[factionEventId], "event referenced only by a faction's historicalEventIds must survive GC"); +}); + +test("gcEvents keeps an event still pointed at by a subject relation's historicalEventIds", () => { + const state = makeState(); + const [subjectId, overlordId] = empireIds(state, 2); + + const rel = createSubjectRelation(state, subjectId, overlordId, "vassal", state.tick); + assert.ok(rel, "setup: subject relation should be created"); + const subjectEventId = rel!.historicalEventIds[0]; + assert.ok(subjectEventId, "setup: subject relation should record its creation event"); + + // Evict the creation event from both empires' historicalEventIds so the subject + // relation's own historicalEventIds is the only thing still pointing at it. + flushWithFillerEvents(state, [subjectId, overlordId]); + gcEvents(state); + + assert.ok(state.events[subjectEventId], "event referenced only by a subject relation's historicalEventIds must survive GC"); +}); + +test("gcEvents still drops events with no surviving reference at all", () => { + const state = makeState(); + const orphan = createEvent(state, state.tick, "empire-founded", "orphan", "d", 1, [], []); + + flushWithFillerEvents(state); + gcEvents(state); + + assert.equal(state.events[orphan.id], undefined, "an event with no surviving reference should still be collected"); +}); diff --git a/src/sim/Events.ts b/src/sim/Events.ts index ec8b6b6..44bedf9 100644 --- a/src/sim/Events.ts +++ b/src/sim/Events.ts @@ -27,16 +27,26 @@ function retainTail(ids: Id[], limit: number): Id[] { const DYNASTY_HISTORY_LIMIT = 12; -// The UI only renders the recent global log, a short per-empire history, a -// handful of recent system events, and each dynasty's own chronicle. Keep -// those references tight, then drop any event object no retained list still -// points at so snapshots stay bounded. +// Keep every list that still points at an event tight, then drop any event +// object no retained list or live pointer points at so snapshots stay bounded. +// Beyond the UI-rendered lists (global log, per-empire history, recent system +// events, dynasty chronicle), relation modifiers, factions, and subject +// relations all keep their own `sourceEventId`/`historicalEventIds` pointers +// into state.events for as long as they stay active — those must count as +// referenced too, or the sweep deletes events they still need (e.g. a +// grievance modifier that outlives the GC interval loses its source event, +// silently corrupting age/history read-outs). export function gcEvents(state: GalaxyState): void { state.eventLog = retainTail(state.eventLog, EVENT_LOG_LIMIT); const referenced = new Set(state.eventLog); for (const emp of Object.values(state.empires)) { emp.historicalEventIds = retainTail(emp.historicalEventIds, EMPIRE_HISTORY_LIMIT); for (const eid of emp.historicalEventIds) referenced.add(eid); + for (const rel of Object.values(emp.relationshipByEmpireId)) { + for (const m of rel.modifiers ?? []) { + if (m.sourceEventId) referenced.add(m.sourceEventId); + } + } } for (const sys of Object.values(state.systems)) { sys.recentEventIds = retainTail(sys.recentEventIds, SYSTEM_RECENT_LIMIT); @@ -46,6 +56,12 @@ export function gcEvents(state: GalaxyState): void { dyn.historicalEventIds = retainTail(dyn.historicalEventIds, DYNASTY_HISTORY_LIMIT); for (const eid of dyn.historicalEventIds) referenced.add(eid); } + for (const faction of Object.values(state.factions ?? {})) { + for (const eid of faction.historicalEventIds ?? []) referenced.add(eid); + } + for (const rel of Object.values(state.subjects ?? {})) { + for (const eid of rel.historicalEventIds ?? []) referenced.add(eid); + } for (const eid of Object.keys(state.events)) { if (!referenced.has(eid)) delete state.events[eid]; }