fix: wrap GameOverScreen in ErrorBoundary and add defensive checks for day 30 blank page bug
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 2m25s

- Wrap GameOverScreen in ErrorBoundary in page.tsx to prevent blank page on render errors
- Add defensive Number.isFinite checks in GameOverScreen for all numeric props
- Add regression test for day 30 → game-over flow (day30-blank-page.test.ts)

Fixes #375
This commit is contained in:
2026-06-12 07:01:43 +02:00
parent 8b41f137d5
commit 608d4c4ff7
6 changed files with 229 additions and 7 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies # 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. Found: 4 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts 1. 1) data/guardian-encounters.ts > data/guardian-procedural.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_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.", "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." "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."
}, },
+1
View File
@@ -217,6 +217,7 @@ Mana-Loop/
│ │ │ │ ├── cross-module-lifecycle-consistency.test.ts │ │ │ │ ├── cross-module-lifecycle-consistency.test.ts
│ │ │ │ ├── cross-module-prestige-discipline.test.ts │ │ │ │ ├── cross-module-prestige-discipline.test.ts
│ │ │ │ ├── curse-amplification.test.ts │ │ │ │ ├── curse-amplification.test.ts
│ │ │ │ ├── day30-blank-page.test.ts
│ │ │ │ ├── design-validation-perk-gating.test.ts │ │ │ │ ├── design-validation-perk-gating.test.ts
│ │ │ │ ├── discipline-deactivate-on-spire-entry.test.ts │ │ │ │ ├── discipline-deactivate-on-spire-entry.test.ts
│ │ │ │ ├── discipline-math.test.ts │ │ │ │ ├── discipline-math.test.ts
+10 -4
View File
@@ -17,6 +17,12 @@ export function GameOverScreen({ day, hour, insightGained, totalInsight }: GameO
useGameStore.getState().startNewLoop(); 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 ( return (
<div className="fixed inset-0 game-overlay flex items-center justify-center z-50"> <div className="fixed inset-0 game-overlay flex items-center justify-center z-50">
<Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl"> <Card className="bg-gray-900 border-gray-600 max-w-md w-full mx-4 shadow-2xl">
@@ -32,19 +38,19 @@ export function GameOverScreen({ day, hour, insightGained, totalInsight }: GameO
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-800 rounded"> <div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-amber-400 game-mono">{fmt(insightGained)}</div> <div className="text-xl font-bold text-amber-400 game-mono">{fmt(safeInsightGained)}</div>
<div className="text-xs text-gray-400">Insight Gained</div> <div className="text-xs text-gray-400">Insight Gained</div>
</div> </div>
<div className="p-3 bg-gray-800 rounded"> <div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-blue-400 game-mono">{day}</div> <div className="text-xl font-bold text-blue-400 game-mono">{safeDay}</div>
<div className="text-xs text-gray-400">Day Reached</div> <div className="text-xs text-gray-400">Day Reached</div>
</div> </div>
<div className="p-3 bg-gray-800 rounded"> <div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-purple-400 game-mono">{formatHour(hour)}</div> <div className="text-xl font-bold text-purple-400 game-mono">{formatHour(safeHour)}</div>
<div className="text-xs text-gray-400">Hour</div> <div className="text-xs text-gray-400">Hour</div>
</div> </div>
<div className="p-3 bg-gray-800 rounded"> <div className="p-3 bg-gray-800 rounded">
<div className="text-xl font-bold text-green-400 game-mono">{fmt(totalInsight)}</div> <div className="text-xl font-bold text-green-400 game-mono">{fmt(safeTotalInsight)}</div>
<div className="text-xs text-gray-400">Total Insight</div> <div className="text-xs text-gray-400">Total Insight</div>
</div> </div>
</div> </div>
+9 -1
View File
@@ -169,7 +169,15 @@ export default function ManaLoopGame() {
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
if (gameOver) { if (gameOver) {
return <GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />; return (
<ErrorBoundary
onReset={() => {
useGameStore.getState().resetGame();
}}
>
<GameOverScreen day={day} hour={hour} insightGained={loopInsight} totalInsight={insight} />
</ErrorBoundary>
);
} }
if (!mounted) return <div className="p-4 text-center text-gray-400">Loading...</div>; if (!mounted) return <div className="p-4 text-center text-gray-400">Loading...</div>;
@@ -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);
});
});