Initalize new text-based helper to build custom campaigns

This commit is contained in:
pleb
2026-05-02 09:53:41 -07:00
commit e25390c1f8
16 changed files with 1661 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
/** Public BeatLeader API client patterns mirrored from plebsaber.stream `src/lib/server/beatleader.ts`. */
export const BEATLEADER_BASE_URL = "https://api.beatleader.com";
const CACHE_TTL_MS = 5 * 60 * 1000;
const MAX_RETRIES = 5;
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 60_000;
const BACKOFF_FACTOR = 2;
type CacheEntry = { expiresAt: number; data: unknown };
const responseCache: Map<string, CacheEntry> = new Map();
const WEBSITE_COOKIE_HEADER = "Cookie";
export class RateLimitError extends Error {
readonly status = 429;
readonly retryAfterMs?: number;
constructor(message: string, retryAfterMs?: number) {
super(message);
this.name = "RateLimitError";
this.retryAfterMs = retryAfterMs;
}
}
async function requestWith429Retry(
fetchFn: typeof fetch,
url: string,
init?: RequestInit,
): Promise<Response> {
let attempt = 0;
let backoffMs = INITIAL_BACKOFF_MS;
while (true) {
try {
const res = await fetchFn(url, init);
if (res.status === 429) {
attempt += 1;
const retryAfterHeader = res.headers.get("Retry-After");
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
const waitMs = Number.isFinite(retryAfterSec)
? retryAfterSec * 1000
: backoffMs;
if (attempt > MAX_RETRIES) {
throw new RateLimitError("BeatLeader rate limit exceeded", waitMs);
}
await new Promise((r) => setTimeout(r, waitMs));
backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS);
continue;
}
return res;
} catch (err) {
attempt += 1;
if (attempt > MAX_RETRIES) throw err;
await new Promise((r) => setTimeout(r, backoffMs));
backoffMs = Math.min(backoffMs * BACKOFF_FACTOR, MAX_BACKOFF_MS);
}
}
}
async function fetchJsonCached(
fetchFn: typeof fetch,
url: string,
options: { headers?: Record<string, string> } = {},
ttlMs = CACHE_TTL_MS,
): Promise<unknown> {
const now = Date.now();
const auth = Boolean(
options.headers &&
(options.headers["Authorization"] ??
options.headers[WEBSITE_COOKIE_HEADER]),
);
const cacheKey = auth ? null : url;
if (cacheKey) {
const cached = responseCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.data;
}
}
const res = await requestWith429Retry(fetchFn, url, {
headers: options.headers,
});
if (!res.ok) {
throw new Error(`BeatLeader request failed ${res.status} ${url}`);
}
const data = await res.json();
if (cacheKey) {
responseCache.set(cacheKey, { expiresAt: now + ttlMs, data });
}
return data;
}
export type QueryParams = Record<
string,
string | number | boolean | undefined | null
>;
export function buildQuery(params: QueryParams): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
searchParams.set(key, String(value));
}
const qs = searchParams.toString();
return qs ? `?${qs}` : "";
}
/** Optional auth: Bearer preferred over website Cookie (matches plebsaber). */
export class BeatLeaderAPI {
private readonly fetchFn: typeof fetch;
private readonly accessToken?: string;
private readonly websiteCookieHeader?: string;
constructor(
fetchFn: typeof fetch,
accessToken?: string,
websiteCookieHeader?: string,
) {
this.fetchFn = fetchFn;
this.accessToken = accessToken;
this.websiteCookieHeader = websiteCookieHeader;
}
private buildHeaders(): Record<string, string> | undefined {
if (this.accessToken) {
return { Authorization: `Bearer ${this.accessToken}` };
}
if (this.websiteCookieHeader) {
return { [WEBSITE_COOKIE_HEADER]: this.websiteCookieHeader };
}
return undefined;
}
getPlayer(playerId: string): Promise<unknown> {
const url = `${BEATLEADER_BASE_URL}/player/${encodeURIComponent(playerId)}`;
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
}
getPlayerScores(playerId: string, params: {
page?: number;
count?: number;
leaderboardContext?: string;
sortBy?: string | number;
order?: "asc" | "desc" | string;
search?: string;
diff?: string;
mode?: string;
requirements?: string;
type?: string;
hmd?: string;
modifiers?: string;
stars_from?: string | number;
stars_to?: string | number;
eventId?: string | number;
includeIO?: boolean;
} = {}): Promise<unknown> {
const query = buildQuery(params);
const url = `${BEATLEADER_BASE_URL}/player/${
encodeURIComponent(playerId)
}/scores${query}`;
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
}
getLeaderboard(
hash: string,
options: { diff?: string; mode?: string; page?: number; count?: number } =
{},
): Promise<unknown> {
const diff = options.diff ?? "ExpertPlus";
const mode = options.mode ?? "Standard";
const query = buildQuery({ page: options.page, count: options.count });
const url = `${BEATLEADER_BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${
encodeURIComponent(diff)
}/${encodeURIComponent(mode)}${query}`;
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
}
getRankedLeaderboards(
params: {
stars_from?: number;
stars_to?: number;
page?: number;
count?: number;
} = {},
): Promise<unknown> {
const query = buildQuery({
page: params.page,
count: params.count,
type: "ranked",
stars_from: params.stars_from,
stars_to: params.stars_to,
});
const url = `${BEATLEADER_BASE_URL}/leaderboards${query}`;
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
}
getUser(): Promise<unknown> {
const url = `${BEATLEADER_BASE_URL}/user`;
return fetchJsonCached(
this.fetchFn,
url,
{ headers: this.buildHeaders() },
30_000,
);
}
/** Leaderboards for all difficulties/modes tied to one map hash. */
getLeaderboardsByHash(hash: string): Promise<unknown> {
const url = `${BEATLEADER_BASE_URL}/leaderboards/hash/${
encodeURIComponent(hash)
}`;
return fetchJsonCached(this.fetchFn, url, { headers: this.buildHeaders() });
}
}
export function createBeatLeaderAPI(
fetchFn: typeof fetch,
accessToken?: string,
websiteCookieHeader?: string,
): BeatLeaderAPI {
return new BeatLeaderAPI(fetchFn, accessToken, websiteCookieHeader);
}
/** Normalized row for curator UI — derived from `/leaderboards/hash/{hash}` payload. */
export type BeatLeaderMapDifficultySummary = {
difficultyName?: string;
modeName?: string;
stars?: number;
accRating?: number;
passRating?: number;
techRating?: number;
status?: number;
};
function asRecord(v: unknown): Record<string, unknown> | null {
return v != null && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
/** Extract leaderboards array from various BeatLeader response shapes. */
export function leaderboardsArrayFromHashPayload(data: unknown): unknown[] {
const r = asRecord(data);
if (!r) return [];
const lb = r["leaderboards"];
if (Array.isArray(lb)) return lb;
if (Array.isArray(data)) return data;
return [];
}
export function summarizeLeaderboardEntry(
entry: unknown,
): BeatLeaderMapDifficultySummary {
const r = asRecord(entry);
if (!r) return {};
const diff = asRecord(r["difficulty"]);
const difficultyName = (diff?.["difficultyName"] ?? diff?.["name"]) as
| string
| undefined;
const modeName = (diff?.["modeName"] ?? r["modeName"]) as string | undefined;
return {
difficultyName: typeof difficultyName === "string"
? difficultyName
: undefined,
modeName: typeof modeName === "string" ? modeName : "Standard",
stars: (diff?.["stars"] ?? r["stars"]) as number | undefined,
accRating: diff?.["accRating"] as number | undefined,
passRating: diff?.["passRating"] as number | undefined,
techRating: diff?.["techRating"] as number | undefined,
status: diff?.["status"] as number | undefined,
};
}
export function summarizeAllFromHashResponse(
data: unknown,
): BeatLeaderMapDifficultySummary[] {
return leaderboardsArrayFromHashPayload(data).map(summarizeLeaderboardEntry);
}
+99
View File
@@ -0,0 +1,99 @@
/** BeatSaver public API — map key / hash resolution for campaign `songid` + `hash`. */
const BEATSAVER_BASE = "https://api.beatsaver.com";
export type BeatSaverMapMeta = {
id: string;
name?: string;
/** Lowercase SHA-1 for latest / selected version */
hash: string;
songName?: string;
songSubName?: string;
levelAuthorName?: string;
uploaderName?: string;
coverURL?: string;
};
function asRecord(v: unknown): Record<string, unknown> | null {
return v != null && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
async function fetchJson(url: string): Promise<unknown> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`BeatSaver request failed ${res.status} ${url}`);
}
return res.json();
}
function pickLatestVersionHashes(
versions: unknown,
): { hash: string; coverURL?: string } | null {
if (!Array.isArray(versions) || versions.length === 0) return null;
const last = versions[versions.length - 1];
const vr = asRecord(last);
if (!vr) return null;
const h = vr["hash"];
const coverURL = vr["coverURL"];
return {
hash: typeof h === "string" ? h : "",
coverURL: typeof coverURL === "string" ? coverURL : undefined,
};
}
export function mapBeatSaverResponseToMeta(
data: unknown,
): BeatSaverMapMeta | null {
const r = asRecord(data);
if (!r) return null;
const meta = asRecord(r["metadata"]);
const uid = asRecord(r["uploader"]);
const vhash = pickLatestVersionHashes(r["versions"]);
const id = r["id"];
if (!vhash?.hash || typeof id !== "string") return null;
return {
id,
name: typeof r["name"] === "string" ? r["name"] : undefined,
hash: vhash.hash,
songName: meta?.["songName"] as string | undefined,
songSubName: meta?.["songSubName"] as string | undefined,
levelAuthorName: meta?.["levelAuthorName"] as string | undefined,
uploaderName: uid?.["name"] as string | undefined,
coverURL: vhash.coverURL,
};
}
export async function fetchBeatSaverByKey(
key: string,
): Promise<BeatSaverMapMeta | null> {
const trimmed = key.trim().toLowerCase();
if (!trimmed) return null;
const data = await fetchJson(
`${BEATSAVER_BASE}/maps/id/${encodeURIComponent(trimmed)}`,
);
return mapBeatSaverResponseToMeta(data);
}
export async function fetchBeatSaverByHash(
hash: string,
): Promise<BeatSaverMapMeta | null> {
const h = hash.trim().toLowerCase();
if (!h) return null;
const data = await fetchJson(
`${BEATSAVER_BASE}/maps/hash/${encodeURIComponent(h)}`,
);
return mapBeatSaverResponseToMeta(data);
}
export async function resolveBeatSaverMeta(
input: string,
): Promise<BeatSaverMapMeta | null> {
const s = input.trim();
if (!s) return null;
if (/^[a-f0-9]{40}$/i.test(s)) {
return await fetchBeatSaverByHash(s);
}
return await fetchBeatSaverByKey(s);
}
+205
View File
@@ -0,0 +1,205 @@
/** CustomCampaigns-compatible JSON shapes (camelCase aligned with Newtonsoft defaults + planning doc quirks). */
export type CampaignLightColor = {
r: number;
g: number;
b: number;
};
/** Typo preserved in plugin / editor (`CampainMapPosition`). */
export type CampainMapPosition = {
childNodes?: number[];
x: number;
y: number;
scale?: number;
letterPortion?: string;
numberPortion?: number;
nodeDefaultColor?: string;
nodeHighlightColor?: string;
nodeOutlineLocation?: string;
nodeBackgroundLocation?: string;
};
export type CampaignUnlockGate = {
clearsToPass: number;
x: number;
y: number;
};
export type CampaignInfoJson = {
name: string;
desc: string;
bigDesc: string;
allUnlocked: boolean;
mapHeight?: number;
backgroundAlpha?: number;
lightColor?: CampaignLightColor;
unlockGate?: CampaignUnlockGate[];
mapPositions: CampainMapPosition[];
useStandardLevel?: boolean;
customMissionLeaderboard?: string;
};
export type ChallengeModifiers = {
disappearingArrows: boolean;
strictAngles: boolean;
fastNotes: boolean;
noBombs: boolean;
failOnSaberClash: boolean;
instaFail: boolean;
noFail: boolean;
batteryEnergy: boolean;
ghostNotes: boolean;
noArrows: boolean;
speedMul: number;
/** Combo: Bar = 0, Battery = 1 */
energyType: number;
/** Full = 0, FullHeightOnly = 1, NoObstacles = 2 */
enabledObstacleType: number;
};
export type ChallengeRequirement = {
type: string;
count: number;
isMax: boolean;
};
export type InfoSegment = {
hasSeperator?: boolean;
imageName?: string;
text?: string;
};
export type ChallengeInfoJson = {
showEverytime?: boolean;
title?: string;
segments?: InfoSegment[];
};
export type UnlockableItem = {
fileName: string;
name: string;
type: number;
};
export type ChallengeJson = {
name: string;
songid: string;
hash: string;
customDownloadURL: string;
characteristic: string;
difficulty: number;
modifiers: ChallengeModifiers;
unlockMap: boolean;
requirements: ChallengeRequirement[];
externalModifiers?: Record<string, string[]>;
unlockableItems?: UnlockableItem[];
challengeInfo?: ChallengeInfoJson | null;
allowStandardLevel?: boolean;
};
export const DIFFICULTY_LABELS = [
"Easy",
"Normal",
"Hard",
"Expert",
"ExpertPlus",
] as const;
export type DifficultyLabel = typeof DIFFICULTY_LABELS[number];
/** BeatmapCharacteristic-style strings used by CustomCampaigns editor. */
export const CHARACTERISTIC_CHOICES = [
"Standard",
"Lawless",
"OneSaber",
"NoArrows",
"360Degree",
"90Degree",
] as const;
export function difficultyIndexFromLabel(name: string): number {
const n = normalizeDifficultyLabel(name);
const i = (DIFFICULTY_LABELS as readonly string[]).indexOf(n);
return i >= 0 ? i : 4;
}
export function normalizeDifficultyLabel(value: unknown): DifficultyLabel {
const raw = String(value ?? "").trim();
const lower = raw.toLowerCase().replace(/\s+/g, "");
if (lower === "expert+" || lower === "expertplus") return "ExpertPlus";
if (lower === "expert") return "Expert";
if (lower === "hard") return "Hard";
if (lower === "normal") return "Normal";
if (lower === "easy") return "Easy";
const hit = DIFFICULTY_LABELS.find((l) =>
l.toLowerCase().replace("+", "") === lower.replace("+", "") ||
raw === l
);
return hit ?? "ExpertPlus";
}
export function defaultChallengeModifiers(): ChallengeModifiers {
return {
disappearingArrows: false,
strictAngles: false,
fastNotes: false,
noBombs: false,
failOnSaberClash: false,
instaFail: false,
noFail: false,
batteryEnergy: false,
ghostNotes: false,
noArrows: false,
speedMul: 1,
energyType: 0,
enabledObstacleType: 0,
};
}
export function mergeChallengeModifiers(
base: ChallengeModifiers,
patch?: Partial<ChallengeModifiers>,
): ChallengeModifiers {
return { ...base, ...patch };
}
/** Source inventory: single campaign (source of truth before generation). */
export type InventoryMission = {
index: number;
name: string;
songid: string;
hash?: string;
customDownloadURL?: string;
characteristic: string;
difficulty: number;
modifiers?: Partial<ChallengeModifiers>;
unlockMap?: boolean;
allowStandardLevel?: boolean;
requirements: ChallengeRequirement[];
externalModifiers?: Record<string, string[]>;
unlockableItems?: UnlockableItem[];
challengeInfo?: ChallengeInfoJson | null;
notes?: string;
purpose?: string;
};
export type CampaignInventory = {
version: 1;
outputFolderName: string;
bsManagerCampaignsRoot?: string;
info: {
name: string;
desc: string;
bigDesc: string;
allUnlocked: boolean;
mapHeight?: number;
backgroundAlpha?: number;
lightColor?: CampaignLightColor;
unlockGate?: CampaignUnlockGate[];
useStandardLevel?: boolean;
customMissionLeaderboard?: string;
};
mapPositions: CampainMapPosition[];
missions: InventoryMission[];
};
+44
View File
@@ -0,0 +1,44 @@
import { join, relative } from "@std/path";
import { ensureDir } from "@std/fs/ensure-dir";
import { walk } from "@std/fs/walk";
export type DeployOptions = {
srcDir: string;
destParentDir: string;
/** Final folder name under destParentDir (defaults to basename of srcDir) */
folderName?: string;
dryRun: boolean;
};
export async function deployCampaignFolder(
options: DeployOptions,
): Promise<{ destDir: string; files: number }> {
const src = options.srcDir.replace(/\/+$/, "");
const baseName = options.folderName ?? src.split("/").pop() ?? "campaign";
const destDir = join(options.destParentDir, baseName);
let count = 0;
if (options.dryRun) {
for await (
const entry of walk(src, { includeFiles: true, includeDirs: false })
) {
if (entry.isFile) count++;
}
return { destDir, files: count };
}
await ensureDir(options.destParentDir);
for await (
const entry of walk(src, { includeFiles: true, includeDirs: false })
) {
if (!entry.isFile) continue;
const rel = relative(src, entry.path);
const target = join(destDir, rel);
await ensureDir(join(target, ".."));
await Deno.copyFile(entry.path, target);
count++;
}
return { destDir, files: count };
}
+122
View File
@@ -0,0 +1,122 @@
import { join } from "@std/path";
import { ensureDir } from "@std/fs/ensure-dir";
import type {
CampaignInfoJson,
CampaignInventory,
CampainMapPosition,
ChallengeJson,
InventoryMission,
} from "./campaign-types.ts";
import {
defaultChallengeModifiers,
mergeChallengeModifiers,
} from "./campaign-types.ts";
function defaultLinearMapPositions(n: number): CampainMapPosition[] {
const out: CampainMapPosition[] = [];
for (let i = 0; i < n; i++) {
const child = i < n - 1 ? [i + 1] : [];
out.push({
x: i * 140,
y: 0,
scale: 1,
numberPortion: i,
letterPortion: "",
childNodes: child,
});
}
return out;
}
/** Ensure mapPositions length matches mission count; pad with defaults if partial. */
export function resolveMapPositions(
inv: CampaignInventory,
): CampainMapPosition[] {
const n = inv.missions.length;
if (n === 0) return [];
const existing = inv.mapPositions ?? [];
if (existing.length === 0) {
return defaultLinearMapPositions(n);
}
if (existing.length === n) return existing;
if (existing.length > n) return existing.slice(0, n);
const base = defaultLinearMapPositions(n);
for (let i = 0; i < existing.length; i++) {
base[i] = { ...base[i], ...existing[i] };
}
return base;
}
function missionToChallenge(m: InventoryMission): ChallengeJson {
const mods = mergeChallengeModifiers(
defaultChallengeModifiers(),
m.modifiers,
);
const chall: ChallengeJson = {
name: m.name,
songid: m.songid,
hash: m.hash ?? "",
customDownloadURL: m.customDownloadURL ?? "",
characteristic: m.characteristic,
difficulty: m.difficulty,
modifiers: mods,
unlockMap: m.unlockMap ?? false,
requirements: m.requirements ?? [],
};
if (m.allowStandardLevel !== undefined) {
chall.allowStandardLevel = m.allowStandardLevel;
}
if (m.externalModifiers && Object.keys(m.externalModifiers).length > 0) {
chall.externalModifiers = m.externalModifiers;
}
if (m.unlockableItems && m.unlockableItems.length > 0) {
chall.unlockableItems = m.unlockableItems;
}
if (m.challengeInfo !== undefined) chall.challengeInfo = m.challengeInfo;
return chall;
}
export type GenerateResult = {
outDir: string;
missionCount: number;
};
export async function generateCampaignToDist(
inv: CampaignInventory,
distRoot: string,
): Promise<GenerateResult> {
const outDir = join(distRoot, inv.outputFolderName);
await ensureDir(outDir);
const sorted = [...inv.missions].sort((a, b) => a.index - b.index);
const mapPositions = resolveMapPositions({ ...inv, missions: sorted });
const info: CampaignInfoJson = {
name: inv.info.name,
desc: inv.info.desc,
bigDesc: inv.info.bigDesc,
allUnlocked: inv.info.allUnlocked,
mapHeight: inv.info.mapHeight,
backgroundAlpha: inv.info.backgroundAlpha,
lightColor: inv.info.lightColor,
unlockGate: inv.info.unlockGate,
mapPositions,
useStandardLevel: inv.info.useStandardLevel,
customMissionLeaderboard: inv.info.customMissionLeaderboard,
};
await Deno.writeTextFile(
join(outDir, "info.json"),
JSON.stringify(info, null, 2) + "\n",
);
for (const m of sorted) {
const chall = missionToChallenge(m);
await Deno.writeTextFile(
join(outDir, `${m.index}.json`),
JSON.stringify(chall, null, 2) + "\n",
);
}
return { outDir, missionCount: sorted.length };
}
+64
View File
@@ -0,0 +1,64 @@
import type { CampaignInventory } from "./campaign-types.ts";
import { dirname, resolve } from "@std/path";
function isCampaignInventory(raw: unknown): raw is CampaignInventory {
const r = raw as CampaignInventory | null;
if (!r || r.version !== 1) return false;
if (typeof r.outputFolderName !== "string" || !r.outputFolderName.trim()) {
return false;
}
if (!r.info || typeof r.info !== "object") return false;
if (typeof r.info.name !== "string") return false;
if (!Array.isArray(r.mapPositions) || !Array.isArray(r.missions)) {
return false;
}
return true;
}
export function parseCampaignInventory(json: unknown): CampaignInventory {
if (!isCampaignInventory(json)) {
throw new Error(
"Invalid campaign.inventory.json: expected version 1 with info, mapPositions, missions",
);
}
return json;
}
export async function readCampaignInventory(
path: string,
): Promise<CampaignInventory> {
const text = await Deno.readTextFile(path);
const data = JSON.parse(text) as unknown;
return parseCampaignInventory(data);
}
export async function writeCampaignInventory(
path: string,
inv: CampaignInventory,
): Promise<void> {
const dir = dirname(resolve(path));
await Deno.mkdir(dir, { recursive: true });
await Deno.writeTextFile(path, JSON.stringify(inv, null, 2) + "\n");
}
export function nextMissionIndex(inv: CampaignInventory): number {
if (inv.missions.length === 0) return 0;
return Math.max(...inv.missions.map((m) => m.index)) + 1;
}
export function defaultInventoryTemplate(): CampaignInventory {
return {
version: 1,
outputFolderName: "OurCampaign",
info: {
name: "New Campaign",
desc: "Subtitle",
bigDesc: "Long description for the campaign detail panel.",
allUnlocked: false,
mapHeight: 500,
backgroundAlpha: 1,
},
mapPositions: [],
missions: [],
};
}
+18
View File
@@ -0,0 +1,18 @@
import { dirname, fromFileUrl, join } from "@std/path";
const here = dirname(fromFileUrl(import.meta.url));
/** ost-campaign repository root (parent of tools/) */
export const REPO_ROOT = join(here, "..", "..");
export const DEFAULT_INVENTORY_PATH = join(
REPO_ROOT,
"data",
"campaign.inventory.json",
);
export const DEFAULT_DIST_DIR = join(REPO_ROOT, "dist");
export const DEFAULT_BS_MANAGER_CAMPAIGNS =
Deno.env.get("BS_MANAGER_CUSTOM_CAMPAIGNS") ??
"/home/pleb/.local/share/BSManager/BSInstances/1.40.8/CustomCampaigns/";
+186
View File
@@ -0,0 +1,186 @@
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");
}