diff --git a/package.json b/package.json index 0a54499..8177806 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "generate:sfx": "node scripts/generate-sfx.mjs", "preview": "vite preview --host 0.0.0.0", "verify:flow": "node scripts/verify-flow.mjs", + "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs" }, "dependencies": { diff --git a/scripts/verify-save-retry-flow.mjs b/scripts/verify-save-retry-flow.mjs new file mode 100644 index 0000000..1713d3f --- /dev/null +++ b/scripts/verify-save-retry-flow.mjs @@ -0,0 +1,228 @@ +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; + +const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/'; + +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + + await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await page.screenshot({ path: 'dist/verification-save-title-empty.png', fullPage: true }); + + await page.mouse.click(962, 240); + await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); + await page.screenshot({ path: 'dist/verification-save-new-battle.png', fullPage: true }); + + const autoSave = await readCampaignSave(page); + if (autoSave.current?.step !== 'first-battle' || autoSave.slot1?.step !== 'first-battle') { + throw new Error(`Expected first battle auto-save in current and slot 1 saves: ${JSON.stringify(autoSave)}`); + } + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await page.screenshot({ path: 'dist/verification-save-title-continue.png', fullPage: true }); + + await page.mouse.click(962, 310); + await waitForBattleReady(page, 'first-battle-zhuo-commandery'); + const continuedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + if (continuedBattle?.battleOutcome !== null || continuedBattle?.resultVisible !== false || continuedBattle?.turnNumber !== 1) { + throw new Error(`Expected continue to reopen a clean first battle: ${JSON.stringify(continuedBattle)}`); + } + await page.screenshot({ path: 'dist/verification-save-continued-battle.png', fullPage: true }); + + await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('defeat')); + await waitForBattleOutcome(page, 'defeat'); + await page.screenshot({ path: 'dist/verification-save-defeat-result.png', fullPage: true }); + + const defeatSave = await readCampaignSave(page); + if (defeatSave.current?.step !== 'first-battle' || defeatSave.current?.firstBattleReport?.outcome !== 'defeat') { + throw new Error(`Expected defeat to save retry-ready first battle state: ${JSON.stringify(defeatSave.current)}`); + } + + await page.mouse.click(884, 642); + await waitForBattleReady(page, 'first-battle-zhuo-commandery'); + const retriedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const retriedLiuBei = retriedBattle?.units?.find((unit) => unit.id === 'liu-bei'); + const retriedEnemies = retriedBattle?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0) ?? []; + const damagedAlliesAfterRetry = retriedBattle?.units?.filter((unit) => unit.faction === 'ally' && unit.hp !== unit.maxHp) ?? []; + if ( + retriedBattle?.battleOutcome !== null || + retriedBattle?.resultVisible !== false || + retriedBattle?.turnNumber !== 1 || + !retriedLiuBei || + retriedLiuBei.hp !== retriedLiuBei.maxHp || + damagedAlliesAfterRetry.length > 0 || + retriedEnemies.length <= 0 + ) { + throw new Error(`Expected retry to restart battle without stale result or damage state: ${JSON.stringify(retriedBattle)}`); + } + await page.screenshot({ path: 'dist/verification-save-retry-battle.png', fullPage: true }); + + await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); + await waitForBattleOutcome(page, 'victory'); + await page.screenshot({ path: 'dist/verification-save-victory-result.png', fullPage: true }); + + await page.mouse.click(738, 642); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene'); + }, undefined, { timeout: 90000 }); + await page.screenshot({ path: 'dist/verification-save-victory-camp.png', fullPage: true }); + + const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const victorySave = await readCampaignSave(page); + if ( + campState?.scene !== 'CampScene' || + campState?.report?.rewardGold <= 0 || + victorySave.current?.step !== 'first-camp' || + victorySave.current?.firstBattleReport?.outcome !== 'victory' || + victorySave.current?.gold <= 0 + ) { + throw new Error(`Expected victory to return to camp with persisted rewards: ${JSON.stringify({ campState, victorySave })}`); + } + + console.log(`Verified new-game auto-save, browser reload continue, defeat retry, and victory camp return at ${targetUrl}`); +} finally { + await browser?.close(); + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +async function waitForTitle(page) { + await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); + await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 }); + await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('TitleScene'); + }, undefined, { timeout: 90000 }); +} + +async function advanceUntilBattle(page, battleId) { + for (let i = 0; i < 32; i += 1) { + const battleReady = await page.evaluate((expectedBattleId) => { + let state; + try { + state = window.__HEROS_DEBUG__?.battle(); + } catch { + state = undefined; + } + return ( + state?.scene === 'BattleScene' && + state?.battleId === expectedBattleId && + state?.battleOutcome === null && + state?.phase === 'idle' && + state?.mapBackgroundReady === true && + state?.resultVisible === false + ); + }, battleId); + + if (battleReady) { + return; + } + + await page.keyboard.press('Space'); + await page.waitForTimeout(220); + } + + await waitForBattleReady(page, battleId); +} + +async function waitForBattleReady(page, battleId) { + await page.waitForFunction((expectedBattleId) => { + let state; + try { + state = window.__HEROS_DEBUG__?.battle(); + } catch { + state = undefined; + } + return ( + state?.scene === 'BattleScene' && + state?.battleId === expectedBattleId && + state?.battleOutcome === null && + state?.phase === 'idle' && + state?.mapBackgroundReady === true && + state?.resultVisible === false + ); + }, battleId, { timeout: 90000 }); +} + +async function waitForBattleOutcome(page, outcome) { + await page.waitForFunction((expectedOutcome) => { + let state; + try { + state = window.__HEROS_DEBUG__?.battle(); + } catch { + state = undefined; + } + return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true; + }, outcome, { timeout: 90000 }); +} + +async function readCampaignSave(page) { + return page.evaluate(() => { + const read = (key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : undefined; + }; + return { + current: read('heros-web:campaign-state'), + slot1: read('heros-web:campaign-state:slot-1') + }; + }); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname); + if (!isLocal) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '5173'], { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + child.stdout.on('data', () => {}); + + for (let i = 0; i < 80; i += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + child.kill(); + throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`); +} + +async function canReach(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index d4e1f24..caca6ce 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -2789,8 +2789,8 @@ function applyScenarioAllyReadiness(unit: UnitData) { function configureBattleScenario(scenario: BattleScenarioDefinition) { battleScenario = scenario; battleMap = scenario.map; - battleUnits = scenario.units; - battleBonds = scenario.bonds; + battleUnits = scenario.units.map(cloneUnitData); + battleBonds = scenario.bonds.map(cloneBattleBond); battleSaveStorageKey = `heros-web:battle:${scenario.id}`; initialBattleUnits = battleUnits.map(cloneUnitData); initialBattleBonds = battleBonds.map(cloneBattleBond); diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 937b451..5ccd3df 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -475,7 +475,15 @@ export function setFirstBattleReport(report: FirstBattleReport) { const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery']; const victoryStep: CampaignStep = stepRule.victory; const retryStep: CampaignStep = stepRule.retry; - state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; + if (reportClone.outcome !== 'victory') { + state.step = retryStep; + state.latestBattleId = battleId; + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return; + } + + state.step = victoryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally')); state.bonds = reportClone.bonds.map(cloneBondSnapshot);