fix: align audiovisual and narrative feedback
This commit is contained in:
380
scripts/verify-narrative-continuity.mjs
Normal file
380
scripts/verify-narrative-continuity.mjs
Normal file
@@ -0,0 +1,380 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user