167 lines
9.8 KiB
JavaScript
167 lines
9.8 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 } from "./dungeon-race-rules.mjs";
|
|
import { createGameServer } from "./server.mjs";
|
|
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];
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
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.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 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 }, time()).total, 2);
|
|
assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs);
|
|
}));
|
|
|
|
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);
|
|
} finally {
|
|
await new Promise((resolve) => runtime.server.close(resolve));
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|