Files
heros_web/scripts/campaign-flow-segments.mjs

137 lines
4.9 KiB
JavaScript

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