Stabilize save retry flow

This commit is contained in:
2026-06-27 07:00:18 +09:00
parent c49a344d4d
commit 88207c3b6f
4 changed files with 240 additions and 3 deletions

View File

@@ -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": {

View File

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

View File

@@ -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);

View File

@@ -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);