Files
healer-man/server/arena-service.mjs
T
2026-09-11 14:26:15 -04:00

431 lines
18 KiB
JavaScript

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