Release Healer Man 0.1.16

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

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import type { CharacterProfile } from "../app/types";
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
interface PracticeOptions {
character: CharacterProfile;
role: ArenaRole;
bracket: "2v2" | "3v3";
ruleset: "wow" | "coa" | "rom";
stage: ArenaStageManifest;
difficulty: number;
onSnapshot: (snapshot: ArenaAuthoritativeSnapshot) => void;
onResult: (result: ArenaResult) => void;
}
function teamRoles(teamSize: number, playerRole?: ArenaRole): ArenaRole[] {
const roles: ArenaRole[] = playerRole ? [playerRole] : [];
if (!roles.includes("healer")) roles.push("healer");
while (roles.length < teamSize) roles.push("damage");
return roles;
}
export function createArenaPractice(options: PracticeOptions) {
const id = `practice-${Date.now().toString(36)}`;
const teamSize = options.bracket === "2v2" ? 2 : 3;
const participants = [
...teamRoles(teamSize, options.role).map((role, index) => ({
actorId: index === 0 ? "practice-player" : `practice-ally-${index}`,
accountId: null,
characterId: index === 0 ? options.character.id : null,
name: index === 0 ? options.character.name : role === "healer" ? "Training Medic" : `Training Ally ${index}`,
team: 0 as const,
role,
isBot: index !== 0,
rating: options.difficulty,
})),
...teamRoles(teamSize).map((role, index) => ({
actorId: `practice-enemy-${index}`,
accountId: null,
characterId: null,
name: role === "healer" ? "Opponent Medic" : `Opponent ${index + 1}`,
team: 1 as const,
role,
isBot: true,
rating: options.difficulty,
})),
];
const simulation = new ArenaSimulation({
id,
stage: options.stage,
bracket: options.bracket,
mode: "practice",
ruleset: options.ruleset,
participants,
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
});
simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
return {
intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent);
},
stop() {
simulation.stop();
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
arenaAiProfile,
arenaDampening,
arenaDrDuration,
arenaRatingBand,
calculateArenaRating,
clampArenaRating,
} from "./arenaRules";
describe("arena rating and difficulty", () => {
it("clamps ratings and applies the requested Elo bands", () => {
expect(clampArenaRating(-5)).toBe(0);
expect(clampArenaRating(2600)).toBe(2500);
expect(arenaRatingBand(0)).toMatchObject({ title: "Recruit", k: 64 });
expect(arenaRatingBand(2400)).toMatchObject({ title: "Gladiator", k: 20 });
expect(calculateArenaRating(0, 0, 1)).toMatchObject({ delta: 32, after: 32 });
expect(calculateArenaRating(0, 0, 0)).toMatchObject({ delta: 0, after: 0 });
expect(calculateArenaRating(1250, 1250, 0.5)).toMatchObject({ delta: 0, after: 1250 });
});
it("continuously improves bots without removing the error floor", () => {
const recruit = arenaAiProfile(0);
const rival = arenaAiProfile(1600);
const gladiator = arenaAiProfile(2500);
expect(rival.reactionMinSeconds).toBeLessThan(recruit.reactionMinSeconds);
expect(gladiator.healerInterruptChance).toBe(0.85);
expect(gladiator.minimumErrorChance).toBe(0.08);
});
it("starts dampening at ten percent and reaches zero healing at nine minutes", () => {
expect(arenaDampening(0)).toBeCloseTo(0.1);
expect(arenaDampening(30_000)).toBeCloseTo(0.15);
expect(arenaDampening(540_000)).toBe(1);
});
it("uses full, half, quarter, immune DR and resets after fifteen seconds", () => {
expect(arenaDrDuration(8_000, 0, 0).durationMs).toBe(8_000);
expect(arenaDrDuration(8_000, 1, 0).durationMs).toBe(4_000);
expect(arenaDrDuration(8_000, 2, 0).durationMs).toBe(2_000);
expect(arenaDrDuration(8_000, 3, 0).durationMs).toBe(0);
expect(arenaDrDuration(8_000, 3, 15_000).durationMs).toBe(8_000);
});
});
+80
View File
@@ -0,0 +1,80 @@
export type ArenaBracket = "2v2" | "3v3";
export type ArenaMode = "practice" | "rated-ai" | "ranked-pvp";
export type ArenaRuleset = "wow" | "coa" | "rom";
export type ArenaRole = "healer" | "damage";
export interface ArenaAiProfile {
readonly rating: number;
readonly title: string;
readonly reactionMinSeconds: number;
readonly reactionMaxSeconds: number;
readonly healerInterruptChance: number;
readonly coordinatedCcChance: number;
readonly correctDefensiveChance: number;
readonly minimumErrorChance: number;
}
export const ARENA_BRACKETS: Readonly<Record<ArenaBracket, { readonly size: 2 | 3; readonly healers: 1; readonly damage: 1 | 2 }>> = 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([
{ min: 0, max: 499, title: "Recruit", k: 64, reaction: [1.4, 2.4], healerInterrupt: 0, coordinatedCc: 0.05, defensive: 0.35, error: 0.35 },
{ min: 500, max: 999, title: "Combatant", k: 48, reaction: [1, 1.8], healerInterrupt: 0.2, coordinatedCc: 0.2, defensive: 0.5, error: 0.28 },
{ 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 },
{ min: 1400, max: 1799, title: "Rival", k: 32, reaction: [0.55, 1], healerInterrupt: 0.6, coordinatedCc: 0.6, defensive: 0.75, error: 0.15 },
{ 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 },
{ 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 },
{ 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 },
] as const);
export function clampArenaRating(value: number): number {
return Math.max(0, Math.min(2500, Math.round(Number(value) || 0)));
}
export function arenaRatingBand(rating: number): typeof ARENA_RATING_BANDS[number] {
const normalized = clampArenaRating(rating);
return ARENA_RATING_BANDS.find((band) => normalized >= band.min && normalized <= band.max) ?? ARENA_RATING_BANDS[0];
}
function lerp(left: number, right: number, amount: number): number {
return left + (right - left) * Math.max(0, Math.min(1, amount));
}
export function arenaAiProfile(rating: number): ArenaAiProfile {
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 progress = current === next ? 0 : (normalized - current.min) / Math.max(1, current.max - current.min + 1);
return {
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: number, opponentRating: number, score: 0 | 0.5 | 1) {
const before = clampArenaRating(rating);
if (score === 0.5) return { before, expected: 0.5, delta: 0, after: before };
const expected = 1 / (1 + 10 ** ((clampArenaRating(opponentRating) - before) / 400));
const rawDelta = Math.round(arenaRatingBand(before).k * (score - expected));
const after = clampArenaRating(before + rawDelta);
return { before, expected, delta: after - before, after };
}
export function arenaDampening(elapsedMs: number): number {
return Math.min(1, 0.1 + Math.floor(Math.max(0, elapsedMs) / 30_000) * 0.05);
}
export function arenaDrDuration(baseDurationMs: number, priorApplications: number, 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 };
}
+25
View File
@@ -0,0 +1,25 @@
import catalog from "../../shared/arena-stages.json";
import type { ArenaStageManifest } from "./arenaTypes";
function normalizeStage(stage: Omit<ArenaStageManifest, "assets">): ArenaStageManifest {
return {
...stage,
assets: [
{ 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(
catalog.stages.map((stage) => normalizeStage(stage as unknown as Omit<ArenaStageManifest, "assets">)),
);
export const VALIDATED_ARENA_STAGES = Object.freeze(
ARENA_STAGES.filter((stage) => Object.values(stage.validation).slice(1, 6).every(Boolean)),
);
export function arenaStageById(stageId: string) {
return ARENA_STAGES.find((stage) => stage.id === stageId) ?? null;
}
+245
View File
@@ -0,0 +1,245 @@
import { create } from "zustand";
import {
acceptArenaReadyCheck,
deleteArenaLoadout as deleteRemoteLoadout,
enterArenaQueue,
fetchArenaState,
leaveArenaQueue,
openArenaSocket,
putArenaLoadout as putRemoteLoadout,
type ArenaStateResponse,
} from "../app/arenaClient";
import type { CharacterProfile, PlayerSession } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { arenaAiProfile } from "./arenaRules";
import { ARENA_STAGES } from "./arenaStages";
import { createArenaPractice } from "./arenaPractice";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
interface ArenaStoreState {
mode: ArenaModeSelection;
bracket: "2v2" | "3v3";
ruleset: "wow" | "coa" | "rom";
stageId: string;
botDifficulty: number;
selectedLoadoutSlot: number;
remote: ArenaStateResponse | null;
queue: ArenaQueueState;
snapshot: ArenaAuthoritativeSnapshot | null;
result: ArenaResult | null;
loading: boolean;
error: string;
socket: WebSocket | null;
practice: ReturnType<typeof createArenaPractice> | null;
sequence: number;
configure: (patch: Partial<Pick<ArenaStoreState, "mode" | "bracket" | "ruleset" | "stageId" | "botDifficulty" | "selectedLoadoutSlot">>) => void;
load: (session: PlayerSession | null, character: CharacterProfile) => Promise<void>;
saveLoadout: (session: PlayerSession, character: CharacterProfile, input: { slot: number; name: string; specializationId: string }) => Promise<boolean>;
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
ready: (session: PlayerSession) => Promise<void>;
leave: (session: PlayerSession | null) => Promise<void>;
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
resetResult: () => void;
}
function messageFor(error: unknown) {
return error instanceof Error ? error.message : "The arena request could not be completed.";
}
function rulesetFor(character: CharacterProfile): "wow" | "coa" | "rom" {
return character.categoryId === "coa" || character.categoryId === "rom" ? character.categoryId : "wow";
}
function defaultSpec(character: CharacterProfile, preferredRole: "healer" | "damage" = "healer") {
if (character.categoryId === "rom") return romGearProfile(character.classId, preferredRole);
const selected = specializationById(character.specializationId);
if (selected && selected.role !== "tank") return selected;
return specializationsForClass(character.classId).find((spec) => spec.role === preferredRole)
?? specializationsForClass(character.classId).find((spec) => spec.role !== "tank")
?? null;
}
function handleSocketMessage(raw: unknown) {
const message = raw as { type?: string; snapshot?: ArenaAuthoritativeSnapshot; result?: ArenaResult; deadline?: number; matchId?: string; message?: string };
if (message.type === "snapshot" && message.snapshot) useArenaStore.setState({ snapshot: message.snapshot });
if (message.type === "ready-check") useArenaStore.setState((current) => ({ queue: { ...current.queue, status: "ready-check", readyDeadline: message.deadline, matchId: message.matchId } }));
if (message.type === "match-start" && message.snapshot) {
useArenaStore.setState({ snapshot: message.snapshot, queue: { status: "matched", matchId: message.matchId }, loading: false });
useShellStore.setState({ phase: "arena-game" });
}
if (message.type === "result" && message.result) useArenaStore.setState({ result: message.result, loading: false });
if (message.type === "queue-cancelled") useArenaStore.setState({ queue: { status: "queued" }, error: message.message ?? "Ready check cancelled." });
}
async function connectArenaSocket(session: PlayerSession) {
const existing = useArenaStore.getState().socket;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return existing;
const socket = await openArenaSocket(session, handleSocketMessage, () => {
const current = useArenaStore.getState();
const error = current.queue.status === "matched"
? "Arena connection closed; your character is now controlled by a bot."
: current.queue.status !== "idle" ? "Live arena updates disconnected. Reopen Arenas to reconnect." : current.error;
useArenaStore.setState({ socket: null, error });
});
useArenaStore.setState({ socket });
return socket;
}
export const useArenaStore = create<ArenaStoreState>((set, get) => ({
mode: "offline-practice",
bracket: "2v2",
ruleset: "wow",
stageId: "warnorken-arena",
botDifficulty: 1000,
selectedLoadoutSlot: 1,
remote: null,
queue: { status: "idle" },
snapshot: null,
result: null,
loading: false,
error: "",
socket: null,
practice: null,
sequence: 0,
configure: (patch) => set(patch),
load: async (session, character) => {
get().practice?.stop();
get().socket?.close();
const ruleset = rulesetFor(character);
set({ ruleset, loading: Boolean(session?.accessToken), error: "", snapshot: null, result: null, practice: null, socket: null });
if (!session?.accessToken) {
set({ remote: null, queue: { status: "idle" }, loading: false });
return;
}
try {
const remote = await fetchArenaState(session, character.id);
set({ remote, queue: remote.queue, loading: false });
await connectArenaSocket(session);
} catch (error) {
set({ loading: false, error: messageFor(error) });
}
},
saveLoadout: async (session, character, input) => {
const spec = character.categoryId === "rom"
? (["healer", "damage"] as const).map((role) => romGearProfile(character.classId, role)).find((entry) => entry.id === input.specializationId)
: specializationById(input.specializationId);
if (!spec || spec.classId !== character.classId || spec.role === "tank") {
set({ error: "Rated loadouts require a healer or damage specialization from this character's class." });
return false;
}
set({ loading: true, error: "" });
try {
await putRemoteLoadout(session, character.id, input.slot, {
name: input.name,
classId: character.classId,
specializationId: spec.id,
role: spec.role,
ruleset: rulesetFor(character),
talents: character.talentRanks,
actions: Object.fromEntries(Object.entries(character.actionBindings).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
});
const remote = await fetchArenaState(session, character.id);
set({ remote, selectedLoadoutSlot: input.slot, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
deleteLoadout: async (session, characterId, slot) => {
set({ loading: true, error: "" });
try {
await deleteRemoteLoadout(session, characterId, slot);
const remote = await fetchArenaState(session, characterId);
set({ remote, selectedLoadoutSlot: remote.loadouts[0]?.slot ?? 1, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
queueMatch: async (session, character) => {
const state = get();
const validatedStages = ARENA_STAGES.filter((entry) => entry.validation.status === "validated");
const stage = state.stageId === "random"
? validatedStages[Math.floor(Math.random() * validatedStages.length)]
: ARENA_STAGES.find((entry) => entry.id === state.stageId) ?? ARENA_STAGES[0];
const fallback = defaultSpec(character);
if (!stage || !fallback) {
set({ error: "This character has no arena-compatible specialization." });
return false;
}
if (state.mode === "offline-practice") {
const practice = createArenaPractice({
character, role: fallback.role as "healer" | "damage", bracket: state.bracket,
ruleset: state.ruleset, stage, difficulty: state.botDifficulty,
onSnapshot: (snapshot) => set({ snapshot }),
onResult: (result) => set({ result }),
});
set({ practice, error: "", result: null, queue: { status: "matched", matchId: practice ? `practice-${Date.now()}` : undefined } });
useShellStore.setState({ phase: "arena-game" });
return true;
}
if (!session?.accessToken) {
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
return false;
}
let loadout = state.remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
if (!loadout) {
const saved = await get().saveLoadout(session, character, { slot: state.selectedLoadoutSlot, name: `${fallback.displayName} PvP`, specializationId: fallback.id });
if (!saved) return false;
loadout = get().remote?.loadouts.find((entry) => entry.slot === state.selectedLoadoutSlot);
}
if (!loadout) return false;
set({ loading: true, error: "", result: null });
try {
const socket = await connectArenaSocket(session);
const queue = await enterArenaQueue(session, {
characterId: character.id, loadoutSlot: loadout.slot, bracket: state.bracket,
mode: state.mode, ruleset: state.ruleset,
botDifficulty: state.botDifficulty,
});
set({ socket, queue, loading: false });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
},
ready: async (session) => {
try { set({ queue: await acceptArenaReadyCheck(session), error: "" }); }
catch (error) { set({ error: messageFor(error) }); }
},
leave: async (session) => {
get().practice?.stop();
get().socket?.close();
if (session?.accessToken && get().queue.status !== "idle") await leaveArenaQueue(session).catch(() => undefined);
set({ queue: { status: "idle" }, snapshot: null, result: null, practice: null, socket: null, error: "" });
},
intent: (intent) => {
const sequence = get().sequence + 1;
const message = { ...intent, sequence } as ArenaCombatIntent;
set({ sequence });
get().practice?.intent(message);
const socket = get().socket;
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
},
resetResult: () => set({ result: null }),
}));
export function currentArenaAiTitle() {
return arenaAiProfile(useArenaStore.getState().botDifficulty).title;
}
+164
View File
@@ -0,0 +1,164 @@
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
export type Vec3Tuple = readonly [number, number, number];
export type ArenaRole = "healer" | "damage";
export type ArenaPhase = "queue" | "ready-check" | "preparation" | "active" | "finished" | "void";
export interface ArenaStageAsset {
id: "visual" | "collision" | "navigation";
url: string;
checksum?: string;
byteLength?: number;
}
export interface ArenaStageValidation {
status: "source-ready" | "repair-required" | "validated";
traversal: boolean;
collision: boolean;
spawnSafety: boolean;
lineOfSight: boolean;
performance: boolean;
notes: string[];
}
export interface ArenaLineOfSightObject {
id: string;
shape: "box" | "cylinder";
position: Vec3Tuple;
size: Vec3Tuple;
dynamicObjectId?: string;
}
export interface ArenaDynamicObjectDefinition {
id: string;
kind: "pipe-knockback" | "waterfall" | "starting-elevators" | "fire-cycle" | "moving-pillars";
initialDelayMs?: number;
initialDelayRangeMs?: readonly [number, number];
runtimeStartMs?: number;
warningMs: number;
activeMs: number;
cycleMs: number;
}
export interface ArenaStageManifest {
id: string;
name: string;
sourceMap: string;
mapId: number;
assetBaseUrl: string;
assets: readonly ArenaStageAsset[];
bounds: { min: Vec3Tuple; max: Vec3Tuple };
teamSpawns: readonly [Vec3Tuple, Vec3Tuple];
preparationGates: readonly [Vec3Tuple, Vec3Tuple];
spectatorPositions: readonly [Vec3Tuple, Vec3Tuple];
shadowSight: readonly [Vec3Tuple, Vec3Tuple];
lineOfSight: readonly ArenaLineOfSightObject[];
dynamicObjects: readonly ArenaDynamicObjectDefinition[];
validation: ArenaStageValidation;
}
export interface ArenaLoadout {
slot: number;
name: string;
characterId: string;
classId: string;
specializationId: string;
role: ArenaRole;
ruleset: ArenaRuleset;
level: 80;
talents: Record<string, number>;
actions: Record<string, string>;
version: number;
selected: boolean;
updatedAt: number;
}
export interface ArenaQueueRequest {
characterId: string;
loadoutSlot: number;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
botDifficulty?: number;
groupMembers?: readonly { accountId: string; characterId: string; loadoutSlot: number }[];
}
export interface ArenaQueueState {
status: "idle" | "queued" | "ready-check" | "matched";
ticketId?: string;
joinedAt?: number;
searchRange?: number;
readyDeadline?: number;
matchId?: string;
lockedLoadout?: ArenaLoadout;
}
export interface ArenaCombatIntent {
sequence: number;
type: "movement" | "facing" | "target" | "ability";
movement?: { x: number; z: number };
facing?: number;
targetActorId?: string;
abilityId?: string;
clientTick?: number;
}
export interface ArenaActorSnapshot {
id: string;
name: string;
team: 0 | 1;
role: ArenaRole;
isBot: boolean;
disconnected: boolean;
alive: boolean;
position: Vec3Tuple;
facing: number;
health: number;
maxHealth: number;
resource: number;
maxResource: number;
targetActorId: string | null;
cooldowns: Record<string, number>;
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
}
export interface ArenaDynamicObjectSnapshot {
id: string;
state: "closed" | "warning" | "active" | "open" | "raised" | "moving" | "lowered";
progress: number;
collisionEnabled: boolean;
}
export interface ArenaAuthoritativeSnapshot {
matchId: string;
tick: number;
serverTime: number;
phase: ArenaPhase;
phaseEndsAt: number | null;
stageId: string;
bracket: ArenaBracket;
mode: ArenaMode;
ruleset: ArenaRuleset;
dampening: number;
actors: readonly ArenaActorSnapshot[];
dynamicObjects: readonly ArenaDynamicObjectSnapshot[];
acknowledgedSequence?: number;
controlledActorId?: string;
}
export interface ArenaRatingUpdate {
characterId: string;
bracket: ArenaBracket;
before: number;
delta: number;
after: number;
title: string;
}
export interface ArenaResult {
matchId: string;
winnerTeam: 0 | 1 | null;
draw: boolean;
reason: "elimination" | "simultaneous-elimination" | "void";
ratingUpdates: readonly ArenaRatingUpdate[];
}