Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+4 -1
View File
@@ -948,9 +948,12 @@ export function abilityAtLevel(
learnedTalentRank?: number,
): AbilityDefinition {
const rank = abilityRankForLevel(ability, playerLevel, learnedTalentRank);
if (!rank) return ability;
const effects = rank?.effects ?? ability.effects;
const target = effects.length > 0 && effects.every((effect) => effect.kind === "dispel" && effect.relationship === "target" && effect.mode === "cleanse") ? "any" : ability.target;
if (!rank) return target === ability.target ? ability : { ...ability, target };
return {
...ability,
target,
dbcSpellId: rank.spellId,
description: rank.description || ability.description,
castMode: rank.castMode,
+1 -1
View File
@@ -59,7 +59,7 @@ export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTar
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
...(ability.radius ? { radius: ability.radius } : {}),
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
...(shape === "ground" ? { groundRange: ability.range.max, radius: ability.radius ?? 8 } : {}),
...(shape === "ground" ? { groundRange: ability.range.max > 0 ? ability.range.max : name.endsWith(" trap") || name === "consecration" ? 0 : 30, radius: ability.radius ?? 8 } : {}),
});
}
+31 -10
View File
@@ -1,19 +1,40 @@
import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import { arenaCameraPose } from "./arenaCamera";
import { arenaCameraClearDistance, arenaCameraPose, arenaSpawnYaw, ARENA_CAMERA_DISTANCE } from "./arenaCamera";
import { ARENA_STAGES } from "./arenaStages";
describe("arena camera placement", () => {
it("uses a finite overview anchor aimed at the playable center of every arena", () => {
describe("arena player camera", () => {
it("starts behind each character at the playable floor on all eight maps", () => {
for (const stage of ARENA_STAGES) {
for (const team of [0, 1] as const) {
const { position, target } = arenaCameraPose(stage, team);
expect(position).toEqual(stage.spectatorPositions[team]);
expect(position[0], `${stage.id} team ${team} x`).toBeGreaterThan(stage.bounds.min[0]);
expect(position[0], `${stage.id} team ${team} x`).toBeLessThan(stage.bounds.max[0]);
expect(position[2], `${stage.id} team ${team} z`).toBeGreaterThan(stage.bounds.min[2]);
expect(position[2], `${stage.id} team ${team} z`).toBeLessThan(stage.bounds.max[2]);
expect(target.every(Number.isFinite)).toBe(true);
const foot = stage.teamSpawns[team];
const { position, target } = arenaCameraPose(foot, arenaSpawnYaw(stage, team));
expect(target[0]).toBe(foot[0]);
expect(target[2]).toBe(foot[2]);
expect(target[1] - foot[1]).toBeCloseTo(1.35);
expect(new Vector3(...position).distanceTo(new Vector3(...target))).toBeCloseTo(ARENA_CAMERA_DISTANCE);
expect(position[1] - foot[1]).toBeLessThan(4);
}
}
});
it("follows player translation and responds to orbit input", () => {
const first = arenaCameraPose([1, -22, 3], 0);
const moved = arenaCameraPose([11, -20, 5], 0);
expect(moved.position.map((value, axis) => value - first.position[axis])).toEqual([10, 2, 2]);
expect(arenaCameraPose([1, -22, 3], Math.PI / 2).position).not.toEqual(first.position);
expect(arenaCameraPose([0, 0, 0], NaN, Infinity).position.every(Number.isFinite)).toBe(true);
});
it("pulls in before a wall and restores full distance in open space", () => {
const wall = new Mesh(new BoxGeometry(8, 8, 0.2), new MeshBasicMaterial());
wall.position.set(0, 1.5, -2);
wall.updateMatrixWorld(true);
const target = new Vector3(0, 1.5, 0);
const ray = new Raycaster();
expect(arenaCameraClearDistance(target, new Vector3(0, 1.5, -6), wall, ray)).toBeCloseTo(1.7);
expect(arenaCameraClearDistance(target, new Vector3(6, 1.5, 0), wall, ray)).toBe(6);
wall.geometry.dispose();
wall.material.dispose();
});
});
+47 -7
View File
@@ -1,16 +1,56 @@
import type { ArenaStageManifest } from "./arenaTypes";
import { Raycaster, Vector3, type Object3D } from "three";
import { clampCameraPitch, DEFAULT_CAMERA_PITCH } from "./inputMath";
import type { ArenaStageManifest, Vec3Tuple } from "./arenaTypes";
export const ARENA_CAMERA_DISTANCE = 5.8;
export const ARENA_CAMERA_FOCUS_HEIGHT = 1.35;
export interface ArenaCameraPose {
readonly position: [number, number, number];
readonly target: [number, number, number];
}
export function arenaCameraPose(stage: ArenaStageManifest, team: 0 | 1): ArenaCameraPose {
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1]));
export function arenaSpawnYaw(stage: ArenaStageManifest, team: 0 | 1): number {
const from = stage.teamSpawns[team];
const to = stage.preparationGates[team];
return Math.atan2(to[0] - from[0], to[2] - from[2]);
}
/** Follow the controlled character at their elevation, including underground arenas. */
export function arenaCameraPose(
footPosition: Vec3Tuple,
yaw: number,
pitch = DEFAULT_CAMERA_PITCH,
distance = ARENA_CAMERA_DISTANCE,
): ArenaCameraPose {
const safeYaw = Number.isFinite(yaw) ? yaw : 0;
const safePitch = clampCameraPitch(pitch);
const radius = Number.isFinite(distance) ? Math.max(0.25, Math.min(12, distance)) : ARENA_CAMERA_DISTANCE;
const target: [number, number, number] = [footPosition[0], footPosition[1] + ARENA_CAMERA_FOCUS_HEIGHT, footPosition[2]];
return {
position: [...stage.spectatorPositions[team]],
target: [centerX, floorY + 1.2, centerZ],
target,
position: [
target[0] - Math.sin(safeYaw) * Math.cos(safePitch) * radius,
target[1] + Math.sin(safePitch) * radius,
target[2] - Math.cos(safeYaw) * Math.cos(safePitch) * radius,
],
};
}
/** Probe both directions so one-sided imported walls also obstruct the camera. */
export function arenaCameraClearDistance(target: Vector3, desired: Vector3, environment: Object3D, ray: Raycaster): number {
const length = target.distanceTo(desired);
if (length < 0.001) return 0;
const direction = desired.clone().sub(target).normalize();
let distance = length;
for (const offset of [[0, 0, 0], [0.18, 0, 0], [-0.18, 0, 0], [0, 0.18, 0], [0, -0.18, 0]]) {
const origin = target.clone().add(new Vector3(...offset));
ray.near = 0.03;
ray.far = length;
ray.set(origin, direction);
for (const hit of ray.intersectObject(environment, true)) distance = Math.min(distance, hit.distance - 0.2);
ray.set(origin.addScaledVector(direction, length), direction.clone().negate());
for (const hit of ray.intersectObject(environment, true)) distance = Math.min(distance, length - hit.distance - 0.2);
}
return Math.max(0.15, distance);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { CharacterProfile } from "../app/types";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
import { createArenaCharacterKit } from "./arenaCharacter";
import { controlledArenaActor } from "./arenaPlayer";
import type { ArenaAuthoritativeSnapshot } from "./arenaTypes";
describe("arena character selection and saved actions", () => {
it("keeps selected character bindings and enough mana to use learned spells", async () => {
await preloadAbilityCatalogsForClasses(["priest"]);
const character = { id: "mine", name: "My Priest", classId: "priest", raceId: "human", level: 80,
equipment: {}, inventory: [], talentRanks: {}, actionBindings: { "primary:face-left": "priest-flash-heal", "primary:r1": null } } as unknown as CharacterProfile;
const kit = createArenaCharacterKit(character);
expect(kit.actionBindings["primary:face-left"]).toBe("priest-flash-heal");
expect(kit.actionBindings["primary:r1"]).toBeNull();
expect(kit.resource.maximum).toBeGreaterThan(kit.abilities["priest-flash-heal"].cost.amount);
expect(kit.abilities.heal).toBeUndefined();
expect(kit.stats?.health).toBeGreaterThan(0);
});
it("uses server control ownership instead of selecting an arbitrary friendly bot", () => {
const actors = [{ id: "bot", team: 0, isBot: true }, { id: "mine", team: 1, isBot: false, characterId: "saved" }];
const snapshot = { mode: "ranked-pvp", controlledActorId: "mine", actors } as unknown as ArenaAuthoritativeSnapshot;
expect(controlledArenaActor(snapshot, { id: "saved" })?.id).toBe("mine");
expect(controlledArenaActor({ ...snapshot, controlledActorId: undefined }, { id: "saved" })?.id).toBe("mine");
expect(controlledArenaActor({ ...snapshot, controlledActorId: undefined }, { id: "missing" })).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { CharacterProfile } from "../app/types";
import {
abilitiesForCharacter, abilityAtLevel, defaultActionBarForCharacter, resourceProfileForClass,
type AbilityDefinition, type ClassResourceProfile,
} from "./abilityCatalog";
import { normalizeActionBindings } from "./actionBindings";
import type { ArenaCharacterKit } from "./arenaTypes";
import { deriveCharacterStats } from "./wotlkStats";
import { activeEquipmentStatsForClass, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
return abilitiesForCharacter(character.classId, character.secondaryClassId)
.filter((ability) => !ability.passive)
.map((ability) => abilityAtLevel(ability, level, ability.talentEntryId === undefined
? undefined : character.talentRanks[`coa-entry-${ability.talentEntryId}`]));
}
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
return ability.unlockLevel <= level && (ability.source !== "coa-tree" || ability.talentEntryId === undefined
|| (character.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0);
}
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
const stats = deriveCharacterStats(character.classId, level, activeEquipmentStatsForClass(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID, character.classId), character.raceId, {}, character.secondaryClassId);
const profile = resourceProfileForClass(character.classId);
const resource = profile.type === "mana" ? { ...profile, maximum: stats.mana, initial: stats.mana, regenerationPerSecond: stats.mana * 0.004 } : profile;
const abilities = arenaCharacterAbilities(character, level).filter((ability) => arenaAbilityIsLearned(ability, character, level));
const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
const actionBindings = normalizeActionBindings(character.actionBindings, [
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
...all.filter((ability) => ability.source === "coa-tree" && !ability.passive).map((ability) => ability.id),
]);
return {
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
classId: character.classId,
level,
resource: resource as ClassResourceProfile,
stats: { health: stats.health, spellPower: stats.spellPower },
abilities: Object.fromEntries(abilities.map((ability) => [ability.id, ability])),
actionBindings,
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { CharacterProfile } from "../app/types";
import type { ArenaActorSnapshot, ArenaAuthoritativeSnapshot } from "./arenaTypes";
export function controlledArenaActor(snapshot: ArenaAuthoritativeSnapshot | null, character: Pick<CharacterProfile, "id"> | null): ArenaActorSnapshot | null {
if (!snapshot) return null;
return snapshot.actors.find((actor) => actor.id === snapshot.controlledActorId)
?? snapshot.actors.find((actor) => character && actor.characterId === character.id && !actor.isBot)
?? (snapshot.mode === "practice" ? snapshot.actors.find((actor) => actor.id === "practice-player") : null)
?? null;
}
export function arenaTargetActor(snapshot: ArenaAuthoritativeSnapshot | null, id: string | null): ArenaActorSnapshot | null {
const player = snapshot?.actors.find((actor) => actor.id === id);
if (player) return player;
for (const owner of snapshot?.actors ?? []) {
const summon = owner.combat?.summons.find((entry) => entry.id === id);
if (summon) return { ...owner, id: summon.id, name: summon.name, position: summon.position,
health: summon.health, maxHealth: summon.maxHealth, alive: summon.health > 0 };
}
return null;
}
export function arenaIntentFeedback(reason?: string): string {
return ({
"preparation": "Wait for the gates to open.", "invalid-target": "Select a living target.",
"out-of-range": "Target is out of range.", "line-of-sight": "Target is out of sight.",
"resource": "Not enough resource.", "cooldown": "Ability is not ready yet.",
"already-casting": "Already casting.", "moving": "Stop moving to cast.",
"controlled": "You cannot cast while controlled or silenced.", "not-learned": "That ability is not learned by this character.",
"not-facing": "Face your target.", "must-be-behind": "Move behind your target.",
"invalid-destination": "Choose a reachable location in range.", "insufficient-resource": "Not enough resource.",
"school-locked": "That spell school is locked.", "player-controlled": "You cannot act while controlled.",
"actor-unavailable": "You cannot act right now.",
} as Record<string, string>)[reason ?? ""] ?? "Unable to use that ability.";
}
+2
View File
@@ -1,6 +1,7 @@
import type { CharacterProfile } from "../app/types";
import { ArenaSimulation } from "../../server/arena-simulation.mjs";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaResult, ArenaRole, ArenaStageManifest } from "./arenaTypes";
import { createArenaCharacterKit } from "./arenaCharacter";
interface PracticeOptions {
character: CharacterProfile;
@@ -33,6 +34,7 @@ export function createArenaPractice(options: PracticeOptions) {
role,
isBot: index !== 0,
rating: options.difficulty,
characterKit: index === 0 ? createArenaCharacterKit(options.character) : undefined,
})),
...teamRoles(teamSize).map((role, index) => ({
actorId: `practice-enemy-${index}`,
+26 -11
View File
@@ -13,9 +13,11 @@ 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 { createArenaPractice } from "./arenaPractice";
import type { ArenaAuthoritativeSnapshot, ArenaCombatIntent, ArenaLoadout, ArenaQueueState, ArenaResult } from "./arenaTypes";
import { romGearProfile, specializationById, specializationsForClass } from "./specializationCatalog";
import { arenaIntentFeedback } from "./arenaPlayer";
import { preloadAbilityCatalogsForClasses } from "./abilityCatalog";
type ArenaModeSelection = "offline-practice" | "rated-ai" | "ranked-pvp";
@@ -74,6 +76,7 @@ function handleSocketMessage(raw: unknown) {
}
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." });
if (message.type === "intent-rejected") useArenaStore.setState({ error: message.message || arenaIntentFeedback((raw as { reason?: string }).reason) });
}
async function connectArenaSocket(session: PlayerSession) {
@@ -180,15 +183,23 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
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;
set({ loading: true, error: "" });
try {
await preloadAbilityCatalogsForClasses([character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])]);
const { createArenaPractice } = await import("./arenaPractice");
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, loading: false, error: "", result: null, queue: { status: "matched", matchId: `practice-${Date.now()}` } });
useShellStore.setState({ phase: "arena-game" });
return true;
} catch (error) {
set({ loading: false, error: messageFor(error) });
return false;
}
}
if (!session?.accessToken) {
set({ error: "Rated arenas require an online account. Offline Practice remains available." });
@@ -235,7 +246,11 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
const sequence = get().sequence + 1;
const message = { ...intent, sequence } as ArenaCombatIntent;
set({ sequence });
get().practice?.intent(message);
if (intent.type === "target") set((state) => ({ snapshot: state.snapshot ? { ...state.snapshot,
actors: state.snapshot.actors.map((actor) => actor.id === state.snapshot!.controlledActorId
? { ...actor, targetActorId: intent.targetActorId || null } : actor) } : null }));
const response = get().practice?.intent(message);
if (intent.type === "ability") set({ error: response && !response.accepted ? response.message ?? arenaIntentFeedback(response.reason) : "" });
const socket = get().socket;
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
},
+46 -2
View File
@@ -1,4 +1,15 @@
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "./arenaRules";
import type { AbilityDefinition, ClassResourceProfile } from "./abilityCatalog";
export interface ArenaCharacterKit {
readonly seed?: import("./combatStore").CharacterCombatSeed;
readonly stats?: { readonly health: number; readonly spellPower: number };
classId: string;
level: number;
resource: ClassResourceProfile;
abilities: Readonly<Record<string, AbilityDefinition>>;
actionBindings: Readonly<Record<string, string | null>>;
}
export type Vec3Tuple = readonly [number, number, number];
export type ArenaRole = "healer" | "damage";
@@ -95,7 +106,10 @@ export interface ArenaQueueState {
export interface ArenaCombatIntent {
sequence: number;
type: "movement" | "facing" | "target" | "ability";
type: "movement" | "facing" | "target" | "ability" | "command" | "jump" | "bindings";
command?: "attack" | "defend" | "stop" | "recall" | "cancel-cast";
actionBindings?: Readonly<Record<string, string | null>>;
groundPosition?: Vec3Tuple;
movement?: { x: number; z: number };
facing?: number;
targetActorId?: string;
@@ -104,7 +118,35 @@ export interface ArenaCombatIntent {
}
export interface ArenaActorSnapshot {
avatar?: import("../avatar/CharacterModel").AvatarIdentity;
visualEquipment?: readonly import("./lootTypes").InventoryItem[];
combat?: {
resourcePools: import("./combatStore").CombatState["resourcePools"];
maxResourcePools: import("./combatStore").CombatState["maxResourcePools"];
runes: import("./combatStore").CombatState["deathKnightRunes"];
comboPoints: number;
comboTargetId: string | null;
shield: number;
activeCast: import("./combatStore").ActiveCast | null;
summons: readonly import("./combatStore").CombatSummon[];
areaEffects: readonly import("./combatStore").CombatAreaEffect[];
controlledUntil: number;
schoolLockedUntil: import("./combatStore").CombatState["schoolLockedUntil"];
talentRanks: import("./combatStore").CombatState["talentRanks"];
gearStats: import("./combatStore").CombatState["gearStats"];
specializationId: string | null;
playerAnimationEvent: import("./combatAnimation").CharacterCombatAnimationEvent | null;
};
id: string;
characterId?: string | null;
classId?: string;
level?: number;
actionBindings?: Readonly<Record<string, string | null>>;
abilityIds?: readonly string[];
resourceType?: string;
gcdRemainingMs?: number;
lastAbilityId?: string;
abilityRevision?: number;
name: string;
team: 0 | 1;
role: ArenaRole;
@@ -119,7 +161,7 @@ export interface ArenaActorSnapshot {
maxResource: number;
targetActorId: string | null;
cooldowns: Record<string, number>;
effects: readonly { id: string; remainingMs: number; stacks?: number }[];
effects: readonly { id: string; remainingMs: number; stacks?: number; icon?: string; disposition?: "buff" | "debuff" }[];
}
export interface ArenaDynamicObjectSnapshot {
@@ -130,6 +172,8 @@ export interface ArenaDynamicObjectSnapshot {
}
export interface ArenaAuthoritativeSnapshot {
combatPresentation?: readonly import("./combatPresentation").CombatPresentationEvent[];
combatEvents?: readonly import("./combatEvents").StructuredCombatEvent[];
matchId: string;
tick: number;
serverTime: number;
+66 -2
View File
@@ -1,10 +1,20 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const combatMedia = vi.hoisted(() => ({ ready: false }));
const combatMedia = vi.hoisted(() => ({
ready: false,
fetch: vi.fn(),
entry: vi.fn(() => null as import("./combatPresentation").CombatEffectManifestEntry | null),
}));
vi.mock("./combatMediaPack", async (importOriginal) => ({
...(await importOriginal<typeof import("./combatMediaPack")>()),
combatMediaPackReady: () => combatMedia.ready,
fetchOptionalMediaAsset: combatMedia.fetch,
}));
vi.mock("./combatPresentation", async (importOriginal) => ({
...(await importOriginal<typeof import("./combatPresentation")>()),
combatEffectManifestEntry: combatMedia.entry,
}));
import {
@@ -40,6 +50,8 @@ const fixtureEvent: CombatPresentationEvent = {
afterEach(() => {
combatMedia.ready = false;
combatMedia.fetch.mockReset();
combatMedia.entry.mockReset();
vi.unstubAllGlobals();
});
@@ -69,7 +81,7 @@ describe("combat audio output policy", () => {
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
})).toBe(true);
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(false);
expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true);
expect(combatMediaPreloadEnabled({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: true,
@@ -104,4 +116,56 @@ describe("combat audio output policy", () => {
expect(AudioContextConstructor).not.toHaveBeenCalled();
});
it("stays silent without an original sound, even with combat audio enabled", () => {
combatMedia.ready = true;
const AudioContextConstructor = vi.fn();
vi.stubGlobal("window", { AudioContext: AudioContextConstructor });
configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
playCombatAudio(fixtureEvent);
expect(AudioContextConstructor).not.toHaveBeenCalled();
expect(combatMedia.fetch).not.toHaveBeenCalled();
});
it.each(["original", "missing-file", "bad-audio"])("uses original audio or silence for %s", async (scenario) => {
vi.resetModules();
const audio = await import("./combatAudio");
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
const audioContext = {
currentTime: 0,
state: "running",
destination: {},
createGain: () => ({
connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 },
}),
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
createBufferSource: vi.fn(() => source),
createOscillator: vi.fn(),
decodeAudioData: vi.fn(async () => {
if (scenario === "bad-audio") throw new Error("Invalid audio");
return { duration: 1 };
}),
resume: vi.fn(async () => undefined),
};
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
combatMedia.ready = true;
combatMedia.entry.mockReturnValue({
key: "wow335:2061", source: "wow335", spellId: 2061,
sounds: [{ url: "/assets/game/combat-effects/audio/flash-heal.wav" }],
});
combatMedia.fetch.mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), {
status: scenario === "missing-file" ? 404 : 200,
}));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
audio.playCombatAudio({ ...fixtureEvent, source: "wow335", sourceSpellId: 2061 });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(combatMedia.fetch).toHaveBeenCalledWith("/assets/game/combat-effects/audio/flash-heal.wav");
expect(source.start).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
expect(audioContext.createBufferSource).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0);
expect(audioContext.createOscillator).not.toHaveBeenCalled();
audio.resetCombatAudio();
});
});
+1 -62
View File
@@ -4,7 +4,6 @@ import {
type CombatEffectSoundAsset,
type CombatPresentationEvent,
type CombatPresentationSettings,
type CombatPresentationSchool,
} from "./combatPresentation";
import {
combatMediaPackReady,
@@ -147,60 +146,6 @@ function registerVoice(voice: ActiveVoice): () => void {
};
}
const SCHOOL_FREQUENCY: Readonly<Record<CombatPresentationSchool, number>> = {
physical: 115,
arcane: 520,
fire: 190,
frost: 720,
nature: 360,
shadow: 145,
holy: 660,
};
function playSynthesizedCombatCue(event: CombatPresentationEvent): void {
if (
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const audioContext = ensureContext();
if (!audioContext || !groupGains || !configuredSettings?.audioEnabled[event.actorGroup]) return;
void audioContext.resume().catch(() => undefined);
const oscillator = audioContext.createOscillator();
const envelope = audioContext.createGain();
const panner = positionPanner(audioContext, event);
const start = audioContext.currentTime;
const isImpact = event.phase === "impact" || event.delivery === "melee";
const duration = event.phase === "cast-start" ? 0.22 : isImpact ? 0.12 : event.phase === "tick" ? 0.1 : 0.18;
const base = SCHOOL_FREQUENCY[event.school];
oscillator.type = event.school === "physical" ? "sawtooth" : event.school === "holy" ? "sine" : "triangle";
oscillator.frequency.setValueAtTime(base * (event.phase === "cast-start" ? 0.7 : 1), start);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(35, base * (isImpact ? 0.42 : 1.55)), start + duration);
envelope.gain.setValueAtTime(0.0001, start);
envelope.gain.exponentialRampToValueAtTime(event.actorGroup === "player" ? 0.12 : 0.085, start + 0.015);
envelope.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(envelope);
envelope.connect(panner);
panner.connect(groupGains[event.actorGroup]);
let stopped = false;
const voice: ActiveVoice = {
group: event.actorGroup,
stop: () => {
if (stopped) return;
stopped = true;
try { oscillator.stop(); } catch { /* already stopped */ }
},
};
const unregister = registerVoice(voice);
oscillator.onended = () => {
unregister();
oscillator.disconnect();
envelope.disconnect();
panner.disconnect();
};
oscillator.start(start);
oscillator.stop(start + duration + 0.02);
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
@@ -321,13 +266,7 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event);
if (!sound) {
playSynthesizedCombatCue(event);
return;
}
void playBufferedCombatCue(event, sound).then((played) => {
if (!played) playSynthesizedCombatCue(event);
});
if (sound) void playBufferedCombatCue(event, sound).catch(() => undefined);
}
export function stopCombatAudioGroup(group: CombatActorGroup): void {
+32 -4
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const content = vi.hoisted(() => ({
usable: false,
install: vi.fn(async () => undefined),
install: vi.fn(async (): Promise<void> => undefined),
remove: vi.fn(async () => undefined),
listener: null as (() => void) | null,
snapshot: {
@@ -29,25 +29,53 @@ vi.mock("../content/contentManager", () => ({
import {
combatMediaPackReady,
downloadCombatMediaPack,
ensureCombatMediaPack,
fetchCombatMediaAsset,
removeCombatMediaPack,
} from "./combatMediaPack";
beforeEach(() => {
content.usable = false;
content.install.mockClear();
content.install.mockReset();
content.remove.mockClear();
vi.unstubAllGlobals();
});
describe("optional combat media pack", () => {
it("uses the shared verified content-pack installer only after an explicit request", async () => {
describe("original combat media pack", () => {
it("uses the shared verified content-pack installer", async () => {
const progress = vi.fn();
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
expect(content.install).toHaveBeenCalledWith("core:combat-media");
expect(progress).toHaveBeenLastCalledWith({ completed: 4, total: 8 });
});
it("automatically installs missing default media and reuses installed media", async () => {
content.install.mockImplementationOnce(async () => { content.usable = true; });
await expect(ensureCombatMediaPack()).resolves.toBe(true);
await expect(ensureCombatMediaPack()).resolves.toBe(true);
expect(content.install).toHaveBeenCalledTimes(1);
});
it("allows visual fallback after a failed download and retries on the next preparation", async () => {
content.install.mockRejectedValueOnce(new Error("Offline"));
await expect(ensureCombatMediaPack()).resolves.toBe(false);
content.install.mockImplementationOnce(async () => { content.usable = true; });
await expect(ensureCombatMediaPack()).resolves.toBe(true);
expect(content.install).toHaveBeenCalledTimes(2);
});
it("shares an in-flight installation between automatic preparation and a download request", async () => {
let finish!: () => void;
content.install.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve; }));
const preparation = ensureCombatMediaPack();
const download = downloadCombatMediaPack();
expect(content.install).toHaveBeenCalledTimes(1);
content.usable = true;
finish();
await expect(preparation).resolves.toBe(true);
await expect(download).resolves.toEqual({ fileCount: 12 });
});
it("does not fetch source media while its pack is offloaded", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
+16 -2
View File
@@ -20,12 +20,13 @@ export interface CombatMediaDownloadResult {
}
type ProgressListener = (progress: CombatMediaDownloadProgress) => void;
let installRequest: Promise<void> | null = null;
function isCombatMediaUrl(value: unknown): value is string {
return typeof value === "string" && value.startsWith("/assets/game/combat-effects/");
}
/** Callers must enforce the player's original-media opt-in before fetching. */
/** Fetch original media after the enabled pack is ready. */
export async function fetchCombatMediaAsset(url: string): Promise<Response> {
if (!isCombatMediaUrl(url)) throw new Error(`Unsupported combat media URL: ${url}`);
return fetchOptionalMediaAsset(url);
@@ -50,7 +51,9 @@ export async function downloadCombatMediaPack(
};
const unsubscribe = subscribeContentManager(publishProgress);
try {
await installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
installRequest ??= installContentPack(COMBAT_MEDIA_CONTENT_PACK_ID)
.finally(() => { installRequest = null; });
await installRequest;
publishProgress();
const pack = getContentManagerSnapshot().manifest?.packs.find((candidate) => (
candidate.id === COMBAT_MEDIA_CONTENT_PACK_ID
@@ -61,6 +64,17 @@ export async function downloadCombatMediaPack(
}
}
/** Prepare the default media without blocking gameplay when it is unavailable. */
export async function ensureCombatMediaPack(): Promise<boolean> {
if (combatMediaPackReady()) return true;
try {
await downloadCombatMediaPack();
return combatMediaPackReady();
} catch {
return false;
}
}
export async function removeCombatMediaPack(): Promise<void> {
await removeContentPack(COMBAT_MEDIA_CONTENT_PACK_ID);
}
+5 -4
View File
@@ -23,8 +23,8 @@ describe("combat presentation", () => {
audioEnabled: { enemy: false },
audioVolume: { player: 2, party: -1, enemy: 0.35 },
})).toEqual({
originalMediaEnabled: false,
vfxMode: "generated",
originalMediaEnabled: true,
vfxMode: "original",
vfxQuality: "low",
vfxEnabled: { player: false, party: true, enemy: true },
audioEnabled: { player: true, party: true, enemy: false },
@@ -37,12 +37,13 @@ describe("combat presentation", () => {
}).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
originalMediaEnabled: false,
vfxMode: "original",
}).vfxMode).toBe("generated");
}).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings({
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
vfxMode: "hybrid",
}).vfxMode).toBe("generated");
}).vfxMode).toBe("original");
expect(normalizeCombatPresentationSettings(null)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
});
+5 -8
View File
@@ -40,8 +40,8 @@ export interface CombatPresentationSettings {
const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"];
export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({
originalMediaEnabled: false,
vfxMode: "generated",
originalMediaEnabled: true,
vfxMode: "original",
vfxQuality: "high",
vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }),
audioEnabled: Object.freeze({ player: true, party: true, enemy: true }),
@@ -90,12 +90,9 @@ export function normalizeCombatPresentationSettings(
const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean"
? record.originalMediaEnabled
: fallback.originalMediaEnabled;
const requestedVfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original"
? record.vfxMode
: fallback.vfxMode;
const vfxMode = requestedVfxMode === "original" && !originalMediaEnabled
? "generated"
: requestedVfxMode;
const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high"
? record.vfxQuality
: fallback.vfxQuality;
@@ -219,11 +216,11 @@ function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): v
export function preloadCombatEffectManifest(): Promise<void> {
if (manifestRequest) return manifestRequest;
if (typeof fetch === "undefined") return Promise.resolve();
if (typeof fetch === "undefined" || !combatMediaPackReady()) return Promise.resolve();
manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL)
.then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status))))
.then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest))
.catch(() => undefined);
.catch(() => { manifestRequest = null; });
return manifestRequest;
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const media = vi.hoisted(() => ({ ready: false, fetch: vi.fn() }));
vi.mock("./combatMediaPack", () => ({
COMBAT_MEDIA_MANIFEST_URL: "/assets/game/combat-effects/manifest.json",
combatMediaPackReady: () => media.ready,
fetchCombatMediaAsset: media.fetch,
}));
beforeEach(() => {
vi.resetModules();
media.ready = false;
media.fetch.mockReset();
});
describe("original media loading", () => {
it("uses the original default once the pack is ready without changing the saved preference", async () => {
const { DEFAULT_COMBAT_PRESENTATION_SETTINGS: settings, effectiveCombatVfxMode } = await import("./combatPresentation");
expect(effectiveCombatVfxMode(settings)).toBe("generated");
media.ready = true;
expect(effectiveCombatVfxMode(settings)).toBe("original");
expect(effectiveCombatVfxMode({ ...settings, originalMediaEnabled: false })).toBe("generated");
expect(settings.vfxMode).toBe("original");
});
it("does not poison manifest loading before installation or after a failed fetch", async () => {
const { preloadCombatEffectManifest, combatEffectManifestEntry } = await import("./combatPresentation");
await preloadCombatEffectManifest();
expect(media.fetch).not.toHaveBeenCalled();
media.ready = true;
media.fetch.mockRejectedValueOnce(new Error("Offline"));
await preloadCombatEffectManifest();
expect(combatEffectManifestEntry(2061, "wow335")).toBeNull();
const entry = { key: "wow335:2061", source: "wow335", spellId: 2061 };
media.fetch.mockResolvedValueOnce(new Response(JSON.stringify({ schemaVersion: 1, entries: [entry] })));
await preloadCombatEffectManifest();
expect(combatEffectManifestEntry(2061, "wow335")).toEqual(entry);
expect(media.fetch).toHaveBeenCalledTimes(2);
});
});
+50 -11
View File
@@ -330,6 +330,10 @@ export interface DiminishingReturnState {
}
export interface MobCombatState {
/** When the hostile actor is a player, preserve its actual defense and resources. */
readonly characterDefender?: CombatDefenderStats;
readonly characterShield?: number;
readonly characterResources?: Readonly<Partial<Record<ResourceType, number>>>;
readonly id: string;
readonly name: string;
readonly serverEntry: number | null;
@@ -1176,7 +1180,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
return "spell";
}
function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
@@ -1328,7 +1332,7 @@ function resolvePlayerDamageResult(
attackKind,
damageSchool: school,
attacker,
defender: npcCombatDefender(mob, stats.level),
defender: mob.characterDefender ?? npcCombatDefender(mob, stats.level),
rules: immune
? {
...rules,
@@ -1710,6 +1714,15 @@ function talentAdjustedIncomingDamage(amount: number, modifiers: readonly Talent
));
}
/** Renderer-independent player defenses for hostile-player combat adapters. */
export function characterCombatDefender(state: CombatState): CombatDefenderStats {
const stats = applyAuraStats(playerCharacterStats(state.classId, state.level, state.gearStats,
state.raceId, state.secondaryClassId, state.talentModifiers), aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID));
const defender = combatDefenderFromCharacterStats(stats);
const multiplier = talentAdjustedIncomingDamage(10_000, state.talentModifiers) / 10_000;
return { ...defender, damageReductionPercent: (1 - (1 - (defender.damageReductionPercent ?? 0) / 100) * multiplier) * 100 };
}
function normalizedSettings(
current: GameplaySettings,
patch: Partial<GameplaySettings>,
@@ -2534,11 +2547,13 @@ function damageMobInWork(
now,
});
work.auras = [...auraAbsorb.auras];
const characterAbsorbed = Math.min(mob.characterShield ?? 0, auraAbsorb.remainingDamage);
const remainingDamage = Math.max(0, auraAbsorb.remainingDamage - characterAbsorbed);
const adjustedCombatResult = combatResult
? { ...combatResult, finalDamage: Math.max(0, Math.round(auraAbsorb.remainingDamage)) }
? { ...combatResult, finalDamage: Math.max(0, Math.round(remainingDamage)) }
: undefined;
if (adjustedCombatResult) work.combatResults.push(adjustedCombatResult);
const applied = Math.min(mob.health, Math.max(0, Math.round(auraAbsorb.remainingDamage)));
const applied = Math.min(mob.health, Math.max(0, Math.round(remainingDamage)));
recordCombatEventInWork(work, {
occurredAt: now,
kind: "damage",
@@ -2549,7 +2564,7 @@ function damageMobInWork(
rawAmount: combatResult?.rawDamage ?? Math.max(0, Math.round(amount)),
effectiveAmount: applied,
overheal: 0,
absorbed: auraAbsorb.absorbed,
absorbed: auraAbsorb.absorbed + characterAbsorbed,
critical: combatResult?.critical ?? false,
outcome: combatResult?.outcome ?? "hit",
});
@@ -2586,6 +2601,7 @@ function damageMobInWork(
);
work.mobs[id] = {
...engagedMob,
...(mob.characterShield !== undefined ? { characterShield: mob.characterShield - characterAbsorbed } : {}),
health,
dead: killed,
hasLoot,
@@ -2609,7 +2625,9 @@ function damageMobInWork(
combatPhase: killed ? "idle" : "chasing",
homePosition: engagedMob.homePosition ?? getMobPosition(id),
nextAttackAt: killed ? 0 : (mob.nextAttackAt > now ? mob.nextAttackAt : now + 450),
activeCast: killed ? null : engagedMob.activeCast,
activeCast: killed ? null : mob.characterDefender && applied > 0 && engagedMob.activeCast
? { ...engagedMob.activeCast, ...applyWrathCastPushback(engagedMob.activeCast as unknown as ActiveCast, now, `${source.actorId}:${now}`) }
: engagedMob.activeCast,
woundRevision: canPlayWoundAnimation ? mob.woundRevision + 1 : mob.woundRevision,
nextWoundAnimationAt: canPlayWoundAnimation
? now + MOB_WOUND_ANIMATION_COOLDOWN_MS
@@ -2737,19 +2755,19 @@ function applyStatus(
"stun", "fear", "root", "sleep", "blind", "freeze", "charm", "confuse", "knockdown",
]);
const previousDiminishing = mob.controlDiminishingReturns?.[effect.status];
const applications = mob.boss && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
const applications = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status) && previousDiminishing?.resetAt && previousDiminishing.resetAt > now
? previousDiminishing.applications
: 0;
const multiplier = mob.boss && diminishingStatuses.has(effect.status)
const multiplier = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
? [1, 0.5, 0.25, 0][Math.min(3, applications)]
: 1;
const durationMs = Math.round(baseDurationMs * multiplier);
const controlDiminishingReturns = mob.boss && diminishingStatuses.has(effect.status)
const controlDiminishingReturns = (mob.boss || mob.characterDefender) && diminishingStatuses.has(effect.status)
? {
...mob.controlDiminishingReturns,
[effect.status]: {
applications: Math.min(3, applications + 1),
resetAt: now + 15_000,
resetAt: now + (mob.characterDefender ? durationMs : 0) + 15_000,
},
}
: mob.controlDiminishingReturns;
@@ -4986,6 +5004,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const candidates = effect.maxTargets ? affectedMobIds.slice(0, effect.maxTargets) : affectedMobIds;
for (const id of candidates) {
const mob = work.mobs[id];
if (mob?.characterDefender && ability.effects.some((candidate) => candidate.kind === "apply-aura" && candidate.control?.kind === effect.status)) continue;
if (mob) work.mobs[id] = applyStatus(mob, {
...effect,
durationMs: talentAdjustedDuration(effect.durationMs, ability, modifiers),
@@ -5010,6 +5029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) {
const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
@@ -5032,12 +5052,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
if (effect.control && work.mobs[recipientId]) {
const durationMs = auraDefinition.durationMs ?? 1_000;
const previousStatuses = work.mobs[recipientId].statuses;
work.mobs[recipientId] = applyStatus(work.mobs[recipientId], {
kind: "status",
status: effect.control.kind as MobStatusKind,
durationMs,
...(effect.control.magnitude === undefined ? {} : { magnitude: effect.control.magnitude }),
}, `aura:${auraDefinition.id}`, now);
if (work.mobs[recipientId].characterDefender) {
const applied = work.mobs[recipientId].statuses.find((status) => status.sourceAbilityId === `aura:${auraDefinition.id}`);
if (work.mobs[recipientId].statuses === previousStatuses) work.auras = [...previousAuras];
else if (applied) work.auras = work.auras.map((aura) => aura.targetId === recipientId && aura.definition.id === auraDefinition.id ? { ...aura, expiresAt: applied.endsAt } : aura);
}
}
}
} else if (effect.kind === "dispel") {
@@ -5140,7 +5166,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
?? (resource === activeResourceType ? work.maxResource : defaultResourceMaximum(resource));
const drainAmount = effect.percentage ? maximum * effect.amount / 100 : effect.amount;
if (ability.target === "hostile") {
const returned = effect.burn ? 0 : drainAmount;
let drainedFromPlayers = 0;
let hasPlayerTarget = false;
for (const id of affectedMobIds) {
const mob = work.mobs[id];
if (!mob?.characterResources) continue;
hasPlayerTarget = true;
const current = mob.characterResources[resource] ?? 0;
const requested = effect.percentage ? mob.maxMana * effect.amount / 100 : effect.amount;
const drained = Math.min(current, requested);
drainedFromPlayers += drained;
work.mobs[id] = { ...mob, characterResources: { ...mob.characterResources, [resource]: current - drained } };
if (effect.burn && drained > 0) damageMobInWork(state.classId, work, id, drained, now, castThreatSource, damageSchool);
}
const returned = effect.burn ? 0 : hasPlayerTarget ? drainedFromPlayers : drainAmount;
if (returned > 0) {
work.resourcePools[resource] = clamp((work.resourcePools[resource] ?? 0) + returned, 0, maximum);
if (resource === activeResourceType) work.resource = work.resourcePools[resource] ?? work.resource;
+110
View File
@@ -4,6 +4,8 @@ import {
emitControllerToken,
orderedControllerTokenTransitions,
resetControllerState,
setExternalControllerMovement,
interceptControllerInput,
} from "../input/controller";
import {
MAX_MOUSE_DELTA_PER_EVENT,
@@ -23,8 +25,11 @@ import {
keyboardCommandForCode,
queueJumpRequest,
sanitizeMouseDelta,
readInputSnapshot,
type InputActions,
} from "./inputManager";
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { setCapturingInput } from "../input/keybindRuntime";
function stubInputEventTargets(): void {
const windowTarget = new EventTarget();
@@ -59,12 +64,117 @@ function inputActionSpies(blocked = false): InputActions {
}
afterEach(() => {
setCapturingInput(false);
endMouseLookDrag();
clearJumpRequest();
resetControllerState();
vi.unstubAllGlobals();
});
function keyboardEvent(type: "keydown" | "keyup", code: string, modifiers: Partial<KeyboardEvent> = {}) {
const event = new Event(type, { cancelable: true });
Object.defineProperties(event, Object.fromEntries(Object.entries({ code, target: null, ...modifiers }).map(([key, value]) => [key, { value }])));
window.dispatchEvent(event);
return event;
}
describe("remappable gameplay controls", () => {
it("keeps moving forward when a legacy WoW skill also owns W", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc["bar:0:0"] = "KeyW";
next.getBindings = (device) => preferences[device];
next.desktopActionBarsActive = () => true;
next.castDesktopSlot = vi.fn();
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW");
expect(readInputSnapshot().moveForward).toBe(1);
expect(next.castDesktopSlot).not.toHaveBeenCalled();
keyboardEvent("keyup", "KeyW");
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("honors remapped movement and clears held input when a menu opens", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc = replaceInputBinding(preferences.pc, "pc", "move-forward", "ArrowUp");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
keyboardEvent("keydown", "KeyW"); expect(readInputSnapshot().moveForward).toBe(0);
keyboardEvent("keydown", "ArrowUp");
expect(readInputSnapshot()).toMatchObject({ moveForward: 1, lookY: 0 });
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(true);
expect(readInputSnapshot().moveForward).toBe(0);
(next.gameplayActionsBlocked as ReturnType<typeof vi.fn>).mockReturnValue(false);
expect(readInputSnapshot().moveForward).toBe(0);
dispose();
});
it("casts remapped bar chords once and gives ground confirmation its context", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.pc["bar:4:11"] = "Ctrl+Shift+KeyQ";
preferences.pc["ground-confirm"] = "Ctrl+Shift+KeyQ";
next.getBindings = (device) => preferences[device]; next.desktopActionBarsActive = () => true; next.castDesktopSlot = vi.fn();
const dispose = installInput(next);
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true, repeat: true });
expect(next.castDesktopSlot).toHaveBeenCalledExactlyOnceWith(4, 11);
(next.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
keyboardEvent("keydown", "KeyQ", { ctrlKey: true, shiftKey: true });
expect(next.confirmGroundTarget).toHaveBeenCalledTimes(1);
expect(next.castDesktopSlot).toHaveBeenCalledTimes(1);
dispose();
});
it("remaps controller axes and buttons with analog movement preserved", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-forward", "Axis3-");
preferences.controller = replaceInputBinding(preferences.controller, "controller", "move-right", "Button3");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
setExternalControllerMovement({ moveX: 0, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot().moveForward).toBe(0);
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: -.7 });
expect(readInputSnapshot().moveForward).toBeGreaterThan(.3);
expect(readInputSnapshot().moveForward).toBeLessThan(1);
expect(readInputSnapshot().lookY).toBe(0);
setExternalControllerMovement({ moveX: 0, moveY: 0, lookX: 0, lookY: 0 });
emitControllerToken({ token: "Button3", repeat: false, pressed: true });
expect(readInputSnapshot().moveX).toBe(1);
expect(next.castActionBinding).not.toHaveBeenCalled();
emitControllerToken({ token: "Button3", repeat: false, pressed: false });
expect(readInputSnapshot().moveX).toBe(0);
dispose();
});
it("uses customized controller combinations before their base button", () => {
stubInputEventTargets();
const next = inputActionSpies(); const preferences = normalizeInputPreferences(null);
preferences.controller = replaceInputBinding(preferences.controller, "controller", "jump", "Button8&Button0");
next.getBindings = (device) => preferences[device];
const dispose = installInput(next);
emitControllerToken({ token: "Button8", repeat: false, pressed: true });
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
expect(consumeJumpRequest()).not.toBeNull();
expect(next.castActionBinding).not.toHaveBeenCalled();
expect(next.lootActiveBoss).not.toHaveBeenCalled();
dispose();
});
it("blocks keyboard actions, controller actions and movement throughout capture", () => {
stubInputEventTargets();
const next = inputActionSpies(); const dispose = installInput(next);
setCapturingInput(true);
const captured = vi.fn(() => true); const unsubscribe = interceptControllerInput(captured);
keyboardEvent("keydown", "KeyW"); keyboardEvent("keydown", "KeyI"); keyboardEvent("keydown", "Digit1");
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
emitControllerToken({ token: "Button9", repeat: false, pressed: true });
setExternalControllerMovement({ moveX: 1, moveY: -1, lookX: 0, lookY: 0 });
expect(readInputSnapshot()).toMatchObject({ moveX: 0, moveForward: 0 });
expect(next.castActionBinding).not.toHaveBeenCalled(); expect(next.toggleInventory).not.toHaveBeenCalled(); expect(next.togglePause).not.toHaveBeenCalled();
expect(captured).toHaveBeenCalledTimes(2);
unsubscribe(); dispose();
});
});
describe("gameplay input bindings", () => {
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
stubInputEventTargets();
+102 -133
View File
@@ -2,12 +2,13 @@ import { applyRadialDeadzone, normalizePlanar } from "./inputMath";
import {
CONTROLLER_SYSTEM_BACK_TOKEN,
getControllerMovement,
getControllerHeldTokens,
subscribeControllerToken,
} from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent, defaultInputBindings, type InputBindings, type InputDevice } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import type { PartyCommand } from "./partyStore";
import {
actionControlForControllerToken,
actionControlForKeyboardCode,
actionLayerForModifiers,
type ActionBindingControl,
type ActionBindingLayer,
@@ -25,6 +26,10 @@ export interface InputActions {
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
handleCustomKey?: (event: KeyboardEvent) => boolean;
desktopActionBarsActive?: () => boolean;
getBindings?: (device: InputDevice) => InputBindings;
inputSuspended?: () => boolean;
castDesktopSlot?: (bar: number, slot: number) => void;
togglePanel?: (panel: "party" | "boss-guide" | "bindings" | "options" | "keybinds" | "ui-layout") => void;
togglePause: () => void;
toggleMap: () => void;
toggleInventory: () => void;
@@ -150,21 +155,19 @@ export function isMouseLookDragActive(): boolean {
return mouseLookDragActive;
}
const GAMEPLAY_AXIS_KEYS = new Set([
"KeyW", "KeyA", "KeyS", "KeyD",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
]);
export function keyboardAxesForKeys(activeKeys: ReadonlySet<string>): Pick<
InputSnapshot,
"moveX" | "moveForward" | "lookX" | "lookY"
> {
return {
moveX: Number(activeKeys.has("KeyD")) - Number(activeKeys.has("KeyA")),
moveForward: Number(activeKeys.has("KeyW")) - Number(activeKeys.has("KeyS")),
lookX: Number(activeKeys.has("ArrowRight")) - Number(activeKeys.has("ArrowLeft")),
lookY: Number(activeKeys.has("ArrowDown")) - Number(activeKeys.has("ArrowUp")),
};
function inputAxes(value: (id: string) => number): Pick<InputSnapshot, "moveX" | "moveForward" | "lookX" | "lookY"> {
return { moveX: value("move-right") - value("move-left"), moveForward: value("move-forward") - value("move-backward"),
lookX: value("look-right") - value("look-left"), lookY: value("look-down") - value("look-up") };
}
export function keyboardAxesForKeys(activeKeys: ReadonlySet<string>, bindings = defaultInputBindings("pc")) {
return inputAxes((id) => {
const key = bindings[id];
if (!key) return 0;
return Number(activeKeys.has(key));
});
}
function bindingsFor(device: InputDevice): InputBindings {
return actions?.getBindings?.(device) ?? getInputPreferences()[device];
}
export type GameplayInputCommand =
@@ -231,132 +234,88 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
export function installInput(nextActions: InputActions): () => void {
actions = nextActions;
let controllerL1 = false;
let controllerL2 = false;
const keyboardModifiers = new Set<string>();
const currentActionLayer = () => actionLayerForModifiers(
controllerL1 || keyboardModifiers.has("ShiftLeft") || keyboardModifiers.has("ShiftRight"),
controllerL2 || keyboardModifiers.has("AltLeft") || keyboardModifiers.has("AltRight"),
);
const publishActionLayer = () => nextActions.setActionLayer(currentActionLayer());
const suspended = () => isCapturingInput() || nextActions.inputSuspended?.();
const publishActionLayer = () => {
const held = getControllerHeldTokens();
const layerHeld = (layer: string) => Object.entries(bindingsFor("controller")).some(([id, key]) => {
const parts = key?.split("&").slice(0, -1) ?? [];
return id.startsWith("ability:" + layer + ":") && parts.length > 0 && parts.every((token) => held.has(token));
});
nextActions.setActionLayer(actionLayerForModifiers(layerHeld("secondary") || keyboardModifiers.has("Shift"), layerHeld("tertiary") || keyboardModifiers.has("Alt")));
};
const dispatch = (id: string) => {
if (id.startsWith("ability:")) {
const [, layer, control] = id.split(":");
nextActions.setActionLayer(layer as ActionBindingLayer);
nextActions.castActionBinding(layer as ActionBindingLayer, control as ActionBindingControl);
} else if (id.startsWith("bar:")) {
const [, bar, slot] = id.split(":"); nextActions.castDesktopSlot?.(Number(bar), Number(slot));
} else if (id === "ground-confirm") nextActions.confirmGroundTarget();
else if (id === "ground-cancel") nextActions.cancelGroundTarget();
else if (["party", "boss-guide", "bindings", "options", "keybinds", "ui-layout"].includes(id)) nextActions.togglePanel?.(id as Parameters<NonNullable<InputActions["togglePanel"]>>[0]);
else runCommand(id as GameplayInputCommand, nextActions);
};
const candidates = (device: InputDevice) => INPUT_ACTIONS.filter((entry) => {
if (entry.context === "global" || entry.context === "menu" || entry.context === "ability-editor") return false;
if (entry.context === "ground") return nextActions.groundTargetActive() && !nextActions.gameplayActionsBlocked();
if (entry.id.startsWith("bar:") && (!nextActions.desktopActionBarsActive?.() || Number(entry.id.split(":")[1]) >= 5)) return false;
if (device === "pc" && entry.id.startsWith("ability:") && nextActions.desktopActionBarsActive?.()) return false;
return !nextActions.gameplayActionsBlocked() || (entry.group === "Panels" && (device === "pc" || entry.id === "pause"));
});
const unsubscribeController = subscribeControllerToken(({ token, repeat, pressed }) => {
const isPressed = pressed !== false;
if (token === "Button4" || token === "Button6") {
if (token === "Button4") controllerL1 = isPressed;
else controllerL2 = isPressed;
nextActions.setInputMode("gamepad");
publishActionLayer();
return;
}
if (!isPressed) return;
if (suspended()) return;
publishActionLayer();
if (pressed === false || repeat) return;
nextActions.setInputMode("gamepad");
if (repeat) return;
if (nextActions.groundTargetActive()) {
if (token === "Button0") {
nextActions.confirmGroundTarget();
return;
if (token === CONTROLLER_SYSTEM_BACK_TOKEN) {
if (!nextActions.gameplayActionsBlocked()) {
if (nextActions.groundTargetActive()) nextActions.cancelGroundTarget(); else nextActions.togglePause();
}
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
nextActions.cancelGroundTarget();
return;
}
}
if (
token === "Button0"
&& !nextActions.gameplayActionsBlocked()
&& nextActions.lootActiveBoss()
) return;
const actionControl = actionControlForControllerToken(token);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
const layer = currentActionLayer();
nextActions.setActionLayer(layer);
nextActions.castActionBinding(layer, actionControl);
return;
}
const command = controllerCommandForToken(token);
if (!command) return;
// Android's system Back is semantic rather than the Circle face button.
// Let the active menu's controller hook own it while a surface is open;
// in live gameplay it opens the pause hub.
if (token === CONTROLLER_SYSTEM_BACK_TOKEN && nextActions.gameplayActionsBlocked()) return;
// Start must remain available in every context so a Thor player can always
// reach or leave the pause hub. Menu components own the other buttons while
// an overlay is visible, preventing casts from firing behind a panel.
if (command === "pause") {
runCommand(command, nextActions);
return;
const bindings = bindingsFor("controller"); const held = getControllerHeldTokens();
const matches = candidates("controller").filter((entry) => controllerBindingMatches(bindings[entry.id], token, held))
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground")
|| (bindings[b.id]?.split("&").length ?? 0) - (bindings[a.id]?.split("&").length ?? 0));
for (const entry of matches) {
if (entry.continuous) return;
if (entry.id === "loot") { if (nextActions.lootActiveBoss()) return; else continue; }
dispatch(entry.id); return;
}
if (nextActions.gameplayActionsBlocked()) return;
runCommand(command, nextActions);
});
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, select, [contenteditable='true']")) return;
if (event.defaultPrevented || target?.closest?.("input, textarea, select, [contenteditable='true']") || suspended()) return;
if (nextActions.handleCustomKey?.(event)) {
event.preventDefault();
event.stopImmediatePropagation();
keys.delete(event.code);
return;
event.preventDefault(); event.stopImmediatePropagation(); keys.delete(event.code); return;
}
if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
keyboardModifiers.add(event.code);
nextActions.setInputMode("keyboard");
publishActionLayer();
if (!nextActions.gameplayActionsBlocked()) event.preventDefault();
return;
}
if (!nextActions.gameplayActionsBlocked() && GAMEPLAY_AXIS_KEYS.has(event.code)) {
event.preventDefault();
keys.add(event.code);
actions?.setInputMode("keyboard");
}
if (event.repeat) return;
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
event.preventDefault();
nextActions.setInputMode("keyboard");
if (event.code === "Enter") nextActions.confirmGroundTarget();
else nextActions.cancelGroundTarget();
return;
}
const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
event.preventDefault();
nextActions.setInputMode("keyboard");
const layer = actionLayerForModifiers(event.shiftKey, event.altKey);
nextActions.setActionLayer(layer);
nextActions.castActionBinding(layer, actionControl);
return;
}
const command = keyboardCommandForCode(event.code, event.shiftKey);
if (!command) return;
const partyCommand = command === "party-attack"
|| command === "party-defend"
|| command === "party-stop"
|| command === "party-recall";
// Function keys are browser-reserved on common PC browsers. Keep party
// shortcuts inside the game even while a menu temporarily blocks orders.
if (partyCommand) event.preventDefault();
const overlayCommand = command === "pause"
|| command === "map"
|| command === "inventory"
|| command === "spellbook"
|| command === "talents";
if (overlayCommand) {
runCommand(command, nextActions);
return;
}
if (nextActions.gameplayActionsBlocked()) return;
if (command === "jump") event.preventDefault();
if (event.code === "Tab") event.preventDefault();
runCommand(command, nextActions);
const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code];
if (modifier) { keyboardModifiers.add(modifier); publishActionLayer(); return; }
const key = inputKeyForEvent(event);
if (!key) return;
const bindings = bindingsFor("pc");
if (nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.context === "menu" && bindings[entry.id] === key)) return;
const matches = candidates("pc").filter((entry) => bindings[entry.id] === key)
.sort((a, b) => Number(b.context === "ground") - Number(a.context === "ground"));
// Preserve ordinary movement while a modifier is held (e.g. Shift + W).
const continuous = !nextActions.gameplayActionsBlocked() && INPUT_ACTIONS.some((entry) => entry.continuous && (bindings[entry.id] === event.code || bindings[entry.id] === key));
if (continuous) { keys.add(event.code); keys.add(key); event.preventDefault(); nextActions.setInputMode("keyboard"); }
if (!matches.length) return;
const entry = matches[0];
event.preventDefault(); nextActions.setInputMode("keyboard");
if (entry.continuous || event.repeat) return;
dispatch(entry.id);
};
const onKeyUp = (event: KeyboardEvent) => {
keys.delete(event.code);
if (keyboardModifiers.delete(event.code)) publishActionLayer();
for (const key of keys) if (key === event.code || key.endsWith("+" + event.code)) keys.delete(key);
const modifier = { ShiftLeft: "Shift", ShiftRight: "Shift", AltLeft: "Alt", AltRight: "Alt", ControlLeft: "Ctrl", ControlRight: "Ctrl", MetaLeft: "Meta", MetaRight: "Meta" }[event.code];
if (modifier) {
keyboardModifiers.delete(modifier);
for (const key of keys) if (key.split("+").includes(modifier)) keys.delete(key);
}
publishActionLayer();
};
const onMouseMove = (event: MouseEvent) => {
if (mouseLookDragActive) {
@@ -385,8 +344,6 @@ export function installInput(nextActions: InputActions): () => void {
const clear = () => {
keys.clear();
keyboardModifiers.clear();
controllerL1 = false;
controllerL2 = false;
clearJumpRequest();
mouseLookDragActive = false;
previousMousePosition = null;
@@ -418,11 +375,23 @@ export function consumeMouseLook(): readonly [number, number] {
}
export function readInputSnapshot(): InputSnapshot {
const keyboard = keyboardAxesForKeys(keys);
if (isCapturingInput() || actions?.inputSuspended?.() || actions?.gameplayActionsBlocked()) {
keys.clear();
return { moveX: 0, moveForward: 0, lookX: 0, lookY: 0, gamepadActive: false };
}
const keyboard = keyboardAxesForKeys(keys, bindingsFor("pc"));
const controller = getControllerMovement();
const [stickMoveX, stickForward] = applyRadialDeadzone(controller.moveX, -controller.moveY);
const [stickLookX, stickLookY] = applyRadialDeadzone(controller.lookX, controller.lookY, 0.16);
const axes = [controller.moveX, controller.moveY, controller.lookX, controller.lookY];
const held = getControllerHeldTokens(); const bindings = bindingsFor("controller");
const remapped = inputAxes((id) => {
const parts = bindings[id]?.split("&") ?? []; const token = parts.pop();
if (!token || !parts.every((part) => held.has(part))) return 0;
const axis = /^Axis([0-3])([+-])$/.exec(token);
return axis ? Math.max(0, axes[Number(axis[1])] * (axis[2] === "+" ? 1 : -1)) : Number(held.has(token));
});
const [stickMoveX, stickForward] = applyRadialDeadzone(remapped.moveX, remapped.moveForward);
const [stickLookX, stickLookY] = applyRadialDeadzone(remapped.lookX, remapped.lookY, 0.16);
const [moveX, moveForward] = normalizePlanar(
keyboard.moveX + stickMoveX,
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { INPUT_ACTIONS, conflictingInputActions, defaultInputDevice, normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
import { exportUiPreferences, importUiPreferences, normalizeUiPreferences } from "./uiLayout";
import { profileInputBindings, restoreMovementBindings, withProfileInputBindings } from "./keybindPreferences";
describe("profile keybinds", () => {
it("defaults desktop browsers to PC and mobile/native apps to Controller", () => {
expect(defaultInputDevice(false, false)).toBe("pc");
expect(defaultInputDevice(false, true)).toBe("controller");
expect(defaultInputDevice(true, false)).toBe("controller");
expect(defaultInputDevice(true, true)).toBe("controller");
});
it("migrates older profiles with every action, independent input types and 60 WoW slots", () => {
const preferences = normalizeUiPreferences({ version: 1 });
expect(preferences.keybinds.pc["move-forward"]).toBe("KeyW");
expect(preferences.keybinds.controller["move-forward"]).toBe("Axis1-");
expect(INPUT_ACTIONS.filter((entry) => entry.id.startsWith("bar:"))).toHaveLength(60);
expect(INPUT_ACTIONS.filter((entry) => entry.id.startsWith("ability:"))).toHaveLength(24);
expect(Object.keys(preferences.keybinds.pc)).toHaveLength(INPUT_ACTIONS.length);
preferences.keybinds.pc.jump = null;
expect(preferences.keybinds.controller.jump).toBe("Button10");
});
it("finds an existing W skill conflict and repairs movement without losing assigned skills or templates", () => {
const preferences = normalizeUiPreferences(null);
preferences.current.pc.bars[4].slots[11] = { key: "KeyW", abilityId: "heal" };
preferences.keybinds.pc["move-left"] = null;
preferences.slots.mobile[2] = { name: "Travel", layout: preferences.current.mobile };
const bindings = profileInputBindings(preferences, "pc");
expect(conflictingInputActions(bindings, "pc", "move-forward", "KeyW")).toEqual(["bar:4:11"]);
const repaired = restoreMovementBindings(preferences, "pc");
expect(repaired.current.pc.bars[4].slots[11]).toEqual({ key: null, abilityId: "heal" });
expect(repaired.keybinds.pc["move-left"]).toBe("KeyA");
expect(repaired.slots).toEqual(preferences.slots);
expect(preferences.current.pc.bars[4].slots[11].key).toBe("KeyW");
});
it("supports explicit conflict replacement and persistent unbinding", () => {
const preferences = normalizeUiPreferences(null);
const bindings = profileInputBindings(preferences, "pc");
expect(conflictingInputActions(bindings, "pc", "bar:0:0", "KeyW")).toEqual(["move-forward"]);
const changed = withProfileInputBindings(preferences, "pc", replaceInputBinding(bindings, "pc", "bar:0:0", "KeyW"));
const restored = importUiPreferences(exportUiPreferences(changed));
expect(restored.keybinds.pc["move-forward"]).toBeNull();
expect(restored.current.pc.bars[0].slots[0].key).toBe("KeyW");
expect(restoreMovementBindings(restored, "pc").current.pc.bars[0].slots[0].key).toBeNull();
});
it("uses the loaded template's bar keys and changes only the chosen input type", () => {
let preferences = normalizeUiPreferences(null);
const bindings = profileInputBindings(preferences, "pc");
preferences = withProfileInputBindings(preferences, "pc", replaceInputBinding(bindings, "pc", "bar:4:11", "Ctrl+Shift+KeyQ"));
preferences.current.pc.bars[4].slots[11].key = "KeyZ";
expect(profileInputBindings(preferences, "pc")["bar:4:11"]).toBe("KeyZ");
const next = withProfileInputBindings(preferences, "controller", replaceInputBinding(preferences.keybinds.controller, "controller", "jump", "Button4&Button3"));
expect(next.current.pc).toEqual(preferences.current.pc);
expect(importUiPreferences(exportUiPreferences(next)).keybinds.controller.jump).toBe("Button4&Button3");
});
it("distinguishes contextual defaults and mutually exclusive PC layouts from real collisions", () => {
const preferences = normalizeUiPreferences(null);
for (const device of ["pc", "controller"] as const) {
const bindings = profileInputBindings(preferences, device);
expect(INPUT_ACTIONS.flatMap((entry) => conflictingInputActions(bindings, device, entry.id, bindings[entry.id]))).toEqual([]);
}
expect(conflictingInputActions(preferences.keybinds.controller, "controller", "menu-confirm", "Button2")).toContain("editor-clear");
expect(conflictingInputActions(preferences.keybinds.pc, "pc", "jump", "KeyC")).toContain("companion");
});
it("validates imported keybinds, normalizes chords and retains defaults for corrupt profile fields", () => {
const normalized = normalizeInputPreferences({ pc: { jump: "Shift+Ctrl+KeyQ", inventory: "invalid", pause: null }, controller: { jump: "Button6&Button4&Button3" } });
expect(normalized.pc.jump).toBe("Ctrl+Shift+KeyQ");
expect(normalized.pc.inventory).toBe("KeyI");
expect(normalized.pc.pause).toBeNull();
expect(normalized.controller.jump).toBe("Button4&Button6&Button3");
const invalid = JSON.parse(exportUiPreferences(normalizeUiPreferences(null)));
invalid.keybinds.controller.jump = "Axis99+";
expect(() => importUiPreferences(JSON.stringify(invalid))).toThrow("Invalid keybind");
});
it("requires clearing a modifier's standalone action before using it in a controller chord", () => {
const bindings = normalizeInputPreferences(null).controller;
expect(conflictingInputActions(bindings, "controller", "jump", "Button8&Button0")).toEqual(["companion"]);
const changed = replaceInputBinding(bindings, "controller", "jump", "Button8&Button0");
expect(changed.companion).toBeNull();
expect(changed["ability:primary:face-bottom"]).toBe("Button0");
expect(conflictingInputActions(changed, "controller", "companion", "Button8")).toEqual(["jump"]);
expect(conflictingInputActions(bindings, "controller", "jump", "Button4")).toHaveLength(8);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { type InputBindings, type InputDevice, INPUT_ACTIONS, replaceInputBinding } from "../input/keybinds";
import { type UiPreferences } from "./uiLayout";
/** PC bar keys live with their UI layout so loading a template also loads its keys. */
export function profileInputBindings(preferences: UiPreferences, device: InputDevice): InputBindings {
if (device === "controller") return { ...preferences.keybinds.controller };
const result = { ...preferences.keybinds.pc };
preferences.current.pc.bars.forEach((bar, b) => bar.slots.forEach((slot, s) => { result[`bar:${b}:${s}`] = slot.key; }));
return result;
}
export function withProfileInputBindings(preferences: UiPreferences, device: InputDevice, bindings: InputBindings): UiPreferences {
const layout = preferences.current.pc;
return {
...preferences,
keybinds: { ...preferences.keybinds, [device]: { ...bindings } },
current: device !== "pc" ? preferences.current : { ...preferences.current, pc: {
...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({ ...slot, key: bindings[`bar:${b}:${s}`] ?? null })) })),
} },
};
}
export function restoreMovementBindings(preferences: UiPreferences, device: InputDevice): UiPreferences {
let bindings = profileInputBindings(preferences, device);
for (const entry of INPUT_ACTIONS.filter((entry) => entry.id.startsWith("move-") || entry.id === "jump")) {
bindings = replaceInputBinding(bindings, device, entry.id, entry[device]);
}
return withProfileInputBindings(preferences, device, bindings);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null;
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
interface PlayerSnapshot {
position: Vector3Tuple;
+2 -2
View File
@@ -37,7 +37,7 @@ describe("personal UI layouts", () => {
layout = bindDesktopSlot(layout, 4, 11, null);
expect(layout.bars[4].slots[11].key).toBeNull();
expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ");
expect(normalizeActionKey("Escape")).toBeNull();
expect(normalizeActionKey("Escape")).toBe("Escape");
expect(normalizeActionKey("ShiftLeft")).toBeNull();
});
it.each([
@@ -45,7 +45,7 @@ describe("personal UI layouts", () => {
["extra slots", (data: any) => { data.slots.mobile.push(null); }],
["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }],
["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }],
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }],
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
])("rejects %s without mutating the original preferences", (_, mutate) => {
+16 -1
View File
@@ -1,4 +1,5 @@
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
import { normalizeInputBinding, INPUT_ACTIONS, normalizeInputPreferences, type InputPreferences } from "../input/keybinds";
export type UiDevice = "mobile" | "pc";
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
@@ -31,6 +32,7 @@ export interface UiLayout {
export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences {
version: 1;
keybinds: InputPreferences;
current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>;
}
@@ -58,7 +60,7 @@ function record(value: unknown): Record<string, unknown> {
function bounded(value: unknown, fallback: number, min: number, max: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
}
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Escape|Backspace|Delete|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
export function normalizeActionKey(value: unknown): string | null {
if (typeof value !== "string") return null;
const parts = value.split("+");
@@ -112,6 +114,7 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
const slots = record(source.slots);
return {
version: 1,
keybinds: normalizeInputPreferences(source.keybinds),
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
@@ -163,6 +166,18 @@ export function importUiPreferences(text: string): UiPreferences {
}
}
};
if (source.keybinds !== undefined) {
if (!source.keybinds || typeof source.keybinds !== "object" || Array.isArray(source.keybinds)) throw new Error("Invalid keybind configuration.");
for (const device of ["pc", "controller"] as const) {
const raw = record(source.keybinds)[device];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid keybind configuration.");
const bindings = record(record(source.keybinds)[device]);
for (const entry of INPUT_ACTIONS) {
const key = bindings[entry.id];
if (key !== undefined && key !== null && !normalizeInputBinding(device, key)) throw new Error("Invalid keybind configuration.");
}
}
}
for (const device of UI_DEVICES) {
validateLayout(record(source.current)[device]);
const slots = record(source.slots)[device];
+3
View File
@@ -3,6 +3,7 @@ import { Capacitor } from "@capacitor/core";
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore";
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
import { publishInputPreferences } from "../input/keybindRuntime";
export function currentUiDevice(): UiDevice {
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
@@ -49,6 +50,8 @@ function activateOwner() {
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
}
activateOwner();
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
useUiLayoutStore.subscribe((state) => publishInputPreferences(state.preferences.keybinds));
useShellStore.subscribe((state, previous) => {
if (state.session !== previous.session) activateOwner();
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import type { CharacterProfile } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useArenaStore } from "./arenaStore";
import type { ArenaActorSnapshot } from "./arenaTypes";
/** Existing character panels read this view; the arena server owns combat. */
export function useArenaCombatView(character: CharacterProfile | null, actor: ArenaActorSnapshot | null) {
const mirroring = useRef(false);
useEffect(() => {
if (!character) return;
const previous = useCombatStore.getState();
const previousParty = usePartyStore.getState();
const previousGame = useGameStore.getState();
useCombatStore.getState().initializeCharacter(character);
usePartyStore.setState({ members: [] });
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
const unsubscribe = useCombatStore.subscribe((state, before) => {
if (mirroring.current) return;
if (state.actionBindings !== before.actionBindings) {
useArenaStore.getState().intent({ type: "bindings", actionBindings: state.actionBindings });
useShellStore.getState().saveActiveActionBindings(state.actionBindings);
}
if (state.settings !== before.settings) useShellStore.getState().saveActiveSettings(state.settings);
});
return () => {
unsubscribe();
useCombatStore.setState(previous, true);
usePartyStore.setState(previousParty, true);
useGameStore.setState(previousGame, true);
};
}, [character?.id]);
useEffect(() => {
if (!actor) return;
mirroring.current = true;
const runtime = actor.combat;
useCombatStore.setState({ health: actor.health, maxHealth: actor.maxHealth,
resource: actor.resource, maxResource: actor.maxResource, level: actor.level,
...(actor.actionBindings ? { actionBindings: actor.actionBindings } : {}),
...(runtime ? { resourcePools: runtime.resourcePools, maxResourcePools: runtime.maxResourcePools,
deathKnightRunes: runtime.runes, comboPoints: runtime.comboPoints, comboTargetId: runtime.comboTargetId,
shield: runtime.shield, activeCast: runtime.activeCast, talentRanks: runtime.talentRanks,
gearStats: runtime.gearStats, specializationId: runtime.specializationId } : {}) });
mirroring.current = false;
}, [actor]);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useRef } from "react";
import type { CharacterProfile } from "../app/types";
import { controlledArenaActor } from "./arenaPlayer";
import { useArenaStore } from "./arenaStore";
import { createArenaCharacterKit } from "./arenaCharacter";
import { actionBindingId, type ActionBindingLayer } from "./actionBindings";
import { activeUiLayout, currentUiDevice } from "./uiLayoutStore";
import { actionKeyForEvent, desktopSlotForKey } from "./uiLayout";
import { endMouseLookDrag, installInput, isDesktopCameraLookPointer } from "./inputManager";
import { useGameStore } from "./store";
export function useArenaInput(options: {
character: CharacterProfile | null;
blocked: boolean;
cast: (id: string) => void;
togglePause: () => void;
setLayer: (layer: ActionBindingLayer) => void;
setInputMode: (mode: "keyboard" | "gamepad") => void;
groundTargetActive: () => boolean;
confirmGroundTarget: () => void;
cancelGroundTarget: () => void;
}) {
const current = useRef(options);
current.current = options;
useEffect(() => {
const cycle = (direction: 1 | -1, friendly: boolean) => {
const state = useArenaStore.getState();
const actor = controlledArenaActor(state.snapshot, current.current.character);
if (!actor) return;
const targets = state.snapshot!.actors.filter((candidate) => candidate.alive && (friendly ? candidate.team === actor.team : candidate.team !== actor.team));
const index = targets.findIndex((candidate) => candidate.id === actor.targetActorId);
const selected = targets[(index + direction + targets.length) % targets.length];
if (selected) state.intent({ type: "target", targetActorId: selected.id });
};
const noop = () => {};
const uninstall = installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
castDesktopSlot: (bar, slot) => {
const id = activeUiLayout().bars[bar]?.slots[slot]?.abilityId;
if (id) current.current.cast(id);
},
handleCustomKey: (event) => {
if (current.current.blocked || currentUiDevice() !== "pc") return false;
const slot = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
if (!slot) return false;
if (!event.repeat && slot.abilityId) current.current.cast(slot.abilityId);
current.current.setInputMode("keyboard");
return true;
},
togglePause: () => current.current.togglePause(),
toggleMap: () => useGameStore.getState().toggleOverlay("map"),
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
toggleSpellbook: () => useGameStore.getState().toggleOverlay("spellbook"),
toggleTalents: () => useGameStore.getState().toggleOverlay("talents"),
togglePanel: (panel) => useGameStore.getState().toggleOverlay(panel),
lootActiveBoss: () => false,
castActionBinding: (layer, control) => {
const character = current.current.character;
if (!character) return;
const actor = controlledArenaActor(useArenaStore.getState().snapshot, character);
const bindings = actor?.actionBindings ?? createArenaCharacterKit(character).actionBindings;
const id = bindings[actionBindingId(layer, control)];
if (id) current.current.cast(id);
},
cycleTarget: (direction) => cycle(direction, false),
cyclePartyTarget: (direction) => cycle(direction, true),
setPartyCommand: (command) => useArenaStore.getState().intent({ type: "command", command }),
recallParty: () => useArenaStore.getState().intent({ type: "command", command: "recall" }),
clearTarget: () => useArenaStore.getState().intent({ type: "target", targetActorId: "" }),
groundTargetActive: () => current.current.groundTargetActive(),
confirmGroundTarget: () => current.current.confirmGroundTarget(),
cancelGroundTarget: () => current.current.cancelGroundTarget(),
gameplayActionsBlocked: () => current.current.blocked,
setCameraLookActive: noop,
setInputMode: (mode) => current.current.setInputMode(mode),
setActionLayer: (layer) => current.current.setLayer(layer),
});
const stopMovement = () => {
endMouseLookDrag();
useArenaStore.getState().intent({ type: "movement", movement: { x: 0, z: 0 } });
};
const mouseUp = (event: MouseEvent) => { if (isDesktopCameraLookPointer("mouse", event.button)) endMouseLookDrag(); };
window.addEventListener("mouseup", mouseUp, true);
window.addEventListener("blur", stopMovement);
return () => {
window.removeEventListener("mouseup", mouseUp, true);
window.removeEventListener("blur", stopMovement);
stopMovement();
uninstall();
};
}, []);
useEffect(() => {
if (options.blocked) {
endMouseLookDrag();
useArenaStore.getState().intent({ type: "movement", movement: { x: 0, z: 0 } });
}
}, [options.blocked]);
}