feat: add spoiler-safe campaign objective journal

This commit is contained in:
2026-07-28 11:05:57 +09:00
parent a67cfbfbe3
commit 91607cfd4c
16 changed files with 5116 additions and 220 deletions

View File

@@ -0,0 +1,619 @@
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, '\\$&');
}