@@ -194,11 +248,36 @@ export default function ArenaRuntime() {
const intent = useArenaStore((state) => state.intent);
const leave = useArenaStore((state) => state.leave);
const load = useArenaStore((state) => state.load);
+ const startPractice = useArenaStore((state) => state.startPractice);
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const movementRef = useRef<[number, number]>([0, 0]);
+ const [loadedStageId, setLoadedStageId] = useState
(null);
+ const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
+ const handleStageReady = useCallback(() => {
+ if (!stage) return;
+ setLoadedStageId(stage.id);
+ setStageFailure((current) => current?.stageId === stage.id ? null : current);
+ startPractice();
+ }, [stage, startPractice]);
+ const handleStageError = useCallback((error: unknown) => {
+ if (!stage) return;
+ setLoadedStageId(stage.id);
+ setStageFailure({ stageId: stage.id, reason: error instanceof Error ? error.message : "Arena asset failed to load" });
+ startPractice();
+ }, [stage, startPractice]);
+
+ useEffect(() => {
+ if (!stage || loadedStageId === stage.id) return;
+ const timeout = window.setTimeout(() => {
+ setLoadedStageId(stage.id);
+ setStageFailure({ stageId: stage.id, reason: "Arena asset loading timed out" });
+ startPractice();
+ }, 30_000);
+ return () => window.clearTimeout(timeout);
+ }, [loadedStageId, stage, startPractice]);
useEffect(() => {
if (snapshot || !character) return;
@@ -254,7 +333,8 @@ export default function ArenaRuntime() {
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
return (
-
+
+
{stage.name}{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}Dampening {Math.round(snapshot.dampening * 100)}%
diff --git a/src/game/arenaPractice.ts b/src/game/arenaPractice.ts
index 622ed142..76be5a0e 100644
--- a/src/game/arenaPractice.ts
+++ b/src/game/arenaPractice.ts
@@ -55,9 +55,15 @@ export function createArenaPractice(options: PracticeOptions) {
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
});
- simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
+ let started = false;
return {
+ start() {
+ if (started) return;
+ started = true;
+ simulation.resetPreparationClock();
+ simulation.start();
+ },
intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent);
},
diff --git a/src/game/arenaStore.ts b/src/game/arenaStore.ts
index 994c58fd..187dda75 100644
--- a/src/game/arenaStore.ts
+++ b/src/game/arenaStore.ts
@@ -41,6 +41,7 @@ interface ArenaStoreState {
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise;
ready: (session: PlayerSession) => Promise;
+ startPractice: () => void;
leave: (session: PlayerSession | null) => Promise;
intent: (intent: Omit) => void;
resetResult: () => void;
@@ -221,6 +222,8 @@ export const useArenaStore = create((set, get) => ({
catch (error) { set({ error: messageFor(error) }); }
},
+ startPractice: () => get().practice?.start(),
+
leave: async (session) => {
get().practice?.stop();
get().socket?.close();
diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts
index 98bf2bb3..0feb5204 100644
--- a/src/game/combatStore.test.ts
+++ b/src/game/combatStore.test.ts
@@ -13,6 +13,7 @@ import {
MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS,
PLAYER_AURA_ENTITY_ID,
+ resolvedAbilityCosts,
selectedMob,
useCombatStore,
} from "./combatStore";
@@ -97,6 +98,32 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
+ it("uses the displayed primary mana and resolved percentage cost for affordability", () => {
+ useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
+ const initial = useCombatStore.getState();
+ const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt");
+ expect(frostbolt).toBeDefined();
+ const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial);
+ expect(cost).toEqual({ resource: "mana", amount: Math.round(initial.maxResource * 0.14) });
+ registerTarget("mana-target", [10, 0, 0]);
+
+ useCombatStore.setState({
+ resource: cost.amount,
+ resourcePools: { ...initial.resourcePools, mana: 0 },
+ });
+ expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 1_000)).toMatchObject({ ok: true });
+ useCombatStore.getState().cancelCast();
+
+ useCombatStore.setState({
+ resource: cost.amount - 1,
+ resourcePools: { ...initial.resourcePools, mana: initial.maxResource },
+ });
+ expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 3_000)).toMatchObject({
+ ok: false,
+ reason: "not-enough-resource",
+ });
+ });
+
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]);
diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts
index 84cc76cb..74045f11 100644
--- a/src/game/combatStore.ts
+++ b/src/game/combatStore.ts
@@ -1504,16 +1504,19 @@ function initialResourcePools(
return { pools, maximums };
}
-function abilityCosts(
+export function resolvedAbilityCosts(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
- state: Pick & Partial>,
+ state: Pick & Partial>,
): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map();
+ const activeResourceType = resourceProfileForClass(state.classId).type;
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const maximum = cost.resource === "health"
? state.maxHealth
- : state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
+ : cost.resource === activeResourceType
+ ? state.maxResource
+ : state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
baseAmount,
@@ -3866,7 +3869,7 @@ export const useCombatStore = create((set, get) => ({
: ability?.target;
const modifiers = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
- const costs = ability ? abilityCosts(ability, modifiers, state) : [];
+ const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
@@ -3895,7 +3898,9 @@ export const useCombatStore = create((set, get) => ({
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
else if (costs.some((cost) => cost.resource === "health"
? state.health <= cost.amount
- : (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource";
+ : (cost.resource === resourceProfileForClass(state.classId).type
+ ? state.resource
+ : state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
const playerOrigin = currentOrigin(state, origin);
@@ -4060,7 +4065,11 @@ export const useCombatStore = create((set, get) => ({
state.classId,
);
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
- const spentPools = { ...state.resourcePools };
+ const activeResourceType = resourceProfileForClass(state.classId).type;
+ const spentPools: Partial> = {
+ ...state.resourcePools,
+ [activeResourceType]: state.resource,
+ };
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
let healthAfterCosts = state.health;
for (const cost of costsToSpend) {
@@ -4073,7 +4082,6 @@ export const useCombatStore = create((set, get) => ({
const spentRunes = runeCost && completingCast?.mode !== "channel"
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
: state.deathKnightRunes;
- const activeResourceType = resourceProfileForClass(state.classId).type;
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
if (!completingCast && castTimeMs > 0) {
castSequence += 1;
diff --git a/src/styles.css b/src/styles.css
index 77d4f10a..77317901 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -3327,6 +3327,51 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.arena-world,
.arena-world > div { position: absolute; inset: 0; }
+.arena-render-fallback {
+ display: grid;
+ place-content: center;
+ gap: .4rem;
+ padding: 2rem;
+ color: #e8eee4;
+ text-align: center;
+ background: radial-gradient(circle at center, #17251f, #07100d 68%);
+}
+
+.arena-render-fallback small { max-width: 32rem; color: #b9c4b6; }
+
+.arena-asset-status {
+ position: absolute;
+ z-index: 3;
+ top: 50%;
+ left: 50%;
+ display: grid;
+ width: min(24rem, calc(100vw - 2rem));
+ gap: .45rem;
+ padding: .85rem 1rem;
+ border: 1px solid rgba(220, 190, 99, .5);
+ text-align: center;
+ background: rgba(6, 13, 10, .92);
+ transform: translate(-50%, -50%);
+ box-shadow: 0 1rem 4rem rgba(0, 0, 0, .45);
+ pointer-events: none;
+}
+
+.arena-asset-status > span {
+ overflow: hidden;
+ height: .32rem;
+ background: rgba(216, 185, 98, .16);
+}
+
+.arena-asset-status > span i {
+ display: block;
+ height: 100%;
+ background: #d8b962;
+ transition: width .15s ease-out;
+}
+
+.arena-asset-status small { color: #b9c4b6; }
+.arena-asset-status--fallback { top: auto; bottom: 5.3rem; transform: translateX(-50%); }
+
.arena-match-header {
position: absolute;
top: .7rem;
diff --git a/src/ui/Hud.tsx b/src/ui/Hud.tsx
index 6a56712c..39cfb9b4 100644
--- a/src/ui/Hud.tsx
+++ b/src/ui/Hud.tsx
@@ -4,7 +4,7 @@ import { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
-import { useCombatStore } from "../game/combatStore";
+import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession";
@@ -137,9 +137,12 @@ export function Hud() {
const resource = useCombatStore((state) => state.resource);
const maxResource = useCombatStore((state) => state.maxResource);
const resourcePools = useCombatStore((state) => state.resourcePools);
+ const maxResourcePools = useCombatStore((state) => state.maxResourcePools);
const resourceName = useCombatStore((state) => state.resourceName);
const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks);
+ const talentModifiers = useCombatStore((state) => state.talentModifiers);
+ const auras = useCombatStore((state) => state.auras);
const actionBindings = useCombatStore((state) => state.actionBindings);
const cooldowns = useCombatStore((state) => state.cooldowns);
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
@@ -190,11 +193,17 @@ export function Hud() {
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
const remaining = Math.max(personalRemaining, globalRemaining);
- const abilityCosts = ability.costs?.length ? ability.costs : [ability.cost];
+ const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
+ classId,
+ maxHealth,
+ maxResource,
+ maxResourcePools,
+ auras,
+ });
const cannotPay = abilityCosts.some((cost) => cost.resource === "health"
? health <= cost.amount
- : (resourcePools[cost.resource] ?? (cost.resource === resourceTone ? resource : 0)) < cost.amount);
- const displayCost = abilityCosts[0] ?? ability.cost;
+ : (cost.resource === resourceTone ? resource : resourcePools[cost.resource] ?? 0) < cost.amount);
+ const displayCost = abilityCosts[0] ?? { resource: ability.cost.resource, amount: 0 };
const item: ActionBarItem = {
id: ability.id,
name: ability.name,
@@ -211,7 +220,7 @@ export function Hud() {
target: ability.target,
};
return [control, item];
- })) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, classColor, clock, cooldowns, globalCooldownEndsAt, health, level, resource, resourceName, resourcePools, resourceTone, talentRanks]);
+ })) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
useGameStore.getState().actionLayer,