fix: sequence battle feedback and autosaves

This commit is contained in:
2026-07-29 03:49:21 +09:00
parent d4bd489b3d
commit 26ab244ad2
6 changed files with 2002 additions and 48 deletions

View File

@@ -47,6 +47,11 @@ const battleBaseKey = `heros-web:battle:${battleId}`;
const battleSlotKey = `${battleBaseKey}:slot-1`;
const legacyBattleKey = 'heros-web:first-battle-state';
const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey];
const otherBattleBaseKey =
'heros-web:battle:second-battle-yellow-turban-pursuit';
const otherBattleSlotKey = `${otherBattleBaseKey}:slot-1`;
const otherBattleCheckpoint =
'{"sentinel":"preserve-other-battle-checkpoint"}';
const baseUrl =
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ??
`http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`;
@@ -222,7 +227,10 @@ try {
staleMarkerHp,
validMarkerHp,
valid,
removalStats
removalStats,
restoreRemovalInterceptor() {
storagePrototype.removeItem = originalRemoveItem;
}
}
});
@@ -491,6 +499,606 @@ try {
assert.equal(restored.slotGeneration, campaignGeneration);
assert.equal(restored.campaignStep, campaignStep);
assert.equal(restored.slotStep, campaignStep);
const tutorialPendingEventProbe = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.scheduleFirstBattleTutorial !==
'function'
) {
throw new Error(
'The first-battle tutorial resume probe is unavailable.'
);
}
const originalStart =
scene.startFirstBattleTutorial;
const originalDelayedCall =
scene.time.delayedCall;
const originalState = {
phase: scene.phase,
activeFaction: scene.activeFaction,
turnNumber: scene.turnNumber,
firstBattleTutorialStep:
scene.firstBattleTutorialStep,
activeBattleEvent: scene.activeBattleEvent,
battleEventQueue: scene.battleEventQueue,
battleEventObjects: scene.battleEventObjects
};
let tutorialStartCalls = 0;
let retryCalls = 0;
try {
scene.phase = 'idle';
scene.activeFaction = 'ally';
scene.turnNumber = 1;
scene.firstBattleTutorialStep = undefined;
scene.activeBattleEvent = undefined;
scene.battleEventObjects = [];
scene.battleEventQueue = [{
key: 'qa-restored-pending-event',
title: 'Restored pending event',
lines: ['This event must display before tutorial input.'],
priority: 'normal',
playCue: false
}];
scene.startFirstBattleTutorial = () => {
tutorialStartCalls += 1;
return true;
};
scene.time.delayedCall = () => {
retryCalls += 1;
return { remove() {} };
};
scene.scheduleFirstBattleTutorial();
return {
tutorialStartCalls,
retryCalls,
pendingEventCount:
scene.battleEventQueue.length
};
} finally {
scene.startFirstBattleTutorial = originalStart;
scene.time.delayedCall = originalDelayedCall;
scene.phase = originalState.phase;
scene.activeFaction = originalState.activeFaction;
scene.turnNumber = originalState.turnNumber;
scene.firstBattleTutorialStep =
originalState.firstBattleTutorialStep;
scene.activeBattleEvent =
originalState.activeBattleEvent;
scene.battleEventQueue =
originalState.battleEventQueue;
scene.battleEventObjects =
originalState.battleEventObjects;
}
});
assert.deepEqual(
tutorialPendingEventProbe,
{
tutorialStartCalls: 0,
retryCalls: 1,
pendingEventCount: 1
},
`${renderer}: a restored pending battle event was not allowed to finish before the first-battle tutorial resumed.`
);
const autosaveContract = await page.evaluate(() => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
const battle = window.__HEROS_DEBUG__?.battle?.();
const fixtureState =
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
fixtureState?.restoreRemovalInterceptor?.();
return {
sceneMethod:
typeof scene?.persistBattleAutosave === 'function',
debugState:
battle?.autosave ?? null
};
});
assert.equal(
autosaveContract.sceneMethod,
true,
`${renderer}: BattleScene.persistBattleAutosave(reason) is unavailable.`
);
assertAutosaveDebugState(
autosaveContract.debugState,
`${renderer}: initial autosave debug state`
);
const actionBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const actionTrigger = await page.evaluate(() => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
if (
!scene ||
!unit ||
typeof scene.completeUnitAction !== 'function'
) {
throw new Error(
'The ally-action autosave trigger is unavailable.'
);
}
scene.phase = 'command';
scene.activeFaction = 'ally';
scene.selectedUnit = unit;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.completeUnitAction(unit, 'wait');
return {
phase:
window.__HEROS_DEBUG__?.battle?.()?.phase ?? null,
actedUnitIds: [
...(window.__HEROS_DEBUG__?.battle?.()?.actedUnitIds ?? [])
]
};
});
assert(
actionTrigger.actedUnitIds.includes('liu-bei'),
`${renderer}: the ally action did not complete.`
);
await page.waitForFunction(
({
requestedBattleSlotKey,
previousSavedAt,
previousWriteCount
}) => {
const persisted = JSON.parse(
window.localStorage.getItem(requestedBattleSlotKey) ??
'null'
);
const autosave =
window.__HEROS_DEBUG__?.battle?.()?.autosave;
return (
persisted?.savedAt &&
persisted.savedAt !== previousSavedAt &&
persisted.actedUnitIds?.includes('liu-bei') &&
autosave?.writeCount === previousWriteCount + 1 &&
autosave?.lastResult === 'saved' &&
autosave?.lastReason === 'ally-action'
);
},
{
requestedBattleSlotKey: battleSlotKey,
previousSavedAt: actionBaseline.state.savedAt,
previousWriteCount:
actionBaseline.autosave.writeCount
}
);
const actionAutosave = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assertAutosaveWrite(
actionBaseline,
actionAutosave,
{
reason: 'ally-action',
expectedTurnNumber: fixture.validTurnNumber,
expectedActedUnitId: 'liu-bei'
},
`${renderer}: ally action autosave`
);
const unsafeAutosaves = [];
for (const [
unsafeIndex,
unsafePhase
] of ['command', 'animating'].entries()) {
const beforeUnsafe = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const unsafeTurnNumber = 41 + unsafeIndex;
const eventProbe = await page.evaluate(
({
requestedPhase,
requestedTurnNumber
}) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
if (
!scene ||
!unit ||
typeof scene.persistBattleAutosave !== 'function'
) {
throw new Error(
'The unsafe pagehide autosave probe is unavailable.'
);
}
scene.turnNumber = requestedTurnNumber;
scene.activeFaction = 'ally';
scene.phase = requestedPhase;
scene.selectedUnit =
requestedPhase === 'command' ? unit : undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
window.dispatchEvent(new Event('pagehide'));
const battle =
window.__HEROS_DEBUG__?.battle?.();
return {
phase: battle?.phase ?? null,
turnNumber: battle?.turnNumber ?? null,
autosave: battle?.autosave ?? null
};
},
{
requestedPhase: unsafePhase,
requestedTurnNumber: unsafeTurnNumber
}
);
const afterUnsafe = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assert.equal(eventProbe.phase, unsafePhase);
assert.equal(
eventProbe.turnNumber,
unsafeTurnNumber
);
assertAutosaveDebugState(
eventProbe.autosave,
`${renderer}: ${unsafePhase} pagehide debug state`
);
assert.equal(
afterUnsafe.raw,
beforeUnsafe.raw,
`${renderer}: ${unsafePhase} pagehide overwrote the last safe checkpoint.`
);
assert.equal(
afterUnsafe.autosave.writeCount,
beforeUnsafe.autosave.writeCount,
`${renderer}: ${unsafePhase} pagehide incremented the autosave write count.`
);
assert.equal(
afterUnsafe.autosave.skipCount,
beforeUnsafe.autosave.skipCount + 1,
`${renderer}: ${unsafePhase} pagehide did not record one safe-state skip.`
);
assert.equal(
afterUnsafe.autosave.failureCount,
beforeUnsafe.autosave.failureCount,
`${renderer}: ${unsafePhase} pagehide was reported as a failure instead of a skip.`
);
assert.equal(
afterUnsafe.autosave.lastResult,
'skipped'
);
assert.equal(
afterUnsafe.autosave.lastReason,
'pagehide'
);
assert.equal(
afterUnsafe.autosave.lastSavedAt,
beforeUnsafe.state.savedAt
);
unsafeAutosaves.push({
phase: unsafePhase,
turnNumber: unsafeTurnNumber,
writeCount: afterUnsafe.autosave.writeCount,
skipCount: afterUnsafe.autosave.skipCount,
checkpointSavedAt: afterUnsafe.state.savedAt
});
}
const readinessBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const readinessTurnNumber = 42;
const readinessProbe = await page.evaluate(
(requestedTurnNumber) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (!scene) {
throw new Error(
'The autosave readiness probe is unavailable.'
);
}
scene.turnNumber = requestedTurnNumber;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.battleAutosaveReady = false;
window.dispatchEvent(new Event('pagehide'));
const blocked =
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
null;
scene.battleAutosaveReady = true;
return blocked;
},
readinessTurnNumber
);
const readinessAfter = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assertAutosaveDebugState(
readinessProbe,
`${renderer}: pre-initialization pagehide debug state`
);
assert.equal(
readinessAfter.raw,
readinessBaseline.raw,
`${renderer}: a pagehide before battle initialization replaced the last safe checkpoint.`
);
assert.equal(
readinessAfter.autosave.writeCount,
readinessBaseline.autosave.writeCount
);
assert.equal(
readinessAfter.autosave.skipCount,
readinessBaseline.autosave.skipCount + 1
);
assert.equal(
readinessAfter.autosave.lastResult,
'skipped'
);
assert.equal(
readinessAfter.autosave.lastReason,
'pagehide'
);
const idleBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const idleTurnNumber = 43;
const idleMarkerHp = Math.max(
1,
fixture.validMarkerHp - 1
);
await page.evaluate(
({
requestedTurnNumber,
requestedMarkerHp
}) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
if (
!scene ||
!unit ||
typeof scene.persistBattleAutosave !== 'function'
) {
throw new Error(
'The idle pagehide autosave probe is unavailable.'
);
}
scene.turnNumber = requestedTurnNumber;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
unit.hp = requestedMarkerHp;
window.dispatchEvent(new Event('pagehide'));
},
{
requestedTurnNumber: idleTurnNumber,
requestedMarkerHp: idleMarkerHp
}
);
await page.waitForFunction(
({
requestedBattleSlotKey,
previousSavedAt,
previousWriteCount,
expectedTurnNumber,
expectedMarkerHp
}) => {
const persisted = JSON.parse(
window.localStorage.getItem(requestedBattleSlotKey) ??
'null'
);
const autosave =
window.__HEROS_DEBUG__?.battle?.()?.autosave;
return (
persisted?.savedAt &&
persisted.savedAt !== previousSavedAt &&
persisted.turnNumber === expectedTurnNumber &&
persisted.units?.find(
(unit) => unit.id === 'liu-bei'
)?.hp === expectedMarkerHp &&
autosave?.writeCount === previousWriteCount + 1 &&
autosave?.lastResult === 'saved' &&
autosave?.lastReason === 'pagehide'
);
},
{
requestedBattleSlotKey: battleSlotKey,
previousSavedAt: idleBaseline.state.savedAt,
previousWriteCount:
idleBaseline.autosave.writeCount,
expectedTurnNumber: idleTurnNumber,
expectedMarkerHp: idleMarkerHp
}
);
const idleAutosave = await readAutosaveCheckpoint(
page,
battleSlotKey
);
assertAutosaveWrite(
idleBaseline,
idleAutosave,
{
reason: 'pagehide',
expectedTurnNumber: idleTurnNumber,
expectedActedUnitId: 'liu-bei',
expectedMarkerHp: idleMarkerHp
},
`${renderer}: ally idle pagehide autosave`
);
assert.equal(
idleAutosave.autosave.skipCount,
idleBaseline.autosave.skipCount,
`${renderer}: a safe idle pagehide was counted as skipped.`
);
const rollbackBaseline = await readAutosaveCheckpoint(
page,
battleSlotKey
);
const rollbackBaseRaw = await page.evaluate(
(requestedBattleBaseKey) =>
window.localStorage.getItem(requestedBattleBaseKey),
battleBaseKey
);
const rollbackProbe = await page.evaluate(
({
requestedBattleSlotKey,
requestedBattleBaseKey,
requestedOtherBattleSlotKey,
requestedOtherBattleBaseKey,
requestedOtherBattleCheckpoint
}) => {
const scene =
window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.persistBattleAutosave !== 'function'
) {
throw new Error(
'The autosave rollback probe is unavailable.'
);
}
scene.battleAutosaveReady = true;
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.turnNumber = 44;
window.localStorage.setItem(
requestedOtherBattleSlotKey,
requestedOtherBattleCheckpoint
);
window.localStorage.setItem(
requestedOtherBattleBaseKey,
requestedOtherBattleCheckpoint
);
const storagePrototype =
Object.getPrototypeOf(window.localStorage);
const originalSetItem = storagePrototype.setItem;
let injected = false;
storagePrototype.setItem = function setItem(
key,
value
) {
if (
this === window.localStorage &&
String(key) === requestedBattleSlotKey &&
!injected
) {
injected = true;
throw new DOMException(
'Injected autosave write refusal',
'QuotaExceededError'
);
}
return originalSetItem.call(this, key, value);
};
let saved;
try {
saved = scene.persistBattleAutosave('debug');
} finally {
storagePrototype.setItem = originalSetItem;
}
return {
saved,
injected,
slotRaw: window.localStorage.getItem(
requestedBattleSlotKey
),
baseRaw: window.localStorage.getItem(
requestedBattleBaseKey
),
otherBattleSlotRaw: window.localStorage.getItem(
requestedOtherBattleSlotKey
),
otherBattleBaseRaw: window.localStorage.getItem(
requestedOtherBattleBaseKey
),
autosave:
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
null
};
},
{
requestedBattleSlotKey: battleSlotKey,
requestedBattleBaseKey: battleBaseKey,
requestedOtherBattleSlotKey: otherBattleSlotKey,
requestedOtherBattleBaseKey: otherBattleBaseKey,
requestedOtherBattleCheckpoint:
otherBattleCheckpoint
}
);
assert.equal(rollbackProbe.injected, true);
assert.equal(rollbackProbe.saved, false);
assert.equal(
rollbackProbe.slotRaw,
rollbackBaseline.raw,
`${renderer}: a failed autosave did not restore the canonical slot checkpoint.`
);
assert.equal(
rollbackProbe.baseRaw,
rollbackBaseRaw,
`${renderer}: a failed autosave did not restore the slot-one compatibility checkpoint.`
);
assert.equal(
rollbackProbe.otherBattleSlotRaw,
otherBattleCheckpoint,
`${renderer}: a failed autosave discarded another battle's canonical checkpoint in the same campaign slot.`
);
assert.equal(
rollbackProbe.otherBattleBaseRaw,
otherBattleCheckpoint,
`${renderer}: a failed autosave discarded another battle's compatibility checkpoint in the same campaign slot.`
);
assert.equal(
rollbackProbe.autosave.writeCount,
rollbackBaseline.autosave.writeCount
);
assert.equal(
rollbackProbe.autosave.skipCount,
rollbackBaseline.autosave.skipCount
);
assert.equal(
rollbackProbe.autosave.failureCount,
rollbackBaseline.autosave.failureCount + 1
);
assert.equal(
rollbackProbe.autosave.lastResult,
'failed'
);
assert.equal(
rollbackProbe.autosave.lastReason,
'debug'
);
assert.equal(
rollbackProbe.autosave.lastSavedAt,
rollbackBaseline.state.savedAt
);
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
assert.equal(consoleErrors.length, 0, consoleErrors.join('\n'));
@@ -513,7 +1121,56 @@ try {
campaignGeneration,
saveGeneration: beforeControl.acceptedGeneration,
restoredTurnNumber: restored.turnNumber,
restoredMarkerHp: restored.markerHp
restoredMarkerHp: restored.markerHp,
pendingEventBeforeTutorial:
tutorialPendingEventProbe
},
autosave: {
allyAction: {
phaseAfterAction:
actionTrigger.phase,
turnNumber:
actionAutosave.state.turnNumber,
actedUnitIds:
actionAutosave.state.actedUnitIds,
savedAt:
actionAutosave.state.savedAt,
debug:
actionAutosave.autosave
},
unsafePagehide:
unsafeAutosaves,
initializationGuard: {
turnNumber: readinessTurnNumber,
debug: readinessAfter.autosave
},
idlePagehide: {
turnNumber:
idleAutosave.state.turnNumber,
markerHp:
idleAutosave.state.units.find(
(unit) => unit.id === 'liu-bei'
)?.hp ?? null,
savedAt:
idleAutosave.state.savedAt,
debug:
idleAutosave.autosave
},
failedWriteRollback: {
slotPreserved:
rollbackProbe.slotRaw ===
rollbackBaseline.raw,
basePreserved:
rollbackProbe.baseRaw ===
rollbackBaseRaw,
otherBattleSlotPreserved:
rollbackProbe.otherBattleSlotRaw ===
otherBattleCheckpoint,
otherBattleBasePreserved:
rollbackProbe.otherBattleBaseRaw ===
otherBattleCheckpoint,
debug: rollbackProbe.autosave
}
},
pageErrors: pageErrors.length,
consoleErrors: consoleErrors.length
@@ -576,6 +1233,159 @@ async function waitForBattleReady(page) {
);
}
async function readAutosaveCheckpoint(
page,
requestedBattleSlotKey
) {
const checkpoint = await page.evaluate(
(storageKey) => {
const raw = window.localStorage.getItem(storageKey);
const battle =
window.__HEROS_DEBUG__?.battle?.();
return {
raw,
state: JSON.parse(raw ?? 'null'),
autosave: battle?.autosave ?? null
};
},
requestedBattleSlotKey
);
assert(
checkpoint.raw,
`Missing battle autosave checkpoint at ${requestedBattleSlotKey}.`
);
assert(
checkpoint.state,
`Invalid battle autosave checkpoint at ${requestedBattleSlotKey}.`
);
assertAutosaveDebugState(
checkpoint.autosave,
`Autosave debug state for ${requestedBattleSlotKey}`
);
return checkpoint;
}
function assertAutosaveDebugState(
autosave,
label
) {
assert(
autosave && typeof autosave === 'object',
`${label} is unavailable.`
);
for (const field of [
'writeCount',
'skipCount',
'failureCount'
]) {
assert(
Number.isInteger(autosave[field]) &&
autosave[field] >= 0,
`${label}.${field} must be a non-negative integer.`
);
}
assert(
[
'saved',
'skipped',
'failed',
null
].includes(autosave.lastResult),
`${label}.lastResult is invalid.`
);
assert(
autosave.lastReason === null ||
typeof autosave.lastReason === 'string',
`${label}.lastReason is invalid.`
);
assert(
autosave.lastSlot === null ||
autosave.lastSlot === 1,
`${label}.lastSlot is invalid.`
);
assert(
autosave.lastSavedAt === null ||
(
typeof autosave.lastSavedAt === 'string' &&
Number.isFinite(
Date.parse(autosave.lastSavedAt)
)
),
`${label}.lastSavedAt is invalid.`
);
}
function assertAutosaveWrite(
before,
after,
{
reason,
expectedTurnNumber,
expectedActedUnitId,
expectedMarkerHp
},
label
) {
assert.notEqual(
after.raw,
before.raw,
`${label} did not update the active battle slot.`
);
assert.notEqual(
after.state.savedAt,
before.state.savedAt,
`${label} did not refresh savedAt.`
);
assert.equal(
after.state.turnNumber,
expectedTurnNumber,
`${label} saved the wrong turn.`
);
assert(
after.state.actedUnitIds.includes(
expectedActedUnitId
),
`${label} omitted the completed ally action.`
);
if (expectedMarkerHp !== undefined) {
assert.equal(
after.state.units.find(
(unit) => unit.id === 'liu-bei'
)?.hp,
expectedMarkerHp,
`${label} did not persist the live ally state.`
);
}
assert.equal(
after.autosave.writeCount,
before.autosave.writeCount + 1,
`${label} did not record exactly one write.`
);
assert.equal(
after.autosave.skipCount,
before.autosave.skipCount,
`${label} unexpectedly recorded a skip.`
);
assert.equal(
after.autosave.failureCount,
before.autosave.failureCount,
`${label} unexpectedly recorded a failure.`
);
assert.equal(
after.autosave.lastResult,
'saved'
);
assert.equal(
after.autosave.lastReason,
reason
);
assert.equal(after.autosave.lastSlot, 1);
assert.equal(
after.autosave.lastSavedAt,
after.state.savedAt
);
}
async function assertDesktopRuntime(page, expectedRenderer) {
const runtime = await page.evaluate(() => {
const canvas = document.querySelector('canvas');