187 lines
6.1 KiB
TypeScript
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");
|
|
}
|