Render a markdown file describing the campaign progression during campaign generation
This commit is contained in:
parent
e25390c1f8
commit
7badf6588d
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
8
deno.lock
generated
@ -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"
|
||||
|
||||
11
tools/cli.ts
11
tools/cli.ts
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
416
tools/lib/mission-markdown.ts
Normal file
416
tools/lib/mission-markdown.ts
Normal 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 };
|
||||
}
|
||||
90
tools/lib/mission-markdown_test.ts
Normal file
90
tools/lib/mission-markdown_test.ts
Normal 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");
|
||||
});
|
||||
11
tools/tui.ts
11
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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user