campaign-creator-tui/tools/lib/mission-markdown.ts

417 lines
13 KiB
TypeScript

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