forked from phenom/healer-man
307 lines
19 KiB
JavaScript
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 });
|
|
}
|
|
});
|