Initalize new text-based helper to build custom campaigns
This commit is contained in:
commit
e25390c1f8
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/dist/
|
||||
/.env
|
||||
/tools/.env
|
||||
/docs
|
||||
4
README.md
Normal file
4
README.md
Normal file
@ -0,0 +1,4 @@
|
||||
# Campaign Creator TUI
|
||||
|
||||
**Quick use:**
|
||||
`deno task tui` or `deno task validate`, `deno task generate`, `deno task deploy -- --dry-run`.
|
||||
15
data/campaign.inventory.json
Normal file
15
data/campaign.inventory.json
Normal file
@ -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": []
|
||||
}
|
||||
20
deno.json
Normal file
20
deno.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
91
deno.lock
generated
Normal file
91
deno.lock
generated
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
19
tools/README.md
Normal file
19
tools/README.md
Normal file
@ -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
tools/cli.ts
Normal file
177
tools/cli.ts
Normal file
@ -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);
|
||||
})();
|
||||
278
tools/lib/beatleader.ts
Normal file
278
tools/lib/beatleader.ts
Normal file
@ -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);
|
||||
}
|
||||
99
tools/lib/beatsaver.ts
Normal file
99
tools/lib/beatsaver.ts
Normal file
@ -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);
|
||||
}
|
||||
205
tools/lib/campaign-types.ts
Normal file
205
tools/lib/campaign-types.ts
Normal file
@ -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[];
|
||||
};
|
||||
44
tools/lib/deploy.ts
Normal file
44
tools/lib/deploy.ts
Normal file
@ -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 };
|
||||
}
|
||||
122
tools/lib/generate.ts
Normal file
122
tools/lib/generate.ts
Normal file
@ -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 };
|
||||
}
|
||||
64
tools/lib/inventory.ts
Normal file
64
tools/lib/inventory.ts
Normal file
@ -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: [],
|
||||
};
|
||||
}
|
||||
18
tools/lib/paths.ts
Normal file
18
tools/lib/paths.ts
Normal file
@ -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/";
|
||||
186
tools/lib/validate.ts
Normal file
186
tools/lib/validate.ts
Normal file
@ -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
tools/tui.ts
Normal file
315
tools/tui.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
Loading…
x
Reference in New Issue
Block a user