test: add combat happy-path e2e test; fix SpireCombatPage infinite render loop
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s
This commit is contained in:
@@ -16,6 +16,7 @@ Mana-Loop/
|
|||||||
│ ├── dependency-graph.json
|
│ ├── dependency-graph.json
|
||||||
│ └── project-structure.txt
|
│ └── project-structure.txt
|
||||||
├── e2e/
|
├── e2e/
|
||||||
|
│ ├── combat-happy-path.spec.ts
|
||||||
│ ├── enchanter-happy-path.spec.ts
|
│ ├── enchanter-happy-path.spec.ts
|
||||||
│ ├── fabricator-happy-path.spec.ts
|
│ ├── fabricator-happy-path.spec.ts
|
||||||
│ └── playtest.spec.ts
|
│ └── playtest.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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -127,19 +127,26 @@ export function SpireCombatPage() {
|
|||||||
return base + floorBonus + randomVariation;
|
return base + floorBonus + randomVariation;
|
||||||
}, [currentFloor, seededRandom]);
|
}, [currentFloor, seededRandom]);
|
||||||
|
|
||||||
// Track the last floor+totalRooms combo we generated a room for.
|
// Generate initial room when floor or room count changes.
|
||||||
// Prevents infinite re-render loop: without this guard, the effect
|
// Uses a ref guard to prevent infinite re-render loops.
|
||||||
// fires → setCurrentRoom → store update → re-render → tick advances
|
// Generate room on mount and when floor changes.
|
||||||
// currentFloor → effect fires → ... (loop).
|
// Uses a ref guard to prevent infinite re-render loops.
|
||||||
const lastGeneratedRef = useRef<string | null>(null);
|
const lastGeneratedRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const key = `${currentFloor}:${totalRooms}`;
|
const key = `${currentFloor}:${totalRooms}`;
|
||||||
if (lastGeneratedRef.current === key) return; // already generated
|
if (lastGeneratedRef.current === key) return;
|
||||||
lastGeneratedRef.current = key;
|
lastGeneratedRef.current = key;
|
||||||
setRoomsCleared(0);
|
setRoomsCleared(0);
|
||||||
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
|
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
|
||||||
setCurrentRoom(newRoom);
|
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 _handleRoomCleared = () => {
|
||||||
const nextRoomIndex = roomsCleared + 1;
|
const nextRoomIndex = roomsCleared + 1;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
|
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@@ -32,7 +33,7 @@ export function SpireHeader({
|
|||||||
isDescending,
|
isDescending,
|
||||||
}: SpireHeaderProps) {
|
}: SpireHeaderProps) {
|
||||||
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
|
const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
|
||||||
const { insight } = usePrestigeStore((s) => ({ insight: s.insight }));
|
const insight = usePrestigeStore((s) => s.insight);
|
||||||
|
|
||||||
const guardian = getGuardianForFloor(currentFloor);
|
const guardian = getGuardianForFloor(currentFloor);
|
||||||
const isGuardian = isGuardianFloor(currentFloor);
|
const isGuardian = isGuardianFloor(currentFloor);
|
||||||
|
|||||||
Reference in New Issue
Block a user