import assert from 'node:assert/strict'; import { createServer } from 'vite'; const storage = new Map(); globalThis.window = { localStorage: { getItem(key) { return storage.has(key) ? storage.get(key) : null; }, setItem(key, value) { storage.set(key, String(value)); }, removeItem(key) { storage.delete(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 { battleScenarios } = await server.ssrLoadModule( '/src/game/data/battles.ts' ); const firstPursuit = await server.ssrLoadModule( '/src/game/data/firstPursuitScoutMemory.ts' ); const secondRelief = await server.ssrLoadModule( '/src/game/data/secondBattleReliefExploration.ts' ); const thirdCamp = await server.ssrLoadModule( '/src/game/data/thirdCampExploration.ts' ); const fixtures = [ { visitId: firstPursuit.firstPursuitScoutVisitId, sourceBattleId: firstPursuit.firstPursuitScoutSourceBattleId, step: 'first-camp', progress: { title: '탁현 의용군 정찰 막사', meta: '정찰 탐색 이어하기' } }, { visitId: secondRelief.secondBattleReliefVisitId, sourceBattleId: secondRelief.secondBattleReliefSourceBattleId, step: 'second-camp', progress: { title: '북쪽 마을·나루 구호', meta: '현장 탐색 이어하기' } }, { visitId: thirdCamp.thirdCampExplorationVisitId, sourceBattleId: thirdCamp.thirdCampExplorationSourceBattleId, step: 'third-camp', progress: { title: '광종 연합군 주둔지', meta: '출진 준비 이어하기' } } ]; assert.deepEqual( [...campaign.campaignCampVisitIds], fixtures.map(({ visitId }) => visitId), 'The exported camp-visit allowlist must contain the three resumable visits in campaign order.' ); for (const fixture of fixtures) { verifyValidSetterPersistence(fixture); verifyNormalizationFailures(fixture); verifyCampaignStepClearsVisit(fixture); verifyBattleRecordUpdateClearsVisit(fixture, 'victory'); verifyBattleRecordUpdateClearsVisit(fixture, 'defeat'); } verifyCityMutualExclusion(); verifyLegacySaveWithoutCampVisitField(); console.log( 'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step and battle-record clearing, city mutual exclusion, and legacy-save compatibility.' ); function verifyValidSetterPersistence(fixture) { storage.clear(); campaign.setCampaignState(validCampVisitState(fixture, 2)); const activated = campaign.setActiveCampVisitId(fixture.visitId); assert.equal( activated.activeCampVisitId, fixture.visitId, `${fixture.visitId}: the valid setter call must activate the visit.` ); assert.equal( activated.activeCityStayId, undefined, `${fixture.visitId}: activating a camp visit must leave no active city stay.` ); assertPersistedVisit( fixture.visitId, 2, `${fixture.visitId}: setter persistence` ); const loadedFromBase = campaign.loadCampaignState(); assert.equal( loadedFromBase.activeCampVisitId, fixture.visitId, `${fixture.visitId}: base-save reload must retain the active visit.` ); const loadedFromSlot = campaign.loadCampaignState(2); assert.equal( loadedFromSlot.activeCampVisitId, fixture.visitId, `${fixture.visitId}: active-slot reload must retain the active visit.` ); assert.deepEqual( campaign.summarizeCampaignProgress(loadedFromSlot), fixture.progress, `${fixture.visitId}: campaign progress must describe the resumed exploration location.` ); const slotSummary = campaign .listCampaignSaveSlots() .find(({ slot }) => slot === 2); assert.deepEqual( { title: slotSummary?.progressTitle, meta: slotSummary?.progressMeta }, fixture.progress, `${fixture.visitId}: the active save-slot summary must expose the same resume location and meta.` ); const cleared = campaign.setActiveCampVisitId(); assert.equal( cleared.activeCampVisitId, undefined, `${fixture.visitId}: an empty setter call must clear the active visit.` ); assertPersistedVisit( undefined, 2, `${fixture.visitId}: explicit clear persistence` ); campaign.setCampaignState(validCampVisitState(fixture, 2)); campaign.setActiveCampVisitId(fixture.visitId); const invalidSetterResult = campaign.setActiveCampVisitId( 'unknown-camp-visit' ); assert.equal( invalidSetterResult.activeCampVisitId, undefined, `${fixture.visitId}: an unknown setter value must clear rather than retain a stale visit.` ); assertPersistedVisit( undefined, 2, `${fixture.visitId}: invalid setter persistence` ); } function verifyNormalizationFailures(fixture) { const invalidId = validCampVisitState(fixture); invalidId.activeCampVisitId = 'unknown-camp-visit'; assertNormalizedAway( invalidId, `${fixture.visitId}: unknown visit id` ); const wrongStep = validCampVisitState(fixture); wrongStep.step = 'fourth-camp'; assertNormalizedAway( wrongStep, `${fixture.visitId}: mismatched campaign step` ); const wrongLatestBattle = validCampVisitState(fixture); const alternateBattleId = alternateBattleFor(fixture.sourceBattleId); const alternateReport = battleReport(alternateBattleId, 'victory'); wrongLatestBattle.battleHistory[alternateBattleId] = battleSettlement(alternateReport); wrongLatestBattle.latestBattleId = alternateBattleId; assertNormalizedAway( wrongLatestBattle, `${fixture.visitId}: mismatched latest battle` ); const defeatedSettlement = validCampVisitState(fixture); defeatedSettlement.battleHistory[fixture.sourceBattleId].outcome = 'defeat'; assertNormalizedAway( defeatedSettlement, `${fixture.visitId}: defeated source settlement` ); const missingReport = validCampVisitState(fixture); delete missingReport.firstBattleReport; assertNormalizedAway( missingReport, `${fixture.visitId}: missing firstBattleReport` ); const defeatedReport = validCampVisitState(fixture); defeatedReport.firstBattleReport.outcome = 'defeat'; assertNormalizedAway( defeatedReport, `${fixture.visitId}: defeated firstBattleReport` ); const wrongReportBattle = validCampVisitState(fixture); wrongReportBattle.firstBattleReport = battleReport( alternateBattleFor(fixture.sourceBattleId), 'victory' ); assertNormalizedAway( wrongReportBattle, `${fixture.visitId}: mismatched firstBattleReport battle` ); const pendingAftermath = validCampVisitState(fixture); pendingAftermath.pendingAftermathBattleId = fixture.sourceBattleId; assertNormalizedAway( pendingAftermath, `${fixture.visitId}: pending victory aftermath` ); storage.clear(); const setterDuringAftermath = validCampVisitState(fixture); setterDuringAftermath.pendingAftermathBattleId = fixture.sourceBattleId; campaign.setCampaignState(setterDuringAftermath); assert.equal( campaign.setActiveCampVisitId(fixture.visitId) .activeCampVisitId, undefined, `${fixture.visitId}: a pending aftermath must reject exploration activation.` ); } function verifyCampaignStepClearsVisit(fixture) { storage.clear(); campaign.setCampaignState(validCampVisitState(fixture, 2)); campaign.setActiveCampVisitId(fixture.visitId); const changed = campaign.markCampaignStep('fourth-camp'); assert.equal( changed.activeCampVisitId, undefined, `${fixture.visitId}: changing to an incompatible campaign step must clear the active visit.` ); assertPersistedVisit( undefined, 2, `${fixture.visitId}: markCampaignStep persistence` ); } function verifyBattleRecordUpdateClearsVisit(fixture, outcome) { storage.clear(); campaign.setCampaignState(validCampVisitState(fixture, 2)); campaign.setActiveCampVisitId(fixture.visitId); campaign.setFirstBattleReport( battleReport(fixture.sourceBattleId, outcome) ); assert.equal( campaign.getCampaignState().activeCampVisitId, undefined, `${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.` ); assertPersistedVisit( undefined, 2, `${fixture.visitId}: ${outcome} battle-record persistence` ); } function verifyCityMutualExclusion() { const firstFixture = fixtures[0]; const campState = validCampVisitState(firstFixture, 2); campState.activeCampVisitId = firstFixture.visitId; campState.activeCityStayId = 'xuzhou'; const normalizedCamp = loadRawState(campState); assert.equal( normalizedCamp.activeCampVisitId, firstFixture.visitId, 'A valid camp visit must survive when a stale city marker is present.' ); assert.equal( normalizedCamp.activeCityStayId, undefined, 'A stale city marker must normalize away in a valid camp-visit context.' ); const cityState = validCityState(3); cityState.activeCityStayId = 'xuzhou'; cityState.activeCampVisitId = firstFixture.visitId; const normalizedCity = loadRawState(cityState); assert.equal( normalizedCity.activeCityStayId, 'xuzhou', 'A valid city stay must survive when a stale camp-visit marker is present.' ); assert.equal( normalizedCity.activeCampVisitId, undefined, 'A stale camp-visit marker must normalize away in a valid city context.' ); campaign.saveCampaignState(normalizedCity, 3); assert.equal( storedState(campaign.campaignStorageKey).activeCampVisitId, undefined, 'Persisting a normalized city state must remove the stale camp marker from the base save.' ); assert.equal( storedState(slotKey(3)).activeCampVisitId, undefined, 'Persisting a normalized city state must remove the stale camp marker from the active slot.' ); campaign.setCampaignState(validCampVisitState(firstFixture, 2)); campaign.setActiveCampVisitId(firstFixture.visitId); const invalidCitySetter = campaign.setActiveCityStayId('xuzhou'); assert.equal( invalidCitySetter.activeCampVisitId, firstFixture.visitId, 'An invalid city setter call must not erase a valid camp-visit resume marker.' ); campaign.setCampaignState(validCityState(3)); campaign.setActiveCityStayId('xuzhou'); const invalidCampSetter = campaign.setActiveCampVisitId( firstFixture.visitId ); assert.equal( invalidCampSetter.activeCityStayId, 'xuzhou', 'An invalid camp setter call must not erase a valid city-stay marker.' ); assert.equal( invalidCampSetter.activeCampVisitId, undefined, 'An invalid camp setter call in a city context must not create a camp marker.' ); } function verifyLegacySaveWithoutCampVisitField() { const fixture = fixtures[0]; const legacy = validCampVisitState(fixture, 1); delete legacy.activeCampVisitId; delete legacy.activeCityStayId; const loaded = loadRawState(legacy); assert.equal( loaded.activeCampVisitId, undefined, 'A legacy campaign save without activeCampVisitId must load safely.' ); assert.deepEqual( campaign.summarizeCampaignProgress(loaded), { title: battleScenarios[fixture.sourceBattleId].title, meta: '승리 후 군영' }, 'A legacy save without the new field must keep the established camp summary.' ); campaign.saveCampaignState(loaded, 1); assert.equal( Object.hasOwn(storedState(campaign.campaignStorageKey), 'activeCampVisitId'), false, 'Saving a legacy state must not synthesize an inactive camp-visit field in the base save.' ); assert.equal( Object.hasOwn(storedState(slotKey(1)), 'activeCampVisitId'), false, 'Saving a legacy state must not synthesize an inactive camp-visit field in the active slot.' ); const minimalLegacy = { updatedAt: '2026-07-01T00:00:00.000Z', step: 'first-camp', activeSaveSlot: 1, gold: 73 }; const minimalLoaded = loadRawState(minimalLegacy); assert.equal(minimalLoaded.version, 1); assert.equal(minimalLoaded.step, 'first-camp'); assert.equal(minimalLoaded.gold, 73); assert.equal(minimalLoaded.activeCampVisitId, undefined); } function assertNormalizedAway(state, label) { const slot = state.activeSaveSlot ?? 1; const loadedFromBase = loadRawState(state); assert.equal( loadedFromBase.activeCampVisitId, undefined, `${label}: base-save normalization must remove the active visit.` ); storage.clear(); const serialized = JSON.stringify(state); storage.set(campaign.campaignStorageKey, serialized); storage.set(slotKey(slot), serialized); const loadedFromSlot = campaign.loadCampaignState(slot); assert.equal( loadedFromSlot.activeCampVisitId, undefined, `${label}: slot-save normalization must remove the active visit.` ); campaign.saveCampaignState(loadedFromSlot, slot); assertPersistedVisit(undefined, slot, `${label}: normalized persistence`); } function assertPersistedVisit(expectedVisitId, slot, label) { const base = storedState(campaign.campaignStorageKey); const slotted = storedState(slotKey(slot)); assert(base, `${label}: base campaign save is missing.`); assert(slotted, `${label}: active slot save is missing.`); assert.equal( base.activeCampVisitId, expectedVisitId, `${label}: unexpected base-save active visit.` ); assert.equal( slotted.activeCampVisitId, expectedVisitId, `${label}: unexpected slot-save active visit.` ); assert.equal( base.activeSaveSlot, slot, `${label}: base save must retain the active slot.` ); assert.equal( slotted.activeSaveSlot, slot, `${label}: slot save must retain the active slot.` ); } function loadRawState(state) { storage.clear(); const serialized = JSON.stringify(state); storage.set(campaign.campaignStorageKey, serialized); storage.set(slotKey(state.activeSaveSlot ?? 1), serialized); return campaign.loadCampaignState(); } function validCampVisitState(fixture, slot = 1) { const report = battleReport(fixture.sourceBattleId, 'victory'); const state = campaign.createInitialCampaignState(); state.updatedAt = '2026-07-28T08:00:00.000Z'; state.step = fixture.step; state.activeSaveSlot = slot; state.gold = 420; state.roster = clone(report.units); state.bonds = clone(report.bonds); state.latestBattleId = fixture.sourceBattleId; state.firstBattleReport = report; state.battleHistory = { [fixture.sourceBattleId]: battleSettlement(report) }; state.activeCampVisitId = fixture.visitId; delete state.pendingAftermathBattleId; delete state.activeCityStayId; return state; } function validCityState(slot = 1) { const battleId = 'seventh-battle-xuzhou-rescue'; const report = battleReport(battleId, 'victory'); const state = campaign.createInitialCampaignState(); state.updatedAt = '2026-07-28T08:20:00.000Z'; state.step = 'seventh-camp'; state.activeSaveSlot = slot; state.gold = 640; state.roster = clone(report.units); state.bonds = clone(report.bonds); state.latestBattleId = battleId; state.firstBattleReport = report; state.battleHistory = { [battleId]: battleSettlement(report) }; delete state.pendingAftermathBattleId; return state; } function battleReport(battleId, outcome) { const scenario = battleScenarios[battleId]; assert(scenario, `Missing battle scenario fixture ${battleId}.`); const alliedUnits = scenario.units .filter((unit) => unit.faction === 'ally') .map((unit) => clone(unit)); return { battleId, battleTitle: scenario.title, outcome, turnNumber: 5, rewardGold: outcome === 'victory' ? 100 : 0, defeatedEnemies: outcome === 'victory' ? scenario.units.filter((unit) => unit.faction === 'enemy').length : 0, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: [], units: alliedUnits, bonds: scenario.bonds.map((bond) => ({ ...clone(bond), battleExp: 0 })), itemRewards: [], campaignRewards: { supplies: [], equipment: [], reputation: [], recruits: [], unlocks: [] }, completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-28T08:00:00.000Z' }; } function battleSettlement(report) { return { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, turnNumber: report.turnNumber, rewardGold: report.rewardGold, itemRewards: [...report.itemRewards], campaignRewards: clone(report.campaignRewards), objectives: [], units: [], bonds: [], completedAt: report.createdAt }; } function alternateBattleFor(sourceBattleId) { return Object.keys(battleScenarios).find( (battleId) => battleId !== sourceBattleId && !fixtures.some((fixture) => fixture.sourceBattleId === battleId) ); } function storedState(key) { const raw = storage.get(key); return raw ? JSON.parse(raw) : undefined; } function slotKey(slot) { return `${campaign.campaignStorageKey}:slot-${slot}`; } function clone(value) { return JSON.parse(JSON.stringify(value)); } } finally { await server.close(); }