Initalize new text-based helper to build custom campaigns
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/dist/
|
||||||
|
/.env
|
||||||
|
/tools/.env
|
||||||
|
/docs
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Campaign Creator TUI
|
||||||
|
|
||||||
|
**Quick use:**
|
||||||
|
`deno task tui` or `deno task validate`, `deno task generate`, `deno task deploy -- --dry-run`.
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
+177
@@ -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 <path>] [--dist <campaignDir>]
|
||||||
|
deno task generate [--inventory <path>] [--dist-dir <distRoot>]
|
||||||
|
deno task deploy [--dist <campaignDir>] [--to <CustomCampaignsDir>] [--dry-run] [--name <folderName>]
|
||||||
|
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/";
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
+315
@@ -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/<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user