316 lines
9.2 KiB
TypeScript

#!/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/<folder>/", 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 } = await generateCampaignToDist(inv, DEFAULT_DIST_DIR);
console.log(`Generated:\n ${outDir}`);
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;
}
}
})();