Update Healer Man server

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

No files matched your search

+1 -1
View File
@@ -872,7 +872,7 @@ export function openGameDatabase(options = {}) {
}
const romGroup = onlineGroupCharacterSource(JSON.parse(members[0].character_json)) === "rom";
const randomizedBiteSized = type === "dungeon"
&& contentId === "randomized-bite-sized-stormwind-stockade";
&& contentId.startsWith("randomized-bite-sized-");
if (romGroup && randomizedBiteSized) {
throw new GameDatabaseError("This WoW-only activity is unavailable to RoM groups.", 409, "group_source_incompatible");
}
+48 -12
View File
@@ -3,6 +3,18 @@ import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.j
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
const classes = new Set(catalog.classes.map((entry) => entry.id));
function storedFinalBossId(dungeonId, difficultyId, killedBossIds) {
const difficulty = dungeons.get(dungeonId)?.difficulties.find((entry) => entry.id === difficultyId);
if (!difficulty) return null;
if (!difficulty.finalBossIds?.length) return difficulty.finalBossId || null;
const defeated = difficulty.finalBossIds.filter((id) => killedBossIds.includes(id));
// The earlier Deadmines ship cut defeated Greenskin before VanCleef.
if (dungeonId === "bite-sized-deadmines-ship" && defeated.length > 1 && defeated.includes("creature-79336")) {
return "creature-79336";
}
return defeated.length === 1 ? defeated[0] : null;
}
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_runs (
@@ -26,12 +38,22 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "boss_difficulty")) {
database.exec("ALTER TABLE dungeon_runs ADD COLUMN boss_difficulty INTEGER NOT NULL DEFAULT 0 CHECK (boss_difficulty BETWEEN 0 AND 10)");
}
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "final_boss_id")) {
database.exec("ALTER TABLE dungeon_runs ADD COLUMN final_boss_id TEXT");
}
const updateFinalBoss = database.prepare("UPDATE dungeon_runs SET final_boss_id = ? WHERE id = ?");
for (const row of database.prepare("SELECT id, dungeon_id, difficulty_id, bosses_json FROM dungeon_runs WHERE final_boss_id IS NULL").all()) {
const bossId = storedFinalBossId(row.dungeon_id, row.difficulty_id, JSON.parse(row.bosses_json));
if (bossId) updateFinalBoss.run(bossId, row.id);
}
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_boss_difficulty_idx
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, full_clear, duration_ms, finished_at, id)`);
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_final_boss_idx
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, final_boss_id, full_clear, duration_ms, finished_at, id)`);
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
const insert = database.prepare(`INSERT INTO dungeon_runs
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty, final_boss_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}),
@@ -48,13 +70,21 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
fail("Choose a boss difficulty from 1 to 10.");
return { dungeon, difficultyId, difficulty, bossDifficulty };
}
function placement(run) {
function boardBossId(difficulty, requestedBossId) {
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
if (requestedBossId && !finalBossIds.includes(requestedBossId)) fail("Choose a final boss from this dungeon.");
return difficulty.finalBossIds?.length ? requestedBossId || finalBossIds[0] : null;
}
function placement(run, selectedBossId = storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) {
const variant = dungeons.get(run.dungeonId)?.difficulties.find((entry) => entry.id === run.difficultyId)?.finalBossIds?.length;
const bossCondition = variant ? "AND final_boss_id = ?" : "";
const counts = database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
COALESCE(SUM(full_clear), 0) AS full_total,
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ?`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0);
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${bossCondition}`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0,
...(variant ? [selectedBossId] : []));
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
}
@@ -74,6 +104,8 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
const killedBossIds = [...new Set(input.killedBossIds)].sort();
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
if (!finalBossIds.some((id) => killedBossIds.includes(id))) fail("The final boss must be defeated before recording a run.");
if (difficulty.finalBossIds && finalBossIds.filter((id) => killedBossIds.includes(id)).length !== 1)
fail("A randomized run must defeat exactly one selected final boss.");
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
const party = input.party.map((member) => {
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
@@ -99,17 +131,18 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty);
return { run, placement: placement(run) };
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty,
storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds));
return { run, placement: placement(run, existing?.final_boss_id ?? storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) };
},
getDungeonRun(id) {
const row = byId.get(id);
if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found");
const run = record(row);
return { run, placement: placement(run) };
return { run, placement: placement(run, row.final_boss_id) };
},
listDungeonLeaderboard(input) {
const { dungeon, difficultyId, bossDifficulty } = selection(input);
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
const filter = input.clear || "final";
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
const offset = Number(input.offset ?? 0);
@@ -117,11 +150,14 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page.");
const condition = filter === "full" ? "AND full_clear = 1" : "";
const bossId = boardBossId(difficulty, input.bossId);
const bossCondition = bossId ? "AND final_boss_id = ?" : "";
const args = [dungeon.id, difficultyId, bossDifficulty, ...(bossId ? [bossId] : [])];
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, bossDifficulty, limit, offset);
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(...args, limit, offset);
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition}`).get(dungeon.id, difficultyId, bossDifficulty).total;
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}`).get(...args).total;
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
},
};
+42 -6
View File
@@ -58,18 +58,34 @@ test("validates final boss, dungeon/difficulty, duration and party without trust
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
}));
test("accepts any rolled Stockades boss as a full clear for both bite-sized modes", () => withDatabase((database) => {
for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade"]) {
test("separates each rolled boss's board within its dungeon", () => withDatabase((database, reopen) => {
for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi",
"bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem"]) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const difficulty = dungeon.difficulties[0];
assert.equal(difficulty.finalBossIds.length, 5);
assert.equal(difficulty.finalBossIds.length, dungeon.bosses.length);
assert.ok(difficulty.finalBossIds.length > 1);
for (const [index, bossId] of difficulty.finalBossIds.entries()) {
const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id);
payload.killedBossIds = [bossId];
assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true);
const submitted = database.submitDungeonRun(null, payload);
assert.equal(submitted.run.fullClear, true);
assert.deepEqual(submitted.placement, { finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
assert.equal(database.listDungeonLeaderboard({ dungeonId, bossId, clear: "full" }).total, 1);
assert.equal(database.listDungeonLeaderboard({ dungeonId, bossId, clear: "full" }).entries[0].id, payload.id);
}
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5);
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 1);
assert.throws(() => database.listDungeonLeaderboard({ dungeonId, bossId: "wrong-boss" }), { status: 400 });
const impossible = run(`${dungeonId}-multiple-run`, 1234, false, dungeonId);
impossible.killedBossIds = difficulty.finalBossIds.slice(0, 2);
assert.throws(() => database.submitDungeonRun(null, impossible), { status: 400 });
}
database = reopen();
const id = "bite-sized-mystic-altar-golem";
const bosses = catalog.dungeons.find((entry) => entry.id === id).difficulties[0].finalBossIds;
assert.equal(database.listDungeonLeaderboard({ dungeonId: id, bossId: bosses[1] }).total, 1);
}));
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
@@ -93,6 +109,16 @@ test("public HTTP leaderboards accept completion records and return results with
assert.deepEqual((await sliderBoard.json()).entries.map((entry) => entry.id), [slider.id]);
assert.equal((await (await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=1`)).json()).total, 0);
assert.equal((await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=11`)).status, 400);
const romDungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-mystic-altar-golem");
const romBoss = romDungeon.difficulties[0].finalBossIds[1];
const romRun = run("http-rom-boss-run", 23456, false, romDungeon.id);
romRun.killedBossIds = [romBoss];
assert.equal((await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(romRun) })).status, 201);
const romBoard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=${romDungeon.id}&boss=${romBoss}`);
assert.equal(romBoard.status, 200);
assert.deepEqual((await romBoard.json()).entries.map((entry) => entry.id), [romRun.id]);
assert.equal((await (await fetch(`${base}/api/dungeon-leaderboards?dungeon=${romDungeon.id}`)).json()).total, 0);
const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) });
assert.equal(bad.status, 400);
const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) });
@@ -141,12 +167,22 @@ test("migrates the old leaderboard schema and keeps its scores outside Easy", ()
duration_ms INTEGER NOT NULL, full_clear INTEGER NOT NULL, finished_at INTEGER NOT NULL,
party_json TEXT NOT NULL, bosses_json TEXT NOT NULL, submitted_by_account_id TEXT, received_at INTEGER NOT NULL)`);
const legacy = run("pre-slider-result", 5000);
database.prepare("INSERT INTO dungeon_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(legacy.id,
const insert = database.prepare("INSERT INTO dungeon_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
insert.run(legacy.id,
legacy.dungeonId, legacy.difficultyId, legacy.durationMs, 1, legacy.finishedAt,
JSON.stringify(legacy.party), JSON.stringify(legacy.killedBossIds), null, Date.now());
const oldShip = run("pre-single-boss-ship", 6000, false, "bite-sized-deadmines-ship");
oldShip.killedBossIds = ["creature-79333", "creature-79336"];
insert.run(oldShip.id, oldShip.dungeonId, oldShip.difficultyId, oldShip.durationMs, 1,
oldShip.finishedAt, JSON.stringify(oldShip.party), JSON.stringify(oldShip.killedBossIds), null, Date.now());
const repository = createDungeonLeaderboardRepository(database, { ErrorType: Error });
assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines", bossDifficulty: 1 }).total, 0);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: oldShip.dungeonId,
bossId: "creature-79336" }).total, 1);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: oldShip.dungeonId,
bossId: "creature-79333" }).total, 0);
assert.equal(repository.getDungeonRun(oldShip.id).placement.finalTotal, 1);
createDungeonLeaderboardRepository(database, { ErrorType: Error });
assert.equal(repository.getDungeonRun(legacy.id).run.durationMs, 5000);
} finally { database.close(); }
+68 -5
View File
@@ -5,14 +5,21 @@ import os from "node:os";
import path from "node:path";
import { openGameDatabase } from "./database.mjs";
import { createDungeonRaceService } from "./dungeon-race-service.mjs";
import { botOpponent, botProgress, botTierForRecord } from "./dungeon-race-rules.mjs";
import { botOpponent, botProgress, botTierForRecord, hashRaceSeed } from "./dungeon-race-rules.mjs";
import { createGameServer } from "./server.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { DatabaseSync } from "node:sqlite";
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const dungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade");
const bossIds = [dungeon.difficulties[0].finalBossId];
const bossIdsFor = (match) => [catalog.dungeons.find((entry) => entry.id === match.dungeonId)
.difficulties.find((entry) => entry.id === match.difficultyId).finalBossId];
const bossIdsFor = (match) => {
const difficulty = catalog.dungeons.find((entry) => entry.id === match.dungeonId)
.difficulties.find((entry) => entry.id === match.difficultyId);
const ids = difficulty.finalBossIds ?? [difficulty.finalBossId];
const seed = match.dungeonId.startsWith("randomized-bite-sized-")
? `${match.seed}:boss` : match.seed;
return [ids[hashRaceSeed(seed) % ids.length]];
};
test("NPC tiers follow rolling records with seeded adjacent variation and milestones", () => {
for (const seed of ["race-tier-a", "race-tier-b", "race-tier-c", "race-tier-d"]) {
@@ -27,6 +34,32 @@ test("NPC tiers follow rolling records with seeded adjacent variation and milest
}
});
test("migrates existing race matches onto their seeded boss boards", () => {
const database = new DatabaseSync(":memory:");
try {
database.exec(`CREATE TABLE accounts (id TEXT PRIMARY KEY);
CREATE TABLE dungeon_race_matches (id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL,
seed TEXT NOT NULL, difficulty_id TEXT NOT NULL, boss_difficulty INTEGER NOT NULL,
status TEXT NOT NULL, created_at INTEGER NOT NULL, start_at INTEGER, settled_at INTEGER,
winner_side INTEGER, bot_json TEXT)`);
const insert = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, settled_at)
VALUES (?, ?, ?, ?, 1, 'settled', 1, 2)`);
const dungeonIds = ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi",
"bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem"];
for (const dungeonId of dungeonIds) insert.run(`race-old-${dungeonId}`, dungeonId,
`race-old-${dungeonId}`, dungeon.defaultDifficultyId);
createDungeonRaceRepository(database, { ErrorType: Error });
for (const dungeonId of dungeonIds) {
const seed = `race-old-${dungeonId}`;
assert.equal(database.prepare("SELECT boss_id FROM dungeon_race_matches WHERE id = ?").get(seed).boss_id,
bossIdsFor({ dungeonId, difficultyId: dungeon.defaultDifficultyId, seed })[0]);
}
} finally { database.close(); }
});
async function fixture(run) {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-"));
const database = openGameDatabase({ dataDir });
@@ -57,6 +90,9 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
assert.equal(service.ready(rom, paired.matchId).status, "countdown");
advance(3_000);
assert.equal(service.state(wow).status, "active");
const bossIds = bossIdsFor(paired);
assert.throws(() => service.finish(wow, paired.matchId, { runId: paired.seed,
bossIds: [dungeon.difficulties[0].finalBossIds.find((id) => id !== bossIds[0])] }), { status: 400 });
const first = service.finish(wow, paired.matchId, { runId: paired.seed, bossIds });
assert.equal(first.result, "win");
assert.equal(service.state(rom).result, "loss");
@@ -64,10 +100,37 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
const late = service.finish(rom, paired.matchId, { runId: paired.seed, bossIds });
assert.equal(late.result, "loss");
assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "30d" }, time()).entries[0].username, "race_wow");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id }, time()).total, 2);
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).total, 2);
const otherBossId = dungeon.difficulties[0].finalBossIds.find((id) => id !== bossIds[0]);
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: otherBossId }, time()).total, 0);
assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).total, 2);
assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs);
}));
test("both randomized creature pools validate and rank their cross-dungeon final boss", async () => fixture(async ({ database, service, player, advance, time }) => {
for (const [index, dungeonId] of ["randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available"].entries()) {
const left = await player(`race_random_${index}_a`);
const right = await player(`race_random_${index}_b`);
service.queue(left, { characterId: `race_random_${index}_a-character`, role: "healer", pool: "randomized", dungeonId });
const match = service.queue(right, { characterId: `race_random_${index}_b-character`, role: "healer", pool: "randomized", dungeonId });
assert.equal(match.dungeonId, dungeonId);
service.ready(left, match.matchId); service.ready(right, match.matchId);
advance(3_000);
const bossIds = bossIdsFor(match);
assert.match(bossIds[0], /^randomized:(wow|rom):boss:/);
const wrongBossId = catalog.dungeons.find((entry) => entry.id === dungeonId).difficulties[0]
.finalBossIds.find((id) => id !== bossIds[0]);
assert.throws(() => service.finish(left, match.matchId, { runId: match.seed, bossIds: [wrongBossId] }), { status: 400 });
assert.equal(service.finish(left, match.matchId, { runId: match.seed, bossIds }).result, "win");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId,
bossId: bossIds[0] }, time()).total, 1);
}
}));
test("five-second unmatched queue fills with a modeled NPC party", async () => fixture(async ({ database, service, player, advance, time }) => {
const account = await player("race_solo");
service.queue(account, { characterId: "race_solo-character", role: "healer", pool: "authored" });
+37 -10
View File
@@ -1,8 +1,15 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
function raceBossId(dungeonId, difficultyId, seed) {
const difficulty = dungeons.get(dungeonId)?.difficulties.find((entry) => entry.id === difficultyId);
const finalIds = difficulty?.finalBossIds;
const objectiveSeed = dungeonId.startsWith("randomized-bite-sized-") ? `${seed}:boss` : seed;
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
}
export function createDungeonRaceRepository(database, { ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
@@ -26,13 +33,24 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
CREATE INDEX IF NOT EXISTS dungeon_race_account_idx
ON dungeon_race_participants(account_id, outcome, clear_at);
`);
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "boss_id")) {
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN boss_id TEXT");
}
const missingBosses = database.prepare("SELECT id, dungeon_id, difficulty_id, seed FROM dungeon_race_matches WHERE boss_id IS NULL").all();
const updateBoss = database.prepare("UPDATE dungeon_race_matches SET boss_id = ? WHERE id = ?");
for (const match of missingBosses) {
const bossId = raceBossId(match.dungeon_id, match.difficulty_id, match.seed);
if (bossId) updateBoss.run(bossId, match.id);
}
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_race_boss_idx
ON dungeon_race_matches(dungeon_id, boss_id, status, settled_at)`);
database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')")
.run(Date.now());
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?)`);
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?)`);
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
(match_id, side, account_id, character_id, username, source, role)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
@@ -72,7 +90,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
function createMatch(match) {
transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null);
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed));
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
});
@@ -95,8 +113,10 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
|| match.status === "void") throw new ErrorType("This race is not accepting clears.", 409, "race_not_active");
const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId);
const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []);
const selectedBossId = raceBossId(match.dungeonId, match.difficultyId, match.seed);
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|| !bossIds.some((value) => finalIds.includes(value)))
|| !bossIds.some((value) => finalIds.includes(value))
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear");
return transaction(() => {
updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId);
@@ -120,28 +140,34 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
.get(accountId, timestamp - RACE_PERIOD_MS);
return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) };
}
function leaderboard({ view = "wins", period = "30d", dungeonId = null, limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
function leaderboard({ view = "wins", period = "30d", dungeonId = null, bossId = null,
limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
const dungeon = dungeons.get(dungeonId);
const bossIds = dungeon?.difficulties.find((entry) => entry.id === dungeon.defaultDifficultyId)?.finalBossIds;
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
|| (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId)))
|| (bossId !== null && (!bossIds || !bossIds.includes(bossId)))
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100
|| !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000)
throw new ErrorType("Choose a valid race leaderboard.", 400, "invalid_race_leaderboard");
if (view === "times" && !dungeonId) throw new ErrorType("Choose a dungeon for race times.", 400, "race_dungeon_required");
const since = period === "30d" ? timestamp - RACE_PERIOD_MS : 0;
const byDungeon = dungeonId ? "AND m.dungeon_id = ?" : "";
const args = dungeonId ? [since, dungeonId] : [since];
const selectedBossId = dungeonId && bossIds?.length ? bossId ?? bossIds[0] : null;
const byBoss = selectedBossId ? "AND m.boss_id = ?" : "";
const args = [since, ...(dungeonId ? [dungeonId] : []), ...(selectedBossId ? [selectedBossId] : [])];
const query = view === "wins" ? `
SELECT p.account_id AS accountId, a.username AS username,
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id
WHERE m.status = 'settled' AND m.settled_at >= ? ${byDungeon}
WHERE m.status = 'settled' AND m.settled_at >= ? ${byDungeon} ${byBoss}
GROUP BY p.account_id, a.username ORDER BY wins DESC, losses ASC, a.username COLLATE NOCASE ASC`
: `SELECT p.account_id AS accountId, a.username AS username, MIN(p.duration_ms) AS durationMs
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id
WHERE p.clear_at >= ? ${byDungeon} AND p.duration_ms IS NOT NULL
WHERE p.clear_at >= ? ${byDungeon} ${byBoss} AND p.duration_ms IS NOT NULL
GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`;
const all = database.prepare(query).all(...args);
let lastValue = null;
@@ -154,7 +180,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
? { wins: Number(row.wins), losses: Number(row.losses) }
: { durationMs: Number(row.durationMs) }) };
});
return { view, period, dungeonId, total: ranked.length, entries: ranked.slice(offset, offset + limit) };
return { view, period, dungeonId, bossId: selectedBossId,
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
}
return { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)),
start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun,
+1
View File
@@ -5,6 +5,7 @@ import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set([
"bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"forsaken-abbey",
...Object.keys(availability.enabled),
]);
+2
View File
@@ -310,6 +310,7 @@ export function createGameServer(options = {}) {
view: url.searchParams.get("view") ?? "wins",
period: url.searchParams.get("period") ?? "30d",
dungeonId: url.searchParams.get("dungeon") || null,
bossId: url.searchParams.get("boss") || null,
limit: Number(url.searchParams.get("limit") ?? 20),
offset: Number(url.searchParams.get("offset") ?? 0),
}));
@@ -342,6 +343,7 @@ export function createGameServer(options = {}) {
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
bossId: url.searchParams.get("boss"),
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
return;