commit e25390c1f8b21aca2f02f9cb703b90f252637c3c Author: pleb Date: Sat May 2 09:53:41 2026 -0700 Initalize new text-based helper to build custom campaigns diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b06366 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dist/ +/.env +/tools/.env +/docs \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0142558 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Campaign Creator TUI + +**Quick use:** +`deno task tui` or `deno task validate`, `deno task generate`, `deno task deploy -- --dry-run`. \ No newline at end of file diff --git a/data/campaign.inventory.json b/data/campaign.inventory.json new file mode 100644 index 0000000..a63b089 --- /dev/null +++ b/data/campaign.inventory.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "outputFolderName": "OurCampaign", + "bsManagerCampaignsRoot": "/home/pleb/.local/share/BSManager/BSInstances/1.40.8/CustomCampaigns/", + "info": { + "name": "New Campaign", + "desc": "Subtitle", + "bigDesc": "Long description for the campaign detail panel.", + "allUnlocked": false, + "mapHeight": 500, + "backgroundAlpha": 1 + }, + "mapPositions": [], + "missions": [] +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..ac5aad3 --- /dev/null +++ b/deno.json @@ -0,0 +1,20 @@ +{ + "name": "@ost-campaign/tools", + "version": "0.1.0", + "exports": {}, + "tasks": { + "tui": "deno run -A ./tools/tui.ts", + "validate": "deno run -A ./tools/cli.ts validate", + "generate": "deno run -A ./tools/cli.ts generate", + "deploy": "deno run -A ./tools/cli.ts deploy", + "check": "deno check ./tools/**/*.ts && deno lint ./tools" + }, + "imports": { + "@std/assert": "jsr:@std/assert@^1.0", + "@std/fs": "jsr:@std/fs@^1.0", + "@std/path": "jsr:@std/path@^1.0", + "@std/jsonc": "jsr:@std/jsonc@^1.0", + "@std/cli": "jsr:@std/cli@^1.0", + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.7" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..e42587a --- /dev/null +++ b/deno.lock @@ -0,0 +1,91 @@ +{ + "version": "5", + "specifiers": { + "jsr:@cliffy/ansi@1.0.1": "1.0.1", + "jsr:@cliffy/internal@1.0.1": "1.0.1", + "jsr:@cliffy/keycode@1.0.1": "1.0.1", + "jsr:@cliffy/prompt@^1.0.0-rc.7": "1.0.1", + "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/cli@1": "1.0.28", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.9": "1.0.9", + "jsr:@std/fs@1": "1.0.23", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/io@~0.225.3": "0.225.3", + "jsr:@std/path@1": "1.1.4", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/text@^1.0.17": "1.0.18" + }, + "jsr": { + "@cliffy/ansi@1.0.1": { + "integrity": "46be51d0993a916dbed68564a6630dc1a742ebb0247744e04bc17e85d72f5bed", + "dependencies": [ + "jsr:@cliffy/internal", + "jsr:@std/encoding", + "jsr:@std/io" + ] + }, + "@cliffy/internal@1.0.1": { + "integrity": "9e2bba59ad559b790f09c57219c727a69f0179ebabc07f1bf9db25232b606760" + }, + "@cliffy/keycode@1.0.1": { + "integrity": "b01053b39bce5536e36aff9b262d84a7b289bcff03d904f3cf60f9dc1605ce9f" + }, + "@cliffy/prompt@1.0.1": { + "integrity": "717265ab9a2ad2a3c06d2a090bb3068f1a5a5efd6ea3c6da9ea09274f74830cd", + "dependencies": [ + "jsr:@cliffy/ansi", + "jsr:@cliffy/internal", + "jsr:@cliffy/keycode", + "jsr:@std/assert", + "jsr:@std/fmt", + "jsr:@std/io", + "jsr:@std/path@^1.1.4", + "jsr:@std/text" + ] + }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e" + }, + "@std/cli@1.0.28": { + "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", + "dependencies": [ + "jsr:@std/path@^1.1.4" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/io@0.225.3": { + "integrity": "27b07b591384d12d7b568f39e61dff966b8230559122df1e9fd11cc068f7ddd1" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/text@1.0.18": { + "integrity": "d199e516f80599813c64fd4aee5b8f26f6f7d1e1434c88fd153aeea6fea6a9b9" + } + }, + "workspace": { + "dependencies": [ + "jsr:@cliffy/prompt@^1.0.0-rc.7", + "jsr:@std/assert@1", + "jsr:@std/cli@1", + "jsr:@std/fs@1", + "jsr:@std/jsonc@1", + "jsr:@std/path@1" + ] + } +} diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..bf82b31 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,19 @@ +# ost-campaign Deno toolchain + +Interactive helper (`deno task tui`) and CLI +(`deno task validate|generate|deploy`) for authoring **CustomCampaigns** JSON +from [`data/campaign.inventory.json`](data/campaign.inventory.json). + +- **BeatSaver** resolves `songid` + SHA-1 `hash` from a key or hash. +- **BeatLeader** (public API) surfaces leaderboards/stars keyed by hash via + `getLeaderboardsByHash()`. + +Deploy target defaults to the BSManager path in docs; override with +`BS_MANAGER_CUSTOM_CAMPAIGNS`. + +```bash +deno task tui +deno task validate +deno task generate +deno task deploy --dry-run +``` diff --git a/tools/cli.ts b/tools/cli.ts new file mode 100644 index 0000000..33d7113 --- /dev/null +++ b/tools/cli.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env -S deno run -A +/** Non-interactive entry: validate | generate | deploy */ + +import { parseArgs } from "@std/cli/parse-args"; +import { join } from "@std/path"; + +import { + DEFAULT_BS_MANAGER_CAMPAIGNS, + DEFAULT_DIST_DIR, + DEFAULT_INVENTORY_PATH, +} from "./lib/paths.ts"; +import { generateCampaignToDist } from "./lib/generate.ts"; +import { + defaultInventoryTemplate, + readCampaignInventory, + writeCampaignInventory, +} from "./lib/inventory.ts"; +import { deployCampaignFolder } from "./lib/deploy.ts"; +import { + formatIssues, + validateGeneratedFolder, + validateInventory, +} from "./lib/validate.ts"; + +function usage(): string { + return ` +ost-campaign tools (Deno) + +Usage: + deno task validate [--inventory ] [--dist ] + deno task generate [--inventory ] [--dist-dir ] + deno task deploy [--dist ] [--to ] [--dry-run] [--name ] + +Defaults: + inventory: ${DEFAULT_INVENTORY_PATH} + dist-root: ${DEFAULT_DIST_DIR} + deploy to: ${DEFAULT_BS_MANAGER_CAMPAIGNS} + +Environment: + BS_MANAGER_CUSTOM_CAMPAIGNS Overrides default BSManager CustomCampaigns path +`.trim(); +} + +async function cmdValidate(invPath: string, distDir?: string): Promise { + const inv = await readCampaignInventory(invPath); + const invIssues = validateInventory(inv); + console.log(invIssues.length ? formatIssues(invIssues) : "(inventory OK)"); + let code = invIssues.some((x) => x.level === "error") ? 1 : 0; + if (distDir) { + try { + await Deno.stat(distDir); + } catch { + console.error(`Missing dist folder: ${distDir}`); + return 1; + } + const disk = await validateGeneratedFolder(inv, distDir); + if (disk.length) { + console.log("--- generated folder ---"); + console.log(formatIssues(disk)); + } + if (disk.some((x) => x.level === "error")) code = 1; + } + return code; +} + +async function cmdGenerate(invPath: string, distRoot: string): Promise { + const inv = await readCampaignInventory(invPath); + const invIssues = validateInventory(inv); + const errors = invIssues.filter((x) => x.level === "error"); + if (errors.length) { + console.error(formatIssues(errors)); + console.error("(fix inventory errors before generating)"); + return 1; + } + const { outDir, missionCount } = await generateCampaignToDist(inv, distRoot); + console.log(`Wrote ${missionCount} missions to ${outDir}`); + return 0; +} + +async function cmdDeploy( + distCampaignDir: string, + destParent: string, + dryRun: boolean, + folderName?: string, +): Promise { + const { destDir, files } = await deployCampaignFolder({ + srcDir: distCampaignDir, + destParentDir: destParent, + folderName, + dryRun, + }); + if (dryRun) { + console.log(`Dry run: would copy ${files} files to ${destDir}`); + } else { + console.log(`Copied ${files} files -> ${destDir}`); + } + return 0; +} + +async function seedInventoryIfMissing(path: string): Promise { + try { + await Deno.stat(path); + } catch { + const tmpl = defaultInventoryTemplate(); + await writeCampaignInventory(path, tmpl); + console.log(`Created starter inventory at ${path}`); + } +} + +const parsed = parseArgs(Deno.args, { + alias: { + i: "inventory", + I: "inventory", + h: "help", + }, + boolean: ["help", "dry-run"], + string: ["inventory", "dist", "dist-dir", "to", "name"], +}); + +if (parsed.help || parsed._.length === 0) { + console.log(usage()); + Deno.exit(parsed.help ? 0 : 1); +} + +const cmd = String(parsed._[0]); + +(async () => { + const invPath = parsed.inventory ?? DEFAULT_INVENTORY_PATH; + + if (cmd === "validate") { + try { + await Deno.stat(invPath); + } catch { + console.error(`Inventory not found: ${invPath}`); + Deno.exit(1); + } + let distDir: string | undefined = parsed.dist; + if (!distDir) { + try { + const inv = await readCampaignInventory(invPath); + const candidate = join(DEFAULT_DIST_DIR, inv.outputFolderName); + try { + await Deno.stat(candidate); + distDir = candidate; + } catch { + distDir = undefined; + } + } catch { + distDir = undefined; + } + } + Deno.exit(await cmdValidate(invPath, distDir)); + } + + if (cmd === "generate") { + await seedInventoryIfMissing(invPath); + const distRoot = parsed["dist-dir"] ?? DEFAULT_DIST_DIR; + Deno.exit(await cmdGenerate(invPath, distRoot)); + } + + if (cmd === "deploy") { + let distCampaignDir = parsed.dist; + const folderName = parsed.name; + await seedInventoryIfMissing(invPath); + if (!distCampaignDir) { + const inv = await readCampaignInventory(invPath); + distCampaignDir = join(DEFAULT_DIST_DIR, inv.outputFolderName); + } + const dry = parsed["dry-run"] === true; + const dest = parsed.to ?? DEFAULT_BS_MANAGER_CAMPAIGNS; + Deno.exit(await cmdDeploy(distCampaignDir, dest, dry, folderName)); + } + + console.error(`Unknown command: ${cmd}`); + console.log(usage()); + Deno.exit(1); +})(); diff --git a/tools/lib/beatleader.ts b/tools/lib/beatleader.ts new file mode 100644 index 0000000..76833b7 --- /dev/null +++ b/tools/lib/beatleader.ts @@ -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 = 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 { + 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 } = {}, + ttlMs = CACHE_TTL_MS, +): Promise { + 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 | undefined { + if (this.accessToken) { + return { Authorization: `Bearer ${this.accessToken}` }; + } + if (this.websiteCookieHeader) { + return { [WEBSITE_COOKIE_HEADER]: this.websiteCookieHeader }; + } + return undefined; + } + + getPlayer(playerId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | null { + return v != null && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : 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); +} diff --git a/tools/lib/beatsaver.ts b/tools/lib/beatsaver.ts new file mode 100644 index 0000000..761ee5b --- /dev/null +++ b/tools/lib/beatsaver.ts @@ -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 | null { + return v != null && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : null; +} + +async function fetchJson(url: string): Promise { + 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 { + 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 { + 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 { + const s = input.trim(); + if (!s) return null; + if (/^[a-f0-9]{40}$/i.test(s)) { + return await fetchBeatSaverByHash(s); + } + return await fetchBeatSaverByKey(s); +} diff --git a/tools/lib/campaign-types.ts b/tools/lib/campaign-types.ts new file mode 100644 index 0000000..02f2ace --- /dev/null +++ b/tools/lib/campaign-types.ts @@ -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; + 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 { + 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; + unlockMap?: boolean; + allowStandardLevel?: boolean; + requirements: ChallengeRequirement[]; + externalModifiers?: Record; + 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[]; +}; diff --git a/tools/lib/deploy.ts b/tools/lib/deploy.ts new file mode 100644 index 0000000..e3a5fa5 --- /dev/null +++ b/tools/lib/deploy.ts @@ -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 }; +} diff --git a/tools/lib/generate.ts b/tools/lib/generate.ts new file mode 100644 index 0000000..efafdaa --- /dev/null +++ b/tools/lib/generate.ts @@ -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 { + 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 }; +} diff --git a/tools/lib/inventory.ts b/tools/lib/inventory.ts new file mode 100644 index 0000000..b073516 --- /dev/null +++ b/tools/lib/inventory.ts @@ -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 { + 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 { + 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: [], + }; +} diff --git a/tools/lib/paths.ts b/tools/lib/paths.ts new file mode 100644 index 0000000..24e2466 --- /dev/null +++ b/tools/lib/paths.ts @@ -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/"; diff --git a/tools/lib/validate.ts b/tools/lib/validate.ts new file mode 100644 index 0000000..b3b7549 --- /dev/null +++ b/tools/lib/validate.ts @@ -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(); + 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"); +} diff --git a/tools/tui.ts b/tools/tui.ts new file mode 100644 index 0000000..48161ef --- /dev/null +++ b/tools/tui.ts @@ -0,0 +1,315 @@ +#!/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 } = 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; + } + } +})();