fix: align audiovisual and narrative feedback

This commit is contained in:
2026-07-23 23:20:50 +09:00
parent 8cf0886d7d
commit e06b15ce7a
27 changed files with 1368 additions and 142 deletions

View 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);
}
}