forked from phenom/healer-man
Release Healer Man 0.1.16
This commit is contained in:
1 parent
f49e54f35f
commit
b57d2b2839
89 files changed
+6055
-17
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user