import { randomBytes, randomUUID } from "node:crypto"; import { GameDatabaseError } from "./database.mjs"; import { ARENA_BRACKETS, arenaMatchTolerance, assertArenaQueueInput, validateArenaComposition } from "./arena-rules.mjs"; import { ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs"; import { ArenaSimulation } from "./arena-simulation.mjs"; import { arenaKitForLoadout } from "./arena-character-kit.mjs"; const READY_CHECK_MS = 15_000; const PVP_BOT_FILL_MS = 30_000; function average(values) { return Math.round(values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length)); } function publicTicket(ticket, now = Date.now()) { if (!ticket) return { status: "idle" }; return { status: ticket.status, ticketId: ticket.id, joinedAt: ticket.joinedAt, searchRange: arenaMatchTolerance(now - ticket.joinedAt), readyDeadline: ticket.lobby?.readyDeadline ?? null, matchId: ticket.matchId ?? ticket.lobby?.matchId ?? null, lockedLoadout: ticket.members.find((member) => member.accountId === ticket.ownerAccountId)?.loadout ?? ticket.members[0]?.loadout, }; } function roleCapacity(bracket, members) { const roles = members.map((member) => member.role); return validateArenaComposition(bracket, roles, { allowPartial: true }).ok; } export function createArenaService({ database, random = Math.random, now = () => Date.now(), autoPump = true } = {}) { const tickets = new Map(); const accountTickets = new Map(); const lobbies = new Map(); const matches = new Map(); const socketTickets = new Map(); const subscribers = new Map(); const previousStageByAccount = new Map(); function broadcast(accountId, message) { for (const send of subscribers.get(accountId) ?? []) send(message); } function broadcastMatch(match, message) { for (const participant of match.participants) if (participant.accountId) broadcast(participant.accountId, message); } function broadcastSnapshot(match, snapshot) { for (const participant of match.participants) { if (participant.accountId) broadcast(participant.accountId, { type: "snapshot", snapshot: { ...snapshot, controlledActorId: participant.actorId }, }); } } function queueState(accountId) { return publicTicket(accountTickets.get(accountId), now()); } function arenaState(accountId, characterId = null) { return { ...database.getArenaState(accountId, characterId), queue: queueState(accountId), stages: ARENA_STAGES, }; } function resolveMembers(account, input) { const groupState = database.onlineGroupState(account.id); const group = groupState.group; const requested = Array.isArray(input.groupMembers) ? input.groupMembers : []; if (!group || group.members.length <= 1) { const loadout = database.arenaLoadoutForQueue(account.id, input.characterId, input.loadoutSlot); return [{ accountId: account.id, username: account.username, characterId: input.characterId, loadout, role: loadout.role }]; } if (group.leaderAccountId !== account.id) { throw new GameDatabaseError("Only the group leader can enter the group into an arena queue.", 403, "arena_leader_required"); } if (group.members.length > ARENA_BRACKETS[input.bracket].size) { throw new GameDatabaseError(`That group is too large for ${input.bracket}.`, 409, "arena_group_too_large"); } return group.members.map((member) => { if (accountTickets.has(member.accountId)) throw new GameDatabaseError(`${member.username} is already queued.`, 409, "arena_already_queued"); const selection = requested.find((entry) => entry.accountId === member.accountId) ?? (member.accountId === account.id ? input : null); const characterId = selection?.characterId ?? member.character?.id; if (!characterId) throw new GameDatabaseError(`${member.username} must select a group character.`, 409, "arena_group_character_required"); const loadout = selection?.loadoutSlot ? database.arenaLoadoutForQueue(member.accountId, characterId, selection.loadoutSlot) : database.selectedArenaLoadoutForQueue(member.accountId, characterId); return { accountId: member.accountId, username: member.username, characterId, loadout, role: loadout.role, }; }); } function enqueue(account, inputValue) { if (accountTickets.has(account.id)) throw new GameDatabaseError("You are already in an arena queue.", 409, "arena_already_queued"); let input; try { input = { ...inputValue, ...assertArenaQueueInput(inputValue) }; } catch (error) { throw new GameDatabaseError(error.message, 400, "invalid_arena_queue"); } const members = resolveMembers(account, input); const composition = validateArenaComposition(input.bracket, members.map((member) => member.role), { allowPartial: true }); if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code); if (members.some((member) => member.loadout.ruleset !== input.ruleset)) { throw new GameDatabaseError("Every loadout must use the selected ruleset.", 400, "arena_ruleset_mismatch"); } const ratings = members.map((member) => database.getArenaRating(member.accountId, member.characterId, input.bracket).rating); const ticket = { id: `queue-${randomUUID()}`, ownerAccountId: account.id, bracket: input.bracket, mode: input.mode, ruleset: input.ruleset, joinedAt: now(), rating: average(ratings), members, status: "queued", ready: new Set(), lobby: null, matchId: null, }; tickets.set(ticket.id, ticket); for (const member of members) accountTickets.set(member.accountId, ticket); if (ticket.mode === "rated-ai") createLobby([[ticket], []], ticket.rating); else pump(); return publicTicket(ticket, now()); } function removeTicket(ticket) { tickets.delete(ticket.id); for (const member of ticket.members) if (accountTickets.get(member.accountId) === ticket) accountTickets.delete(member.accountId); } function leaveQueue(accountId) { const ticket = accountTickets.get(accountId); if (!ticket) return { status: "idle" }; if (ticket.status === "matched") { const match = matches.get(ticket.matchId); const participant = match?.participants.find((entry) => entry.accountId === accountId); if (participant) match.simulation.disconnect(participant.actorId); return publicTicket(ticket, now()); } if (ticket.lobby) cancelLobby(ticket.lobby, "A player left the ready check."); else removeTicket(ticket); return { status: "idle" }; } function compatible(left, right, timestamp) { if (left.id === right.id || left.bracket !== right.bracket || left.ruleset !== right.ruleset || left.mode !== right.mode) return false; const tolerance = Math.max(arenaMatchTolerance(timestamp - left.joinedAt), arenaMatchTolerance(timestamp - right.joinedAt)); return Math.abs(left.rating - right.rating) <= tolerance; } function assembleHumanLobby(pool, timestamp) { if (pool.length < 2) return null; const bracket = pool[0].bracket; const teamSize = ARENA_BRACKETS[bracket].size; for (let anchorIndex = 0; anchorIndex < pool.length; anchorIndex += 1) { const anchor = pool[anchorIndex]; const compatiblePool = pool.filter((ticket) => compatible(anchor, ticket, timestamp) || ticket === anchor); const teams = [[], []]; const memberSets = [[], []]; for (const ticket of compatiblePool) { const side = [0, 1].find((team) => ( memberSets[team].length + ticket.members.length <= teamSize && roleCapacity(bracket, [...memberSets[team], ...ticket.members]) )); if (side == null) continue; teams[side].push(ticket); memberSets[side].push(...ticket.members); if (memberSets.every((members) => validateArenaComposition(bracket, members.map((member) => member.role)).ok)) return teams; } } return null; } function pump() { const timestamp = now(); for (const ticket of [...tickets.values()]) { if (ticket.status === "ready-check" && ticket.lobby?.readyDeadline <= timestamp) cancelLobby(ticket.lobby, "The ready check expired."); } const waiting = [...tickets.values()].filter((ticket) => ticket.status === "queued" && ticket.mode === "ranked-pvp"); const pools = new Map(); for (const ticket of waiting) { const key = `${ticket.bracket}:${ticket.ruleset}`; const pool = pools.get(key) ?? []; pool.push(ticket); pools.set(key, pool); } for (const pool of pools.values()) { while (true) { const teams = assembleHumanLobby(pool.filter((ticket) => ticket.status === "queued"), timestamp); if (!teams) break; createLobby(teams, average(teams.flat().map((ticket) => ticket.rating))); } for (const ticket of pool) { if (ticket.status === "queued" && timestamp - ticket.joinedAt >= PVP_BOT_FILL_MS) createLobby([[ticket], []], ticket.rating); } } } function botMember(team, role, rating, index) { return { actorId: `bot-${randomUUID()}`, accountId: null, characterId: null, name: `${rating >= 2400 ? "Gladiator" : rating >= 1800 ? "Duelist" : rating >= 1000 ? "Challenger" : "Recruit"} ${role === "healer" ? "Medic" : `Fighter ${index + 1}`}`, team, role, isBot: true, disconnected: false, rating, loadout: null, }; } function fillTeam(humans, team, bracket, rating) { const members = humans.map((member) => { const character = database.getCloudSave?.(member.accountId)?.data?.characters?.find((entry) => entry.id === member.characterId) ?? {}; return ({ actorId: `player-${randomUUID()}`, accountId: member.accountId, characterId: member.characterId, name: character.name || member.username, characterKit: arenaKitForLoadout(member.loadout, character), team, role: member.role, isBot: false, disconnected: false, rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating, loadout: member.loadout, }); }); const composition = validateArenaComposition(bracket, members.map((member) => member.role), { allowPartial: true }); if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code); for (let index = 0; index < composition.missing.healer; index += 1) members.push(botMember(team, "healer", rating, index)); for (let index = 0; index < composition.missing.damage; index += 1) members.push(botMember(team, "damage", rating, index)); return members; } function createLobby(teamTickets, effectiveRating) { const allTickets = teamTickets.flat(); if (!allTickets.length || allTickets.some((ticket) => ticket.status !== "queued")) return null; const first = allTickets[0]; const matchId = `arena-${randomUUID()}`; const humanTeams = teamTickets.map((side) => side.flatMap((ticket) => ticket.members)); const participants = [ ...fillTeam(humanTeams[0], 0, first.bracket, effectiveRating), ...fillTeam(humanTeams[1], 1, first.bracket, effectiveRating), ]; const previousStageId = allTickets.map((ticket) => previousStageByAccount.get(ticket.ownerAccountId)).find(Boolean) ?? null; const stage = chooseArenaStage({ random, previousStageId }); const lobby = { id: `lobby-${randomUUID()}`, matchId, bracket: first.bracket, mode: first.mode, ruleset: first.ruleset, stage, tickets: allTickets, participants, effectiveRating, readyAccounts: new Set(), readyDeadline: now() + READY_CHECK_MS, }; lobbies.set(lobby.id, lobby); for (const ticket of allTickets) { ticket.status = "ready-check"; ticket.lobby = lobby; ticket.matchId = matchId; for (const member of ticket.members) broadcast(member.accountId, { type: "ready-check", deadline: lobby.readyDeadline, matchId }); } return lobby; } function cancelLobby(lobby, message) { if (!lobbies.has(lobby.id)) return; lobbies.delete(lobby.id); for (const ticket of lobby.tickets) { ticket.lobby = null; ticket.matchId = null; ticket.status = "queued"; ticket.ready.clear(); ticket.joinedAt = now(); for (const member of ticket.members) broadcast(member.accountId, { type: "queue-cancelled", message }); } } function ready(accountId) { const ticket = accountTickets.get(accountId); const lobby = ticket?.lobby; if (!ticket || ticket.status !== "ready-check" || !lobby) throw new GameDatabaseError("No arena ready check is active.", 409, "arena_ready_check_missing"); if (lobby.readyDeadline <= now()) { cancelLobby(lobby, "The ready check expired."); throw new GameDatabaseError("The arena ready check expired.", 409, "arena_ready_check_expired"); } lobby.readyAccounts.add(accountId); ticket.ready.add(accountId); const humanAccounts = lobby.participants.filter((participant) => participant.accountId).map((participant) => participant.accountId); if (humanAccounts.every((id) => lobby.readyAccounts.has(id))) startMatch(lobby); return publicTicket(ticket, now()); } function startMatch(lobby) { if (!lobbies.delete(lobby.id)) return; const startedAt = now(); const stored = { id: lobby.matchId, bracket: lobby.bracket, mode: lobby.mode, ruleset: lobby.ruleset, stageId: lobby.stage.id, startedAt, participants: lobby.participants, }; database.createArenaMatch(stored); const match = { ...stored, stage: lobby.stage, subscribers: new Set(), result: null, simulation: null }; const simulation = new ArenaSimulation({ ...stored, stage: lobby.stage, startedAt, random, onSnapshot: (snapshot) => broadcastSnapshot(match, snapshot), onResult: (result) => settleMatch(match, result), }); match.simulation = simulation; matches.set(match.id, match); for (const ticket of lobby.tickets) { ticket.status = "matched"; ticket.lobby = null; ticket.matchId = match.id; for (const member of ticket.members) { previousStageByAccount.set(member.accountId, lobby.stage.id); const participant = match.participants.find((entry) => entry.accountId === member.accountId); broadcast(member.accountId, { type: "match-start", matchId: match.id, stage: lobby.stage, snapshot: { ...simulation.snapshot(), controlledActorId: participant?.actorId }, }); } } simulation.start(); } function settleMatch(match, result) { if (match.result) return match.result; const settlement = result.reason === "void" ? (database.voidArenaMatch(match.id), { ...result, ratingUpdates: [] }) : { ...result, ...database.settleArenaMatch(match.id, result.winnerTeam) }; match.result = settlement; broadcastMatch(match, { type: "result", result: settlement }); for (const participant of match.participants) { if (!participant.accountId) continue; const ticket = accountTickets.get(participant.accountId); if (ticket?.matchId === match.id) removeTicket(ticket); } return settlement; } function socketTicket(accountId) { const token = randomBytes(32).toString("base64url"); socketTickets.set(token, { accountId, expiresAt: now() + 30_000 }); return { ticket: token, expiresAt: now() + 30_000, path: "/ws/arena" }; } function consumeSocketTicket(token) { const entry = socketTickets.get(token); socketTickets.delete(token); if (!entry || entry.expiresAt <= now()) return null; return entry.accountId; } function subscribe(accountId, send) { const set = subscribers.get(accountId) ?? new Set(); set.add(send); subscribers.set(accountId, set); const ticket = accountTickets.get(accountId); if (ticket?.matchId) { const match = matches.get(ticket.matchId); if (match) { const participant = match.participants.find((entry) => entry.accountId === accountId); send({ type: "snapshot", snapshot: { ...match.simulation.snapshot(), controlledActorId: participant?.actorId } }); } } return () => { set.delete(send); if (!set.size) subscribers.delete(accountId); const current = accountTickets.get(accountId); if (current?.status === "matched") { const match = matches.get(current.matchId); const participant = match?.participants.find((entry) => entry.accountId === accountId); if (participant) match.simulation.disconnect(participant.actorId); } }; } function receive(accountId, message) { const ticket = accountTickets.get(accountId); const match = matches.get(ticket?.matchId); if (!match || match.result) return { accepted: false, reason: "match-unavailable" }; const participant = match.participants.find((entry) => entry.accountId === accountId); if (!participant) return { accepted: false, reason: "spectator" }; return match.simulation.receiveIntent(participant.actorId, message); } const interval = autoPump ? setInterval(pump, 250) : null; interval?.unref?.(); function close() { if (interval) clearInterval(interval); for (const match of matches.values()) { if (!match.result) match.simulation.finish(null, "void"); } subscribers.clear(); socketTickets.clear(); } return { arenaState, queueState, enqueue, leaveQueue, ready, pump, socketTicket, consumeSocketTicket, subscribe, receive, close, inspect: () => ({ tickets, lobbies, matches }), }; }