import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; const campSkinsUrl = new URL('../src/game/data/campSkins.ts', import.meta.url); const campaignRoutingUrl = new URL('../src/game/state/campaignRouting.ts', import.meta.url); const specialCheckpointSteps = { 'hanzhong-shuhan': 'shu-han-foundation-camp', 'yiling-baidi': 'baidi-entrustment-camp', nanzhong: 'northern-campaign-prep-camp', northern: 'ending-complete' }; export async function readCampaignFlowSegments() { const [campSkinsSource, campaignRoutingSource] = await Promise.all([ readFile(campSkinsUrl, 'utf8'), readFile(campaignRoutingUrl, 'utf8') ]); const routes = parseCampaignRoutes(campaignRoutingSource); const arcs = parseCampSkinBattleArcs(campSkinsSource); validateCampaignRoutes(routes); validateCampSkinArcs(arcs, routes.length); const segments = arcs.map((arc, index) => { const segmentRoutes = routes.slice(arc.firstBattleOrdinal - 1, arc.lastBattleOrdinal); const lastRoute = segmentRoutes.at(-1); const chapterLabel = readCampSkinChapterLabel(campSkinsSource, arc.skinId); return { index, id: arc.skinId, label: chapterLabel, firstBattleOrdinal: arc.firstBattleOrdinal, lastBattleOrdinal: arc.lastBattleOrdinal, firstBattleId: segmentRoutes[0]?.battleId, lastBattleId: lastRoute?.battleId, checkpointStep: specialCheckpointSteps[arc.skinId] ?? lastRoute?.campStep, battleIds: segmentRoutes.map((route) => route.battleId) }; }); const manifestHash = createHash('sha256') .update(JSON.stringify(segments.map(({ id, firstBattleOrdinal, lastBattleOrdinal, checkpointStep, battleIds }) => ({ id, firstBattleOrdinal, lastBattleOrdinal, checkpointStep, battleIds })))) .digest('hex'); return { routes, segments, manifestHash }; } export function campaignFlowSegmentSummary(segment) { return `${segment.id} (${segment.label}, ${segment.firstBattleOrdinal}-${segment.lastBattleOrdinal}전)`; } function parseCampaignRoutes(source) { const mapBody = source.match(/const battleIdByCampaignStep[\s\S]*?=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; return [...mapBody.matchAll(/^\s*'([^']+-battle)':\s*'([^']+)',?\s*$/gm)].map((match) => ({ battleStep: match[1], battleId: match[2], campStep: match[1].replace(/-battle$/, '-camp') })); } function parseCampSkinBattleArcs(source) { const arcBody = source.match(/export const campSkinBattleArcs:[\s\S]*?=\s*\[([\s\S]*?)\n\];/)?.[1] ?? ''; return [...arcBody.matchAll( /\{\s*skinId:\s*'([^']+)',\s*firstBattleOrdinal:\s*(\d+),\s*lastBattleOrdinal:\s*(\d+)\s*\}/g )].map((match) => ({ skinId: match[1], firstBattleOrdinal: Number(match[2]), lastBattleOrdinal: Number(match[3]) })); } function readCampSkinChapterLabel(source, skinId) { const marker = `id: '${skinId}'`; const markerIndex = source.indexOf(marker); const nearbySource = markerIndex >= 0 ? source.slice(markerIndex, markerIndex + 900) : ''; const label = nearbySource.match(/chapterLabel:\s*'([^']+)'/)?.[1]; if (!label) { throw new Error(`Could not find the camp chapter label for ${skinId}.`); } return label; } function validateCampaignRoutes(routes) { const battleIds = routes.map((route) => route.battleId); const battleSteps = routes.map((route) => route.battleStep); if ( routes.length !== 66 || new Set(battleIds).size !== routes.length || new Set(battleSteps).size !== routes.length ) { throw new Error(`Campaign routing must contain 66 unique ordered battles: ${JSON.stringify({ routeCount: routes.length, battleIdCount: new Set(battleIds).size, battleStepCount: new Set(battleSteps).size })}`); } } function validateCampSkinArcs(arcs, routeCount) { if (arcs.length === 0) { throw new Error('No camp-skin battle arcs were found.'); } const segmentIds = arcs.map(({ skinId }) => skinId); if (new Set(segmentIds).size !== segmentIds.length) { throw new Error(`Camp-skin battle arc IDs must be unique: ${JSON.stringify(segmentIds)}`); } const invalidSegmentIds = segmentIds.filter((segmentId) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segmentId)); if (invalidSegmentIds.length > 0) { throw new Error(`Camp-skin battle arc IDs must be safe kebab-case paths: ${JSON.stringify(invalidSegmentIds)}`); } let expectedFirstOrdinal = 1; for (const arc of arcs) { if ( arc.firstBattleOrdinal !== expectedFirstOrdinal || arc.lastBattleOrdinal < arc.firstBattleOrdinal ) { throw new Error(`Camp-skin battle arcs must be contiguous and ordered: ${JSON.stringify({ arc, expectedFirstOrdinal })}`); } expectedFirstOrdinal = arc.lastBattleOrdinal + 1; } if (expectedFirstOrdinal !== routeCount + 1) { throw new Error(`Camp-skin battle arcs must cover all ${routeCount} battles; next ordinal was ${expectedFirstOrdinal}.`); } }