diff --git a/package-lock.json b/package-lock.json index 974b4130..2f04effc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.16", + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.16", + "version": "0.1.17", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 1fd7be0e..1d567a6a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.16", + "version": "0.1.17", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index f73d321d..54d3ea0c 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.16", - "versionCode": 1016 + "versionName": "0.1.17", + "versionCode": 1017 } diff --git a/server/arena-simulation.d.mts b/server/arena-simulation.d.mts index 73acead2..ef911504 100644 --- a/server/arena-simulation.d.mts +++ b/server/arena-simulation.d.mts @@ -39,6 +39,7 @@ export declare const ARENA_TICK_MS: 50; export declare class ArenaSimulation { constructor(options: ArenaSimulationOptions); start(): this; + resetPreparationClock(startedAt?: number): boolean; stop(): void; step(): void; snapshot(): ArenaAuthoritativeSnapshot; diff --git a/server/arena-simulation.mjs b/server/arena-simulation.mjs index 21013a93..0343040c 100644 --- a/server/arena-simulation.mjs +++ b/server/arena-simulation.mjs @@ -156,6 +156,13 @@ export class ArenaSimulation { return this; } + resetPreparationClock(startedAt = Date.now()) { + if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false; + this.startedAt = Number(startedAt); + this.phaseEndsAt = this.startedAt + 10_000; + return true; + } + stop() { if (this.interval) clearInterval(this.interval); this.interval = null; diff --git a/server/arena-simulation.test.mjs b/server/arena-simulation.test.mjs index 1a629c0c..a309ede6 100644 --- a/server/arena-simulation.test.mjs +++ b/server/arena-simulation.test.mjs @@ -26,6 +26,14 @@ test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplic assert.ok(simulation.actor("left-healer").position[0] > before); }); +test("a deferred practice start receives a fresh preparation clock", () => { + const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena"); + const simulation = new ArenaSimulation({ id: "deferred", stage, bracket: "2v2", mode: "practice", ruleset: "wow", participants: participants(), startedAt: 1_000 }); + assert.equal(simulation.resetPreparationClock(20_000), true); + assert.equal(simulation.snapshot().serverTime, 20_000); + assert.equal(simulation.snapshot().phaseEndsAt, 30_000); +}); + test("Dalaran and Ring of Valor mechanics synchronize collision state", () => { const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers"); const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall"); diff --git a/src/ArenaRuntime.tsx b/src/ArenaRuntime.tsx index 79785005..399749d5 100644 --- a/src/ArenaRuntime.tsx +++ b/src/ArenaRuntime.tsx @@ -1,7 +1,7 @@ -import { Clone } from "@react-three/drei"; +import { Clone, useProgress } from "@react-three/drei"; import { Canvas, useFrame } from "@react-three/fiber"; -import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react"; -import { FrontSide, Group, Mesh, Vector3 } from "three"; +import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"; +import { Group, Vector3 } from "three"; import { selectedCharacter, useShellStore } from "./app/shellStore"; import { CharacterModel } from "./avatar/CharacterModel"; import { ProceduralAvatar } from "./avatar/ProceduralAvatar"; @@ -12,23 +12,46 @@ import { arenaStageById } from "./game/arenaStages"; import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes"; import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF"; -function StageEnvironment({ stage }: { stage: ArenaStageManifest }) { - const visual = stage.assets.find((asset) => asset.id === "visual")?.url; - return visual ? : ; +class ArenaStageErrorBoundary extends Component<{ + readonly children: ReactNode; + readonly fallback: ReactNode; + readonly onError: (error: unknown) => void; +}, { readonly failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: unknown) { + this.props.onError(error); + } + + render() { + return this.state.failed ? this.props.fallback : this.props.children; + } } -function LoadedStage({ url }: { url: string }) { +function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onReady: () => void }) { + const visual = stage.assets.find((asset) => asset.id === "visual")?.url; + return ( + <> + + {visual ? : } + + ); +} + +function StageReady({ onReady }: { readonly onReady: () => void }) { + useEffect(onReady, [onReady]); + return null; +} + +function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) { const gltf = useGameGLTF(`${url}?arena-runtime=1`); useEffect(() => { - gltf.scene.traverse((object) => { - if (!(object instanceof Mesh)) return; - const materials = Array.isArray(object.material) ? object.material : [object.material]; - for (const material of materials) { - material.side = FrontSide; - material.needsUpdate = true; - } - }); - }, [gltf.scene]); + onReady(); + }, [gltf.scene, onReady]); return ; } @@ -37,11 +60,12 @@ function ProceduralStage({ stage }: { stage: ArenaStageManifest }) { const depth = stage.bounds.max[2] - stage.bounds.min[2]; const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2; const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2; + const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175; return ( - - - + + + {stage.lineOfSight.map((object) => ( @@ -157,15 +181,26 @@ function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage return null; } -function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType; movement: RefObject<[number, number]> }) { +function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType; movement: RefObject<[number, number]>; onStageReady: () => void; onStageError: (error: unknown) => void }) { return ( - + 3D arena unavailableThis device could not start WebGL. Return to Arena Command and try another renderer.} + > - + } + onError={onStageError} + > + }> + {actors.map((actor) => )} @@ -174,6 +209,25 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move ); } +function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: ArenaStageManifest; readonly ready: boolean; readonly fallbackReason: string | null }) { + const { active, progress } = useProgress(); + if (ready && !active && !fallbackReason) return null; + const value = Math.max(3, Math.min(100, Math.round(progress))); + return ( +
+ {fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`} + {fallbackReason ? ( + The detailed arena asset could not be decoded, so a collision-matched fallback is active. + ) : ( + <> + + Streaming arena geometry · {value}% + + )} +
+ ); +} + function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) { return (
@@ -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,