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
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:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -32,19 +38,19 @@ export function GameOverScreen({ day, hour, insightGained, totalInsight }: GameO
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
+9
-1
@@ -169,7 +169,15 @@ export default function ManaLoopGame() {
|
||||
useEffect(() => { setMounted(true); }, []); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
|
||||
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>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user