/** * 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 }; }