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(); 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(); const hashes = new Map(); 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(); 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 { 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"); }