fix: resume campaign progress safely
This commit is contained in:
658
scripts/verify-sortie-preparation-checkpoint.mjs
Normal file
658
scripts/verify-sortie-preparation-checkpoint.mjs
Normal file
@@ -0,0 +1,658 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
let failCanonicalSlotWrites = false;
|
||||
let storageReadCount = 0;
|
||||
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
storageReadCount += 1;
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (
|
||||
failCanonicalSlotWrites &&
|
||||
String(key).includes(':slot-')
|
||||
) {
|
||||
throw new Error(
|
||||
'Simulated canonical campaign save-slot failure.'
|
||||
);
|
||||
}
|
||||
storage.set(String(key), String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(String(key));
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaign = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const battleSaves = await server.ssrLoadModule(
|
||||
'/src/game/state/battleSaveStorage.ts'
|
||||
);
|
||||
|
||||
verifyDefeatPreparationDurability();
|
||||
verifyVictoryPreparationAndStoryAtomicity();
|
||||
verifyCanonicalFailureAtomicity();
|
||||
verifyStaleCheckpointNormalization();
|
||||
verifySceneRoutingContracts();
|
||||
|
||||
console.log(
|
||||
'Verified durable sortie-improvement checkpoints for defeat retry and victory next-sortie flows: atomic story intent, title-over-battle-save priority, camp restoration, launch-time consumption, canonical-write failure invariance, stale-state normalization, and redundant default-story write avoidance.'
|
||||
);
|
||||
|
||||
function verifyDefeatPreparationDurability() {
|
||||
resetToBattle();
|
||||
campaign.setFirstBattleReport(
|
||||
reportFixture('defeat')
|
||||
);
|
||||
const before =
|
||||
campaign.getCampaignState();
|
||||
const prepared =
|
||||
campaign.beginCampaignSortiePreparation({
|
||||
mode: 'retry',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'first-battle-zhuo-commandery'
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
preparationProjection(prepared),
|
||||
{
|
||||
intent: 'sortie-improvement',
|
||||
mode: 'retry',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetCampaignStep: 'first-battle'
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
prepared.storyCheckpoint,
|
||||
undefined,
|
||||
'Defeat preparation must not invent an aftermath story.'
|
||||
);
|
||||
assertPersistedState(
|
||||
prepared,
|
||||
'defeat retry preparation'
|
||||
);
|
||||
|
||||
const readCountBeforeResume =
|
||||
storageReadCount;
|
||||
assert.equal(
|
||||
battleSaves.readCampaignBattleResume(
|
||||
prepared,
|
||||
prepared.activeSaveSlot,
|
||||
{
|
||||
getItem() {
|
||||
throw new Error(
|
||||
'A battle save must not be inspected while explicit preparation is pending.'
|
||||
);
|
||||
},
|
||||
setItem() {},
|
||||
removeItem() {}
|
||||
}
|
||||
),
|
||||
undefined,
|
||||
'Explicit sortie preparation must outrank a prior battle resume.'
|
||||
);
|
||||
assert.equal(
|
||||
storageReadCount,
|
||||
readCountBeforeResume,
|
||||
'The isolated resume probe unexpectedly touched browser storage.'
|
||||
);
|
||||
|
||||
const reloaded =
|
||||
campaign.loadCampaignState(
|
||||
prepared.activeSaveSlot
|
||||
);
|
||||
assert.deepEqual(
|
||||
preparationProjection(reloaded),
|
||||
preparationProjection(prepared),
|
||||
'Reload must preserve the exact retry preparation destination.'
|
||||
);
|
||||
|
||||
const launched = campaign.markCampaignStep(
|
||||
'first-battle',
|
||||
{ startBattle: true }
|
||||
);
|
||||
assert.equal(
|
||||
launched.sortiePreparationCheckpoint,
|
||||
undefined,
|
||||
'A committed retry launch must consume the preparation checkpoint.'
|
||||
);
|
||||
assert.equal(
|
||||
launched.battleResumeGeneration,
|
||||
before.battleResumeGeneration + 1,
|
||||
'Retry launch must advance the battle-resume generation atomically.'
|
||||
);
|
||||
assertPersistedState(
|
||||
launched,
|
||||
'retry launch consumption'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyVictoryPreparationAndStoryAtomicity() {
|
||||
resetToBattle();
|
||||
campaign.setFirstBattleReport(
|
||||
reportFixture('victory'),
|
||||
{
|
||||
storyCheckpoint:
|
||||
storyWrite('default')
|
||||
}
|
||||
);
|
||||
const settled =
|
||||
campaign.getCampaignState();
|
||||
const storyRevision =
|
||||
settled.storyCheckpoint.revision;
|
||||
const prepared =
|
||||
campaign.beginCampaignSortiePreparation(
|
||||
{
|
||||
mode: 'next-sortie',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'second-battle-yellow-turban-pursuit'
|
||||
},
|
||||
{
|
||||
storyCheckpoint:
|
||||
storyWrite(
|
||||
'sortie-improvement'
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
preparationProjection(prepared),
|
||||
{
|
||||
intent: 'sortie-improvement',
|
||||
mode: 'next-sortie',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'second-battle-yellow-turban-pursuit',
|
||||
targetCampaignStep: 'second-battle'
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
prepared.storyCheckpoint.revision,
|
||||
storyRevision,
|
||||
'Changing only the aftermath continuation intent must retain its exact story revision.'
|
||||
);
|
||||
assert.equal(
|
||||
prepared.storyCheckpoint
|
||||
.continuationIntent,
|
||||
'sortie-improvement'
|
||||
);
|
||||
assertPersistedState(
|
||||
prepared,
|
||||
'victory improvement and aftermath story'
|
||||
);
|
||||
|
||||
const handoff =
|
||||
campaign.markCampaignStoryHandoff(
|
||||
prepared.storyCheckpoint.sequenceId,
|
||||
prepared.storyCheckpoint.revision
|
||||
);
|
||||
const arrived =
|
||||
campaign.completeCampaignStoryHandoffAftermath(
|
||||
'first-battle-zhuo-commandery',
|
||||
{
|
||||
sequenceId:
|
||||
handoff.storyCheckpoint.sequenceId,
|
||||
revision:
|
||||
handoff.storyCheckpoint.revision
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
arrived.storyCheckpoint,
|
||||
undefined
|
||||
);
|
||||
assert(
|
||||
arrived.sortiePreparationCheckpoint,
|
||||
'Camp arrival must retain the explicit improvement overlay checkpoint.'
|
||||
);
|
||||
assertPersistedState(
|
||||
arrived,
|
||||
'victory camp arrival'
|
||||
);
|
||||
|
||||
const launched =
|
||||
campaign.beginCampaignStory(
|
||||
'second-battle',
|
||||
{
|
||||
version: 1,
|
||||
sequenceId:
|
||||
'second-battle-intro-fixture',
|
||||
beatId:
|
||||
'second-battle-intro-fixture',
|
||||
pageIndex: 0,
|
||||
continuationIntent: 'default'
|
||||
},
|
||||
{ startBattle: true }
|
||||
);
|
||||
assert.equal(
|
||||
launched.sortiePreparationCheckpoint,
|
||||
undefined,
|
||||
'Beginning the next sortie story must consume the preparation checkpoint in the same campaign write.'
|
||||
);
|
||||
assert.equal(
|
||||
launched.step,
|
||||
'second-battle'
|
||||
);
|
||||
assertPersistedState(
|
||||
launched,
|
||||
'next-sortie story launch consumption'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyCanonicalFailureAtomicity() {
|
||||
resetToBattle();
|
||||
campaign.setFirstBattleReport(
|
||||
reportFixture('defeat')
|
||||
);
|
||||
assertCanonicalWriteFailure(
|
||||
() =>
|
||||
campaign.beginCampaignSortiePreparation({
|
||||
mode: 'retry',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'first-battle-zhuo-commandery'
|
||||
}),
|
||||
'defeat sortie preparation'
|
||||
);
|
||||
|
||||
resetToBattle();
|
||||
campaign.setFirstBattleReport(
|
||||
reportFixture('victory'),
|
||||
{
|
||||
storyCheckpoint:
|
||||
storyWrite('default')
|
||||
}
|
||||
);
|
||||
assertCanonicalWriteFailure(
|
||||
() =>
|
||||
campaign.beginCampaignSortiePreparation(
|
||||
{
|
||||
mode: 'next-sortie',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'second-battle-yellow-turban-pursuit'
|
||||
},
|
||||
{
|
||||
storyCheckpoint:
|
||||
storyWrite(
|
||||
'sortie-improvement'
|
||||
)
|
||||
}
|
||||
),
|
||||
'victory sortie preparation and story intent'
|
||||
);
|
||||
}
|
||||
|
||||
function verifyStaleCheckpointNormalization() {
|
||||
resetToBattle();
|
||||
campaign.setFirstBattleReport(
|
||||
reportFixture('defeat')
|
||||
);
|
||||
const valid =
|
||||
campaign.beginCampaignSortiePreparation({
|
||||
mode: 'retry',
|
||||
sourceBattleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
targetBattleId:
|
||||
'first-battle-zhuo-commandery'
|
||||
});
|
||||
|
||||
for (const [label, mutate] of [
|
||||
[
|
||||
'advanced campaign step',
|
||||
(state) => {
|
||||
state.step = 'second-battle';
|
||||
}
|
||||
],
|
||||
[
|
||||
'mismatched target campaign step',
|
||||
(state) => {
|
||||
state.sortiePreparationCheckpoint
|
||||
.targetCampaignStep =
|
||||
'second-battle';
|
||||
}
|
||||
],
|
||||
[
|
||||
'forged non-next battle and matching step',
|
||||
(state) => {
|
||||
state.sortiePreparationCheckpoint
|
||||
.targetBattleId =
|
||||
'third-battle-guangzong-road';
|
||||
state.sortiePreparationCheckpoint
|
||||
.targetCampaignStep =
|
||||
'third-battle';
|
||||
}
|
||||
],
|
||||
[
|
||||
'wrong preparation mode',
|
||||
(state) => {
|
||||
state.sortiePreparationCheckpoint.mode =
|
||||
'next-sortie';
|
||||
}
|
||||
],
|
||||
[
|
||||
'invalid timestamp',
|
||||
(state) => {
|
||||
state.sortiePreparationCheckpoint.savedAt =
|
||||
'not-a-time';
|
||||
}
|
||||
]
|
||||
]) {
|
||||
const stale = structuredClone(valid);
|
||||
mutate(stale);
|
||||
const loaded = loadRawState(stale);
|
||||
assert.equal(
|
||||
loaded.sortiePreparationCheckpoint,
|
||||
undefined,
|
||||
`${label} must retire a stale sortie-preparation checkpoint.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySceneRoutingContracts() {
|
||||
const titleSource = sourceFile(
|
||||
'src/game/scenes/TitleScene.ts'
|
||||
);
|
||||
const continueSource = sourceSection(
|
||||
titleSource,
|
||||
'private async continueGameAsync(',
|
||||
'private async continueStoryCheckpoint('
|
||||
);
|
||||
assertOrder(
|
||||
continueSource,
|
||||
'await this.continueStoryCheckpoint(campaign)',
|
||||
'campaign.sortiePreparationCheckpoint',
|
||||
'Title must finish or resume aftermath story before restoring preparation.'
|
||||
);
|
||||
assertOrder(
|
||||
continueSource,
|
||||
'campaign.sortiePreparationCheckpoint',
|
||||
'readCampaignBattleResume(',
|
||||
'Explicit preparation must outrank an older battle save.'
|
||||
);
|
||||
|
||||
const campSource = sourceFile(
|
||||
'src/game/scenes/CampScene.ts'
|
||||
);
|
||||
const campInit = sourceSection(
|
||||
campSource,
|
||||
'init(data?: CampSceneData)',
|
||||
'\n create()'
|
||||
);
|
||||
assert(
|
||||
campInit.includes(
|
||||
'.sortiePreparationCheckpoint'
|
||||
) &&
|
||||
campInit.includes(
|
||||
'durablePreparation.targetBattleId'
|
||||
) &&
|
||||
campInit.includes(
|
||||
"durablePreparation.mode === 'retry'"
|
||||
),
|
||||
'Camp init must rebuild the exact improvement overlay and retry target from campaign state.'
|
||||
);
|
||||
|
||||
const battleSource = sourceFile(
|
||||
'src/game/scenes/BattleScene.ts'
|
||||
);
|
||||
const improvementSource = sourceSection(
|
||||
battleSource,
|
||||
'private openResultSortieImprovement(',
|
||||
'private publishFirstBattleReport('
|
||||
);
|
||||
assertOrder(
|
||||
improvementSource,
|
||||
'this.commitResultSortiePreparation(',
|
||||
'startLazyScene(this,',
|
||||
'Result navigation must not begin until its durable destination is committed.'
|
||||
);
|
||||
const storyCommitSource = sourceSection(
|
||||
battleSource,
|
||||
'private commitResultStoryCheckpoint(',
|
||||
'private handleResultContinue()'
|
||||
);
|
||||
assert(
|
||||
storyCommitSource.includes(
|
||||
"current.continuationIntent ===\n continuationIntent"
|
||||
) &&
|
||||
storyCommitSource.includes(
|
||||
'return true;'
|
||||
),
|
||||
'An already exact default aftermath checkpoint must avoid a redundant storage write.'
|
||||
);
|
||||
}
|
||||
|
||||
function resetToBattle() {
|
||||
failCanonicalSlotWrites = false;
|
||||
storage.clear();
|
||||
const state =
|
||||
campaign.createInitialCampaignState();
|
||||
state.step = 'first-battle';
|
||||
state.activeSaveSlot = 1;
|
||||
return campaign.setCampaignState(state);
|
||||
}
|
||||
|
||||
function reportFixture(outcome) {
|
||||
return {
|
||||
battleId:
|
||||
'first-battle-zhuo-commandery',
|
||||
battleTitle: '탁현 방어전',
|
||||
outcome,
|
||||
turnNumber: 3,
|
||||
rewardGold:
|
||||
outcome === 'victory' ? 100 : 0,
|
||||
defeatedEnemies:
|
||||
outcome === 'victory' ? 1 : 0,
|
||||
totalEnemies: 1,
|
||||
objectives: [],
|
||||
units: [],
|
||||
bonds: [],
|
||||
itemRewards: [],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt:
|
||||
'2026-07-29T01:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
function storyWrite(continuationIntent) {
|
||||
return {
|
||||
version: 1,
|
||||
sequenceId:
|
||||
'first-aftermath-fixture',
|
||||
beatId:
|
||||
'first-aftermath-fixture',
|
||||
pageIndex: 0,
|
||||
continuationIntent
|
||||
};
|
||||
}
|
||||
|
||||
function preparationProjection(state) {
|
||||
const checkpoint =
|
||||
state.sortiePreparationCheckpoint;
|
||||
assert(
|
||||
checkpoint,
|
||||
'Expected a sortie-preparation checkpoint.'
|
||||
);
|
||||
return {
|
||||
intent: checkpoint.intent,
|
||||
mode: checkpoint.mode,
|
||||
sourceBattleId:
|
||||
checkpoint.sourceBattleId,
|
||||
targetBattleId:
|
||||
checkpoint.targetBattleId,
|
||||
targetCampaignStep:
|
||||
checkpoint.targetCampaignStep
|
||||
};
|
||||
}
|
||||
|
||||
function loadRawState(state) {
|
||||
failCanonicalSlotWrites = false;
|
||||
storage.clear();
|
||||
const serialized = JSON.stringify(state);
|
||||
storage.set(
|
||||
campaign.campaignStorageKey,
|
||||
serialized
|
||||
);
|
||||
storage.set(
|
||||
slotKey(state.activeSaveSlot),
|
||||
serialized
|
||||
);
|
||||
return campaign.loadCampaignState(
|
||||
state.activeSaveSlot
|
||||
);
|
||||
}
|
||||
|
||||
function assertPersistedState(expected, label) {
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
expected,
|
||||
`${label}: memory diverged from the returned campaign.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
storage.get(
|
||||
campaign.campaignStorageKey
|
||||
)
|
||||
),
|
||||
expected,
|
||||
`${label}: compatibility mirror diverged.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
storage.get(
|
||||
slotKey(expected.activeSaveSlot)
|
||||
)
|
||||
),
|
||||
expected,
|
||||
`${label}: canonical slot diverged.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertCanonicalWriteFailure(
|
||||
action,
|
||||
label
|
||||
) {
|
||||
const before =
|
||||
persistentSnapshot();
|
||||
failCanonicalSlotWrites = true;
|
||||
try {
|
||||
assert.throws(
|
||||
action,
|
||||
/Simulated canonical campaign save-slot failure/,
|
||||
`${label} must surface its canonical write failure.`
|
||||
);
|
||||
} finally {
|
||||
failCanonicalSlotWrites = false;
|
||||
}
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
before.state,
|
||||
`${label} changed in-memory campaign state after failure.`
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(
|
||||
campaign.campaignStorageKey
|
||||
),
|
||||
before.base,
|
||||
`${label} changed the compatibility mirror after failure.`
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(
|
||||
slotKey(before.state.activeSaveSlot)
|
||||
),
|
||||
before.slot,
|
||||
`${label} changed the canonical slot after failure.`
|
||||
);
|
||||
}
|
||||
|
||||
function persistentSnapshot() {
|
||||
const state =
|
||||
campaign.getCampaignState();
|
||||
return {
|
||||
state,
|
||||
base: storage.get(
|
||||
campaign.campaignStorageKey
|
||||
),
|
||||
slot: storage.get(
|
||||
slotKey(state.activeSaveSlot)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function slotKey(slot) {
|
||||
return `${campaign.campaignStorageKey}:slot-${slot}`;
|
||||
}
|
||||
|
||||
function sourceFile(path) {
|
||||
return readFileSync(
|
||||
new URL(`../${path}`, import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function sourceSection(
|
||||
source,
|
||||
startMarker,
|
||||
endMarker
|
||||
) {
|
||||
const start = source.indexOf(startMarker);
|
||||
assert(
|
||||
start >= 0,
|
||||
`Missing source marker "${startMarker}".`
|
||||
);
|
||||
const end = source.indexOf(
|
||||
endMarker,
|
||||
start + startMarker.length
|
||||
);
|
||||
assert(
|
||||
end > start,
|
||||
`Missing source marker "${endMarker}".`
|
||||
);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function assertOrder(
|
||||
source,
|
||||
first,
|
||||
second,
|
||||
message
|
||||
) {
|
||||
const firstIndex = source.indexOf(first);
|
||||
const secondIndex =
|
||||
source.indexOf(second);
|
||||
assert(
|
||||
firstIndex >= 0 &&
|
||||
secondIndex > firstIndex,
|
||||
message
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
Reference in New Issue
Block a user