323 lines
9.4 KiB
TypeScript
323 lines
9.4 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,
|
|
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;
|
|
}
|
|
}
|
|
})();
|