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
@@ -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);
});
});