Update Healer Man server

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

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const out = path.resolve('playtest-artifacts/pause-memory');
fs.mkdirSync(out, { recursive: true });
async function sample(page, cdp, label) {
await cdp.send('HeapProfiler.collectGarbage');
const metrics = await cdp.send('Performance.getMetrics');
const values = Object.fromEntries(metrics.metrics.map(({ name, value }) => [name, value]));
const scene = await page.locator('.scene-shell').evaluate((element) => ({
frameLoop: element.dataset.frameLoop,
performance: document.documentElement.dataset.gamePerformance ?? null,
}));
return { label, heapMiB: Math.round(values.JSHeapUsedSize / 1048576),
nodes: values.Nodes, documents: values.Documents, ...scene };
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: path.join(out, 'test-database'), enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4190, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(45000);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
await cdp.send('HeapProfiler.enable');
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
try {
console.log('Opening game');
await page.goto(process.env.PAUSE_TEST_URL || 'http://127.0.0.1:4190', { waitUntil: 'domcontentloaded' });
console.log('Choosing offline play');
await page.getByRole('button', { name: /Play offline/ }).click();
console.log('Creating character');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Memorytester');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
console.log('Entering adventure menu');
await page.locator('.main-menu-screen').waitFor();
console.log('Choosing bundled Wailing Caverns');
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await page.locator('.dungeon-role-option--healer').click();
console.log('Waiting for scene');
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120000 });
await page.waitForTimeout(2000);
await page.screenshot({ path: path.join(out, 'before-pause.png') });
await page.keyboard.press('Escape');
console.log('Waiting for pause');
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
await page.screenshot({ path: path.join(out, 'paused.png') });
const samples = [await sample(page, cdp, 'paused-start')];
console.log(JSON.stringify(samples[0]));
for (const seconds of [15, 30, 60]) {
await page.waitForTimeout((seconds - (samples.length === 1 ? 0 : [0, 15, 30][samples.length - 1])) * 1000);
samples.push(await sample(page, cdp, `paused-${seconds}s`));
console.log(JSON.stringify(samples.at(-1)));
}
fs.writeFileSync(path.join(out, 'results.json'), `${JSON.stringify({ samples, errors }, null, 2)}\n`);
console.log(JSON.stringify({ samples, errors }, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(out, 'failure.png') });
console.error((await page.locator('body').innerText()).slice(-2000));
console.error(errors);
throw error;
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})();
+1 -1
View File
@@ -872,7 +872,7 @@ export function openGameDatabase(options = {}) {
}
const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom";
const 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");
}
+48 -12
View File
@@ -3,6 +3,18 @@ import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.j
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
const 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) };
},
};
+42 -6
View File
@@ -58,18 +58,34 @@ test("validates final boss, dungeon/difficulty, duration and party without trust
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", 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(); }
+68 -5
View File
@@ -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" });
+37 -10
View File
@@ -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,
+1
View File
@@ -5,6 +5,7 @@ import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set([
"bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"forsaken-abbey",
...Object.keys(availability.enabled),
]);
+2
View File
@@ -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;
+1 -1
View File
@@ -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]);
});
+6 -6
View File
@@ -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<void>;
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string, dungeonId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
readonly installManastormRuntime: () => Promise<void>;
@@ -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, {
+1 -1
View File
@@ -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",
+5 -4
View File
@@ -23,7 +23,7 @@ interface BiteSizedReplayDependencies {
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly prepareRandomizedContent: (seed: string) => Promise<void>;
readonly prepareRandomizedContent: (seed: string, dungeonId: string) => Promise<void>;
}
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;
+17
View File
@@ -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);
+20 -7
View File
@@ -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<DungeonLeaderboard> {
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
bossId?: string): Promise<DungeonLeaderboard> {
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 });
}
+1 -1
View File
@@ -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<void> {
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed);
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
}
@@ -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);
+5 -4
View File
@@ -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<void> {
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));
}
+2 -2
View File
@@ -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,
+5
View File
@@ -47,6 +47,11 @@ export function bossCardByKey(key: string): BossCardDefinition | null {
}
export function bossCardForSource(sourceId: string, dungeonId: string, name?: string): BossCardDefinition | null {
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))
+2 -1
View File
@@ -20,7 +20,8 @@ export function dungeonBossCards(
): readonly DungeonBossCard[] {
const seen = new Set<string>();
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);
+5 -4
View File
@@ -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());
});
});
+3
View File
@@ -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");
});
});
+15 -2
View File
@@ -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<string> = 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<Record<string, string>> = 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<Record<string, string>> = 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)}`;
+29 -14
View File
@@ -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`);
+15
View File
@@ -0,0 +1,15 @@
import generated from "./generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonLeaderboardDifficultyRule } from "./dungeonLeaderboardRules";
export interface DungeonLeaderboardCatalogEntry {
readonly id: string;
readonly title: string;
readonly defaultDifficultyId: string;
readonly bosses: readonly { readonly id: string; readonly name: string }[];
readonly difficulties: readonly (DungeonLeaderboardDifficultyRule & {
readonly id: string;
readonly name: string;
})[];
}
export const dungeonLeaderboardCatalog = generated as { readonly dungeons: readonly DungeonLeaderboardCatalogEntry[] };
+5
View File
@@ -8,6 +8,11 @@ export function leaderboardFinalBossIds(rule: DungeonLeaderboardDifficultyRule):
return rule.finalBossIds?.length ? rule.finalBossIds : [rule.finalBossId];
}
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[],
+56 -14
View File
@@ -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<Record<string, boolean>>)[source.id];
return Boolean(source.assets.visual.length && source.assets.collision.length
&& (source.expansion === "runewaker"
? dungeonPackagePassesOfflineGate(source) && configured !== false
: configured === true));
}
const availableRandomizedSources = adaptedSourceEntries.map(([, definition]) => definition).filter(sourceIsAvailable);
export const AVAILABLE_RANDOMIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(availableRandomizedSources),
);
export const BITE_SIZED_RANDOMIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(availableRandomizedSources.filter((source) => AVAILABLE_BITE_SIZED_SOURCE_IDS.has(source.id))),
);
export function randomizedBiteSizedDefinitionForSeed(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): DungeonDefinition {
if (dungeonId !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
&& dungeonId !== RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID) {
throw new Error(`Unknown Randomized Bite-Sized pool: ${dungeonId}`);
}
return createRandomizedBiteSizedStockadesDefinition(
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;
}
+24 -1
View File
@@ -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<string>();
for (let index = 0; index < 500; index += 1) {
const definition = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const again = prepareDungeonSessionDefinition(id, `rom-boss-seed-${index}`)!;
const boss = definition.bosses[0];
selected.add(boss.id);
expect(again.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.optional).toBe(false);
expect(definition.encounterSpawnGroups![0].spawns[0].position).toEqual(anchor);
for (const difficulty of definition.difficultyVariants) {
expect((definition.encounterSpawnGroups![0].spawns[0].spawnMask! & difficulty.spawnMask)).not.toBe(0);
}
expect(source.bosses.map((candidate) => candidate.id)).toContain(boss.id);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => {
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) => {
+35 -25
View File
@@ -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);
}
+2 -10
View File
@@ -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 {
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { dungeonContentSourceId } from "../dungeonContentSource";
import { DUNGEON_REGISTRY, prepareDungeonSessionDefinition } from "../dungeonRegistry";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
import { finalDungeonBoss } from "../dungeonRunStore";
const ids = ["bite-sized-wailing-caverns", "bite-sized-deadmines-ship",
"bite-sized-utgarde-pinnacle-skadi"] as const;
describe("WoW bite-sized seeded bosses", () => {
it.each(ids)("selects every %s source boss at a stable finale anchor", (id) => {
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
const anchor = DUNGEON_REGISTRY[id].bosses[0].position;
const selected = new Set<string>();
for (let index = 0; index < 500; index += 1) {
const seed = `wow-slice-${index}`;
const definition = prepareDungeonSessionDefinition(id, seed)!;
const boss = definition.bosses[0];
const spawn = definition.encounterSpawnGroups![0].spawns[0];
selected.add(boss.id);
expect(prepareDungeonSessionDefinition(id, seed)!.bosses[0].id).toBe(boss.id);
expect(boss.position).toEqual(anchor);
expect(boss.eventOnly).toBe(true);
expect(boss.optional).toBe(false);
expect(spawn.id).toBe(boss.id);
expect(spawn.position).toEqual(anchor);
expect(definition.entities[spawn.entityId].kind).toBe("boss");
expect(finalDungeonBoss(definition)?.id).toBe(boss.id);
expect(definition.staticSpawns.every((entry) => definition.entities[entry.entityId].kind !== "boss")).toBe(true);
}
expect(selected).toEqual(new Set(source.bosses.map((boss) => boss.id)));
});
it.each(ids)("gates the %s boss on retained mobs and completes on its death", (id) => {
const definition = prepareDungeonSessionDefinition(id, "encounter-run")!;
const encounter = definition.encounters![0];
const mobs = definition.staticSpawns.map((spawn) => spawn.id);
expect(mobs.length).toBeGreaterThan(0);
let snapshot = createDungeonEncounterSnapshot(encounter);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100,
facts: { registeredEntityIds: mobs, deadEntityIds: mobs.slice(0, -1) } }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 200,
facts: { registeredEntityIds: mobs, deadEntityIds: mobs } }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([definition.encounterSpawnGroups![0].id]);
const bossId = definition.bosses[0].id;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 300,
facts: { registeredEntityIds: [bossId], deadEntityIds: [bossId] } }).snapshot;
expect(snapshot.completedObjectiveIds).toContain(bossId);
expect(snapshot.flags["dungeon-complete"]).toBe(true);
});
});
+65 -92
View File
@@ -18,6 +18,7 @@ import {
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_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",
},
@@ -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<Record<string, PopulationEntityDefinition>>;
const boss = { ...base.bosses[0], name: bossCandidate.name };
const entities = { ...randomizedEntities, [selectedBoss.id]: selectedBoss };
const boss = { ...base.bosses[0], id: selectedBossId, name: selectedBoss.name };
return {
...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,
+9
View File
@@ -0,0 +1,9 @@
/** A run id selects one stable boss for saves and every member of a shared party. */
export function seededBossIndex(seed: string, candidateCount: number): number {
let hash = 0x811c9dc5;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % candidateCount;
}
+49
View File
@@ -0,0 +1,49 @@
import type { DungeonBossObjective, DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
import { seededBossIndex } from "./seededBossSelection";
export interface SourceBossCandidate {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
}
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
/** Resolves ordinary, event, and roaming source bosses into a reusable one-boss spawn. */
export function sourceBossCandidates(source: DungeonDefinition): readonly SourceBossCandidate[] {
return source.bosses.map((objective) => {
const namedBoss = (entityId: string) => {
const entity = source.entities[entityId];
return entity?.kind === "boss" && normalizedName(entity.name) === normalizedName(objective.name);
};
const staticSpawn = source.staticSpawns.find((spawn) => spawn.id === objective.id
&& source.entities[spawn.entityId]?.kind === "boss");
const groupSpawn = source.encounterSpawnGroups?.flatMap((group) => group.spawns)
.filter((spawn) => namedBoss(spawn.entityId)).at(-1);
const roamingMember = source.roamingPacks.flatMap((pack) => pack.members)
.find((member) => namedBoss(member.entityId));
const spawn = staticSpawn ?? groupSpawn ?? (roamingMember ? {
id: objective.id, entityId: roamingMember.entityId, position: objective.position, yaw: 0,
} : undefined);
const entity = spawn && source.entities[spawn.entityId];
if (!spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing a usable source spawn for ${objective.name}.`);
}
return { objective, spawn, entity };
});
}
export function seededSourceBoss(source: DungeonDefinition, seed: string): SourceBossCandidate {
const candidates = sourceBossCandidates(source);
if (!candidates.length) throw new Error(`${source.id} has no source bosses to select.`);
return candidates[seededBossIndex(seed, candidates.length)];
}
export function finaleBossSpawn(source: DungeonDefinition, candidate: SourceBossCandidate,
position: DungeonBossObjective["position"]): StaticMobSpawnDefinition {
return { ...candidate.spawn, id: candidate.objective.id, position,
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
}
File diff suppressed because it is too large. Load diff
+37 -11
View File
@@ -1,13 +1,20 @@
import { describe, expect, it } from "vitest";
import {
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<string>();
const observedTrashLineups = new Set<string>();
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)]);
}
});
});
+1
View File
@@ -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; }
+5 -1
View File
@@ -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 <small>All bosses</small></ControllerButton>
</div>
<div className="dungeon-leaderboard__meta">
{onBoss ? <ControllerButton {...controls} controlId="leaderboard-boss" onClick={onBoss}
aria-label={`Leaderboard boss: ${bossLabel}. Change boss`}>Boss: {bossLabel} ↻</ControllerButton> : null}
{onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty}
aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton>
: <span>{difficultyLabel}</span>}
+15 -4
View File
@@ -7,6 +7,7 @@ import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { 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<BoardResponse | null>(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<BoardResponse>(`/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 })
<button className="front-button" type="button" aria-pressed={pool === "randomized"} disabled={busy}
onClick={() => { setPool("randomized"); setDungeonId(""); }}>Randomized Bite-Sized</button>
</div>
<label>Dungeon<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
<option value="">Random eligible dungeon</option>
<label>{pool === "randomized" ? "Creature pool" : "Dungeon"}<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
<option value="">{pool === "randomized" ? "Random available creature pool" : "Random eligible dungeon"}</option>
{allEntries.map((entry) => <option key={entry.id} value={entry.id}>{entry.title}</option>)}
</select></label>
<div className="dungeon-race-screen__choices" role="group" aria-label="Your role">
@@ -116,6 +123,10 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
<button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>}
{boardScope === "dungeon" && bossOptions.length > 1 && <label>Boss<select value={selectedBoardBossId}
onChange={(event) => setBoardBossId(event.target.value)}>
{bossOptions.map((boss) => <option key={boss.id} value={boss.id}>{boss.name}</option>)}
</select></label>}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={period === "30d"} onClick={() => setPeriod("30d")}>Last 30 days</button>
<button className="front-button" type="button" aria-pressed={period === "all"} onClick={() => setPeriod("all")}>All time</button></div>
{boardError && <p role="status">{boardError}</p>}
+15 -2
View File
@@ -22,6 +22,7 @@ import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { 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<DungeonClearFilter>("final");
const [difficultyId, setDifficultyId] = useState("");
const [bossDifficulty, setBossDifficulty] = useState(1);
const [leaderboardBossId, setLeaderboardBossId] = useState("");
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(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} />
</div>
)}
+18 -12
View File
@@ -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: () =>
<section className="randomized-bite-sized-screen__hero">
<p className="front-kicker">A different lineup every run</p>
<h1>Randomized Bite-Sized</h1>
<p>The mode picks an eligible short layout—currently Cellblock Rush—deals a different cross-dungeon creature into every enemy slot, then rolls the boss separately.</p>
<p>Cellblock Shuffle deals a different mob into every corridor slot and one final boss. Both come from the creature pool you choose.</p>
<div className="randomized-bite-sized-screen__odds">
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
@@ -135,6 +136,11 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
</section>
<section className="randomized-bite-sized-screen__launch">
<p className="front-kicker">Run setup</p>
<div className="randomized-bite-sized-screen__roles randomized-bite-sized-screen__pool" role="group" aria-label="Randomized creature pool">
<strong>Creature pool</strong>
<button className="front-button" type="button" aria-pressed={pool === "bite-sized"} disabled={downloading} onClick={() => setPool("bite-sized")}>Available Bite-Sized dungeon sources</button>
<button className="front-button" type="button" aria-pressed={pool === "all"} disabled={downloading} onClick={() => setPool("all")}>All available dungeon sources</button>
</div>
<h2>{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}</h2>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
{!queueEnabled ? null : !installed ? (
+8
View File
@@ -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<string>(), onExpand: noop, offset: 0, onPage: noop, onRefresh: noop,
difficultyLabel: "Normal", bossLabel: "Targorr the Dread", onBoss: noop, selectedId: "", select: noop }));
expect(html).toContain("Boss: Targorr the Dread");
expect(html).toContain("Change boss");
});
});
+6 -5
View File
@@ -3,13 +3,14 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
type LeaderboardScope } from "../app/dungeonLeaderboardClient";
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) };
}