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
|
# 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,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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user