620 lines
18 KiB
JavaScript
620 lines
18 KiB
JavaScript
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, '\\$&');
|
|
}
|