Initalize new text-based helper to build custom campaigns

This commit is contained in:
pleb 2026-05-02 09:53:41 -07:00
commit e25390c1f8
16 changed files with 1661 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/dist/
/.env
/tools/.env
/docs

4
README.md Normal file
View 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`.

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}
})();