191 lines
11 KiB
JavaScript
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()); } };
|
|
}
|