Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+134
View File
@@ -12,6 +12,8 @@ import {
cloneDefaultCompanionCooldownPolicy,
normalizeCompanionCooldownPolicy,
} from "./companion-cooldown-policy.mjs";
import { DEFAULT_PARTY_BUFF_POLICY, normalizePartyBuffPolicy } from "./party-buff-policy.mjs";
import { DEFAULT_DUNGEON_QUEUE_POLICY, normalizeDungeonQueuePolicy } from "./dungeon-queue-policy.mjs";
import {
cloneDefaultCompanionSpecReadinessPolicy,
normalizeCompanionSpecReadinessPolicy,
@@ -20,6 +22,7 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
@@ -110,6 +113,24 @@ export function openGameDatabase(options = {}) {
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS party_buff_policy (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_queue_policy (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS companion_spec_readiness (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
@@ -215,6 +236,16 @@ export function openGameDatabase(options = {}) {
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(cloneDefaultCompanionCooldownPolicy()));
database.prepare(`
INSERT OR IGNORE INTO party_buff_policy (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(DEFAULT_PARTY_BUFF_POLICY));
database.prepare(`
INSERT OR IGNORE INTO dungeon_queue_policy (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(DEFAULT_DUNGEON_QUEUE_POLICY));
database.prepare(`
INSERT OR IGNORE INTO companion_spec_readiness (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
@@ -252,6 +283,22 @@ export function openGameDatabase(options = {}) {
RETURNING revision, updated_at
`),
companionCooldownPolicy: database.prepare("SELECT * FROM companion_cooldown_policy WHERE id = 1"),
partyBuffPolicy: database.prepare("SELECT * FROM party_buff_policy WHERE id = 1"),
dungeonQueuePolicy: database.prepare("SELECT * FROM dungeon_queue_policy WHERE id = 1"),
updateDungeonQueuePolicy: database.prepare(`
UPDATE dungeon_queue_policy
SET schema_version = 1, revision = revision + 1, config_json = ?,
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
updatePartyBuffPolicy: database.prepare(`
UPDATE party_buff_policy
SET schema_version = 1, revision = revision + 1, config_json = ?,
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
updateCompanionCooldownPolicy: database.prepare(`
UPDATE companion_cooldown_policy
SET schema_version = 1, revision = revision + 1, config_json = ?,
@@ -450,6 +497,74 @@ export function openGameDatabase(options = {}) {
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function getPartyBuffPolicy() {
const row = statements.partyBuffPolicy.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizePartyBuffPolicy(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man party buff policy is corrupt; using safe defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: DEFAULT_PARTY_BUFF_POLICY };
}
}
function getDungeonQueuePolicy() {
const row = statements.dungeonQueuePolicy.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizeDungeonQueuePolicy(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man dungeon queue policy is corrupt; using defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: DEFAULT_DUNGEON_QUEUE_POLICY };
}
}
function putDungeonQueuePolicy(account, expectedRevision, config) {
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizeDungeonQueuePolicy(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid dungeon queue policy.", 400, "invalid_dungeon_queue_policy");
}
const now = Date.now();
const row = statements.updateDungeonQueuePolicy.get(
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
);
if (!row) throw new GameDatabaseError("The dungeon queue policy changed. Reload and try again.", 409, "revision_conflict");
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function putPartyBuffPolicy(account, expectedRevision, config) {
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizePartyBuffPolicy(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid party buff policy.", 400, "invalid_party_buff_policy");
}
const now = Date.now();
const row = statements.updatePartyBuffPolicy.get(
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
);
if (!row) throw new GameDatabaseError("The party buff policy changed. Reload and try again.", 409, "revision_conflict");
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function getCompanionSpecReadiness() {
const row = statements.companionSpecReadiness.get();
try {
@@ -1066,6 +1181,21 @@ export function openGameDatabase(options = {}) {
databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...(() => {
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError });
return {
getDungeonRaceMatch: races.getMatch,
createDungeonRaceMatch: races.createMatch,
readyDungeonRace: races.ready,
startDungeonRace: races.start,
settleDungeonRace: races.settleMatch,
completeDungeonRaceRun: races.completeRaceRun,
replaceDungeonRaceWithBot: races.replaceWithBot,
voidDungeonRace: races.void,
dungeonRaceRecordForAccount: races.recordForAccount,
listDungeonRaceLeaderboard: races.leaderboard,
};
})(),
...createDungeonSimulationRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
register,
login,
@@ -1078,6 +1208,10 @@ export function openGameDatabase(options = {}) {
putManastormConfig,
getCompanionCooldownPolicy,
putCompanionCooldownPolicy,
getPartyBuffPolicy,
putPartyBuffPolicy,
getDungeonQueuePolicy,
putDungeonQueuePolicy,
getCompanionSpecReadiness,
putCompanionSpecReadiness,
onlineGroupState,
+35
View File
@@ -0,0 +1,35 @@
import dungeonCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const knownIds = new Set(dungeonCatalog.dungeons.map((dungeon) => dungeon.id));
const modes = ["regular", "biteSized", "randomizedBiteSized"];
export const DEFAULT_DUNGEON_QUEUE_POLICY = Object.freeze({
schemaVersion: 1,
disabled: { regular: [], biteSized: [], randomizedBiteSized: [] },
});
function modeForId(id) {
if (id.startsWith("randomized-bite-sized-")) return "randomizedBiteSized";
if (id.startsWith("bite-sized-")) return "biteSized";
return "regular";
}
export function normalizeDungeonQueuePolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1
|| !value.disabled || typeof value.disabled !== "object" || Array.isArray(value.disabled)) {
throw new Error("A version 1 dungeon queue policy is required.");
}
if (Object.keys(value.disabled).some((mode) => !modes.includes(mode))) {
throw new Error("Unknown dungeon queue mode.");
}
const disabled = {};
for (const mode of modes) {
const ids = value.disabled[mode];
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !knownIds.has(id) || modeForId(id) !== mode)
|| new Set(ids).size !== ids.length) {
throw new Error(`Invalid disabled dungeon IDs for ${mode}.`);
}
disabled[mode] = [...ids].sort();
}
return { schemaVersion: 1, disabled };
}
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { normalizeDungeonQueuePolicy } from "./dungeon-queue-policy.mjs";
import { createGameServer } from "./server.mjs";
test("queue policy validates dungeon IDs and mode membership", () => {
assert.throws(() => normalizeDungeonQueuePolicy({ schemaVersion: 1, disabled: {
regular: ["bite-sized-stormwind-stockade"], biteSized: [], randomizedBiteSized: [],
} }), /invalid disabled dungeon/i);
assert.throws(() => normalizeDungeonQueuePolicy({ schemaVersion: 1, disabled: {
regular: ["unknown-dungeon"], biteSized: [], randomizedBiteSized: [],
} }), /invalid disabled dungeon/i);
});
test("GM queue settings persist with revision checks", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-queue-policy-"));
let database = openGameDatabase({ dataDir });
try {
assert.deepEqual(database.getDungeonQueuePolicy().config.disabled, {
regular: [], biteSized: [], randomizedBiteSized: [],
});
const gm = (await database.register("codex_dev_gm", "test-password")).account;
const player = (await database.register("queue_player", "test-password")).account;
const config = { schemaVersion: 1, disabled: {
regular: ["stormwind-stockade"],
biteSized: ["bite-sized-stormwind-stockade"],
randomizedBiteSized: ["randomized-bite-sized-stormwind-stockade"],
} };
assert.throws(() => database.putDungeonQueuePolicy(player, 0, config), (error) => error.status === 403);
assert.equal(database.putDungeonQueuePolicy(gm, 0, config).revision, 1);
assert.throws(() => database.putDungeonQueuePolicy(gm, 0, config), (error) => error.status === 409);
database.close();
database = openGameDatabase({ dataDir });
assert.deepEqual(database.getDungeonQueuePolicy().config, config);
} finally {
database.close();
rmSync(dataDir, { recursive: true, force: true });
}
});
test("queue policy API is public to read and GM only to edit", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-queue-api-"));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
try {
const initial = await (await fetch(`${origin}/api/dungeon-queue-policy`)).json();
assert.deepEqual(initial.config.disabled.regular, []);
const register = async (username) => (await (await fetch(`${origin}/api/auth/register`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: "test-password" }),
})).json()).token;
const playerToken = await register("queue_reader");
const gmToken = await register("codex_dev_gm");
const config = { schemaVersion: 1, disabled: {
regular: [], biteSized: ["bite-sized-stormwind-stockade"], randomizedBiteSized: [],
} };
const put = (token, expectedRevision) => fetch(`${origin}/api/gm/dungeon-queue-policy`, {
method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ expectedRevision, config }),
});
assert.equal((await put(playerToken, 0)).status, 403);
assert.equal((await put(gmToken, 0)).status, 200);
assert.equal((await put(gmToken, 0)).status, 409);
assert.deepEqual((await (await fetch(`${origin}/api/dungeon-queue-policy`)).json()).config, config);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(dataDir, { recursive: true, force: true });
}
});
+60
View File
@@ -0,0 +1,60 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import bots from "../src/game/dungeonRaceBotCatalog.json" with { type: "json" };
export const RACE_QUEUE_WAIT_MS = 5_000;
export const RACE_PREPARE_MS = 5 * 60_000;
export const RACE_COUNTDOWN_MS = 3_000;
export const RACE_DISCONNECT_MS = 30_000;
export const RACE_MAX_MS = 2 * 60 * 60_000;
export const RACE_PERIOD_MS = 30 * 24 * 60 * 60_000;
export function racePoolForDungeon(id) {
if (id.startsWith("randomized-bite-sized-")) return "randomized";
return id.startsWith("bite-sized-") ? "authored" : null;
}
export function raceDungeons(pool, policy) {
const disabled = new Set(policy?.disabled?.[pool === "randomized" ? "randomizedBiteSized" : "biteSized"] ?? []);
return catalog.dungeons.filter((dungeon) => racePoolForDungeon(dungeon.id) === pool
&& dungeon.difficulties.some((difficulty) => difficulty.finalBossId) && !disabled.has(dungeon.id));
}
export function compatibleDungeons(left, right, policy) {
if (left.pool !== right.pool) return [];
return raceDungeons(left.pool, policy).filter((dungeon) =>
(!left.dungeonId || left.dungeonId === dungeon.id) && (!right.dungeonId || right.dungeonId === dungeon.id));
}
export function hashRaceSeed(value) {
let hash = 2166136261;
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function botTierForRecord(wins, losses, seed) {
const total = wins + losses;
const rate = total < 5 ? 0.5 : wins / total;
const base = rate < 0.4 ? 0 : rate < 0.6 ? 1 : rate < 0.8 ? 2 : 3;
const roll = hashRaceSeed(seed) % 4;
return bots.tiers[Math.max(0, Math.min(3, base + (roll === 0 ? -1 : roll === 3 ? 1 : 0)))].id;
}
export function botOpponent(seed, tierId, source = null) {
const tier = bots.tiers.find((entry) => entry.id === tierId) ?? bots.tiers[1];
const resolvedSource = source ?? (hashRaceSeed(`${seed}:source`) % 2 ? "rom" : "wow");
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
const finishMs = Math.round(bots.baselineMs * tier.multiplier * variance);
return { type: "bot", name: `${tier.label} ${resolvedSource === "rom" ? "RuneWaker" : "Azeroth"} Team`,
tier: tier.id, source: resolvedSource, roster: bots.teams[resolvedSource], finishMs,
milestones: bots.milestones.map((entry) => ({ ...entry, atMs: Math.round(finishMs * entry.at) })) };
}
export function botProgress(elapsedMs, bot) {
if (elapsedMs <= 0) return 0;
const previous = [{ atMs: 0, progress: 0 }, ...bot.milestones]
.filter((entry) => entry.atMs <= elapsedMs).at(-1);
const next = bot.milestones.find((entry) => entry.atMs > elapsedMs);
if (!next) return 100;
return Math.floor(previous.progress + (next.progress - previous.progress)
* (elapsedMs - previous.atMs) / (next.atMs - previous.atMs));
}
+190
View File
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import { GameDatabaseError } from "./database.mjs";
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons,
RACE_COUNTDOWN_MS, RACE_DISCONNECT_MS, RACE_MAX_MS, RACE_PREPARE_MS, RACE_QUEUE_WAIT_MS } from "./dungeon-race-rules.mjs";
export function createDungeonRaceService({ database, now = () => Date.now(), autoPump = true } = {}) {
const tickets = new Map();
const matches = new Map();
const accountMatch = new Map();
const lastSeen = new Map();
function currentMatch(accountId) {
const id = accountMatch.get(accountId);
return id ? matches.get(id) ?? null : null;
}
function requireParticipant(accountId, id) {
const match = matches.get(id) ?? database.getDungeonRaceMatch(id);
if (!match?.participants.some((entry) => entry.accountId === accountId))
throw new GameDatabaseError("That race is not yours.", 404, "race_not_found");
return match;
}
function publicState(accountId, timestamp = now()) {
const ticket = tickets.get(accountId);
if (ticket) return { status: "queued", serverTime: timestamp, joinedAt: ticket.joinedAt,
botAt: ticket.joinedAt + RACE_QUEUE_WAIT_MS, pool: ticket.pool, dungeonId: ticket.dungeonId };
const match = currentMatch(accountId);
if (!match) return { status: "idle", serverTime: timestamp };
const self = match.participants.find((entry) => entry.accountId === accountId);
const rival = match.participants.find((entry) => entry.side !== self?.side);
const elapsedMs = match.startAt ? Math.max(0, timestamp - match.startAt) : 0;
return { status: match.status === "active" && timestamp < match.startAt ? "countdown" : match.status,
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty,
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
ready: self?.readyAt != null, opponent: match.bot ?? (rival ? { type: "human", name: rival.username,
source: rival.source, ready: rival.readyAt != null, finished: rival.clearAt != null } : null),
opponentProgress: match.bot ? botProgress(elapsedMs, match.bot) : null,
result: match.status === "settled" ? (match.winnerSide === self?.side ? "win" : "loss") : null,
clearDurationMs: self?.durationMs ?? null };
}
function refresh(id) {
const updated = database.getDungeonRaceMatch(id);
if (updated) matches.set(id, updated);
return updated;
}
function settle(match, winnerSide, timestamp) {
const updated = database.settleDungeonRace(match.id, winnerSide, timestamp);
if (updated) matches.set(match.id, updated);
}
function makeBot(seed, accountId) {
const { wins, losses } = database.dungeonRaceRecordForAccount(accountId, now());
return botOpponent(seed, botTierForRecord(wins, losses, seed));
}
function createMatch(left, right, choices, timestamp) {
const id = `race-${randomUUID()}`;
const dungeon = choices[hashRaceSeed(id) % choices.length];
const bot = right ? null : makeBot(id, left.accountId);
const participants = [left, ...(right ? [right] : [{ side: 1, accountId: null,
characterId: null, username: bot.name, source: bot.source, role: null }])]
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
username: entry.username, source: entry.source, role: entry.role }));
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id,
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
matches.set(id, match);
for (const participant of participants) if (participant.accountId) {
tickets.delete(participant.accountId);
accountMatch.set(participant.accountId, id);
lastSeen.set(participant.accountId, timestamp);
}
return match;
}
function replaceWithBot(match, side, timestamp) {
const leaving = match.participants.find((entry) => entry.side === side);
if (leaving?.accountId) { accountMatch.delete(leaving.accountId); lastSeen.delete(leaving.accountId); }
const surviving = match.participants.find((entry) => entry.side !== side && entry.accountId);
if (!surviving) { matches.set(match.id, database.voidDungeonRace(match.id, timestamp)); return; }
const bot = makeBot(match.seed, surviving.accountId);
const updated = database.replaceDungeonRaceWithBot(match.id, side, bot);
matches.set(match.id, updated);
if (updated.participants.find((entry) => entry.accountId === surviving.accountId)?.readyAt != null) {
matches.set(match.id, database.startDungeonRace(match.id, timestamp + RACE_COUNTDOWN_MS));
}
}
function pump() {
const timestamp = now();
const waiting = [...tickets.values()].sort((a, b) => a.joinedAt - b.joinedAt);
for (const left of waiting) {
if (!tickets.has(left.accountId)) continue;
const policy = database.getDungeonQueuePolicy().config;
const right = waiting.find((candidate) => candidate.accountId !== left.accountId
&& tickets.has(candidate.accountId) && compatibleDungeons(left, candidate, policy).length > 0);
if (right) { createMatch(left, right, compatibleDungeons(left, right, policy), timestamp); continue; }
if (timestamp - left.joinedAt >= RACE_QUEUE_WAIT_MS) {
const choices = raceDungeons(left.pool, policy).filter((entry) => !left.dungeonId || left.dungeonId === entry.id);
if (choices.length) createMatch(left, null, choices, timestamp);
else tickets.delete(left.accountId);
}
}
for (const match of [...matches.values()]) {
if (match.status === "preparing" && timestamp - match.createdAt >= RACE_PREPARE_MS) {
const ready = match.participants.filter((entry) => entry.accountId && entry.readyAt != null);
if (ready.length === 1 && match.participants.filter((entry) => entry.accountId).length === 2) {
const missing = match.participants.find((entry) => entry.accountId && entry.readyAt == null);
replaceWithBot(match, missing.side, timestamp);
} else if (ready.length === 0) {
matches.set(match.id, database.voidDungeonRace(match.id, timestamp));
}
}
if (match.status !== "active" || timestamp < match.startAt) continue;
if (timestamp - match.startAt >= RACE_MAX_MS) {
matches.set(match.id, database.voidDungeonRace(match.id, timestamp));
continue;
}
if (match.bot && timestamp >= match.startAt + match.bot.finishMs) {
settle(match, match.participants.find((entry) => !entry.accountId).side, match.startAt + match.bot.finishMs);
continue;
}
const stale = match.participants.filter((entry) => entry.accountId
&& timestamp - (lastSeen.get(entry.accountId) ?? match.startAt) >= RACE_DISCONNECT_MS);
if (stale.length === 2) { matches.set(match.id, database.voidDungeonRace(match.id, timestamp)); continue; }
if (stale.length === 1) settle(match, 1 - stale[0].side, timestamp);
}
}
function queue(account, input) {
if (tickets.has(account.id) || ["preparing", "active"].includes(currentMatch(account.id)?.status))
throw new GameDatabaseError("You are already in a race.", 409, "race_already_active");
if (!input || !["authored", "randomized"].includes(input.pool)
|| (input.dungeonId !== undefined && (typeof input.dungeonId !== "string" || input.dungeonId.length > 120))
|| !["tank", "healer", "damage"].includes(input.role))
throw new GameDatabaseError("Choose a valid race queue and party role.", 400, "invalid_race_queue");
const character = database.getCloudSave(account.id).data?.characters?.find((entry) => entry?.id === input.characterId);
if (!character || !["wow", "rom"].includes(character.categoryId ?? "wow"))
throw new GameDatabaseError("Choose a WoW or RoM character from your account.", 400, "invalid_race_character");
if (database.onlineGroupState(account.id).group?.members.length > 1)
throw new GameDatabaseError("Leave your online group before entering a solo race.", 409, "race_group_active");
const eligible = raceDungeons(input.pool, database.getDungeonQueuePolicy().config);
if (!eligible.length || (input.dungeonId && !eligible.some((entry) => entry.id === input.dungeonId)))
throw new GameDatabaseError("No eligible dungeon is enabled for this queue.", 409, "race_dungeon_unavailable");
tickets.set(account.id, { accountId: account.id, username: account.username,
characterId: character.id, source: character.categoryId ?? "wow", role: input.role,
pool: input.pool, dungeonId: input.dungeonId ?? null, joinedAt: now() });
pump();
return publicState(account.id);
}
function state(account) {
pump();
if (currentMatch(account.id)?.status === "active") lastSeen.set(account.id, now());
return publicState(account.id);
}
function ready(account, id) {
const match = requireParticipant(account.id, id);
if (match.status !== "preparing") return publicState(account.id);
lastSeen.set(account.id, now());
const updated = database.readyDungeonRace(id, account.id, now());
matches.set(id, updated);
const humans = updated.participants.filter((entry) => entry.accountId);
if (humans.every((entry) => entry.readyAt != null)) matches.set(id, database.startDungeonRace(id, now() + RACE_COUNTDOWN_MS));
return publicState(account.id);
}
function finish(account, id, input) {
const match = requireParticipant(account.id, id);
if (!accountMatch.has(account.id)) { accountMatch.set(account.id, id); matches.set(id, match); }
if (input?.runId !== match.seed)
throw new GameDatabaseError("This clear belongs to a different race run.", 400, "race_run_mismatch");
pump();
const updated = database.completeDungeonRaceRun(id, account.id, input.bossIds, now());
matches.set(id, updated);
if (updated.status === "active") settle(updated, updated.participants.find((entry) => entry.accountId === account.id).side, now());
return publicState(account.id);
}
function leave(account, id = null) {
if (id && currentMatch(account.id)?.id !== id)
throw new GameDatabaseError("That race is not yours.", 404, "race_not_found");
tickets.delete(account.id);
const match = currentMatch(account.id);
if (!match) return publicState(account.id);
const self = match.participants.find((entry) => entry.accountId === account.id);
if (match.status === "preparing" || (match.status === "active" && now() < match.startAt)) {
replaceWithBot(match, self.side, now());
} else if (match.status === "active") settle(match, 1 - self.side, now());
accountMatch.delete(account.id);
lastSeen.delete(account.id);
return publicState(account.id);
}
const timer = autoPump ? setInterval(pump, 250) : null;
timer?.unref?.();
return { queue, state, ready, finish, leave, pump, inspect: () => ({ tickets, matches }),
close: () => { if (timer) clearInterval(timer); for (const match of matches.values())
if (["preparing", "active"].includes(match.status)) database.voidDungeonRace(match.id, now()); } };
}
+166
View File
@@ -0,0 +1,166 @@
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 });
}
});
+162
View File
@@ -0,0 +1,162 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
export function createDungeonRaceRepository(database, { ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS 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 CHECK (status IN ('preparing', 'active', 'settled', 'void')),
created_at INTEGER NOT NULL, start_at INTEGER, settled_at INTEGER,
winner_side INTEGER CHECK (winner_side IN (0, 1)), bot_json TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_race_participants (
match_id TEXT NOT NULL REFERENCES dungeon_race_matches(id) ON DELETE CASCADE,
side INTEGER NOT NULL CHECK (side IN (0, 1)),
account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
character_id TEXT, username TEXT NOT NULL, source TEXT NOT NULL,
role TEXT, ready_at INTEGER, clear_at INTEGER, duration_ms INTEGER,
outcome TEXT CHECK (outcome IN ('win', 'loss')),
PRIMARY KEY (match_id, side)
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_race_result_idx
ON dungeon_race_matches(status, settled_at, dungeon_id);
CREATE INDEX IF NOT EXISTS dungeon_race_account_idx
ON dungeon_race_participants(account_id, outcome, clear_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', ?, ?)`);
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
(match_id, side, account_id, character_id, username, source, role)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const updateReady = database.prepare(`UPDATE dungeon_race_participants SET ready_at = COALESCE(ready_at, ?)
WHERE match_id = ? AND account_id = ?`);
const updateStart = database.prepare(`UPDATE dungeon_race_matches SET status = 'active', start_at = ?
WHERE id = ? AND status = 'preparing'`);
const updateBot = database.prepare(`UPDATE dungeon_race_matches SET bot_json = ?
WHERE id = ? AND status IN ('preparing', 'active')`);
const deleteParticipant = database.prepare("DELETE FROM dungeon_race_participants WHERE match_id = ? AND side = ?");
const updateClear = database.prepare(`UPDATE dungeon_race_participants
SET clear_at = COALESCE(clear_at, ?), duration_ms = COALESCE(duration_ms, ?)
WHERE match_id = ? AND account_id = ? AND clear_at IS NULL`);
const updateSettlement = database.prepare(`UPDATE dungeon_race_matches
SET status = 'settled', settled_at = ?, winner_side = ? WHERE id = ? AND status = 'active'`);
const updateVoid = database.prepare(`UPDATE dungeon_race_matches
SET status = 'void', settled_at = ? WHERE id = ? AND status IN ('preparing', 'active')`);
const updateOutcome = database.prepare(`UPDATE dungeon_race_participants SET outcome = ? WHERE match_id = ? AND side = ?`);
function transaction(action) {
database.exec("BEGIN IMMEDIATE");
try { const result = action(); database.exec("COMMIT"); return result; }
catch (error) { database.exec("ROLLBACK"); throw error; }
}
function getMatch(id) {
const match = matchById.get(id);
if (!match) return null;
return { id: match.id, dungeonId: match.dungeon_id, seed: match.seed,
difficultyId: match.difficulty_id, bossDifficulty: match.boss_difficulty,
status: match.status, createdAt: match.created_at, startAt: match.start_at,
settledAt: match.settled_at, winnerSide: match.winner_side,
bot: match.bot_json ? JSON.parse(match.bot_json) : null,
participants: participantsById.all(id).map((row) => ({ side: row.side, accountId: row.account_id,
characterId: row.character_id, username: row.username, source: row.source, role: row.role,
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
}
function createMatch(match) {
transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null);
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
});
return getMatch(match.id);
}
function settleMatch(id, winnerSide, timestamp) {
return transaction(() => {
const changed = updateSettlement.run(timestamp, winnerSide, id).changes;
if (changed) for (const participant of participantsById.all(id)) {
if (participant.account_id) updateOutcome.run(participant.side === winnerSide ? "win" : "loss", id, participant.side);
}
return getMatch(id);
});
}
function completeRaceRun(id, accountId, bossIds, timestamp) {
const match = getMatch(id);
if (!match || !match.participants.some((entry) => entry.accountId === accountId))
throw new ErrorType("That race is not yours.", 404, "race_not_found");
if (!match.startAt || timestamp < match.startAt || timestamp - match.startAt > 2 * 60 * 60_000
|| 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] : []);
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|| !bossIds.some((value) => finalIds.includes(value)))
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);
return getMatch(id);
});
}
function replaceWithBot(id, side, bot) {
transaction(() => {
deleteParticipant.run(id, side);
insertParticipant.run(id, side, null, null, bot.name, bot.source, null);
updateBot.run(JSON.stringify(bot), id);
});
return getMatch(id);
}
function recordForAccount(accountId, timestamp) {
const row = database.prepare(`SELECT
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
WHERE p.account_id = ? AND m.status = 'settled' AND m.settled_at >= ?`)
.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()) {
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
|| (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId)))
|| !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 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}
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
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;
let rank = 0;
const ranked = all.map((row, index) => {
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
if (value !== lastValue) rank = index + 1;
lastValue = value;
return { ...row, rank, ...(view === "wins"
? { 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 { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)),
start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun,
replaceWithBot, void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard };
}
+20
View File
@@ -0,0 +1,20 @@
export const DEFAULT_PARTY_BUFF_POLICY = Object.freeze({ schemaVersion: 1, rules: Object.freeze([]) });
export function normalizePartyBuffPolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1
|| !Array.isArray(value.rules) || value.rules.length > 500) {
throw new Error("Party buff policy must have schemaVersion 1 and at most 500 rules.");
}
const seen = new Set();
const rules = value.rules.map((rule, index) => {
if (!rule || typeof rule !== "object" || Array.isArray(rule)
|| typeof rule.abilityId !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(rule.abilityId)
|| !["party", "tank", "healer"].includes(rule.scope)) {
throw new Error(`Party buff rule ${index} must have a valid abilityId and scope.`);
}
if (seen.has(rule.abilityId)) throw new Error(`Duplicate party buff rule: ${rule.abilityId}.`);
seen.add(rule.abilityId);
return { abilityId: rule.abilityId, scope: rule.scope };
});
return { schemaVersion: 1, rules };
}
+20
View File
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizePartyBuffPolicy } from "./party-buff-policy.mjs";
test("accepts explicit party, tank, and healer selections", () => {
assert.deepEqual(normalizePartyBuffPolicy({ schemaVersion: 1, rules: [
{ abilityId: "priest-fortitude", scope: "party" },
{ abilityId: "paladin-blessing", scope: "tank" },
{ abilityId: "mage-intellect", scope: "healer" },
] }).rules.map((rule) => rule.scope), ["party", "tank", "healer"]);
});
test("rejects duplicate rules and invalid scopes", () => {
assert.throws(() => normalizePartyBuffPolicy({ schemaVersion: 1, rules: [
{ abilityId: "fortitude", scope: "party" }, { abilityId: "fortitude", scope: "tank" },
] }), /Duplicate/);
assert.throws(() => normalizePartyBuffPolicy({ schemaVersion: 1, rules: [
{ abilityId: "fortitude", scope: "everyone" },
] }), /valid abilityId and scope/);
});
+75 -1
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
import { createArenaService } from "./arena-service.mjs";
import { createDungeonRaceService } from "./dungeon-race-service.mjs";
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
@@ -188,6 +189,7 @@ export function createGameServer(options = {}) {
});
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const dungeonRaceService = createDungeonRaceService({ database: gameDatabase });
const allowAuthRequest = createAuthLimiter();
const allowReportRequest = createAuthLimiter();
const allowDungeonRunRequest = createAuthLimiter();
@@ -303,6 +305,40 @@ export function createGameServer(options = {}) {
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
return;
}
if (url.pathname === "/api/dungeon-races/leaderboard" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonRaceLeaderboard({
view: url.searchParams.get("view") ?? "wins",
period: url.searchParams.get("period") ?? "30d",
dungeonId: url.searchParams.get("dungeon") || null,
limit: Number(url.searchParams.get("limit") ?? 20),
offset: Number(url.searchParams.get("offset") ?? 0),
}));
return;
}
if (url.pathname === "/api/dungeon-races/queue" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, dungeonRaceService.queue(account, await readJson(request, 4 * 1024)));
return;
}
if (url.pathname === "/api/dungeon-races/queue" && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, dungeonRaceService.leave(account));
return;
}
if (url.pathname === "/api/dungeon-races/state" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, dungeonRaceService.state(account));
return;
}
const dungeonRaceMatch = /^\/api\/dungeon-races\/(race-[A-Za-z0-9-]+)\/(ready|finish|forfeit)$/.exec(url.pathname);
if (dungeonRaceMatch && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const [, id, action] = dungeonRaceMatch;
json(response, 200, action === "ready" ? dungeonRaceService.ready(account, id)
: action === "finish" ? dungeonRaceService.finish(account, id, await readJson(request, 4 * 1024))
: dungeonRaceService.leave(account, id));
return;
}
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"),
@@ -540,6 +576,43 @@ export function createGameServer(options = {}) {
json(response, 200, result);
return;
}
if (url.pathname === "/api/party-buff-policy" && request.method === "GET") {
const current = gameDatabase.getPartyBuffPolicy();
json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config });
return;
}
if (url.pathname === "/api/dungeon-queue-policy" && request.method === "GET") {
const current = gameDatabase.getDungeonQueuePolicy();
json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config });
return;
}
if (url.pathname === "/api/gm/dungeon-queue-policy" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
json(response, 200, gameDatabase.getDungeonQueuePolicy());
return;
}
if (url.pathname === "/api/gm/dungeon-queue-policy" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
const body = await readJson(request, bodyLimit);
json(response, 200, gameDatabase.putDungeonQueuePolicy(account, body.expectedRevision, body.config));
return;
}
if (url.pathname === "/api/gm/party-buff-policy" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
json(response, 200, gameDatabase.getPartyBuffPolicy());
return;
}
if (url.pathname === "/api/gm/party-buff-policy" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
const body = await readJson(request, bodyLimit);
const result = gameDatabase.putPartyBuffPolicy(account, body.expectedRevision, body.config);
json(response, 200, result);
return;
}
if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") {
const current = gameDatabase.getCompanionSpecReadiness();
json(response, 200, {
@@ -686,9 +759,10 @@ export function createGameServer(options = {}) {
balanceLabJobs.close?.();
onlineSessions.clear();
arenaService.close();
dungeonRaceService.close();
gameDatabase.close();
});
return { server, database: gameDatabase, arenaService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
return { server, database: gameDatabase, arenaService, dungeonRaceService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+20
View File
@@ -94,6 +94,14 @@ test("serves the game and authenticated cloud-save API", async () => {
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(deniedCooldownPolicy.status, 403);
const publicPartyBuffPolicy = await (await fetch(`${origin}/api/party-buff-policy`)).json();
assert.deepEqual(publicPartyBuffPolicy.config.rules, []);
const deniedPartyBuffPolicy = await fetch(`${origin}/api/gm/party-buff-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1, rules: [] } }),
});
assert.equal(deniedPartyBuffPolicy.status, 403);
const publicReadiness = await (await fetch(`${origin}/api/companion-spec-readiness`)).json();
assert.equal(publicReadiness.revision, 0);
assert.equal(publicReadiness.config.statuses["warrior:protection"], "ready");
@@ -268,6 +276,18 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(publishedCooldownPolicyPayload.config.generic.defensiveHealthPercent, 61);
const playerCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json();
assert.equal(playerCooldownPolicy.config.generic.defensiveHealthPercent, 61);
const publishedPartyBuffPolicy = await fetch(`${origin}/api/gm/party-buff-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({
expectedRevision: publicPartyBuffPolicy.revision,
config: { schemaVersion: 1, rules: [{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" }] },
}),
});
assert.equal(publishedPartyBuffPolicy.status, 200);
assert.equal((await publishedPartyBuffPolicy.json()).revision, 1);
const playerPartyBuffPolicy = await (await fetch(`${origin}/api/party-buff-policy`)).json();
assert.deepEqual(playerPartyBuffPolicy.config.rules, [{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" }]);
const publishedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },