417 lines
13 KiB
TypeScript
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 };
|
|
}
|