Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 22:02:18 -04:00
1 parent 8a98797fcf
commit 11934a3979
44 files changed
+3183 -329

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const out = path.resolve('playtest-artifacts/pause-memory');
fs.mkdirSync(out, { recursive: true });
async function sample(page, cdp, label) {
await cdp.send('HeapProfiler.collectGarbage');
const metrics = await cdp.send('Performance.getMetrics');
const values = Object.fromEntries(metrics.metrics.map(({ name, value }) => [name, value]));
const scene = await page.locator('.scene-shell').evaluate((element) => ({
frameLoop: element.dataset.frameLoop,
performance: document.documentElement.dataset.gamePerformance ?? null,
}));
return { label, heapMiB: Math.round(values.JSHeapUsedSize / 1048576),
nodes: values.Nodes, documents: values.Documents, ...scene };
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: path.join(out, 'test-database'), enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4190, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(45000);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
await cdp.send('HeapProfiler.enable');
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
try {
console.log('Opening game');
await page.goto(process.env.PAUSE_TEST_URL || 'http://127.0.0.1:4190', { waitUntil: 'domcontentloaded' });
console.log('Choosing offline play');
await page.getByRole('button', { name: /Play offline/ }).click();
console.log('Creating character');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Memorytester');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
console.log('Entering adventure menu');
await page.locator('.main-menu-screen').waitFor();
console.log('Choosing bundled Wailing Caverns');
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await page.locator('.dungeon-role-option--healer').click();
console.log('Waiting for scene');
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120000 });
await page.waitForTimeout(2000);
await page.screenshot({ path: path.join(out, 'before-pause.png') });
await page.keyboard.press('Escape');
console.log('Waiting for pause');
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
await page.screenshot({ path: path.join(out, 'paused.png') });
const samples = [await sample(page, cdp, 'paused-start')];
console.log(JSON.stringify(samples[0]));
for (const seconds of [15, 30, 60]) {
await page.waitForTimeout((seconds - (samples.length === 1 ? 0 : [0, 15, 30][samples.length - 1])) * 1000);
samples.push(await sample(page, cdp, `paused-${seconds}s`));
console.log(JSON.stringify(samples.at(-1)));
}
fs.writeFileSync(path.join(out, 'results.json'), `${JSON.stringify({ samples, errors }, null, 2)}\n`);
console.log(JSON.stringify({ samples, errors }, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(out, 'failure.png') });
console.error((await page.locator('body').innerText()).slice(-2000));
console.error(errors);
throw error;
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})();
+1 -1
View File
@@ -872,7 +872,7 @@ export function openGameDatabase(options = {}) {
} }
const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom"; const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom";
const randomizedBiteSized = type === "dungeon" const randomizedBiteSized = type === "dungeon"
&& contentId === "randomized-bite-sized-stormwind-stockade"; && contentId.startsWith("randomized-bite-sized-");
if (romGroup && randomizedBiteSized) { if (romGroup && randomizedBiteSized) {
throw new GameDatabaseError("This WoW-only activity is unavailable to RoM groups.", 409, "group_source_incompatible"); throw new GameDatabaseError("This WoW-only activity is unavailable to RoM groups.", 409, "group_source_incompatible");
} }
+48 -12
View File
@@ -3,6 +3,18 @@ import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.j
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry])); const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
const classes = new Set(catalog.classes.map((entry) => entry.id)); const classes = new Set(catalog.classes.map((entry) => entry.id));
function storedFinalBossId(dungeonId, difficultyId, killedBossIds) {
const difficulty = dungeons.get(dungeonId)?.difficulties.find((entry) => entry.id === difficultyId);
if (!difficulty) return null;
if (!difficulty.finalBossIds?.length) return difficulty.finalBossId || null;
const defeated = difficulty.finalBossIds.filter((id) => killedBossIds.includes(id));
// The earlier Deadmines ship cut defeated Greenskin before VanCleef.
if (dungeonId === "bite-sized-deadmines-ship" && defeated.length > 1 && defeated.includes("creature-79336")) {
return "creature-79336";
}
return defeated.length === 1 ? defeated[0] : null;
}
export function createDungeonLeaderboardRepository(database, { ErrorType }) { export function createDungeonLeaderboardRepository(database, { ErrorType }) {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_runs ( CREATE TABLE IF NOT EXISTS dungeon_runs (
@@ -26,12 +38,22 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "boss_difficulty")) { if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "boss_difficulty")) {
database.exec("ALTER TABLE dungeon_runs ADD COLUMN boss_difficulty INTEGER NOT NULL DEFAULT 0 CHECK (boss_difficulty BETWEEN 0 AND 10)"); database.exec("ALTER TABLE dungeon_runs ADD COLUMN boss_difficulty INTEGER NOT NULL DEFAULT 0 CHECK (boss_difficulty BETWEEN 0 AND 10)");
} }
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "final_boss_id")) {
database.exec("ALTER TABLE dungeon_runs ADD COLUMN final_boss_id TEXT");
}
const updateFinalBoss = database.prepare("UPDATE dungeon_runs SET final_boss_id = ? WHERE id = ?");
for (const row of database.prepare("SELECT id, dungeon_id, difficulty_id, bosses_json FROM dungeon_runs WHERE final_boss_id IS NULL").all()) {
const bossId = storedFinalBossId(row.dungeon_id, row.difficulty_id, JSON.parse(row.bosses_json));
if (bossId) updateFinalBoss.run(bossId, row.id);
}
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_boss_difficulty_idx database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_boss_difficulty_idx
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, full_clear, duration_ms, finished_at, id)`); ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, full_clear, duration_ms, finished_at, id)`);
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_final_boss_idx
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, final_boss_id, full_clear, duration_ms, finished_at, id)`);
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?"); const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
const insert = database.prepare(`INSERT INTO dungeon_runs const insert = database.prepare(`INSERT INTO dungeon_runs
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty) (id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty, final_boss_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); }; const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id, const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}), ...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}),
@@ -48,13 +70,21 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
fail("Choose a boss difficulty from 1 to 10."); fail("Choose a boss difficulty from 1 to 10.");
return { dungeon, difficultyId, difficulty, bossDifficulty }; return { dungeon, difficultyId, difficulty, bossDifficulty };
} }
function placement(run) { function boardBossId(difficulty, requestedBossId) {
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
if (requestedBossId && !finalBossIds.includes(requestedBossId)) fail("Choose a final boss from this dungeon.");
return difficulty.finalBossIds?.length ? requestedBossId || finalBossIds[0] : null;
}
function placement(run, selectedBossId = storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) {
const variant = dungeons.get(run.dungeonId)?.difficulties.find((entry) => entry.id === run.difficultyId)?.finalBossIds?.length;
const bossCondition = variant ? "AND final_boss_id = ?" : "";
const counts = database.prepare(`SELECT COUNT(*) AS total, const counts = database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster, COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
COALESCE(SUM(full_clear), 0) AS full_total, COALESCE(SUM(full_clear), 0) AS full_total,
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ?`) FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${bossCondition}`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0); .get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0,
...(variant ? [selectedBossId] : []));
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total), return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) }; fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
} }
@@ -74,6 +104,8 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
const killedBossIds = [...new Set(input.killedBossIds)].sort(); const killedBossIds = [...new Set(input.killedBossIds)].sort();
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId]; const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
if (!finalBossIds.some((id) => killedBossIds.includes(id))) fail("The final boss must be defeated before recording a run."); if (!finalBossIds.some((id) => killedBossIds.includes(id))) fail("The final boss must be defeated before recording a run.");
if (difficulty.finalBossIds && finalBossIds.filter((id) => killedBossIds.includes(id)).length !== 1)
fail("A randomized run must defeat exactly one selected final boss.");
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party."); if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
const party = input.party.map((member) => { const party = input.party.map((member) => {
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim() if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
@@ -99,17 +131,18 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds })) fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict"); throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0, } else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty); run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty,
return { run, placement: placement(run) }; storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds));
return { run, placement: placement(run, existing?.final_boss_id ?? storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) };
}, },
getDungeonRun(id) { getDungeonRun(id) {
const row = byId.get(id); const row = byId.get(id);
if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found"); if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found");
const run = record(row); const run = record(row);
return { run, placement: placement(run) }; return { run, placement: placement(run, row.final_boss_id) };
}, },
listDungeonLeaderboard(input) { listDungeonLeaderboard(input) {
const { dungeon, difficultyId, bossDifficulty } = selection(input); const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
const filter = input.clear || "final"; const filter = input.clear || "final";
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears."); if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
const offset = Number(input.offset ?? 0); const offset = Number(input.offset ?? 0);
@@ -117,11 +150,14 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000 if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page."); || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page.");
const condition = filter === "full" ? "AND full_clear = 1" : ""; const condition = filter === "full" ? "AND full_clear = 1" : "";
const bossId = boardBossId(difficulty, input.bossId);
const bossCondition = bossId ? "AND final_boss_id = ?" : "";
const args = [dungeon.id, difficultyId, bossDifficulty, ...(bossId ? [bossId] : [])];
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, bossDifficulty, limit, offset); ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(...args, limit, offset);
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition}`).get(dungeon.id, difficultyId, bossDifficulty).total; WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}`).get(...args).total;
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) }; return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
}, },
}; };
+42 -6
View File
@@ -58,18 +58,34 @@ test("validates final boss, dungeon/difficulty, duration and party without trust
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 }); assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 }); assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
})); }));
test("accepts any rolled Stockades boss as a full clear for both bite-sized modes", () => withDatabase((database) => { test("separates each rolled boss's board within its dungeon", () => withDatabase((database, reopen) => {
for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade"]) { for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi",
"bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem"]) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId); const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const difficulty = dungeon.difficulties[0]; const difficulty = dungeon.difficulties[0];
assert.equal(difficulty.finalBossIds.length, 5); assert.equal(difficulty.finalBossIds.length, dungeon.bosses.length);
assert.ok(difficulty.finalBossIds.length > 1);
for (const [index, bossId] of difficulty.finalBossIds.entries()) { for (const [index, bossId] of difficulty.finalBossIds.entries()) {
const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id); const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id);
payload.killedBossIds = [bossId]; payload.killedBossIds = [bossId];
assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true); const submitted = database.submitDungeonRun(null, payload);
assert.equal(submitted.run.fullClear, true);
assert.deepEqual(submitted.placement, { finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
assert.equal(database.listDungeonLeaderboard({ dungeonId, bossId, clear: "full" }).total, 1);
assert.equal(database.listDungeonLeaderboard({ dungeonId, bossId, clear: "full" }).entries[0].id, payload.id);
} }
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5); assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 1);
assert.throws(() => database.listDungeonLeaderboard({ dungeonId, bossId: "wrong-boss" }), { status: 400 });
const impossible = run(`${dungeonId}-multiple-run`, 1234, false, dungeonId);
impossible.killedBossIds = difficulty.finalBossIds.slice(0, 2);
assert.throws(() => database.submitDungeonRun(null, impossible), { status: 400 });
} }
database = reopen();
const id = "bite-sized-mystic-altar-golem";
const bosses = catalog.dungeons.find((entry) => entry.id === id).difficulties[0].finalBossIds;
assert.equal(database.listDungeonLeaderboard({ dungeonId: id, bossId: bosses[1] }).total, 1);
})); }));
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => { test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-")); const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
@@ -93,6 +109,16 @@ test("public HTTP leaderboards accept completion records and return results with
assert.deepEqual((await sliderBoard.json()).entries.map((entry) => entry.id), [slider.id]); assert.deepEqual((await sliderBoard.json()).entries.map((entry) => entry.id), [slider.id]);
assert.equal((await (await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=1`)).json()).total, 0); assert.equal((await (await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=1`)).json()).total, 0);
assert.equal((await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=11`)).status, 400); assert.equal((await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=11`)).status, 400);
const romDungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-mystic-altar-golem");
const romBoss = romDungeon.difficulties[0].finalBossIds[1];
const romRun = run("http-rom-boss-run", 23456, false, romDungeon.id);
romRun.killedBossIds = [romBoss];
assert.equal((await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(romRun) })).status, 201);
const romBoard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=${romDungeon.id}&boss=${romBoss}`);
assert.equal(romBoard.status, 200);
assert.deepEqual((await romBoard.json()).entries.map((entry) => entry.id), [romRun.id]);
assert.equal((await (await fetch(`${base}/api/dungeon-leaderboards?dungeon=${romDungeon.id}`)).json()).total, 0);
const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) }); const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) });
assert.equal(bad.status, 400); assert.equal(bad.status, 400);
const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) });
@@ -141,12 +167,22 @@ test("migrates the old leaderboard schema and keeps its scores outside Easy", ()
duration_ms INTEGER NOT NULL, full_clear INTEGER NOT NULL, finished_at INTEGER NOT NULL, duration_ms INTEGER NOT NULL, full_clear INTEGER NOT NULL, finished_at INTEGER NOT NULL,
party_json TEXT NOT NULL, bosses_json TEXT NOT NULL, submitted_by_account_id TEXT, received_at INTEGER NOT NULL)`); party_json TEXT NOT NULL, bosses_json TEXT NOT NULL, submitted_by_account_id TEXT, received_at INTEGER NOT NULL)`);
const legacy = run("pre-slider-result", 5000); const legacy = run("pre-slider-result", 5000);
database.prepare("INSERT INTO dungeon_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(legacy.id, const insert = database.prepare("INSERT INTO dungeon_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
insert.run(legacy.id,
legacy.dungeonId, legacy.difficultyId, legacy.durationMs, 1, legacy.finishedAt, legacy.dungeonId, legacy.difficultyId, legacy.durationMs, 1, legacy.finishedAt,
JSON.stringify(legacy.party), JSON.stringify(legacy.killedBossIds), null, Date.now()); JSON.stringify(legacy.party), JSON.stringify(legacy.killedBossIds), null, Date.now());
const oldShip = run("pre-single-boss-ship", 6000, false, "bite-sized-deadmines-ship");
oldShip.killedBossIds = ["creature-79333", "creature-79336"];
insert.run(oldShip.id, oldShip.dungeonId, oldShip.difficultyId, oldShip.durationMs, 1,
oldShip.finishedAt, JSON.stringify(oldShip.party), JSON.stringify(oldShip.killedBossIds), null, Date.now());
const repository = createDungeonLeaderboardRepository(database, { ErrorType: Error }); const repository = createDungeonLeaderboardRepository(database, { ErrorType: Error });
assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1); assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines", bossDifficulty: 1 }).total, 0); assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines", bossDifficulty: 1 }).total, 0);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: oldShip.dungeonId,
bossId: "creature-79336" }).total, 1);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: oldShip.dungeonId,
bossId: "creature-79333" }).total, 0);
assert.equal(repository.getDungeonRun(oldShip.id).placement.finalTotal, 1);
createDungeonLeaderboardRepository(database, { ErrorType: Error }); createDungeonLeaderboardRepository(database, { ErrorType: Error });
assert.equal(repository.getDungeonRun(legacy.id).run.durationMs, 5000); assert.equal(repository.getDungeonRun(legacy.id).run.durationMs, 5000);
} finally { database.close(); } } finally { database.close(); }
+68 -5
View File
@@ -5,14 +5,21 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { openGameDatabase } from "./database.mjs"; import { openGameDatabase } from "./database.mjs";
import { createDungeonRaceService } from "./dungeon-race-service.mjs"; import { createDungeonRaceService } from "./dungeon-race-service.mjs";
import { botOpponent, botProgress, botTierForRecord } from "./dungeon-race-rules.mjs"; import { botOpponent, botProgress, botTierForRecord, hashRaceSeed } from "./dungeon-race-rules.mjs";
import { createGameServer } from "./server.mjs"; import { createGameServer } from "./server.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { DatabaseSync } from "node:sqlite";
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" }; import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const dungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade"); const dungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade");
const bossIds = [dungeon.difficulties[0].finalBossId]; const bossIdsFor = (match) => {
const bossIdsFor = (match) => [catalog.dungeons.find((entry) => entry.id === match.dungeonId) const difficulty = catalog.dungeons.find((entry) => entry.id === match.dungeonId)
.difficulties.find((entry) => entry.id === match.difficultyId).finalBossId]; .difficulties.find((entry) => entry.id === match.difficultyId);
const ids = difficulty.finalBossIds ?? [difficulty.finalBossId];
const seed = match.dungeonId.startsWith("randomized-bite-sized-")
? `${match.seed}:boss` : match.seed;
return [ids[hashRaceSeed(seed) % ids.length]];
};
test("NPC tiers follow rolling records with seeded adjacent variation and milestones", () => { test("NPC tiers follow rolling records with seeded adjacent variation and milestones", () => {
for (const seed of ["race-tier-a", "race-tier-b", "race-tier-c", "race-tier-d"]) { for (const seed of ["race-tier-a", "race-tier-b", "race-tier-c", "race-tier-d"]) {
@@ -27,6 +34,32 @@ test("NPC tiers follow rolling records with seeded adjacent variation and milest
} }
}); });
test("migrates existing race matches onto their seeded boss boards", () => {
const database = new DatabaseSync(":memory:");
try {
database.exec(`CREATE TABLE accounts (id TEXT PRIMARY KEY);
CREATE TABLE dungeon_race_matches (id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL,
seed TEXT NOT NULL, difficulty_id TEXT NOT NULL, boss_difficulty INTEGER NOT NULL,
status TEXT NOT NULL, created_at INTEGER NOT NULL, start_at INTEGER, settled_at INTEGER,
winner_side INTEGER, bot_json TEXT)`);
const insert = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, settled_at)
VALUES (?, ?, ?, ?, 1, 'settled', 1, 2)`);
const dungeonIds = ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi",
"bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem"];
for (const dungeonId of dungeonIds) insert.run(`race-old-${dungeonId}`, dungeonId,
`race-old-${dungeonId}`, dungeon.defaultDifficultyId);
createDungeonRaceRepository(database, { ErrorType: Error });
for (const dungeonId of dungeonIds) {
const seed = `race-old-${dungeonId}`;
assert.equal(database.prepare("SELECT boss_id FROM dungeon_race_matches WHERE id = ?").get(seed).boss_id,
bossIdsFor({ dungeonId, difficultyId: dungeon.defaultDifficultyId, seed })[0]);
}
} finally { database.close(); }
});
async function fixture(run) { async function fixture(run) {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-")); const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-"));
const database = openGameDatabase({ dataDir }); const database = openGameDatabase({ dataDir });
@@ -57,6 +90,9 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
assert.equal(service.ready(rom, paired.matchId).status, "countdown"); assert.equal(service.ready(rom, paired.matchId).status, "countdown");
advance(3_000); advance(3_000);
assert.equal(service.state(wow).status, "active"); assert.equal(service.state(wow).status, "active");
const bossIds = bossIdsFor(paired);
assert.throws(() => service.finish(wow, paired.matchId, { runId: paired.seed,
bossIds: [dungeon.difficulties[0].finalBossIds.find((id) => id !== bossIds[0])] }), { status: 400 });
const first = service.finish(wow, paired.matchId, { runId: paired.seed, bossIds }); const first = service.finish(wow, paired.matchId, { runId: paired.seed, bossIds });
assert.equal(first.result, "win"); assert.equal(first.result, "win");
assert.equal(service.state(rom).result, "loss"); assert.equal(service.state(rom).result, "loss");
@@ -64,10 +100,37 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
const late = service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }); const late = service.finish(rom, paired.matchId, { runId: paired.seed, bossIds });
assert.equal(late.result, "loss"); assert.equal(late.result, "loss");
assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "30d" }, time()).entries[0].username, "race_wow"); assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "30d" }, time()).entries[0].username, "race_wow");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id }, time()).total, 2); assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).total, 2);
const otherBossId = dungeon.difficulties[0].finalBossIds.find((id) => id !== bossIds[0]);
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: otherBossId }, time()).total, 0);
assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).total, 2);
assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs); assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs);
})); }));
test("both randomized creature pools validate and rank their cross-dungeon final boss", async () => fixture(async ({ database, service, player, advance, time }) => {
for (const [index, dungeonId] of ["randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available"].entries()) {
const left = await player(`race_random_${index}_a`);
const right = await player(`race_random_${index}_b`);
service.queue(left, { characterId: `race_random_${index}_a-character`, role: "healer", pool: "randomized", dungeonId });
const match = service.queue(right, { characterId: `race_random_${index}_b-character`, role: "healer", pool: "randomized", dungeonId });
assert.equal(match.dungeonId, dungeonId);
service.ready(left, match.matchId); service.ready(right, match.matchId);
advance(3_000);
const bossIds = bossIdsFor(match);
assert.match(bossIds[0], /^randomized:(wow|rom):boss:/);
const wrongBossId = catalog.dungeons.find((entry) => entry.id === dungeonId).difficulties[0]
.finalBossIds.find((id) => id !== bossIds[0]);
assert.throws(() => service.finish(left, match.matchId, { runId: match.seed, bossIds: [wrongBossId] }), { status: 400 });
assert.equal(service.finish(left, match.matchId, { runId: match.seed, bossIds }).result, "win");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId,
bossId: bossIds[0] }, time()).total, 1);
}
}));
test("five-second unmatched queue fills with a modeled NPC party", async () => fixture(async ({ database, service, player, advance, time }) => { test("five-second unmatched queue fills with a modeled NPC party", async () => fixture(async ({ database, service, player, advance, time }) => {
const account = await player("race_solo"); const account = await player("race_solo");
service.queue(account, { characterId: "race_solo-character", role: "healer", pool: "authored" }); service.queue(account, { characterId: "race_solo-character", role: "healer", pool: "authored" });
+37 -10
View File
@@ -1,8 +1,15 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" }; import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs"; import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry])); const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
function raceBossId(dungeonId, difficultyId, seed) {
const difficulty = dungeons.get(dungeonId)?.difficulties.find((entry) => entry.id === difficultyId);
const finalIds = difficulty?.finalBossIds;
const objectiveSeed = dungeonId.startsWith("randomized-bite-sized-") ? `${seed}:boss` : seed;
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
}
export function createDungeonRaceRepository(database, { ErrorType }) { export function createDungeonRaceRepository(database, { ErrorType }) {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_race_matches ( CREATE TABLE IF NOT EXISTS dungeon_race_matches (
@@ -26,13 +33,24 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
CREATE INDEX IF NOT EXISTS dungeon_race_account_idx CREATE INDEX IF NOT EXISTS dungeon_race_account_idx
ON dungeon_race_participants(account_id, outcome, clear_at); ON dungeon_race_participants(account_id, outcome, clear_at);
`); `);
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "boss_id")) {
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN boss_id TEXT");
}
const missingBosses = database.prepare("SELECT id, dungeon_id, difficulty_id, seed FROM dungeon_race_matches WHERE boss_id IS NULL").all();
const updateBoss = database.prepare("UPDATE dungeon_race_matches SET boss_id = ? WHERE id = ?");
for (const match of missingBosses) {
const bossId = raceBossId(match.dungeon_id, match.difficulty_id, match.seed);
if (bossId) updateBoss.run(bossId, match.id);
}
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_race_boss_idx
ON dungeon_race_matches(dungeon_id, boss_id, status, settled_at)`);
database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')") database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')")
.run(Date.now()); .run(Date.now());
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?"); const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side"); const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json) (id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?)`); VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?)`);
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
(match_id, side, account_id, character_id, username, source, role) (match_id, side, account_id, character_id, username, source, role)
VALUES (?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?)`);
@@ -72,7 +90,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
function createMatch(match) { function createMatch(match) {
transaction(() => { transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt, insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null); match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed));
for (const participant of match.participants) insertParticipant.run(match.id, participant.side, for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role); participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
}); });
@@ -95,8 +113,10 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
|| match.status === "void") throw new ErrorType("This race is not accepting clears.", 409, "race_not_active"); || match.status === "void") throw new ErrorType("This race is not accepting clears.", 409, "race_not_active");
const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId); const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId);
const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []); const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []);
const selectedBossId = raceBossId(match.dungeonId, match.difficultyId, match.seed);
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value)) if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|| !bossIds.some((value) => finalIds.includes(value))) || !bossIds.some((value) => finalIds.includes(value))
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear"); throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear");
return transaction(() => { return transaction(() => {
updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId); updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId);
@@ -120,28 +140,34 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
.get(accountId, timestamp - RACE_PERIOD_MS); .get(accountId, timestamp - RACE_PERIOD_MS);
return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) }; return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) };
} }
function leaderboard({ view = "wins", period = "30d", dungeonId = null, limit = 20, offset = 0 } = {}, timestamp = Date.now()) { function leaderboard({ view = "wins", period = "30d", dungeonId = null, bossId = null,
limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
const dungeon = dungeons.get(dungeonId);
const bossIds = dungeon?.difficulties.find((entry) => entry.id === dungeon.defaultDifficultyId)?.finalBossIds;
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period) if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
|| (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId))) || (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId)))
|| (bossId !== null && (!bossIds || !bossIds.includes(bossId)))
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100
|| !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000) || !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000)
throw new ErrorType("Choose a valid race leaderboard.", 400, "invalid_race_leaderboard"); throw new ErrorType("Choose a valid race leaderboard.", 400, "invalid_race_leaderboard");
if (view === "times" && !dungeonId) throw new ErrorType("Choose a dungeon for race times.", 400, "race_dungeon_required"); if (view === "times" && !dungeonId) throw new ErrorType("Choose a dungeon for race times.", 400, "race_dungeon_required");
const since = period === "30d" ? timestamp - RACE_PERIOD_MS : 0; const since = period === "30d" ? timestamp - RACE_PERIOD_MS : 0;
const byDungeon = dungeonId ? "AND m.dungeon_id = ?" : ""; const byDungeon = dungeonId ? "AND m.dungeon_id = ?" : "";
const args = dungeonId ? [since, dungeonId] : [since]; const selectedBossId = dungeonId && bossIds?.length ? bossId ?? bossIds[0] : null;
const byBoss = selectedBossId ? "AND m.boss_id = ?" : "";
const args = [since, ...(dungeonId ? [dungeonId] : []), ...(selectedBossId ? [selectedBossId] : [])];
const query = view === "wins" ? ` const query = view === "wins" ? `
SELECT p.account_id AS accountId, a.username AS username, SELECT p.account_id AS accountId, a.username AS username,
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins, SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id JOIN accounts a ON a.id = p.account_id
WHERE m.status = 'settled' AND m.settled_at >= ? ${byDungeon} WHERE m.status = 'settled' AND m.settled_at >= ? ${byDungeon} ${byBoss}
GROUP BY p.account_id, a.username ORDER BY wins DESC, losses ASC, a.username COLLATE NOCASE ASC` GROUP BY p.account_id, a.username ORDER BY wins DESC, losses ASC, a.username COLLATE NOCASE ASC`
: `SELECT p.account_id AS accountId, a.username AS username, MIN(p.duration_ms) AS durationMs : `SELECT p.account_id AS accountId, a.username AS username, MIN(p.duration_ms) AS durationMs
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id JOIN accounts a ON a.id = p.account_id
WHERE p.clear_at >= ? ${byDungeon} AND p.duration_ms IS NOT NULL WHERE p.clear_at >= ? ${byDungeon} ${byBoss} AND p.duration_ms IS NOT NULL
GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`; GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`;
const all = database.prepare(query).all(...args); const all = database.prepare(query).all(...args);
let lastValue = null; let lastValue = null;
@@ -154,7 +180,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
? { wins: Number(row.wins), losses: Number(row.losses) } ? { wins: Number(row.wins), losses: Number(row.losses) }
: { durationMs: Number(row.durationMs) }) }; : { durationMs: Number(row.durationMs) }) };
}); });
return { view, period, dungeonId, total: ranked.length, entries: ranked.slice(offset, offset + limit) }; return { view, period, dungeonId, bossId: selectedBossId,
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
} }
return { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)), return { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)),
start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun, start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun,
+1
View File
@@ -5,6 +5,7 @@ import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set([ export const REPORT_DUNGEON_IDS = new Set([
"bite-sized-stormwind-stockade", "bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"forsaken-abbey", "forsaken-abbey",
...Object.keys(availability.enabled), ...Object.keys(availability.enabled),
]); ]);
+2
View File
@@ -310,6 +310,7 @@ export function createGameServer(options = {}) {
view: url.searchParams.get("view") ?? "wins", view: url.searchParams.get("view") ?? "wins",
period: url.searchParams.get("period") ?? "30d", period: url.searchParams.get("period") ?? "30d",
dungeonId: url.searchParams.get("dungeon") || null, dungeonId: url.searchParams.get("dungeon") || null,
bossId: url.searchParams.get("boss") || null,
limit: Number(url.searchParams.get("limit") ?? 20), limit: Number(url.searchParams.get("limit") ?? 20),
offset: Number(url.searchParams.get("offset") ?? 0), offset: Number(url.searchParams.get("offset") ?? 0),
})); }));
@@ -342,6 +343,7 @@ export function createGameServer(options = {}) {
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") { if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"), json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"), difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
bossId: url.searchParams.get("boss"),
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined, bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") })); offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
return; return;
+1 -1
View File
@@ -64,7 +64,7 @@ describe("online group activity entry", () => {
await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies); await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith(activity.contentId); expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith(activity.contentId);
expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id); expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id, activity.contentId);
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0]) expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]); .toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
}); });
+6 -6
View File
@@ -12,7 +12,7 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures"; import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge"; import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime"; import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { dungeonContentPackId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource"; import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager"; import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
const GROUP_POLL_MS = 2_500; const GROUP_POLL_MS = 2_500;
@@ -20,7 +20,7 @@ const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies { interface OnlineGroupActivityEntryDependencies {
readonly prepareDungeonContent: (dungeonId: string) => Promise<void>; readonly prepareDungeonContent: (dungeonId: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>; readonly prepareRandomizedDungeonContent: (seed: string, dungeonId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>; readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean; readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
readonly installManastormRuntime: () => Promise<void>; readonly installManastormRuntime: () => Promise<void>;
@@ -33,9 +33,9 @@ const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies
await installContentPack(dungeonContentPackId(dungeonId)); await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified."); if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified.");
}, },
prepareRandomizedDungeonContent: async (seed) => { prepareRandomizedDungeonContent: async (seed, dungeonId) => {
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent"); const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
await ensureRandomizedBiteSizedRunContent(seed); await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
}, },
installDungeonRuntime: async () => { installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime"); const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
@@ -61,8 +61,8 @@ export async function enterOnlineGroupActivity(
let entered: boolean; let entered: boolean;
if (activity.type === "dungeon") { if (activity.type === "dungeon") {
await dependencies.prepareDungeonContent(activity.contentId); await dependencies.prepareDungeonContent(activity.contentId);
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) { if (isRandomizedBiteSizedDungeonId(activity.contentId)) {
await dependencies.prepareRandomizedDungeonContent(activity.id); await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
} }
await dependencies.installDungeonRuntime(); await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role, { entered = dependencies.enterDungeon(activity.contentId, role, {
+1 -1
View File
@@ -105,7 +105,7 @@ describe("bite-sized replay", () => {
prepareRandomizedContent, prepareRandomizedContent,
})).resolves.toBe(true); })).resolves.toBe(true);
expect(prepareRandomizedContent).toHaveBeenCalledWith("next-randomized-seed"); expect(prepareRandomizedContent).toHaveBeenCalledWith("next-randomized-seed", RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(saveCheckpoint).toHaveBeenCalledWith(null); expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", { expect(enterDungeon).toHaveBeenCalledWith(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
difficultyId: "normal", difficultyId: "normal",
+5 -4
View File
@@ -23,7 +23,7 @@ interface BiteSizedReplayDependencies {
readonly loadPolicy: () => Promise<DungeonQueuePolicy>; readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string; readonly createRunId: () => string;
readonly contentIsUsable: (dungeonId: string) => boolean; readonly contentIsUsable: (dungeonId: string) => boolean;
readonly prepareRandomizedContent: (seed: string) => Promise<void>; readonly prepareRandomizedContent: (seed: string, dungeonId: string) => Promise<void>;
} }
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = { const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
@@ -31,7 +31,7 @@ const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
loadPolicy: loadDungeonQueuePolicy, loadPolicy: loadDungeonQueuePolicy,
createRunId: () => globalThis.crypto.randomUUID(), createRunId: () => globalThis.crypto.randomUUID(),
contentIsUsable: dungeonContentIsUsable, contentIsUsable: dungeonContentIsUsable,
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent, prepareRandomizedContent: (seed, dungeonId) => ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId),
}; };
/** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */ /** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */
@@ -52,7 +52,8 @@ export async function startAnotherBiteSizedDungeon(
const policy = await dependencies.loadPolicy(); const policy = await dependencies.loadPolicy();
const next = randomized const next = randomized
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random, ? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon)) (dungeon) => dungeon.id === progress.dungeonId
&& dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon))
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random, : randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon)); (dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon));
if (!next) { if (!next) {
@@ -94,7 +95,7 @@ export async function startAnotherBiteSizedDungeon(
const runId = dependencies.createRunId(); const runId = dependencies.createRunId();
if (randomized) { if (randomized) {
try { try {
await dependencies.prepareRandomizedContent(runId); await dependencies.prepareRandomizedContent(runId, next.id);
} catch (caught) { } catch (caught) {
shell.setNotice(caught instanceof Error ? caught.message : "The randomized creature download failed."); shell.setNotice(caught instanceof Error ? caught.message : "The randomized creature download failed.");
return false; return false;
+17
View File
@@ -58,7 +58,24 @@ describe("offline dungeon leaderboard", () => {
}; };
saveLocalDungeonRun(run); saveLocalDungeonRun(run);
expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full").entries) expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full").entries)
.toEqual([]);
expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full", 0, 0, rolledBossId).entries)
.toEqual([expect.objectContaining({ id: run.id, killedBossIds: [rolledBossId], fullClear: true })]); .toEqual([expect.objectContaining({ id: run.id, killedBossIds: [rolledBossId], fullClear: true })]);
expect(localDungeonRunPlacement(run)).toEqual({ finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
});
it("keeps local RoM boss times on separate boards", () => {
const rom = catalog.dungeons.find((entry) => entry.id === "bite-sized-paspers-shrine-hafiz")!;
const bossIds = leaderboardFinalBossIds(rom.difficulties[0]);
const first = { ...record("rom-first-boss", 3000), dungeonId: rom.id,
difficultyId: rom.defaultDifficultyId, killedBossIds: [bossIds[0]] };
const second = { ...record("rom-second-boss", 4000), dungeonId: rom.id,
difficultyId: rom.defaultDifficultyId, killedBossIds: [bossIds[1]] };
saveLocalDungeonRun(first); saveLocalDungeonRun(second);
expect(localDungeonLeaderboard(rom.id, rom.defaultDifficultyId, "final", 0, 0, bossIds[0]).entries
.map((entry) => entry.id)).toEqual([first.id]);
expect(localDungeonLeaderboard(rom.id, rom.defaultDifficultyId, "final", 0, 0, bossIds[1]).entries
.map((entry) => entry.id)).toEqual([second.id]);
expect(localDungeonRunPlacement(second)).toEqual({ finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
}); });
it("retains the local time after a failed upload and uses the same run id when retried", async () => { it("retains the local time after a failed upload and uses the same run id when retried", async () => {
const run = record("offline-retry-test", 4321); const run = record("offline-retry-test", 4321);
+20 -7
View File
@@ -1,9 +1,9 @@
import { requestOnlineJson } from "./onlineAccountClient"; import { requestOnlineJson } from "./onlineAccountClient";
import type { PlayerSession } from "./types"; import type { PlayerSession } from "./types";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json"; import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboardCatalog";
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard, import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes"; type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
import { isLeaderboardFullClear, leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules"; import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
const STORAGE_KEY = "healer-man:dungeon-runs:v1"; const STORAGE_KEY = "healer-man:dungeon-runs:v1";
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@@ -27,6 +27,7 @@ function localRuns(): DungeonRunRecord[] {
const finalBossIds = difficulty ? leaderboardFinalBossIds(difficulty) : []; const finalBossIds = difficulty ? leaderboardFinalBossIds(difficulty) : [];
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|| !difficulty || !finalBossIds.some((id) => progress.killedBossIds.includes(id)) || !difficulty || !finalBossIds.some((id) => progress.killedBossIds.includes(id))
|| (difficulty.finalBossIds?.length && finalBossIds.filter((id) => progress.killedBossIds.includes(id)).length !== 1)
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return []; || progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt, return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
fullClear: isLeaderboardFullClear(difficulty, progress.killedBossIds) }]; fullClear: isLeaderboardFullClear(difficulty, progress.killedBossIds) }];
@@ -43,9 +44,14 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
} }
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string, export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0): DungeonLeaderboard { filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string): DungeonLeaderboard {
const difficulty = catalog.dungeons.find((entry) => entry.id === dungeonId)?.difficulties
.find((entry) => entry.id === difficultyId);
const selectedBossId = difficulty?.finalBossIds?.length
? bossId ?? leaderboardFinalBossIds(difficulty)[0] : null;
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
&& (entry.bossDifficulty ?? 0) === bossDifficulty && (entry.bossDifficulty ?? 0) === bossDifficulty
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs && (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id)); || a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE) return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
@@ -53,19 +59,26 @@ export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
} }
export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement { export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement {
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0); const difficulty = catalog.dungeons.find((entry) => entry.id === record.dungeonId)?.difficulties
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0); .find((entry) => entry.id === record.difficultyId);
const bossId = difficulty?.finalBossIds?.length
? leaderboardRunBossId(difficulty, record.killedBossIds) ?? undefined : undefined;
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId);
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId);
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)); && (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
&& (!bossId || entry.killedBossIds.includes(bossId)));
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length, return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
finalTotal: final.total, fullTotal: full.total, finalTotal: final.total, fullTotal: full.total,
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null }; fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
} }
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string, export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0): Promise<DungeonLeaderboard> { filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
bossId?: string): Promise<DungeonLeaderboard> {
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter, const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) }); offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
if (bossId) query.set("boss", bossId);
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal }); return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
} }
+1 -1
View File
@@ -85,7 +85,7 @@ function choosePracticeDungeon(pool: RacePool): string | null {
return entries.filter((entry) => entry.available)[0]?.id ?? null; return entries.filter((entry) => entry.available)[0]?.id ?? null;
} }
async function prepareContent(dungeonId: string, seed: string): Promise<void> { async function prepareContent(dungeonId: string, seed: string): Promise<void> {
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed); if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId)); else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified."); if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
} }
@@ -3,6 +3,7 @@ import {
ensureRandomizedBiteSizedRunContent, ensureRandomizedBiteSizedRunContent,
randomizedBiteSizedModelUrls, randomizedBiteSizedModelUrls,
} from "./randomizedBiteSizedContent"; } from "./randomizedBiteSizedContent";
import { RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID } from "../game/dungeonContentSource";
describe("randomized bite-sized run content", () => { describe("randomized bite-sized run content", () => {
it("derives only the distinct model URLs selected by the shared run seed", () => { it("derives only the distinct model URLs selected by the shared run seed", () => {
@@ -14,6 +15,13 @@ describe("randomized bite-sized run content", () => {
expect(first.every((url) => url.startsWith("/assets/") && url.endsWith(".glb"))).toBe(true); expect(first.every((url) => url.startsWith("/assets/") && url.endsWith(".glb"))).toBe(true);
}); });
it("prepares the selected Bite-Sized source pool for the same shared seed", () => {
const urls = randomizedBiteSizedModelUrls("asset-seed", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID);
expect(urls).toEqual(randomizedBiteSizedModelUrls("asset-seed", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID));
expect(urls.length).toBeGreaterThan(1);
expect(new Set(urls).size).toBe(urls.length);
});
it("installs the base location and only the rolled creature model packs before entry", async () => { it("installs the base location and only the rolled creature model packs before entry", async () => {
const installPack = vi.fn(async () => undefined); const installPack = vi.fn(async () => undefined);
const installUrls = vi.fn(async () => undefined); const installUrls = vi.fn(async () => undefined);
+5 -4
View File
@@ -10,8 +10,8 @@ import {
} from "../game/dungeonContentSource"; } from "../game/dungeonContentSource";
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry"; import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
export function randomizedBiteSizedModelUrls(seed: string): readonly string[] { export function randomizedBiteSizedModelUrls(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): readonly string[] {
const definition = randomizedBiteSizedDefinitionForSeed(seed); const definition = randomizedBiteSizedDefinitionForSeed(seed, dungeonId);
return [...new Set(Object.values(definition.entities).flatMap((entity) => ( return [...new Set(Object.values(definition.entities).flatMap((entity) => (
entity.visual.model?.url ? [entity.visual.model.url] : [] entity.visual.model?.url ? [entity.visual.model.url] : []
)))].sort(); )))].sort();
@@ -37,11 +37,12 @@ const DEFAULT_DEPENDENCIES: RandomizedContentDependencies = {
export async function ensureRandomizedBiteSizedRunContent( export async function ensureRandomizedBiteSizedRunContent(
seed: string, seed: string,
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES, dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
): Promise<void> { ): Promise<void> {
if (dependencies.development) return; if (dependencies.development) return;
const basePackId = dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); const basePackId = dungeonContentPackId(dungeonId);
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) { if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
await dependencies.installPack(basePackId); await dependencies.installPack(basePackId);
} }
await dependencies.installUrls(randomizedBiteSizedModelUrls(seed)); await dependencies.installUrls(randomizedBiteSizedModelUrls(seed, dungeonId));
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore"; import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
import { useCompanionPartyPreferences } from "./companionPartyPreferences"; import { useCompanionPartyPreferences } from "./companionPartyPreferences";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore"; import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource"; import { isRandomizedBiteSizedDungeonId } from "./dungeonContentSource";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */ /** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() { export function PartyBridge() {
@@ -46,7 +46,7 @@ export function PartyBridge() {
? onlineGroup.members ? onlineGroup.members
: []; : [];
const randomizedReadyOnly = onlineActivity?.type === "dungeon" const randomizedReadyOnly = onlineActivity?.type === "dungeon"
&& onlineActivity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID; && isRandomizedBiteSizedDungeonId(onlineActivity.contentId);
const companionOptions = { const companionOptions = {
readinessPolicy, readinessPolicy,
readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision, readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision,
+5
View File
@@ -47,6 +47,11 @@ export function bossCardByKey(key: string): BossCardDefinition | null {
} }
export function bossCardForSource(sourceId: string, dungeonId: string, name?: string): BossCardDefinition | null { export function bossCardForSource(sourceId: string, dungeonId: string, name?: string): BossCardDefinition | null {
const randomizedCategory = /^randomized:(wow|rom):boss:/.exec(sourceId)?.[1];
if (randomizedCategory && name) {
const key = `${randomizedCategory}:${name.trim().toLowerCase().replace(/\s+/g, " ")}`;
return cardsByKey.get(key) ?? null;
}
return BOSS_CARD_CATALOG.find((card) => card.sources.some( return BOSS_CARD_CATALOG.find((card) => card.sources.some(
(source) => source.dungeonId === dungeonId && source.entityId === sourceId, (source) => source.dungeonId === dungeonId && source.entityId === sourceId,
)) ?? BOSS_CARD_CATALOG.find((card) => card.sources.some((source) => source.entityId === sourceId)) )) ?? BOSS_CARD_CATALOG.find((card) => card.sources.some((source) => source.entityId === sourceId))
+2 -1
View File
@@ -20,7 +20,8 @@ export function dungeonBossCards(
): readonly DungeonBossCard[] { ): readonly DungeonBossCard[] {
const seen = new Set<string>(); const seen = new Set<string>();
return dungeonBossesForDifficulty(dungeon, difficultyId).flatMap((boss) => { return dungeonBossesForDifficulty(dungeon, difficultyId).flatMap((boss) => {
const category = dungeon.expansion === "runewaker" ? "rom" : "wow"; const category = /^randomized:(wow|rom):boss:/.exec(boss.id)?.[1]
?? (dungeon.expansion === "runewaker" ? "rom" : "wow");
const normalizedName = boss.name.trim().toLowerCase().replace(/\s+/g, " "); const normalizedName = boss.name.trim().toLowerCase().replace(/\s+/g, " ");
const fallbackKey = `${category}:${normalizedName}`; const fallbackKey = `${category}:${normalizedName}`;
const sourceCard = bossCardForSource(boss.id, dungeon.id, boss.name); const sourceCard = bossCardForSource(boss.id, dungeon.id, boss.name);
+5 -4
View File
@@ -17,7 +17,7 @@ import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.
describe("dungeon catalog", () => { describe("dungeon catalog", () => {
it("lists the complete campaign and bite-sized variants", () => { it("lists the complete campaign and bite-sized variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(92); expect(DUNGEON_CATALOG).toHaveLength(93);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({ expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade", id: "stormwind-stockade",
releaseStage: "beta", releaseStage: "beta",
@@ -79,9 +79,9 @@ describe("dungeon catalog", () => {
it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => { it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => {
expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id)) expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id))
.toEqual(["randomized-bite-sized-stormwind-stockade"]); .toEqual(["randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade-available"]);
expect(randomRandomizedBiteSizedDungeon("randomized-bite-sized-stormwind-stockade", () => 0)?.id) expect(randomRandomizedBiteSizedDungeon("randomized-bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("randomized-bite-sized-stormwind-stockade"); .toBe("randomized-bite-sized-stormwind-stockade-available");
}); });
it("keeps stable unique ids and resolves only catalogued dungeons", () => { it("keeps stable unique ids and resolves only catalogued dungeons", () => {
@@ -111,6 +111,7 @@ describe("dungeon catalog", () => {
"bite-sized-mystic-altar-golem", "bite-sized-mystic-altar-golem",
"bite-sized-paspers-shrine-hafiz", "bite-sized-paspers-shrine-hafiz",
"randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"stormwind-stockade", "stormwind-stockade",
"bite-sized-stormwind-stockade", "bite-sized-stormwind-stockade",
"bite-sized-deadmines-ship", "bite-sized-deadmines-ship",
@@ -142,7 +143,7 @@ describe("dungeon catalog", () => {
.toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon", .toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
"bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "bite-sized-stormwind-stockade", "bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns", "bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey", "randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade-available", "forsaken-abbey",
...Object.keys(availability.enabled)].sort()); ...Object.keys(availability.enabled)].sort());
}); });
}); });
+3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId, dungeonContentPackId,
dungeonContentSourceId, dungeonContentSourceId,
@@ -13,6 +14,8 @@ describe("derived dungeon content sources", () => {
expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade"); expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade");
expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID); expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("activity:randomized-bite-sized"); expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("activity:randomized-bite-sized");
expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID)).toBe("activity:randomized-bite-sized");
expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines"); expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
}); });
}); });
+15 -2
View File
@@ -1,6 +1,7 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade"; export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade"; export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade"; export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade-available";
export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns"; export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship"; export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi"; export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi";
@@ -12,6 +13,7 @@ export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-s
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([ const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID, BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID, BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID, BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
@@ -32,6 +34,7 @@ export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, [BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns", [BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines", [BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle", [BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
@@ -42,7 +45,17 @@ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.free
export function isDerivedStockadesDungeonId(dungeonId: string): boolean { export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
return dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID return dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID
|| dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID; || isRandomizedBiteSizedDungeonId(dungeonId);
}
export function isDerivedRandomBossDungeonId(dungeonId: string): boolean {
return isDerivedStockadesDungeonId(dungeonId)
|| dungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
|| dungeonId === BITE_SIZED_DEADMINES_DUNGEON_ID
|| dungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID
|| dungeonId === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID
|| dungeonId === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID
|| dungeonId === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID;
} }
/** Derived dungeon variants share the installed assets of their source dungeon. */ /** Derived dungeon variants share the installed assets of their source dungeon. */
@@ -51,7 +64,7 @@ export function dungeonContentSourceId(dungeonId: string): string {
} }
export function dungeonContentPackId(dungeonId: string): string { export function dungeonContentPackId(dungeonId: string): string {
if (dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) { if (isRandomizedBiteSizedDungeonId(dungeonId)) {
return RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID; return RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID;
} }
return `dungeon:${dungeonContentSourceId(dungeonId)}`; return `dungeon:${dungeonContentSourceId(dungeonId)}`;
+29 -14
View File
@@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog"; import { CLASSES } from "../app/characterCatalog";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry"; import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore"; import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore";
import { isDerivedStockadesDungeonId, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource"; import { dungeonContentSourceId, isDerivedRandomBossDungeonId, isDerivedStockadesDungeonId,
STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades"; import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
import { AVAILABLE_RANDOMIZED_CREATURE_CATALOG, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG } from "./dungeonRegistry";
import { RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
const stockades = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === STORMWIND_STOCKADE_DUNGEON_ID)!; const stockades = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === STORMWIND_STOCKADE_DUNGEON_ID)!;
const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => ( const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => (
@@ -14,28 +17,40 @@ const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) =>
describe("dungeon leaderboard catalog", () => { describe("dungeon leaderboard catalog", () => {
it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => { it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => {
const catalog = { const catalog = {
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title, dungeons: DUNGEON_DEFINITIONS.map((dungeon) => {
finalBossId: isDerivedStockadesDungeonId(dungeon.id) const randomizedCandidates = dungeon.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses[0].id ? AVAILABLE_RANDOMIZED_CREATURE_CATALOG
: dungeon.id === RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID
? BITE_SIZED_RANDOMIZED_CREATURE_CATALOG : null;
const candidates = randomizedCandidates
? randomizedCandidates.filter((candidate) => candidate.kind === "boss").map((candidate) => ({
id: `randomized:${candidate.key}`, name: candidate.name,
}))
: isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses
: isDerivedRandomBossDungeonId(dungeon.id)
? DUNGEON_DEFINITIONS.find((source) => source.id === dungeonContentSourceId(dungeon.id))!.bosses
: dungeon.bosses;
return { id: dungeon.id, title: dungeon.title,
finalBossId: isDerivedRandomBossDungeonId(dungeon.id)
? candidates[0].id
: finalDungeonBoss(dungeon)?.id ?? "", : finalDungeonBoss(dungeon)?.id ?? "",
bosses: (isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses : dungeon.bosses) bosses: candidates.map((boss) => ({ id: boss.id, name: boss.name })),
.map((boss) => ({ id: boss.id, name: boss.name })),
difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty, difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty,
bossIds: isDerivedStockadesDungeonId(dungeon.id) bossIds: isDerivedRandomBossDungeonId(dungeon.id)
? biteSizedBosses.map((boss) => boss.id) ? candidates.map((boss) => boss.id)
: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id), : dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: isDerivedStockadesDungeonId(dungeon.id) finalBossId: isDerivedRandomBossDungeonId(dungeon.id)
? biteSizedBosses[0].id ? candidates[0].id
: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "", : finalDungeonBoss(dungeon, difficulty.id)?.id ?? "",
...(isDerivedStockadesDungeonId(dungeon.id) ...(isDerivedRandomBossDungeonId(dungeon.id)
? { finalBossIds: biteSizedBosses.map((boss) => boss.id) } ? { finalBossIds: candidates.map((boss) => boss.id) }
: {}), : {}),
})), })),
defaultDifficultyId: dungeon.defaultDifficultyId, defaultDifficultyId: dungeon.defaultDifficultyId,
})).sort((a, b) => a.id.localeCompare(b.id)), }; }).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })), classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
}; };
expect(catalog.dungeons).toHaveLength(92); expect(catalog.dungeons).toHaveLength(93);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]); expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json"; const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`); if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
+15
View File
@@ -0,0 +1,15 @@
import generated from "./generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonLeaderboardDifficultyRule } from "./dungeonLeaderboardRules";
export interface DungeonLeaderboardCatalogEntry {
readonly id: string;
readonly title: string;
readonly defaultDifficultyId: string;
readonly bosses: readonly { readonly id: string; readonly name: string }[];
readonly difficulties: readonly (DungeonLeaderboardDifficultyRule & {
readonly id: string;
readonly name: string;
})[];
}
export const dungeonLeaderboardCatalog = generated as { readonly dungeons: readonly DungeonLeaderboardCatalogEntry[] };
+5
View File
@@ -8,6 +8,11 @@ export function leaderboardFinalBossIds(rule: DungeonLeaderboardDifficultyRule):
return rule.finalBossIds?.length ? rule.finalBossIds : [rule.finalBossId]; return rule.finalBossIds?.length ? rule.finalBossIds : [rule.finalBossId];
} }
export function leaderboardRunBossId(rule: DungeonLeaderboardDifficultyRule,
killedBossIds: readonly string[]): string | null {
return leaderboardFinalBossIds(rule).find((id) => killedBossIds.includes(id)) ?? null;
}
export function isLeaderboardFullClear( export function isLeaderboardFullClear(
rule: DungeonLeaderboardDifficultyRule, rule: DungeonLeaderboardDifficultyRule,
killedBossIds: readonly string[], killedBossIds: readonly string[],
+56 -14
View File
@@ -23,9 +23,17 @@ import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCat
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment"; import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
import { import {
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_STOCKADES_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentSourceId, dungeonContentSourceId,
isRandomizedBiteSizedDungeonId,
} from "./dungeonContentSource"; } from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import { import {
@@ -224,31 +232,61 @@ export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)), randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
); );
export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefinition { const AVAILABLE_BITE_SIZED_SOURCE_IDS = new Set([
"stormwind-stockade", "wailing-caverns", "deadmines", "utgarde-pinnacle",
"forsaken-abbey", "paspers-shrine", "mystic-altar",
]);
function sourceIsAvailable(source: DungeonDefinition): boolean {
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[source.id];
return Boolean(source.assets.visual.length && source.assets.collision.length
&& (source.expansion === "runewaker"
? dungeonPackagePassesOfflineGate(source) && configured !== false
: configured === true));
}
const availableRandomizedSources = adaptedSourceEntries.map(([, definition]) => definition).filter(sourceIsAvailable);
export const AVAILABLE_RANDOMIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(availableRandomizedSources),
);
export const BITE_SIZED_RANDOMIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(availableRandomizedSources.filter((source) => AVAILABLE_BITE_SIZED_SOURCE_IDS.has(source.id))),
);
export function randomizedBiteSizedDefinitionForSeed(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): DungeonDefinition {
if (dungeonId !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
&& dungeonId !== RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID) {
throw new Error(`Unknown Randomized Bite-Sized pool: ${dungeonId}`);
}
return createRandomizedBiteSizedStockadesDefinition( return createRandomizedBiteSizedStockadesDefinition(
stockadesDefinition, stockadesDefinition,
seed, seed,
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, dungeonId === RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID
? BITE_SIZED_RANDOMIZED_CREATURE_CATALOG : AVAILABLE_RANDOMIZED_CREATURE_CATALOG,
dungeonId,
); );
} }
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template"); const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template"); const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition); const randomizedBiteSizedAvailableTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID);
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition); const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition, "catalog-template");
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition); const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition, "catalog-template");
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, "catalog-template");
const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"]; const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"];
const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"]; const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"];
const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"]; const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"];
if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition) { if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition) {
throw new Error("The three RoM source dungeons are required for their bite-sized variants."); throw new Error("The three RoM source dungeons are required for their bite-sized variants.");
} }
const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition); const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, "catalog-template");
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition); const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template");
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition); const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, "catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries, ...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate], [biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate], [randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
[randomizedBiteSizedAvailableTemplate.id, randomizedBiteSizedAvailableTemplate],
[biteSizedWailingCaverns.id, biteSizedWailingCaverns], [biteSizedWailingCaverns.id, biteSizedWailingCaverns],
[biteSizedDeadmines.id, biteSizedDeadmines], [biteSizedDeadmines.id, biteSizedDeadmines],
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle], [biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
@@ -287,12 +325,16 @@ export function clearBossTestingDungeons(): void {
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */ /** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null { export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID && id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) { let definition: DungeonDefinition;
return dungeonDefinitionById(id); if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
} else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id);
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID else if (id === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID) definition = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, seed);
? createBiteSizedStockadesDefinition(stockadesDefinition, seed) else if (id === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID) definition = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, seed);
: randomizedBiteSizedDefinitionForSeed(seed); else if (id === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID) definition = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, seed);
else if (id === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID) definition = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition, seed);
else if (id === BITE_SIZED_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
else return dungeonDefinitionById(id);
sessionDungeonDefinitions.set(id, definition); sessionDungeonDefinitions.set(id, definition);
return definition; return definition;
} }
+24 -1
View File
@@ -4,7 +4,7 @@ import { NodeIO } from "@gltf-transform/core";
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three"; import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource"; import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource";
import { DUNGEON_REGISTRY } from "../dungeonRegistry"; import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime"; import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation"; import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation";
@@ -15,6 +15,27 @@ const sliceIds = [
] as const; ] as const;
describe("RoM bite-sized finales", () => { describe("RoM bite-sized finales", () => {
it.each(sliceIds)("selects every %s source boss from stable run seeds at the same finale anchor", (id) => {
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
const selected = new Set<string>();
for (let index = 0; index < 500; index += 1) {
const definition = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const again = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const boss = definition.bosses[0];
selected.add(boss.id);
expect(again.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.optional).toBe(false);
expect(definition.encounterSpawnGroups![0].spawns[0].position).toEqual(anchor);
for (const difficulty of definition.difficultyVariants) {
expect((definition.encounterSpawnGroups![0].spawns[0].spawnMask! & difficulty.spawnMask)).not.toBe(0);
}
expect(source.bosses.map((candidate) => candidate.id)).toContain(boss.id);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => { it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => {
const slice = DUNGEON_REGISTRY[id]; const slice = DUNGEON_REGISTRY[id];
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)]; const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
@@ -45,6 +66,8 @@ describe("RoM bite-sized finales", () => {
expect(completed.phase).toBe("complete"); expect(completed.phase).toBe("complete");
// The complete source remains available with its original population and objectives. // The complete source remains available with its original population and objectives.
expect(source.bosses.length).toBeGreaterThan(1); expect(source.bosses.length).toBeGreaterThan(1);
expect(slice.presentation.ambientLight.intensity).toBeGreaterThan(source.presentation.ambientLight.intensity);
expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity);
}); });
it.each(sliceIds)("keeps the %s start and boss reachable on shipped navigation", async (id) => { it.each(sliceIds)("keeps the %s start and boss reachable on shipped navigation", async (id) => {
+35 -25
View File
@@ -10,6 +10,7 @@ import {
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
} from "../dungeonContentSource"; } from "../dungeonContentSource";
import { seededBossIndex } from "./seededBossSelection";
interface RoMSlice { interface RoMSlice {
readonly sourceId: string; readonly sourceId: string;
@@ -19,7 +20,7 @@ interface RoMSlice {
readonly entranceName: string; readonly entranceName: string;
readonly start: Vector3Tuple; readonly start: Vector3Tuple;
readonly forward: Vector3Tuple; readonly forward: Vector3Tuple;
readonly bossName: string; readonly finaleBossName: string;
readonly mobSpawnIds: readonly string[]; readonly mobSpawnIds: readonly string[];
readonly summary: string; readonly summary: string;
readonly adaptation: string; readonly adaptation: string;
@@ -29,65 +30,71 @@ const ROM_SLICES: readonly RoMSlice[] = [
{ {
sourceId: "forsaken-abbey", sourceId: "forsaken-abbey",
id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
title: "Forsaken Abbey: Ancalon's Approach", title: "Forsaken Abbey: Final Passage",
location: "Forsaken Abbey · Ancalon's Passage", location: "Forsaken Abbey · Ancalon's Passage",
entranceName: "Ancalon's Passage", entranceName: "Ancalon's Passage",
// The omitted source bat occupied this floor-projected point before the last packs. // The omitted source bat occupied this floor-projected point before the last packs.
start: [330.022888, -19.847076, -34.424903], start: [330.022888, -19.847076, -34.424903],
forward: [1, 0, 0], forward: [1, 0, 0],
bossName: "Demon Witch Ancalon", finaleBossName: "Demon Witch Ancalon",
mobSpawnIds: [ mobSpawnIds: [
"rw-fa-spawn-7855", "rw-fa-spawn-7856", "rw-fa-spawn-7855", "rw-fa-spawn-7856",
"rw-fa-spawn-12074", "rw-fa-spawn-12081", "rw-fa-spawn-12074", "rw-fa-spawn-12081",
"rw-fa-spawn-12071", "rw-fa-spawn-12072", "rw-fa-spawn-12071", "rw-fa-spawn-12072",
], ],
summary: "Clear the shadows and followers in the final passage, then defeat Demon Witch Ancalon.", summary: "Clear the shadows and followers in the final passage, then defeat one surprise Abbey boss.",
adaptation: "The earlier keepers, side rooms, patrol script, and deferred keeper doors are outside this short run.", adaptation: "The earlier keepers, side rooms, patrol script, and deferred keeper doors are outside this short run.",
}, },
{ {
sourceId: "paspers-shrine", sourceId: "paspers-shrine",
id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID, id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
title: "Paspers' Shrine: Hafiz's Hall", title: "Paspers' Shrine: Final Hall",
location: "Paspers' Shrine · Hafiz's Hall", location: "Paspers' Shrine · Hafiz's Hall",
entranceName: "Hafiz's Hall", entranceName: "Hafiz's Hall",
// The omitted source axe soldier occupied this projected point before the final corridor. // The omitted source axe soldier occupied this projected point before the final corridor.
start: [95.618457, 0.348065, -285.473267], start: [95.618457, 0.348065, -285.473267],
forward: [1, 0, 0], forward: [1, 0, 0],
bossName: "Blackhorn Hafiz", finaleBossName: "Blackhorn Hafiz",
mobSpawnIds: [ mobSpawnIds: [
"rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709", "rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709",
"rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712", "rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712",
"rw-paspers-shrine-spawn-5943715", "rw-paspers-shrine-spawn-5943716", "rw-paspers-shrine-spawn-5943715", "rw-paspers-shrine-spawn-5943716",
], ],
summary: "Defeat the Blackhorn guards along the last corridor, then confront Blackhorn Hafiz.", summary: "Defeat the Blackhorn guards along the last corridor, then confront one surprise Shrine boss.",
adaptation: "The other Blackhorn bosses and noncombat source interactions are outside this short run.", adaptation: "The other Blackhorn bosses and noncombat source interactions are outside this short run.",
}, },
{ {
sourceId: "mystic-altar", sourceId: "mystic-altar",
id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
title: "Mystic Altar: Golem's Antechamber", title: "Mystic Altar: Final Antechamber",
location: "Mystic Altar · Golem's Antechamber", location: "Mystic Altar · Golem's Antechamber",
entranceName: "Golem's Antechamber", entranceName: "Golem's Antechamber",
start: [-392.475647, -17.665024, 329], start: [-392.475647, -17.665024, 329],
forward: [-1, 0, 0], forward: [-1, 0, 0],
bossName: "Cursed lron Golem", finaleBossName: "Cursed lron Golem",
mobSpawnIds: [ mobSpawnIds: [
"rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465", "rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465",
"rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245", "rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245",
"rw-mystic-altar-spawn-13248", "rw-mystic-altar-spawn-13332", "rw-mystic-altar-spawn-13248", "rw-mystic-altar-spawn-13332",
], ],
summary: "Clear the guards, beetles, and Morcus beasts before the Cursed Iron Golem.", summary: "Clear the guards, beetles, and Morcus beasts before one surprise Altar boss.",
adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.", adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.",
}, },
]; ];
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefinition { function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string): DungeonDefinition {
if (source.id !== slice.sourceId) throw new Error(`${slice.id} requires ${slice.sourceId}.`); if (source.id !== slice.sourceId) throw new Error(`${slice.id} requires ${slice.sourceId}.`);
const boss = source.objectiveOrder.find((objective) => objective.name === slice.bossName); const finale = source.objectiveOrder.find((objective) => objective.name === slice.finaleBossName);
const bossSpawn = boss && source.staticSpawns.find((spawn) => spawn.id === boss.id); if (!finale || source.bosses.length === 0) {
if (!boss || !bossSpawn || source.entities[bossSpawn.entityId]?.kind !== "boss") { throw new Error(`${slice.sourceId} is missing ${slice.finaleBossName} or its boss list.`);
throw new Error(`${slice.sourceId} is missing ${slice.bossName}.`);
} }
const boss = source.bosses[seededBossIndex(seed, source.bosses.length)];
const sourceBossSpawn = source.staticSpawns.find((spawn) => spawn.id === boss.id);
if (!sourceBossSpawn || source.entities[sourceBossSpawn.entityId]?.kind !== "boss") {
throw new Error(`${slice.sourceId} is missing source boss ${boss.name}.`);
}
const bossSpawn = { ...sourceBossSpawn, position: finale.position,
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
const mobSpawns = slice.mobSpawnIds.map((id) => { const mobSpawns = slice.mobSpawnIds.map((id) => {
const spawn = source.staticSpawns.find((candidate) => candidate.id === id); const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) { if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
@@ -110,7 +117,7 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
scope: "boss-progression", scope: "boss-progression",
files: ["src/game/dungeons/biteSizedRoMDungeons.ts"], files: ["src/game/dungeons/biteSizedRoMDungeons.ts"],
reviewedBehavior: slice.summary, reviewedBehavior: slice.summary,
adaptations: [slice.adaptation, "The boss appears after the retained source mobs are defeated."], adaptations: [slice.adaptation, "One source boss is selected from the run id and appears in the finale room after the retained source mobs are defeated."],
remainingGaps: ["The start, party route, combat, boss activation, and completion need an in-game pass."], remainingGaps: ["The start, party route, combat, boss activation, and completion need an in-game pass."],
}, },
rules: [ rules: [
@@ -136,12 +143,12 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
}, },
], ],
}; };
const objective: DungeonBossObjective = { ...boss, eventOnly: true }; const objective: DungeonBossObjective = { ...boss, position: finale.position, optional: false, eventOnly: true };
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId)); const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id))); const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
const areas = [ const areas = [
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 }, { id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
{ id: "boss-room", name: slice.bossName, center: boss.position, radius: 32 }, { id: "boss-room", name: `${boss.name}'s Room`, center: finale.position, radius: 32 },
]; ];
return { return {
@@ -153,7 +160,10 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
yaw: Math.atan2(slice.forward[0], slice.forward[2]) }, yaw: Math.atan2(slice.forward[0], slice.forward[2]) },
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon", presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`, summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
unchartedAreaName: slice.entranceName }, unchartedAreaName: slice.entranceName,
ambientLight: { color: "#e2dccc", intensity: 0.9 },
hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: 1.2 },
directionalLight: { ...source.presentation.directionalLight, color: "#fff0d9", intensity: 1.35 } },
areas, areas,
entities, entities,
staticSpawns: mobSpawns, staticSpawns: mobSpawns,
@@ -169,14 +179,14 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi
}; };
} }
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition): DungeonDefinition { export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[0]); return createRoMSlice(source, ROM_SLICES[0], seed);
} }
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition): DungeonDefinition { export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[1]); return createRoMSlice(source, ROM_SLICES[1], seed);
} }
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition): DungeonDefinition { export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[2]); return createRoMSlice(source, ROM_SLICES[2], seed);
} }
+2 -10
View File
@@ -5,6 +5,7 @@ import {
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID,
} from "../dungeonContentSource"; } from "../dungeonContentSource";
import { seededBossIndex } from "./seededBossSelection";
const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const; const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const;
const CENTRAL_CORRIDOR_HALF_WIDTH = 15; const CENTRAL_CORRIDOR_HALF_WIDTH = 15;
@@ -22,17 +23,8 @@ export const BITE_SIZED_STOCKADES_BOSS_CANDIDATES: readonly BiteSizedStockadesBo
{ sourceSpawnId: "creature-79116", name: "Dextren Ward" }, { sourceSpawnId: "creature-79116", name: "Dextren Ward" },
]); ]);
function seedHash(seed: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function biteSizedStockadesBossIndex(seed: string): number { export function biteSizedStockadesBossIndex(seed: string): number {
return seedHash(seed) % BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; return seededBossIndex(seed, BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
} }
function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition { function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition {
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { dungeonContentSourceId } from "../dungeonContentSource";
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
import { finalDungeonBoss } from "../dungeonRunStore";
const ids = ["bite-sized-wailing-caverns", "bite-sized-deadmines-ship",
"bite-sized-utgarde-pinnacle-skadi"] as const;
describe("WoW bite-sized seeded bosses", () => {
it.each(ids)("selects every %s source boss at a stable finale anchor", (id) => {
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
const selected = new Set<string>();
for (let index = 0; index < 500; index += 1) {
const seed = `wow-slice-${index}`;
const definition = prepareDungeonSessionDefinition(id, seed)!;
const boss = definition.bosses[0];
const spawn = definition.encounterSpawnGroups![0].spawns[0];
selected.add(boss.id);
expect(prepareDungeonSessionDefinition(id, seed)!.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.eventOnly).toBe(true);
expect(boss.optional).toBe(false);
expect(spawn.id).toBe(boss.id);
expect(spawn.position).toEqual(anchor);
expect(definition.entities[spawn.entityId].kind).toBe("boss");
expect(finalDungeonBoss(definition)?.id).toBe(boss.id);
expect(definition.staticSpawns.every((entry) => definition.entities[entry.entityId].kind !== "boss")).toBe(true);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(ids)("gates the %s boss on retained mobs and completes on its death", (id) => {
const definition = prepareDungeonSessionDefinition(id, "encounter-run")!;
const encounter = definition.encounters![0];
const mobs = definition.staticSpawns.map((spawn) => spawn.id);
expect(mobs.length).toBeGreaterThan(0);
let snapshot = createDungeonEncounterSnapshot(encounter);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100,
facts: { registeredEntityIds: mobs, deadEntityIds: mobs.slice(0, -1) } }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 200,
facts: { registeredEntityIds: mobs, deadEntityIds: mobs } }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([definition.encounterSpawnGroups![0].id]);
const bossId = definition.bosses[0].id;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 300,
facts: { registeredEntityIds: [bossId], deadEntityIds: [bossId] } }).snapshot;
expect(snapshot.completedObjectiveIds).toContain(bossId);
expect(snapshot.flags["dungeon-complete"]).toBe(true);
});
});
+65 -92
View File
@@ -18,6 +18,7 @@ import {
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID, BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID, BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource"; } from "../dungeonContentSource";
import { finaleBossSpawn, seededSourceBoss } from "./sourceBossCandidates";
const DEADMINES_ROUTE_WIDTH = 34; const DEADMINES_ROUTE_WIDTH = 34;
function normalizedName(value: string): string { function normalizedName(value: string): string {
@@ -39,30 +40,6 @@ function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): n
return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end))); return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end)));
} }
function routeSpawns(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly StaticMobSpawnDefinition[] {
return source.staticSpawns.filter((spawn) => (
source.entities[spawn.entityId]?.kind !== "boss"
&& distanceToRoute(spawn.position, route) <= width
));
}
function routeRoamingPacks(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly MobPackDefinition[] {
return source.roamingPacks.flatMap((pack) => {
const members = pack.members.filter((member) => source.entities[member.entityId]?.kind !== "boss");
return members.length > 0 && pack.waypoints.every((point) => distanceToRoute(point, route) <= width * 1.7)
? [{ ...pack, members }]
: [];
});
}
function entitiesFor( function entitiesFor(
source: DungeonDefinition, source: DungeonDefinition,
staticSpawns: readonly StaticMobSpawnDefinition[], staticSpawns: readonly StaticMobSpawnDefinition[],
@@ -120,9 +97,11 @@ function appendPrototypeWarning(source: DungeonDefinition, note: string): Dungeo
}; };
} }
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition { export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon."); if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const verdan = bossByName(source, "Verdan the Everliving"); const verdan = bossByName(source, "Verdan the Everliving");
const selected = seededSourceBoss(source, seed);
const bossSpawn = finaleBossSpawn(source, selected, verdan.objective.position);
const startPosition: Vector3Tuple = [82, -30.92, 8]; const startPosition: Vector3Tuple = [82, -30.92, 8];
// These points sit on the shipped Wailing Caverns navigation surface in Verdan's chamber. // These points sit on the shipped Wailing Caverns navigation surface in Verdan's chamber.
const staticSpawns: readonly StaticMobSpawnDefinition[] = [ const staticSpawns: readonly StaticMobSpawnDefinition[] = [
@@ -133,7 +112,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`; const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`;
const verdanGroup: DungeonEncounterSpawnGroupDefinition = { const verdanGroup: DungeonEncounterSpawnGroupDefinition = {
id: verdanGroupId, id: verdanGroupId,
spawns: [verdan.spawn], spawns: [bossSpawn],
}; };
const encounter: DungeonEncounterDefinition = { const encounter: DungeonEncounterDefinition = {
id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`, id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`,
@@ -145,11 +124,11 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
fidelity: "healer-man-authored", fidelity: "healer-man-authored",
scope: "boss-progression", scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/dungeons/wailingCaverns.ts"], files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/dungeons/wailingCaverns.ts"],
reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat Verdan the Everliving.", reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat one source Wailing Caverns boss selected by the run ID.",
adaptations: [ adaptations: [
"The Fanglords, Naralex escort, and Mutanus event are outside this short run.", "The Fanglords, Naralex escort, and Mutanus event are outside this short run.",
"The room mobs use source Wailing Caverns creatures at floor-checked chamber positions.", "The room mobs use source Wailing Caverns creatures at floor-checked chamber positions.",
"Verdan is held until the room mobs are defeated.", "The selected boss is held at Verdan's finale anchor until the room mobs are defeated.",
], ],
remainingGaps: ["The room encounter still needs an in-game combat and completion pass."], remainingGaps: ["The room encounter still needs an in-game combat and completion pass."],
}, },
@@ -165,18 +144,19 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
}, },
{ {
id: "verdan-room-boss-defeated", id: "verdan-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [verdan.spawn.id] }, trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["verdan"] }], guards: [{ kind: "phase", phases: ["verdan"] }],
actions: runCompletionActions("verdan-room", verdan.objective.id), actions: runCompletionActions("verdan-room", selected.objective.id),
}, },
], ],
}; };
const finalObjective: DungeonBossObjective = { ...verdan.objective, eventOnly: true }; const finalObjective: DungeonBossObjective = { ...selected.objective, position: verdan.objective.position,
optional: false, eventOnly: true };
return { return {
...source, ...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID, id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Verdan’s Lair", title: "Wailing Caverns: Everliving Chamber",
lfgDungeonIds: [], lfgDungeonIds: [],
entrance: { entrance: {
...source.entrance, ...source.entrance,
@@ -189,7 +169,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
...source.presentation, ...source.presentation,
location: "Wailing Caverns · Verdan’s Chamber", location: "Wailing Caverns · Verdan’s Chamber",
theme: "Bite-Sized Dungeon", theme: "Bite-Sized Dungeon",
summary: "Clear the creatures in Verdan’s chamber, then defeat Verdan the Everliving.", summary: "Clear the chamber creatures, then defeat one surprise Wailing Caverns boss.",
loadingMessage: "Entering Verdan’s chamber...", loadingMessage: "Entering Verdan’s chamber...",
unchartedAreaName: "Crag of the Everliving", unchartedAreaName: "Crag of the Everliving",
}, },
@@ -212,76 +192,72 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio
}; };
} }
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition { export function createBiteSizedDeadminesDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon."); if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const smite = bossByName(source, "Mr. Smite");
const captain = bossByName(source, "Captain Greenskin"); const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef"); const finalBoss = bossByName(source, "Edwin VanCleef");
const startPosition = smite.spawn.position; const selected = seededSourceBoss(source, seed);
const startPosition = captain.spawn.position;
const route: readonly Vector3Tuple[] = [ const route: readonly Vector3Tuple[] = [
startPosition, startPosition,
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position, finalBoss.objective.position,
]; ];
const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH); const routeTrash = ["creature-79334", "creature-79335", "creature-79369", "creature-79370"].map((id) => {
const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH); const spawn = source.staticSpawns.find((entry) => entry.id === id);
const vanCleefSpawn: StaticMobSpawnDefinition = { if (!spawn || source.entities[spawn.entityId]?.kind !== "mob") {
...finalBoss.spawn, throw new Error(`The Deadmines final deck is missing source mob ${id}.`);
position: finalBoss.objective.position, }
}; return spawn;
});
const bossSpawn = finaleBossSpawn(source, selected, finalBoss.objective.position);
const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`; const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = { const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
id: vanCleefGroupId, id: vanCleefGroupId,
spawns: [vanCleefSpawn], spawns: [bossSpawn],
}; };
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`; const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
const encounter: DungeonEncounterDefinition = { const encounter: DungeonEncounterDefinition = {
id: encounterId, id: encounterId,
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID, dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
initialPhase: "greenskin", initialPhase: "ship-mobs",
initialState: { flags: { "dungeon-complete": false } }, initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" }, resetPolicy: { bossWipePhase: "ship-mobs", checkpointPhase: "ship-mobs" },
source: { source: {
fidelity: "healer-man-authored", fidelity: "healer-man-authored",
scope: "boss-progression", scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"], files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start on Mr. Smite's floor-snapped deck position, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.", reviewedBehavior: "Start on Greenskin's deck, clear two Defias guard pairs, then confront one seeded Deadmines boss in VanCleef's cabin.",
adaptations: [ adaptations: [
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.", "The mine, foundry, lower ship decks, and Cookie branch are omitted from this run.",
"VanCleef is held in an encounter spawn group until Greenskin is defeated.", "One boss from the complete Deadmines boss list appears in the cabin after the retained crew die.",
"Source boss IDs are retained for objective records and loot lookup.", "The selected source boss ID is retained for objective records and loot lookup.",
], ],
remainingGaps: [ remainingGaps: [
"The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.", "The final-deck approach and party traversal still need a live combat pass.",
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.", "Original boss-specific scripted actions beyond their imported combat profiles are not included in this short slice.",
], ],
}, },
rules: [ rules: [
{ {
id: "ship-greenskin-defeated", id: "ship-crew-defeated",
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] }, trigger: { kind: "entities-dead", entityIds: routeTrash.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["greenskin"] }], guards: [{ kind: "phase", phases: ["ship-mobs"] }],
actions: [ actions: [
{ id: "ship-complete-greenskin", kind: "complete-objective", objectiveId: captain.objective.id, persistence: "run" },
{ id: "ship-checkpoint-greenskin", kind: "set-checkpoint", checkpointId: captain.objective.id, persistence: "checkpoint" },
{ id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId }, { id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId },
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" }, { id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "boss" },
], ],
}, },
{ {
id: "ship-vancleef-defeated", id: "ship-vancleef-defeated",
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] }, trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["van-cleef"] }], guards: [{ kind: "phase", phases: ["boss"] }],
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id), actions: runCompletionActions("ship-complete-vancleef", selected.objective.id),
}, },
], ],
}; };
const encounterGroups = [vanCleefGroup]; const encounterGroups = [vanCleefGroup];
const objectives: readonly DungeonBossObjective[] = [ const objective: DungeonBossObjective = { ...selected.objective, position: finalBoss.objective.position,
captain.objective, optional: false, eventOnly: true };
{ ...finalBoss.objective, eventOnly: true },
];
return { return {
...source, ...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID, id: BITE_SIZED_DEADMINES_DUNGEON_ID,
@@ -289,29 +265,28 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
lfgDungeonIds: [], lfgDungeonIds: [],
entrance: { entrance: {
...source.entrance, ...source.entrance,
name: "Mr. Smite’s Deck", name: "Greenskin’s Deck",
footPosition: startPosition, footPosition: startPosition,
forward: [-0.5, 0, -0.866], forward: [0, 0, -1],
yaw: -2.618, yaw: Math.PI,
}, },
presentation: { presentation: {
...source.presentation, ...source.presentation,
location: "The Deadmines · The Defias Ship", location: "The Deadmines · The Defias Ship",
theme: "Bite-Sized Dungeon", theme: "Bite-Sized Dungeon",
summary: "Board the Defias ship, push through the crew, defeat Captain Greenskin, and finish VanCleef in the cabin.", summary: "Clear two Defias guard pairs on the final deck, then face one surprise Deadmines boss in the cabin.",
loadingMessage: "Boarding the Defias ship...", loadingMessage: "Boarding the Defias ship...",
unchartedAreaName: "Defias Ship", unchartedAreaName: "Defias Ship",
}, },
areas: [ areas: [
routeArea("entrance", "Mr. Smite’s Deck", startPosition, 25), routeArea("entrance", "Greenskin’s Deck", startPosition, 32),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28), routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
], ],
entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]), entities: entitiesFor(source, routeTrash, [], encounterGroups),
staticSpawns: [...routeTrash, captain.spawn], staticSpawns: routeTrash,
roamingPacks, roamingPacks: [],
bosses: objectives, bosses: [objective],
objectiveOrder: objectives, objectiveOrder: [objective],
finalBossDurability: "standard", finalBossDurability: "standard",
encounters: [encounter], encounters: [encounter],
encounterSpawnGroups: encounterGroups, encounterSpawnGroups: encounterGroups,
@@ -320,12 +295,12 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
)), )),
validation: appendPrototypeWarning( validation: appendPrototypeWarning(
source, source,
"The Deadmines ship route and two-boss progression are Healer Man-authored; source ship geometry, crew, Greenskin, and VanCleef assets are shared with The Deadmines.", "The Deadmines ship route and one-boss progression are Healer Man-authored; source ship geometry, crew, and boss assets are shared with The Deadmines.",
), ),
}; };
} }
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition { export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle."); if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless"); const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground"); const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
@@ -333,6 +308,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[7]) { if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[7]) {
throw new Error("Utgarde Pinnacle is missing the Skadi room content."); throw new Error("Utgarde Pinnacle is missing the Skadi room content.");
} }
const selected = seededSourceBoss(source, seed);
// The source start object is across a collision gap from the Skadi corridor. // The source start object is across a collision gap from the Skadi corridor.
const startPosition: Vector3Tuple = [-511, 104.8, 370]; const startPosition: Vector3Tuple = [-511, 104.8, 370];
const bossPosition = firstWave.spawns[0].position; const bossPosition = firstWave.spawns[0].position;
@@ -341,14 +317,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
firstWave.spawns[6], firstWave.spawns[6],
firstWave.spawns[5], firstWave.spawns[5],
]; ];
const skadiSpawn: StaticMobSpawnDefinition = { const bossSpawn = finaleBossSpawn(source, selected, bossPosition);
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: bossPosition,
};
const skadiGroup: DungeonEncounterSpawnGroupDefinition = { const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup, ...sourceSkadiGroup,
spawns: [skadiSpawn], spawns: [bossSpawn],
}; };
const encounter: DungeonEncounterDefinition = { const encounter: DungeonEncounterDefinition = {
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`, id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`,
@@ -360,10 +332,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
fidelity: "healer-man-authored", fidelity: "healer-man-authored",
scope: "boss-progression", scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"], files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"],
reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront Skadi at its end.", reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront one seeded Utgarde Pinnacle boss at its end.",
adaptations: [ adaptations: [
"The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.", "The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.",
"Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position at the corridor's end.", "The selected source boss uses its combat data and objective ID at a floor-snapped source wave position at the corridor's end.",
], ],
remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."], remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."],
}, },
@@ -379,18 +351,19 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
}, },
{ {
id: "skadi-room-boss-defeated", id: "skadi-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [skadiSpawn.id] }, trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
guards: [{ kind: "phase", phases: ["skadi"] }], guards: [{ kind: "phase", phases: ["skadi"] }],
actions: runCompletionActions("skadi-room", skadi.id), actions: runCompletionActions("skadi-room", selected.objective.id),
}, },
], ],
}; };
const objective: DungeonBossObjective = { ...skadi, position: bossPosition, eventOnly: true }; const objective: DungeonBossObjective = { ...selected.objective, position: bossPosition,
optional: false, eventOnly: true };
return { return {
...source, ...source,
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID, id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
title: "Utgarde Pinnacle: Skadi’s Gauntlet", title: "Utgarde Pinnacle: Eagle’s Eye Gauntlet",
lfgDungeonIds: [], lfgDungeonIds: [],
entrance: { entrance: {
...source.entrance, ...source.entrance,
@@ -403,7 +376,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
...source.presentation, ...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye", location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon", theme: "Bite-Sized Dungeon",
summary: "Defeat the three guards on Eagle’s Eye, then fight Skadi on the approach.", summary: "Defeat the three guards on Eagle’s Eye, then fight one surprise Utgarde Pinnacle boss.",
loadingMessage: "Entering Skadi’s gauntlet...", loadingMessage: "Entering Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye", unchartedAreaName: "Eagle’s Eye",
}, },
@@ -2,6 +2,7 @@ import type { DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition } from "../mobPopulation"; import type { PopulationEntityDefinition } from "../mobPopulation";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource"; import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./biteSizedStockades"; import { createBiteSizedStockadesDefinition } from "./biteSizedStockades";
import { seededBossIndex } from "./seededBossSelection";
export type RandomizedCreatureKind = "mob" | "boss"; export type RandomizedCreatureKind = "mob" | "boss";
@@ -114,11 +115,7 @@ function dealMobs(
} }
function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition { function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition {
// Boss source ids remain intact so their existing loot table and collection const id = `randomized:${candidate.key}`;
// card can still resolve. Trash ids are namespaced away from the location.
const id = candidate.kind === "boss"
? candidate.entityId
: `randomized:${candidate.key}`;
const combat = candidate.definition.combat ? { const combat = candidate.definition.combat ? {
...candidate.definition.combat, ...candidate.definition.combat,
// The identity and attacks are randomized, while durability and pressure // The identity and attacks are randomized, while durability and pressure
@@ -130,58 +127,59 @@ function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntity
} }
/** /**
* Keeps the reviewed Stockades geometry and navigation, then deals a different * Keeps the reviewed Stockades geometry and navigation, then deals creatures
* cross-dungeon creature to every corridor slot and rolls the boss separately. * and a boss from the selected cross-dungeon pool.
*/ */
export function createRandomizedBiteSizedStockadesDefinition( export function createRandomizedBiteSizedStockadesDefinition(
source: DungeonDefinition, source: DungeonDefinition,
seed: string, seed: string,
catalog: readonly RandomizedBiteSizedCreature[], catalog: readonly RandomizedBiteSizedCreature[],
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
): DungeonDefinition { ): DungeonDefinition {
const base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`); const base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`);
const bossSpawnId = base.bosses[0]?.id; const bossSpawnId = base.bosses[0]?.id;
if (!bossSpawnId) throw new Error("Randomized Bite-Sized Stockades has no final boss slot."); if (!bossSpawnId) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
const bossSpawn = base.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
if (!bossSpawn) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
const trashSpawns = base.staticSpawns.filter((spawn) => spawn.id !== bossSpawnId); const trashSpawns = base.staticSpawns.filter((spawn) => spawn.id !== bossSpawnId);
const mobPool = catalog.filter((candidate) => candidate.kind === "mob"); const mobPool = catalog.filter((candidate) => candidate.kind === "mob");
if (mobPool.length < trashSpawns.length) { if (mobPool.length < trashSpawns.length) {
throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`); throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`);
} }
const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`); const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`);
const originalBossSpawn = source.staticSpawns.find((spawn) => spawn.id === bossSpawnId); const bossPool = catalog.filter((candidate) => candidate.kind === "boss");
const originalBoss = originalBossSpawn ? source.entities[originalBossSpawn.entityId] : undefined; if (!bossPool.length) throw new Error("Randomized Bite-Sized Stockades needs a boss candidate.");
const originalBossKey = originalBoss ? creatureKey(source, originalBoss) : null; const bossCandidate = bossPool[seededBossIndex(`${seed}:boss`, bossPool.length)];
const bossCandidate = seededShuffle( const selectedBoss = { ...runtimeEntity(bossCandidate), id: bossCandidate.entityId };
catalog.filter((candidate) => candidate.kind === "boss"), const selectedBossId = `randomized:${bossCandidate.key}`;
`${seed}:boss`,
).find((candidate) => candidate.key !== originalBossKey);
if (!bossCandidate) throw new Error("Randomized Bite-Sized Stockades has no boss candidates.");
const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]])); const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]]));
const bossEntity = runtimeEntity(bossCandidate);
const randomizedSpawns = base.staticSpawns.map((spawn) => { const randomizedSpawns = base.staticSpawns.map((spawn) => {
const candidate = selectedMobs.get(spawn.id); const candidate = selectedMobs.get(spawn.id);
return { ...spawn, entityId: candidate ? runtimeEntity(candidate).id : bossEntity.id }; return candidate ? { ...spawn, entityId: runtimeEntity(candidate).id }
: spawn.id === bossSpawnId ? { ...spawn, id: selectedBossId, entityId: selectedBoss.id } : spawn;
}); });
const selectedCandidates = [...selectedMobs.values(), bossCandidate]; const randomizedEntities = Object.fromEntries([...selectedMobs.values()].map((candidate) => {
const entities = Object.fromEntries(selectedCandidates.map((candidate) => {
const entity = runtimeEntity(candidate); const entity = runtimeEntity(candidate);
return [entity.id, entity]; return [entity.id, entity];
})) as Readonly<Record<string, PopulationEntityDefinition>>; })) as Readonly<Record<string, PopulationEntityDefinition>>;
const boss = { ...base.bosses[0], name: bossCandidate.name }; const entities = { ...randomizedEntities, [selectedBoss.id]: selectedBoss };
const boss = { ...base.bosses[0], id: selectedBossId, name: selectedBoss.name };
return { return {
...base, ...base,
id: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, id: dungeonId,
title: "Randomized Bite-Sized: Cellblock Shuffle", title: dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
? "Randomized Bite-Sized: All Available"
: "Randomized Bite-Sized: Bite-Sized Sources",
presentation: { presentation: {
...base.presentation, ...base.presentation,
location: "Stormwind Stockade · Shifting Cellblock", location: "Stormwind Stockade · Shifting Cellblock",
theme: "Randomized Bite-Sized", theme: "Randomized Bite-Sized",
summary: "Every enemy slot is dealt a distinct cross-dungeon creature, followed by an independently randomized boss.", summary: "Every corridor slot and the final boss are selected from the chosen cross-dungeon creature pool.",
loadingMessage: "Choosing a location and shuffling every creature slot...", loadingMessage: "Choosing a location and shuffling every creature slot...",
}, },
areas: base.areas.map((area) => area.id === "bite-sized-boss-room" areas: base.areas.map((area) => area.id === "bite-sized-boss-room"
? { ...area, name: `${bossCandidate.name}'s Cell` } ? { ...area, name: `${boss.name}'s Cell` }
: area), : area),
entities, entities,
staticSpawns: randomizedSpawns, staticSpawns: randomizedSpawns,
@@ -189,16 +187,26 @@ export function createRandomizedBiteSizedStockadesDefinition(
objectiveOrder: [boss], objectiveOrder: [boss],
encounters: (base.encounters ?? []).map((encounter) => ({ encounters: (base.encounters ?? []).map((encounter) => ({
...encounter, ...encounter,
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`, id: `${dungeonId}:boss-progression`,
dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, dungeonId,
rules: encounter.rules.map((rule) => ({
...rule,
trigger: rule.trigger.kind === "entities-dead"
? { ...rule.trigger, entityIds: [selectedBossId] } : rule.trigger,
guards: rule.guards?.map((guard) => guard.kind === "objective-complete"
? { ...guard, objectiveId: selectedBossId } : guard),
actions: rule.actions.map((action) => action.kind === "complete-objective"
? { ...action, objectiveId: selectedBossId }
: action.kind === "set-checkpoint" ? { ...action, checkpointId: selectedBossId } : action),
})),
source: encounter.source ? { source: encounter.source ? {
...encounter.source, ...encounter.source,
files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"], files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"],
reviewedBehavior: "Every corridor slot receives a distinct seeded creature and the final boss uses an independent seeded roll.", reviewedBehavior: "Every corridor slot and the final boss receive seeded creatures from the selected cross-dungeon pool.",
adaptations: [ adaptations: [
...encounter.source.adaptations, ...encounter.source.adaptations,
"The trash shuffle deals without replacement from the complete model-backed cross-dungeon mob catalog, so one run cannot repeat a mob identity.", "The trash shuffle deals without replacement from the complete model-backed cross-dungeon mob catalog, so one run cannot repeat a mob identity.",
"The final boss is rolled independently from the complete model-backed cross-dungeon boss catalog while retaining the location's stable completion objective.", "The final boss uses a namespaced creature key as its objective and leaderboard identity, with source combat and visuals.",
"Randomized combat multipliers are normalized before party-level scaling so source-dungeon tuning cannot overwhelm a short leveling run.", "Randomized combat multipliers are normalized before party-level scaling so source-dungeon tuning cannot overwhelm a short leveling run.",
], ],
} : encounter.source, } : encounter.source,
+9
View File
@@ -0,0 +1,9 @@
/** A run id selects one stable boss for saves and every member of a shared party. */
export function seededBossIndex(seed: string, candidateCount: number): number {
let hash = 0x811c9dc5;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % candidateCount;
}
+49
View File
@@ -0,0 +1,49 @@
import type { DungeonBossObjective, DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
import { seededBossIndex } from "./seededBossSelection";
export interface SourceBossCandidate {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
}
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
/** Resolves ordinary, event, and roaming source bosses into a reusable one-boss spawn. */
export function sourceBossCandidates(source: DungeonDefinition): readonly SourceBossCandidate[] {
return source.bosses.map((objective) => {
const namedBoss = (entityId: string) => {
const entity = source.entities[entityId];
return entity?.kind === "boss" && normalizedName(entity.name) === normalizedName(objective.name);
};
const staticSpawn = source.staticSpawns.find((spawn) => spawn.id === objective.id
&& source.entities[spawn.entityId]?.kind === "boss");
const groupSpawn = source.encounterSpawnGroups?.flatMap((group) => group.spawns)
.filter((spawn) => namedBoss(spawn.entityId)).at(-1);
const roamingMember = source.roamingPacks.flatMap((pack) => pack.members)
.find((member) => namedBoss(member.entityId));
const spawn = staticSpawn ?? groupSpawn ?? (roamingMember ? {
id: objective.id, entityId: roamingMember.entityId, position: objective.position, yaw: 0,
} : undefined);
const entity = spawn && source.entities[spawn.entityId];
if (!spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing a usable source spawn for ${objective.name}.`);
}
return { objective, spawn, entity };
});
}
export function seededSourceBoss(source: DungeonDefinition, seed: string): SourceBossCandidate {
const candidates = sourceBossCandidates(source);
if (!candidates.length) throw new Error(`${source.id} has no source bosses to select.`);
return candidates[seededBossIndex(seed, candidates.length)];
}
export function finaleBossSpawn(source: DungeonDefinition, candidate: SourceBossCandidate,
position: DungeonBossObjective["position"]): StaticMobSpawnDefinition {
return { ...candidate.spawn, id: candidate.objective.id, position,
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
}
File diff suppressed because it is too large. Load diff
+37 -11
View File
@@ -1,13 +1,20 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID,
} from "./dungeonContentSource"; } from "./dungeonContentSource";
import { import {
DUNGEON_REGISTRY, DUNGEON_REGISTRY,
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
AVAILABLE_RANDOMIZED_CREATURE_CATALOG,
BITE_SIZED_RANDOMIZED_CREATURE_CATALOG,
dungeonDefinitionById, dungeonDefinitionById,
randomizedBiteSizedDefinitionForSeed,
dungeonCanEnter,
} from "./dungeonRegistry"; } from "./dungeonRegistry";
import { dungeonLeaderboardCatalog } from "./dungeonLeaderboardCatalog";
import { seededBossIndex } from "./dungeons/seededBossSelection";
import { activateDungeonSession } from "./dungeonSession"; import { activateDungeonSession } from "./dungeonSession";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades"; import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
@@ -82,13 +89,12 @@ describe("Randomized Bite-Sized Stockades", () => {
} }
}); });
it("uses independent deterministic trash and boss rolls across run ids", () => { it("uses deterministic cross-dungeon trash and bosses across run ids", () => {
const first = create("shared-run"); const first = create("shared-run");
const second = create("shared-run"); const second = create("shared-run");
expect(second.staticSpawns).toEqual(first.staticSpawns); expect(second.staticSpawns).toEqual(first.staticSpawns);
expect(second.bosses).toEqual(first.bosses); expect(second.bosses).toEqual(first.bosses);
const bossEntityIds = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.entityId));
const observedBossEntityIds = new Set<string>(); const observedBossEntityIds = new Set<string>();
const observedTrashLineups = new Set<string>(); const observedTrashLineups = new Set<string>();
for (let index = 0; index < 2_000; index += 1) { for (let index = 0; index < 2_000; index += 1) {
@@ -101,28 +107,48 @@ describe("Randomized Bite-Sized Stockades", () => {
.map((spawn) => spawn.entityId) .map((spawn) => spawn.entityId)
.join("|")); .join("|"));
} }
expect([...observedBossEntityIds].every((entityId) => bossEntityIds.has(entityId))).toBe(true); expect([...observedBossEntityIds].every((entityId) => catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === entityId))).toBe(true);
expect(observedBossEntityIds.size).toBeGreaterThan(100); expect(observedBossEntityIds.size).toBeGreaterThan(50);
expect(observedTrashLineups.size).toBeGreaterThan(1_900); expect(observedTrashLineups.size).toBeGreaterThan(1_900);
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true); expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns); expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
}); });
it("keeps the randomized boss on the location's stable completion objective", () => { it("uses the selected cross-dungeon boss for combat and completion", () => {
const randomized = create("random-boss-objective"); const randomized = create("random-boss-objective");
const boss = randomized.bosses[0]; const boss = randomized.bosses[0];
const spawn = randomized.staticSpawns.find((candidate) => candidate.id === boss.id)!; const spawn = randomized.staticSpawns.find((candidate) => candidate.id === boss.id)!;
const entity = randomized.entities[spawn.entityId]; const entity = randomized.entities[spawn.entityId];
const base = createBiteSizedStockadesDefinition(source, "random-boss-objective:objective-slot"); const candidate = catalog.find((entry) => `randomized:${entry.key}` === boss.id)!;
const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === boss.id)!;
const originalBoss = base.entities[originalSpawn.entityId];
expect(entity.kind).toBe("boss"); expect(entity.kind).toBe("boss");
expect(boss.name).toBe(entity.name); expect(boss.name).toBe(entity.name);
expect(catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === spawn.entityId)).toBe(true); expect(candidate.kind).toBe("boss");
expect(entity.combat?.serverEntry).not.toBe(originalBoss.combat?.serverEntry); expect(entity.combat?.serverEntry).toBe(candidate.definition.combat?.serverEntry);
expect(randomized.encounters?.[0].rules[0].trigger).toEqual({ kind: "entities-dead", entityIds: [boss.id] }); expect(randomized.encounters?.[0].rules[0].trigger).toEqual({ kind: "entities-dead", entityIds: [boss.id] });
expect(entity.combat).toMatchObject({ healthMultiplier: 1, damageMultiplier: 1 }); expect(entity.combat?.healthMultiplier).toBe(1);
});
it("keeps both selectable pools available and excludes unavailable source dungeons", () => {
expect(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG.length).toBeGreaterThan(12);
expect(AVAILABLE_RANDOMIZED_CREATURE_CATALOG.length).toBeGreaterThan(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG.length);
const allKeys = new Set(AVAILABLE_RANDOMIZED_CREATURE_CATALOG.map((entry) => entry.key));
expect(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG.every((entry) => allKeys.has(entry.key))).toBe(true);
expect(AVAILABLE_RANDOMIZED_CREATURE_CATALOG.every((entry) => dungeonCanEnter(entry.sourceDungeonId))).toBe(true);
for (const [dungeonId, pool] of [
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, AVAILABLE_RANDOMIZED_CREATURE_CATALOG],
[RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG],
] as const) {
const definition = randomizedBiteSizedDefinitionForSeed("shared-pool-seed", dungeonId);
expect(definition.id).toBe(dungeonId);
expect(definition.staticSpawns.every((spawn) => pool.some((entry) => (
entry.kind === "boss" ? entry.entityId === spawn.entityId : `randomized:${entry.key}` === spawn.entityId
)))).toBe(true);
expect(definition.bosses[0].id).toMatch(/^randomized:(wow|rom):boss:/);
const board = dungeonLeaderboardCatalog.dungeons.find((entry) => entry.id === dungeonId)!;
const finalBossIds = board.difficulties[0].finalBossIds!;
expect(definition.bosses[0].id).toBe(finalBossIds[seededBossIndex("shared-pool-seed:boss", finalBossIds.length)]);
}
}); });
}); });
+1
View File
@@ -4673,6 +4673,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.randomized-bite-sized-screen__odds span, .randomized-bite-sized-screen__odds span,
.randomized-creature-readiness__summary span { color: #b8c6cb; font-size: .75rem; text-transform: uppercase; } .randomized-creature-readiness__summary span { color: #b8c6cb; font-size: .75rem; text-transform: uppercase; }
.randomized-bite-sized-screen__roles { display: grid; gap: 10px; margin-top: 18px; } .randomized-bite-sized-screen__roles { display: grid; gap: 10px; margin-top: 18px; }
.randomized-bite-sized-screen__pool [aria-pressed="true"] { border-color: #e4be70; background: #44371f; color: #ffe4a3; }
.randomized-creature-readiness__summary { max-width: 700px; } .randomized-creature-readiness__summary { max-width: 700px; }
.randomized-creature-readiness__layout { display: grid; grid-template-columns: minmax(420px, 1.1fr) minmax(320px, .9fr); gap: 22px; } .randomized-creature-readiness__layout { display: grid; grid-template-columns: minmax(420px, 1.1fr) minmax(320px, .9fr); gap: 22px; }
+5 -1
View File
@@ -6,7 +6,7 @@ import "./dungeonRuns.css";
export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter, export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter,
expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty, expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty,
selectedId, select }: { bossLabel, onBoss, selectedId, select }: {
readonly dungeonTitle: string; readonly dungeonTitle: string;
readonly data: LeaderboardData; readonly data: LeaderboardData;
readonly scope: LeaderboardScope; readonly scope: LeaderboardScope;
@@ -20,6 +20,8 @@ export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter,
readonly onRefresh: () => void; readonly onRefresh: () => void;
readonly difficultyLabel: string; readonly difficultyLabel: string;
readonly onDifficulty?: () => void; readonly onDifficulty?: () => void;
readonly bossLabel?: string;
readonly onBoss?: () => void;
readonly selectedId: string; readonly selectedId: string;
readonly select: (id: string) => void; readonly select: (id: string) => void;
}) { }) {
@@ -35,6 +37,8 @@ export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter,
onClick={() => onFilter("full")}>Full dungeon clears <small>All bosses</small></ControllerButton> onClick={() => onFilter("full")}>Full dungeon clears <small>All bosses</small></ControllerButton>
</div> </div>
<div className="dungeon-leaderboard__meta"> <div className="dungeon-leaderboard__meta">
{onBoss ? <ControllerButton {...controls} controlId="leaderboard-boss" onClick={onBoss}
aria-label={`Leaderboard boss: ${bossLabel}. Change boss`}>Boss: {bossLabel} ↻</ControllerButton> : null}
{onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty} {onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty}
aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton> aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton>
: <span>{difficultyLabel}</span>} : <span>{difficultyLabel}</span>}
+15 -4
View File
@@ -7,6 +7,7 @@ import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog"; import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy"; import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { formatDungeonDuration } from "../game/dungeonRunTypes"; import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore"; import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById } from "../game/specializationCatalog"; import { specializationById } from "../game/specializationCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface"; import { BrandMark, FrontSurface } from "./FrontSurface";
@@ -31,6 +32,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
const [period, setPeriod] = useState<"30d" | "all">("30d"); const [period, setPeriod] = useState<"30d" | "all">("30d");
const [boardScope, setBoardScope] = useState<"overall" | "dungeon">("overall"); const [boardScope, setBoardScope] = useState<"overall" | "dungeon">("overall");
const [boardView, setBoardView] = useState<"wins" | "times">("wins"); const [boardView, setBoardView] = useState<"wins" | "times">("wins");
const [boardBossId, setBoardBossId] = useState("");
const [board, setBoard] = useState<BoardResponse | null>(null); const [board, setBoard] = useState<BoardResponse | null>(null);
const [boardError, setBoardError] = useState(""); const [boardError, setBoardError] = useState("");
useEffect(() => { void refreshPolicy(session); }, [refreshPolicy, session]); useEffect(() => { void refreshPolicy(session); }, [refreshPolicy, session]);
@@ -38,17 +40,22 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
: dungeonsByCategory("bite-sized")).filter((entry) => entry.available && dungeonEnabledForQueue(policy, entry)), : dungeonsByCategory("bite-sized")).filter((entry) => entry.available && dungeonEnabledForQueue(policy, entry)),
[pool, policy]); [pool, policy]);
const boardDungeonId = dungeonId || allEntries[0]?.id || ""; const boardDungeonId = dungeonId || allEntries[0]?.id || "";
const boardDungeon = leaderboardCatalog.dungeons.find((entry) => entry.id === boardDungeonId);
const bossOptions = boardDungeon?.difficulties.find((entry) => entry.id === boardDungeon.defaultDifficultyId)
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView, const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId } : {}) }); period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId,
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}) } : {}) });
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; } if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal }) void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
.then((value) => { setBoard(value); setBoardError(""); }) .then((value) => { setBoard(value); setBoardError(""); })
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null); .catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } }); setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
return () => controller.abort(); return () => controller.abort();
}, [boardDungeonId, boardScope, boardView, period]); }, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId]);
const specialization = specializationById(character?.specializationId); const specialization = specializationById(character?.specializationId);
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId) const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
: specialization ? [specialization.role] : []; : specialization ? [specialization.role] : [];
@@ -80,8 +87,8 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
<button className="front-button" type="button" aria-pressed={pool === "randomized"} disabled={busy} <button className="front-button" type="button" aria-pressed={pool === "randomized"} disabled={busy}
onClick={() => { setPool("randomized"); setDungeonId(""); }}>Randomized Bite-Sized</button> onClick={() => { setPool("randomized"); setDungeonId(""); }}>Randomized Bite-Sized</button>
</div> </div>
<label>Dungeon<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}> <label>{pool === "randomized" ? "Creature pool" : "Dungeon"}<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
<option value="">Random eligible dungeon</option> <option value="">{pool === "randomized" ? "Random available creature pool" : "Random eligible dungeon"}</option>
{allEntries.map((entry) => <option key={entry.id} value={entry.id}>{entry.title}</option>)} {allEntries.map((entry) => <option key={entry.id} value={entry.id}>{entry.title}</option>)}
</select></label> </select></label>
<div className="dungeon-race-screen__choices" role="group" aria-label="Your role"> <div className="dungeon-race-screen__choices" role="group" aria-label="Your role">
@@ -116,6 +123,10 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div> <button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button> {boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
<button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>} <button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>}
{boardScope === "dungeon" && bossOptions.length > 1 && <label>Boss<select value={selectedBoardBossId}
onChange={(event) => setBoardBossId(event.target.value)}>
{bossOptions.map((boss) => <option key={boss.id} value={boss.id}>{boss.name}</option>)}
</select></label>}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={period === "30d"} onClick={() => setPeriod("30d")}>Last 30 days</button> <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={period === "30d"} onClick={() => setPeriod("30d")}>Last 30 days</button>
<button className="front-button" type="button" aria-pressed={period === "all"} onClick={() => setPeriod("all")}>All time</button></div> <button className="front-button" type="button" aria-pressed={period === "all"} onClick={() => setPeriod("all")}>All time</button></div>
{boardError && <p role="status">{boardError}</p>} {boardError && <p role="status">{boardError}</p>}
+15 -2
View File
@@ -22,6 +22,7 @@ import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { DungeonLeaderboard } from "./DungeonLeaderboard"; import { DungeonLeaderboard } from "./DungeonLeaderboard";
import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog";
import { useDungeonLeaderboard } from "./useDungeonLeaderboard"; import { useDungeonLeaderboard } from "./useDungeonLeaderboard";
import type { DungeonClearFilter } from "../game/dungeonRunTypes"; import type { DungeonClearFilter } from "../game/dungeonRunTypes";
import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient"; import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient";
@@ -89,13 +90,23 @@ export function DungeonSelectScreen() {
const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final"); const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final");
const [difficultyId, setDifficultyId] = useState(""); const [difficultyId, setDifficultyId] = useState("");
const [bossDifficulty, setBossDifficulty] = useState(1); const [bossDifficulty, setBossDifficulty] = useState(1);
const [leaderboardBossId, setLeaderboardBossId] = useState("");
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set()); const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set());
const [leaderboardOpen, setLeaderboardOpen] = useState(false); const [leaderboardOpen, setLeaderboardOpen] = useState(false);
const [readinessOpen, setReadinessOpen] = useState(false); const [readinessOpen, setReadinessOpen] = useState(false);
const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId) const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId)
?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId); ?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId);
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter, bossDifficulty); const bossOptions = leaderboardCatalog.dungeons.find((entry) => entry.id === selectedDungeon?.id)?.bosses ?? [];
const hasBossBoards = (leaderboardCatalog.dungeons.find((entry) => entry.id === selectedDungeon?.id)?.difficulties
.find((entry) => entry.id === leaderboardDifficulty?.id)?.finalBossIds?.length ?? 0) > 1;
const selectedBoss = hasBossBoards ? bossOptions.find((boss) => boss.id === leaderboardBossId) ?? bossOptions[0] : undefined;
const cycleBoss = () => {
const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id);
if (hasBossBoards && bossOptions.length) setLeaderboardBossId(bossOptions[(index + 1) % bossOptions.length].id);
};
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal",
clearFilter, bossDifficulty, selectedBoss?.id);
const toggleRun = (id: string) => setExpandedRuns((current) => { const toggleRun = (id: string) => setExpandedRuns((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
}); });
@@ -213,6 +224,7 @@ export function DungeonSelectScreen() {
{ id: "leaderboard-final", run: () => setClearFilter("final") }, { id: "leaderboard-final", run: () => setClearFilter("final") },
{ id: "leaderboard-full", run: () => setClearFilter("full") }, { id: "leaderboard-full", run: () => setClearFilter("full") },
{ id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 }, { id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 },
{ id: "leaderboard-boss", run: cycleBoss, enabled: hasBossBoards },
{ id: "leaderboard-refresh", run: leaderboard.refresh }, { id: "leaderboard-refresh", run: leaderboard.refresh },
] : []), ] : []),
{ id: "boss-difficulty-down", run: () => setBossDifficulty((value) => Math.max(1, value - 1)), enabled: bossDifficulty > 1 }, { id: "boss-difficulty-down", run: () => setBossDifficulty((value) => Math.max(1, value - 1)), enabled: bossDifficulty > 1 },
@@ -239,7 +251,7 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false), run: () => setRolePickerOpen(false),
}] : []), }] : []),
{ id: "dungeon-back", run: returnToMainMenu }, { id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty]); ], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back", initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload onBack: pendingDownload
@@ -426,6 +438,7 @@ export function DungeonSelectScreen() {
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset} expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
onRefresh={leaderboard.refresh} difficultyLabel={`${bossDifficultyLabel(bossDifficulty)} · ${leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}`} onRefresh={leaderboard.refresh} difficultyLabel={`${bossDifficultyLabel(bossDifficulty)} · ${leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}`}
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined} onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
bossLabel={selectedBoss?.name} onBoss={hasBossBoards ? cycleBoss : undefined}
selectedId={controller.selectedId} select={controller.select} /> selectedId={controller.selectedId} select={controller.select} />
</div> </div>
)} )}
+18 -12
View File
@@ -8,12 +8,13 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
import { useContentManagerSnapshot } from "../content/useContentManager"; import { useContentManagerSnapshot } from "../content/useContentManager";
import { import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
dungeonContentPackId, dungeonContentPackId,
} from "../game/dungeonContentSource"; } from "../game/dungeonContentSource";
import { dungeonById, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog"; import { dungeonById } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy"; import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore"; import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry"; import { AVAILABLE_RANDOMIZED_CREATURE_CATALOG, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface"; import { BrandMark, FrontSurface } from "./FrontSurface";
import { DungeonDifficultySlider } from "./DungeonDifficultySlider"; import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
@@ -23,9 +24,6 @@ import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedC
installDungeonShellRuntime(); installDungeonShellRuntime();
const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
const CANDIDATES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) { export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) {
useContentManagerSnapshot(); useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
@@ -37,6 +35,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember); const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity); const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const [bossDifficulty, setBossDifficulty] = useState(1); const [bossDifficulty, setBossDifficulty] = useState(1);
const [pool, setPool] = useState<"bite-sized" | "all">("all");
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false); const [readinessOpen, setReadinessOpen] = useState(false);
@@ -48,7 +47,10 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const roles = character const roles = character
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : specialization ? [specialization.role] : [] ? character.categoryId === "rom" ? partyRolesForClass(character.classId) : specialization ? [specialization.role] : []
: []; : [];
const installed = dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); const dungeonId = pool === "all" ? RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID : RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID;
const DUNGEON = dungeonById(dungeonId);
const CANDIDATES = pool === "all" ? AVAILABLE_RANDOMIZED_CREATURE_CATALOG : BITE_SIZED_RANDOMIZED_CREATURE_CATALOG;
const installed = dungeonContentIsUsable(dungeonId);
const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length; const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length;
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length; const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON)); const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
@@ -56,8 +58,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const launch = async (role: PartyRole) => { const launch = async (role: PartyRole) => {
if (!character || !DUNGEON?.available) return; if (!character || !DUNGEON?.available) return;
const policy = await loadDungeonQueuePolicy(); const policy = await loadDungeonQueuePolicy();
const selectedDungeon = randomRandomizedBiteSizedDungeon(undefined, Math.random, const selectedDungeon = DUNGEON && dungeonEnabledForQueue(policy, DUNGEON) ? DUNGEON : null;
(candidate) => dungeonEnabledForQueue(policy, candidate));
if (!selectedDungeon) { if (!selectedDungeon) {
setError("No Randomized Bite-Sized location is enabled for this queue."); setError("No Randomized Bite-Sized location is enabled for this queue.");
return; return;
@@ -66,7 +67,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const runId = globalThis.crypto.randomUUID(); const runId = globalThis.crypto.randomUUID();
setDownloading(true); setError(""); setDownloading(true); setError("");
try { try {
await ensureRandomizedBiteSizedRunContent(runId); await ensureRandomizedBiteSizedRunContent(runId, undefined, selectedDungeon.id);
enterDungeon(selectedDungeon.id, role, { enterDungeon(selectedDungeon.id, role, {
difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId, difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId,
bossDifficulty, bossDifficulty,
@@ -99,8 +100,8 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const install = async () => { const install = async () => {
setDownloading(true); setError(""); setDownloading(true); setError("");
try { try {
await installContentPack(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)); await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)) { if (!dungeonContentIsUsable(dungeonId)) {
throw new Error("The Stockades content pack could not be verified."); throw new Error("The Stockades content pack could not be verified.");
} }
} catch (caught) { } catch (caught) {
@@ -123,7 +124,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
<section className="randomized-bite-sized-screen__hero"> <section className="randomized-bite-sized-screen__hero">
<p className="front-kicker">A different lineup every run</p> <p className="front-kicker">A different lineup every run</p>
<h1>Randomized Bite-Sized</h1> <h1>Randomized Bite-Sized</h1>
<p>The mode picks an eligible short layout—currently Cellblock Rush—deals a different cross-dungeon creature into every enemy slot, then rolls the boss separately.</p> <p>Cellblock Shuffle deals a different mob into every corridor slot and one final boss. Both come from the creature pool you choose.</p>
<div className="randomized-bite-sized-screen__odds"> <div className="randomized-bite-sized-screen__odds">
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div> <div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
<div><strong>{bossCount}</strong><span>possible final bosses</span></div> <div><strong>{bossCount}</strong><span>possible final bosses</span></div>
@@ -135,6 +136,11 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
</section> </section>
<section className="randomized-bite-sized-screen__launch"> <section className="randomized-bite-sized-screen__launch">
<p className="front-kicker">Run setup</p> <p className="front-kicker">Run setup</p>
<div className="randomized-bite-sized-screen__roles randomized-bite-sized-screen__pool" role="group" aria-label="Randomized creature pool">
<strong>Creature pool</strong>
<button className="front-button" type="button" aria-pressed={pool === "bite-sized"} disabled={downloading} onClick={() => setPool("bite-sized")}>Available Bite-Sized dungeon sources</button>
<button className="front-button" type="button" aria-pressed={pool === "all"} disabled={downloading} onClick={() => setPool("all")}>All available dungeon sources</button>
</div>
<h2>{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}</h2> <h2>{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}</h2>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} /> <DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
{!queueEnabled ? null : !installed ? ( {!queueEnabled ? null : !installed ? (
+8
View File
@@ -54,4 +54,12 @@ describe("dungeon results and leaderboard", () => {
expect(html).toContain(text); expect(html).toContain(text);
expect(html).toContain('aria-expanded="true"'); expect(html).toContain('aria-expanded="true"');
}); });
it("names the selected boss on a variable-boss dungeon board", () => {
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: biteSized.title,
data: { entries: [], total: 0 }, scope: "world", loading: false, filter: "final", onFilter: noop,
expanded: new Set<string>(), onExpand: noop, offset: 0, onPage: noop, onRefresh: noop,
difficultyLabel: "Normal", bossLabel: "Targorr the Dread", onBoss: noop, selectedId: "", select: noop }));
expect(html).toContain("Boss: Targorr the Dread");
expect(html).toContain("Change boss");
});
}); });
+6 -5
View File
@@ -3,13 +3,14 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
type LeaderboardScope } from "../app/dungeonLeaderboardClient"; type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes"; import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter, bossDifficulty = 1) { export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter,
bossDifficulty = 1, bossId?: string) {
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0); const [revision, setRevision] = useState(0);
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({ const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
data: { entries: [], total: 0 }, scope: "world", loading: true, data: { entries: [], total: 0 }, scope: "world", loading: true,
}); });
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty]); useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId]);
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []); useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
useEffect(() => { useEffect(() => {
const refresh = () => setRevision((value) => value + 1); const refresh = () => setRevision((value) => value + 1);
@@ -21,12 +22,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f
let live = true; let live = true;
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 8000);
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true }); setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty) if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId)
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); }) .then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty), .catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId),
scope: "device", loading: false }); }); scope: "device", loading: false }); });
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false }); else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
return () => { live = false; controller.abort(); clearTimeout(timeout); }; return () => { live = false; controller.abort(); clearTimeout(timeout); };
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty]); }, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId]);
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) }; return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
} }