Files
heros_web/scripts/verify-sortie-preparation-checkpoint.mjs

659 lines
16 KiB
JavaScript

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