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 }); } });