Files
healer-man/server/dungeon-race.test.mjs
T
2026-09-27 21:48:00 -04:00

307 lines
19 KiB
JavaScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
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, hashRaceSeed, raceAffixPace, rollRaceAffixes } 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 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]];
};
const combatLogFor = (match, completed) => ({
version: 1, runId: match.seed, dungeonId: match.dungeonId, durationMs: 1_000, completed,
events: [{ atMs: 100, kind: "damage", source: "player", target: "mob-1",
ability: "test-aoe", raw: 100, effective: 90, overheal: 0, absorbed: 10, critical: false }],
droppedEventCount: 0,
kills: [{ id: "mob-1", name: "Mob One", boss: false, maxHealth: 90,
engagedAtMs: 100, firstDamageAtMs: 100, diedAtMs: 500, durationMs: 400 }],
packs: [{ startedAtMs: 100, endedAtMs: 500, durationMs: 400, mobIds: ["mob-1"],
partyDamage: [{ actorId: "player", damage: 90 }] }],
droppedKillCount: 0,
party: [{ actorId: "player", label: "Player", classId: "priest", level: 60, role: "healer" }],
partyDamage: [{ actorId: "player", damage: 90 }],
});
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"]) {
assert.ok(["novice", "regular", "veteran"].includes(botTierForRecord(0, 0, seed)));
assert.ok(["novice", "regular"].includes(botTierForRecord(0, 20, seed)));
assert.ok(["veteran", "elite"].includes(botTierForRecord(20, 0, seed)));
const bot = botOpponent(seed, "regular", "rom");
assert.equal(bot.roster.length, 5);
assert.equal(botProgress(0, bot), 0);
assert.equal(botProgress(bot.finishMs, bot), 100);
assert.ok(botProgress(bot.finishMs / 2, bot) > 48);
}
});
test("a shared seed rolls one stable pair and scales an NPC's entire progress model", () => {
const seed = "race-affix-parity";
const pair = rollRaceAffixes(seed);
assert.deepEqual(pair, rollRaceAffixes(seed));
assert.equal(pair.length, 2);
const plain = botOpponent(seed, "regular", "wow");
const tuned = botOpponent(seed, "regular", "wow", pair);
assert.equal(tuned.finishMs, Math.round(plain.finishMs * raceAffixPace(pair)));
assert.equal(tuned.milestones.at(-1).atMs, tuned.finishMs);
});
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",
"bite-sized-grafu-castle-barkud", "bite-sized-barren-caves-anglo",
"bite-sized-hoto-teeth", "bite-sized-kawaks-tomb-linobia", "bite-sized-hall-of-survivors-pursuer"];
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]);
assert.equal(database.prepare("SELECT affix_ids_json FROM dungeon_race_matches WHERE id = ?").get(seed).affix_ids_json, "[]");
}
} finally { database.close(); }
});
async function fixture(run) {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-"));
const database = openGameDatabase({ dataDir });
let clock = Date.now();
const service = createDungeonRaceService({ database, now: () => clock, autoPump: false });
async function player(username, source = "wow") {
const { account } = await database.register(username, "test-password");
database.putCloudSave(account.id, { version: 2, characters: [{ id: `${username}-character`,
name: username, categoryId: source, classId: source === "rom" ? "rom-priest" : "priest",
specializationId: source === "rom" ? undefined : "priest:discipline", level: 30 }] });
return account;
}
const advance = (duration) => { clock += duration; service.pump(); };
try { await run({ database, service, player, advance, time: () => clock }); }
finally { service.close(); database.close(); rmSync(dataDir, { recursive: true, force: true }); }
}
test("random and specific tickets pair across WoW and RoM, then a later losing clear still ranks", async () => fixture(async ({ database, service, player, advance, time }) => {
const wow = await player("race_wow");
const rom = await player("race_rom", "rom");
assert.equal(service.queue(wow, { characterId: "race_wow-character", role: "healer", pool: "authored" }).status, "queued");
const paired = service.queue(rom, { characterId: "race_rom-character", role: "healer", pool: "authored",
dungeonId: dungeon.id });
assert.equal(paired.status, "preparing");
assert.equal(service.state(wow).seed, paired.seed);
assert.deepEqual(service.state(wow).affixIds, paired.affixIds);
assert.deepEqual(paired.affixIds, rollRaceAffixes(paired.seed));
assert.equal(paired.opponent.source, "wow");
service.ready(wow, paired.matchId);
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");
advance(1_000);
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,
bossId: bossIds[0] }, time()).total, 2);
assert.deepEqual(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).entries[0].affixIds, paired.affixIds);
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("records a partial race log, upgrades it on clear, and scopes review to its participant", async () => fixture(async ({ database, service, player, advance, time }) => {
const owner = await player("race_log_owner");
const stranger = await player("race_log_stranger");
service.queue(owner, { characterId: "race_log_owner-character", role: "healer", pool: "authored",
dungeonId: dungeon.id });
advance(5_000);
const match = service.state(owner);
service.ready(owner, match.matchId);
advance(3_000);
const partial = combatLogFor(match, false);
assert.deepEqual(database.savePartialDungeonRaceCombatLog(match.matchId, owner.id, partial, time()),
{ ok: true, matchId: match.matchId, completed: false });
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).completed, false);
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).log.packs[0].durationMs, 400);
assert.throws(() => database.getDungeonRaceCombatLog(stranger, match.matchId),
{ code: "race_combat_log_not_found" });
assert.throws(() => database.listDungeonRaceCombatLogs(owner), { code: "gm_required" });
assert.throws(() => database.savePartialDungeonRaceCombatLog(match.matchId, stranger.id,
partial, time()), { code: "race_not_found" });
assert.throws(() => service.finish(owner, match.matchId, { runId: match.seed,
bossIds: bossIdsFor(match), combatLog: { ...combatLogFor(match, true), events: [{}] } }),
{ code: "invalid_race_combat_log" });
service.finish(owner, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match),
combatLog: combatLogFor(match, true) });
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).completed, true);
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).log.events[0].ability, "test-aoe");
}));
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], spotId: match.biteSizedSelection.spot.id, spotRevision: match.biteSizedSelection.spot.revision }, 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" });
advance(4_999);
assert.equal(service.state(account).status, "queued");
advance(1);
const state = service.state(account);
assert.equal(state.status, "preparing");
assert.equal(state.opponent.type, "bot");
assert.equal(state.opponent.roster.length, 5);
assert.equal(state.opponent.milestones.length, 5);
service.ready(account, state.matchId);
advance(3_000 + state.opponent.finishMs);
assert.equal(service.state(account).result, "loss");
assert.deepEqual(database.listDungeonRaceLeaderboard({ period: "all" }, time()).entries
.map((entry) => ({ username: entry.username, wins: entry.wins, losses: entry.losses })),
[{ username: "race_solo", wins: 0, losses: 1 }]);
}));
test("a missing human during preparation becomes a bot without charging the ready player", async () => fixture(async ({ service, player, advance }) => {
const left = await player("race_ready");
const right = await player("race_missing");
service.queue(left, { characterId: "race_ready-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_missing-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId);
advance(5 * 60_000);
assert.equal(service.state(left).opponent.type, "bot");
assert.equal(service.state(right).status, "idle");
assert.equal(service.state(left).status, "countdown");
}));
test("leaving during the countdown replaces the rival without a result", async () => fixture(async ({ service, player, advance }) => {
const left = await player("race_cancel_a");
const right = await player("race_cancel_b");
service.queue(left, { characterId: "race_cancel_a-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_cancel_b-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId); service.ready(right, match.matchId);
assert.equal(service.leave(left, match.matchId).status, "idle");
assert.equal(service.state(right).opponent.type, "bot");
assert.equal(service.state(right).result, null);
advance(3_000);
assert.equal(service.state(right).status, "active");
}));
test("the 30-day window expires while all-time wins and times remain", async () => fixture(async ({ database, service, player, advance, time }) => {
const left = await player("race_window_a");
const right = await player("race_window_b");
service.queue(left, { characterId: "race_window_a-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_window_b-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId); service.ready(right, match.matchId);
advance(3_000);
service.finish(left, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match) });
assert.equal(database.listDungeonRaceLeaderboard({ period: "30d" }, time()).total, 2);
advance(30 * 24 * 60 * 60_000 + 1);
assert.equal(database.listDungeonRaceLeaderboard({ period: "30d" }, time()).total, 0);
assert.equal(database.listDungeonRaceLeaderboard({ period: "all" }, time()).total, 2);
}));
test("an active disconnect forfeits after the grace period and the other player can still clear", async () => fixture(async ({ service, player, advance }) => {
const left = await player("race_drop_a");
const right = await player("race_drop_b");
service.queue(left, { characterId: "race_drop_a-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_drop_b-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId); service.ready(right, match.matchId);
advance(3_000);
service.state(right);
advance(29_000);
assert.equal(service.state(left).result, "loss");
assert.equal(service.state(right).result, "win");
assert.equal(service.finish(right, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match) }).result, "win");
}));
test("production HTTP routes expose ranked queue and public boards without shelved modes", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-http-"));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${runtime.server.address().port}`;
try {
const auth = await (await fetch(`${base}/api/auth/register`, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "race_http", password: "test-password" }) })).json();
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` };
await fetch(`${base}/api/cloud-save`, { method: "PUT", headers,
body: JSON.stringify({ data: { version: 2, characters: [{ id: "http-character", name: "Runner",
classId: "priest", categoryId: "wow", level: 30 }] } }) });
const response = await fetch(`${base}/api/dungeon-races/queue`, { method: "POST", headers,
body: JSON.stringify({ characterId: "http-character", role: "healer", pool: "authored" }) });
assert.equal(response.status, 200);
assert.equal((await response.json()).status, "queued");
assert.equal((await fetch(`${base}/api/dungeon-races/state`, { headers })).status, 200);
assert.equal((await fetch(`${base}/api/dungeon-races/leaderboard?period=30d`)).status, 200);
assert.equal((await fetch(`${base}/api/dungeon-races/queue`, { method: "DELETE", headers })).status, 200);
const matchId = "race-http-combat-log";
runtime.database.createDungeonRaceMatch({ id: matchId, dungeonId: dungeon.id, seed: matchId,
difficultyId: dungeon.defaultDifficultyId, createdAt: Date.now() - 2_000,
participants: [{ side: 0, accountId: auth.account.id, characterId: "http-character",
username: auth.account.username, source: "wow", role: "healer" },
{ side: 1, accountId: null, characterId: null, username: "Bot", source: "wow", role: null }],
bot: null });
runtime.database.startDungeonRace(matchId, Date.now() - 1_000);
const logResponse = await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`, {
method: "POST", headers, body: JSON.stringify({ combatLog: combatLogFor({
seed: matchId, dungeonId: dungeon.id }, false) }),
});
assert.equal(logResponse.status, 200);
const review = await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`, { headers });
assert.equal(review.status, 200);
assert.equal((await review.json()).log.kills[0].durationMs, 400);
assert.equal((await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`)).status, 401);
assert.equal((await fetch(`${base}/api/gm/dungeon-races/combat-logs`, { headers })).status, 403);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(dataDir, { recursive: true, force: true });
}
});