From 7badf6588d9b2c7e4e9131a2337469acdc6665fa Mon Sep 17 00:00:00 2001 From: pleb Date: Sat, 2 May 2026 10:13:41 -0700 Subject: [PATCH] Render a markdown file describing the campaign progression during campaign generation --- data/campaign.inventory.json | 30 ++- deno.json | 3 +- deno.lock | 8 +- tools/cli.ts | 11 +- tools/lib/generate.ts | 13 +- tools/lib/mission-markdown.ts | 416 +++++++++++++++++++++++++++++ tools/lib/mission-markdown_test.ts | 90 +++++++ tools/tui.ts | 11 +- 8 files changed, 574 insertions(+), 8 deletions(-) create mode 100644 tools/lib/mission-markdown.ts create mode 100644 tools/lib/mission-markdown_test.ts diff --git a/data/campaign.inventory.json b/data/campaign.inventory.json index a63b089..3a1fcd0 100644 --- a/data/campaign.inventory.json +++ b/data/campaign.inventory.json @@ -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": "" + } + ] } diff --git a/deno.json b/deno.json index ac5aad3..62fdd5d 100644 --- a/deno.json +++ b/deno.json @@ -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", diff --git a/deno.lock b/deno.lock index e42587a..a5cc1d6 100644 --- a/deno.lock +++ b/deno.lock @@ -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" diff --git a/tools/cli.ts b/tools/cli.ts index 33d7113..1890d5f 100644 --- a/tools/cli.ts +++ b/tools/cli.ts @@ -72,8 +72,17 @@ async function cmdGenerate(invPath: string, distRoot: string): Promise { 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; } diff --git a/tools/lib/generate.ts b/tools/lib/generate.ts index efafdaa..aaf2bfd 100644 --- a/tools/lib/generate.ts +++ b/tools/lib/generate.ts @@ -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, + }; } diff --git a/tools/lib/mission-markdown.ts b/tools/lib/mission-markdown.ts new file mode 100644 index 0000000..b874add --- /dev/null +++ b/tools/lib/mission-markdown.ts @@ -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).ModeName) as + | string + | undefined, + difficultyName: (o.difficultyName ?? (o as Record).DifficultyName) as + | string + | undefined, + value: (o.value ?? (o as Record).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).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).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; + 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 { + 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; + 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, + fetchImpl: typeof fetch, + warnings: string[], +): Promise { + 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; +}): 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(); + const urlsByMissionIndex = new Map(); + + 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 }; +} diff --git a/tools/lib/mission-markdown_test.ts b/tools/lib/mission-markdown_test.ts new file mode 100644 index 0000000..2c4fa26 --- /dev/null +++ b/tools/lib/mission-markdown_test.ts @@ -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"); +}); diff --git a/tools/tui.ts b/tools/tui.ts index 48161ef..26c18cd 100644 --- a/tools/tui.ts +++ b/tools/tui.ts @@ -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; }