Release Healer Man 0.1.16

This commit is contained in:
phenom committed 2026-09-05 20:42:38 -04:00
1 parent f49e54f35f
commit b57d2b2839
89 files changed
+6055 -17

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
test("arena loadouts are character-owned, tank-free, and matches settle exactly once", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-arena-db-test-"));
const database = openGameDatabase({ dataDir: directory });
try {
const left = await database.register("ArenaLeft", "arena-left-pass");
const right = await database.register("ArenaRight", "arena-right-pass");
database.putCloudSave(left.account.id, { version: 1, characters: [{ id: "left-priest", name: "Left", classId: "priest", categoryId: "wow" }] });
database.putCloudSave(right.account.id, { version: 1, characters: [{ id: "right-mage", name: "Right", classId: "mage", categoryId: "wow" }] });
const loadout = database.putArenaLoadout(left.account.id, "left-priest", 1, {
name: "Disc Control", classId: "priest", specializationId: "priest:discipline",
role: "healer", ruleset: "wow", talents: { "priest:discipline:1": 5 }, actions: { "1": "heal" }, selected: true,
});
assert.equal(loadout.level, 80);
assert.equal(loadout.version, 1);
assert.equal(database.getArenaState(left.account.id, "left-priest").loadouts.length, 1);
assert.throws(() => database.putArenaLoadout(right.account.id, "right-mage", 1, {
name: "Protection", classId: "mage", specializationId: "warrior:protection", ruleset: "wow", talents: {}, actions: {},
}), (error) => error instanceof GameDatabaseError);
database.createArenaMatch({
id: "arena-idempotent", bracket: "2v2", mode: "rated-ai", ruleset: "wow", stageId: "warnorken-arena", startedAt: Date.now(),
participants: [
{ actorId: "left", accountId: left.account.id, characterId: "left-priest", team: 0, role: "healer", isBot: false, rating: 0 },
{ actorId: "left-bot", team: 0, role: "damage", isBot: true, rating: 0 },
{ actorId: "right", accountId: right.account.id, characterId: "right-mage", team: 1, role: "damage", isBot: false, rating: 0 },
{ actorId: "right-bot", team: 1, role: "healer", isBot: true, rating: 0 },
],
});
const first = database.settleArenaMatch("arena-idempotent", 0);
const retry = database.settleArenaMatch("arena-idempotent", 0);
assert.equal(first.ratingUpdates.find((update) => update.characterId === "left-priest").after, 32);
assert.deepEqual(retry.ratingUpdates, first.ratingUpdates);
assert.equal(database.getArenaRating(left.account.id, "left-priest", "2v2").rating, 32);
assert.equal(database.getArenaRating(left.account.id, "left-priest", "3v3").rating, 0);
} finally {
database.close();
rmSync(directory, { recursive: true, force: true });
}
});
+115
View File
@@ -0,0 +1,115 @@
export const ARENA_BRACKETS = Object.freeze({
"2v2": Object.freeze({ size: 2, healers: 1, damage: 1 }),
"3v3": Object.freeze({ size: 3, healers: 1, damage: 2 }),
});
export const ARENA_RATING_BANDS = Object.freeze([
Object.freeze({ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 }),
Object.freeze({ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 }),
Object.freeze({ min: 1000, max: 1399, title: "Challenger", k: 40, reaction: [0.75, 1.3], healerInterrupt: 0.4, coordinatedCc: 0.4, defensive: 0.65, error: 0.2 }),
Object.freeze({ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 }),
Object.freeze({ min: 1800, max: 2099, title: "Duelist", k: 28, reaction: [0.4, 0.8], healerInterrupt: 0.72, coordinatedCc: 0.72, defensive: 0.82, error: 0.12 }),
Object.freeze({ min: 2100, max: 2399, title: "Elite", k: 24, reaction: [0.3, 0.65], healerInterrupt: 0.8, coordinatedCc: 0.8, defensive: 0.88, error: 0.1 }),
Object.freeze({ min: 2400, max: 2500, title: "Gladiator", k: 20, reaction: [0.25, 0.55], healerInterrupt: 0.85, coordinatedCc: 0.85, defensive: 0.9, error: 0.08 }),
]);
export const ARENA_MODES = Object.freeze(["rated-ai", "ranked-pvp"]);
export const ARENA_RULESETS = Object.freeze(["wow", "coa", "rom"]);
export function clampArenaRating(value) {
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
}
export function arenaRatingBand(rating) {
const normalized = clampArenaRating(rating);
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max)
?? ARENA_RATING_BANDS[0];
}
function lerp(left, right, amount) {
return left + (right - left) * Math.max(0, Math.min(1, amount));
}
export function arenaAiProfile(rating) {
const normalized = clampArenaRating(rating);
const index = ARENA_RATING_BANDS.findIndex((band) => normalized >= band.min && normalized <= band.max);
const current = ARENA_RATING_BANDS[Math.max(0, index)];
const next = ARENA_RATING_BANDS[Math.min(ARENA_RATING_BANDS.length - 1, Math.max(0, index) + 1)];
const span = Math.max(1, current.max - current.min + 1);
const progress = current === next ? 0 : (normalized - current.min) / span;
return Object.freeze({
rating: normalized,
title: current.title,
reactionMinSeconds: lerp(current.reaction[0], next.reaction[0], progress),
reactionMaxSeconds: lerp(current.reaction[1], next.reaction[1], progress),
healerInterruptChance: lerp(current.healerInterrupt, next.healerInterrupt, progress),
coordinatedCcChance: lerp(current.coordinatedCc, next.coordinatedCc, progress),
correctDefensiveChance: lerp(current.defensive, next.defensive, progress),
minimumErrorChance: lerp(current.error, next.error, progress),
});
}
export function calculateArenaRating({ rating, opponentRating, score }) {
const before = clampArenaRating(rating);
const opposing = clampArenaRating(opponentRating);
const normalizedScore = score === 0.5 ? 0.5 : score > 0 ? 1 : 0;
if (normalizedScore === 0.5) return { before, expected: 0.5, delta: 0, after: before };
const expected = 1 / (1 + 10 ** ((opposing - before) / 400));
const delta = Math.round(arenaRatingBand(before).k * (normalizedScore - expected));
const after = clampArenaRating(before + delta);
return { before, expected, delta: after - before, after };
}
export function arenaDampening(elapsedMs) {
const increases = Math.floor(Math.max(0, Number(elapsedMs) || 0) / 30_000);
return Math.min(1, 0.1 + increases * 0.05);
}
export function applyArenaHealing(rawAmount, elapsedMs) {
return Math.max(0, Math.round(Math.max(0, Number(rawAmount) || 0) * (1 - arenaDampening(elapsedMs))));
}
export function arenaMatchTolerance(waitMs) {
return 100 + Math.floor(Math.max(0, Number(waitMs) || 0) / 10_000) * 100;
}
export function validateArenaComposition(bracket, roles, { allowPartial = false } = {}) {
const definition = ARENA_BRACKETS[bracket];
if (!definition) return { ok: false, code: "invalid_bracket", message: "Choose 2v2 or 3v3." };
if (!Array.isArray(roles) || roles.some((role) => !["healer", "damage"].includes(role))) {
return { ok: false, code: "invalid_arena_role", message: "Rated arenas allow healer and damage specializations only." };
}
const healers = roles.filter((role) => role === "healer").length;
const damage = roles.filter((role) => role === "damage").length;
if (roles.length > definition.size || healers > definition.healers || damage > definition.damage) {
return { ok: false, code: "invalid_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
}
if (!allowPartial && (roles.length !== definition.size || healers !== definition.healers || damage !== definition.damage)) {
return { ok: false, code: "incomplete_composition", message: `${bracket} requires exactly one healer and ${definition.damage} DPS.` };
}
return { ok: true, missing: { healer: definition.healers - healers, damage: definition.damage - damage } };
}
export function arenaDrDuration(baseDurationMs, priorApplications, elapsedSinceEndMs = Infinity) {
const applications = elapsedSinceEndMs >= 15_000 ? 0 : Math.max(0, Math.trunc(priorApplications));
const multiplier = [1, 0.5, 0.25, 0][Math.min(3, applications)];
return { durationMs: Math.round(Math.max(0, baseDurationMs) * multiplier), applications: Math.min(3, applications + 1), multiplier };
}
export function shouldArenaBotChooseCorrectly(successChance, minimumErrorChance, random = Math.random) {
const cappedSuccess = Math.min(1 - Math.max(0, minimumErrorChance), Math.max(0, successChance));
return random() < cappedSuccess;
}
export function assertArenaQueueInput(input) {
if (!ARENA_BRACKETS[input?.bracket]) throw new Error("Choose 2v2 or 3v3.");
if (!ARENA_MODES.includes(input?.mode)) throw new Error("Choose Rated AI or Ranked PvP.");
if (!ARENA_RULESETS.includes(input?.ruleset)) throw new Error("Choose a valid arena ruleset.");
if (typeof input?.characterId !== "string" || !input.characterId.trim()) throw new Error("Choose a character.");
return {
bracket: input.bracket,
mode: input.mode,
ruleset: input.ruleset,
characterId: input.characterId.trim().slice(0, 100),
};
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ARENA_RATING_BANDS,
arenaAiProfile,
arenaDampening,
arenaDrDuration,
arenaMatchTolerance,
calculateArenaRating,
shouldArenaBotChooseCorrectly,
validateArenaComposition,
} from "./arena-rules.mjs";
function seededRandom(seed) {
let value = seed >>> 0;
return () => {
value = (value * 1664525 + 1013904223) >>> 0;
return value / 0x100000000;
};
}
test("arena Elo uses band K values, draws are neutral, and clamps at both ends", () => {
assert.deepEqual(calculateArenaRating({ rating: 1000, opponentRating: 1000, score: 1 }), {
before: 1000, expected: 0.5, delta: 20, after: 1020,
});
assert.equal(calculateArenaRating({ rating: 2499, opponentRating: 2500, score: 1 }).after, 2500);
assert.equal(calculateArenaRating({ rating: 0, opponentRating: 0, score: 0 }).after, 0);
assert.equal(calculateArenaRating({ rating: 1600, opponentRating: 1900, score: 0.5 }).delta, 0);
});
test("arena composition requires exactly one healer and rejects tanks", () => {
assert.equal(validateArenaComposition("2v2", ["healer", "damage"]).ok, true);
assert.equal(validateArenaComposition("3v3", ["healer", "damage", "damage"]).ok, true);
assert.equal(validateArenaComposition("3v3", ["healer"], { allowPartial: true }).missing.damage, 2);
assert.equal(validateArenaComposition("2v2", ["tank", "damage"]).code, "invalid_arena_role");
assert.equal(validateArenaComposition("2v2", ["damage", "damage"]).code, "invalid_composition");
});
test("difficulty, dampening, matchmaking, and DR follow the rated rules", () => {
const gladiator = arenaAiProfile(2500);
assert.equal(gladiator.healerInterruptChance, 0.85);
assert.equal(gladiator.minimumErrorChance, 0.08);
assert.equal(shouldArenaBotChooseCorrectly(1, gladiator.minimumErrorChance, () => 0.93), false);
assert.equal(arenaDampening(0), 0.1);
assert.equal(arenaDampening(540_000), 1);
assert.equal(arenaMatchTolerance(0), 100);
assert.equal(arenaMatchTolerance(30_000), 400);
assert.equal(arenaDrDuration(8000, 2, 0).durationMs, 2000);
assert.equal(arenaDrDuration(8000, 3, 15_000).durationMs, 8000);
});
test("every AI band honors its settings and seeded minimum-error floor", () => {
for (const [index, band] of ARENA_RATING_BANDS.entries()) {
const profile = arenaAiProfile(band.min);
assert.equal(profile.title, band.title);
assert.equal(profile.reactionMinSeconds, band.reaction[0]);
assert.equal(profile.reactionMaxSeconds, band.reaction[1]);
assert.equal(profile.healerInterruptChance, band.healerInterrupt);
assert.equal(profile.coordinatedCcChance, band.coordinatedCc);
assert.equal(profile.correctDefensiveChance, band.defensive);
assert.equal(profile.minimumErrorChance, band.error);
const random = seededRandom(index + 1);
const decisions = Array.from({ length: 4000 }, () => shouldArenaBotChooseCorrectly(1, profile.minimumErrorChance, random));
const errorRate = decisions.filter((decision) => !decision).length / decisions.length;
assert.ok(errorRate >= profile.minimumErrorChance - 0.025, `${band.title} fell below its minimum-error floor`);
assert.ok(errorRate <= profile.minimumErrorChance + 0.025, `${band.title} exceeded its seeded error tolerance`);
}
});
+425
View File
@@ -0,0 +1,425 @@
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";
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) => ({
actorId: `player-${randomUUID()}`,
accountId: member.accountId,
characterId: member.characterId,
name: member.loadout.name || member.username,
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 }),
};
}
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createArenaService } from "./arena-service.mjs";
function fakeDatabase() {
return {
onlineGroupState: () => ({ group: null, invitations: [] }),
arenaLoadoutForQueue: (_accountId, characterId) => ({
slot: 1, name: "Disc", characterId, classId: "priest", specializationId: "priest:discipline",
role: "healer", ruleset: "wow", level: 80, talents: {}, actions: {}, version: 1, selected: true, updatedAt: 1,
}),
selectedArenaLoadoutForQueue: (_accountId, characterId) => ({
slot: 2, name: "Selected", characterId, classId: "warrior", specializationId: "warrior:arms",
role: "damage", ruleset: "wow", level: 80, talents: {}, actions: {}, version: 1, selected: true, updatedAt: 1,
}),
getArenaRating: () => ({ rating: 1200, title: "Challenger" }),
getArenaState: () => ({ characterId: "priest", loadouts: [], ratings: {} }),
createArenaMatch: () => {}, settleArenaMatch: () => ({ ratingUpdates: [] }), voidArenaMatch: () => {},
};
}
test("Rated AI fills immediately and requires the 15 second ready check", () => {
let clock = 1000;
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
const account = { id: "account", username: "ArenaPlayer" };
const queued = service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" });
assert.equal(queued.status, "ready-check");
assert.equal(queued.readyDeadline, clock + 15_000);
service.ready(account.id);
const match = [...service.inspect().matches.values()][0];
assert.equal(match.participants.length, 4);
assert.equal(match.participants.filter((participant) => participant.team === 0 && participant.role === "healer").length, 1);
match.simulation.stop();
service.close();
});
test("group leaders queue partial teams with each member's selected loadout", () => {
const database = fakeDatabase();
database.onlineGroupState = () => ({
invitations: [],
group: {
leaderAccountId: "leader",
members: [
{ accountId: "leader", username: "Leader", character: { id: "priest" } },
{ accountId: "member", username: "Member", character: { id: "warrior" } },
],
},
});
const service = createArenaService({ database, now: () => 1000, random: () => 0, autoPump: false });
const queued = service.enqueue({ id: "leader", username: "Leader" }, {
characterId: "priest", loadoutSlot: 1, bracket: "3v3", mode: "rated-ai", ruleset: "wow",
});
assert.equal(queued.status, "ready-check");
service.ready("leader");
assert.equal(service.inspect().matches.size, 0);
service.ready("member");
const match = [...service.inspect().matches.values()][0];
assert.equal(match.participants.filter((participant) => participant.accountId).length, 2);
assert.equal(match.participants.filter((participant) => participant.team === 0 && participant.isBot).length, 1);
match.simulation.stop();
service.close();
});
test("expired ready checks return tickets to matchmaking", () => {
let clock = 1000;
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
const account = { id: "account", username: "ArenaPlayer" };
service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" });
clock += 15_001;
service.pump();
assert.equal(service.queueState(account.id).status, "queued");
assert.equal(service.inspect().matches.size, 0);
service.close();
});
test("Ranked PvP permanently fills every open slot after thirty seconds", () => {
let clock = 1000;
const service = createArenaService({ database: fakeDatabase(), now: () => clock, random: () => 0, autoPump: false });
const account = { id: "account", username: "ArenaPlayer" };
assert.equal(service.enqueue(account, { characterId: "priest", loadoutSlot: 1, bracket: "3v3", mode: "ranked-pvp", ruleset: "wow" }).status, "queued");
clock += 30_000;
service.pump();
const lobby = [...service.inspect().lobbies.values()][0];
assert.equal(lobby.participants.length, 6);
assert.equal(lobby.participants.filter((participant) => participant.isBot).length, 5);
service.close();
});
+46
View File
@@ -0,0 +1,46 @@
import type {
ArenaAuthoritativeSnapshot,
ArenaCombatIntent,
ArenaResult,
ArenaRole,
ArenaStageManifest,
} from "../src/game/arenaTypes";
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "../src/game/arenaRules";
export interface ArenaSimulationParticipant {
actorId: string;
accountId?: string | null;
characterId?: string | null;
name: string;
team: 0 | 1;
role: ArenaRole;
isBot: boolean;
disconnected?: boolean;
rating: number;
}
export interface ArenaSimulationOptions {
id: string;
stage: ArenaStageManifest;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
participants: readonly ArenaSimulationParticipant[];
random?: () => number;
startedAt?: number;
onSnapshot?: (snapshot: ArenaAuthoritativeSnapshot) => void;
onResult?: (result: Omit<ArenaResult, "ratingUpdates">) => void;
}
export declare const ARENA_TICK_RATE: 20;
export declare const ARENA_SNAPSHOT_RATE: 10;
export declare const ARENA_TICK_MS: 50;
export declare class ArenaSimulation {
constructor(options: ArenaSimulationOptions);
start(): this;
stop(): void;
step(): void;
snapshot(): ArenaAuthoritativeSnapshot;
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string };
}
+427
View File
@@ -0,0 +1,427 @@
import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
export const ARENA_TICK_RATE = 20;
export const ARENA_SNAPSHOT_RATE = 10;
export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE;
const ABILITIES = Object.freeze({
attack: Object.freeze({ cooldownMs: 1500, range: 5, damage: 40 }),
strike: Object.freeze({ cooldownMs: 6000, range: 5, damage: 120 }),
"ranged-attack": Object.freeze({ cooldownMs: 2200, range: 30, damage: 52 }),
heal: Object.freeze({ cooldownMs: 0, range: 40, healing: 150, castMs: 1500, resource: 12 }),
interrupt: Object.freeze({ cooldownMs: 15000, range: 8, lockoutMs: 4000 }),
control: Object.freeze({ cooldownMs: 30000, range: 25, controlMs: 6000, drCategory: "incapacitate" }),
defensive: Object.freeze({ cooldownMs: 90000, range: 0, defensiveMs: 8000 }),
});
function distance2d(left, right) {
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
}
function segmentIntersectsBox(from, to, object) {
if (object.shape !== "box" && object.shape !== "cylinder") return false;
const radiusX = object.size[0] / 2;
const radiusZ = object.size[2] / 2;
const dx = to[0] - from[0];
const dz = to[2] - from[2];
const lengthSquared = dx * dx + dz * dz;
const amount = lengthSquared > 0
? Math.max(0, Math.min(1, ((object.position[0] - from[0]) * dx + (object.position[2] - from[2]) * dz) / lengthSquared))
: 0;
const nearestX = from[0] + dx * amount;
const nearestZ = from[2] + dz * amount;
return Math.abs(nearestX - object.position[0]) <= radiusX
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
}
function newActor(participant, spawn, teamIndex, arenaCenter) {
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
return {
id: participant.actorId,
accountId: participant.accountId ?? null,
characterId: participant.characterId ?? null,
name: participant.name,
team: participant.team,
role: participant.role,
isBot: Boolean(participant.isBot),
disconnected: Boolean(participant.disconnected),
alive: true,
position: [spawn[0], spawn[1], spawn[2] + offset],
facing,
movement: [0, 0],
health: 1000,
maxHealth: 1000,
resource: 100,
maxResource: 100,
targetActorId: null,
cooldowns: new Map(),
effects: new Map(),
dr: new Map(),
casting: null,
lockoutUntil: 0,
lastSequence: 0,
nextBotDecisionAt: 0,
rating: Number(participant.rating ?? 0),
};
}
export function arenaDynamicObjectState(stage, activeElapsedMs) {
return stage.dynamicObjects.map((definition) => {
if (definition.kind === "starting-elevators") {
const delay = definition.initialDelayMs ?? 0;
const travelMs = definition.activeMs || 1500;
if (activeElapsedMs < delay) return { id: definition.id, state: "raised", progress: 0, collisionEnabled: true };
if (activeElapsedMs < delay + travelMs) {
return { id: definition.id, state: "moving", progress: (activeElapsedMs - delay) / travelMs, collisionEnabled: true };
}
return { id: definition.id, state: "open", progress: 1, collisionEnabled: false };
}
if (definition.kind === "waterfall") {
const start = definition.runtimeStartMs ?? definition.initialDelayRangeMs?.[0] ?? 30000;
const cycle = definition.activeMs + definition.cycleMs;
const time = activeElapsedMs - start;
if (time < -definition.warningMs) return { id: definition.id, state: "open", progress: 0, collisionEnabled: false };
if (time < 0) return { id: definition.id, state: "warning", progress: (definition.warningMs + time) / definition.warningMs, collisionEnabled: false };
const within = time % Math.max(1, cycle);
if (within >= definition.activeMs) {
const remaining = cycle - within;
const warning = remaining <= definition.warningMs;
return { id: definition.id, state: warning ? "warning" : "open", progress: warning ? 1 - remaining / definition.warningMs : 0, collisionEnabled: false };
}
return { id: definition.id, state: "active", progress: within / definition.activeMs, collisionEnabled: true };
}
if (definition.kind === "pipe-knockback") {
const warningStart = Math.max(0, (definition.initialDelayMs ?? 5000) - definition.warningMs);
if (activeElapsedMs < warningStart) return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
if (activeElapsedMs < (definition.initialDelayMs ?? 5000)) return { id: definition.id, state: "warning", progress: 1, collisionEnabled: true };
if (activeElapsedMs < (definition.initialDelayMs ?? 5000) + definition.activeMs) return { id: definition.id, state: "active", progress: 1, collisionEnabled: false };
return { id: definition.id, state: "closed", progress: 0, collisionEnabled: true };
}
const delay = definition.initialDelayMs ?? 0;
const time = activeElapsedMs - delay;
const raised = time >= 0 && (definition.cycleMs <= 0 || time % definition.cycleMs < definition.activeMs);
return {
id: definition.id,
state: definition.kind === "fire-cycle" ? (raised ? "active" : "closed") : (raised ? "raised" : "lowered"),
progress: raised ? 1 : 0,
collisionEnabled: definition.kind === "moving-pillars" ? raised : false,
};
});
}
export class ArenaSimulation {
constructor(options) {
this.id = options.id;
this.stage = {
...options.stage,
dynamicObjects: options.stage.dynamicObjects.map((object) => ({ ...object })),
lineOfSight: options.stage.lineOfSight.map((object) => ({ ...object })),
};
this.bracket = options.bracket;
this.mode = options.mode;
this.ruleset = options.ruleset;
this.random = options.random ?? Math.random;
this.onSnapshot = options.onSnapshot ?? (() => {});
this.onResult = options.onResult ?? (() => {});
this.phase = "preparation";
this.tickNumber = 0;
this.startedAt = Number(options.startedAt ?? Date.now());
this.phaseEndsAt = this.startedAt + 10_000;
this.activeStartedAt = null;
this.finished = false;
this.interval = null;
this.actors = new Map();
this.lastMechanicPulse = new Map();
const teamCounts = [0, 0];
const arenaCenter = [
(this.stage.bounds.min[0] + this.stage.bounds.max[0]) / 2,
(this.stage.bounds.min[2] + this.stage.bounds.max[2]) / 2,
];
for (const participant of options.participants) {
const team = participant.team;
const actor = newActor(participant, this.stage.teamSpawns[team], teamCounts[team]++, arenaCenter);
this.actors.set(actor.id, actor);
}
for (const object of this.stage.dynamicObjects) {
if (object.initialDelayRangeMs) {
const [min, max] = object.initialDelayRangeMs;
object.runtimeStartMs = min + this.random() * Math.max(0, max - min);
}
}
}
start() {
if (!this.interval && !this.finished) this.interval = setInterval(() => this.step(Date.now()), ARENA_TICK_MS);
return this;
}
stop() {
if (this.interval) clearInterval(this.interval);
this.interval = null;
}
actor(actorId) {
return this.actors.get(actorId) ?? null;
}
hasLineOfSight(left, right, dynamicStates = null) {
const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state]));
return !this.stage.lineOfSight.some((object) => {
if (object.dynamicObjectId && !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
return segmentIntersectsBox(left.position, right.position, object);
});
}
receiveIntent(actorId, intent) {
const actor = this.actor(actorId);
if (!actor || actor.isBot || actor.disconnected || !actor.alive || this.phase !== "active") return { accepted: false, reason: "actor-unavailable" };
const sequence = Math.trunc(Number(intent?.sequence));
if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" };
actor.lastSequence = sequence;
if (intent.type === "movement") {
const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0));
const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0));
const length = Math.hypot(x, z);
actor.movement = length > 1 ? [x / length, z / length] : [x, z];
return { accepted: true };
}
if (intent.type === "facing") {
actor.facing = Number.isFinite(Number(intent.facing)) ? Number(intent.facing) : actor.facing;
return { accepted: true };
}
if (intent.type === "target") {
actor.targetActorId = this.actors.has(intent.targetActorId) ? intent.targetActorId : null;
return { accepted: true };
}
if (intent.type === "ability") return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId);
return { accepted: false, reason: "unknown-intent" };
}
useAbility(actor, abilityId, targetActorId = null) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const ability = ABILITIES[abilityId];
if (!ability || !actor.alive || now < (actor.cooldowns.get(abilityId) ?? 0) || now < actor.lockoutUntil) return { accepted: false, reason: "ability-unavailable" };
if (actor.effects.get("control")?.endsAt > now) return { accepted: false, reason: "controlled" };
const target = this.actor(targetActorId ?? actor.targetActorId) ?? (abilityId === "defensive" ? actor : null);
if (!target || !target.alive) return { accepted: false, reason: "invalid-target" };
const friendly = target.team === actor.team;
if ((ability.healing && !friendly) || (!ability.healing && abilityId !== "defensive" && friendly)) return { accepted: false, reason: "invalid-target" };
if (ability.range > 0 && distance2d(actor, target) > ability.range) return { accepted: false, reason: "out-of-range" };
if (target !== actor && !this.hasLineOfSight(actor, target)) return { accepted: false, reason: "line-of-sight" };
if (ability.resource && actor.resource < ability.resource) return { accepted: false, reason: "resource" };
if (ability.castMs) {
actor.resource -= ability.resource ?? 0;
actor.casting = { abilityId, targetActorId: target.id, completesAt: now + ability.castMs };
return { accepted: true, casting: true };
}
actor.cooldowns.set(abilityId, now + ability.cooldownMs);
if (ability.damage) this.damage(target, ability.damage);
if (ability.lockoutMs) {
if (!target.casting) return { accepted: true, interrupted: false };
target.casting = null;
target.lockoutUntil = now + ability.lockoutMs;
}
if (ability.controlMs) {
const state = target.dr.get(ability.drCategory) ?? { applications: 0, endedAt: -Infinity };
const dr = arenaDrDuration(ability.controlMs, state.applications, now - state.endedAt);
if (dr.durationMs > 0) target.effects.set("control", { id: "control", endsAt: now + dr.durationMs, drCategory: ability.drCategory });
target.dr.set(ability.drCategory, { applications: dr.applications, endedAt: now + dr.durationMs });
}
if (ability.defensiveMs) actor.effects.set("defensive", { id: "defensive", endsAt: now + ability.defensiveMs });
return { accepted: true };
}
damage(target, amount) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const reduction = target.effects.get("defensive")?.endsAt > now ? 0.5 : 0;
target.health = Math.max(0, target.health - Math.round(amount * (1 - reduction)));
if (target.health <= 0) {
target.alive = false;
target.movement = [0, 0];
target.casting = null;
}
}
dynamicObjects() {
const elapsed = this.activeStartedAt == null ? -1 : this.tickNumber * ARENA_TICK_MS - (this.activeStartedAt - this.startedAt);
return arenaDynamicObjectState(this.stage, elapsed);
}
step() {
if (this.finished) return;
this.tickNumber += 1;
const simulationNow = this.startedAt + this.tickNumber * ARENA_TICK_MS;
if (this.phase === "preparation" && simulationNow >= this.phaseEndsAt) {
this.phase = "active";
this.activeStartedAt = this.phaseEndsAt;
this.phaseEndsAt = null;
}
if (this.phase === "active") {
this.updateActors(simulationNow);
this.applyMechanics(simulationNow);
this.checkResult();
}
if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot());
}
updateActors(now) {
const deltaSeconds = ARENA_TICK_MS / 1000;
for (const actor of this.actors.values()) {
for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId);
for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId);
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * deltaSeconds);
if (!actor.alive) continue;
if (actor.casting?.completesAt <= now) {
const cast = actor.casting;
actor.casting = null;
if (cast.abilityId === "heal") {
const target = this.actor(cast.targetActorId);
if (target?.alive && target.team === actor.team && distance2d(actor, target) <= ABILITIES.heal.range && this.hasLineOfSight(actor, target)) {
const elapsed = Math.max(0, now - this.activeStartedAt);
target.health = Math.min(target.maxHealth, target.health + applyArenaHealing(ABILITIES.heal.healing, elapsed));
}
}
}
if (!actor.casting && !(actor.effects.get("control")?.endsAt > now)) this.moveActor(actor, deltaSeconds);
if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
}
}
moveActor(actor, deltaSeconds) {
const speed = 7;
const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds;
const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds;
actor.position[0] = Math.max(this.stage.bounds.min[0], Math.min(this.stage.bounds.max[0], nextX));
actor.position[2] = Math.max(this.stage.bounds.min[2], Math.min(this.stage.bounds.max[2], nextZ));
}
botDecision(actor, now) {
const rating = actor.rating ?? 0;
const profile = arenaAiProfile(rating);
actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000;
const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive);
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive && this.hasLineOfSight(actor, entry));
const target = enemies.sort((left, right) => left.health - right.health)[0];
if (!target) {
actor.movement = [0, 0];
return;
}
actor.targetActorId = target.id;
const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * 0.86) {
actor.movement = [0, 0];
this.useAbility(actor, "heal", lowAlly.id);
return;
}
const castingHealer = enemies.find((entry) => entry.role === "healer" && entry.casting);
if (castingHealer && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "interrupt", castingHealer.id);
return;
}
if (actor.health < actor.maxHealth * 0.4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "defensive", actor.id);
return;
}
if (shouldArenaBotChooseCorrectly(profile.coordinatedCcChance * 0.25, profile.minimumErrorChance, this.random)) {
const healer = enemies.find((entry) => entry.role === "healer");
if (healer && this.useAbility(actor, "control", healer.id).accepted) return;
}
const distance = distance2d(actor, target);
if (distance > 4.5) {
const dx = target.position[0] - actor.position[0];
const dz = target.position[2] - actor.position[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
actor.movement = [dx / length, dz / length];
if (distance <= 30) this.useAbility(actor, "ranged-attack", target.id);
} else {
actor.movement = [0, 0];
this.useAbility(actor, "strike", target.id);
this.useAbility(actor, "attack", target.id);
}
}
applyMechanics(now) {
const activeElapsed = now - this.activeStartedAt;
const states = new Map(this.dynamicObjects().map((state) => [state.id, state]));
if (this.stage.id === "dalaran-sewers") {
for (const id of ["pipes", "waterfall"]) {
if (states.get(id)?.state !== "active") continue;
const pulseMs = id === "waterfall" ? 1500 : 750;
const previous = this.lastMechanicPulse.get(id) ?? -Infinity;
if (activeElapsed - previous < pulseMs) continue;
this.lastMechanicPulse.set(id, activeElapsed);
for (const actor of this.actors.values()) {
if (!actor.alive || Math.abs(actor.position[0]) > 6 || Math.abs(actor.position[2]) > 14) continue;
const direction = actor.position[2] >= 0 ? 1 : -1;
actor.position[2] += direction * (id === "waterfall" ? 4 : 2);
}
}
}
if (this.stage.id === "ring-of-valor" && states.get("fire")?.state === "active") {
const previous = this.lastMechanicPulse.get("fire") ?? -Infinity;
if (activeElapsed - previous >= 1000) {
this.lastMechanicPulse.set("fire", activeElapsed);
for (const actor of this.actors.values()) {
if (actor.alive && Math.abs(actor.position[2]) > 25) this.damage(actor, 35);
}
}
}
}
checkResult() {
const alive = [0, 1].map((team) => [...this.actors.values()].some((actor) => actor.team === team && actor.alive));
if (alive[0] && alive[1]) return;
const winnerTeam = alive[0] === alive[1] ? null : alive[0] ? 0 : 1;
this.finish(winnerTeam, winnerTeam == null ? "simultaneous-elimination" : "elimination");
}
finish(winnerTeam, reason) {
if (this.finished) return;
this.finished = true;
this.phase = reason === "void" ? "void" : "finished";
this.stop();
this.onSnapshot(this.snapshot());
this.onResult({ matchId: this.id, winnerTeam, draw: winnerTeam == null, reason });
}
disconnect(actorId) {
const actor = this.actor(actorId);
if (!actor) return false;
actor.disconnected = true;
actor.isBot = true;
actor.nextBotDecisionAt = 0;
return true;
}
snapshot() {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const elapsed = this.activeStartedAt == null ? 0 : Math.max(0, now - this.activeStartedAt);
return {
matchId: this.id,
tick: this.tickNumber,
serverTime: now,
phase: this.phase,
phaseEndsAt: this.phaseEndsAt,
stageId: this.stage.id,
bracket: this.bracket,
mode: this.mode,
ruleset: this.ruleset,
dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1,
actors: [...this.actors.values()].map((actor) => ({
id: actor.id, name: actor.name, team: actor.team, role: actor.role,
isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive,
position: [...actor.position], facing: actor.facing,
health: actor.health, maxHealth: actor.maxHealth,
resource: Math.round(actor.resource), maxResource: actor.maxResource,
targetActorId: actor.targetActorId,
cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])),
effects: [
...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })),
...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []),
],
})),
dynamicObjects: this.dynamicObjects(),
};
}
}
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ARENA_STAGES } from "./arena-stages.mjs";
import { ArenaSimulation, ARENA_TICK_RATE, arenaDynamicObjectState } from "./arena-simulation.mjs";
function participants() {
return [
{ actorId: "left-healer", name: "Left Healer", team: 0, role: "healer", isBot: false, rating: 1200 },
{ actorId: "left-damage", name: "Left Damage", team: 0, role: "damage", isBot: true, rating: 1200 },
{ actorId: "right-healer", name: "Right Healer", team: 1, role: "healer", isBot: true, rating: 1200 },
{ actorId: "right-damage", name: "Right Damage", team: 1, role: "damage", isBot: true, rating: 1200 },
];
}
test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplicate packets", () => {
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
const simulation = new ArenaSimulation({ id: "sim", stage, bracket: "2v2", mode: "rated-ai", ruleset: "wow", participants: participants(), random: () => 0 });
for (let index = 0; index < ARENA_TICK_RATE * 10 - 1; index += 1) simulation.step();
assert.equal(simulation.snapshot().phase, "preparation");
simulation.step();
assert.equal(simulation.snapshot().phase, "active");
assert.equal(simulation.receiveIntent("left-healer", { sequence: 1, type: "movement", movement: { x: 1, z: 0 } }).accepted, true);
assert.equal(simulation.receiveIntent("left-healer", { sequence: 1, type: "movement", movement: { x: 1, z: 0 } }).reason, "stale-sequence");
const before = simulation.actor("left-healer").position[0];
simulation.step();
assert.ok(simulation.actor("left-healer").position[0] > before);
});
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
const active = arenaDynamicObjectState(dalaran, 31_000).find((entry) => entry.id === "waterfall");
assert.equal(warning.state, "warning");
assert.equal(active.state, "active");
assert.equal(active.collisionEnabled, true);
const ring = ARENA_STAGES.find((entry) => entry.id === "ring-of-valor");
assert.equal(arenaDynamicObjectState(ring, 20_000).find((entry) => entry.id === "elevators").state, "raised");
assert.equal(arenaDynamicObjectState(ring, 21_000).find((entry) => entry.id === "elevators").state, "moving");
assert.equal(arenaDynamicObjectState(ring, 22_500).find((entry) => entry.id === "elevators").state, "open");
assert.equal(arenaDynamicObjectState(ring, 24_000).find((entry) => entry.id === "pillars-north").state, "lowered");
assert.equal(arenaDynamicObjectState(ring, 26_000).find((entry) => entry.id === "pillars-north").state, "raised");
});
+27
View File
@@ -0,0 +1,27 @@
const HEALER_SPECIALIZATIONS = new Set([
"paladin:holy", "priest:discipline", "priest:holy", "shaman:restoration", "druid:restoration",
"witch-doctor:brewing", "bloodmage:fleshweaver", "chronomancer:time", "cultist:heretic",
"starcaller:moon-priest", "sun-cleric:blessings", "tinker:invention", "venomancer:vizier",
"primalist:life",
]);
const TANK_SPECIALIZATIONS = new Set([
"warrior:protection", "paladin:protection", "death-knight:blood", "druid:feral-bear",
"felsworn:infernal", "witch-hunter:black-knight", "knight-of-xoroth:defiance", "guardian:vanguard",
"templar:oathkeeper", "bloodmage:eternal", "cultist:dreadnought", "starcaller:moon-guard",
"sun-cleric:valkyrie", "venomancer:fortitude", "reaper:domination", "primalist:mountain-king",
"runemaster:riftblade",
]);
export function arenaRoleForSpecialization(classId, specializationId, ruleset) {
const normalizedClass = String(classId ?? "").trim().toLowerCase();
const normalizedSpec = String(specializationId ?? "").trim().toLowerCase();
if (!normalizedClass || !normalizedSpec || !normalizedSpec.startsWith(`${normalizedClass}:`)) return null;
if (ruleset === "rom") {
if (normalizedSpec === `${normalizedClass}:role-healer`) return "healer";
if (normalizedSpec === `${normalizedClass}:role-damage`) return "damage";
return normalizedSpec.endsWith(":role-tank") ? "tank" : null;
}
if (TANK_SPECIALIZATIONS.has(normalizedSpec)) return "tank";
return HEALER_SPECIALIZATIONS.has(normalizedSpec) ? "healer" : "damage";
}
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const source = JSON.parse(readFileSync(fileURLToPath(new URL("../shared/arena-stages.json", import.meta.url)), "utf8"));
function normalizeStage(stage) {
return Object.freeze({
...stage,
assets: Object.freeze([
{ id: "visual", url: `${stage.assetBaseUrl}/visual.glb` },
{ id: "collision", url: `${stage.assetBaseUrl}/collision.glb` },
{ id: "navigation", url: `${stage.assetBaseUrl}/navigation.glb` },
]),
});
}
export const ARENA_STAGES = Object.freeze(source.stages.map(normalizeStage));
export const VALIDATED_ARENA_STAGES = Object.freeze(ARENA_STAGES.filter((stage) => (
stage.validation.traversal
&& stage.validation.collision
&& stage.validation.spawnSafety
&& stage.validation.lineOfSight
&& stage.validation.performance
)));
export function arenaStageById(stageId) {
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
}
export function chooseArenaStage({ random = Math.random, previousStageId = null, allowUnvalidated = false } = {}) {
const pool = allowUnvalidated ? ARENA_STAGES : VALIDATED_ARENA_STAGES;
if (!pool.length) throw new Error("No validated arena stages are available.");
const alternatives = pool.length > 1 ? pool.filter((stage) => stage.id !== previousStageId) : pool;
return alternatives[Math.floor(random() * alternatives.length)];
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ARENA_STAGES, VALIDATED_ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
test("the rated pool contains all eight validated arenas", () => {
assert.equal(ARENA_STAGES.length, 8);
assert.deepEqual(VALIDATED_ARENA_STAGES.map((stage) => stage.id), ARENA_STAGES.map((stage) => stage.id));
});
test("rated selection covers the pool uniformly and avoids the previous arena", () => {
const choices = VALIDATED_ARENA_STAGES.map((_, index, pool) => (
chooseArenaStage({ random: () => (index + 0.25) / pool.length })
));
assert.deepEqual(choices.map((stage) => stage.id), VALIDATED_ARENA_STAGES.map((stage) => stage.id));
const previousStageId = VALIDATED_ARENA_STAGES[0].id;
for (let index = 0; index < VALIDATED_ARENA_STAGES.length - 1; index += 1) {
const selected = chooseArenaStage({
previousStageId,
random: () => (index + 0.25) / (VALIDATED_ARENA_STAGES.length - 1),
});
assert.notEqual(selected.id, previousStageId);
}
});
+339
View File
@@ -8,6 +8,8 @@ import {
normalizeManastormAdminConfig,
stampManastormReviewMetadata,
} from "./manastorm-config.mjs";
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
const scrypt = promisify(scryptCallback);
@@ -115,7 +117,64 @@ export function openGameDatabase(options = {}) {
) STRICT;
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
ON online_group_invites(invitee_account_id, status, expires_at);
CREATE TABLE IF NOT EXISTS arena_loadouts (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
character_id TEXT NOT NULL,
slot INTEGER NOT NULL CHECK (slot BETWEEN 1 AND 10),
name TEXT NOT NULL,
class_id TEXT NOT NULL,
specialization_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('healer', 'damage')),
ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')),
level INTEGER NOT NULL CHECK (level = 80),
talents_json TEXT NOT NULL,
actions_json TEXT NOT NULL,
version INTEGER NOT NULL,
selected INTEGER NOT NULL CHECK (selected IN (0, 1)),
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, character_id, slot)
) STRICT;
CREATE INDEX IF NOT EXISTS arena_loadouts_character_idx
ON arena_loadouts(account_id, character_id, selected);
CREATE TABLE IF NOT EXISTS arena_ratings (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
character_id TEXT NOT NULL,
bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')),
rating INTEGER NOT NULL CHECK (rating BETWEEN 0 AND 2500),
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, character_id, bracket)
) STRICT;
CREATE TABLE IF NOT EXISTS arena_matches (
id TEXT PRIMARY KEY,
bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')),
mode TEXT NOT NULL CHECK (mode IN ('rated-ai', 'ranked-pvp')),
ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')),
stage_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'settled', 'void')),
winner_team INTEGER CHECK (winner_team IN (0, 1)),
started_at INTEGER NOT NULL,
settled_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS arena_match_participants (
match_id TEXT NOT NULL REFERENCES arena_matches(id) ON DELETE CASCADE,
actor_id TEXT NOT NULL,
account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
character_id TEXT,
team INTEGER NOT NULL CHECK (team IN (0, 1)),
role TEXT NOT NULL CHECK (role IN ('healer', 'damage')),
is_bot INTEGER NOT NULL CHECK (is_bot IN (0, 1)),
disconnected INTEGER NOT NULL DEFAULT 0 CHECK (disconnected IN (0, 1)),
rating_before INTEGER,
rating_delta INTEGER,
rating_after INTEGER,
PRIMARY KEY (match_id, actor_id),
UNIQUE (match_id, account_id, character_id)
) STRICT;
CREATE INDEX IF NOT EXISTS arena_matches_status_idx ON arena_matches(status, started_at);
`);
database.prepare(`
UPDATE arena_matches SET status = 'void', settled_at = ? WHERE status = 'active'
`).run(Date.now());
database.prepare(`
INSERT OR IGNORE INTO manastorm_admin_config (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
@@ -152,6 +211,23 @@ export function openGameDatabase(options = {}) {
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
arenaLoadouts: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? ORDER BY slot
`),
arenaLoadout: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?
`),
selectedArenaLoadout: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ?
ORDER BY selected DESC, slot ASC LIMIT 1
`),
arenaRating: database.prepare(`
SELECT rating, updated_at FROM arena_ratings WHERE account_id = ? AND character_id = ? AND bracket = ?
`),
upsertArenaRating: database.prepare(`
INSERT INTO arena_ratings (account_id, character_id, bracket, rating, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(account_id, character_id, bracket) DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at
`),
};
function issueSession(accountId) {
@@ -563,6 +639,260 @@ export function openGameDatabase(options = {}) {
return onlineGroupState(accountId);
}
function arenaCharacter(accountId, characterId) {
const save = getCloudSave(accountId);
const characters = Array.isArray(save.data?.characters) ? save.data.characters : [];
const character = characters.find((entry) => String(entry?.id ?? "") === characterId);
if (!character) throw new GameDatabaseError("That character is not in your roster.", 404, "arena_character_not_found");
return character;
}
function parseArenaLoadout(row) {
return {
slot: Number(row.slot),
name: row.name,
characterId: row.character_id,
classId: row.class_id,
specializationId: row.specialization_id,
role: row.role,
ruleset: row.ruleset,
level: 80,
talents: parseStoredJson(row.talents_json, {}),
actions: parseStoredJson(row.actions_json, {}),
version: Number(row.version),
selected: Boolean(row.selected),
updatedAt: Number(row.updated_at),
};
}
function validateArenaRecord(value, name, maxEntries, maxLength) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new GameDatabaseError(`${name} must be an object.`, 400, `invalid_arena_${name.toLowerCase()}`);
}
const entries = Object.entries(value);
if (entries.length > maxEntries) {
throw new GameDatabaseError(`${name} has too many entries.`, 400, `invalid_arena_${name.toLowerCase()}`);
}
const json = JSON.stringify(value);
if (json.length > maxLength) {
throw new GameDatabaseError(`${name} is too large.`, 400, `invalid_arena_${name.toLowerCase()}`);
}
return json;
}
function putArenaLoadout(accountId, characterIdInput, slotInput, input) {
const characterId = String(characterIdInput ?? "").trim().slice(0, 100);
const slot = Math.trunc(Number(slotInput));
if (!characterId || !Number.isInteger(slot) || slot < 1 || slot > 10) {
throw new GameDatabaseError("Choose loadout slot 1 through 10.", 400, "invalid_arena_loadout_slot");
}
const character = arenaCharacter(accountId, characterId);
const classId = String(input?.classId ?? character.classId ?? "").trim().toLowerCase().slice(0, 50);
if (!classId || classId !== String(character.classId ?? "").trim().toLowerCase()) {
throw new GameDatabaseError("The loadout specialization must belong to this character's class.", 400, "arena_class_mismatch");
}
const ruleset = String(input?.ruleset ?? character.categoryId ?? "wow").trim().toLowerCase();
if (!["wow", "coa", "rom"].includes(ruleset)) {
throw new GameDatabaseError("Choose a valid arena ruleset.", 400, "invalid_arena_ruleset");
}
const specializationId = String(input?.specializationId ?? "").trim().toLowerCase().slice(0, 100);
const role = arenaRoleForSpecialization(classId, specializationId, ruleset);
if (role === "tank") throw new GameDatabaseError("Tank specializations cannot enter rated arenas.", 400, "arena_tank_forbidden");
if (!role || !["healer", "damage"].includes(role)) {
throw new GameDatabaseError("Choose a valid healer or damage specialization.", 400, "invalid_arena_specialization");
}
if (input?.role && input.role !== role) {
throw new GameDatabaseError("The selected role does not match that specialization.", 400, "arena_role_mismatch");
}
const name = String(input?.name ?? `Loadout ${slot}`).trim().slice(0, 32);
if (!name) throw new GameDatabaseError("Name this PvP loadout.", 400, "invalid_arena_loadout_name");
const talentsJson = validateArenaRecord(input?.talents ?? {}, "Talents", 100, 16 * 1024);
const actionsJson = validateArenaRecord(input?.actions ?? {}, "Actions", 120, 24 * 1024);
const previous = statements.arenaLoadout.get(accountId, characterId, slot);
const version = Number(previous?.version ?? 0) + 1;
const selected = input?.selected === true || !statements.arenaLoadouts.all(accountId, characterId).length;
const now = Date.now();
database.exec("BEGIN IMMEDIATE");
try {
if (selected) database.prepare("UPDATE arena_loadouts SET selected = 0 WHERE account_id = ? AND character_id = ?").run(accountId, characterId);
database.prepare(`
INSERT INTO arena_loadouts (
account_id, character_id, slot, name, class_id, specialization_id, role, ruleset,
level, talents_json, actions_json, version, selected, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 80, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, character_id, slot) DO UPDATE SET
name = excluded.name, class_id = excluded.class_id,
specialization_id = excluded.specialization_id, role = excluded.role,
ruleset = excluded.ruleset, level = 80, talents_json = excluded.talents_json,
actions_json = excluded.actions_json, version = excluded.version,
selected = excluded.selected, updated_at = excluded.updated_at
`).run(
accountId, characterId, slot, name, classId, specializationId, role, ruleset,
talentsJson, actionsJson, version, selected ? 1 : 0, now,
);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return parseArenaLoadout(statements.arenaLoadout.get(accountId, characterId, slot));
}
function deleteArenaLoadout(accountId, characterIdInput, slotInput) {
const characterId = String(characterIdInput ?? "").trim().slice(0, 100);
const slot = Math.trunc(Number(slotInput));
arenaCharacter(accountId, characterId);
const previous = statements.arenaLoadout.get(accountId, characterId, slot);
if (!previous) throw new GameDatabaseError("That PvP loadout does not exist.", 404, "arena_loadout_not_found");
database.exec("BEGIN IMMEDIATE");
try {
database.prepare("DELETE FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?").run(accountId, characterId, slot);
if (previous.selected) {
database.prepare(`
UPDATE arena_loadouts SET selected = 1
WHERE account_id = ? AND character_id = ? AND slot = (
SELECT MIN(slot) FROM arena_loadouts WHERE account_id = ? AND character_id = ?
)
`).run(accountId, characterId, accountId, characterId);
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return { ok: true };
}
function getArenaRating(accountId, characterId, bracket) {
if (!["2v2", "3v3"].includes(bracket)) throw new GameDatabaseError("Choose 2v2 or 3v3.", 400, "invalid_arena_bracket");
const row = statements.arenaRating.get(accountId, characterId, bracket);
const rating = clampArenaRating(row?.rating ?? 0);
return { bracket, rating, title: arenaRatingBand(rating).title, updatedAt: row ? Number(row.updated_at) : null };
}
function getArenaState(accountId, characterIdInput = null) {
const cloud = getCloudSave(accountId);
const roster = Array.isArray(cloud.data?.characters) ? cloud.data.characters : [];
const requestedId = String(characterIdInput ?? "").trim();
const character = requestedId ? arenaCharacter(accountId, requestedId) : roster[0] ?? null;
if (!character) return { characterId: null, loadouts: [], ratings: { "2v2": null, "3v3": null } };
const characterId = String(character.id);
return {
characterId,
loadouts: statements.arenaLoadouts.all(accountId, characterId).map(parseArenaLoadout),
ratings: {
"2v2": getArenaRating(accountId, characterId, "2v2"),
"3v3": getArenaRating(accountId, characterId, "3v3"),
},
};
}
function arenaLoadoutForQueue(accountId, characterId, slot) {
arenaCharacter(accountId, characterId);
const row = statements.arenaLoadout.get(accountId, characterId, Math.trunc(Number(slot)));
if (!row) throw new GameDatabaseError("Create that PvP loadout before queueing.", 409, "arena_loadout_required");
return parseArenaLoadout(row);
}
function selectedArenaLoadoutForQueue(accountId, characterId) {
arenaCharacter(accountId, characterId);
const row = statements.selectedArenaLoadout.get(accountId, characterId);
if (!row) throw new GameDatabaseError("Create and select a PvP loadout before queueing.", 409, "arena_loadout_required");
return parseArenaLoadout(row);
}
function createArenaMatch(match) {
database.exec("BEGIN IMMEDIATE");
try {
database.prepare(`
INSERT INTO arena_matches (id, bracket, mode, ruleset, stage_id, status, winner_team, started_at, settled_at)
VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, NULL)
`).run(match.id, match.bracket, match.mode, match.ruleset, match.stageId, match.startedAt);
const insertParticipant = database.prepare(`
INSERT INTO arena_match_participants (
match_id, actor_id, account_id, character_id, team, role, is_bot, disconnected,
rating_before, rating_delta, rating_after
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
`);
for (const participant of match.participants) {
const before = participant.isBot
? clampArenaRating(participant.rating)
: getArenaRating(participant.accountId, participant.characterId, match.bracket).rating;
insertParticipant.run(
match.id, participant.actorId, participant.accountId ?? null, participant.characterId ?? null,
participant.team, participant.role, participant.isBot ? 1 : 0, participant.disconnected ? 1 : 0, before,
);
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
return match;
}
function settleArenaMatch(matchId, winnerTeam) {
database.exec("BEGIN IMMEDIATE");
try {
const match = database.prepare("SELECT * FROM arena_matches WHERE id = ?").get(matchId);
if (!match) throw new GameDatabaseError("Arena match not found.", 404, "arena_match_not_found");
const rows = database.prepare("SELECT * FROM arena_match_participants WHERE match_id = ? ORDER BY actor_id").all(matchId);
if (match.status === "settled") {
database.exec("COMMIT");
return {
matchId,
winnerTeam: match.winner_team == null ? null : Number(match.winner_team),
draw: match.winner_team == null,
ratingUpdates: rows.filter((row) => !row.is_bot).map((row) => ({
characterId: row.character_id, bracket: match.bracket, before: Number(row.rating_before),
delta: Number(row.rating_delta), after: Number(row.rating_after), title: arenaRatingBand(row.rating_after).title,
})),
};
}
if (match.status === "void") throw new GameDatabaseError("That arena match was voided.", 409, "arena_match_void");
const normalizedWinner = winnerTeam === 0 || winnerTeam === 1 ? winnerTeam : null;
const teamRatings = [0, 1].map((team) => {
const teamRows = rows.filter((row) => Number(row.team) === team);
return Math.round(teamRows.reduce((sum, row) => sum + Number(row.rating_before ?? 0), 0) / Math.max(1, teamRows.length));
});
const updates = [];
for (const row of rows) {
if (row.is_bot || !row.account_id || !row.character_id) continue;
const team = Number(row.team);
const score = normalizedWinner == null ? 0.5 : normalizedWinner === team ? 1 : 0;
const update = calculateArenaRating({
rating: Number(row.rating_before), opponentRating: teamRatings[team === 0 ? 1 : 0], score,
});
database.prepare(`
UPDATE arena_match_participants SET rating_delta = ?, rating_after = ?
WHERE match_id = ? AND actor_id = ? AND rating_after IS NULL
`).run(update.delta, update.after, matchId, row.actor_id);
statements.upsertArenaRating.run(row.account_id, row.character_id, match.bracket, update.after, Date.now());
updates.push({
characterId: row.character_id, bracket: match.bracket, before: update.before,
delta: update.delta, after: update.after, title: arenaRatingBand(update.after).title,
});
}
const settledAt = Date.now();
database.prepare(`
UPDATE arena_matches SET status = 'settled', winner_team = ?, settled_at = ? WHERE id = ? AND status = 'active'
`).run(normalizedWinner, settledAt, matchId);
database.exec("COMMIT");
return { matchId, winnerTeam: normalizedWinner, draw: normalizedWinner == null, ratingUpdates: updates };
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
function voidArenaMatch(matchId) {
const settledAt = Date.now();
const result = database.prepare(`
UPDATE arena_matches SET status = 'void', settled_at = ? WHERE id = ? AND status = 'active'
`).run(settledAt, matchId);
return { ok: true, changed: Number(result.changes) > 0 };
}
return {
databasePath,
register,
@@ -582,6 +912,15 @@ export function openGameDatabase(options = {}) {
updateOnlineGroupMember,
startOnlineGroupActivity,
clearOnlineGroupActivity,
getArenaState,
putArenaLoadout,
deleteArenaLoadout,
arenaLoadoutForQueue,
selectedArenaLoadoutForQueue,
getArenaRating,
createArenaMatch,
settleArenaMatch,
voidArenaMatch,
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
close: () => database.close(),
};
+51 -2
View File
@@ -3,8 +3,10 @@ import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
import { createArenaService } from "./arena-service.mjs";
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
@@ -151,6 +153,7 @@ export function createGameServer(options = {}) {
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
const gameDatabase = options.database ?? openGameDatabase(options);
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase });
const allowAuthRequest = createAuthLimiter();
gameDatabase.pruneExpiredSessions();
@@ -168,7 +171,7 @@ export function createGameServer(options = {}) {
response.writeHead(204, {
...cors,
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
"Access-Control-Max-Age": "86400",
});
response.end();
@@ -264,6 +267,45 @@ export function createGameServer(options = {}) {
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
return;
}
if (url.pathname === "/api/arena/state" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, arenaService.arenaState(account.id, url.searchParams.get("characterId")));
return;
}
const arenaLoadoutMatch = /^\/api\/arena\/loadouts\/(\d+)$/.exec(url.pathname);
if (arenaLoadoutMatch && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 64 * 1024);
json(response, 200, gameDatabase.putArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1], body));
return;
}
if (arenaLoadoutMatch && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 16 * 1024);
json(response, 200, gameDatabase.deleteArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1]));
return;
}
if (url.pathname === "/api/arena/queue" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 128 * 1024);
json(response, 201, arenaService.enqueue(account, body));
return;
}
if (url.pathname === "/api/arena/queue" && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, arenaService.leaveQueue(account.id));
return;
}
if (url.pathname === "/api/arena/ready" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, arenaService.ready(account.id));
return;
}
if (url.pathname === "/api/arena/socket-ticket" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 201, arenaService.socketTicket(account.id));
return;
}
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
const current = gameDatabase.getManastormConfig();
json(response, 200, {
@@ -327,11 +369,18 @@ export function createGameServer(options = {}) {
}
});
server.on("upgrade", (request, socket, head) => {
if (!upgradeArenaWebSocket(request, socket, head, arenaService)) {
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
}
});
server.on("close", () => {
onlineSessions.clear();
arenaService.close();
gameDatabase.close();
});
return { server, database: gameDatabase, staticDir, contentDir };
return { server, database: gameDatabase, arenaService, staticDir, contentDir };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+63
View File
@@ -147,6 +147,69 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(memberSession.players.length, 2);
assert.equal(memberSession.events[0].targetActorId, "boss");
const leftGroup = await fetch(`${origin}/api/online-group/leave`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(leftGroup.status, 200);
const arenaCharacter = {
id: "web-healer", name: "Mendara", classId: "priest", categoryId: "wow",
specializationId: "priest:discipline", raceId: "human", gender: "female", level: 80,
talentRanks: {}, actionBindings: {}, inventory: [], equipment: {},
};
const arenaSave = await fetch(`${origin}/api/cloud-save`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ data: { version: 1, characters: [arenaCharacter] } }),
});
assert.equal(arenaSave.status, 200);
const loadout = await fetch(`${origin}/api/arena/loadouts/1`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({
characterId: arenaCharacter.id, name: "Discipline PvP", classId: "priest",
specializationId: "priest:discipline", role: "healer", ruleset: "wow",
talents: {}, actions: {}, selected: true,
}),
});
assert.equal(loadout.status, 200);
assert.equal((await loadout.json()).level, 80);
const arenaState = await (await fetch(`${origin}/api/arena/state?characterId=${arenaCharacter.id}`, {
headers: { Authorization: `Bearer ${auth.token}` },
})).json();
assert.equal(arenaState.stages.length, 8);
assert.equal(arenaState.stages.every((stage) => stage.validation.status === "validated"), true);
const socketTicket = await (await fetch(`${origin}/api/arena/socket-ticket`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
})).json();
const socket = new WebSocket(`${origin.replace("http:", "ws:")}${socketTicket.path}?ticket=${socketTicket.ticket}`);
const messages = [];
socket.addEventListener("message", (event) => messages.push(JSON.parse(String(event.data))));
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
const arenaQueue = await fetch(`${origin}/api/arena/queue`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ characterId: arenaCharacter.id, loadoutSlot: 1, bracket: "2v2", mode: "rated-ai", ruleset: "wow" }),
});
assert.equal(arenaQueue.status, 201);
assert.equal((await arenaQueue.json()).status, "ready-check");
const arenaReady = await fetch(`${origin}/api/arena/ready`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(arenaReady.status, 200);
assert.equal((await arenaReady.json()).status, "matched");
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(messages.some((message) => message.type === "connected"), true);
assert.equal(messages.some((message) => message.type === "ready-check"), true);
assert.equal(messages.some((message) => message.type === "match-start"), true);
socket.close();
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
+119
View File
@@ -0,0 +1,119 @@
import { createHash } from "node:crypto";
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function frame(opcode, payload = Buffer.alloc(0)) {
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
let header;
if (body.length < 126) {
header = Buffer.from([0x80 | opcode, body.length]);
} else if (body.length <= 0xffff) {
header = Buffer.allocUnsafe(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(body.length, 2);
} else {
header = Buffer.allocUnsafe(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(body.length), 2);
}
return Buffer.concat([header, body]);
}
export function upgradeArenaWebSocket(request, socket, head, arenaService) {
const url = new URL(request.url ?? "/", "http://localhost");
if (url.pathname !== "/ws/arena") return false;
const key = String(request.headers["sec-websocket-key"] ?? "");
const version = String(request.headers["sec-websocket-version"] ?? "");
const accountId = arenaService.consumeSocketTicket(url.searchParams.get("ticket") ?? "");
if (!accountId || !key || version !== "13") {
socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
return true;
}
const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64");
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${accept}`,
"\r\n",
].join("\r\n"));
let buffered = Buffer.from(head ?? Buffer.alloc(0));
let closed = false;
const send = (message) => {
if (!closed && socket.writable) socket.write(frame(0x1, JSON.stringify(message)));
};
const unsubscribe = arenaService.subscribe(accountId, send);
function close(code = 1000, reason = "") {
if (closed) return;
closed = true;
const reasonBytes = Buffer.from(String(reason).slice(0, 100));
const body = Buffer.allocUnsafe(2 + reasonBytes.length);
body.writeUInt16BE(code, 0);
reasonBytes.copy(body, 2);
if (socket.writable) socket.end(frame(0x8, body));
unsubscribe();
}
function parse() {
while (buffered.length >= 2) {
const first = buffered[0];
const second = buffered[1];
const final = Boolean(first & 0x80);
const opcode = first & 0x0f;
const masked = Boolean(second & 0x80);
let length = second & 0x7f;
let offset = 2;
if (!final || !masked) return close(1002, "Invalid client frame");
if (length === 126) {
if (buffered.length < 4) return;
length = buffered.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffered.length < 10) return;
const largeLength = buffered.readBigUInt64BE(2);
if (largeLength > 65536n) return close(1009, "Frame too large");
length = Number(largeLength);
offset = 10;
}
if (length > 65536) return close(1009, "Frame too large");
if (buffered.length < offset + 4 + length) return;
const mask = buffered.subarray(offset, offset + 4);
const payload = Buffer.from(buffered.subarray(offset + 4, offset + 4 + length));
buffered = buffered.subarray(offset + 4 + length);
for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4];
if (opcode === 0x8) return close();
if (opcode === 0x9) {
if (socket.writable) socket.write(frame(0xa, payload));
continue;
}
if (opcode !== 0x1) return close(1003, "Text messages only");
try {
const message = JSON.parse(payload.toString("utf8"));
const result = arenaService.receive(accountId, message);
if (!result.accepted) send({ type: "intent-rejected", sequence: message?.sequence, reason: result.reason });
} catch {
send({ type: "protocol-error", message: "Arena messages must be valid JSON." });
}
}
}
socket.on("data", (chunk) => {
if (closed) return;
buffered = Buffer.concat([buffered, chunk]);
parse();
});
socket.on("error", () => close(1011, "Socket error"));
socket.on("close", () => {
if (!closed) {
closed = true;
unsubscribe();
}
});
if (buffered.length) parse();
send({ type: "connected", queue: arenaService.queueState(accountId) });
return true;
}