Render a markdown file describing the campaign progression during campaign generation

This commit is contained in:
pleb 2026-05-02 10:13:41 -07:00
parent e25390c1f8
commit 7badf6588d
8 changed files with 574 additions and 8 deletions

View File

@ -11,5 +11,33 @@
"backgroundAlpha": 1
},
"mapPositions": [],
"missions": []
"missions": [
{
"index": 0,
"name": "A Jhintleman's $100 Bills",
"songid": "36c0b",
"hash": "348ef45fe3504e2f6b86706b9f616317893163df",
"customDownloadURL": "",
"characteristic": "Standard",
"difficulty": 4,
"modifiers": {
"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
},
"requirements": [],
"notes": "",
"purpose": ""
}
]
}

View File

@ -7,7 +7,8 @@
"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"
"check": "deno check ./tools/**/*.ts && deno lint ./tools",
"test": "deno test -A ./tools/lib/mission-markdown_test.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1.0",

8
deno.lock generated
View File

@ -5,6 +5,7 @@
"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": "1.0.19",
"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",
@ -37,7 +38,7 @@
"jsr:@cliffy/ansi",
"jsr:@cliffy/internal",
"jsr:@cliffy/keycode",
"jsr:@std/assert",
"jsr:@std/assert@^1.0.19",
"jsr:@std/fmt",
"jsr:@std/io",
"jsr:@std/path@^1.1.4",
@ -45,7 +46,10 @@
]
},
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e"
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/cli@1.0.28": {
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"

View File

@ -72,8 +72,17 @@ async function cmdGenerate(invPath: string, distRoot: string): Promise<number> {
console.error("(fix inventory errors before generating)");
return 1;
}
const { outDir, missionCount } = await generateCampaignToDist(inv, distRoot);
const {
outDir,
missionCount,
markdownPath,
markdownWarnings,
} = await generateCampaignToDist(inv, distRoot);
console.log(`Wrote ${missionCount} missions to ${outDir}`);
console.log(`Mission markdown: ${markdownPath}`);
if (markdownWarnings.length) {
console.warn("Markdown / URL warnings:\n" + markdownWarnings.join("\n"));
}
return 0;
}

View File

@ -11,6 +11,7 @@ import {
defaultChallengeModifiers,
mergeChallengeModifiers,
} from "./campaign-types.ts";
import { writeMissionProgressionMarkdown } from "./mission-markdown.ts";
function defaultLinearMapPositions(n: number): CampainMapPosition[] {
const out: CampainMapPosition[] = [];
@ -79,6 +80,8 @@ function missionToChallenge(m: InventoryMission): ChallengeJson {
export type GenerateResult = {
outDir: string;
missionCount: number;
markdownPath: string;
markdownWarnings: string[];
};
export async function generateCampaignToDist(
@ -118,5 +121,13 @@ export async function generateCampaignToDist(
);
}
return { outDir, missionCount: sorted.length };
const { markdownPath, warnings: markdownWarnings } =
await writeMissionProgressionMarkdown(outDir, inv, mapPositions, sorted);
return {
outDir,
missionCount: sorted.length,
markdownPath,
markdownWarnings,
};
}

View File

@ -0,0 +1,416 @@
/**
* Human-readable mission progression markdown + BeatSaver / BeatLeader URLs.
* BeatLeader logic mirrors plebsaber.stream (MapActionButtons + player-playlist-gaps).
*/
import { join } from "@std/path";
import type {
CampainMapPosition,
CampaignInventory,
ChallengeRequirement,
InventoryMission,
} from "./campaign-types.ts";
import { DIFFICULTY_LABELS } from "./campaign-types.ts";
const BEATLEADER_API_HASH_BASE = "https://api.beatleader.com/leaderboards/hash/";
const FETCH_TIMEOUT_MS = 20_000;
export type BLDifficulty = {
modeName?: string;
difficultyName?: string;
value?: number;
ModeName?: string;
DifficultyName?: string;
Value?: number;
};
export type BLLeaderboardInfo = {
id?: string | number | null;
Id?: string | number | null;
leaderboardId?: string | number | null;
LeaderboardId?: string | number | null;
difficulty?: BLDifficulty;
Difficulty?: BLDifficulty;
};
function normalizeName(name?: string | null): string {
return (name ?? "").toString().toLowerCase().replace(/\s+/g, "");
}
function difficultyRankIndex(diffName?: string | null): number {
const order = ["easy", "normal", "hard", "expert", "expert+", "expertplus"];
const n = normalizeName(diffName).replace("expertplus", "expert+");
const idx = order.indexOf(n);
return idx === -1 ? -1 : idx;
}
export function getDifficulty(obj?: BLDifficulty | null): BLDifficulty {
const o = obj ?? {} as BLDifficulty;
return {
modeName: (o.modeName ?? (o as Record<string, unknown>).ModeName) as
| string
| undefined,
difficultyName: (o.difficultyName ?? (o as Record<string, unknown>).DifficultyName) as
| string
| undefined,
value: (o.value ?? (o as Record<string, unknown>).Value) as number | undefined,
};
}
/** Prefer Standard mode; ExpertPlus; else highest known difficulty / value (playlist-gaps). */
export function pickPreferredLeaderboard(
lbs: BLLeaderboardInfo[] | null | undefined,
): BLLeaderboardInfo | null {
if (!Array.isArray(lbs) || lbs.length === 0) return null;
const getDiff = (lb: BLLeaderboardInfo) =>
getDifficulty(lb.difficulty ?? (lb as Record<string, unknown>).Difficulty as BLDifficulty | undefined);
const isStandard = (lb: BLLeaderboardInfo) =>
normalizeName(getDiff(lb)?.modeName) === "standard";
const inStandard = lbs.filter(isStandard);
const pool = inStandard.length > 0 ? inStandard : lbs;
const expertPlus = pool.find((lb) => {
const n = normalizeName(getDiff(lb)?.difficultyName);
return n === "expertplus" || n === "expert+";
});
if (expertPlus) return expertPlus;
const byKnownOrder = [...pool].sort((a, b) =>
difficultyRankIndex(getDiff(a)?.difficultyName) -
difficultyRankIndex(getDiff(b)?.difficultyName)
);
const bestByOrder = byKnownOrder[byKnownOrder.length - 1];
if (
bestByOrder && difficultyRankIndex(getDiff(bestByOrder)?.difficultyName) >= 0
) {
return bestByOrder;
}
const byValue = [...pool].sort((a, b) =>
(getDiff(a)?.value ?? 0) - (getDiff(b)?.value ?? 0)
);
return byValue[byValue.length - 1] ?? pool[0] ?? null;
}
function characteristicMatches(
modeName: string | undefined,
characteristic: string,
): boolean {
return normalizeName(modeName) === normalizeName(characteristic);
}
function difficultyLabelMatches(
difficultyName: string | undefined,
difficultyIndex: number,
): boolean {
const label = DIFFICULTY_LABELS[difficultyIndex] ?? "ExpertPlus";
const want = normalizeName(label).replace("expertplus", "expert+");
const got = normalizeName(difficultyName).replace("expertplus", "expert+");
return got === want ||
(label === "ExpertPlus" && (got === "expert+" || got === "expertplus"));
}
/** Stable pick when multiple leaderboards match (deterministic markdown). */
function pickFirstStableById(lbs: BLLeaderboardInfo[]): BLLeaderboardInfo {
return [...lbs].sort((a, b) =>
String(extractLeaderboardId(a) ?? "").localeCompare(
String(extractLeaderboardId(b) ?? ""),
)
)[0];
}
/**
* Prefer exact characteristic + difficulty match, then characteristic pool + preferred diff,
* then global preferred leaderboard.
*/
export function pickLeaderboardForMission(
lbs: BLLeaderboardInfo[] | null | undefined,
characteristic: string,
difficultyIndex: number,
): BLLeaderboardInfo | null {
if (!Array.isArray(lbs) || lbs.length === 0) return null;
const getDiff = (lb: BLLeaderboardInfo) =>
getDifficulty(lb.difficulty ?? (lb as Record<string, unknown>).Difficulty as BLDifficulty | undefined);
const exact = lbs.filter((lb) => {
const d = getDiff(lb);
return characteristicMatches(d.modeName, characteristic) &&
difficultyLabelMatches(d.difficultyName, difficultyIndex);
});
if (exact.length > 0) return pickFirstStableById(exact);
const charOnly = lbs.filter((lb) =>
characteristicMatches(getDiff(lb).modeName, characteristic)
);
if (charOnly.length > 0) return pickPreferredLeaderboard(charOnly);
return pickPreferredLeaderboard(lbs);
}
export function extractLeaderboardId(lb: BLLeaderboardInfo | null): string | null {
if (!lb) return null;
const anyLb = lb as Record<string, unknown>;
const id = anyLb.id ?? anyLb.leaderboardId ?? anyLb.Id ?? anyLb.LeaderboardId;
return id != null ? String(id) : null;
}
export function buildBeatLeaderUrlFromLeaderboard(
lb: BLLeaderboardInfo | null,
hash: string,
): string {
const id = extractLeaderboardId(lb);
if (id && id.length > 0) {
return `https://beatleader.com/leaderboard/global/${id}`;
}
return `https://beatleader.com/leaderboards?search=${encodeURIComponent(hash)}`;
}
/** Hash + diff query variant (MapActionButtons fallback when no leaderboard id). */
export function buildBeatLeaderUrlHashQuery(params: {
hash: string;
diffName: string;
modeName: string;
}): string {
const { hash, diffName, modeName } = params;
return `https://beatleader.com/leaderboard/global/${hash}?diff=${encodeURIComponent(diffName)}&mode=${encodeURIComponent(modeName)}`;
}
export function buildBeatSaverUrl(
songid: string | undefined,
hash: string | undefined,
): string | null {
const key = songid?.trim();
if (key) return `https://beatsaver.com/maps/${key}`;
const h = hash?.trim().toLowerCase();
if (h && /^[a-f0-9]{40}$/.test(h)) {
return `https://beatsaver.com/search/hash/${h}`;
}
return null;
}
export async function fetchLeaderboardsByHash(
hash: string,
fetchImpl: typeof fetch = fetch,
): Promise<BLLeaderboardInfo[] | null> {
const h = hash.trim().toLowerCase();
if (!/^[a-f0-9]{40}$/.test(h)) return null;
try {
const res = await fetchImpl(
BEATLEADER_API_HASH_BASE + encodeURIComponent(h),
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
);
if (!res.ok) return null;
const data = await res.json() as Record<string, unknown>;
const leaderboards = data.leaderboards ?? data.Leaderboards;
return Array.isArray(leaderboards)
? leaderboards as BLLeaderboardInfo[]
: null;
} catch {
return null;
}
}
function escapeMarkdownHeader(text: string): string {
return text.replace(/\r?\n/g, " ").trim();
}
function formatRequirementsOneLine(reqs: ChallengeRequirement[]): string {
if (!reqs.length) return "_None_";
return reqs.map((r) =>
`${r.type}: ${r.count}${r.isMax ? " (max)" : ""}`
).join(" · ");
}
function formatLinksOneLine(urls: ResolvedMissionUrls | undefined): string {
const bl = urls?.beatLeaderUrl
? `[BeatLeader](${urls.beatLeaderUrl})`
: "_unavailable_";
const bs = urls?.beatSaverUrl
? `[BeatSaver](${urls.beatSaverUrl})`
: "_unavailable_";
return `- **Links:** ${bl} · ${bs}`;
}
export type ResolvedMissionUrls = {
beatLeaderUrl: string | null;
beatSaverUrl: string | null;
};
export async function resolveMissionUrls(
mission: InventoryMission,
leaderboardsByHash: Map<string, BLLeaderboardInfo[] | null>,
fetchImpl: typeof fetch,
warnings: string[],
): Promise<ResolvedMissionUrls> {
const beatSaverUrl = buildBeatSaverUrl(mission.songid, mission.hash);
if (!beatSaverUrl) {
warnings.push(
`Mission ${mission.index}: BeatSaver URL skipped (need songid or 40-char hash).`,
);
}
const h = mission.hash?.trim().toLowerCase();
const hashOk = Boolean(h && /^[a-f0-9]{40}$/.test(h));
if (!hashOk) {
const search = mission.songid?.trim() || mission.name?.trim() || "";
if (search) {
warnings.push(
`Mission ${mission.index}: BeatLeader hash missing/invalid; using site search.`,
);
return {
beatSaverUrl,
beatLeaderUrl:
`https://beatleader.com/leaderboards?search=${encodeURIComponent(search)}`,
};
}
warnings.push(
`Mission ${mission.index}: BeatLeader URL skipped (no valid hash or search term).`,
);
return { beatSaverUrl, beatLeaderUrl: null };
}
let lbs = leaderboardsByHash.get(h!);
if (lbs === undefined) {
lbs = await fetchLeaderboardsByHash(h!, fetchImpl);
leaderboardsByHash.set(h!, lbs);
}
if (!lbs || lbs.length === 0) {
warnings.push(
`Mission ${mission.index}: BeatLeader API returned no leaderboards for hash; using hash+diff URL.`,
);
const diffName = DIFFICULTY_LABELS[mission.difficulty] ?? "ExpertPlus";
return {
beatSaverUrl,
beatLeaderUrl: buildBeatLeaderUrlHashQuery({
hash: h!,
diffName,
modeName: mission.characteristic || "Standard",
}),
};
}
const picked = pickLeaderboardForMission(
lbs,
mission.characteristic || "Standard",
mission.difficulty,
);
const id = extractLeaderboardId(picked);
if (!id) {
warnings.push(
`Mission ${mission.index}: No leaderboard id from API; using hash+diff URL.`,
);
const diffName = DIFFICULTY_LABELS[mission.difficulty] ?? "ExpertPlus";
return {
beatSaverUrl,
beatLeaderUrl: buildBeatLeaderUrlHashQuery({
hash: h!,
diffName,
modeName: mission.characteristic || "Standard",
}),
};
}
return {
beatSaverUrl,
beatLeaderUrl: buildBeatLeaderUrlFromLeaderboard(picked, h!),
};
}
export function formatMissionProgressionMarkdown(params: {
inv: CampaignInventory;
mapPositions: CampainMapPosition[];
missions: InventoryMission[];
urlsByMissionIndex: Map<number, ResolvedMissionUrls>;
}): string {
const { inv, mapPositions, missions, urlsByMissionIndex } = params;
const lines: string[] = [];
lines.push(`# ${escapeMarkdownHeader(inv.info.name)}`);
lines.push("");
if (inv.info.desc?.trim()) {
lines.push(inv.info.desc.trim());
lines.push("");
}
if (inv.info.bigDesc?.trim()) {
lines.push(inv.info.bigDesc.trim());
lines.push("");
}
lines.push("## Missions");
lines.push("");
if (missions.length === 0) {
lines.push("_No missions._");
lines.push("");
return lines.join("\n");
}
const diffLabel = (i: number) => DIFFICULTY_LABELS[i] ?? String(i);
for (const m of missions) {
const urls = urlsByMissionIndex.get(m.index);
const children = mapPositions[m.index]?.childNodes ?? [];
const nextLabel = children.length
? [...children].sort((a, b) => a - b).join(", ")
: "_none (end)_";
lines.push(`### Mission ${m.index}: ${escapeMarkdownHeader(m.name)}`);
lines.push("");
lines.push(
`- **Difficulty:** ${diffLabel(m.difficulty)} · **Characteristic:** ${escapeMarkdownHeader(m.characteristic)}`,
);
if (m.purpose?.trim()) {
lines.push(`- **Purpose:** ${m.purpose.trim()}`);
}
if (m.notes?.trim()) {
lines.push(`- **Notes:** ${m.notes.trim()}`);
}
lines.push(formatLinksOneLine(urls));
lines.push(
`- **Requirements:** ${formatRequirementsOneLine(m.requirements ?? [])}`,
);
lines.push("");
lines.push(`**Unlocks missions:** ${nextLabel}`);
lines.push("");
lines.push("---");
lines.push("");
}
return lines.join("\n").replace(/\n---\n\n$/, "\n");
}
export const MISSION_PROGRESSION_FILENAME = "mission-progression.md";
export async function writeMissionProgressionMarkdown(
outDir: string,
inv: CampaignInventory,
mapPositions: CampainMapPosition[],
sortedMissions: InventoryMission[],
options?: { fetchImpl?: typeof fetch },
): Promise<{ warnings: string[]; markdownPath: string }> {
const warnings: string[] = [];
const fetchImpl = options?.fetchImpl ?? fetch;
const leaderboardsByHash = new Map<string, BLLeaderboardInfo[] | null>();
const urlsByMissionIndex = new Map<number, ResolvedMissionUrls>();
for (const m of sortedMissions) {
const resolved = await resolveMissionUrls(
m,
leaderboardsByHash,
fetchImpl,
warnings,
);
urlsByMissionIndex.set(m.index, resolved);
}
const body = formatMissionProgressionMarkdown({
inv,
mapPositions,
missions: sortedMissions,
urlsByMissionIndex,
});
const markdownPath = join(outDir, MISSION_PROGRESSION_FILENAME);
await Deno.writeTextFile(markdownPath, body.endsWith("\n") ? body : body + "\n");
return { warnings, markdownPath };
}

View File

@ -0,0 +1,90 @@
import { assertEquals } from "@std/assert";
import {
buildBeatLeaderUrlFromLeaderboard,
buildBeatSaverUrl,
extractLeaderboardId,
pickLeaderboardForMission,
pickPreferredLeaderboard,
} from "./mission-markdown.ts";
Deno.test("buildBeatSaverUrl uses map key when songid present", () => {
assertEquals(buildBeatSaverUrl("abc123", undefined), "https://beatsaver.com/maps/abc123");
});
Deno.test("buildBeatSaverUrl falls back to hash search", () => {
const h = "a".repeat(40);
assertEquals(
buildBeatSaverUrl(undefined, h),
`https://beatsaver.com/search/hash/${h}`,
);
});
Deno.test("buildBeatSaverUrl returns null without key or hash", () => {
assertEquals(buildBeatSaverUrl(undefined, undefined), null);
assertEquals(buildBeatSaverUrl("", "not-a-hash"), null);
});
Deno.test("extractLeaderboardId reads camelCase and PascalCase", () => {
assertEquals(extractLeaderboardId({ id: 42 }), "42");
assertEquals(extractLeaderboardId({ leaderboardId: "x" }), "x");
assertEquals(extractLeaderboardId({ Id: "y" }), "y");
assertEquals(extractLeaderboardId({ LeaderboardId: "z" }), "z");
assertEquals(extractLeaderboardId({}), null);
});
Deno.test("buildBeatLeaderUrlFromLeaderboard prefers global id", () => {
assertEquals(
buildBeatLeaderUrlFromLeaderboard({ id: "99" }, "deadbeef"),
"https://beatleader.com/leaderboard/global/99",
);
});
Deno.test("buildBeatLeaderUrlFromLeaderboard search fallback uses hash", () => {
assertEquals(
buildBeatLeaderUrlFromLeaderboard(null, "abc"),
"https://beatleader.com/leaderboards?search=abc",
);
});
Deno.test("pickLeaderboardForMission prefers exact characteristic+difficulty", () => {
const lbs = [
{
id: "1",
difficulty: {
modeName: "Standard",
difficultyName: "Expert",
},
},
{
id: "2",
difficulty: {
modeName: "Standard",
difficultyName: "ExpertPlus",
},
},
{
id: "3",
difficulty: {
modeName: "OneSaber",
difficultyName: "ExpertPlus",
},
},
];
const picked = pickLeaderboardForMission(lbs, "Standard", 4); // ExpertPlus index 4
assertEquals(extractLeaderboardId(picked), "2");
});
Deno.test("pickPreferredLeaderboard chooses ExpertPlus in Standard pool", () => {
const lbs = [
{
id: "a",
difficulty: { modeName: "Standard", difficultyName: "Hard" },
},
{
id: "b",
difficulty: { modeName: "Standard", difficultyName: "ExpertPlus" },
},
];
const picked = pickPreferredLeaderboard(lbs);
assertEquals(extractLeaderboardId(picked), "b");
});

View File

@ -269,8 +269,15 @@ function listMissions(inv: CampaignInventory) {
console.error(formatIssues(errors));
continue;
}
const { outDir } = await generateCampaignToDist(inv, DEFAULT_DIST_DIR);
console.log(`Generated:\n ${outDir}`);
const {
outDir,
markdownPath,
markdownWarnings,
} = await generateCampaignToDist(inv, DEFAULT_DIST_DIR);
console.log(`Generated:\n ${outDir}\n ${markdownPath}`);
if (markdownWarnings.length) {
console.warn("Markdown / URL warnings:\n" + markdownWarnings.join("\n"));
}
continue;
}