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,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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user