import { readFile } from 'node:fs/promises'; import { createServer } from 'vite'; const storage = new Map(); let rejectStorageWrites = false; globalThis.window = { localStorage: { getItem(key) { return storage.has(key) ? storage.get(key) : null; }, setItem(key, value) { if (rejectStorageWrites) { throw new Error('Simulated campaign storage write 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 memoryModule = await server.ssrLoadModule( '/src/game/data/firstPursuitScoutMemory.ts' ); const visitModule = await server.ssrLoadModule( '/src/game/data/firstPursuitScoutVisit.ts' ); const campaignModule = await server.ssrLoadModule( '/src/game/state/campaignState.ts' ); const actionModule = await server.ssrLoadModule( '/src/game/state/firstPursuitScoutActions.ts' ); const { battleScenarios } = await server.ssrLoadModule( '/src/game/data/battles.ts' ); verifyCanonicalIds(memoryModule); verifyCanonicalVisitDefinition(memoryModule, visitModule); verifyValidChoices(memoryModule, campaignModule, battleScenarios); verifyInvalidMarkers(memoryModule, campaignModule, battleScenarios); verifyTargetIsolation(memoryModule, campaignModule, battleScenarios); verifyGuardedExplorationCompletion( memoryModule, visitModule, campaignModule, actionModule, battleScenarios ); await verifyCampExplorationWiring(memoryModule, visitModule); console.log( 'First-pursuit scout verification passed (canonical visit data, guarded exploration completion, strict memory validation, target isolation, and camp-scene wiring).' ); } finally { await server.close(); } function verifyCanonicalIds(memoryModule) { assert( memoryModule.firstPursuitScoutVisitId === 'first-pursuit-scout-tent', 'The first-pursuit scout visit id must remain stable.' ); assert( memoryModule.firstPursuitScoutSourceBattleId === 'first-battle-zhuo-commandery' && memoryModule.firstPursuitScoutTargetBattleId === 'second-battle-yellow-turban-pursuit', 'The scout memory must remain scoped from the first victory to the second battle.' ); assert( JSON.stringify(memoryModule.firstPursuitScoutChoiceIds) === JSON.stringify(['trace-river-ambush', 'mark-village-relief']), 'The two canonical scout choice ids must remain stable and ordered.' ); } function verifyCanonicalVisitDefinition(memoryModule, visitModule) { const definition = visitModule.firstPursuitScoutVisitDefinition; assert( definition.id === memoryModule.firstPursuitScoutVisitId && definition.availableAfterBattleIds.length === 1 && definition.availableAfterBattleIds[0] === memoryModule.firstPursuitScoutSourceBattleId, 'The first-camp exploration definition must use the canonical visit and source-battle ids.' ); assert( JSON.stringify(definition.choices.map(({ id }) => id)) === JSON.stringify(memoryModule.firstPursuitScoutChoiceIds), 'The first-camp exploration definition must expose the two canonical choices in order.' ); definition.choices.forEach((choice) => { assert( choice.label.length > 0 && choice.response.startsWith('간옹:') && choice.bondExp > 0 && choice.itemRewards.length > 0, `Choice ${choice.id} must include dialogue, bond, and item feedback.` ); assert( visitModule.getFirstPursuitScoutVisitChoice(choice.id) === choice, `Choice lookup must return the canonical object for ${choice.id}.` ); }); } function verifyValidChoices(memoryModule, campaignModule, battleScenarios) { const memories = memoryModule.firstPursuitScoutChoiceIds.map((choiceId) => { const campaign = createCompletedScoutCampaign( memoryModule, campaignModule, battleScenarios, choiceId ); const memory = memoryModule.resolveFirstPursuitScoutMemory({ campaign, battleId: memoryModule.firstPursuitScoutTargetBattleId }); assert( memory?.visitId === memoryModule.firstPursuitScoutVisitId && memory.choiceId === choiceId && memory.choiceLabel.length > 0 && memory.deploymentIntentPreview === true && memory.campSummaryLine.startsWith('군영 준비 · ') && memory.openingLines.length === 2 && memory.openingLines[0] === memory.campSummaryLine, `A completed canonical choice must resolve a concise target-battle payoff: ${JSON.stringify(memory)}` ); return memory; }); assert( memories[0].campSummaryLine !== memories[1].campSummaryLine && memories[0].openingLines[1] !== memories[1].openingLines[1], 'Each scout choice must return distinct camp and opening guidance.' ); memories[0].openingLines[0] = 'mutated outside resolver'; const campaign = createCompletedScoutCampaign( memoryModule, campaignModule, battleScenarios, memoryModule.firstPursuitScoutChoiceIds[0] ); const resolvedAgain = memoryModule.resolveFirstPursuitScoutMemory({ campaign, battleId: memoryModule.firstPursuitScoutTargetBattleId }); assert( resolvedAgain?.openingLines[0] === resolvedAgain?.campSummaryLine, 'Resolved opening lines must be cloned instead of exposing canonical data.' ); } function verifyInvalidMarkers( memoryModule, campaignModule, battleScenarios ) { const valid = createCompletedScoutCampaign( memoryModule, campaignModule, battleScenarios, memoryModule.firstPursuitScoutChoiceIds[0] ); const sourceBattleId = memoryModule.firstPursuitScoutSourceBattleId; const targetBattleId = memoryModule.firstPursuitScoutTargetBattleId; const visitId = memoryModule.firstPursuitScoutVisitId; const cases = [ { label: 'missing source settlement', mutate(campaign) { delete campaign.battleHistory[sourceBattleId]; } }, { label: 'source defeat', mutate(campaign) { campaign.battleHistory[sourceBattleId].outcome = 'defeat'; } }, { label: 'mismatched source settlement id', mutate(campaign) { campaign.battleHistory[sourceBattleId].battleId = targetBattleId; } }, { label: 'missing visit completion', mutate(campaign) { campaign.completedCampVisits = []; } }, { label: 'lookalike visit completion', mutate(campaign) { campaign.completedCampVisits = [`${visitId}-forged`]; } }, { label: 'missing choice marker', mutate(campaign) { delete campaign.campVisitChoiceIds[visitId]; } }, { label: 'unknown choice marker', mutate(campaign) { campaign.campVisitChoiceIds[visitId] = 'reveal-all-enemies'; } }, { label: 'choice stored under a forged visit key', mutate(campaign) { campaign.campVisitChoiceIds[`${visitId}-forged`] = campaign.campVisitChoiceIds[visitId]; delete campaign.campVisitChoiceIds[visitId]; } } ]; cases.forEach(({ label, mutate }) => { const campaign = structuredClone(valid); mutate(campaign); const memory = memoryModule.resolveFirstPursuitScoutMemory({ campaign, battleId: targetBattleId }); assert( memory === undefined, `Invalid scout state (${label}) must not grant deployment intent preview: ${JSON.stringify(memory)}` ); }); } function verifyTargetIsolation( memoryModule, campaignModule, battleScenarios ) { const campaign = createCompletedScoutCampaign( memoryModule, campaignModule, battleScenarios, memoryModule.firstPursuitScoutChoiceIds[1] ); const unrelatedBattleIds = [ memoryModule.firstPursuitScoutSourceBattleId, 'third-battle-guangzong-road' ]; unrelatedBattleIds.forEach((battleId) => { const memory = memoryModule.resolveFirstPursuitScoutMemory({ campaign, battleId }); assert( memory === undefined, `Scout payoff must not leak from the second battle into ${battleId}.` ); }); } function verifyGuardedExplorationCompletion( memoryModule, visitModule, campaignModule, actionModule, battleScenarios ) { storage.clear(); campaignModule.resetCampaignState(); assert( actionModule.completeFirstPursuitScoutVisit( memoryModule.firstPursuitScoutChoiceIds[0] ).reason === 'invalid-campaign', 'The exploration action must reject scout rewards before the first victory camp.' ); const scenario = battleScenarios[memoryModule.firstPursuitScoutSourceBattleId]; campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 6, rewardGold: scenario.baseVictoryGold, defeatedEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: [], units: scenario.units, bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-27T00:00:00.000Z' }); assert( actionModule.completeFirstPursuitScoutVisit('forged-choice').reason === 'invalid-choice', 'The exploration action must reject a forged scout choice.' ); const choice = visitModule.firstPursuitScoutVisitDefinition.choices[0]; const beforeFailedSave = scoutProgressSnapshot( campaignModule.getCampaignState(), memoryModule.firstPursuitScoutVisitId ); let failedSave; rejectStorageWrites = true; try { failedSave = actionModule.completeFirstPursuitScoutVisit(choice.id); } finally { rejectStorageWrites = false; } const afterFailedSave = scoutProgressSnapshot( campaignModule.getCampaignState(), memoryModule.firstPursuitScoutVisitId ); assert( failedSave?.ok === false && failedSave.reason === 'save-unavailable' && JSON.stringify(afterFailedSave) === JSON.stringify(beforeFailedSave), `A failed campaign write must restore the in-memory scout state: ${JSON.stringify({ failedSave, beforeFailedSave, afterFailedSave })}` ); const completed = actionModule.completeFirstPursuitScoutVisit(choice.id); assert( completed.ok === true && completed.choice.id === choice.id && completed.campaign.completedCampVisits.includes( memoryModule.firstPursuitScoutVisitId ) && completed.campaign.campVisitChoiceIds[ memoryModule.firstPursuitScoutVisitId ] === choice.id, 'A valid exploration choice must atomically save completion and its canonical choice id.' ); assert( choice.itemRewards.every((reward) => { const match = reward.match(/^(.+?)\s+([+-]?\d+)$/); return Boolean( match && (completed.campaign.inventory[match[1]] ?? 0) >= Number.parseInt(match[2], 10) ); }), 'A valid exploration choice must save its item reward.' ); assert( actionModule.completeFirstPursuitScoutVisit(choice.id).reason === 'already-completed', 'The exploration action must reject duplicate rewards.' ); } async function verifyCampExplorationWiring(memoryModule, visitModule) { const campSource = await readFile( new URL('../src/game/scenes/CampScene.ts', import.meta.url), 'utf8' ); const explorationSource = await readFile( new URL( '../src/game/scenes/CampVisitExplorationScene.ts', import.meta.url ), 'utf8' ); assert( campSource.includes( "import { firstPursuitScoutVisitDefinition } from '../data/firstPursuitScoutVisit';" ), 'CampScene must import the canonical first-pursuit visit definition.' ); assert( campSource.includes("'CampVisitExplorationScene'") && campSource.includes('정찰막으로 이동'), 'An incomplete first-pursuit visit must lead to the walkable camp exploration scene.' ); assert( explorationSource.includes( "completeFirstPursuitScoutVisit(choice.id)" ) && explorationSource.includes("textureKey: 'exploration-jian-yong'"), 'The exploration scene must use the guarded action and Jian Yong dedicated SD sheet.' ); const campaign = createLiteralSyncCampaign(memoryModule); visitModule.firstPursuitScoutVisitDefinition.choices.forEach((choice) => { campaign.campVisitChoiceIds[memoryModule.firstPursuitScoutVisitId] = choice.id; const memory = memoryModule.resolveFirstPursuitScoutMemory({ campaign, battleId: memoryModule.firstPursuitScoutTargetBattleId }); assert( memory?.choiceLabel === choice.label, `Visit and battle memory labels must remain synchronized for ${choice.id}.` ); }); } function createCompletedScoutCampaign( memoryModule, campaignModule, battleScenarios, choiceId ) { storage.clear(); campaignModule.resetCampaignState(); const scenario = battleScenarios[memoryModule.firstPursuitScoutSourceBattleId]; campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 6, rewardGold: scenario.baseVictoryGold, defeatedEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: [], units: scenario.units, bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-27T00:00:00.000Z' }); const updated = campaignModule.applyCampVisitReward( memoryModule.firstPursuitScoutVisitId, {}, choiceId ); assert( updated, `Campaign state must accept canonical scout choice ${choiceId}.` ); return campaignModule.getCampaignState(); } function createLiteralSyncCampaign(memoryModule) { return { battleHistory: { [memoryModule.firstPursuitScoutSourceBattleId]: { battleId: memoryModule.firstPursuitScoutSourceBattleId, outcome: 'victory' } }, completedCampVisits: [memoryModule.firstPursuitScoutVisitId], campVisitChoiceIds: {} }; } function scoutProgressSnapshot(campaign, visitId) { return { completedCampVisits: [...campaign.completedCampVisits], reportCompletedCampVisits: [ ...(campaign.firstBattleReport?.completedCampVisits ?? []) ], campVisitChoiceIds: { ...campaign.campVisitChoiceIds }, inventory: { ...campaign.inventory }, bonds: campaign.bonds.map(({ id, level, exp, battleExp }) => ({ id, level, exp, battleExp })), visitCompleted: campaign.completedCampVisits.includes(visitId), visitChoiceId: campaign.campVisitChoiceIds[visitId] ?? null }; } function assert(condition, message) { if (!condition) { throw new Error(message); } }