513 lines
18 KiB
JavaScript
513 lines
18 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
||
import { createServer } from 'vite';
|
||
|
||
const criticalSecureObjectiveIds = {
|
||
'twenty-second-battle-red-cliffs-fire': [
|
||
'fire-ship-landing',
|
||
'wind-signal',
|
||
'chain-fleet'
|
||
],
|
||
'thirty-third-battle-chengdu-surrender': [
|
||
'outer-village',
|
||
'chengdu-storehouse'
|
||
],
|
||
'forty-sixth-battle-yiling-fire': [
|
||
'fire-camps',
|
||
'retreat-road'
|
||
],
|
||
'fifty-fourth-battle-meng-huo-final-capture': [
|
||
'council-road',
|
||
'village-witnesses'
|
||
],
|
||
'sixty-sixth-battle-wuzhang-final': [
|
||
'preserve-wuzhang-camps',
|
||
'protect-returning-villages'
|
||
]
|
||
};
|
||
|
||
const wuzhangBackgroundPattern = /^story-wuzhang(?:-|$)/;
|
||
const explicitFinalTransitionIdPattern = /(?:(?:zhuge(?:-liang)?|kongming).*(?:death|dies|passing|passes|farewell|last-breath|seogeo)|(?:death|dies|passing|passes|farewell|last-breath|seogeo).*(?:zhuge(?:-liang)?|kongming)|(?:time|years?)-(?:passes|passage|skip|later|elapsed)|(?:after|post)-wuzhang-(?:time|years?|passage))/i;
|
||
const uniqueStoryArtifactPattern = /(?:서찰|군령패|항복 권고문|접선 문서|사절 문서|통행문서|장부|지도|표식|기록|서약)$/;
|
||
const repeatableRewardLabels = new Set();
|
||
|
||
const campSceneSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
||
const failures = [];
|
||
const server = await createServer({
|
||
logLevel: 'error',
|
||
server: { middlewareMode: true },
|
||
appType: 'custom'
|
||
});
|
||
|
||
let battleScenarios;
|
||
let scenarioModule;
|
||
let storyAssetModule;
|
||
let campaignFlowModule;
|
||
let prologueMilitiaCampModule;
|
||
|
||
try {
|
||
({ battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'));
|
||
scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||
storyAssetModule = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
|
||
campaignFlowModule = await server.ssrLoadModule('/src/game/data/campaignFlow.ts');
|
||
prologueMilitiaCampModule = await server.ssrLoadModule(
|
||
'/src/game/data/prologueMilitiaCamp.ts'
|
||
);
|
||
} finally {
|
||
await server.close();
|
||
}
|
||
|
||
validateEarlyHanSeokContinuity();
|
||
validateCriticalVictoryObjectives();
|
||
validateFinalStoryTransition();
|
||
validateWuzhangStoryBackgrounds();
|
||
validateNorthernCampaignTimeline();
|
||
validateImmediateUniqueRewardDuplicates();
|
||
|
||
if (failures.length > 0) {
|
||
throw new Error(
|
||
`Narrative continuity verification failed with ${failures.length} issue(s):\n` +
|
||
failures.map((failure) => `- ${failure}`).join('\n')
|
||
);
|
||
}
|
||
|
||
const criticalObjectiveCount = Object.values(criticalSecureObjectiveIds)
|
||
.reduce((count, objectiveIds) => count + objectiveIds.length, 0);
|
||
console.log(
|
||
`Verified the Han Seok first-battle retreat, second-battle pursuit, and final defeat, ` +
|
||
`${criticalObjectiveCount} narrative-critical secure objectives, ` +
|
||
'the Wuzhang transition and background family, the 55-66 timeline chapter, ' +
|
||
'and immediate unique camp reward continuity.'
|
||
);
|
||
|
||
function validateEarlyHanSeokContinuity() {
|
||
const firstBattleId = 'first-battle-zhuo-commandery';
|
||
const secondBattleId = 'second-battle-yellow-turban-pursuit';
|
||
const firstScenario = battleScenarios[firstBattleId];
|
||
const secondScenario = battleScenarios[secondBattleId];
|
||
|
||
assert(firstScenario, `Missing first Han Seok encounter: ${firstBattleId}`);
|
||
assert(secondScenario, `Missing Han Seok pursuit battle: ${secondBattleId}`);
|
||
if (!firstScenario || !secondScenario) {
|
||
return;
|
||
}
|
||
|
||
const firstLeaderObjective = firstScenario.objectives.find(
|
||
(objective) =>
|
||
objective.kind === 'defeat-leader' &&
|
||
objective.unitId === firstScenario.leaderUnitId
|
||
);
|
||
const firstBattleObjectiveCopy = [
|
||
firstScenario.victoryConditionLabel,
|
||
firstLeaderObjective?.label,
|
||
...firstScenario.openingObjectiveLines
|
||
].filter(Boolean).join(' ');
|
||
assert(
|
||
firstBattleObjectiveCopy.includes('한석') &&
|
||
firstBattleObjectiveCopy.includes('선봉') &&
|
||
/(격퇴|퇴각)/.test(firstBattleObjectiveCopy),
|
||
`${firstBattleId}: the first Han Seok encounter must be framed as routing his vanguard`
|
||
);
|
||
assert(
|
||
!/(?:한석|두령)[^.!?]{0,12}격파/.test(firstBattleObjectiveCopy),
|
||
`${firstBattleId}: first-battle objective copy must not imply Han Seok is finally defeated`
|
||
);
|
||
|
||
const commander = prologueMilitiaCampModule
|
||
.prologueMilitiaCampNpcDefinitions
|
||
.find((npc) => npc.id === 'zou-jing');
|
||
const commanderText = commander?.dialogue.map((line) => line.text).join(' ') ?? '';
|
||
assert(
|
||
commanderText.includes('한석') && commanderText.includes('선봉'),
|
||
'The pre-battle militia-camp command must introduce the first enemy as Han Seok’s vanguard'
|
||
);
|
||
|
||
const departureText = scenarioModule.prologuePages
|
||
.filter((page) => ['first-sortie', 'battle-briefing'].includes(page.id))
|
||
.map((page) => page.text)
|
||
.join(' ');
|
||
assert(
|
||
departureText.includes('한석') && departureText.includes('선봉'),
|
||
'The playable prologue departure must preserve the Han Seok vanguard framing'
|
||
);
|
||
|
||
const firstVictoryText = firstScenario.victoryPages
|
||
.flatMap((page) => [
|
||
page.text,
|
||
page.cutscene?.title,
|
||
page.cutscene?.subtitle,
|
||
...(page.cutscene?.rewards?.map((reward) => reward.label) ?? [])
|
||
])
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
assert(
|
||
firstVictoryText.includes('한석') &&
|
||
/(달아|퇴각|도주|빠져나)/.test(firstVictoryText) &&
|
||
/(북쪽|나루)/.test(firstVictoryText) &&
|
||
/(잔당|패잔병|남은 병력)/.test(firstVictoryText),
|
||
`${firstBattleId}: victory story must explicitly show Han Seok escaping north with the remnants`
|
||
);
|
||
|
||
const firstCampFlow = campaignFlowModule.getSortieFlow(firstBattleId);
|
||
const firstCampBridge = [
|
||
firstCampFlow?.title,
|
||
firstCampFlow?.description,
|
||
firstCampFlow?.rewardHint
|
||
].filter(Boolean).join(' ');
|
||
assert(
|
||
firstCampFlow?.nextBattleId === secondBattleId &&
|
||
firstCampBridge.includes('한석') &&
|
||
/(퇴각|달아|도주)/.test(firstCampBridge) &&
|
||
firstCampBridge.includes('추격'),
|
||
'The first-camp sortie flow must bridge Han Seok’s retreat directly into the pursuit battle'
|
||
);
|
||
|
||
const secondIntroText = scenarioModule.secondBattleIntroPages
|
||
.flatMap((page) => [
|
||
page.text,
|
||
page.cutscene?.title,
|
||
page.cutscene?.subtitle,
|
||
...(page.cutscene?.rewards?.map((reward) => reward.label) ?? [])
|
||
])
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const secondBattleObjectiveCopy = [
|
||
secondScenario.victoryConditionLabel,
|
||
...secondScenario.openingObjectiveLines
|
||
].join(' ');
|
||
const secondVictoryText = secondScenario.victoryPages
|
||
.flatMap((page) => [
|
||
page.text,
|
||
page.cutscene?.title,
|
||
page.cutscene?.subtitle,
|
||
...(page.cutscene?.rewards?.map((reward) => reward.label) ?? [])
|
||
])
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
assert(
|
||
secondIntroText.includes('탁현') &&
|
||
secondIntroText.includes('한석') &&
|
||
/(퇴각|달아|도주)/.test(secondIntroText) &&
|
||
secondIntroText.includes('추격'),
|
||
`${secondBattleId}: intro pages must identify the battle as pursuit of Han Seok after his Tak retreat`
|
||
);
|
||
assert(
|
||
secondBattleObjectiveCopy.includes('탁현') &&
|
||
secondBattleObjectiveCopy.includes('한석') &&
|
||
secondBattleObjectiveCopy.includes('퇴각') &&
|
||
secondScenario.victoryConditionLabel.includes('격파'),
|
||
`${secondBattleId}: battle objective must close the explicitly linked Han Seok pursuit`
|
||
);
|
||
assert(
|
||
secondVictoryText.includes('한석') && secondVictoryText.includes('격파'),
|
||
`${secondBattleId}: victory story must explicitly close the pursuit with Han Seok’s final defeat`
|
||
);
|
||
}
|
||
|
||
function validateCriticalVictoryObjectives() {
|
||
for (const [battleId, objectiveIds] of Object.entries(criticalSecureObjectiveIds)) {
|
||
const scenario = battleScenarios[battleId];
|
||
assert(scenario, `Missing narrative-critical battle scenario: ${battleId}`);
|
||
if (!scenario) {
|
||
continue;
|
||
}
|
||
|
||
for (const objectiveId of objectiveIds) {
|
||
const objective = scenario.objectives.find((entry) => entry.id === objectiveId);
|
||
assert(objective, `${battleId}: missing narrative-critical objective ${objectiveId}`);
|
||
if (!objective) {
|
||
continue;
|
||
}
|
||
assert(
|
||
objective.kind === 'secure-terrain',
|
||
`${battleId}/${objectiveId}: expected secure-terrain, received ${objective.kind}`
|
||
);
|
||
assert(
|
||
objective.requiredForVictory === true,
|
||
`${battleId}/${objectiveId}: narrative-critical secure objective must set requiredForVictory: true`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateFinalStoryTransition() {
|
||
const finalScenario = battleScenarios['sixty-sixth-battle-wuzhang-final'];
|
||
const victoryPages = finalScenario?.victoryPages;
|
||
const endingPages = scenarioModule.endingEpiloguePages;
|
||
|
||
assert(Array.isArray(victoryPages) && victoryPages.length > 0, 'Battle 66 must provide victory pages');
|
||
assert(Array.isArray(endingPages) && endingPages.length > 0, 'Ending epilogue must provide pages');
|
||
if (!Array.isArray(victoryPages) || !Array.isArray(endingPages)) {
|
||
return;
|
||
}
|
||
|
||
const transitionPage = [...victoryPages, ...endingPages]
|
||
.find((page) => explicitFinalTransitionIdPattern.test(page.id));
|
||
assert(
|
||
transitionPage,
|
||
'Battle 66 aftermath and ending epilogue need an explicit death/time-passage page id ' +
|
||
'(for example zhuge-liang-passing or time-passage)'
|
||
);
|
||
}
|
||
|
||
function validateWuzhangStoryBackgrounds() {
|
||
const storyLists = [
|
||
['sixtySixthBattleIntroPages', scenarioModule.sixtySixthBattleIntroPages],
|
||
['sixtySixthBattleVictoryPages', scenarioModule.sixtySixthBattleVictoryPages]
|
||
];
|
||
|
||
for (const [sourceName, pages] of storyLists) {
|
||
assert(Array.isArray(pages) && pages.length > 0, `${sourceName}: expected a non-empty page list`);
|
||
if (!Array.isArray(pages)) {
|
||
continue;
|
||
}
|
||
for (const page of pages) {
|
||
assert(
|
||
wuzhangBackgroundPattern.test(page.background),
|
||
`${sourceName}/${page.id}: expected a Wuzhang-specific background family, received ${page.background}`
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [sourceName, pages, pageId] of [
|
||
['sixtySixthBattleVictoryPages', scenarioModule.sixtySixthBattleVictoryPages, 'sixty-sixth-zhuge-passes'],
|
||
['endingEpiloguePages', scenarioModule.endingEpiloguePages, 'ending-jiang-wei-inherits-map']
|
||
]) {
|
||
const pageIndex = pages.findIndex((page) => page.id === pageId);
|
||
const selectedKeys = storyAssetModule.storyBackgroundKeysForPages(pages);
|
||
assert(pageIndex >= 0, `${sourceName}: missing ${pageId}`);
|
||
assert(
|
||
selectedKeys[pageIndex] === 'story-wuzhang-jiangwei-inheritance',
|
||
`${sourceName}/${pageId}: the purpose-built Wuzhang inheritance artwork must be selected at runtime`
|
||
);
|
||
}
|
||
}
|
||
|
||
function validateNorthernCampaignTimeline() {
|
||
const chapter = extractObjectContaining(campSceneSource, "id: 'northern-campaign'");
|
||
assert(chapter, 'Camp timeline must define the northern-campaign chapter');
|
||
if (!chapter) {
|
||
return;
|
||
}
|
||
|
||
const title = extractStringProperty(chapter, 'title');
|
||
const period = extractStringProperty(chapter, 'period');
|
||
const battleIds = extractStringArrayProperty(chapter, 'battleIds');
|
||
assert(
|
||
battleIds.includes('fifty-fifth-battle-northern-qishan-road') &&
|
||
battleIds.includes('sixty-sixth-battle-wuzhang-final'),
|
||
'The audited northern-campaign timeline chapter must span battles 55 through 66'
|
||
);
|
||
assert(title && title !== '북벌 준비', "The 55-66 timeline title must go beyond the placeholder '북벌 준비'");
|
||
assert(period && period !== '출사표를 향해', "The 55-66 timeline period must go beyond the placeholder '출사표를 향해'");
|
||
}
|
||
|
||
function validateImmediateUniqueRewardDuplicates() {
|
||
const campBattleIds = extractCampBattleIds(campSceneSource);
|
||
const visitsSource = extractAssignedArray(campSceneSource, 'campVisits');
|
||
assert(visitsSource, 'Cannot inspect camp visits for immediate unique reward duplication');
|
||
if (!visitsSource) {
|
||
return;
|
||
}
|
||
|
||
const visitRewardsByBattleId = new Map();
|
||
for (const visitSource of splitTopLevelObjects(visitsSource)) {
|
||
const visitId = extractStringProperty(visitSource, 'id') ?? 'unknown-visit';
|
||
const battleIds = extractAvailableBattleIds(visitSource, campBattleIds);
|
||
const rewardLabels = extractRewardLabels(visitSource)
|
||
.map(normalizeRewardLabel)
|
||
.filter(Boolean);
|
||
for (const battleId of battleIds) {
|
||
const visits = visitRewardsByBattleId.get(battleId) ?? [];
|
||
visits.push({ visitId, rewardLabels });
|
||
visitRewardsByBattleId.set(battleId, visits);
|
||
}
|
||
}
|
||
|
||
for (const [battleId, visits] of visitRewardsByBattleId) {
|
||
const scenario = battleScenarios[battleId];
|
||
if (!scenario) {
|
||
continue;
|
||
}
|
||
const battleRewardLabels = new Set(scenario.itemRewards.map(normalizeRewardLabel).filter(Boolean));
|
||
for (const visit of visits) {
|
||
for (const rewardLabel of visit.rewardLabels) {
|
||
if (
|
||
battleRewardLabels.has(rewardLabel) &&
|
||
uniqueStoryArtifactPattern.test(rewardLabel) &&
|
||
!repeatableRewardLabels.has(rewardLabel)
|
||
) {
|
||
const message =
|
||
`${battleId}/${visit.visitId}: unique story reward '${rewardLabel}' is granted by both the battle and its immediate camp visit`;
|
||
failures.push(message);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function extractCampBattleIds(source) {
|
||
const objectSource = extractAssignedObject(source, 'campBattleIds');
|
||
if (!objectSource) {
|
||
return new Map();
|
||
}
|
||
|
||
const ids = new Map([['first', 'first-battle-zhuo-commandery']]);
|
||
for (const match of objectSource.matchAll(/\b(\w+)\s*:\s*'([^']+)'/g)) {
|
||
ids.set(match[1], match[2]);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function extractAvailableBattleIds(visitSource, campBattleIds) {
|
||
const availableMatch = /availableAfterBattleIds\s*:\s*\[([^\]]*)\]/.exec(visitSource);
|
||
if (!availableMatch) {
|
||
return [];
|
||
}
|
||
|
||
const ids = [];
|
||
for (const match of availableMatch[1].matchAll(/campBattleIds\.(\w+)/g)) {
|
||
const battleId = campBattleIds.get(match[1]);
|
||
if (battleId) {
|
||
ids.push(battleId);
|
||
}
|
||
}
|
||
for (const match of availableMatch[1].matchAll(/'([^']+)'/g)) {
|
||
ids.push(match[1]);
|
||
}
|
||
return [...new Set(ids)];
|
||
}
|
||
|
||
function extractRewardLabels(source) {
|
||
const labels = [];
|
||
for (const rewardsMatch of source.matchAll(/itemRewards\s*:\s*\[([^\]]*)\]/g)) {
|
||
for (const labelMatch of rewardsMatch[1].matchAll(/'([^']+)'/g)) {
|
||
labels.push(labelMatch[1]);
|
||
}
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
function normalizeRewardLabel(reward) {
|
||
const normalized = reward.replace(/\s+/g, ' ').trim();
|
||
const match = /^(.*?)(?:\s*([+xX-]?)\s*(\d+))$/.exec(normalized);
|
||
return (match?.[1] ?? normalized).trim();
|
||
}
|
||
|
||
function extractAssignedArray(source, name) {
|
||
const assignmentIndex = source.indexOf(`const ${name}`);
|
||
if (assignmentIndex < 0) {
|
||
return undefined;
|
||
}
|
||
const equalsIndex = source.indexOf('=', assignmentIndex);
|
||
const openIndex = source.indexOf('[', equalsIndex);
|
||
return extractBalanced(source, openIndex, '[', ']');
|
||
}
|
||
|
||
function extractAssignedObject(source, name) {
|
||
const assignmentIndex = source.indexOf(`const ${name}`);
|
||
if (assignmentIndex < 0) {
|
||
return undefined;
|
||
}
|
||
const equalsIndex = source.indexOf('=', assignmentIndex);
|
||
const openIndex = source.indexOf('{', equalsIndex);
|
||
return extractBalanced(source, openIndex, '{', '}');
|
||
}
|
||
|
||
function extractObjectContaining(source, marker) {
|
||
const markerIndex = source.indexOf(marker);
|
||
if (markerIndex < 0) {
|
||
return undefined;
|
||
}
|
||
const openIndex = source.lastIndexOf('{', markerIndex);
|
||
return extractBalanced(source, openIndex, '{', '}');
|
||
}
|
||
|
||
function extractBalanced(source, openIndex, openCharacter, closeCharacter) {
|
||
if (openIndex < 0 || source[openIndex] !== openCharacter) {
|
||
return undefined;
|
||
}
|
||
|
||
let depth = 0;
|
||
let quote;
|
||
let escaped = false;
|
||
for (let index = openIndex; index < source.length; index += 1) {
|
||
const character = source[index];
|
||
if (quote) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (character === '\\') {
|
||
escaped = true;
|
||
} else if (character === quote) {
|
||
quote = undefined;
|
||
}
|
||
continue;
|
||
}
|
||
if (character === "'" || character === '"' || character === '`') {
|
||
quote = character;
|
||
continue;
|
||
}
|
||
if (character === openCharacter) {
|
||
depth += 1;
|
||
} else if (character === closeCharacter) {
|
||
depth -= 1;
|
||
if (depth === 0) {
|
||
return source.slice(openIndex, index + 1);
|
||
}
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function splitTopLevelObjects(arraySource) {
|
||
const objects = [];
|
||
let objectStart = -1;
|
||
let depth = 0;
|
||
let quote;
|
||
let escaped = false;
|
||
for (let index = 1; index < arraySource.length - 1; index += 1) {
|
||
const character = arraySource[index];
|
||
if (quote) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (character === '\\') {
|
||
escaped = true;
|
||
} else if (character === quote) {
|
||
quote = undefined;
|
||
}
|
||
continue;
|
||
}
|
||
if (character === "'" || character === '"' || character === '`') {
|
||
quote = character;
|
||
continue;
|
||
}
|
||
if (character === '{') {
|
||
if (depth === 0) {
|
||
objectStart = index;
|
||
}
|
||
depth += 1;
|
||
} else if (character === '}') {
|
||
depth -= 1;
|
||
if (depth === 0 && objectStart >= 0) {
|
||
objects.push(arraySource.slice(objectStart, index + 1));
|
||
objectStart = -1;
|
||
}
|
||
}
|
||
}
|
||
return objects;
|
||
}
|
||
|
||
function extractStringProperty(source, propertyName) {
|
||
return new RegExp(`\\b${propertyName}\\s*:\\s*'([^']+)'`).exec(source)?.[1];
|
||
}
|
||
|
||
function extractStringArrayProperty(source, propertyName) {
|
||
const arrayMatch = new RegExp(`\\b${propertyName}\\s*:\\s*\\[([\\s\\S]*?)\\]`).exec(source);
|
||
return arrayMatch ? [...arrayMatch[1].matchAll(/'([^']+)'/g)].map((match) => match[1]) : [];
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) {
|
||
failures.push(message);
|
||
}
|
||
}
|