import assert from 'node:assert/strict'; import { createServer } from 'vite'; const storage = new Map(); let rejectCanonicalSlotWrites = false; globalThis.window = { localStorage: { getItem(key) { return storage.has(key) ? storage.get(key) : null; }, setItem(key, value) { if ( rejectCanonicalSlotWrites && key === 'heros-web:campaign-state:slot-1' ) { throw new Error( 'Simulated canonical campaign save failure.' ); } storage.set(key, String(value)); }, removeItem(key) { storage.delete(key); }, clear() { storage.clear(); } } }; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true, hmr: false }, appType: 'custom' }); try { const campaign = await server.ssrLoadModule( '/src/game/state/campaignState.ts' ); const { battleScenarios } = await server.ssrLoadModule( '/src/game/data/battles.ts' ); const scenario = battleScenarios['first-battle-zhuo-commandery']; const report = { battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 4, rewardGold: 120, defeatedEnemies: 2, totalEnemies: 2, objectives: [], units: scenario.units.filter( (unit) => unit.faction === 'ally' ), bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: ['Bean x2', 'Iron Sword 1'], campaignRewards: { supplies: ['Bean x2'], equipment: ['Iron Sword 1'], reputation: [], recruits: [], unlocks: [] }, completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-28T07:00:00.000Z' }; campaign.resetCampaignState(); campaign.setFirstBattleReport(report); const firstSettlement = campaign.getCampaignState(); const initialSettlementProgress = battleHistoryProgressOf( firstSettlement, scenario.id ); assert.deepEqual( economyOf(firstSettlement), { gold: 120, inventory: { Bean: 2, 'Iron Sword': 1 } }, 'The first victory settlement must apply its ordinary economy exactly once.' ); campaign.setFirstBattleReport(report); const exactReplay = campaign.getCampaignState(); assert.deepEqual( economyOf(exactReplay), economyOf(firstSettlement), 'Replaying the exact same victory report must not duplicate gold or items.' ); const aftermathReading = campaign.setCampaignStoryCheckpoint({ version: 1, sequenceId: 'settlement-aftermath-fixture', beatId: 'settlement-aftermath-fixture', pageIndex: 0 }); const aftermathReference = { sequenceId: aftermathReading.storyCheckpoint.sequenceId, revision: aftermathReading.storyCheckpoint.revision }; const aftermathHandoff = campaign.markCampaignStoryHandoff( aftermathReference.sequenceId, aftermathReference.revision ); const beforeMismatchedAftermath = JSON.stringify(aftermathHandoff); const staleAftermath = campaign.completeCampaignStoryHandoffAftermath( scenario.id, { ...aftermathReference, revision: aftermathReference.revision + 1 } ); assert.equal( JSON.stringify(staleAftermath), beforeMismatchedAftermath, 'A stale handoff reference must consume neither the pending aftermath nor its story checkpoint.' ); const wrongBattleAftermath = campaign.completeCampaignStoryHandoffAftermath( 'second-battle-yellow-turban-pursuit', aftermathReference ); assert.equal( JSON.stringify(wrongBattleAftermath), beforeMismatchedAftermath, 'A mismatched battle must consume neither half of an aftermath handoff.' ); const completedAftermath = campaign.completeCampaignStoryHandoffAftermath( scenario.id, aftermathReference ); assert.equal( completedAftermath.pendingAftermathBattleId, undefined, 'An exact aftermath handoff must retire the pending settlement.' ); assert.equal( completedAftermath.storyCheckpoint, undefined, 'An exact aftermath handoff must retire its story checkpoint atomically.' ); assert.equal( completedAftermath.storyCheckpointRevision, aftermathReference.revision + 1, 'An exact aftermath handoff must invalidate its consumed revision.' ); campaign.setActiveCampVisitId( 'first-pursuit-scout-tent' ); campaign.setCampaignExplorationCheckpoint({ version: 1, scene: 'camp-visit-exploration', contextId: 'first-pursuit-scout-tent', player: { x: 824, y: 716, direction: 'east' } }); const consumed = campaign.getCampaignState(); const progressedUnit = consumed.roster[0]; const progressedBond = consumed.bonds[0]; assert(progressedUnit, 'Expected a settled allied unit.'); assert(progressedBond, 'Expected a settled campaign bond.'); consumed.gold = 0; consumed.inventory = {}; progressedUnit.level = Math.min( 99, progressedUnit.level + 1 ); progressedUnit.exp = 37; progressedUnit.equipment.weapon.level = Math.min( 9, progressedUnit.equipment.weapon.level + 1 ); progressedUnit.equipment.weapon.exp = 3; progressedBond.level = Math.min( 100, progressedBond.level + 1 ); progressedBond.exp = 41; progressedBond.battleExp += 11; campaign.setCampaignState(consumed); const consumedEconomy = economyOf( campaign.getCampaignState() ); const progressedContext = progressAndContextOf( campaign.getCampaignState(), progressedUnit.id, progressedBond.id ); campaign.setFirstBattleReport(report); const consumedExactReplay = campaign.getCampaignState(); assert.deepEqual( economyOf(consumedExactReplay), consumedEconomy, 'Replaying a settled victory must not resurrect its already-consumed gold or items.' ); assert.deepEqual( progressAndContextOf( consumedExactReplay, progressedUnit.id, progressedBond.id ), progressedContext, 'Replaying a settled victory must preserve post-settlement unit, bond, navigation, and exploration progress.' ); const consumedReplayProgress = reportAndSettlementProgressOf( consumedExactReplay, scenario.id ); assert.deepEqual( consumedReplayProgress.firstBattleReport.units.find( (unit) => unit.id === progressedUnit.id ), progressedContext.unit, 'A settled victory replay must refresh firstBattleReport unit progress from the live roster.' ); assert.deepEqual( consumedReplayProgress.firstBattleReport.bonds.find( (bond) => bond.id === progressedBond.id ), progressedContext.bond, 'A settled victory replay must refresh firstBattleReport bond progress from the live campaign bonds.' ); assert.deepEqual( consumedReplayProgress.battleHistory, initialSettlementProgress, 'A settled victory replay must preserve the original battleHistory unit and bond snapshots.' ); campaign.setFirstBattleReport({ ...report, rewardGold: 200, itemRewards: ['Bean x5', 'Iron Armor 1'], campaignRewards: { supplies: ['Bean x5'], equipment: ['Iron Armor 1'], reputation: [], recruits: [], unlocks: [] }, createdAt: '2026-07-28T07:05:00.000Z' }); const revisedReplay = campaign.getCampaignState(); assert.deepEqual( economyOf(revisedReplay), consumedEconomy, 'Revising an already-settled victory report must update history without replacing or resurrecting consumed economy.' ); assert.deepEqual( progressAndContextOf( revisedReplay, progressedUnit.id, progressedBond.id ), progressedContext, 'Revising a settled victory report must not rewind live unit, bond, navigation, or exploration state.' ); assert.deepEqual( reportAndSettlementProgressOf( revisedReplay, scenario.id ), consumedReplayProgress, 'Revising a settled victory report must preserve both firstBattleReport and battleHistory unit/bond progress.' ); const beforeStaleDefeat = campaign.getCampaignState(); const storedBeforeStaleDefeat = new Map(storage); const staleDefeatResult = campaign.setFirstBattleReport({ ...report, outcome: 'defeat', rewardGold: 0, itemRewards: [], createdAt: '2026-07-28T07:06:00.000Z' }); assert.equal( staleDefeatResult.outcome, 'victory', 'A late defeat callback must resolve to the already-settled victory report.' ); assert.deepEqual( campaign.getCampaignState(), beforeStaleDefeat, 'A late defeat callback must not rewind a settled victory step, active exploration context, roster, bonds, or economy.' ); assert.deepEqual( [...storage.entries()], [...storedBeforeStaleDefeat.entries()], 'A rejected late defeat callback must not write a replacement settlement.' ); const reportlessSettlement = campaign.getCampaignState(); delete reportlessSettlement.firstBattleReport; campaign.setCampaignState(reportlessSettlement); assertFailedCampaignMutationIsAtomic( campaign, 'settled victory report recovery', () => campaign.setFirstBattleReport(report) ); const beforeReportlessDefeat = campaign.getCampaignState(); const storedBeforeReportlessDefeat = new Map(storage); const reportlessDefeatResult = campaign.setFirstBattleReport({ ...report, outcome: 'defeat', rewardGold: 0, itemRewards: [], createdAt: '2026-07-28T07:07:00.000Z' }); assert.equal( reportlessDefeatResult.outcome, 'victory', 'A late defeat callback must still resolve to victory when the settled battle report is missing.' ); assert.equal( reportlessDefeatResult.rewardGold, beforeReportlessDefeat.battleHistory[ scenario.id ].rewardGold, 'A reportless late defeat must recover its return value from the authoritative victory settlement.' ); assert.deepEqual( campaign.getCampaignState(), beforeReportlessDefeat, 'A reportless late defeat must leave in-memory campaign state unchanged.' ); assert.deepEqual( [...storage.entries()], [...storedBeforeReportlessDefeat.entries()], 'A reportless late defeat must leave canonical and mirror storage unchanged.' ); const recoveredReport = campaign.setFirstBattleReport(report); const recoveredSettlement = campaign.getCampaignState(); assert.equal( recoveredReport.outcome, 'victory', 'A settled victory replay must restore a missing firstBattleReport.' ); assert.equal( recoveredSettlement.firstBattleReport?.outcome, 'victory', 'The recovered firstBattleReport must persist the settled victory outcome.' ); assert.equal( recoveredSettlement.firstBattleReport?.rewardGold, revisedReplay.battleHistory[ scenario.id ].rewardGold, 'Recovered report rewards must come from the authoritative settlement rather than stale replay input.' ); assert.deepEqual( economyOf(recoveredSettlement), consumedEconomy, 'Recovering a missing settled victory report must not resurrect consumed economy.' ); assert.deepEqual( reportAndSettlementProgressOf( recoveredSettlement, scenario.id ), consumedReplayProgress, 'Recovering a missing settled victory report must preserve firstBattleReport and battleHistory unit/bond progress.' ); const beforeMissingBond = campaign.getCampaignState(); const storedBeforeMissingBond = new Map(storage); const missingBondResult = campaign.applyCampVisitReward( 'verify-missing-bond-visit', { bondId: 'verify-missing-bond', bondExp: 10, gold: 90, itemRewards: ['Bean x3'] }, 'verify-missing-bond-choice' ); const afterMissingBond = campaign.getCampaignState(); assert.equal( missingBondResult, undefined, 'A camp visit reward that requests an unavailable bond must fail.' ); assert.deepEqual( completionAndEconomyOf(afterMissingBond), completionAndEconomyOf(beforeMissingBond), 'A missing requested bond must leave visit completion, choice history, gold, and inventory unchanged.' ); assert.deepEqual( [...storage.entries()], [...storedBeforeMissingBond.entries()], 'A rejected missing-bond reward must not persist any partial state.' ); const rollbackFixture = campaign.getCampaignState(); const rollbackBond = rollbackFixture.bonds.find( (bond) => bond.unitIds.every((unitId) => rollbackFixture.roster.some( (unit) => unit.id === unitId ) ) ); assert( rollbackBond, 'Expected a roster-backed bond for persistence rollback verification.' ); rollbackBond.level = 30; rollbackBond.exp = 0; rollbackFixture.selectedSortieUnitIds = [ ...rollbackBond.unitIds ]; campaign.setCampaignState(rollbackFixture); assertFailedCampaignMutationIsAtomic( campaign, 'sortie resonance selection', () => campaign.setCampaignSortieResonanceSelection( scenario.id, rollbackBond.id ) ); assertFailedCampaignMutationIsAtomic( campaign, 'reserve training focus', () => campaign.setCampaignReserveTrainingFocus( 'class-practice' ) ); assertFailedCampaignMutationIsAtomic( campaign, 'reserve training assignment', () => campaign.setCampaignReserveTrainingAssignment( rollbackFixture.roster[0].id, 'bond-practice' ) ); assertFailedCampaignMutationIsAtomic( campaign, 'victory reward notice dismissal', () => campaign.dismissCampaignVictoryRewardNotice( scenario.id ) ); const rollbackRecruit = structuredClone( rollbackFixture.roster[0] ); rollbackRecruit.id = 'verify-rollback-recruit'; rollbackRecruit.name = 'Rollback Recruit'; assertFailedCampaignMutationIsAtomic( campaign, 'roster expansion', () => campaign.ensureCampaignRosterUnits([ rollbackRecruit ]) ); console.log( 'Campaign settlement idempotency verification passed: settled victory replays preserve report/history progress and active context, missing reports recover safely, stale defeats are ignored, missing-bond camp rewards are atomic, and canonical save failures leave memory and storage unchanged.' ); } finally { await server.close(); } function economyOf(state) { return { gold: state.gold, inventory: { ...state.inventory } }; } function completionAndEconomyOf(state) { return { gold: state.gold, inventory: { ...state.inventory }, completedCampVisits: [ ...state.completedCampVisits ], reportCompletedCampVisits: [ ...(state.firstBattleReport ?.completedCampVisits ?? []) ], campVisitChoiceIds: { ...state.campVisitChoiceIds } }; } function progressAndContextOf(state, unitId, bondId) { const unit = state.roster.find( (candidate) => candidate.id === unitId ); const bond = state.bonds.find( (candidate) => candidate.id === bondId ); return { step: state.step, latestBattleId: state.latestBattleId ?? null, pendingAftermathBattleId: state.pendingAftermathBattleId ?? null, activeCampVisitId: state.activeCampVisitId ?? null, activeCityStayId: state.activeCityStayId ?? null, explorationCheckpoint: state.explorationCheckpoint ?? null, unit: unit ? { id: unit.id, level: unit.level, exp: unit.exp, equipment: structuredClone(unit.equipment) } : null, bond: bond ? { id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp } : null }; } function reportAndSettlementProgressOf( state, battleId ) { return { firstBattleReport: { units: unitProgressOf( state.firstBattleReport?.units ?? [] ), bonds: bondProgressOf( state.firstBattleReport?.bonds ?? [] ) }, battleHistory: battleHistoryProgressOf( state, battleId ) }; } function battleHistoryProgressOf(state, battleId) { const settlement = state.battleHistory[battleId]; return { units: unitProgressOf(settlement?.units ?? []), bonds: bondProgressOf(settlement?.bonds ?? []) }; } function unitProgressOf(units) { return units .map((unit) => ({ id: unit.id ?? unit.unitId, level: unit.level, exp: unit.exp, equipment: structuredClone(unit.equipment) })) .sort((left, right) => left.id.localeCompare(right.id) ); } function bondProgressOf(bonds) { return bonds .map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp })) .sort((left, right) => left.id.localeCompare(right.id) ); } function assertFailedCampaignMutationIsAtomic( campaign, label, mutate ) { const before = campaign.getCampaignState(); const storedBefore = new Map(storage); rejectCanonicalSlotWrites = true; try { assert.throws( mutate, /Simulated canonical campaign save failure/, `${label} must surface the canonical save failure.` ); } finally { rejectCanonicalSlotWrites = false; } assert.deepEqual( campaign.getCampaignState(), before, `${label} must not leave ghost in-memory state after a failed canonical save.` ); assert.deepEqual( [...storage.entries()], [...storedBefore.entries()], `${label} must not mutate canonical or mirror storage after a failed canonical save.` ); }