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
1 change: 1 addition & 0 deletions .noet/CONSTRAINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions .noet/verification/VER-10.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 16 additions & 0 deletions .noet/verification/VER-11.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions .noet/verification/VER-8.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
15 changes: 15 additions & 0 deletions .noet/verification/VER-9.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
94 changes: 94 additions & 0 deletions src/sim/Events.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
24 changes: 20 additions & 4 deletions src/sim/Events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Id>(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);
Expand All @@ -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];
}
Expand Down
Loading