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