diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 43fc2b0..590d862 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -16,6 +16,7 @@ Mana-Loop/ │ ├── dependency-graph.json │ └── project-structure.txt ├── e2e/ +│ ├── combat-happy-path.spec.ts │ ├── enchanter-happy-path.spec.ts │ ├── fabricator-happy-path.spec.ts │ └── playtest.spec.ts diff --git a/e2e/combat-happy-path.spec.ts b/e2e/combat-happy-path.spec.ts new file mode 100644 index 0000000..d0acbfb --- /dev/null +++ b/e2e/combat-happy-path.spec.ts @@ -0,0 +1,325 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.use({ + baseURL: 'http://localhost:3000/', +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function waitForMs(page: Page, ms: number) { + await page.waitForTimeout(ms); +} + +async function startFreshGame(page: Page) { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await page.waitForLoadState('networkidle'); + await waitForMs(page, 3000); +} + +async function clickTab(page: Page, label: string) { + const tab = page.getByRole('tab', { name: new RegExp(label, 'i') }).first(); + await tab.click(); + await waitForMs(page, 400); +} + +async function clickBtn(page: Page, text: string) { + const btn = page.getByRole('button', { name: new RegExp(text, 'i') }).first(); + await btn.click(); + await waitForMs(page, 200); +} + +async function waitForBridge(page: Page) { + for (let attempt = 0; attempt < 30; attempt++) { + const ready = await page.evaluate(() => !!(window as any).__TEST__); + if (ready) return; + await waitForMs(page, 1000); + } + throw new Error('Debug bridge (window.__TEST__) not available after 30s'); +} + +// ─── Test ───────────────────────────────────────────────────────────────────── + +test.describe('Combat Happy-Path: Spire Climb → Combat → Mana Recovery → Exit', () => { + + test('climb spire, fight until mana drains, gather mana to recover, descend, exit', async ({ page }) => { + test.setTimeout(600_000); + + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 1: Start fresh game and wait for bridge + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 1: Starting fresh game...'); + await startFreshGame(page); + await waitForMs(page, 1500); + await waitForBridge(page); + console.log('[TEST] Bridge ready!'); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 2: Set up prerequisites via Debug tab + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 2: Setting up prerequisites...'); + await clickTab(page, 'debug'); + await waitForMs(page, 500); + + // ── 2a. Unlock all elements ────────────────────────────────────────────── + console.log('[TEST] 2a. Unlocking all elements...'); + const elementsHeader = page.locator('button', { hasText: /^Elements$/ }).first(); + if (await elementsHeader.isVisible({ timeout: 3000 })) { + await elementsHeader.click(); + await waitForMs(page, 300); + } + await clickBtn(page, 'Unlock All Elements'); + await waitForMs(page, 500); + + // ── 2b. Boost max mana via Raw Mana Mastery discipline XP ──────────────── + console.log('[TEST] 2b. Boosting max mana via Raw Mana Mastery XP...'); + await page.evaluate(() => { + const disc = (window as any).__TEST__.useDisciplineStore; + if (!disc) return; + const state = disc.getState(); + const existing = state.disciplines['raw-mastery']; + const newXP = (existing?.xp || 0) + 20000; + disc.setState({ + disciplines: { + ...state.disciplines, + 'raw-mastery': { id: 'raw-mastery', xp: newXP, paused: false }, + }, + totalXP: state.totalXP + 20000, + concurrentLimit: Math.max( + state.concurrentLimit, + Math.min(4 + Math.floor((state.totalXP + 20000) / 500), 7), + ), + }); + }); + await waitForMs(page, 300); + + const rawMasteryXP = await page.evaluate(() => + (window as any).__TEST__.useDisciplineStore.getState().disciplines?.['raw-mastery']?.xp || 0 + ); + console.log(`[TEST] Raw Mana Mastery XP: ${rawMasteryXP}`); + expect(rawMasteryXP).toBe(20000); + + // ── 2c. Fill mana to max ───────────────────────────────────────────────── + console.log('[TEST] 2c. Filling mana to max...'); + await clickBtn(page, 'Fill Mana'); + await waitForMs(page, 500); + + const manaAfterFill = await page.evaluate(() => + (window as any).__TEST__.useManaStore.getState().rawMana + ); + console.log(`[TEST] Raw mana after fill: ${manaAfterFill}`); + expect(manaAfterFill).toBeGreaterThan(0); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 3: Enter the Spire via "Climb the Spire" button + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 3: Entering the Spire...'); + await clickTab(page, 'spells'); + await waitForMs(page, 500); + + const climbBtn = page.getByRole('button', { name: /climb the spire/i }).first(); + await expect(climbBtn).toBeVisible({ timeout: 10000 }); + await climbBtn.click(); + await waitForMs(page, 2000); + + // Verify SpireCombatPage is showing + await expect(page.getByText('Floor 1').first()).toBeVisible({ timeout: 10000 }); + console.log('[TEST] Spire combat page loaded!'); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 4: Stay in combat — let the game auto-tick and fight + // manaBolt costs 3 raw mana per cast, deals 5 damage. + // Floor 1 HP = 151, so ~31 casts to clear = ~258 seconds. + // We let it fight for 120 seconds to clear several floors. + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 4: Fighting in the Spire...'); + + const startMana = await page.evaluate(() => + (window as any).__TEST__.useManaStore.getState().rawMana + ); + const startFloor = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().currentFloor + ); + console.log(`[TEST] Starting: Floor ${startFloor}, Mana ${startMana}`); + + console.log('[TEST] Letting combat run for 120 seconds...'); + await waitForMs(page, 120000); + + const floorAfterCombat = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().currentFloor + ); + const manaAfterCombat = await page.evaluate(() => + (window as any).__TEST__.useManaStore.getState().rawMana + ); + console.log(`[TEST] After combat: Floor ${floorAfterCombat}, Mana ${manaAfterCombat}`); + expect(floorAfterCombat).toBeGreaterThan(startFloor); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 5: Exit the Spire to access the Gather button on the main page + // The Gather button (ManaDisplay) is in the LeftPanel which is only + // rendered on the main game page, not in the SpireCombatPage view. + // We exit spire, gather mana, then re-enter. + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 5: Exiting spire to gather mana...'); + + // First descend to floor 1 (Exit Spire button only shows on floor 1) + for (let i = 0; i < 200; i++) { + const floorNow = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().currentFloor + ); + if (floorNow <= 1) break; + + const climbDownBtn = page.getByRole('button', { name: /climb down/i }).first(); + const btnVisible = await climbDownBtn.isVisible({ timeout: 2000 }).catch(() => false); + if (btnVisible) { + await climbDownBtn.click(); + await waitForMs(page, 500); + } else { + break; + } + } + + // Click Exit Spire button (visible on floor 1) + const exitBtn = page.getByRole('button', { name: /exit spire/i }).first(); + await expect(exitBtn).toBeVisible({ timeout: 10000 }); + await exitBtn.click(); + await waitForMs(page, 2000); + + const spireModeAfterExit = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().spireMode + ); + expect(spireModeAfterExit).toBe(false); + console.log('[TEST] Exited spire, back on main page'); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 6: Hold "Gather +X Mana" button to recover mana quickly + // The Gather button is in the LeftPanel's ManaDisplay component. + // Holding it fires gatherMana() via requestAnimationFrame loop. + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 6: Holding Gather button to recover mana...'); + + const gatherBtn = page.getByRole('button', { name: /gather.*mana/i }).first(); + await expect(gatherBtn).toBeVisible({ timeout: 10000 }); + + const manaBeforeGather = await page.evaluate(() => + (window as any).__TEST__.useManaStore.getState().rawMana + ); + console.log(`[TEST] Mana before gather: ${manaBeforeGather}`); + + // Hold the gather button for 10 seconds + await gatherBtn.hover(); + await page.mouse.down(); + await waitForMs(page, 10000); + await page.mouse.up(); + console.log('[TEST] Released Gather button.'); + + const manaAfterGather = await page.evaluate(() => + (window as any).__TEST__.useManaStore.getState().rawMana + ); + console.log(`[TEST] Mana after gathering: ${manaAfterGather}`); + expect(manaAfterGather).toBeGreaterThanOrEqual(manaBeforeGather); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 7: Re-enter the Spire and continue fighting with recovered mana + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 7: Re-entering the Spire with recovered mana...'); + + await clickTab(page, 'spells'); + await waitForMs(page, 500); + + const climbBtn2 = page.getByRole('button', { name: /climb the spire/i }).first(); + await expect(climbBtn2).toBeVisible({ timeout: 10000 }); + await climbBtn2.click(); + await waitForMs(page, 2000); + + await expect(page.getByText('Floor 1').first()).toBeVisible({ timeout: 10000 }); + console.log('[TEST] Re-entered spire!'); + + // Let combat continue + console.log('[TEST] Letting combat run for 60 more seconds...'); + await waitForMs(page, 60000); + + const floorAfterMoreCombat = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().currentFloor + ); + console.log(`[TEST] After more combat: Floor ${floorAfterMoreCombat}`); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 8: Descend the spire back to floor 1 + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 8: Descending to floor 1...'); + + for (let i = 0; i < 200; i++) { + const floorNow = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().currentFloor + ); + if (floorNow <= 1) break; + + const climbDownBtn = page.getByRole('button', { name: /climb down/i }).first(); + const btnVisible = await climbDownBtn.isVisible({ timeout: 2000 }).catch(() => false); + if (btnVisible) { + await climbDownBtn.click(); + await waitForMs(page, 500); + } else { + break; + } + } + + const floorAfterDescend = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().currentFloor + ); + console.log(`[TEST] Floor after descending: ${floorAfterDescend}`); + expect(floorAfterDescend).toBe(1); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 9: Exit the Spire + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 9: Exiting the Spire...'); + + const exitBtn2 = page.getByRole('button', { name: /exit spire/i }).first(); + await expect(exitBtn2).toBeVisible({ timeout: 10000 }); + await exitBtn2.click(); + await waitForMs(page, 2000); + + const spireModeFinal = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().spireMode + ); + expect(spireModeFinal).toBe(false); + + // Verify we are back on the main game page + await expect(page.getByRole('tab', { name: /spells/i }).first()).toBeVisible({ timeout: 10000 }); + console.log('[TEST] Back on main game page!'); + + // ══════════════════════════════════════════════════════════════════════════ + // STEP 10: Verify final state + // ══════════════════════════════════════════════════════════════════════════ + console.log('[TEST] Step 10: Verifying final state...'); + + const maxFloorReached = await page.evaluate(() => + (window as any).__TEST__.useCombatStore.getState().maxFloorReached + ); + const gameOver = await page.evaluate(() => + (window as any).__TEST__.useUIStore.getState().gameOver + ); + + console.log(`[TEST] MaxFloorReached: ${maxFloorReached}, GameOver: ${gameOver}`); + expect(maxFloorReached).toBeGreaterThanOrEqual(1); + expect(gameOver).toBe(false); + + // No React errors throughout the test + await waitForMs(page, 1000); + const reactErrors = errors.filter(e => + e.includes('React') || e.includes('Minified') || e.includes('Error #') + || e.includes('Maximum update depth') + ); + expect(reactErrors, `React errors: ${JSON.stringify(reactErrors)}`).toHaveLength(0); + + console.log('[TEST] ✅ Combat happy-path test passed!'); + }); +}); diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx index 7bbd333..9834da4 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx @@ -127,19 +127,26 @@ export function SpireCombatPage() { return base + floorBonus + randomVariation; }, [currentFloor, seededRandom]); - // Track the last floor+totalRooms combo we generated a room for. - // Prevents infinite re-render loop: without this guard, the effect - // fires → setCurrentRoom → store update → re-render → tick advances - // currentFloor → effect fires → ... (loop). + // Generate initial room when floor or room count changes. + // Uses a ref guard to prevent infinite re-render loops. + // Generate room on mount and when floor changes. + // Uses a ref guard to prevent infinite re-render loops. const lastGeneratedRef = useRef(null); useEffect(() => { const key = `${currentFloor}:${totalRooms}`; - if (lastGeneratedRef.current === key) return; // already generated + if (lastGeneratedRef.current === key) return; lastGeneratedRef.current = key; setRoomsCleared(0); const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms); setCurrentRoom(newRoom); - }, [currentFloor, totalRooms, setCurrentRoom]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentFloor, totalRooms]); + + // Reset the generation guard when the component mounts + // (e.g. when re-entering spire mode after exit) + useEffect(() => { + lastGeneratedRef.current = null; + }, []); const _handleRoomCleared = () => { const nextRoomIndex = roomsCleared + 1; diff --git a/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx b/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx index 7447e01..a1cc533 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireHeader.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores'; +import { useShallow } from 'zustand/react/shallow'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; @@ -32,7 +33,7 @@ export function SpireHeader({ isDescending, }: SpireHeaderProps) { const maxFloorReached = useCombatStore((s) => s.maxFloorReached); - const { insight } = usePrestigeStore((s) => ({ insight: s.insight })); + const insight = usePrestigeStore((s) => s.insight); const guardian = getGuardianForFloor(currentFloor); const isGuardian = isGuardianFloor(currentFloor);