381 lines
13 KiB
JavaScript
381 lines
13 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;
|
|
|
|
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');
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
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 ${criticalObjectiveCount} narrative-critical secure objectives, ` +
|
|
'the Wuzhang transition and background family, the 55-66 timeline chapter, ' +
|
|
'and immediate unique camp reward continuity.'
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|