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