import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { createServer } from 'vite'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true, hmr: false }, appType: 'custom' }); try { const journalModule = await server.ssrLoadModule( '/src/game/state/campaignObjectiveJournal.ts' ); const campaignModule = await server.ssrLoadModule( '/src/game/state/campaignState.ts' ); const routingModule = await server.ssrLoadModule( '/src/game/state/campaignRouting.ts' ); const { battleScenarios } = await server.ssrLoadModule( '/src/game/data/battles.ts' ); const { getSortieFlow } = await server.ssrLoadModule( '/src/game/data/campaignFlow.ts' ); const secondReliefModule = await server.ssrLoadModule( '/src/game/data/secondBattleReliefExploration.ts' ); const thirdExplorationModule = await server.ssrLoadModule( '/src/game/data/thirdCampExploration.ts' ); const resolve = journalModule.resolveCampaignObjectiveJournal; const source = await readFile( new URL( '../src/game/state/campaignObjectiveJournal.ts', import.meta.url ), 'utf8' ); verifyPureModuleContract(journalModule, source); verifyInitialAndStoryStates(resolve, campaignModule); verifyPrologueVillageProgress(resolve, campaignModule); verifyPrologueCampProgress(resolve, campaignModule); verifyAftermathIsolation( resolve, campaignModule, battleScenarios, getSortieFlow ); verifyCityStayIsolation( resolve, campaignModule, battleScenarios ); verifyEarlyInterludes( resolve, campaignModule, battleScenarios, secondReliefModule, thirdExplorationModule ); verifyAllBattleAndCampRoutesHideFutureDetails( resolve, campaignModule, routingModule, battleScenarios, getSortieFlow ); console.log( 'Campaign objective journal verification passed (current/completed/clue triplet, known/open progress, short resume context, early exploration sequencing, and future battle/title/condition isolation).' ); } finally { await server.close(); } function verifyPureModuleContract(journalModule, source) { assert.deepEqual(journalModule.campaignObjectiveJournalStatusLabels, { current: '◆ 진행', completed: '✓ 완료', clue: '? 실마리' }); assert.doesNotMatch( source, /(?:from|import\s+type)\s+['"]phaser['"]/i, 'The objective journal must remain Phaser-independent.' ); assert.doesNotMatch( source, /\.(?:victoryConditionLabel|defeatConditionLabel|openingObjectiveLines|objectives|units|leaderUnitId)\b/, 'The journal must not read battle win conditions, future units, or hidden tactical data.' ); assert.doesNotMatch( source, /getSortieFlow\([^)]*\)\.(?:title|description|rewardHint)/, 'Campaign flow prose must not be copied into the spoiler-safe journal.' ); } function verifyInitialAndStoryStates(resolve, campaignModule) { const initial = campaignModule.createInitialCampaignState(); const initialSnapshot = resolve(initial); assertSnapshotShape(initialSnapshot); assert.equal(initialSnapshot.contextKind, 'not-started'); assert.equal(initialSnapshot.current.statusLabel, '◆ 진행'); assert.equal(initialSnapshot.recentCompleted.statusLabel, '✓ 완료'); assert.equal(initialSnapshot.nextClue.statusLabel, '? 실마리'); assert.deepEqual( pickProgress(initialSnapshot.progress), { completed: 0, total: 1, knownTotal: true } ); const prologue = freshState(campaignModule, { step: 'prologue' }); const prologueSnapshot = resolve(prologue); assert.equal(prologueSnapshot.contextKind, 'story'); assert.match(prologueSnapshot.current.title, /서막/); assert.equal(prologueSnapshot.resumeContext.lines.length, 3); assert( prologueSnapshot.resumeContext.lines.every( (line) => line.length <= 110 ), 'Resume context lines must stay short enough for a compact recap card.' ); } function verifyPrologueVillageProgress(resolve, campaignModule) { const ids = campaignModule.prologueVillageCampaignTutorialIds; const village = freshState(campaignModule, { step: 'prologue-town' }); let snapshot = resolve(village); assert.equal(snapshot.contextKind, 'village-exploration'); assert.equal(snapshot.current.title, '격문 앞의 장비'); assert.match(snapshot.nextClue.detail, /서쪽 모병 격문/); assert.deepEqual(pickProgress(snapshot.progress), { completed: 0, total: 4, knownTotal: true }); village.completedTutorialIds.push(ids.meetZhangFei); snapshot = resolve(village); assert.equal(snapshot.current.title, '주점의 관우'); assert.equal(snapshot.recentCompleted.title, '격문 앞의 장비'); assert.equal(snapshot.progress.completed, 1); village.completedTutorialIds.push( ids.meetGuanYu, ids.registerVolunteers ); snapshot = resolve(village); assert.equal(snapshot.current.title, '도원결의'); assert.match(snapshot.nextClue.detail, /복숭아 동산/); assert.equal(snapshot.progress.completed, 3); village.completedTutorialIds.push(ids.complete); snapshot = resolve(village); assert.match(snapshot.current.title, /결의 뒤 이야기/); assert.equal(snapshot.recentCompleted.title, '도원결의'); assert.equal(snapshot.progress.completed, 4); } function verifyPrologueCampProgress(resolve, campaignModule) { const ids = campaignModule.prologueMilitiaCampCampaignTutorialIds; const camp = freshState(campaignModule, { step: 'prologue-camp' }); camp.completedTutorialIds.push(ids.inspectArms); let snapshot = resolve(camp); assert.equal(snapshot.contextKind, 'militia-camp-exploration'); assert.equal(snapshot.current.title, '관우의 정찰'); assert.equal( snapshot.recentCompleted.title, '병기·보급 점검', 'The recent completion must follow persisted insertion order, not fixed objective order.' ); assert.equal(snapshot.progress.completed, 1); camp.completedTutorialIds.push( ids.readyVanguard, ids.reviewScoutReport ); snapshot = resolve(camp); assert.equal(snapshot.current.title, '첫 출전 명령'); assert.match(snapshot.nextClue.detail, /북쪽 지휘 막사/); assert.equal(snapshot.progress.completed, 3); camp.completedTutorialIds.push(ids.complete); snapshot = resolve(camp); assert.match(snapshot.current.title, /출정 이야기/); assert.equal(snapshot.recentCompleted.title, '첫 출전 군령'); assert.equal(snapshot.progress.completed, 4); assert.doesNotMatch( stringify(snapshot), /한석 선봉 격퇴/, 'The first battle victory condition must not leak before the battlefield opens.' ); } function verifyAftermathIsolation( resolve, campaignModule, battleScenarios, getSortieFlow ) { const battleId = 'first-battle-zhuo-commandery'; const state = campaignWithSettlement( campaignModule, battleScenarios, battleId, 'first-camp' ); state.pendingAftermathBattleId = battleId; const snapshot = resolve(state); const flow = getSortieFlow(state.latestBattleId, state.step); assert.equal(snapshot.contextKind, 'aftermath'); assert.match(snapshot.current.title, /승리 후 이야기/); assert.equal(snapshot.recentCompleted.statusLabel, '✓ 완료'); assertHiddenFlowDetailsAbsent(snapshot, flow, battleScenarios); assert.doesNotMatch( stringify(snapshot), new RegExp(escapeRegExp(battleScenarios[battleId].victoryConditionLabel)) ); } function verifyCityStayIsolation( resolve, campaignModule, battleScenarios ) { const sourceBattleId = 'seventh-battle-xuzhou-rescue'; const state = campaignWithSettlement( campaignModule, battleScenarios, sourceBattleId, 'seventh-camp' ); state.activeCityStayId = 'xuzhou'; let snapshot = resolve(state); assert.equal(snapshot.contextKind, 'city-stay'); assert.match(snapshot.current.title, /서주.*현지 정보/); assert.deepEqual(pickProgress(snapshot.progress), { completed: 0, total: 2, knownTotal: true }); assert.doesNotMatch( stringify(snapshot), new RegExp( escapeRegExp( battleScenarios['eighth-battle-xiaopei-supply-road'].title ) ), 'A city stay must not expose the title of its future battle.' ); assert.doesNotMatch( stringify(snapshot), /소패 보급로 장부 확인|여포군 기병/, 'A city stay journal must not copy future battle briefing prose.' ); state.completedCampVisits.push('city-xuzhou-xiaopei-ledger'); snapshot = resolve(state); assert.match(snapshot.current.title, /동료와 대화/); assert.equal(snapshot.recentCompleted.title, '현지 정보 확보'); assert.equal(snapshot.progress.completed, 1); state.completedCampDialogues.push( 'city-xuzhou-liu-mi-supply-trust' ); snapshot = resolve(state); assert.match(snapshot.current.title, /군영으로 돌아가/); assert.equal(snapshot.progress.completed, 2); assert.match(snapshot.progress.label, /시장은 선택/); } function verifyEarlyInterludes( resolve, campaignModule, battleScenarios, secondReliefModule, thirdExplorationModule ) { const firstCamp = campaignWithSettlement( campaignModule, battleScenarios, 'first-battle-zhuo-commandery', 'first-camp' ); let snapshot = resolve(firstCamp); assert.equal(snapshot.contextKind, 'interlude-exploration'); assert.match(snapshot.current.title, /북문 정찰/); assert.doesNotMatch( stringify(snapshot), new RegExp( escapeRegExp( battleScenarios['second-battle-yellow-turban-pursuit'].title ) ) ); firstCamp.completedCampVisits.push('first-pursuit-scout-tent'); firstCamp.campVisitChoiceIds['first-pursuit-scout-tent'] = 'trace-river-ambush'; snapshot = resolve(firstCamp); assert.equal(snapshot.progress.completed, 1); assert.equal( snapshot.recentCompleted.title, '강가 매복 흔적을 쫓는다' ); const secondCamp = campaignWithSettlement( campaignModule, battleScenarios, secondReliefModule.secondBattleReliefSourceBattleId, 'second-camp' ); snapshot = resolve(secondCamp); assert.match(snapshot.current.title, /북쪽 마을/); assert.equal(snapshot.progress.completed, 0); const firstObjectiveId = 'reassure-northern-village'; secondCamp.completedCampVisits.push( secondReliefModule.secondBattleReliefObjectiveProgressId( firstObjectiveId ) ); snapshot = resolve(secondCamp); assert.match(snapshot.current.title, /나루 사공/); assert.equal(snapshot.progress.completed, 1); assert.match(snapshot.recentCompleted.title, /북쪽 마을/); [ 'question-ferry-witness', 'inspect-messenger-trail' ].forEach((objectiveId) => { secondCamp.completedCampVisits.push( secondReliefModule.secondBattleReliefObjectiveProgressId( objectiveId ) ); }); snapshot = resolve(secondCamp); assert.match(snapshot.current.title, /다음 출정의 우선 방침/); assert.doesNotMatch( stringify(snapshot), new RegExp( escapeRegExp( battleScenarios[ secondReliefModule.secondBattleReliefTargetBattleId ].title ) ), 'The final relief decision must use generic current-action wording instead of the future battle title.' ); secondCamp.completedCampVisits.push( secondReliefModule.secondBattleReliefVisitId ); secondCamp.campVisitChoiceIds[ secondReliefModule.secondBattleReliefVisitId ] = 'restore-northern-village'; snapshot = resolve(secondCamp); assert.equal(snapshot.progress.completed, 4); assert.equal( snapshot.recentCompleted.title, '북쪽 마을 보급선을 먼저 세운다' ); const thirdCamp = campaignWithSettlement( campaignModule, battleScenarios, thirdExplorationModule.thirdCampExplorationSourceBattleId, 'third-camp', { objectives: [ { id: 'fort', achieved: true, status: 'done' }, { id: 'quick', achieved: true, status: 'done' } ], turnNumber: 22 } ); thirdCamp.firstBattleReport = { ...minimalFirstBattleReport( thirdCamp.battleHistory[ thirdExplorationModule.thirdCampExplorationSourceBattleId ] ) }; snapshot = resolve(thirdCamp); assert.equal(snapshot.contextKind, 'interlude-exploration'); assert.match(snapshot.current.detail, /모두 선택/); assert.equal(snapshot.progress.total, 3); assert.doesNotMatch( stringify(snapshot), new RegExp( escapeRegExp( battleScenarios[ thirdExplorationModule.thirdCampExplorationTargetBattleId ].title ) ) ); thirdCamp.completedCampVisits.push( thirdExplorationModule.thirdCampExplorationActivityProgressId( 'equipment' ) ); snapshot = resolve(thirdCamp); assert.equal(snapshot.progress.completed, 1); assert.equal(snapshot.recentCompleted.title, '장비 정비'); } function verifyAllBattleAndCampRoutesHideFutureDetails( resolve, campaignModule, routingModule, battleScenarios, getSortieFlow ) { const routes = routingModule.campaignBattleRouteEntries(); routes.forEach(({ step, battleId }, index) => { const battleState = freshState(campaignModule, { step }); const battleSnapshot = resolve(battleState); assertSnapshotShape(battleSnapshot); assert.equal( battleSnapshot.current.title, `${battleScenarios[battleId].title} 진행` ); assert.doesNotMatch( stringify(battleSnapshot), new RegExp( escapeRegExp(battleScenarios[battleId].victoryConditionLabel) ), `Current battle ${battleId} must not expose its victory condition through the campaign journal.` ); const nextRoute = routes[index + 1]; if (nextRoute) { assert.doesNotMatch( stringify(battleSnapshot), new RegExp( escapeRegExp(battleScenarios[nextRoute.battleId].title) ), `Battle step ${step} must not expose future title ${nextRoute.battleId}.` ); } const campStep = step.replace(/-battle$/, '-camp'); const campState = campaignWithSettlement( campaignModule, battleScenarios, battleId, campStep ); const campSnapshot = resolve(campState); assertSnapshotShape(campSnapshot); const flow = getSortieFlow( campState.latestBattleId, campState.step ); assertHiddenFlowDetailsAbsent( campSnapshot, flow, battleScenarios ); }); } function freshState(campaignModule, overrides = {}) { return Object.assign( campaignModule.createInitialCampaignState(), overrides ); } function campaignWithSettlement( campaignModule, battleScenarios, battleId, step, overrides = {} ) { const state = freshState(campaignModule, { step }); const settlement = minimalSettlement( battleScenarios[battleId], overrides ); state.latestBattleId = battleId; state.battleHistory[battleId] = settlement; return state; } function minimalSettlement(scenario, overrides = {}) { return { battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 12, rewardGold: 0, itemRewards: [], objectives: [], units: [], bonds: [], completedAt: '2026-07-28T00:00:00.000Z', ...overrides }; } function minimalFirstBattleReport(settlement) { return { battleId: settlement.battleId, battleTitle: settlement.battleTitle, outcome: settlement.outcome, turnNumber: settlement.turnNumber, rewardGold: settlement.rewardGold, defeatedEnemies: 1, totalEnemies: 1, objectives: structuredClone(settlement.objectives), units: [], bonds: [], itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: settlement.completedAt }; } function assertSnapshotShape(snapshot) { assert(snapshot); assert.equal(typeof snapshot.contextKind, 'string'); assertEntry(snapshot.current, '◆ 진행'); assertEntry(snapshot.recentCompleted, '✓ 완료'); assertEntry(snapshot.nextClue, '? 실마리'); assert.equal(typeof snapshot.progress.completed, 'number'); assert.equal(typeof snapshot.progress.knownTotal, 'boolean'); assert.equal(typeof snapshot.progress.label, 'string'); if (snapshot.progress.knownTotal) { assert.equal(typeof snapshot.progress.total, 'number'); assert(snapshot.progress.completed <= snapshot.progress.total); } else { assert.equal(snapshot.progress.total, undefined); } assert.equal(typeof snapshot.resumeContext.title, 'string'); assert.equal(typeof snapshot.resumeContext.summary, 'string'); assert.equal(snapshot.resumeContext.lines.length, 3); } function assertEntry(entry, expectedLabel) { assert(entry); assert.equal(entry.statusLabel, expectedLabel); assert.equal(typeof entry.id, 'string'); assert(entry.id.length > 0); assert.equal(typeof entry.title, 'string'); assert(entry.title.length > 0); assert.equal(typeof entry.detail, 'string'); assert(entry.detail.length > 0); } function assertHiddenFlowDetailsAbsent( snapshot, flow, battleScenarios ) { const serialized = stringify(snapshot); if (!flow?.nextBattleId) { return; } const future = battleScenarios[flow.nextBattleId]; assert(future); assert.doesNotMatch( serialized, new RegExp(escapeRegExp(future.title)), `Future battle title ${future.title} must remain hidden.` ); assert.doesNotMatch( serialized, new RegExp(escapeRegExp(future.victoryConditionLabel)), `Future victory condition ${future.victoryConditionLabel} must remain hidden.` ); assert.equal( serialized.includes(flow.description), false, 'Future campaign-flow description must not be copied into the journal.' ); assert.equal( serialized.includes(flow.rewardHint), false, 'Future reward/recruit hints must not be copied into the journal.' ); } function pickProgress(progress) { return { completed: progress.completed, total: progress.total, knownTotal: progress.knownTotal }; } function stringify(value) { return JSON.stringify(value); } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }