Files
heros_web/scripts/verify-narrative-continuity.mjs

513 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Seoks 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 Seoks 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 Seoks 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);
}
}