fix: harden campaign resume UX and flow verification
This commit is contained in:
136
scripts/campaign-flow-segments.mjs
Normal file
136
scripts/campaign-flow-segments.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
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}.`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user