forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
8a98797fcf
commit
11934a3979
44 files changed
+3183
-329
No files matched your search
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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[] };
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 & 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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user