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