Files
healer-man/server/dungeon-race-service.mjs
T
2026-09-26 17:51:56 -04:00

191 lines
11 KiB
JavaScript

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