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
|
"backgroundAlpha": 1
|
||||||
},
|
},
|
||||||
"mapPositions": [],
|
"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",
|
"validate": "deno run -A ./tools/cli.ts validate",
|
||||||
"generate": "deno run -A ./tools/cli.ts generate",
|
"generate": "deno run -A ./tools/cli.ts generate",
|
||||||
"deploy": "deno run -A ./tools/cli.ts deploy",
|
"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": {
|
"imports": {
|
||||||
"@std/assert": "jsr:@std/assert@^1.0",
|
"@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/internal@1.0.1": "1.0.1",
|
||||||
"jsr:@cliffy/keycode@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:@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/assert@^1.0.19": "1.0.19",
|
||||||
"jsr:@std/cli@1": "1.0.28",
|
"jsr:@std/cli@1": "1.0.28",
|
||||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||||
@ -37,7 +38,7 @@
|
|||||||
"jsr:@cliffy/ansi",
|
"jsr:@cliffy/ansi",
|
||||||
"jsr:@cliffy/internal",
|
"jsr:@cliffy/internal",
|
||||||
"jsr:@cliffy/keycode",
|
"jsr:@cliffy/keycode",
|
||||||
"jsr:@std/assert",
|
"jsr:@std/assert@^1.0.19",
|
||||||
"jsr:@std/fmt",
|
"jsr:@std/fmt",
|
||||||
"jsr:@std/io",
|
"jsr:@std/io",
|
||||||
"jsr:@std/path@^1.1.4",
|
"jsr:@std/path@^1.1.4",
|
||||||
@ -45,7 +46,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@std/assert@1.0.19": {
|
"@std/assert@1.0.19": {
|
||||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e"
|
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"@std/cli@1.0.28": {
|
"@std/cli@1.0.28": {
|
||||||
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
|
"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)");
|
console.error("(fix inventory errors before generating)");
|
||||||
return 1;
|
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(`Wrote ${missionCount} missions to ${outDir}`);
|
||||||
|
console.log(`Mission markdown: ${markdownPath}`);
|
||||||
|
if (markdownWarnings.length) {
|
||||||
|
console.warn("Markdown / URL warnings:\n" + markdownWarnings.join("\n"));
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
defaultChallengeModifiers,
|
defaultChallengeModifiers,
|
||||||
mergeChallengeModifiers,
|
mergeChallengeModifiers,
|
||||||
} from "./campaign-types.ts";
|
} from "./campaign-types.ts";
|
||||||
|
import { writeMissionProgressionMarkdown } from "./mission-markdown.ts";
|
||||||
|
|
||||||
function defaultLinearMapPositions(n: number): CampainMapPosition[] {
|
function defaultLinearMapPositions(n: number): CampainMapPosition[] {
|
||||||
const out: CampainMapPosition[] = [];
|
const out: CampainMapPosition[] = [];
|
||||||
@ -79,6 +80,8 @@ function missionToChallenge(m: InventoryMission): ChallengeJson {
|
|||||||
export type GenerateResult = {
|
export type GenerateResult = {
|
||||||
outDir: string;
|
outDir: string;
|
||||||
missionCount: number;
|
missionCount: number;
|
||||||
|
markdownPath: string;
|
||||||
|
markdownWarnings: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateCampaignToDist(
|
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));
|
console.error(formatIssues(errors));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { outDir } = await generateCampaignToDist(inv, DEFAULT_DIST_DIR);
|
const {
|
||||||
console.log(`Generated:\n ${outDir}`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user