187 lines
6.1 KiB
TypeScript

import { join } from "@std/path";
import type { CampaignInventory } from "./campaign-types.ts";
import { resolveMapPositions } from "./generate.ts";
export type ValidateIssue = {
level: "error" | "warn";
code: string;
message: string;
detail?: string;
};
function error(code: string, message: string, detail?: string): ValidateIssue {
return { level: "error", code, message, detail };
}
function warn(code: string, message: string, detail?: string): ValidateIssue {
return { level: "warn", code, message, detail };
}
export function validateInventory(inv: CampaignInventory): ValidateIssue[] {
const issues: ValidateIssue[] = [];
if (!inv.info.name?.trim()) issues.push(error("info.name", "Campaign name is required"));
if (inv.missions.length === 0) issues.push(warn("missions.empty", "No missions in inventory yet"));
const sorted = [...inv.missions].sort((a, b) => a.index - b.index);
const seenIdx = new Set<number>();
for (const m of inv.missions) {
if (!Number.isInteger(m.index) || m.index < 0) {
issues.push(error("mission.index", `Invalid mission index: ${m.index}`));
}
if (seenIdx.has(m.index)) issues.push(error("mission.index.dup", `Duplicate mission index ${m.index}`));
seenIdx.add(m.index);
}
for (let i = 0; i < sorted.length; i++) {
if (sorted[i].index !== i) {
issues.push(
error(
"mission.sequence",
`Mission indices must be contiguous from 0..n-1; expected ${i}, got ${sorted[i].index}`,
),
);
break;
}
}
const keys = new Map<string, number>();
const hashes = new Map<string, number>();
for (const m of inv.missions) {
const k = m.songid?.trim().toLowerCase();
if (k) {
if (keys.has(k) && keys.get(k) !== m.index) {
issues.push(error("songid.dup", `Duplicate BeatSaver key ${m.songid}`, `missions ${keys.get(k)} and ${m.index}`));
}
keys.set(k, m.index);
}
const h = m.hash?.trim().toLowerCase();
if (h && /^[a-f0-9]{40}$/.test(h)) {
if (hashes.has(h) && hashes.get(h) !== m.index) {
issues.push(error("hash.dup", `Duplicate map hash`, `missions ${hashes.get(h)} and ${m.index}`));
}
hashes.set(h, m.index);
} else if (m.hash?.trim()) {
issues.push(warn("hash.format", `Mission ${m.index}: hash should be 40 hex chars`, m.hash));
} else {
issues.push(warn("hash.missing", `Mission ${m.index}: hash empty — SongCore lookup may be unreliable`));
}
if (!m.name?.trim()) issues.push(warn("mission.name", `Mission ${m.index}: name empty`));
if (!m.characteristic?.trim()) issues.push(error("mission.characteristic", `Mission ${m.index}: characteristic required`));
if (!Number.isInteger(m.difficulty) || m.difficulty < 0 || m.difficulty > 4) {
issues.push(error("mission.difficulty", `Mission ${m.index}: difficulty must be 0..4`));
}
}
if (inv.mapPositions.length > 0 && inv.mapPositions.length !== inv.missions.length) {
issues.push(
warn(
"mapPositions.length",
`mapPositions has ${inv.mapPositions.length} entries but ${inv.missions.length} missions — generator will pad/truncate`,
),
);
}
if (!inv.info.allUnlocked && inv.missions.length > 0) {
const n = inv.missions.length;
const positions = resolveMapPositions(inv);
const indegree = new Array(n).fill(0);
const adj: number[][] = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
const children = positions[i]?.childNodes ?? [];
for (const j of children) {
if (!Number.isInteger(j) || j < 0 || j >= n) {
issues.push(error("childNodes.range", `mapPositions[${i}].childNodes references invalid node ${j}`));
continue;
}
indegree[j] += 1;
adj[i].push(j);
}
}
const roots = indegree.map((d, i) => (d === 0 ? i : -1)).filter((i) => i >= 0);
const startSet = roots.length > 0 ? roots : [0];
const visited = new Set<number>();
const stack = [...startSet];
while (stack.length) {
const u = stack.pop()!;
if (visited.has(u)) continue;
visited.add(u);
for (const v of adj[u] ?? []) stack.push(v);
}
for (let i = 0; i < n; i++) {
if (!visited.has(i)) {
issues.push(error("graph.reach", `Mission node ${i} not reachable from campaign start roots`, `roots=${startSet.join(",")}`));
}
}
}
return issues;
}
export async function validateGeneratedFolder(
inv: CampaignInventory,
outDir: string,
): Promise<ValidateIssue[]> {
const issues: ValidateIssue[] = [];
try {
await Deno.stat(join(outDir, "info.json"));
} catch {
issues.push(error("dist.info", "Missing info.json in output folder", outDir));
return issues;
}
const infoText = await Deno.readTextFile(join(outDir, "info.json"));
const info = JSON.parse(infoText) as { mapPositions?: unknown[] };
const mp = Array.isArray(info.mapPositions) ? info.mapPositions.length : 0;
let i = 0;
while (true) {
try {
await Deno.stat(join(outDir, `${i}.json`));
i++;
} catch {
break;
}
}
const expected = inv.missions.length;
if (expected === 0) {
if (i > 0) {
issues.push(
warn(
"dist.stray_missions",
`Inventory has zero missions but dist has ${i} numbered JSON file(s)`,
),
);
}
if (mp > 0 && i === 0) {
issues.push(warn("dist.mapPositions.extra", `mapPositions length ${mp} but zero mission files generated`));
}
return issues;
}
if (i === 0) {
issues.push(error("dist.missions", "No numbered mission JSON files found"));
} else if (i !== expected) {
issues.push(
warn(
"dist.count",
`Found ${i} mission files on disk but inventory has ${inv.missions.length} missions`,
),
);
}
if (mp > 0 && mp !== i) {
issues.push(warn("dist.mapPositions", `info.json mapPositions length ${mp} !== mission file count ${i}`));
}
return issues;
}
export function formatIssues(issues: ValidateIssue[]): string {
return issues
.map((x) => `[${x.level.toUpperCase()}] ${x.code}: ${x.message}${x.detail ? ` (${x.detail})` : ""}`)
.join("\n");
}