From 11934a39790dcd4b65a068999a2e71e0ed9874a9 Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 26 Sep 2026 22:02:18 -0400 Subject: [PATCH] Update Healer Man server --- scripts/playtest-pause-memory.cjs | 78 + server/database.mjs | 2 +- server/dungeon-leaderboards.mjs | 60 +- server/dungeon-leaderboards.test.mjs | 48 +- server/dungeon-race.test.mjs | 73 +- server/dungeon-races.mjs | 47 +- server/dungeon-reports.mjs | 1 + server/server.mjs | 2 + src/app/OnlineGroupCoordinator.test.ts | 2 +- src/app/OnlineGroupCoordinator.tsx | 12 +- src/app/biteSizedDungeonRuntime.test.ts | 2 +- src/app/biteSizedDungeonRuntime.ts | 9 +- src/app/dungeonLeaderboardClient.test.ts | 17 + src/app/dungeonLeaderboardClient.ts | 27 +- src/app/dungeonRaceStore.ts | 2 +- src/app/randomizedBiteSizedContent.test.ts | 8 + src/app/randomizedBiteSizedContent.ts | 9 +- src/game/PartyBridge.tsx | 4 +- src/game/bossCardCatalog.ts | 5 + src/game/dungeonBossCards.ts | 3 +- src/game/dungeonCatalog.test.ts | 9 +- src/game/dungeonContentSource.test.ts | 3 + src/game/dungeonContentSource.ts | 17 +- src/game/dungeonLeaderboardCatalog.test.ts | 43 +- src/game/dungeonLeaderboardCatalog.ts | 15 + src/game/dungeonLeaderboardRules.ts | 5 + src/game/dungeonRegistry.ts | 70 +- .../dungeons/biteSizedRoMDungeons.test.ts | 25 +- src/game/dungeons/biteSizedRoMDungeons.ts | 60 +- src/game/dungeons/biteSizedStockades.ts | 12 +- .../dungeons/biteSizedWoWDungeons.test.ts | 52 + src/game/dungeons/biteSizedWoWDungeons.ts | 157 +- .../dungeons/randomizedBiteSizedStockades.ts | 66 +- src/game/dungeons/seededBossSelection.ts | 9 + src/game/dungeons/sourceBossCandidates.ts | 49 + .../dungeonLeaderboardCatalog.generated.json | 2369 ++++++++++++++++- src/game/randomizedBiteSizedStockades.test.ts | 48 +- src/styles.css | 1 + src/ui/DungeonLeaderboard.tsx | 6 +- src/ui/DungeonRaceScreen.tsx | 19 +- src/ui/DungeonSelectScreen.tsx | 17 +- src/ui/RandomizedBiteSizedScreen.tsx | 30 +- src/ui/dungeonRuns.test.ts | 8 + src/ui/useDungeonLeaderboard.ts | 11 +- 44 files changed, 3183 insertions(+), 329 deletions(-) create mode 100644 scripts/playtest-pause-memory.cjs create mode 100644 src/game/dungeonLeaderboardCatalog.ts create mode 100644 src/game/dungeons/biteSizedWoWDungeons.test.ts create mode 100644 src/game/dungeons/seededBossSelection.ts create mode 100644 src/game/dungeons/sourceBossCandidates.ts diff --git a/scripts/playtest-pause-memory.cjs b/scripts/playtest-pause-memory.cjs new file mode 100644 index 00000000..e6e223dd --- /dev/null +++ b/scripts/playtest-pause-memory.cjs @@ -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)); + } +})(); diff --git a/server/database.mjs b/server/database.mjs index ed6ceaac..d50d986e 100644 --- a/server/database.mjs +++ b/server/database.mjs @@ -872,7 +872,7 @@ export function openGameDatabase(options = {}) { } const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom"; const randomizedBiteSized = type === "dungeon" - && contentId === "randomized-bite-sized-stormwind-stockade"; + && contentId.startsWith("randomized-bite-sized-"); if (romGroup && randomizedBiteSized) { throw new GameDatabaseError("This WoW-only activity is unavailable to RoM groups.", 409, "group_source_incompatible"); } diff --git a/server/dungeon-leaderboards.mjs b/server/dungeon-leaderboards.mjs index 14438412..3c41b823 100644 --- a/server/dungeon-leaderboards.mjs +++ b/server/dungeon-leaderboards.mjs @@ -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 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 }) { database.exec(` 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")) { 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 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 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); 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, ...(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."); 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, COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster, 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 - FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ?`) - .get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0); + 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, + ...(variant ? [selectedBossId] : [])); return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.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 finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId]; 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."); const party = input.party.map((member) => { 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 })) 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, - run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty); - return { run, placement: placement(run) }; + run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty, + 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) { const row = byId.get(id); if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found"); const run = record(row); - return { run, placement: placement(run) }; + return { run, placement: placement(run, row.final_boss_id) }; }, listDungeonLeaderboard(input) { - const { dungeon, difficultyId, bossDifficulty } = selection(input); + const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input); const filter = input.clear || "final"; if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears."); 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 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page."); 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 - WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} - ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, bossDifficulty, limit, offset); + WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition} + ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(...args, limit, offset); 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) }; }, }; diff --git a/server/dungeon-leaderboards.test.mjs b/server/dungeon-leaderboards.test.mjs index e96bc144..30acdd67 100644 --- a/server/dungeon-leaderboards.test.mjs +++ b/server/dungeon-leaderboards.test.mjs @@ -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", offset: -1 }), { status: 400 }); })); -test("accepts any rolled Stockades boss as a full clear for both bite-sized modes", () => withDatabase((database) => { - for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade"]) { +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", + "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 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()) { const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id); 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 () => { 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.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); + 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: [] }) }); 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) }); @@ -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, 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); - 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, 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 }); assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1); 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 }); assert.equal(repository.getDungeonRun(legacy.id).run.durationMs, 5000); } finally { database.close(); } diff --git a/server/dungeon-race.test.mjs b/server/dungeon-race.test.mjs index 78581c75..88d5033c 100644 --- a/server/dungeon-race.test.mjs +++ b/server/dungeon-race.test.mjs @@ -5,14 +5,21 @@ import os from "node:os"; import path from "node:path"; import { openGameDatabase } from "./database.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 { createDungeonRaceRepository } from "./dungeon-races.mjs"; +import { DatabaseSync } from "node:sqlite"; 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 bossIds = [dungeon.difficulties[0].finalBossId]; -const bossIdsFor = (match) => [catalog.dungeons.find((entry) => entry.id === match.dungeonId) - .difficulties.find((entry) => entry.id === match.difficultyId).finalBossId]; +const bossIdsFor = (match) => { + const difficulty = catalog.dungeons.find((entry) => entry.id === match.dungeonId) + .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", () => { 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) { const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-")); 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"); advance(3_000); 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 }); assert.equal(first.result, "win"); 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 }); assert.equal(late.result, "loss"); 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); })); +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 }) => { const account = await player("race_solo"); service.queue(account, { characterId: "race_solo-character", role: "healer", pool: "authored" }); diff --git a/server/dungeon-races.mjs b/server/dungeon-races.mjs index b90d5036..96a31789 100644 --- a/server/dungeon-races.mjs +++ b/server/dungeon-races.mjs @@ -1,8 +1,15 @@ 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])); +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 }) { database.exec(` 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 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')") .run(Date.now()); 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 insertMatch = database.prepare(`INSERT INTO dungeon_race_matches - (id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json) - VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?)`); + (id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id) + VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?)`); const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants (match_id, side, account_id, character_id, username, source, role) VALUES (?, ?, ?, ?, ?, ?, ?)`); @@ -72,7 +90,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) { function createMatch(match) { transaction(() => { 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, 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"); const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId); 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)) - || !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"); return transaction(() => { 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); 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) || (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId))) + || (bossId !== null && (!bossIds || !bossIds.includes(bossId))) || !Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000) 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"); const since = period === "30d" ? timestamp - RACE_PERIOD_MS : 0; 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" ? ` 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 = 'loss' THEN 1 ELSE 0 END) AS losses 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 - 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` : `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 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`; const all = database.prepare(query).all(...args); let lastValue = null; @@ -154,7 +180,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) { ? { wins: Number(row.wins), losses: Number(row.losses) } : { 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)), start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun, diff --git a/server/dungeon-reports.mjs b/server/dungeon-reports.mjs index fd005f37..0f6d7520 100644 --- a/server/dungeon-reports.mjs +++ b/server/dungeon-reports.mjs @@ -5,6 +5,7 @@ import policy from "../shared/dungeon-report-policy.json" with { type: "json" }; export const REPORT_DUNGEON_IDS = new Set([ "bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade", + "randomized-bite-sized-stormwind-stockade-available", "forsaken-abbey", ...Object.keys(availability.enabled), ]); diff --git a/server/server.mjs b/server/server.mjs index 11691de4..82d51150 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -310,6 +310,7 @@ export function createGameServer(options = {}) { view: url.searchParams.get("view") ?? "wins", period: url.searchParams.get("period") ?? "30d", dungeonId: url.searchParams.get("dungeon") || null, + bossId: url.searchParams.get("boss") || null, limit: Number(url.searchParams.get("limit") ?? 20), offset: Number(url.searchParams.get("offset") ?? 0), })); @@ -342,6 +343,7 @@ export function createGameServer(options = {}) { if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") { json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"), 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, offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") })); return; diff --git a/src/app/OnlineGroupCoordinator.test.ts b/src/app/OnlineGroupCoordinator.test.ts index f867a947..9929c1db 100644 --- a/src/app/OnlineGroupCoordinator.test.ts +++ b/src/app/OnlineGroupCoordinator.test.ts @@ -64,7 +64,7 @@ describe("online group activity entry", () => { await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies); 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]) .toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]); }); diff --git a/src/app/OnlineGroupCoordinator.tsx b/src/app/OnlineGroupCoordinator.tsx index 2229f47d..57f1adbe 100644 --- a/src/app/OnlineGroupCoordinator.tsx +++ b/src/app/OnlineGroupCoordinator.tsx @@ -12,7 +12,7 @@ import { useContentManagerSnapshot } from "../content/useContentManager"; import { SHELVED_MODES_ENABLED } from "./releaseFeatures"; import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge"; 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"; const GROUP_POLL_MS = 2_500; @@ -20,7 +20,7 @@ const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000; interface OnlineGroupActivityEntryDependencies { readonly prepareDungeonContent: (dungeonId: string) => Promise; - readonly prepareRandomizedDungeonContent: (seed: string) => Promise; + readonly prepareRandomizedDungeonContent: (seed: string, dungeonId: string) => Promise; readonly installDungeonRuntime: () => Promise; readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean; readonly installManastormRuntime: () => Promise; @@ -33,9 +33,9 @@ const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies await installContentPack(dungeonContentPackId(dungeonId)); 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"); - await ensureRandomizedBiteSizedRunContent(seed); + await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId); }, installDungeonRuntime: async () => { const { installDungeonShellRuntime } = await import("./dungeonShellRuntime"); @@ -61,8 +61,8 @@ export async function enterOnlineGroupActivity( let entered: boolean; if (activity.type === "dungeon") { await dependencies.prepareDungeonContent(activity.contentId); - if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) { - await dependencies.prepareRandomizedDungeonContent(activity.id); + if (isRandomizedBiteSizedDungeonId(activity.contentId)) { + await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId); } await dependencies.installDungeonRuntime(); entered = dependencies.enterDungeon(activity.contentId, role, { diff --git a/src/app/biteSizedDungeonRuntime.test.ts b/src/app/biteSizedDungeonRuntime.test.ts index 103fdf45..40251f11 100644 --- a/src/app/biteSizedDungeonRuntime.test.ts +++ b/src/app/biteSizedDungeonRuntime.test.ts @@ -105,7 +105,7 @@ describe("bite-sized replay", () => { prepareRandomizedContent, })).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(enterDungeon).toHaveBeenCalledWith(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", { difficultyId: "normal", diff --git a/src/app/biteSizedDungeonRuntime.ts b/src/app/biteSizedDungeonRuntime.ts index 62bc6ca2..7ffeefca 100644 --- a/src/app/biteSizedDungeonRuntime.ts +++ b/src/app/biteSizedDungeonRuntime.ts @@ -23,7 +23,7 @@ interface BiteSizedReplayDependencies { readonly loadPolicy: () => Promise; readonly createRunId: () => string; readonly contentIsUsable: (dungeonId: string) => boolean; - readonly prepareRandomizedContent: (seed: string) => Promise; + readonly prepareRandomizedContent: (seed: string, dungeonId: string) => Promise; } const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = { @@ -31,7 +31,7 @@ const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = { loadPolicy: loadDungeonQueuePolicy, createRunId: () => globalThis.crypto.randomUUID(), 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. */ @@ -52,7 +52,8 @@ export async function startAnotherBiteSizedDungeon( const policy = await dependencies.loadPolicy(); const next = randomized ? 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, (dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon)); if (!next) { @@ -94,7 +95,7 @@ export async function startAnotherBiteSizedDungeon( const runId = dependencies.createRunId(); if (randomized) { try { - await dependencies.prepareRandomizedContent(runId); + await dependencies.prepareRandomizedContent(runId, next.id); } catch (caught) { shell.setNotice(caught instanceof Error ? caught.message : "The randomized creature download failed."); return false; diff --git a/src/app/dungeonLeaderboardClient.test.ts b/src/app/dungeonLeaderboardClient.test.ts index bde71eca..2c77447e 100644 --- a/src/app/dungeonLeaderboardClient.test.ts +++ b/src/app/dungeonLeaderboardClient.test.ts @@ -58,7 +58,24 @@ describe("offline dungeon leaderboard", () => { }; saveLocalDungeonRun(run); 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 })]); + 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 () => { const run = record("offline-retry-test", 4321); diff --git a/src/app/dungeonLeaderboardClient.ts b/src/app/dungeonLeaderboardClient.ts index eca86e81..58517fa8 100644 --- a/src/app/dungeonLeaderboardClient.ts +++ b/src/app/dungeonLeaderboardClient.ts @@ -1,9 +1,9 @@ import { requestOnlineJson } from "./onlineAccountClient"; 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, 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 listeners = new Set<() => void>(); @@ -27,6 +27,7 @@ function localRuns(): DungeonRunRecord[] { const finalBossIds = difficulty ? leaderboardFinalBossIds(difficulty) : []; if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon || !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 []; return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt, fullClear: isLeaderboardFullClear(difficulty, progress.killedBossIds) }]; @@ -43,9 +44,14 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void { } 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 && (entry.bossDifficulty ?? 0) === bossDifficulty + && (!selectedBossId || entry.killedBossIds.includes(selectedBossId)) && (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs || a.finishedAt - b.finishedAt || a.id.localeCompare(b.id)); 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 { - const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0); - const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0); + const difficulty = catalog.dungeons.find((entry) => entry.id === record.dungeonId)?.difficulties + .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 - && (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, finalTotal: final.total, fullTotal: full.total, fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null }; } export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string, - filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0): Promise { + filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0, + bossId?: string): Promise { const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter, 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 }); } diff --git a/src/app/dungeonRaceStore.ts b/src/app/dungeonRaceStore.ts index 2866bca8..20ee0b60 100644 --- a/src/app/dungeonRaceStore.ts +++ b/src/app/dungeonRaceStore.ts @@ -85,7 +85,7 @@ function choosePracticeDungeon(pool: RacePool): string | null { return entries.filter((entry) => entry.available)[0]?.id ?? null; } async function prepareContent(dungeonId: string, seed: string): Promise { - 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)); if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified."); } diff --git a/src/app/randomizedBiteSizedContent.test.ts b/src/app/randomizedBiteSizedContent.test.ts index 82de9836..6a8e18a1 100644 --- a/src/app/randomizedBiteSizedContent.test.ts +++ b/src/app/randomizedBiteSizedContent.test.ts @@ -3,6 +3,7 @@ import { ensureRandomizedBiteSizedRunContent, randomizedBiteSizedModelUrls, } from "./randomizedBiteSizedContent"; +import { RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID } from "../game/dungeonContentSource"; describe("randomized bite-sized run content", () => { 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); }); + 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 () => { const installPack = vi.fn(async () => undefined); const installUrls = vi.fn(async () => undefined); diff --git a/src/app/randomizedBiteSizedContent.ts b/src/app/randomizedBiteSizedContent.ts index 5ac40ad7..990bcd3f 100644 --- a/src/app/randomizedBiteSizedContent.ts +++ b/src/app/randomizedBiteSizedContent.ts @@ -10,8 +10,8 @@ import { } from "../game/dungeonContentSource"; import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry"; -export function randomizedBiteSizedModelUrls(seed: string): readonly string[] { - const definition = randomizedBiteSizedDefinitionForSeed(seed); +export function randomizedBiteSizedModelUrls(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): readonly string[] { + const definition = randomizedBiteSizedDefinitionForSeed(seed, dungeonId); return [...new Set(Object.values(definition.entities).flatMap((entity) => ( entity.visual.model?.url ? [entity.visual.model.url] : [] )))].sort(); @@ -37,11 +37,12 @@ const DEFAULT_DEPENDENCIES: RandomizedContentDependencies = { export async function ensureRandomizedBiteSizedRunContent( seed: string, dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES, + dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, ): Promise { if (dependencies.development) return; - const basePackId = dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); + const basePackId = dungeonContentPackId(dungeonId); if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) { await dependencies.installPack(basePackId); } - await dependencies.installUrls(randomizedBiteSizedModelUrls(seed)); + await dependencies.installUrls(randomizedBiteSizedModelUrls(seed, dungeonId)); } diff --git a/src/game/PartyBridge.tsx b/src/game/PartyBridge.tsx index be3c8855..8765fab5 100644 --- a/src/game/PartyBridge.tsx +++ b/src/game/PartyBridge.tsx @@ -10,7 +10,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore"; import { useCompanionPartyPreferences } from "./companionPartyPreferences"; 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. */ export function PartyBridge() { @@ -46,7 +46,7 @@ export function PartyBridge() { ? onlineGroup.members : []; const randomizedReadyOnly = onlineActivity?.type === "dungeon" - && onlineActivity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID; + && isRandomizedBiteSizedDungeonId(onlineActivity.contentId); const companionOptions = { readinessPolicy, readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision, diff --git a/src/game/bossCardCatalog.ts b/src/game/bossCardCatalog.ts index fd13898a..aff180ed 100644 --- a/src/game/bossCardCatalog.ts +++ b/src/game/bossCardCatalog.ts @@ -47,6 +47,11 @@ export function bossCardByKey(key: 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( (source) => source.dungeonId === dungeonId && source.entityId === sourceId, )) ?? BOSS_CARD_CATALOG.find((card) => card.sources.some((source) => source.entityId === sourceId)) diff --git a/src/game/dungeonBossCards.ts b/src/game/dungeonBossCards.ts index 74934bef..71c9d365 100644 --- a/src/game/dungeonBossCards.ts +++ b/src/game/dungeonBossCards.ts @@ -20,7 +20,8 @@ export function dungeonBossCards( ): readonly DungeonBossCard[] { const seen = new Set(); 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 fallbackKey = `${category}:${normalizedName}`; const sourceCard = bossCardForSource(boss.id, dungeon.id, boss.name); diff --git a/src/game/dungeonCatalog.test.ts b/src/game/dungeonCatalog.test.ts index 964491cf..dd2fa227 100644 --- a/src/game/dungeonCatalog.test.ts +++ b/src/game/dungeonCatalog.test.ts @@ -17,7 +17,7 @@ import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated. describe("dungeon catalog", () => { 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({ id: "stormwind-stockade", releaseStage: "beta", @@ -79,9 +79,9 @@ describe("dungeon catalog", () => { it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => { 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) - .toBe("randomized-bite-sized-stormwind-stockade"); + .toBe("randomized-bite-sized-stormwind-stockade-available"); }); it("keeps stable unique ids and resolves only catalogued dungeons", () => { @@ -111,6 +111,7 @@ describe("dungeon catalog", () => { "bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "randomized-bite-sized-stormwind-stockade", + "randomized-bite-sized-stormwind-stockade-available", "stormwind-stockade", "bite-sized-stormwind-stockade", "bite-sized-deadmines-ship", @@ -142,7 +143,7 @@ describe("dungeon catalog", () => { .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-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()); }); }); diff --git a/src/game/dungeonContentSource.test.ts b/src/game/dungeonContentSource.test.ts index eeeb6413..d54b8691 100644 --- a/src/game/dungeonContentSource.test.ts +++ b/src/game/dungeonContentSource.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID, dungeonContentPackId, dungeonContentSourceId, @@ -13,6 +14,8 @@ describe("derived dungeon content sources", () => { 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(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"); }); }); diff --git a/src/game/dungeonContentSource.ts b/src/game/dungeonContentSource.ts index 7f1595ce..44b28c8a 100644 --- a/src/game/dungeonContentSource.ts +++ b/src/game/dungeonContentSource.ts @@ -1,6 +1,7 @@ export const STORMWIND_STOCKADE_DUNGEON_ID = "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_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_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship"; 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 = new Set([ 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_DEADMINES_DUNGEON_ID, BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID, @@ -32,6 +34,7 @@ export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean { const DUNGEON_CONTENT_SOURCE_IDS: Readonly> = Object.freeze({ [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_DEADMINES_DUNGEON_ID]: "deadmines", [BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle", @@ -42,7 +45,17 @@ const DUNGEON_CONTENT_SOURCE_IDS: Readonly> = Object.free export function isDerivedStockadesDungeonId(dungeonId: string): boolean { 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. */ @@ -51,7 +64,7 @@ export function dungeonContentSourceId(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 `dungeon:${dungeonContentSourceId(dungeonId)}`; diff --git a/src/game/dungeonLeaderboardCatalog.test.ts b/src/game/dungeonLeaderboardCatalog.test.ts index 6b95146a..623d1377 100644 --- a/src/game/dungeonLeaderboardCatalog.test.ts +++ b/src/game/dungeonLeaderboardCatalog.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { CLASSES } from "../app/characterCatalog"; import { DUNGEON_DEFINITIONS } from "./dungeonRegistry"; 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 { 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 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", () => { it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => { const catalog = { - dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title, - finalBossId: isDerivedStockadesDungeonId(dungeon.id) - ? biteSizedBosses[0].id + dungeons: DUNGEON_DEFINITIONS.map((dungeon) => { + const randomizedCandidates = dungeon.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_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 ?? "", - bosses: (isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses : dungeon.bosses) - .map((boss) => ({ id: boss.id, name: boss.name })), + bosses: candidates.map((boss) => ({ id: boss.id, name: boss.name })), difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty, - bossIds: isDerivedStockadesDungeonId(dungeon.id) - ? biteSizedBosses.map((boss) => boss.id) + bossIds: isDerivedRandomBossDungeonId(dungeon.id) + ? candidates.map((boss) => boss.id) : dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id), - finalBossId: isDerivedStockadesDungeonId(dungeon.id) - ? biteSizedBosses[0].id + finalBossId: isDerivedRandomBossDungeonId(dungeon.id) + ? candidates[0].id : finalDungeonBoss(dungeon, difficulty.id)?.id ?? "", - ...(isDerivedStockadesDungeonId(dungeon.id) - ? { finalBossIds: biteSizedBosses.map((boss) => boss.id) } + ...(isDerivedRandomBossDungeonId(dungeon.id) + ? { finalBossIds: candidates.map((boss) => boss.id) } : {}), })), 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 })), }; - expect(catalog.dungeons).toHaveLength(92); + expect(catalog.dungeons).toHaveLength(93); expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]); const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json"; if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`); diff --git a/src/game/dungeonLeaderboardCatalog.ts b/src/game/dungeonLeaderboardCatalog.ts new file mode 100644 index 00000000..7b34b3a6 --- /dev/null +++ b/src/game/dungeonLeaderboardCatalog.ts @@ -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[] }; diff --git a/src/game/dungeonLeaderboardRules.ts b/src/game/dungeonLeaderboardRules.ts index 59130ff1..adeb3ad5 100644 --- a/src/game/dungeonLeaderboardRules.ts +++ b/src/game/dungeonLeaderboardRules.ts @@ -8,6 +8,11 @@ export function leaderboardFinalBossIds(rule: DungeonLeaderboardDifficultyRule): 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( rule: DungeonLeaderboardDifficultyRule, killedBossIds: readonly string[], diff --git a/src/game/dungeonRegistry.ts b/src/game/dungeonRegistry.ts index 1f0c6d65..2e1fc314 100644 --- a/src/game/dungeonRegistry.ts +++ b/src/game/dungeonRegistry.ts @@ -23,9 +23,17 @@ import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCat import { withWowDungeonEnvironment } from "./wowDungeonEnvironment"; import { 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_AVAILABLE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID, dungeonContentSourceId, + isRandomizedBiteSizedDungeonId, } from "./dungeonContentSource"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; import { @@ -224,31 +232,61 @@ export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze( 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>)[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( stockadesDefinition, 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 randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template"); -const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition); -const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition); -const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition); +const randomizedBiteSizedAvailableTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID); +const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition, "catalog-template"); +const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition, "catalog-template"); +const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, "catalog-template"); const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"]; const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"]; const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"]; if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition) { throw new Error("The three RoM source dungeons are required for their bite-sized variants."); } -const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition); -const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition); -const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition); +const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, "catalog-template"); +const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template"); +const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, "catalog-template"); const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [ ...adaptedSourceEntries, [biteSizedStockadesTemplate.id, biteSizedStockadesTemplate], [randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate], + [randomizedBiteSizedAvailableTemplate.id, randomizedBiteSizedAvailableTemplate], [biteSizedWailingCaverns.id, biteSizedWailingCaverns], [biteSizedDeadmines.id, biteSizedDeadmines], [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. */ export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null { - if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID && id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) { - return dungeonDefinitionById(id); - } - const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID - ? createBiteSizedStockadesDefinition(stockadesDefinition, seed) - : randomizedBiteSizedDefinitionForSeed(seed); + let definition: DungeonDefinition; + if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed); + else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id); + else if (id === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID) definition = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, seed); + else if (id === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID) definition = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, 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); return definition; } diff --git a/src/game/dungeons/biteSizedRoMDungeons.test.ts b/src/game/dungeons/biteSizedRoMDungeons.test.ts index 3766931e..9bfcc77c 100644 --- a/src/game/dungeons/biteSizedRoMDungeons.test.ts +++ b/src/game/dungeons/biteSizedRoMDungeons.test.ts @@ -4,7 +4,7 @@ import { NodeIO } from "@gltf-transform/core"; import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three"; import { describe, expect, it } from "vitest"; import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource"; -import { DUNGEON_REGISTRY } from "../dungeonRegistry"; +import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry"; import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime"; import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation"; @@ -15,6 +15,27 @@ const sliceIds = [ ] as const; 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(); + 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) => { const slice = DUNGEON_REGISTRY[id]; const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)]; @@ -45,6 +66,8 @@ describe("RoM bite-sized finales", () => { expect(completed.phase).toBe("complete"); // The complete source remains available with its original population and objectives. 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) => { diff --git a/src/game/dungeons/biteSizedRoMDungeons.ts b/src/game/dungeons/biteSizedRoMDungeons.ts index 9c077551..e4dac2ea 100644 --- a/src/game/dungeons/biteSizedRoMDungeons.ts +++ b/src/game/dungeons/biteSizedRoMDungeons.ts @@ -10,6 +10,7 @@ import { BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID, } from "../dungeonContentSource"; +import { seededBossIndex } from "./seededBossSelection"; interface RoMSlice { readonly sourceId: string; @@ -19,7 +20,7 @@ interface RoMSlice { readonly entranceName: string; readonly start: Vector3Tuple; readonly forward: Vector3Tuple; - readonly bossName: string; + readonly finaleBossName: string; readonly mobSpawnIds: readonly string[]; readonly summary: string; readonly adaptation: string; @@ -29,65 +30,71 @@ const ROM_SLICES: readonly RoMSlice[] = [ { sourceId: "forsaken-abbey", id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, - title: "Forsaken Abbey: Ancalon's Approach", + title: "Forsaken Abbey: Final Passage", location: "Forsaken Abbey · Ancalon's Passage", entranceName: "Ancalon's Passage", // The omitted source bat occupied this floor-projected point before the last packs. start: [330.022888, -19.847076, -34.424903], forward: [1, 0, 0], - bossName: "Demon Witch Ancalon", + finaleBossName: "Demon Witch Ancalon", mobSpawnIds: [ "rw-fa-spawn-7855", "rw-fa-spawn-7856", "rw-fa-spawn-12074", "rw-fa-spawn-12081", "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.", }, { sourceId: "paspers-shrine", id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID, - title: "Paspers' Shrine: Hafiz's Hall", + title: "Paspers' Shrine: Final Hall", location: "Paspers' Shrine · Hafiz's Hall", entranceName: "Hafiz's Hall", // The omitted source axe soldier occupied this projected point before the final corridor. start: [95.618457, 0.348065, -285.473267], forward: [1, 0, 0], - bossName: "Blackhorn Hafiz", + finaleBossName: "Blackhorn Hafiz", mobSpawnIds: [ "rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709", "rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712", "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.", }, { sourceId: "mystic-altar", id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, - title: "Mystic Altar: Golem's Antechamber", + title: "Mystic Altar: Final Antechamber", location: "Mystic Altar · Golem's Antechamber", entranceName: "Golem's Antechamber", start: [-392.475647, -17.665024, 329], forward: [-1, 0, 0], - bossName: "Cursed lron Golem", + finaleBossName: "Cursed lron Golem", mobSpawnIds: [ "rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465", "rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245", "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.", }, ]; -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}.`); - const boss = source.objectiveOrder.find((objective) => objective.name === slice.bossName); - const bossSpawn = boss && source.staticSpawns.find((spawn) => spawn.id === boss.id); - if (!boss || !bossSpawn || source.entities[bossSpawn.entityId]?.kind !== "boss") { - throw new Error(`${slice.sourceId} is missing ${slice.bossName}.`); + const finale = source.objectiveOrder.find((objective) => objective.name === slice.finaleBossName); + if (!finale || source.bosses.length === 0) { + throw new Error(`${slice.sourceId} is missing ${slice.finaleBossName} or its boss list.`); } + 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 spawn = source.staticSpawns.find((candidate) => candidate.id === id); 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", files: ["src/game/dungeons/biteSizedRoMDungeons.ts"], 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."], }, 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 entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id))); const areas = [ { 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 { @@ -153,7 +160,10 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi yaw: Math.atan2(slice.forward[0], slice.forward[2]) }, presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon", 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, entities, staticSpawns: mobSpawns, @@ -169,14 +179,14 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefi }; } -export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition): DungeonDefinition { - return createRoMSlice(source, ROM_SLICES[0]); +export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition, seed: string): DungeonDefinition { + return createRoMSlice(source, ROM_SLICES[0], seed); } -export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition): DungeonDefinition { - return createRoMSlice(source, ROM_SLICES[1]); +export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition, seed: string): DungeonDefinition { + return createRoMSlice(source, ROM_SLICES[1], seed); } -export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition): DungeonDefinition { - return createRoMSlice(source, ROM_SLICES[2]); +export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition { + return createRoMSlice(source, ROM_SLICES[2], seed); } diff --git a/src/game/dungeons/biteSizedStockades.ts b/src/game/dungeons/biteSizedStockades.ts index dfbbfe45..2de3b0f8 100644 --- a/src/game/dungeons/biteSizedStockades.ts +++ b/src/game/dungeons/biteSizedStockades.ts @@ -5,6 +5,7 @@ import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID, } from "../dungeonContentSource"; +import { seededBossIndex } from "./seededBossSelection"; const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const; 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" }, ]); -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 { - 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 { diff --git a/src/game/dungeons/biteSizedWoWDungeons.test.ts b/src/game/dungeons/biteSizedWoWDungeons.test.ts new file mode 100644 index 00000000..5d49bf33 --- /dev/null +++ b/src/game/dungeons/biteSizedWoWDungeons.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/game/dungeons/biteSizedWoWDungeons.ts b/src/game/dungeons/biteSizedWoWDungeons.ts index 2273414e..c3843d0b 100644 --- a/src/game/dungeons/biteSizedWoWDungeons.ts +++ b/src/game/dungeons/biteSizedWoWDungeons.ts @@ -18,6 +18,7 @@ import { BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID, BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID, } from "../dungeonContentSource"; +import { finaleBossSpawn, seededSourceBoss } from "./sourceBossCandidates"; const DEADMINES_ROUTE_WIDTH = 34; 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))); } -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( source: DungeonDefinition, 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."); 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]; // These points sit on the shipped Wailing Caverns navigation surface in Verdan's chamber. const staticSpawns: readonly StaticMobSpawnDefinition[] = [ @@ -133,7 +112,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`; const verdanGroup: DungeonEncounterSpawnGroupDefinition = { id: verdanGroupId, - spawns: [verdan.spawn], + spawns: [bossSpawn], }; const encounter: DungeonEncounterDefinition = { id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`, @@ -145,11 +124,11 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio fidelity: "healer-man-authored", scope: "boss-progression", 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: [ "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.", - "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."], }, @@ -165,18 +144,19 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio }, { 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"] }], - 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 { ...source, id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID, - title: "Wailing Caverns: Verdan’s Lair", + title: "Wailing Caverns: Everliving Chamber", lfgDungeonIds: [], entrance: { ...source.entrance, @@ -189,7 +169,7 @@ export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinitio ...source.presentation, location: "Wailing Caverns · Verdan’s Chamber", 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...", 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."); - const smite = bossByName(source, "Mr. Smite"); const captain = bossByName(source, "Captain Greenskin"); 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[] = [ startPosition, - [-807, 30, -42], - captain.objective.position, finalBoss.objective.position, ]; - const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH); - const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH); - const vanCleefSpawn: StaticMobSpawnDefinition = { - ...finalBoss.spawn, - position: finalBoss.objective.position, - }; + const routeTrash = ["creature-79334", "creature-79335", "creature-79369", "creature-79370"].map((id) => { + const spawn = source.staticSpawns.find((entry) => entry.id === id); + if (!spawn || source.entities[spawn.entityId]?.kind !== "mob") { + throw new Error(`The Deadmines final deck is missing source mob ${id}.`); + } + return spawn; + }); + const bossSpawn = finaleBossSpawn(source, selected, finalBoss.objective.position); const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`; const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = { id: vanCleefGroupId, - spawns: [vanCleefSpawn], + spawns: [bossSpawn], }; const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`; const encounter: DungeonEncounterDefinition = { id: encounterId, dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID, - initialPhase: "greenskin", + initialPhase: "ship-mobs", initialState: { flags: { "dungeon-complete": false } }, - resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" }, + resetPolicy: { bossWipePhase: "ship-mobs", checkpointPhase: "ship-mobs" }, source: { fidelity: "healer-man-authored", scope: "boss-progression", 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: [ - "The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.", - "VanCleef is held in an encounter spawn group until Greenskin is defeated.", - "Source boss IDs are retained for objective records and loot lookup.", + "The mine, foundry, lower ship decks, and Cookie branch are omitted from this run.", + "One boss from the complete Deadmines boss list appears in the cabin after the retained crew die.", + "The selected source boss ID is retained for objective records and loot lookup.", ], remainingGaps: [ - "The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.", - "Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.", + "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 short slice.", ], }, rules: [ { - id: "ship-greenskin-defeated", - trigger: { kind: "entities-dead", entityIds: [captain.objective.id] }, - guards: [{ kind: "phase", phases: ["greenskin"] }], + id: "ship-crew-defeated", + trigger: { kind: "entities-dead", entityIds: routeTrash.map((spawn) => spawn.id) }, + guards: [{ kind: "phase", phases: ["ship-mobs"] }], 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-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" }, + { id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "boss" }, ], }, { id: "ship-vancleef-defeated", - trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] }, - guards: [{ kind: "phase", phases: ["van-cleef"] }], - actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id), + trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] }, + guards: [{ kind: "phase", phases: ["boss"] }], + actions: runCompletionActions("ship-complete-vancleef", selected.objective.id), }, ], }; const encounterGroups = [vanCleefGroup]; - const objectives: readonly DungeonBossObjective[] = [ - captain.objective, - { ...finalBoss.objective, eventOnly: true }, - ]; + const objective: DungeonBossObjective = { ...selected.objective, position: finalBoss.objective.position, + optional: false, eventOnly: true }; return { ...source, id: BITE_SIZED_DEADMINES_DUNGEON_ID, @@ -289,29 +265,28 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D lfgDungeonIds: [], entrance: { ...source.entrance, - name: "Mr. Smite’s Deck", + name: "Greenskin’s Deck", footPosition: startPosition, - forward: [-0.5, 0, -0.866], - yaw: -2.618, + forward: [0, 0, -1], + yaw: Math.PI, }, presentation: { ...source.presentation, location: "The Deadmines · The Defias Ship", 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...", unchartedAreaName: "Defias Ship", }, areas: [ - routeArea("entrance", "Mr. Smite’s Deck", startPosition, 25), - routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32), + routeArea("entrance", "Greenskin’s Deck", startPosition, 32), routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28), ], - entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]), - staticSpawns: [...routeTrash, captain.spawn], - roamingPacks, - bosses: objectives, - objectiveOrder: objectives, + entities: entitiesFor(source, routeTrash, [], encounterGroups), + staticSpawns: routeTrash, + roamingPacks: [], + bosses: [objective], + objectiveOrder: [objective], finalBossDurability: "standard", encounters: [encounter], encounterSpawnGroups: encounterGroups, @@ -320,12 +295,12 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D )), validation: appendPrototypeWarning( 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."); const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless"); 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]) { 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. const startPosition: Vector3Tuple = [-511, 104.8, 370]; const bossPosition = firstWave.spawns[0].position; @@ -341,14 +317,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti firstWave.spawns[6], firstWave.spawns[5], ]; - const skadiSpawn: StaticMobSpawnDefinition = { - ...sourceSkadiGroup.spawns[0], - id: skadi.id, - position: bossPosition, - }; + const bossSpawn = finaleBossSpawn(source, selected, bossPosition); const skadiGroup: DungeonEncounterSpawnGroupDefinition = { ...sourceSkadiGroup, - spawns: [skadiSpawn], + spawns: [bossSpawn], }; const encounter: DungeonEncounterDefinition = { id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`, @@ -360,10 +332,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti fidelity: "healer-man-authored", scope: "boss-progression", 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: [ "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."], }, @@ -379,18 +351,19 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti }, { id: "skadi-room-boss-defeated", - trigger: { kind: "entities-dead", entityIds: [skadiSpawn.id] }, + trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] }, 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 { ...source, id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID, - title: "Utgarde Pinnacle: Skadi’s Gauntlet", + title: "Utgarde Pinnacle: Eagle’s Eye Gauntlet", lfgDungeonIds: [], entrance: { ...source.entrance, @@ -403,7 +376,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti ...source.presentation, location: "Utgarde Pinnacle · Eagle’s Eye", 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...", unchartedAreaName: "Eagle’s Eye", }, diff --git a/src/game/dungeons/randomizedBiteSizedStockades.ts b/src/game/dungeons/randomizedBiteSizedStockades.ts index 974889f8..47032172 100644 --- a/src/game/dungeons/randomizedBiteSizedStockades.ts +++ b/src/game/dungeons/randomizedBiteSizedStockades.ts @@ -2,6 +2,7 @@ import type { DungeonDefinition } from "../dungeonTypes"; import type { PopulationEntityDefinition } from "../mobPopulation"; import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource"; import { createBiteSizedStockadesDefinition } from "./biteSizedStockades"; +import { seededBossIndex } from "./seededBossSelection"; export type RandomizedCreatureKind = "mob" | "boss"; @@ -114,11 +115,7 @@ function dealMobs( } function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition { - // Boss source ids remain intact so their existing loot table and collection - // card can still resolve. Trash ids are namespaced away from the location. - const id = candidate.kind === "boss" - ? candidate.entityId - : `randomized:${candidate.key}`; + const id = `randomized:${candidate.key}`; const combat = candidate.definition.combat ? { ...candidate.definition.combat, // 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 - * cross-dungeon creature to every corridor slot and rolls the boss separately. + * Keeps the reviewed Stockades geometry and navigation, then deals creatures + * and a boss from the selected cross-dungeon pool. */ export function createRandomizedBiteSizedStockadesDefinition( source: DungeonDefinition, seed: string, catalog: readonly RandomizedBiteSizedCreature[], + dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, ): DungeonDefinition { const base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`); const bossSpawnId = base.bosses[0]?.id; 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 mobPool = catalog.filter((candidate) => candidate.kind === "mob"); if (mobPool.length < trashSpawns.length) { throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`); } const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`); - const originalBossSpawn = source.staticSpawns.find((spawn) => spawn.id === bossSpawnId); - const originalBoss = originalBossSpawn ? source.entities[originalBossSpawn.entityId] : undefined; - const originalBossKey = originalBoss ? creatureKey(source, originalBoss) : null; - const bossCandidate = seededShuffle( - catalog.filter((candidate) => candidate.kind === "boss"), - `${seed}:boss`, - ).find((candidate) => candidate.key !== originalBossKey); - if (!bossCandidate) throw new Error("Randomized Bite-Sized Stockades has no boss candidates."); - + const bossPool = catalog.filter((candidate) => candidate.kind === "boss"); + if (!bossPool.length) throw new Error("Randomized Bite-Sized Stockades needs a boss candidate."); + const bossCandidate = bossPool[seededBossIndex(`${seed}:boss`, bossPool.length)]; + const selectedBoss = { ...runtimeEntity(bossCandidate), id: bossCandidate.entityId }; + const selectedBossId = `randomized:${bossCandidate.key}`; const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]])); - const bossEntity = runtimeEntity(bossCandidate); const randomizedSpawns = base.staticSpawns.map((spawn) => { 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 entities = Object.fromEntries(selectedCandidates.map((candidate) => { + const randomizedEntities = Object.fromEntries([...selectedMobs.values()].map((candidate) => { const entity = runtimeEntity(candidate); return [entity.id, entity]; })) as Readonly>; - 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 { ...base, - id: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, - title: "Randomized Bite-Sized: Cellblock Shuffle", + id: dungeonId, + title: dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID + ? "Randomized Bite-Sized: All Available" + : "Randomized Bite-Sized: Bite-Sized Sources", presentation: { ...base.presentation, location: "Stormwind Stockade · Shifting Cellblock", 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...", }, areas: base.areas.map((area) => area.id === "bite-sized-boss-room" - ? { ...area, name: `${bossCandidate.name}'s Cell` } + ? { ...area, name: `${boss.name}'s Cell` } : area), entities, staticSpawns: randomizedSpawns, @@ -189,16 +187,26 @@ export function createRandomizedBiteSizedStockadesDefinition( objectiveOrder: [boss], encounters: (base.encounters ?? []).map((encounter) => ({ ...encounter, - id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`, - dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + id: `${dungeonId}:boss-progression`, + 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 ? { ...encounter.source, 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: [ ...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 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.", ], } : encounter.source, diff --git a/src/game/dungeons/seededBossSelection.ts b/src/game/dungeons/seededBossSelection.ts new file mode 100644 index 00000000..649b962d --- /dev/null +++ b/src/game/dungeons/seededBossSelection.ts @@ -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; +} diff --git a/src/game/dungeons/sourceBossCandidates.ts b/src/game/dungeons/sourceBossCandidates.ts new file mode 100644 index 00000000..eadaeb59 --- /dev/null +++ b/src/game/dungeons/sourceBossCandidates.ts @@ -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) }; +} diff --git a/src/game/generated/dungeonLeaderboardCatalog.generated.json b/src/game/generated/dungeonLeaderboardCatalog.generated.json index 4711307d..39b3f5ea 100644 --- a/src/game/generated/dungeonLeaderboardCatalog.generated.json +++ b/src/game/generated/dungeonLeaderboardCatalog.generated.json @@ -219,8 +219,28 @@ { "id": "bite-sized-deadmines-ship", "title": "The Deadmines: Take the Ship", - "finalBossId": "creature-79336", + "finalBossId": "creature-79168", "bosses": [ + { + "id": "creature-79168", + "name": "Rhahk'zor" + }, + { + "id": "creature-79223", + "name": "Sneed" + }, + { + "id": "creature-79206", + "name": "Gilnid" + }, + { + "id": "creature-79337", + "name": "Mr. Smite" + }, + { + "id": "creature-79344", + "name": "Cookie" + }, { "id": "creature-79333", "name": "Captain Greenskin" @@ -235,19 +255,61 @@ "id": "normal", "name": "normal", "bossIds": [ + "creature-79168", + "creature-79223", + "creature-79206", + "creature-79337", + "creature-79344", "creature-79333", "creature-79336" ], - "finalBossId": "creature-79336" + "finalBossId": "creature-79168", + "finalBossIds": [ + "creature-79168", + "creature-79223", + "creature-79206", + "creature-79337", + "creature-79344", + "creature-79333", + "creature-79336" + ] } ], "defaultDifficultyId": "normal" }, { "id": "bite-sized-forsaken-abbey-ancalon", - "title": "Forsaken Abbey: Ancalon's Approach", - "finalBossId": "rw-fa-spawn-7854", + "title": "Forsaken Abbey: Final Passage", + "finalBossId": "rw-fa-spawn-7384", "bosses": [ + { + "id": "rw-fa-spawn-7384", + "name": "Necromage Keeper 1" + }, + { + "id": "rw-fa-spawn-7426", + "name": "Necromage Keeper 2" + }, + { + "id": "rw-fa-spawn-7379", + "name": "Necromage Keeper 3" + }, + { + "id": "rw-fa-spawn-7425", + "name": "Necromage Keeper 4" + }, + { + "id": "rw-fa-spawn-7472", + "name": "Flowing Chaos" + }, + { + "id": "rw-fa-spawn-7755", + "name": "Cruel Eater" + }, + { + "id": "rw-fa-spawn-7844", + "name": "Hollow Shell" + }, { "id": "rw-fa-spawn-7854", "name": "Demon Witch Ancalon" @@ -258,18 +320,43 @@ "id": "normal", "name": "normal", "bossIds": [ + "rw-fa-spawn-7384", + "rw-fa-spawn-7426", + "rw-fa-spawn-7379", + "rw-fa-spawn-7425", + "rw-fa-spawn-7472", + "rw-fa-spawn-7755", + "rw-fa-spawn-7844", "rw-fa-spawn-7854" ], - "finalBossId": "rw-fa-spawn-7854" + "finalBossId": "rw-fa-spawn-7384", + "finalBossIds": [ + "rw-fa-spawn-7384", + "rw-fa-spawn-7426", + "rw-fa-spawn-7379", + "rw-fa-spawn-7425", + "rw-fa-spawn-7472", + "rw-fa-spawn-7755", + "rw-fa-spawn-7844", + "rw-fa-spawn-7854" + ] } ], "defaultDifficultyId": "normal" }, { "id": "bite-sized-mystic-altar-golem", - "title": "Mystic Altar: Golem's Antechamber", - "finalBossId": "rw-mystic-altar-spawn-13506", + "title": "Mystic Altar: Final Antechamber", + "finalBossId": "rw-mystic-altar-spawn-11831", "bosses": [ + { + "id": "rw-mystic-altar-spawn-11831", + "name": "Razeela" + }, + { + "id": "rw-mystic-altar-spawn-11448", + "name": "Dorlos" + }, { "id": "rw-mystic-altar-spawn-13506", "name": "Cursed lron Golem" @@ -280,18 +367,41 @@ "id": "normal", "name": "normal", "bossIds": [ + "rw-mystic-altar-spawn-11831", + "rw-mystic-altar-spawn-11448", "rw-mystic-altar-spawn-13506" ], - "finalBossId": "rw-mystic-altar-spawn-13506" + "finalBossId": "rw-mystic-altar-spawn-11831", + "finalBossIds": [ + "rw-mystic-altar-spawn-11831", + "rw-mystic-altar-spawn-11448", + "rw-mystic-altar-spawn-13506" + ] } ], "defaultDifficultyId": "normal" }, { "id": "bite-sized-paspers-shrine-hafiz", - "title": "Paspers' Shrine: Hafiz's Hall", - "finalBossId": "rw-paspers-shrine-spawn-5942136", + "title": "Paspers' Shrine: Final Hall", + "finalBossId": "rw-paspers-shrine-spawn-5942127", "bosses": [ + { + "id": "rw-paspers-shrine-spawn-5942127", + "name": "Blackhorn Nisorn" + }, + { + "id": "rw-paspers-shrine-spawn-4407635", + "name": "Blackhorn Dorglas" + }, + { + "id": "rw-paspers-shrine-spawn-5947918", + "name": "Blackhorn Bareth" + }, + { + "id": "rw-paspers-shrine-spawn-4407640", + "name": "Blackhorn Afalen" + }, { "id": "rw-paspers-shrine-spawn-5942136", "name": "Blackhorn Hafiz" @@ -302,9 +412,20 @@ "id": "normal", "name": "normal", "bossIds": [ + "rw-paspers-shrine-spawn-5942127", + "rw-paspers-shrine-spawn-4407635", + "rw-paspers-shrine-spawn-5947918", + "rw-paspers-shrine-spawn-4407640", "rw-paspers-shrine-spawn-5942136" ], - "finalBossId": "rw-paspers-shrine-spawn-5942136" + "finalBossId": "rw-paspers-shrine-spawn-5942127", + "finalBossIds": [ + "rw-paspers-shrine-spawn-5942127", + "rw-paspers-shrine-spawn-4407635", + "rw-paspers-shrine-spawn-5947918", + "rw-paspers-shrine-spawn-4407640", + "rw-paspers-shrine-spawn-5942136" + ] } ], "defaultDifficultyId": "normal" @@ -360,12 +481,24 @@ }, { "id": "bite-sized-utgarde-pinnacle-skadi", - "title": "Utgarde Pinnacle: Skadi’s Gauntlet", - "finalBossId": "creature-126103", + "title": "Utgarde Pinnacle: Eagle’s Eye Gauntlet", + "finalBossId": "encounter-577", "bosses": [ + { + "id": "encounter-577", + "name": "Svala Sorrowgrave" + }, + { + "id": "creature-126102", + "name": "Gortok Palehoof" + }, { "id": "creature-126103", "name": "Skadi the Ruthless" + }, + { + "id": "creature-126255", + "name": "King Ymiron" } ], "difficulties": [ @@ -373,29 +506,75 @@ "id": "normal", "name": "normal", "bossIds": [ - "creature-126103" + "encounter-577", + "creature-126102", + "creature-126103", + "creature-126255" ], - "finalBossId": "creature-126103" + "finalBossId": "encounter-577", + "finalBossIds": [ + "encounter-577", + "creature-126102", + "creature-126103", + "creature-126255" + ] }, { "id": "heroic", "name": "heroic", "bossIds": [ - "creature-126103" + "encounter-577", + "creature-126102", + "creature-126103", + "creature-126255" ], - "finalBossId": "creature-126103" + "finalBossId": "encounter-577", + "finalBossIds": [ + "encounter-577", + "creature-126102", + "creature-126103", + "creature-126255" + ] } ], "defaultDifficultyId": "normal" }, { "id": "bite-sized-wailing-caverns", - "title": "Wailing Caverns: Verdan’s Lair", - "finalBossId": "verdan-spawn", + "title": "Wailing Caverns: Everliving Chamber", + "finalBossId": "lady-anacondra-spawn", "bosses": [ + { + "id": "lady-anacondra-spawn", + "name": "Lady Anacondra" + }, + { + "id": "kresh-patrol:kresh-roaming", + "name": "Kresh" + }, + { + "id": "lord-cobrahn-spawn", + "name": "Lord Cobrahn" + }, + { + "id": "lord-pythas-spawn", + "name": "Lord Pythas" + }, + { + "id": "skum-spawn", + "name": "Skum" + }, + { + "id": "lord-serpentis-spawn", + "name": "Lord Serpentis" + }, { "id": "verdan-spawn", "name": "Verdan the Everliving" + }, + { + "id": "mutanus-spawn", + "name": "Mutanus the Devourer" } ], "difficulties": [ @@ -403,9 +582,26 @@ "id": "normal", "name": "normal", "bossIds": [ - "verdan-spawn" + "lady-anacondra-spawn", + "kresh-patrol:kresh-roaming", + "lord-cobrahn-spawn", + "lord-pythas-spawn", + "skum-spawn", + "lord-serpentis-spawn", + "verdan-spawn", + "mutanus-spawn" ], - "finalBossId": "verdan-spawn" + "finalBossId": "lady-anacondra-spawn", + "finalBossIds": [ + "lady-anacondra-spawn", + "kresh-patrol:kresh-roaming", + "lord-cobrahn-spawn", + "lord-pythas-spawn", + "skum-spawn", + "lord-serpentis-spawn", + "verdan-spawn", + "mutanus-spawn" + ] } ], "defaultDifficultyId": "normal" @@ -2239,28 +2435,1256 @@ }, { "id": "randomized-bite-sized-stormwind-stockade", - "title": "Randomized Bite-Sized: Cellblock Shuffle", - "finalBossId": "creature-84027", + "title": "Randomized Bite-Sized: All Available", + "finalBossId": "randomized:wow:boss:17881", "bosses": [ { - "id": "creature-84027", - "name": "Targorr the Dread" + "id": "randomized:wow:boss:17881", + "name": "Aeonus" }, { - "id": "creature-79088", - "name": "Kam Deepfury" + "id": "randomized:wow:boss:4422", + "name": "Agathelos the Raging" }, { - "id": "creature-79098", - "name": "Hamhock" + "id": "randomized:wow:boss:4424", + "name": "Aggem Thorncurse" }, { - "id": "creature-79124", + "id": "randomized:wow:boss:4829", + "name": "Aku'mai" + }, + { + "id": "randomized:wow:boss:30258", + "name": "Amanitar" + }, + { + "id": "randomized:wow:boss:9156", + "name": "Ambassador Flamelash" + }, + { + "id": "randomized:wow:boss:18731", + "name": "Ambassador Hellmaw" + }, + { + "id": "randomized:wow:boss:7358", + "name": "Amnennar the Coldbringer" + }, + { + "id": "randomized:wow:boss:7206", + "name": "Ancient Stone Keeper" + }, + { + "id": "randomized:rom:boss:102430", + "name": "Andaphelmor" + }, + { + "id": "randomized:rom:boss:102421", + "name": "Andriol" + }, + { + "id": "randomized:rom:boss:101462", + "name": "Anglo" + }, + { + "id": "randomized:wow:boss:26763", + "name": "Anomalus" + }, + { + "id": "randomized:wow:boss:8127", + "name": "Antu'sul" + }, + { + "id": "randomized:wow:boss:29120", + "name": "Anub'arak" + }, + { + "id": "randomized:wow:boss:6487", + "name": "Arcanist Doan" + }, + { + "id": "randomized:wow:boss:2748", + "name": "Archaedas" + }, + { + "id": "randomized:wow:boss:10811", + "name": "Archivist Galford" + }, + { + "id": "randomized:wow:boss:4275", + "name": "Archmage Arugal" + }, + { + "id": "randomized:rom:boss:105542", + "name": "Ardmond" + }, + { + "id": "randomized:wow:boss:30188", + "name": "Argent Champion" + }, + { + "id": "randomized:wow:boss:8580", + "name": "Atal'alarion" + }, + { + "id": "randomized:wow:boss:8443", + "name": "Avatar of Hakkar" + }, + { + "id": "randomized:wow:boss:9016", + "name": "Bael'Gar" + }, + { + "id": "randomized:wow:boss:10813", + "name": "Balnazzar" + }, + { + "id": "randomized:wow:boss:3887", + "name": "Baron Silverlaine" + }, + { + "id": "randomized:wow:boss:10436", + "name": "Baroness Anastari" + }, + { + "id": "randomized:wow:boss:1716", "name": "Bazil Thredd" }, { - "id": "creature-79116", + "id": "randomized:wow:boss:11519", + "name": "Bazzalan" + }, + { + "id": "randomized:wow:boss:18667", + "name": "Blackheart the Inciter" + }, + { + "id": "randomized:rom:boss:101018", + "name": "Blackhorn Afalen" + }, + { + "id": "randomized:rom:boss:101019", + "name": "Blackhorn Bareth" + }, + { + "id": "randomized:rom:boss:101021", + "name": "Blackhorn Dorglas" + }, + { + "id": "randomized:rom:boss:100794", + "name": "Blackhorn Hafiz" + }, + { + "id": "randomized:rom:boss:101020", + "name": "Blackhorn Nisorn" + }, + { + "id": "randomized:wow:boss:4543", + "name": "Bloodmage Thalnos" + }, + { + "id": "randomized:rom:boss:102046", + "name": "Bloodthirsty Claw" + }, + { + "id": "randomized:rom:boss:105224", + "name": "Boro-Boro Brothers" + }, + { + "id": "randomized:rom:boss:101533", + "name": "Boulder Sidaar" + }, + { + "id": "randomized:wow:boss:17380", + "name": "Broggok" + }, + { + "id": "randomized:wow:boss:36497", + "name": "Bronjahm" + }, + { + "id": "randomized:wow:boss:10263", + "name": "Burning Felguard" + }, + { + "id": "randomized:wow:boss:10997", + "name": "Cannon Master Willey" + }, + { + "id": "randomized:wow:boss:647", + "name": "Captain Greenskin" + }, + { + "id": "randomized:wow:boss:14325", + "name": "Captain Kromcrush" + }, + { + "id": "randomized:wow:boss:17862", + "name": "Captain Skarloc" + }, + { + "id": "randomized:rom:boss:105616", + "name": "Cayus" + }, + { + "id": "randomized:wow:boss:12225", + "name": "Celebras the Cursed" + }, + { + "id": "randomized:wow:boss:4421", + "name": "Charlga Razorflank" + }, + { + "id": "randomized:wow:boss:7267", + "name": "Chief Ukorz Sandscalp" + }, + { + "id": "randomized:wow:boss:17879", + "name": "Chrono Lord Deja" + }, + { + "id": "randomized:wow:boss:26532", + "name": "Chrono-Lord Epoch" + }, + { + "id": "randomized:wow:boss:17976", + "name": "Commander Sarannis" + }, + { + "id": "randomized:wow:boss:4278", + "name": "Commander Springvale" + }, + { + "id": "randomized:wow:boss:26796", + "name": "Commander Stoutbeard" + }, + { + "id": "randomized:rom:boss:105204", + "name": "Conjurer Chapeaunoir" + }, + { + "id": "randomized:wow:boss:645", + "name": "Cookie" + }, + { + "id": "randomized:rom:boss:101883", + "name": "Crasset Hairyfoot" + }, + { + "id": "randomized:wow:boss:6229", + "name": "Crowd Pummeler 9-60" + }, + { + "id": "randomized:rom:boss:100562", + "name": "Cruel Eater" + }, + { + "id": "randomized:wow:boss:10376", + "name": "Crystal Fang" + }, + { + "id": "randomized:rom:boss:100138", + "name": "Cursed lron Golem" + }, + { + "id": "randomized:wow:boss:31134", + "name": "Cyanigosa" + }, + { + "id": "randomized:wow:boss:20885", + "name": "Dalliah the Doomsayer" + }, + { + "id": "randomized:wow:boss:24201", + "name": "Dalronn the Controller" + }, + { + "id": "randomized:wow:boss:18472", + "name": "Darkweaver Syth" + }, + { + "id": "randomized:wow:boss:4428", + "name": "Death Speaker Jargba" + }, + { + "id": "randomized:rom:boss:100074", + "name": "Demon Witch Ancalon" + }, + { + "id": "randomized:wow:boss:5912", + "name": "Deviate Faerie Dragon" + }, + { + "id": "randomized:wow:boss:36502", + "name": "Devourer of Souls" + }, + { + "id": "randomized:wow:boss:1663", "name": "Dextren Ward" + }, + { + "id": "randomized:rom:boss:102476", + "name": "Divine Punishment Pursuer" + }, + { + "id": "randomized:wow:boss:11261", + "name": "Doctor Theolen Krastinov" + }, + { + "id": "randomized:rom:boss:100611", + "name": "Dorlos" + }, + { + "id": "randomized:wow:boss:29573", + "name": "Drakkari Elemental" + }, + { + "id": "randomized:wow:boss:27654", + "name": "Drakos the Interrogator" + }, + { + "id": "randomized:wow:boss:4842", + "name": "Earthcaller Halmgar" + }, + { + "id": "randomized:wow:boss:29932", + "name": "Eck the Ferocious" + }, + { + "id": "randomized:wow:boss:639", + "name": "Edwin VanCleef" + }, + { + "id": "randomized:wow:boss:29309", + "name": "Elder Nadox" + }, + { + "id": "randomized:wow:boss:6235", + "name": "Electrocutioner 6000" + }, + { + "id": "randomized:wow:boss:9019", + "name": "Emperor Dagran Thaurissan" + }, + { + "id": "randomized:rom:boss:101503", + "name": "Ensia" + }, + { + "id": "randomized:wow:boss:18096", + "name": "Epoch Hunter" + }, + { + "id": "randomized:wow:boss:29315", + "name": "Erekem" + }, + { + "id": "randomized:wow:boss:35569", + "name": "Eressea Dawnsinger" + }, + { + "id": "randomized:wow:boss:18373", + "name": "Exarch Maladaar" + }, + { + "id": "randomized:wow:boss:38112", + "name": "Falric" + }, + { + "id": "randomized:wow:boss:4274", + "name": "Fenrus the Devourer" + }, + { + "id": "randomized:wow:boss:9056", + "name": "Fineous Darkvire" + }, + { + "id": "randomized:rom:boss:100561", + "name": "Flowing Chaos" + }, + { + "id": "randomized:wow:boss:36494", + "name": "Forgemaster Garfrost" + }, + { + "id": "randomized:wow:boss:7273", + "name": "Gahz'rilla" + }, + { + "id": "randomized:wow:boss:29306", + "name": "Gal'darah" + }, + { + "id": "randomized:wow:boss:7291", + "name": "Galgann Firehammer" + }, + { + "id": "randomized:rom:boss:102063", + "name": "Geba" + }, + { + "id": "randomized:wow:boss:6243", + "name": "Gelihast" + }, + { + "id": "randomized:wow:boss:9033", + "name": "General Angerforge" + }, + { + "id": "randomized:wow:boss:28586", + "name": "General Bjarngrim" + }, + { + "id": "randomized:wow:boss:10363", + "name": "General Drakkisath" + }, + { + "id": "randomized:wow:boss:4887", + "name": "Ghamoo-ra" + }, + { + "id": "randomized:wow:boss:18105", + "name": "Ghaz'an" + }, + { + "id": "randomized:wow:boss:1763", + "name": "Gilnid" + }, + { + "id": "randomized:rom:boss:102475", + "name": "Glamo" + }, + { + "id": "randomized:wow:boss:8567", + "name": "Glutton" + }, + { + "id": "randomized:rom:boss:101500", + "name": "Goddess of Art's Disciple" + }, + { + "id": "randomized:wow:boss:8983", + "name": "Golem Lord Argelmach" + }, + { + "id": "randomized:wow:boss:26687", + "name": "Gortok Palehoof" + }, + { + "id": "randomized:wow:boss:26731", + "name": "Grand Magus Telestra" + }, + { + "id": "randomized:wow:boss:16807", + "name": "Grand Warlock Nethekurse" + }, + { + "id": "randomized:wow:boss:18732", + "name": "Grandmaster Vorpil" + }, + { + "id": "randomized:wow:boss:4854", + "name": "Grimlok" + }, + { + "id": "randomized:wow:boss:14321", + "name": "Guard Fengus" + }, + { + "id": "randomized:wow:boss:14326", + "name": "Guard Mol'dar" + }, + { + "id": "randomized:wow:boss:14323", + "name": "Guard Slip'kik" + }, + { + "id": "randomized:rom:boss:102395", + "name": "Guldamor" + }, + { + "id": "randomized:wow:boss:28921", + "name": "Hadronox" + }, + { + "id": "randomized:wow:boss:10220", + "name": "Halycon" + }, + { + "id": "randomized:wow:boss:1717", + "name": "Hamhock" + }, + { + "id": "randomized:wow:boss:20912", + "name": "Harbinger Skyriss" + }, + { + "id": "randomized:wow:boss:29311", + "name": "Herald Volazj" + }, + { + "id": "randomized:wow:boss:3975", + "name": "Herod" + }, + { + "id": "randomized:wow:boss:17975", + "name": "High Botanist Freywinn" + }, + { + "id": "randomized:wow:boss:3977", + "name": "High Inquisitor Whitemane" + }, + { + "id": "randomized:wow:boss:9018", + "name": "High Interrogator Gerstahn" + }, + { + "id": "randomized:rom:boss:100563", + "name": "Hollow Shell" + }, + { + "id": "randomized:rom:boss:102307", + "name": "Holy Tree Falynum" + }, + { + "id": "randomized:rom:boss:102643", + "name": "Horror Seeker" + }, + { + "id": "randomized:wow:boss:9319", + "name": "Houndmaster Grebmar" + }, + { + "id": "randomized:wow:boss:3974", + "name": "Houndmaster Loksey" + }, + { + "id": "randomized:wow:boss:17770", + "name": "Hungarfen" + }, + { + "id": "randomized:wow:boss:17797", + "name": "Hydromancer Thespia" + }, + { + "id": "randomized:wow:boss:7795", + "name": "Hydromancer Velratha" + }, + { + "id": "randomized:wow:boss:13280", + "name": "Hydrospawn" + }, + { + "id": "randomized:rom:boss:101881", + "name": "Icebound Guardian" + }, + { + "id": "randomized:wow:boss:36476", + "name": "Ick" + }, + { + "id": "randomized:wow:boss:11488", + "name": "Illyanna Ravenoak" + }, + { + "id": "randomized:wow:boss:11496", + "name": "Immol'thar" + }, + { + "id": "randomized:wow:boss:23954", + "name": "Ingvar the Plunderer" + }, + { + "id": "randomized:wow:boss:10505", + "name": "Instructor Malicia" + }, + { + "id": "randomized:wow:boss:28546", + "name": "Ionar" + }, + { + "id": "randomized:rom:boss:101507", + "name": "Iron Rune Warrior" + }, + { + "id": "randomized:wow:boss:7228", + "name": "Ironaya" + }, + { + "id": "randomized:rom:boss:105543", + "name": "Issac" + }, + { + "id": "randomized:wow:boss:5710", + "name": "Jammal'an the Prophet" + }, + { + "id": "randomized:wow:boss:10503", + "name": "Jandice Barov" + }, + { + "id": "randomized:wow:boss:29310", + "name": "Jedoga Shadowseeker" + }, + { + "id": "randomized:wow:boss:11518", + "name": "Jergosh the Invoker" + }, + { + "id": "randomized:rom:boss:102021", + "name": "Jiasha" + }, + { + "id": "randomized:wow:boss:24664", + "name": "Kael'thas Sunstrider" + }, + { + "id": "randomized:wow:boss:1666", + "name": "Kam Deepfury" + }, + { + "id": "randomized:wow:boss:17377", + "name": "Keli'dan the Breaker" + }, + { + "id": "randomized:wow:boss:26723", + "name": "Keristrasza" + }, + { + "id": "randomized:wow:boss:27483", + "name": "King Dred" + }, + { + "id": "randomized:wow:boss:11501", + "name": "King Gordok" + }, + { + "id": "randomized:wow:boss:26861", + "name": "King Ymiron" + }, + { + "id": "randomized:wow:boss:3653", + "name": "Kresh" + }, + { + "id": "randomized:wow:boss:28684", + "name": "Krik'thir the Gatewatcher" + }, + { + "id": "randomized:wow:boss:27977", + "name": "Krystallus" + }, + { + "id": "randomized:wow:boss:3671", + "name": "Lady Anacondra" + }, + { + "id": "randomized:wow:boss:10502", + "name": "Lady Illucia Barov" + }, + { + "id": "randomized:wow:boss:4831", + "name": "Lady Sarevess" + }, + { + "id": "randomized:wow:boss:17980", + "name": "Laj" + }, + { + "id": "randomized:wow:boss:12203", + "name": "Landslide" + }, + { + "id": "randomized:rom:boss:105351", + "name": "Larvanger Barkud" + }, + { + "id": "randomized:wow:boss:14327", + "name": "Lethtendris" + }, + { + "id": "randomized:wow:boss:27656", + "name": "Ley-Guardian Eregos" + }, + { + "id": "randomized:wow:boss:17848", + "name": "Lieutenant Drake" + }, + { + "id": "randomized:rom:boss:102349", + "name": "Life Leecher" + }, + { + "id": "randomized:rom:boss:104228", + "name": "Linobia" + }, + { + "id": "randomized:rom:boss:101502", + "name": "Locatha" + }, + { + "id": "randomized:wow:boss:28923", + "name": "Loken" + }, + { + "id": "randomized:rom:boss:101534", + "name": "Lookout Luke" + }, + { + "id": "randomized:wow:boss:10504", + "name": "Lord Alexei Barov" + }, + { + "id": "randomized:wow:boss:3669", + "name": "Lord Cobrahn" + }, + { + "id": "randomized:wow:boss:14506", + "name": "Lord Hel'nurath" + }, + { + "id": "randomized:wow:boss:9017", + "name": "Lord Incendius" + }, + { + "id": "randomized:wow:boss:3670", + "name": "Lord Pythas" + }, + { + "id": "randomized:wow:boss:9025", + "name": "Lord Roccor" + }, + { + "id": "randomized:wow:boss:3673", + "name": "Lord Serpentis" + }, + { + "id": "randomized:wow:boss:12236", + "name": "Lord Vyletongue" + }, + { + "id": "randomized:wow:boss:10901", + "name": "Lorekeeper Polkelt" + }, + { + "id": "randomized:wow:boss:12902", + "name": "Lorgus Jett" + }, + { + "id": "randomized:rom:boss:102623", + "name": "Lytfir" + }, + { + "id": "randomized:wow:boss:27655", + "name": "Mage-Lord Urom" + }, + { + "id": "randomized:wow:boss:11487", + "name": "Magister Kalendris" + }, + { + "id": "randomized:wow:boss:10435", + "name": "Magistrate Barthilas" + }, + { + "id": "randomized:wow:boss:9938", + "name": "Magmus" + }, + { + "id": "randomized:wow:boss:27975", + "name": "Maiden of Grief" + }, + { + "id": "randomized:wow:boss:26533", + "name": "Mal'Ganis" + }, + { + "id": "randomized:wow:boss:10438", + "name": "Maleki the Pallid" + }, + { + "id": "randomized:wow:boss:11032", + "name": "Malor the Zealous" + }, + { + "id": "randomized:rom:boss:102452", + "name": "Mantarick" + }, + { + "id": "randomized:wow:boss:38113", + "name": "Marwyn" + }, + { + "id": "randomized:wow:boss:26529", + "name": "Meathook" + }, + { + "id": "randomized:wow:boss:19219", + "name": "Mechano-Lord Capacitus" + }, + { + "id": "randomized:rom:boss:102040", + "name": "Medusa" + }, + { + "id": "randomized:wow:boss:17796", + "name": "Mekgineer Steamrigger" + }, + { + "id": "randomized:wow:boss:7800", + "name": "Mekgineer Thermaplugg" + }, + { + "id": "randomized:wow:boss:17941", + "name": "Mennu the Betrayer" + }, + { + "id": "randomized:wow:boss:12237", + "name": "Meshlok the Harvester" + }, + { + "id": "randomized:wow:boss:35572", + "name": "Mokra the Skullcrusher" + }, + { + "id": "randomized:rom:boss:101884", + "name": "Mole King" + }, + { + "id": "randomized:wow:boss:29305", + "name": "Moorabi" + }, + { + "id": "randomized:wow:boss:29316", + "name": "Moragg" + }, + { + "id": "randomized:wow:boss:7357", + "name": "Mordresh Fire Eye" + }, + { + "id": "randomized:wow:boss:10596", + "name": "Mother Smolderweb" + }, + { + "id": "randomized:wow:boss:646", + "name": "Mr. Smite" + }, + { + "id": "randomized:wow:boss:18708", + "name": "Murmur" + }, + { + "id": "randomized:wow:boss:3654", + "name": "Mutanus the Devourer" + }, + { + "id": "randomized:rom:boss:102701", + "name": "Naos" + }, + { + "id": "randomized:rom:boss:100070", + "name": "Necromage" + }, + { + "id": "randomized:wow:boss:10437", + "name": "Nerub'enkan" + }, + { + "id": "randomized:wow:boss:19221", + "name": "Nethermancer Sepethrea" + }, + { + "id": "randomized:wow:boss:18344", + "name": "Nexus-Prince Shaffar" + }, + { + "id": "randomized:wow:boss:26631", + "name": "Novos the Summoner" + }, + { + "id": "randomized:wow:boss:13282", + "name": "Noxxion" + }, + { + "id": "randomized:wow:boss:7023", + "name": "Obsidian Sentinel" + }, + { + "id": "randomized:wow:boss:4279", + "name": "Odo the Blindwatcher" + }, + { + "id": "randomized:wow:boss:11517", + "name": "Oggleflint" + }, + { + "id": "randomized:wow:boss:17308", + "name": "Omor the Unscarred" + }, + { + "id": "randomized:wow:boss:26794", + "name": "Ormorok the Tree-Shaper" + }, + { + "id": "randomized:wow:boss:4420", + "name": "Overlord Ramtusk" + }, + { + "id": "randomized:wow:boss:9568", + "name": "Overlord Wyrmthalak" + }, + { + "id": "randomized:wow:boss:18341", + "name": "Pandemonius" + }, + { + "id": "randomized:rom:boss:101268", + "name": "Pangkor" + }, + { + "id": "randomized:wow:boss:19220", + "name": "Pathaleon the Calculator" + }, + { + "id": "randomized:wow:boss:7356", + "name": "Plaguemaw the Rotting" + }, + { + "id": "randomized:wow:boss:23953", + "name": "Prince Keleseth" + }, + { + "id": "randomized:wow:boss:29308", + "name": "Prince Taldaram" + }, + { + "id": "randomized:wow:boss:12201", + "name": "Princess Theradras" + }, + { + "id": "randomized:rom:boss:102560", + "name": "Prison Pursuer" + }, + { + "id": "randomized:wow:boss:9816", + "name": "Pyroguard Emberseer" + }, + { + "id": "randomized:wow:boss:9024", + "name": "Pyromancer Loregrain" + }, + { + "id": "randomized:wow:boss:17942", + "name": "Quagmirran" + }, + { + "id": "randomized:wow:boss:9736", + "name": "Quartermaster Zigris" + }, + { + "id": "randomized:wow:boss:7354", + "name": "Ragglesnout" + }, + { + "id": "randomized:rom:boss:101433", + "name": "Raichika the Destroyer" + }, + { + "id": "randomized:rom:boss:102197", + "name": "Raichika the Destroyer" + }, + { + "id": "randomized:wow:boss:10508", + "name": "Ras Frostwhisper" + }, + { + "id": "randomized:wow:boss:11622", + "name": "Rattlegore" + }, + { + "id": "randomized:rom:boss:100154", + "name": "Razeela" + }, + { + "id": "randomized:wow:boss:12258", + "name": "Razorlash" + }, + { + "id": "randomized:wow:boss:3914", + "name": "Rethilgore" + }, + { + "id": "randomized:wow:boss:6910", + "name": "Revelosh" + }, + { + "id": "randomized:wow:boss:644", + "name": "Rhahk'Zor" + }, + { + "id": "randomized:wow:boss:17991", + "name": "Rokmar the Crackler" + }, + { + "id": "randomized:wow:boss:6168", + "name": "Roogug" + }, + { + "id": "randomized:rom:boss:101432", + "name": "Rune Guardian Leader" + }, + { + "id": "randomized:rom:boss:102196", + "name": "Rune Guardian Leader" + }, + { + "id": "randomized:wow:boss:35571", + "name": "Runok Wildmane" + }, + { + "id": "randomized:wow:boss:26530", + "name": "Salramm the Fleshcrafter" + }, + { + "id": "randomized:rom:boss:100645", + "name": "Sandlord Sesphiroth" + }, + { + "id": "randomized:wow:boss:36658", + "name": "Scourgelord Tyrannus" + }, + { + "id": "randomized:wow:boss:24723", + "name": "Selin Fireheart" + }, + { + "id": "randomized:wow:boss:5709", + "name": "Shade of Eranikus" + }, + { + "id": "randomized:rom:boss:103946", + "name": "Shadoj" + }, + { + "id": "randomized:wow:boss:9236", + "name": "Shadow Hunter Vosh'gajin" + }, + { + "id": "randomized:wow:boss:18371", + "name": "Shirrak the Dead Watcher" + }, + { + "id": "randomized:wow:boss:27978", + "name": "Sjonnir The Ironshaper" + }, + { + "id": "randomized:wow:boss:26693", + "name": "Skadi the Ruthless" + }, + { + "id": "randomized:wow:boss:3674", + "name": "Skum" + }, + { + "id": "randomized:wow:boss:29304", + "name": "Slad'ran" + }, + { + "id": "randomized:wow:boss:642", + "name": "Sneed's Shredder" + }, + { + "id": "randomized:rom:boss:101535", + "name": "Snow Blake" + }, + { + "id": "randomized:wow:boss:10264", + "name": "Solakar Flamewreath" + }, + { + "id": "randomized:rom:boss:102195", + "name": "Specter Guardian Creator" + }, + { + "id": "randomized:wow:boss:9218", + "name": "Spirestone Battle Lord" + }, + { + "id": "randomized:wow:boss:9219", + "name": "Spirestone Butcher" + }, + { + "id": "randomized:wow:boss:26668", + "name": "Svala Sorrowgrave" + }, + { + "id": "randomized:wow:boss:17826", + "name": "Swamplord Musel'ek" + }, + { + "id": "randomized:wow:boss:18473", + "name": "Talon King Ikiss" + }, + { + "id": "randomized:wow:boss:11520", + "name": "Taragaman the Hungerer" + }, + { + "id": "randomized:wow:boss:1696", + "name": "Targorr the Dread" + }, + { + "id": "randomized:wow:boss:18343", + "name": "Tavarok" + }, + { + "id": "randomized:rom:boss:102041", + "name": "Teeth of the Reef" + }, + { + "id": "randomized:rom:boss:104161", + "name": "Template 104161" + }, + { + "id": "randomized:rom:boss:107306", + "name": "Template 107306" + }, + { + "id": "randomized:wow:boss:17880", + "name": "Temporus" + }, + { + "id": "randomized:wow:boss:10430", + "name": "The Beast" + }, + { + "id": "randomized:wow:boss:35451", + "name": "The Black Knight" + }, + { + "id": "randomized:wow:boss:17882", + "name": "The Black Stalker" + }, + { + "id": "randomized:wow:boss:17381", + "name": "The Maker" + }, + { + "id": "randomized:wow:boss:26632", + "name": "The Prophet Tharon'ja" + }, + { + "id": "randomized:wow:boss:10516", + "name": "The Unforgiven" + }, + { + "id": "randomized:wow:boss:17978", + "name": "Thorngrin the Tender" + }, + { + "id": "randomized:rom:boss:101584", + "name": "Thynos" + }, + { + "id": "randomized:wow:boss:13601", + "name": "Tinkerer Gizlock" + }, + { + "id": "randomized:wow:boss:26630", + "name": "Trollgore" + }, + { + "id": "randomized:wow:boss:7355", + "name": "Tuten'kash" + }, + { + "id": "randomized:wow:boss:4832", + "name": "Twilight Lord Kelris" + }, + { + "id": "randomized:rom:boss:105701", + "name": "Undead Guard" + }, + { + "id": "randomized:wow:boss:27447", + "name": "Varos Cloudstrider" + }, + { + "id": "randomized:wow:boss:5775", + "name": "Verdan the Everliving" + }, + { + "id": "randomized:wow:boss:24744", + "name": "Vexallus" + }, + { + "id": "randomized:wow:boss:7079", + "name": "Viscous Fallout" + }, + { + "id": "randomized:wow:boss:28587", + "name": "Volkhan" + }, + { + "id": "randomized:wow:boss:9237", + "name": "War Master Voone" + }, + { + "id": "randomized:wow:boss:16809", + "name": "Warbringer O'mrogg" + }, + { + "id": "randomized:wow:boss:16808", + "name": "Warchief Kargath Bladefist" + }, + { + "id": "randomized:wow:boss:10429", + "name": "Warchief Rend Blackhand" + }, + { + "id": "randomized:wow:boss:9041", + "name": "Warder Stilgiss" + }, + { + "id": "randomized:wow:boss:17798", + "name": "Warlord Kalithresh" + }, + { + "id": "randomized:wow:boss:17977", + "name": "Warp Splinter" + }, + { + "id": "randomized:wow:boss:17306", + "name": "Watchkeeper Gargolmar" + }, + { + "id": "randomized:wow:boss:7271", + "name": "Witch Doctor Zum'rah" + }, + { + "id": "randomized:wow:boss:3927", + "name": "Wolf Master Nandos" + }, + { + "id": "randomized:wow:boss:20886", + "name": "Wrath-Scryer Soccothrates" + }, + { + "id": "randomized:rom:boss:101501", + "name": "Yusalien" + }, + { + "id": "randomized:wow:boss:20870", + "name": "Zereketh the Unbound" + }, + { + "id": "randomized:wow:boss:11490", + "name": "Zevrim Thornhoof" + }, + { + "id": "randomized:rom:boss:102425", + "name": "Zygro" } ], "difficulties": [ @@ -2268,19 +3692,880 @@ "id": "normal", "name": "normal", "bossIds": [ - "creature-84027", - "creature-79088", - "creature-79098", - "creature-79124", - "creature-79116" + "randomized:wow:boss:17881", + "randomized:wow:boss:4422", + "randomized:wow:boss:4424", + "randomized:wow:boss:4829", + "randomized:wow:boss:30258", + "randomized:wow:boss:9156", + "randomized:wow:boss:18731", + "randomized:wow:boss:7358", + "randomized:wow:boss:7206", + "randomized:rom:boss:102430", + "randomized:rom:boss:102421", + "randomized:rom:boss:101462", + "randomized:wow:boss:26763", + "randomized:wow:boss:8127", + "randomized:wow:boss:29120", + "randomized:wow:boss:6487", + "randomized:wow:boss:2748", + "randomized:wow:boss:10811", + "randomized:wow:boss:4275", + "randomized:rom:boss:105542", + "randomized:wow:boss:30188", + "randomized:wow:boss:8580", + "randomized:wow:boss:8443", + "randomized:wow:boss:9016", + "randomized:wow:boss:10813", + "randomized:wow:boss:3887", + "randomized:wow:boss:10436", + "randomized:wow:boss:1716", + "randomized:wow:boss:11519", + "randomized:wow:boss:18667", + "randomized:rom:boss:101018", + "randomized:rom:boss:101019", + "randomized:rom:boss:101021", + "randomized:rom:boss:100794", + "randomized:rom:boss:101020", + "randomized:wow:boss:4543", + "randomized:rom:boss:102046", + "randomized:rom:boss:105224", + "randomized:rom:boss:101533", + "randomized:wow:boss:17380", + "randomized:wow:boss:36497", + "randomized:wow:boss:10263", + "randomized:wow:boss:10997", + "randomized:wow:boss:647", + "randomized:wow:boss:14325", + "randomized:wow:boss:17862", + "randomized:rom:boss:105616", + "randomized:wow:boss:12225", + "randomized:wow:boss:4421", + "randomized:wow:boss:7267", + "randomized:wow:boss:17879", + "randomized:wow:boss:26532", + "randomized:wow:boss:17976", + "randomized:wow:boss:4278", + "randomized:wow:boss:26796", + "randomized:rom:boss:105204", + "randomized:wow:boss:645", + "randomized:rom:boss:101883", + "randomized:wow:boss:6229", + "randomized:rom:boss:100562", + "randomized:wow:boss:10376", + "randomized:rom:boss:100138", + "randomized:wow:boss:31134", + "randomized:wow:boss:20885", + "randomized:wow:boss:24201", + "randomized:wow:boss:18472", + "randomized:wow:boss:4428", + "randomized:rom:boss:100074", + "randomized:wow:boss:5912", + "randomized:wow:boss:36502", + "randomized:wow:boss:1663", + "randomized:rom:boss:102476", + "randomized:wow:boss:11261", + "randomized:rom:boss:100611", + "randomized:wow:boss:29573", + "randomized:wow:boss:27654", + "randomized:wow:boss:4842", + "randomized:wow:boss:29932", + "randomized:wow:boss:639", + "randomized:wow:boss:29309", + "randomized:wow:boss:6235", + "randomized:wow:boss:9019", + "randomized:rom:boss:101503", + "randomized:wow:boss:18096", + "randomized:wow:boss:29315", + "randomized:wow:boss:35569", + "randomized:wow:boss:18373", + "randomized:wow:boss:38112", + "randomized:wow:boss:4274", + "randomized:wow:boss:9056", + "randomized:rom:boss:100561", + "randomized:wow:boss:36494", + "randomized:wow:boss:7273", + "randomized:wow:boss:29306", + "randomized:wow:boss:7291", + "randomized:rom:boss:102063", + "randomized:wow:boss:6243", + "randomized:wow:boss:9033", + "randomized:wow:boss:28586", + "randomized:wow:boss:10363", + "randomized:wow:boss:4887", + "randomized:wow:boss:18105", + "randomized:wow:boss:1763", + "randomized:rom:boss:102475", + "randomized:wow:boss:8567", + "randomized:rom:boss:101500", + "randomized:wow:boss:8983", + "randomized:wow:boss:26687", + "randomized:wow:boss:26731", + "randomized:wow:boss:16807", + "randomized:wow:boss:18732", + "randomized:wow:boss:4854", + "randomized:wow:boss:14321", + "randomized:wow:boss:14326", + "randomized:wow:boss:14323", + "randomized:rom:boss:102395", + "randomized:wow:boss:28921", + "randomized:wow:boss:10220", + "randomized:wow:boss:1717", + "randomized:wow:boss:20912", + "randomized:wow:boss:29311", + "randomized:wow:boss:3975", + "randomized:wow:boss:17975", + "randomized:wow:boss:3977", + "randomized:wow:boss:9018", + "randomized:rom:boss:100563", + "randomized:rom:boss:102307", + "randomized:rom:boss:102643", + "randomized:wow:boss:9319", + "randomized:wow:boss:3974", + "randomized:wow:boss:17770", + "randomized:wow:boss:17797", + "randomized:wow:boss:7795", + "randomized:wow:boss:13280", + "randomized:rom:boss:101881", + "randomized:wow:boss:36476", + "randomized:wow:boss:11488", + "randomized:wow:boss:11496", + "randomized:wow:boss:23954", + "randomized:wow:boss:10505", + "randomized:wow:boss:28546", + "randomized:rom:boss:101507", + "randomized:wow:boss:7228", + "randomized:rom:boss:105543", + "randomized:wow:boss:5710", + "randomized:wow:boss:10503", + "randomized:wow:boss:29310", + "randomized:wow:boss:11518", + "randomized:rom:boss:102021", + "randomized:wow:boss:24664", + "randomized:wow:boss:1666", + "randomized:wow:boss:17377", + "randomized:wow:boss:26723", + "randomized:wow:boss:27483", + "randomized:wow:boss:11501", + "randomized:wow:boss:26861", + "randomized:wow:boss:3653", + "randomized:wow:boss:28684", + "randomized:wow:boss:27977", + "randomized:wow:boss:3671", + "randomized:wow:boss:10502", + "randomized:wow:boss:4831", + "randomized:wow:boss:17980", + "randomized:wow:boss:12203", + "randomized:rom:boss:105351", + "randomized:wow:boss:14327", + "randomized:wow:boss:27656", + "randomized:wow:boss:17848", + "randomized:rom:boss:102349", + "randomized:rom:boss:104228", + "randomized:rom:boss:101502", + "randomized:wow:boss:28923", + "randomized:rom:boss:101534", + "randomized:wow:boss:10504", + "randomized:wow:boss:3669", + "randomized:wow:boss:14506", + "randomized:wow:boss:9017", + "randomized:wow:boss:3670", + "randomized:wow:boss:9025", + "randomized:wow:boss:3673", + "randomized:wow:boss:12236", + "randomized:wow:boss:10901", + "randomized:wow:boss:12902", + "randomized:rom:boss:102623", + "randomized:wow:boss:27655", + "randomized:wow:boss:11487", + "randomized:wow:boss:10435", + "randomized:wow:boss:9938", + "randomized:wow:boss:27975", + "randomized:wow:boss:26533", + "randomized:wow:boss:10438", + "randomized:wow:boss:11032", + "randomized:rom:boss:102452", + "randomized:wow:boss:38113", + "randomized:wow:boss:26529", + "randomized:wow:boss:19219", + "randomized:rom:boss:102040", + "randomized:wow:boss:17796", + "randomized:wow:boss:7800", + "randomized:wow:boss:17941", + "randomized:wow:boss:12237", + "randomized:wow:boss:35572", + "randomized:rom:boss:101884", + "randomized:wow:boss:29305", + "randomized:wow:boss:29316", + "randomized:wow:boss:7357", + "randomized:wow:boss:10596", + "randomized:wow:boss:646", + "randomized:wow:boss:18708", + "randomized:wow:boss:3654", + "randomized:rom:boss:102701", + "randomized:rom:boss:100070", + "randomized:wow:boss:10437", + "randomized:wow:boss:19221", + "randomized:wow:boss:18344", + "randomized:wow:boss:26631", + "randomized:wow:boss:13282", + "randomized:wow:boss:7023", + "randomized:wow:boss:4279", + "randomized:wow:boss:11517", + "randomized:wow:boss:17308", + "randomized:wow:boss:26794", + "randomized:wow:boss:4420", + "randomized:wow:boss:9568", + "randomized:wow:boss:18341", + "randomized:rom:boss:101268", + "randomized:wow:boss:19220", + "randomized:wow:boss:7356", + "randomized:wow:boss:23953", + "randomized:wow:boss:29308", + "randomized:wow:boss:12201", + "randomized:rom:boss:102560", + "randomized:wow:boss:9816", + "randomized:wow:boss:9024", + "randomized:wow:boss:17942", + "randomized:wow:boss:9736", + "randomized:wow:boss:7354", + "randomized:rom:boss:101433", + "randomized:rom:boss:102197", + "randomized:wow:boss:10508", + "randomized:wow:boss:11622", + "randomized:rom:boss:100154", + "randomized:wow:boss:12258", + "randomized:wow:boss:3914", + "randomized:wow:boss:6910", + "randomized:wow:boss:644", + "randomized:wow:boss:17991", + "randomized:wow:boss:6168", + "randomized:rom:boss:101432", + "randomized:rom:boss:102196", + "randomized:wow:boss:35571", + "randomized:wow:boss:26530", + "randomized:rom:boss:100645", + "randomized:wow:boss:36658", + "randomized:wow:boss:24723", + "randomized:wow:boss:5709", + "randomized:rom:boss:103946", + "randomized:wow:boss:9236", + "randomized:wow:boss:18371", + "randomized:wow:boss:27978", + "randomized:wow:boss:26693", + "randomized:wow:boss:3674", + "randomized:wow:boss:29304", + "randomized:wow:boss:642", + "randomized:rom:boss:101535", + "randomized:wow:boss:10264", + "randomized:rom:boss:102195", + "randomized:wow:boss:9218", + "randomized:wow:boss:9219", + "randomized:wow:boss:26668", + "randomized:wow:boss:17826", + "randomized:wow:boss:18473", + "randomized:wow:boss:11520", + "randomized:wow:boss:1696", + "randomized:wow:boss:18343", + "randomized:rom:boss:102041", + "randomized:rom:boss:104161", + "randomized:rom:boss:107306", + "randomized:wow:boss:17880", + "randomized:wow:boss:10430", + "randomized:wow:boss:35451", + "randomized:wow:boss:17882", + "randomized:wow:boss:17381", + "randomized:wow:boss:26632", + "randomized:wow:boss:10516", + "randomized:wow:boss:17978", + "randomized:rom:boss:101584", + "randomized:wow:boss:13601", + "randomized:wow:boss:26630", + "randomized:wow:boss:7355", + "randomized:wow:boss:4832", + "randomized:rom:boss:105701", + "randomized:wow:boss:27447", + "randomized:wow:boss:5775", + "randomized:wow:boss:24744", + "randomized:wow:boss:7079", + "randomized:wow:boss:28587", + "randomized:wow:boss:9237", + "randomized:wow:boss:16809", + "randomized:wow:boss:16808", + "randomized:wow:boss:10429", + "randomized:wow:boss:9041", + "randomized:wow:boss:17798", + "randomized:wow:boss:17977", + "randomized:wow:boss:17306", + "randomized:wow:boss:7271", + "randomized:wow:boss:3927", + "randomized:wow:boss:20886", + "randomized:rom:boss:101501", + "randomized:wow:boss:20870", + "randomized:wow:boss:11490", + "randomized:rom:boss:102425" ], - "finalBossId": "creature-84027", + "finalBossId": "randomized:wow:boss:17881", "finalBossIds": [ - "creature-84027", - "creature-79088", - "creature-79098", - "creature-79124", - "creature-79116" + "randomized:wow:boss:17881", + "randomized:wow:boss:4422", + "randomized:wow:boss:4424", + "randomized:wow:boss:4829", + "randomized:wow:boss:30258", + "randomized:wow:boss:9156", + "randomized:wow:boss:18731", + "randomized:wow:boss:7358", + "randomized:wow:boss:7206", + "randomized:rom:boss:102430", + "randomized:rom:boss:102421", + "randomized:rom:boss:101462", + "randomized:wow:boss:26763", + "randomized:wow:boss:8127", + "randomized:wow:boss:29120", + "randomized:wow:boss:6487", + "randomized:wow:boss:2748", + "randomized:wow:boss:10811", + "randomized:wow:boss:4275", + "randomized:rom:boss:105542", + "randomized:wow:boss:30188", + "randomized:wow:boss:8580", + "randomized:wow:boss:8443", + "randomized:wow:boss:9016", + "randomized:wow:boss:10813", + "randomized:wow:boss:3887", + "randomized:wow:boss:10436", + "randomized:wow:boss:1716", + "randomized:wow:boss:11519", + "randomized:wow:boss:18667", + "randomized:rom:boss:101018", + "randomized:rom:boss:101019", + "randomized:rom:boss:101021", + "randomized:rom:boss:100794", + "randomized:rom:boss:101020", + "randomized:wow:boss:4543", + "randomized:rom:boss:102046", + "randomized:rom:boss:105224", + "randomized:rom:boss:101533", + "randomized:wow:boss:17380", + "randomized:wow:boss:36497", + "randomized:wow:boss:10263", + "randomized:wow:boss:10997", + "randomized:wow:boss:647", + "randomized:wow:boss:14325", + "randomized:wow:boss:17862", + "randomized:rom:boss:105616", + "randomized:wow:boss:12225", + "randomized:wow:boss:4421", + "randomized:wow:boss:7267", + "randomized:wow:boss:17879", + "randomized:wow:boss:26532", + "randomized:wow:boss:17976", + "randomized:wow:boss:4278", + "randomized:wow:boss:26796", + "randomized:rom:boss:105204", + "randomized:wow:boss:645", + "randomized:rom:boss:101883", + "randomized:wow:boss:6229", + "randomized:rom:boss:100562", + "randomized:wow:boss:10376", + "randomized:rom:boss:100138", + "randomized:wow:boss:31134", + "randomized:wow:boss:20885", + "randomized:wow:boss:24201", + "randomized:wow:boss:18472", + "randomized:wow:boss:4428", + "randomized:rom:boss:100074", + "randomized:wow:boss:5912", + "randomized:wow:boss:36502", + "randomized:wow:boss:1663", + "randomized:rom:boss:102476", + "randomized:wow:boss:11261", + "randomized:rom:boss:100611", + "randomized:wow:boss:29573", + "randomized:wow:boss:27654", + "randomized:wow:boss:4842", + "randomized:wow:boss:29932", + "randomized:wow:boss:639", + "randomized:wow:boss:29309", + "randomized:wow:boss:6235", + "randomized:wow:boss:9019", + "randomized:rom:boss:101503", + "randomized:wow:boss:18096", + "randomized:wow:boss:29315", + "randomized:wow:boss:35569", + "randomized:wow:boss:18373", + "randomized:wow:boss:38112", + "randomized:wow:boss:4274", + "randomized:wow:boss:9056", + "randomized:rom:boss:100561", + "randomized:wow:boss:36494", + "randomized:wow:boss:7273", + "randomized:wow:boss:29306", + "randomized:wow:boss:7291", + "randomized:rom:boss:102063", + "randomized:wow:boss:6243", + "randomized:wow:boss:9033", + "randomized:wow:boss:28586", + "randomized:wow:boss:10363", + "randomized:wow:boss:4887", + "randomized:wow:boss:18105", + "randomized:wow:boss:1763", + "randomized:rom:boss:102475", + "randomized:wow:boss:8567", + "randomized:rom:boss:101500", + "randomized:wow:boss:8983", + "randomized:wow:boss:26687", + "randomized:wow:boss:26731", + "randomized:wow:boss:16807", + "randomized:wow:boss:18732", + "randomized:wow:boss:4854", + "randomized:wow:boss:14321", + "randomized:wow:boss:14326", + "randomized:wow:boss:14323", + "randomized:rom:boss:102395", + "randomized:wow:boss:28921", + "randomized:wow:boss:10220", + "randomized:wow:boss:1717", + "randomized:wow:boss:20912", + "randomized:wow:boss:29311", + "randomized:wow:boss:3975", + "randomized:wow:boss:17975", + "randomized:wow:boss:3977", + "randomized:wow:boss:9018", + "randomized:rom:boss:100563", + "randomized:rom:boss:102307", + "randomized:rom:boss:102643", + "randomized:wow:boss:9319", + "randomized:wow:boss:3974", + "randomized:wow:boss:17770", + "randomized:wow:boss:17797", + "randomized:wow:boss:7795", + "randomized:wow:boss:13280", + "randomized:rom:boss:101881", + "randomized:wow:boss:36476", + "randomized:wow:boss:11488", + "randomized:wow:boss:11496", + "randomized:wow:boss:23954", + "randomized:wow:boss:10505", + "randomized:wow:boss:28546", + "randomized:rom:boss:101507", + "randomized:wow:boss:7228", + "randomized:rom:boss:105543", + "randomized:wow:boss:5710", + "randomized:wow:boss:10503", + "randomized:wow:boss:29310", + "randomized:wow:boss:11518", + "randomized:rom:boss:102021", + "randomized:wow:boss:24664", + "randomized:wow:boss:1666", + "randomized:wow:boss:17377", + "randomized:wow:boss:26723", + "randomized:wow:boss:27483", + "randomized:wow:boss:11501", + "randomized:wow:boss:26861", + "randomized:wow:boss:3653", + "randomized:wow:boss:28684", + "randomized:wow:boss:27977", + "randomized:wow:boss:3671", + "randomized:wow:boss:10502", + "randomized:wow:boss:4831", + "randomized:wow:boss:17980", + "randomized:wow:boss:12203", + "randomized:rom:boss:105351", + "randomized:wow:boss:14327", + "randomized:wow:boss:27656", + "randomized:wow:boss:17848", + "randomized:rom:boss:102349", + "randomized:rom:boss:104228", + "randomized:rom:boss:101502", + "randomized:wow:boss:28923", + "randomized:rom:boss:101534", + "randomized:wow:boss:10504", + "randomized:wow:boss:3669", + "randomized:wow:boss:14506", + "randomized:wow:boss:9017", + "randomized:wow:boss:3670", + "randomized:wow:boss:9025", + "randomized:wow:boss:3673", + "randomized:wow:boss:12236", + "randomized:wow:boss:10901", + "randomized:wow:boss:12902", + "randomized:rom:boss:102623", + "randomized:wow:boss:27655", + "randomized:wow:boss:11487", + "randomized:wow:boss:10435", + "randomized:wow:boss:9938", + "randomized:wow:boss:27975", + "randomized:wow:boss:26533", + "randomized:wow:boss:10438", + "randomized:wow:boss:11032", + "randomized:rom:boss:102452", + "randomized:wow:boss:38113", + "randomized:wow:boss:26529", + "randomized:wow:boss:19219", + "randomized:rom:boss:102040", + "randomized:wow:boss:17796", + "randomized:wow:boss:7800", + "randomized:wow:boss:17941", + "randomized:wow:boss:12237", + "randomized:wow:boss:35572", + "randomized:rom:boss:101884", + "randomized:wow:boss:29305", + "randomized:wow:boss:29316", + "randomized:wow:boss:7357", + "randomized:wow:boss:10596", + "randomized:wow:boss:646", + "randomized:wow:boss:18708", + "randomized:wow:boss:3654", + "randomized:rom:boss:102701", + "randomized:rom:boss:100070", + "randomized:wow:boss:10437", + "randomized:wow:boss:19221", + "randomized:wow:boss:18344", + "randomized:wow:boss:26631", + "randomized:wow:boss:13282", + "randomized:wow:boss:7023", + "randomized:wow:boss:4279", + "randomized:wow:boss:11517", + "randomized:wow:boss:17308", + "randomized:wow:boss:26794", + "randomized:wow:boss:4420", + "randomized:wow:boss:9568", + "randomized:wow:boss:18341", + "randomized:rom:boss:101268", + "randomized:wow:boss:19220", + "randomized:wow:boss:7356", + "randomized:wow:boss:23953", + "randomized:wow:boss:29308", + "randomized:wow:boss:12201", + "randomized:rom:boss:102560", + "randomized:wow:boss:9816", + "randomized:wow:boss:9024", + "randomized:wow:boss:17942", + "randomized:wow:boss:9736", + "randomized:wow:boss:7354", + "randomized:rom:boss:101433", + "randomized:rom:boss:102197", + "randomized:wow:boss:10508", + "randomized:wow:boss:11622", + "randomized:rom:boss:100154", + "randomized:wow:boss:12258", + "randomized:wow:boss:3914", + "randomized:wow:boss:6910", + "randomized:wow:boss:644", + "randomized:wow:boss:17991", + "randomized:wow:boss:6168", + "randomized:rom:boss:101432", + "randomized:rom:boss:102196", + "randomized:wow:boss:35571", + "randomized:wow:boss:26530", + "randomized:rom:boss:100645", + "randomized:wow:boss:36658", + "randomized:wow:boss:24723", + "randomized:wow:boss:5709", + "randomized:rom:boss:103946", + "randomized:wow:boss:9236", + "randomized:wow:boss:18371", + "randomized:wow:boss:27978", + "randomized:wow:boss:26693", + "randomized:wow:boss:3674", + "randomized:wow:boss:29304", + "randomized:wow:boss:642", + "randomized:rom:boss:101535", + "randomized:wow:boss:10264", + "randomized:rom:boss:102195", + "randomized:wow:boss:9218", + "randomized:wow:boss:9219", + "randomized:wow:boss:26668", + "randomized:wow:boss:17826", + "randomized:wow:boss:18473", + "randomized:wow:boss:11520", + "randomized:wow:boss:1696", + "randomized:wow:boss:18343", + "randomized:rom:boss:102041", + "randomized:rom:boss:104161", + "randomized:rom:boss:107306", + "randomized:wow:boss:17880", + "randomized:wow:boss:10430", + "randomized:wow:boss:35451", + "randomized:wow:boss:17882", + "randomized:wow:boss:17381", + "randomized:wow:boss:26632", + "randomized:wow:boss:10516", + "randomized:wow:boss:17978", + "randomized:rom:boss:101584", + "randomized:wow:boss:13601", + "randomized:wow:boss:26630", + "randomized:wow:boss:7355", + "randomized:wow:boss:4832", + "randomized:rom:boss:105701", + "randomized:wow:boss:27447", + "randomized:wow:boss:5775", + "randomized:wow:boss:24744", + "randomized:wow:boss:7079", + "randomized:wow:boss:28587", + "randomized:wow:boss:9237", + "randomized:wow:boss:16809", + "randomized:wow:boss:16808", + "randomized:wow:boss:10429", + "randomized:wow:boss:9041", + "randomized:wow:boss:17798", + "randomized:wow:boss:17977", + "randomized:wow:boss:17306", + "randomized:wow:boss:7271", + "randomized:wow:boss:3927", + "randomized:wow:boss:20886", + "randomized:rom:boss:101501", + "randomized:wow:boss:20870", + "randomized:wow:boss:11490", + "randomized:rom:boss:102425" + ] + } + ], + "defaultDifficultyId": "normal" + }, + { + "id": "randomized-bite-sized-stormwind-stockade-available", + "title": "Randomized Bite-Sized: Bite-Sized Sources", + "finalBossId": "randomized:wow:boss:1716", + "bosses": [ + { + "id": "randomized:wow:boss:1716", + "name": "Bazil Thredd" + }, + { + "id": "randomized:rom:boss:101018", + "name": "Blackhorn Afalen" + }, + { + "id": "randomized:rom:boss:101019", + "name": "Blackhorn Bareth" + }, + { + "id": "randomized:rom:boss:101021", + "name": "Blackhorn Dorglas" + }, + { + "id": "randomized:rom:boss:100794", + "name": "Blackhorn Hafiz" + }, + { + "id": "randomized:rom:boss:101020", + "name": "Blackhorn Nisorn" + }, + { + "id": "randomized:wow:boss:647", + "name": "Captain Greenskin" + }, + { + "id": "randomized:wow:boss:645", + "name": "Cookie" + }, + { + "id": "randomized:rom:boss:100562", + "name": "Cruel Eater" + }, + { + "id": "randomized:rom:boss:100138", + "name": "Cursed lron Golem" + }, + { + "id": "randomized:rom:boss:100074", + "name": "Demon Witch Ancalon" + }, + { + "id": "randomized:wow:boss:5912", + "name": "Deviate Faerie Dragon" + }, + { + "id": "randomized:wow:boss:1663", + "name": "Dextren Ward" + }, + { + "id": "randomized:rom:boss:100611", + "name": "Dorlos" + }, + { + "id": "randomized:wow:boss:639", + "name": "Edwin VanCleef" + }, + { + "id": "randomized:rom:boss:100561", + "name": "Flowing Chaos" + }, + { + "id": "randomized:wow:boss:1763", + "name": "Gilnid" + }, + { + "id": "randomized:wow:boss:26687", + "name": "Gortok Palehoof" + }, + { + "id": "randomized:wow:boss:1717", + "name": "Hamhock" + }, + { + "id": "randomized:rom:boss:100563", + "name": "Hollow Shell" + }, + { + "id": "randomized:wow:boss:1666", + "name": "Kam Deepfury" + }, + { + "id": "randomized:wow:boss:26861", + "name": "King Ymiron" + }, + { + "id": "randomized:wow:boss:3653", + "name": "Kresh" + }, + { + "id": "randomized:wow:boss:3671", + "name": "Lady Anacondra" + }, + { + "id": "randomized:wow:boss:3669", + "name": "Lord Cobrahn" + }, + { + "id": "randomized:wow:boss:3670", + "name": "Lord Pythas" + }, + { + "id": "randomized:wow:boss:3673", + "name": "Lord Serpentis" + }, + { + "id": "randomized:wow:boss:646", + "name": "Mr. Smite" + }, + { + "id": "randomized:wow:boss:3654", + "name": "Mutanus the Devourer" + }, + { + "id": "randomized:rom:boss:100070", + "name": "Necromage" + }, + { + "id": "randomized:rom:boss:100154", + "name": "Razeela" + }, + { + "id": "randomized:wow:boss:644", + "name": "Rhahk'Zor" + }, + { + "id": "randomized:wow:boss:26693", + "name": "Skadi the Ruthless" + }, + { + "id": "randomized:wow:boss:3674", + "name": "Skum" + }, + { + "id": "randomized:wow:boss:642", + "name": "Sneed's Shredder" + }, + { + "id": "randomized:wow:boss:26668", + "name": "Svala Sorrowgrave" + }, + { + "id": "randomized:wow:boss:1696", + "name": "Targorr the Dread" + }, + { + "id": "randomized:wow:boss:5775", + "name": "Verdan the Everliving" + } + ], + "difficulties": [ + { + "id": "normal", + "name": "normal", + "bossIds": [ + "randomized:wow:boss:1716", + "randomized:rom:boss:101018", + "randomized:rom:boss:101019", + "randomized:rom:boss:101021", + "randomized:rom:boss:100794", + "randomized:rom:boss:101020", + "randomized:wow:boss:647", + "randomized:wow:boss:645", + "randomized:rom:boss:100562", + "randomized:rom:boss:100138", + "randomized:rom:boss:100074", + "randomized:wow:boss:5912", + "randomized:wow:boss:1663", + "randomized:rom:boss:100611", + "randomized:wow:boss:639", + "randomized:rom:boss:100561", + "randomized:wow:boss:1763", + "randomized:wow:boss:26687", + "randomized:wow:boss:1717", + "randomized:rom:boss:100563", + "randomized:wow:boss:1666", + "randomized:wow:boss:26861", + "randomized:wow:boss:3653", + "randomized:wow:boss:3671", + "randomized:wow:boss:3669", + "randomized:wow:boss:3670", + "randomized:wow:boss:3673", + "randomized:wow:boss:646", + "randomized:wow:boss:3654", + "randomized:rom:boss:100070", + "randomized:rom:boss:100154", + "randomized:wow:boss:644", + "randomized:wow:boss:26693", + "randomized:wow:boss:3674", + "randomized:wow:boss:642", + "randomized:wow:boss:26668", + "randomized:wow:boss:1696", + "randomized:wow:boss:5775" + ], + "finalBossId": "randomized:wow:boss:1716", + "finalBossIds": [ + "randomized:wow:boss:1716", + "randomized:rom:boss:101018", + "randomized:rom:boss:101019", + "randomized:rom:boss:101021", + "randomized:rom:boss:100794", + "randomized:rom:boss:101020", + "randomized:wow:boss:647", + "randomized:wow:boss:645", + "randomized:rom:boss:100562", + "randomized:rom:boss:100138", + "randomized:rom:boss:100074", + "randomized:wow:boss:5912", + "randomized:wow:boss:1663", + "randomized:rom:boss:100611", + "randomized:wow:boss:639", + "randomized:rom:boss:100561", + "randomized:wow:boss:1763", + "randomized:wow:boss:26687", + "randomized:wow:boss:1717", + "randomized:rom:boss:100563", + "randomized:wow:boss:1666", + "randomized:wow:boss:26861", + "randomized:wow:boss:3653", + "randomized:wow:boss:3671", + "randomized:wow:boss:3669", + "randomized:wow:boss:3670", + "randomized:wow:boss:3673", + "randomized:wow:boss:646", + "randomized:wow:boss:3654", + "randomized:rom:boss:100070", + "randomized:rom:boss:100154", + "randomized:wow:boss:644", + "randomized:wow:boss:26693", + "randomized:wow:boss:3674", + "randomized:wow:boss:642", + "randomized:wow:boss:26668", + "randomized:wow:boss:1696", + "randomized:wow:boss:5775" ] } ], diff --git a/src/game/randomizedBiteSizedStockades.test.ts b/src/game/randomizedBiteSizedStockades.test.ts index 3cccec89..9b126c08 100644 --- a/src/game/randomizedBiteSizedStockades.test.ts +++ b/src/game/randomizedBiteSizedStockades.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from "vitest"; import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID, } from "./dungeonContentSource"; import { DUNGEON_REGISTRY, RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, + AVAILABLE_RANDOMIZED_CREATURE_CATALOG, + BITE_SIZED_RANDOMIZED_CREATURE_CATALOG, dungeonDefinitionById, + randomizedBiteSizedDefinitionForSeed, + dungeonCanEnter, } from "./dungeonRegistry"; +import { dungeonLeaderboardCatalog } from "./dungeonLeaderboardCatalog"; +import { seededBossIndex } from "./dungeons/seededBossSelection"; import { activateDungeonSession } from "./dungeonSession"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; 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 second = create("shared-run"); expect(second.staticSpawns).toEqual(first.staticSpawns); expect(second.bosses).toEqual(first.bosses); - const bossEntityIds = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.entityId)); const observedBossEntityIds = new Set(); const observedTrashLineups = new Set(); for (let index = 0; index < 2_000; index += 1) { @@ -101,28 +107,48 @@ describe("Randomized Bite-Sized Stockades", () => { .map((spawn) => spawn.entityId) .join("|")); } - expect([...observedBossEntityIds].every((entityId) => bossEntityIds.has(entityId))).toBe(true); - expect(observedBossEntityIds.size).toBeGreaterThan(100); + expect([...observedBossEntityIds].every((entityId) => catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === entityId))).toBe(true); + expect(observedBossEntityIds.size).toBeGreaterThan(50); expect(observedTrashLineups.size).toBeGreaterThan(1_900); 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); }); - 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 boss = randomized.bosses[0]; const spawn = randomized.staticSpawns.find((candidate) => candidate.id === boss.id)!; const entity = randomized.entities[spawn.entityId]; - const base = createBiteSizedStockadesDefinition(source, "random-boss-objective:objective-slot"); - const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === boss.id)!; - const originalBoss = base.entities[originalSpawn.entityId]; + const candidate = catalog.find((entry) => `randomized:${entry.key}` === boss.id)!; expect(entity.kind).toBe("boss"); expect(boss.name).toBe(entity.name); - expect(catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === spawn.entityId)).toBe(true); - expect(entity.combat?.serverEntry).not.toBe(originalBoss.combat?.serverEntry); + expect(candidate.kind).toBe("boss"); + expect(entity.combat?.serverEntry).toBe(candidate.definition.combat?.serverEntry); 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)]); + } }); }); diff --git a/src/styles.css b/src/styles.css index d176f8b2..f20299cf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4673,6 +4673,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } .randomized-bite-sized-screen__odds span, .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__pool [aria-pressed="true"] { border-color: #e4be70; background: #44371f; color: #ffe4a3; } .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; } diff --git a/src/ui/DungeonLeaderboard.tsx b/src/ui/DungeonLeaderboard.tsx index aaed99ab..1bab614b 100644 --- a/src/ui/DungeonLeaderboard.tsx +++ b/src/ui/DungeonLeaderboard.tsx @@ -6,7 +6,7 @@ import "./dungeonRuns.css"; export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter, expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty, - selectedId, select }: { + bossLabel, onBoss, selectedId, select }: { readonly dungeonTitle: string; readonly data: LeaderboardData; readonly scope: LeaderboardScope; @@ -20,6 +20,8 @@ export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, readonly onRefresh: () => void; readonly difficultyLabel: string; readonly onDifficulty?: () => void; + readonly bossLabel?: string; + readonly onBoss?: () => void; readonly selectedId: string; readonly select: (id: string) => void; }) { @@ -35,6 +37,8 @@ export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onClick={() => onFilter("full")}>Full dungeon clears All bosses
+ {onBoss ? Boss: {bossLabel} ↻ : null} {onDifficulty ? {difficultyLabel} ↻ : {difficultyLabel}} diff --git a/src/ui/DungeonRaceScreen.tsx b/src/ui/DungeonRaceScreen.tsx index e0cd1c7e..12fbe44f 100644 --- a/src/ui/DungeonRaceScreen.tsx +++ b/src/ui/DungeonRaceScreen.tsx @@ -7,6 +7,7 @@ import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot"; import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog"; import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy"; import { formatDungeonDuration } from "../game/dungeonRunTypes"; +import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog"; import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore"; import { specializationById } from "../game/specializationCatalog"; import { BrandMark, FrontSurface } from "./FrontSurface"; @@ -31,6 +32,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) const [period, setPeriod] = useState<"30d" | "all">("30d"); const [boardScope, setBoardScope] = useState<"overall" | "dungeon">("overall"); const [boardView, setBoardView] = useState<"wins" | "times">("wins"); + const [boardBossId, setBoardBossId] = useState(""); const [board, setBoard] = useState(null); const [boardError, setBoardError] = useState(""); 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)), [pool, policy]); 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(() => { const controller = new AbortController(); 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; } void requestOnlineJson(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal }) .then((value) => { setBoard(value); setBoardError(""); }) .catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null); setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } }); return () => controller.abort(); - }, [boardDungeonId, boardScope, boardView, period]); + }, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId]); const specialization = specializationById(character?.specializationId); const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId) : specialization ? [specialization.role] : []; @@ -80,8 +87,8 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
-
@@ -116,6 +123,10 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
{boardScope === "dungeon" &&
} + {boardScope === "dungeon" && bossOptions.length > 1 && }
{boardError &&

{boardError}

} diff --git a/src/ui/DungeonSelectScreen.tsx b/src/ui/DungeonSelectScreen.tsx index 9c2fefa7..cd3f4e61 100644 --- a/src/ui/DungeonSelectScreen.tsx +++ b/src/ui/DungeonSelectScreen.tsx @@ -22,6 +22,7 @@ import { useMenuController, type MenuAction } from "../input/useMenuController"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { ControllerButton } from "./ControllerButton"; import { DungeonLeaderboard } from "./DungeonLeaderboard"; +import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog"; import { useDungeonLeaderboard } from "./useDungeonLeaderboard"; import type { DungeonClearFilter } from "../game/dungeonRunTypes"; import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient"; @@ -89,13 +90,23 @@ export function DungeonSelectScreen() { const [clearFilter, setClearFilter] = useState("final"); const [difficultyId, setDifficultyId] = useState(""); const [bossDifficulty, setBossDifficulty] = useState(1); + const [leaderboardBossId, setLeaderboardBossId] = useState(""); const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const [expandedRuns, setExpandedRuns] = useState>(new Set()); const [leaderboardOpen, setLeaderboardOpen] = useState(false); const [readinessOpen, setReadinessOpen] = useState(false); const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId) ?? 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 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-full", run: () => setClearFilter("full") }, { 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: "boss-difficulty-down", run: () => setBossDifficulty((value) => Math.max(1, value - 1)), enabled: bossDifficulty > 1 }, @@ -239,7 +251,7 @@ export function DungeonSelectScreen() { run: () => setRolePickerOpen(false), }] : []), { 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, { initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back", onBack: pendingDownload @@ -426,6 +438,7 @@ export function DungeonSelectScreen() { expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset} onRefresh={leaderboard.refresh} difficultyLabel={`${bossDifficultyLabel(bossDifficulty)} · ${leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}`} onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined} + bossLabel={selectedBoss?.name} onBoss={hasBossBoards ? cycleBoss : undefined} selectedId={controller.selectedId} select={controller.select} /> )} diff --git a/src/ui/RandomizedBiteSizedScreen.tsx b/src/ui/RandomizedBiteSizedScreen.tsx index 46467354..b79c84e8 100644 --- a/src/ui/RandomizedBiteSizedScreen.tsx +++ b/src/ui/RandomizedBiteSizedScreen.tsx @@ -8,12 +8,13 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa import { useContentManagerSnapshot } from "../content/useContentManager"; import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, dungeonContentPackId, } from "../game/dungeonContentSource"; -import { dungeonById, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog"; +import { dungeonById } from "../game/dungeonCatalog"; import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy"; 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 { DungeonDifficultySlider } from "./DungeonDifficultySlider"; import { OnlineGroupPanel } from "./OnlineGroupPanel"; @@ -23,9 +24,6 @@ import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedC installDungeonShellRuntime(); -const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); -const CANDIDATES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG; - export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) { useContentManagerSnapshot(); const activeCharacter = useShellStore((state) => state.activeCharacter); @@ -37,6 +35,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember); const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity); const [bossDifficulty, setBossDifficulty] = useState(1); + const [pool, setPool] = useState<"bite-sized" | "all">("all"); const [downloading, setDownloading] = useState(false); const [error, setError] = useState(""); const [readinessOpen, setReadinessOpen] = useState(false); @@ -48,7 +47,10 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => const roles = character ? 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 bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length; const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON)); @@ -56,8 +58,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => const launch = async (role: PartyRole) => { if (!character || !DUNGEON?.available) return; const policy = await loadDungeonQueuePolicy(); - const selectedDungeon = randomRandomizedBiteSizedDungeon(undefined, Math.random, - (candidate) => dungeonEnabledForQueue(policy, candidate)); + const selectedDungeon = DUNGEON && dungeonEnabledForQueue(policy, DUNGEON) ? DUNGEON : null; if (!selectedDungeon) { setError("No Randomized Bite-Sized location is enabled for this queue."); return; @@ -66,7 +67,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => const runId = globalThis.crypto.randomUUID(); setDownloading(true); setError(""); try { - await ensureRandomizedBiteSizedRunContent(runId); + await ensureRandomizedBiteSizedRunContent(runId, undefined, selectedDungeon.id); enterDungeon(selectedDungeon.id, role, { difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId, bossDifficulty, @@ -99,8 +100,8 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => const install = async () => { setDownloading(true); setError(""); try { - await installContentPack(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)); - if (!dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)) { + await installContentPack(dungeonContentPackId(dungeonId)); + if (!dungeonContentIsUsable(dungeonId)) { throw new Error("The Stockades content pack could not be verified."); } } catch (caught) { @@ -123,7 +124,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>

A different lineup every run

Randomized Bite-Sized

-

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.

+

Cellblock Shuffle deals a different mob into every corridor slot and one final boss. Both come from the creature pool you choose.

{mobCount}mob types in the shuffle
{bossCount}possible final bosses
@@ -135,6 +136,11 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>

Run setup

+
+ Creature pool + + +

{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}

undefined} /> {!queueEnabled ? null : !installed ? ( diff --git a/src/ui/dungeonRuns.test.ts b/src/ui/dungeonRuns.test.ts index 4f144e7f..0f4252da 100644 --- a/src/ui/dungeonRuns.test.ts +++ b/src/ui/dungeonRuns.test.ts @@ -54,4 +54,12 @@ describe("dungeon results and leaderboard", () => { expect(html).toContain(text); 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(), 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"); + }); }); diff --git a/src/ui/useDungeonLeaderboard.ts b/src/ui/useDungeonLeaderboard.ts index 30e280e7..a5f28b6a 100644 --- a/src/ui/useDungeonLeaderboard.ts +++ b/src/ui/useDungeonLeaderboard.ts @@ -3,13 +3,14 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade type LeaderboardScope } from "../app/dungeonLeaderboardClient"; 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 [revision, setRevision] = useState(0); const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({ 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(() => { const refresh = () => setRevision((value) => value + 1); @@ -21,12 +22,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f let live = true; const timeout = setTimeout(() => controller.abort(), 8000); 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 }); }) - .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 }); }); else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false }); 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) }; }