import { randomUUID } from "node:crypto"; import { GameDatabaseError } from "./database.mjs"; import { BITE_SIZED_LAYOUT_CATALOG } from "../shared/bite-sized-layouts.mjs"; import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons, rollRaceAffixes, 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, affixIds: match.affixIds, ...(match.biteSizedSelection ? { biteSizedSelection: match.biteSizedSelection } : {}), 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, affixIds) { const { wins, losses } = database.dungeonRaceRecordForAccount(accountId, now()); return botOpponent(seed, botTierForRecord(wins, losses, seed), null, affixIds); } function createMatch(left, right, choices, timestamp) { const id = `race-${randomUUID()}`; const dungeon = choices[hashRaceSeed(id) % choices.length]; const affixIds = rollRaceAffixes(id); const bot = right ? null : makeBot(id, left.accountId, affixIds); const biteSizedSelection = database.resolveBiteSizedRun?.(dungeon.id, id, BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !database.getDungeonQueuePolicy().config.disabled.biteSized.includes(d.id)).map((d) => d.id)); 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, affixIds, biteSizedSelection, 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, match.affixIds); 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(), input.combatLog ?? null); 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()); } }; }