#!/usr/bin/env -S deno run -A import { Confirm } from "@cliffy/prompt/confirm"; import { Input } from "@cliffy/prompt/input"; import { Select } from "@cliffy/prompt/select"; import { join } from "@std/path"; import { createBeatLeaderAPI, summarizeAllFromHashResponse, } from "./lib/beatleader.ts"; import { resolveBeatSaverMeta } from "./lib/beatsaver.ts"; import type { InventoryMission } from "./lib/campaign-types.ts"; import { CHARACTERISTIC_CHOICES, defaultChallengeModifiers, difficultyIndexFromLabel, } from "./lib/campaign-types.ts"; import { generateCampaignToDist } from "./lib/generate.ts"; import { defaultInventoryTemplate, nextMissionIndex, readCampaignInventory, writeCampaignInventory, } from "./lib/inventory.ts"; import type { CampaignInventory } from "./lib/campaign-types.ts"; import { DEFAULT_BS_MANAGER_CAMPAIGNS, DEFAULT_DIST_DIR, DEFAULT_INVENTORY_PATH, } from "./lib/paths.ts"; import { deployCampaignFolder } from "./lib/deploy.ts"; import { formatIssues, validateGeneratedFolder, validateInventory, } from "./lib/validate.ts"; async function loadOrInitInventory(): Promise< { path: string; inv: CampaignInventory } > { try { await Deno.stat(DEFAULT_INVENTORY_PATH); const inv = await readCampaignInventory(DEFAULT_INVENTORY_PATH); return { path: DEFAULT_INVENTORY_PATH, inv }; } catch { const inv = defaultInventoryTemplate(); await writeCampaignInventory(DEFAULT_INVENTORY_PATH, inv); console.log(`Created starter inventory:\n ${DEFAULT_INVENTORY_PATH}`); return { path: DEFAULT_INVENTORY_PATH, inv }; } } async function saveInventory(path: string, inv: CampaignInventory) { await writeCampaignInventory(path, inv); console.log(`Saved inventory (${inv.missions.length} missions)`); } async function editCampaignMeta(inv: CampaignInventory) { inv.info.name = await Input.prompt({ message: "Campaign title (name)", default: inv.info.name, }); inv.info.desc = await Input.prompt({ message: "Short description", default: inv.info.desc, }); inv.info.bigDesc = await Input.prompt({ message: "Long description (bigDesc)", default: inv.info.bigDesc, }); inv.outputFolderName = await Input.prompt({ message: "Output folder name under dist/", default: inv.outputFolderName, }); const au = await Select.prompt({ message: "allUnlocked?", options: [ { name: "false (use campaign graph / locks)", value: "0" }, { name: "true (everything unlocked)", value: "1" }, ], default: inv.info.allUnlocked ? "1" : "0", }); inv.info.allUnlocked = au === "1"; } async function addMissionInteractive(inv: CampaignInventory) { const raw = await Input.prompt({ message: "BeatSaver key or 40-char map hash", }); const meta = await resolveBeatSaverMeta(raw); if (!meta) { console.error("Could not resolve map from BeatSaver."); return; } const api = createBeatLeaderAPI(globalThis.fetch); const lbPayload = await api.getLeaderboardsByHash(meta.hash); const summaries = summarizeAllFromHashResponse(lbPayload).filter((s) => s.difficultyName ); let characteristic: string; let difficulty: number; if (summaries.length > 0) { const choice = await Select.prompt({ message: "Pick a leaderboard difficulty (BeatLeader)", options: summaries.map((s) => ({ name: `${s.modeName ?? "Standard"} / ${ s.difficultyName ?? "?" } — stars ${s.stars ?? "?"}`, value: `${s.modeName ?? "Standard"}|${s.difficultyName}`, })), default: summaries[0].modeName && summaries[0].difficultyName ? `${summaries[0].modeName}|${summaries[0].difficultyName}` : undefined, }); const [, diffName] = choice.split("|"); characteristic = await Select.prompt({ message: "Characteristic for CustomCampaigns mission JSON", options: CHARACTERISTIC_CHOICES.map((c) => ({ name: c, value: c })), default: "Standard", }); difficulty = difficultyIndexFromLabel(diffName ?? "ExpertPlus"); console.log( `Mapped difficulty label "${diffName}" → index ${difficulty} (${characteristic}).`, ); } else { characteristic = await Select.prompt({ message: "Characteristic", options: CHARACTERISTIC_CHOICES.map((c) => ({ name: c, value: c })), default: "Standard", }); const dn = await Input.prompt({ message: "Difficulty label (Easy..ExpertPlus)", default: "ExpertPlus", }); difficulty = difficultyIndexFromLabel(dn); } const defaultMissionName = `${meta.songName ?? meta.name ?? "Song"}`.trim(); const m: InventoryMission = { index: nextMissionIndex(inv), name: await Input.prompt({ message: "Mission title", default: defaultMissionName, }), songid: meta.id, hash: meta.hash, customDownloadURL: "", characteristic, difficulty, modifiers: defaultChallengeModifiers(), requirements: [], notes: "", purpose: await Input.prompt({ message: "Curator purpose / notes tag (inventory only)", default: "", }), }; const wantReq = await Confirm.prompt({ message: "Add a simple score requirement?", default: false, }); if (wantReq) { const count = Number( await Input.prompt({ message: "Target score", default: "1000000" }), ); m.requirements = [{ type: "score", count: Number.isFinite(count) ? count : 1_000_000, isMax: false, }]; } inv.missions.push(m); console.log(`Added mission ${m.index}: ${m.name} (${m.songid}, ${m.hash})`); } function listMissions(inv: CampaignInventory) { if (!inv.missions.length) { console.log("(no missions yet)"); return; } const sorted = [...inv.missions].sort((a, b) => a.index - b.index); for (const x of sorted) { console.log( `#${x.index}\t${x.songid}\t${ (x.hash ?? "").slice(0, 12) }…\t${x.characteristic}\t${x.difficulty}\t${x.name}`, ); } } (async () => { let { path, inv } = await loadOrInitInventory(); while (true) { const action = await Select.prompt({ message: "Campaign helper menu", options: [ { name: "[1] Edit campaign metadata / output folder", value: "meta" }, { name: "[2] Add mission from BeatSaver key or hash (+ BeatLeader enrichment)", value: "add", }, { name: "[3] List missions in inventory", value: "list" }, { name: "[4] Validate inventory (and dist if present)", value: "val" }, { name: "[5] Generate dist//", value: "gen" }, { name: "[6] Deploy to BSManager CustomCampaigns (dry-run first)", value: "dep", }, { name: "[7] Reload inventory from disk", value: "reload" }, { name: "[q] Quit", value: "q" }, ], default: "add", }); if (action === "q") break; if (action === "reload") { inv = await readCampaignInventory(path); console.log("Reloaded."); continue; } if (action === "meta") { await editCampaignMeta(inv); await saveInventory(path, inv); continue; } if (action === "add") { await addMissionInteractive(inv); await saveInventory(path, inv); continue; } if (action === "list") { listMissions(inv); continue; } if (action === "val") { const issues = validateInventory(inv); console.log(issues.length ? formatIssues(issues) : "(inventory OK)"); const outDir = join(DEFAULT_DIST_DIR, inv.outputFolderName); try { await Deno.stat(outDir); console.log(`\n--- dist folder ---\n${outDir}`); const disk = await validateGeneratedFolder(inv, outDir); console.log(disk.length ? formatIssues(disk) : "(generated folder OK)"); } catch { console.log(`(no generated folder yet: ${outDir})`); } continue; } if (action === "gen") { const issues = validateInventory(inv); const errors = issues.filter((i) => i.level === "error"); if (errors.length) { console.error(formatIssues(errors)); continue; } const { outDir, markdownPath, markdownWarnings, } = await generateCampaignToDist(inv, DEFAULT_DIST_DIR); console.log(`Generated:\n ${outDir}\n ${markdownPath}`); if (markdownWarnings.length) { console.warn("Markdown / URL warnings:\n" + markdownWarnings.join("\n")); } continue; } if (action === "dep") { const outDir = join(DEFAULT_DIST_DIR, inv.outputFolderName); const dry = await Confirm.prompt({ message: `Dry run only (count files under ${outDir})?`, default: true, }); const dest = DEFAULT_BS_MANAGER_CAMPAIGNS; const first = await deployCampaignFolder({ srcDir: outDir, destParentDir: dest, folderName: inv.outputFolderName, dryRun: dry, }); console.log( `${ dry ? "Dry run" : "Deploy" } (${first.files} files) → ${first.destDir}`, ); if (dry) { const doit = await Confirm.prompt({ message: "Run real deploy now?", default: false, }); if (doit) { const second = await deployCampaignFolder({ srcDir: outDir, destParentDir: dest, folderName: inv.outputFolderName, dryRun: false, }); console.log(`Deployed (${second.files} files) → ${second.destDir}`); } } continue; } } })();