diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt
index 618cc6b..c8585d5 100644
--- a/docs/circular-deps.txt
+++ b/docs/circular-deps.txt
@@ -1,5 +1,5 @@
# Circular Dependencies
-Generated: 2026-06-11T10:43:50.823Z
+Generated: 2026-06-11T14:10:00.590Z
Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json
index 7c835cc..064b973 100644
--- a/docs/dependency-graph.json
+++ b/docs/dependency-graph.json
@@ -1,6 +1,6 @@
{
"_meta": {
- "generated": "2026-06-11T10:43:48.464Z",
+ "generated": "2026-06-11T14:09:58.499Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
diff --git a/docs/project-structure.txt b/docs/project-structure.txt
index 1d6f09e..e47aaed 100644
--- a/docs/project-structure.txt
+++ b/docs/project-structure.txt
@@ -217,6 +217,7 @@ Mana-Loop/
│ │ │ │ ├── cross-module-lifecycle-consistency.test.ts
│ │ │ │ ├── cross-module-prestige-discipline.test.ts
│ │ │ │ ├── curse-amplification.test.ts
+│ │ │ │ ├── day30-blank-page.test.ts
│ │ │ │ ├── design-validation-perk-gating.test.ts
│ │ │ │ ├── discipline-deactivate-on-spire-entry.test.ts
│ │ │ │ ├── discipline-math.test.ts
diff --git a/src/app/components/GameOverScreen.tsx b/src/app/components/GameOverScreen.tsx
index 73ef777..fc3937f 100644
--- a/src/app/components/GameOverScreen.tsx
+++ b/src/app/components/GameOverScreen.tsx
@@ -17,6 +17,12 @@ export function GameOverScreen({ day, hour, insightGained, totalInsight }: GameO
useGameStore.getState().startNewLoop();
};
+ // Defensive: ensure all values are valid numbers (guard against render-time edge cases)
+ const safeDay = Number.isFinite(day) ? day : 0;
+ const safeHour = Number.isFinite(hour) ? hour : 0;
+ const safeInsightGained = Number.isFinite(insightGained) ? insightGained : 0;
+ const safeTotalInsight = Number.isFinite(totalInsight) ? totalInsight : 0;
+
return (
@@ -32,19 +38,19 @@ export function GameOverScreen({ day, hour, insightGained, totalInsight }: GameO
-
{fmt(insightGained)}
+
{fmt(safeInsightGained)}
Insight Gained
-
{day}
+
{safeDay}
Day Reached
-
{formatHour(hour)}
+
{formatHour(safeHour)}
Hour
-
{fmt(totalInsight)}
+
{fmt(safeTotalInsight)}
Total Insight
diff --git a/src/app/page.tsx b/src/app/page.tsx
index e6cad9a..ad8669f 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -169,7 +169,15 @@ export default function ManaLoopGame() {
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
if (gameOver) {
- return ;
+ return (
+ {
+ useGameStore.getState().resetGame();
+ }}
+ >
+
+
+ );
}
if (!mounted) return Loading...
;
diff --git a/src/lib/game/__tests__/day30-blank-page.test.ts b/src/lib/game/__tests__/day30-blank-page.test.ts
new file mode 100644
index 0000000..7edc7bc
--- /dev/null
+++ b/src/lib/game/__tests__/day30-blank-page.test.ts
@@ -0,0 +1,207 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { useGameStore } from '../stores/gameStore';
+import { useManaStore, makeInitialElements } from '../stores/manaStore';
+import { useCombatStore } from '../stores/combatStore';
+import { usePrestigeStore } from '../stores/prestigeStore';
+import { useUIStore } from '../stores/uiStore';
+import { useDisciplineStore } from '../stores/discipline-slice';
+import { useCraftingStore } from '../stores/craftingStore';
+import { useAttunementStore } from '../stores/attunementStore';
+import { HOURS_PER_TICK, MAX_DAY } from '../constants';
+import { getFloorMaxHP } from '../utils';
+import { fmt, formatHour } from '../utils/formatting';
+
+// ─── Full Store Reset ─────────────────────────────────────────────────────────
+
+function resetAllStores() {
+ useUIStore.setState({
+ paused: false,
+ gameOver: false,
+ victory: false,
+ logs: [],
+ });
+
+ useGameStore.setState({
+ day: 1,
+ hour: 0,
+ incursionStrength: 0,
+ containmentWards: 0,
+ initialized: true,
+ });
+
+ useManaStore.setState({
+ rawMana: 100,
+ meditateTicks: 0,
+ totalManaGathered: 0,
+ elements: makeInitialElements(50, {}),
+ });
+
+ useCombatStore.setState({
+ currentFloor: 1,
+ floorHP: getFloorMaxHP(1),
+ floorMaxHP: getFloorMaxHP(1),
+ maxFloorReached: 1,
+ activeSpell: 'manaBolt',
+ currentAction: 'meditate',
+ castProgress: 0,
+ spireMode: false,
+ currentRoom: { roomType: 'combat', enemies: [] },
+ clearedFloors: {},
+ climbDirection: null,
+ isDescending: false,
+ golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
+ equipmentSpellStates: [],
+ comboHitCount: 0,
+ floorHitCount: 0,
+ spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
+ activityLog: [],
+ achievements: { unlocked: [], progress: {} },
+ totalSpellsCast: 0,
+ totalDamageDealt: 0,
+ totalCraftsCompleted: 0,
+ });
+
+ usePrestigeStore.setState({
+ loopCount: 0,
+ insight: 0,
+ totalInsight: 0,
+ loopInsight: 0,
+ prestigeUpgrades: {},
+ pactSlots: 1,
+ defeatedGuardians: [],
+ signedPacts: [],
+ signedPactDetails: {},
+ pactRitualFloor: null,
+ pactRitualProgress: 0,
+ });
+
+ useDisciplineStore.setState({
+ disciplines: {},
+ activeIds: [],
+ concurrentLimit: 1,
+ totalXP: 0,
+ });
+
+ useCraftingStore.setState({
+ designs: {},
+ designProgress: 0,
+ designSlot2Active: false,
+ designProgress2: 0,
+ preparationProgress: 0,
+ applicationProgress: 0,
+ equipmentCraftingProgress: 0,
+ equipmentInstances: {},
+ equippedInstances: {},
+ lootInventory: { materials: {}, equipment: [], blueprints: [] },
+ unlockedEffects: [],
+ unlockedRecipes: [],
+ enchantmentPowerBonus: 0,
+ });
+
+ useAttunementStore.setState({
+ attunements: {
+ enchanter: { active: true, level: 1, xp: 0 },
+ },
+ });
+}
+
+// ═══════════════════════════════════════════════════════════════════════════════
+// DAY 30 BLANK PAGE BUG REPRODUCTION
+// ═══════════════════════════════════════════════════════════════════════════════
+
+describe('Day 30 Blank Page Bug', () => {
+ beforeEach(resetAllStores);
+
+ it('should not crash when setting day to 30 and running ticks to game over', () => {
+ // Reproduce: Debug panel sets day to 20, then day to 30
+ useGameStore.setState({ day: 20, hour: 0 });
+ useGameStore.setState({ day: 30, hour: 0 });
+
+ // Verify state is correct
+ expect(useGameStore.getState().day).toBe(30);
+ expect(useGameStore.getState().hour).toBe(0);
+ expect(useUIStore.getState().gameOver).toBe(false);
+
+ // Run ticks until game over (day > 30)
+ let ticks = 0;
+ const maxTicks = 1000;
+ while (!useUIStore.getState().gameOver && ticks < maxTicks) {
+ useGameStore.getState().tick();
+ ticks++;
+ }
+
+ // Game over should have triggered
+ expect(useUIStore.getState().gameOver).toBe(true);
+ expect(useUIStore.getState().victory).toBe(false);
+
+ // loopInsight should be a valid number (not undefined/NaN)
+ const loopInsight = usePrestigeStore.getState().loopInsight;
+ expect(typeof loopInsight).toBe('number');
+ expect(Number.isFinite(loopInsight)).toBe(true);
+
+ // day and hour should be valid
+ const day = useGameStore.getState().day;
+ const hour = useGameStore.getState().hour;
+ expect(typeof day).toBe('number');
+ expect(typeof hour).toBe('number');
+ expect(Number.isFinite(day)).toBe(true);
+ expect(Number.isFinite(hour)).toBe(true);
+
+ console.log(`Game over after ${ticks} ticks. Day=${day}, Hour=${hour}, LoopInsight=${loopInsight}`);
+ });
+
+ it('should produce valid GameOverScreen props after loop ends at day 30', () => {
+ // Set day to 30 directly (simulating debug panel)
+ useGameStore.setState({ day: 30, hour: 0 });
+
+ // Run ticks until game over
+ let ticks = 0;
+ const maxTicks = 1000;
+ while (!useUIStore.getState().gameOver && ticks < maxTicks) {
+ useGameStore.getState().tick();
+ ticks++;
+ }
+
+ expect(useUIStore.getState().gameOver).toBe(true);
+
+ // These are the exact props passed to GameOverScreen in page.tsx
+ const day = useGameStore.getState().day;
+ const hour = useGameStore.getState().hour;
+ const loopInsight = usePrestigeStore.getState().loopInsight;
+ const insight = usePrestigeStore.getState().insight;
+
+ // All must be finite numbers for fmt() and formatHour() to work
+ expect(Number.isFinite(day)).toBe(true);
+ expect(Number.isFinite(hour)).toBe(true);
+ expect(Number.isFinite(loopInsight)).toBe(true);
+ expect(Number.isFinite(insight)).toBe(true);
+
+ // fmt() should not throw
+ expect(() => fmt(loopInsight)).not.toThrow();
+ expect(() => fmt(insight)).not.toThrow();
+
+ // formatHour() should not throw
+ expect(() => formatHour(hour)).not.toThrow();
+ });
+
+ it('should handle game over when jumping from day 20 to day 30 with incursion', () => {
+ // Set up a more realistic state with some progress
+ useManaStore.setState({ rawMana: 500, totalManaGathered: 10000 });
+ useCombatStore.setState({ maxFloorReached: 50 });
+
+ // Jump to day 20, then day 30
+ useGameStore.setState({ day: 20, hour: 0 });
+ useGameStore.setState({ day: 30, hour: 0 });
+
+ // Run ticks until game over
+ let ticks = 0;
+ const maxTicks = 1000;
+ while (!useUIStore.getState().gameOver && ticks < maxTicks) {
+ useGameStore.getState().tick();
+ ticks++;
+ }
+
+ expect(useUIStore.getState().gameOver).toBe(true);
+ expect(usePrestigeStore.getState().loopInsight).toBeGreaterThan(0);
+ });
+});