137 lines
4.9 KiB
JavaScript
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}.`);
|
|
}
|
|
}
|