forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9bebb4e81 | ||
|
|
eaddad4134 | ||
|
|
fc8a7a722a |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.16",
|
"version": "0.1.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.16",
|
"version": "0.1.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.16",
|
"version": "0.1.19",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.16",
|
"versionName": "0.1.19",
|
||||||
"versionCode": 1016
|
"versionCode": 1019
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,10 @@ const sources = [
|
|||||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||||
|
logicalPrefix: "assets/game/arenas",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ const sources = [
|
|||||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||||
|
logicalPrefix: "assets/game/arenas",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([
|
|||||||
"core:equipment",
|
"core:equipment",
|
||||||
"core:character-basic",
|
"core:character-basic",
|
||||||
"core:runtime",
|
"core:runtime",
|
||||||
|
"core:arenas",
|
||||||
"dungeon:wailing-caverns",
|
"dungeon:wailing-caverns",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ function packLabel(packId) {
|
|||||||
if (packId === "core:equipment") return "Starter Equipment";
|
if (packId === "core:equipment") return "Starter Equipment";
|
||||||
if (packId === "core:character-basic") return "Basic Human Character Model";
|
if (packId === "core:character-basic") return "Basic Human Character Model";
|
||||||
if (packId === "core:runtime") return "Game Runtime";
|
if (packId === "core:runtime") return "Game Runtime";
|
||||||
|
if (packId === "core:arenas") return "Rated Arenas";
|
||||||
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
@@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) {
|
|||||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||||
return `dungeon:${segments[3]}`;
|
return `dungeon:${segments[3]}`;
|
||||||
}
|
}
|
||||||
|
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
|
||||||
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
||||||
return `manastorm:${segments[3]}`;
|
return `manastorm:${segments[3]}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ test("assigns stable player-facing packs from logical asset paths", () => {
|
|||||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
|
||||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
|
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
|
||||||
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
|
||||||
|
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
|
||||||
|
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
|
||||||
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
|
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export declare const ARENA_TICK_MS: 50;
|
|||||||
export declare class ArenaSimulation {
|
export declare class ArenaSimulation {
|
||||||
constructor(options: ArenaSimulationOptions);
|
constructor(options: ArenaSimulationOptions);
|
||||||
start(): this;
|
start(): this;
|
||||||
|
resetPreparationClock(startedAt?: number): boolean;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
step(): void;
|
step(): void;
|
||||||
snapshot(): ArenaAuthoritativeSnapshot;
|
snapshot(): ArenaAuthoritativeSnapshot;
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ export class ArenaSimulation {
|
|||||||
return this;
|
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() {
|
stop() {
|
||||||
if (this.interval) clearInterval(this.interval);
|
if (this.interval) clearInterval(this.interval);
|
||||||
this.interval = null;
|
this.interval = null;
|
||||||
|
|||||||
@@ -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);
|
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", () => {
|
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
|
||||||
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
|
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
|
||||||
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
|
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
|
||||||
|
|||||||
+138
-40
@@ -1,56 +1,104 @@
|
|||||||
import { Clone } from "@react-three/drei";
|
import { Clone, useProgress } from "@react-three/drei";
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react";
|
import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||||
import { FrontSide, Group, Mesh, Vector3 } from "three";
|
import { Group, Vector3 } from "three";
|
||||||
import { selectedCharacter, useShellStore } from "./app/shellStore";
|
import { selectedCharacter, useShellStore } from "./app/shellStore";
|
||||||
import { CharacterModel } from "./avatar/CharacterModel";
|
import { CharacterModel } from "./avatar/CharacterModel";
|
||||||
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
|
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
|
||||||
import { classById } from "./app/characterCatalog";
|
import { classById } from "./app/characterCatalog";
|
||||||
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
|
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
|
||||||
import { useArenaStore } from "./game/arenaStore";
|
import { useArenaStore } from "./game/arenaStore";
|
||||||
|
import { arenaCameraPose } from "./game/arenaCamera";
|
||||||
import { arenaStageById } from "./game/arenaStages";
|
import { arenaStageById } from "./game/arenaStages";
|
||||||
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
|
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
|
||||||
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
|
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
|
||||||
|
|
||||||
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) {
|
class ArenaStageErrorBoundary extends Component<{
|
||||||
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
|
readonly children: ReactNode;
|
||||||
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />;
|
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 (
|
||||||
|
<>
|
||||||
|
<ProceduralStage stage={stage} floorOnly />
|
||||||
|
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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`);
|
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gltf.scene.traverse((object) => {
|
onReady();
|
||||||
if (!(object instanceof Mesh)) return;
|
}, [gltf.scene, onReady]);
|
||||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
||||||
for (const material of materials) {
|
|
||||||
material.side = FrontSide;
|
|
||||||
material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [gltf.scene]);
|
|
||||||
return <Clone object={gltf.scene} />;
|
return <Clone object={gltf.scene} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
|
function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) {
|
||||||
const width = stage.bounds.max[0] - stage.bounds.min[0];
|
const width = stage.bounds.max[0] - stage.bounds.min[0];
|
||||||
const depth = stage.bounds.max[2] - stage.bounds.min[2];
|
const depth = stage.bounds.max[2] - stage.bounds.min[2];
|
||||||
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
|
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
|
||||||
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
|
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
|
||||||
|
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
|
||||||
|
const wallHeight = 2.4;
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
|
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
|
||||||
<boxGeometry args={[width, 0.5, depth]} />
|
<boxGeometry args={[width, 0.25, depth]} />
|
||||||
<meshStandardMaterial color="#252b27" roughness={0.9} />
|
<meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{stage.lineOfSight.map((object) => (
|
{!floorOnly && (
|
||||||
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
<>
|
||||||
{object.shape === "cylinder"
|
<gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
|
||||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
{[
|
||||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
[centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
|
||||||
<meshStandardMaterial color="#4e5146" roughness={0.78} />
|
[centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
|
||||||
</mesh>
|
[stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
|
||||||
))}
|
[stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
|
||||||
|
].map(([x, y, z, sizeX, sizeY, sizeZ], index) => (
|
||||||
|
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
|
||||||
|
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
|
||||||
|
<meshStandardMaterial color="#26342d" roughness={0.88} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{stage.teamSpawns.map((spawn, index) => (
|
||||||
|
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
|
||||||
|
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
|
||||||
|
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{stage.lineOfSight.map((object) => (
|
||||||
|
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
||||||
|
{object.shape === "cylinder"
|
||||||
|
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||||
|
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||||
|
<meshStandardMaterial color="#6a6f5e" roughness={0.78} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,31 +189,35 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
|
|||||||
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
|
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
|
||||||
const target = useMemo(() => new Vector3(), []);
|
const target = useMemo(() => new Vector3(), []);
|
||||||
const desired = useMemo(() => new Vector3(), []);
|
const desired = useMemo(() => new Vector3(), []);
|
||||||
|
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
|
||||||
useFrame(({ camera }, delta) => {
|
useFrame(({ camera }, delta) => {
|
||||||
if (!actor) return;
|
if (!actor) return;
|
||||||
const spawn = stage.teamSpawns[actor.team];
|
target.set(...pose.target);
|
||||||
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
|
desired.set(...pose.position);
|
||||||
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
|
|
||||||
const directionX = centerX - spawn[0];
|
|
||||||
const directionZ = centerZ - spawn[2];
|
|
||||||
const length = Math.max(0.001, Math.hypot(directionX, directionZ));
|
|
||||||
target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]);
|
|
||||||
desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5);
|
|
||||||
camera.position.lerp(desired, Math.min(1, delta * 5));
|
camera.position.lerp(desired, Math.min(1, delta * 5));
|
||||||
camera.lookAt(target);
|
camera.lookAt(target);
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) {
|
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
|
||||||
return (
|
return (
|
||||||
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
|
<Canvas
|
||||||
|
shadows
|
||||||
|
dpr={[1, 1.35]}
|
||||||
|
camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
|
||||||
|
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
|
||||||
|
>
|
||||||
<color attach="background" args={["#111815"]} />
|
<color attach="background" args={["#111815"]} />
|
||||||
<fog attach="fog" args={["#111815", 55, 240]} />
|
<fog attach="fog" args={["#111815", 55, 240]} />
|
||||||
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
|
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
|
||||||
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
|
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
|
||||||
<GameGltfLoaderLifecycle>
|
<GameGltfLoaderLifecycle>
|
||||||
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense>
|
{detailedAsset ? (
|
||||||
|
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
|
||||||
|
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
|
||||||
|
</ArenaStageErrorBoundary>
|
||||||
|
) : <ProceduralStage stage={stage} />}
|
||||||
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
|
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
|
||||||
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
|
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
|
||||||
<ArenaCamera actor={localActor} stage={stage} />
|
<ArenaCamera actor={localActor} stage={stage} />
|
||||||
@@ -174,6 +226,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 (
|
||||||
|
<section className={`arena-asset-status${fallbackReason ? " arena-asset-status--fallback" : ""}`} role="status" aria-live="polite">
|
||||||
|
<strong>{fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`}</strong>
|
||||||
|
{fallbackReason ? (
|
||||||
|
<small>The detailed arena asset could not be decoded, so a collision-matched fallback is active.</small>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span><i style={{ width: `${value}%` }} /></span>
|
||||||
|
<small>Streaming arena geometry · {value}%</small>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
|
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
|
||||||
return (
|
return (
|
||||||
<div className={`arena-team-frames arena-team-frames--${team}`}>
|
<div className={`arena-team-frames arena-team-frames--${team}`}>
|
||||||
@@ -194,11 +265,36 @@ export default function ArenaRuntime() {
|
|||||||
const intent = useArenaStore((state) => state.intent);
|
const intent = useArenaStore((state) => state.intent);
|
||||||
const leave = useArenaStore((state) => state.leave);
|
const leave = useArenaStore((state) => state.leave);
|
||||||
const load = useArenaStore((state) => state.load);
|
const load = useArenaStore((state) => state.load);
|
||||||
|
const startPractice = useArenaStore((state) => state.startPractice);
|
||||||
const session = useShellStore((state) => state.session);
|
const session = useShellStore((state) => state.session);
|
||||||
const character = useShellStore(selectedCharacter);
|
const character = useShellStore(selectedCharacter);
|
||||||
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
|
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
|
||||||
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
|
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
|
||||||
const movementRef = useRef<[number, number]>([0, 0]);
|
const movementRef = useRef<[number, number]>([0, 0]);
|
||||||
|
const [loadedStageId, setLoadedStageId] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
if (snapshot || !character) return;
|
if (snapshot || !character) return;
|
||||||
@@ -252,9 +348,11 @@ export default function ArenaRuntime() {
|
|||||||
|
|
||||||
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
|
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
|
||||||
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
|
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
|
||||||
|
const detailedAsset = stageFailure?.stageId !== stage.id;
|
||||||
return (
|
return (
|
||||||
<main className="arena-runtime">
|
<main className="arena-runtime">
|
||||||
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div>
|
<div className="arena-world" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
|
||||||
|
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
|
||||||
<header className="arena-match-header">
|
<header className="arena-match-header">
|
||||||
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
|
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const STATIC_ANDROID_BUNDLED_PACKS = new Set([
|
|||||||
"core:equipment",
|
"core:equipment",
|
||||||
"core:character-basic",
|
"core:character-basic",
|
||||||
"core:runtime",
|
"core:runtime",
|
||||||
|
"core:arenas",
|
||||||
"dungeon:wailing-caverns",
|
"dungeon:wailing-caverns",
|
||||||
]);
|
]);
|
||||||
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
||||||
@@ -23,6 +24,7 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
|
|||||||
"core:equipment",
|
"core:equipment",
|
||||||
"core:character-basic",
|
"core:character-basic",
|
||||||
"core:runtime",
|
"core:runtime",
|
||||||
|
"core:arenas",
|
||||||
"dungeon:wailing-caverns",
|
"dungeon:wailing-caverns",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export function PartyBridge() {
|
|||||||
character.categoryId ?? "wow",
|
character.categoryId ?? "wow",
|
||||||
);
|
);
|
||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
|
combat.synchronizeActors();
|
||||||
|
combat.applyDefaultPartyBuffs();
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||||
|
|
||||||
@@ -44,6 +46,8 @@ export function PartyBridge() {
|
|||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
party.syncLevel(level);
|
party.syncLevel(level);
|
||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
|
combat.synchronizeActors();
|
||||||
|
combat.applyDefaultPartyBuffs();
|
||||||
}, [level]);
|
}, [level]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,6 +56,8 @@ export function PartyBridge() {
|
|||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
usePartyStore.getState().resetCombat(combat.level, combat.health);
|
||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
|
combat.synchronizeActors();
|
||||||
|
combat.applyDefaultPartyBuffs();
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [resetRevision]);
|
}, [resetRevision]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { arenaCameraPose } 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", () => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ArenaStageManifest } from "./arenaTypes";
|
||||||
|
|
||||||
|
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]));
|
||||||
|
return {
|
||||||
|
position: [...stage.spectatorPositions[team]],
|
||||||
|
target: [centerX, floorY + 1.2, centerZ],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -55,9 +55,15 @@ export function createArenaPractice(options: PracticeOptions) {
|
|||||||
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
|
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
|
||||||
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
|
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
|
||||||
});
|
});
|
||||||
simulation.start();
|
|
||||||
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
|
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
|
||||||
|
let started = false;
|
||||||
return {
|
return {
|
||||||
|
start() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
simulation.resetPreparationClock();
|
||||||
|
simulation.start();
|
||||||
|
},
|
||||||
intent(intent: ArenaCombatIntent) {
|
intent(intent: ArenaCombatIntent) {
|
||||||
return simulation.receiveIntent("practice-player", intent);
|
return simulation.receiveIntent("practice-player", intent);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface ArenaStoreState {
|
|||||||
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
|
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
|
||||||
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
|
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
|
||||||
ready: (session: PlayerSession) => Promise<void>;
|
ready: (session: PlayerSession) => Promise<void>;
|
||||||
|
startPractice: () => void;
|
||||||
leave: (session: PlayerSession | null) => Promise<void>;
|
leave: (session: PlayerSession | null) => Promise<void>;
|
||||||
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
|
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
|
||||||
resetResult: () => void;
|
resetResult: () => void;
|
||||||
@@ -221,6 +222,8 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
|
|||||||
catch (error) { set({ error: messageFor(error) }); }
|
catch (error) { set({ error: messageFor(error) }); }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startPractice: () => get().practice?.start(),
|
||||||
|
|
||||||
leave: async (session) => {
|
leave: async (session) => {
|
||||||
get().practice?.stop();
|
get().practice?.stop();
|
||||||
get().socket?.close();
|
get().socket?.close();
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ describe("named aura combat runtime", () => {
|
|||||||
|
|
||||||
it("applies and expires an imported named player buff", () => {
|
it("applies and expires an imported named player buff", () => {
|
||||||
const now = 10_000;
|
const now = 10_000;
|
||||||
|
const baseHealth = useCombatStore.getState().health;
|
||||||
|
const baseMaxHealth = useCombatStore.getState().maxHealth;
|
||||||
const result = useCombatStore.getState().castAbility(
|
const result = useCombatStore.getState().castAbility(
|
||||||
"wow335-priest-power-word-fortitude",
|
"wow335-priest-power-word-fortitude",
|
||||||
undefined,
|
undefined,
|
||||||
@@ -46,9 +48,49 @@ describe("named aura combat runtime", () => {
|
|||||||
))?.icon,
|
))?.icon,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(baseMaxHealth);
|
||||||
|
expect(useCombatStore.getState().health - baseHealth).toBe(
|
||||||
|
useCombatStore.getState().maxHealth - baseMaxHealth,
|
||||||
|
);
|
||||||
|
|
||||||
useCombatStore.getState().tick(0, now + 1_800_000);
|
useCombatStore.getState().tick(0, now + 1_800_000);
|
||||||
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === fortitude?.definition.id)).toBe(false);
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === fortitude?.definition.id)).toBe(false);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants learned long-term stat buffs to the full party on load", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 });
|
||||||
|
usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const playerMaxHealth = useCombatStore.getState().maxHealth;
|
||||||
|
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
|
||||||
|
|
||||||
|
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
|
||||||
|
|
||||||
|
const state = useCombatStore.getState();
|
||||||
|
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
|
||||||
|
for (const targetId of targetIds) {
|
||||||
|
expect(state.auras).toContainEqual(expect.objectContaining({
|
||||||
|
targetId,
|
||||||
|
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
expect(state.maxHealth).toBeGreaterThan(playerMaxHealth);
|
||||||
|
for (const member of usePartyStore.getState().members) {
|
||||||
|
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffedPlayerMaxHealth = state.maxHealth;
|
||||||
|
expect(state.auras.filter((aura) => (
|
||||||
|
aura.targetId === PLAYER_AGGRO_ID
|
||||||
|
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().maxHealth).toBe(buffedPlayerMaxHealth);
|
||||||
|
expect(useCombatStore.getState().auras.filter((aura) => (
|
||||||
|
aura.targetId === PLAYER_AGGRO_ID
|
||||||
|
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
|
||||||
|
))).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("consumes a named absorb before player health", () => {
|
it("consumes a named absorb before player health", () => {
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ describe("authentic combat resources", () => {
|
|||||||
expect(outsideRule.current).toBe(62);
|
expect(outsideRule.current).toBe(62);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("regenerates at least fifteen percent mana per five seconds out of combat", () => {
|
||||||
|
const mana = normalizeActorResourcePool("mana", 1_000, 3_000, 4, 9_000);
|
||||||
|
const regenerated = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: false,
|
||||||
|
actorLevel: 8,
|
||||||
|
now: 10_000,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 3,
|
||||||
|
});
|
||||||
|
expect(regenerated.current).toBe(1_090);
|
||||||
|
});
|
||||||
|
|
||||||
it("decays rage and runic power only out of combat", () => {
|
it("decays rage and runic power only out of combat", () => {
|
||||||
const rage = normalizeActorResourcePool("rage", 20, 100);
|
const rage = normalizeActorResourcePool("rage", 20, 100);
|
||||||
const runic = normalizeActorResourcePool("runic-power", 20, 100);
|
const runic = normalizeActorResourcePool("runic-power", 20, 100);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "./combatActors";
|
} from "./combatActors";
|
||||||
|
|
||||||
export const MANA_FIVE_SECOND_RULE_MS = 5_000;
|
export const MANA_FIVE_SECOND_RULE_MS = 5_000;
|
||||||
|
export const OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS = 0.15;
|
||||||
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
|
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
|
||||||
|
|
||||||
export interface CombatResourceAdvanceRules {
|
export interface CombatResourceAdvanceRules {
|
||||||
@@ -24,6 +25,9 @@ export function advanceCombatResourcePool(
|
|||||||
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
|
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
|
||||||
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
|
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
|
||||||
}
|
}
|
||||||
|
if (pool.id === "mana" && !rules.inCombat) {
|
||||||
|
rate = Math.max(rate, pool.maximum * OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS / 5);
|
||||||
|
}
|
||||||
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
|
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
|
||||||
rate = pool.id === "rage" ? -3 : -2.5;
|
rate = pool.id === "rage" ? -3 : -2.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
MINIMUM_CORPSE_VISIBLE_MS,
|
MINIMUM_CORPSE_VISIBLE_MS,
|
||||||
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
||||||
PLAYER_AURA_ENTITY_ID,
|
PLAYER_AURA_ENTITY_ID,
|
||||||
|
resolvedAbilityCosts,
|
||||||
selectedMob,
|
selectedMob,
|
||||||
useCombatStore,
|
useCombatStore,
|
||||||
} from "./combatStore";
|
} from "./combatStore";
|
||||||
@@ -20,7 +21,7 @@ import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
|
|||||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
|
||||||
import { defaultActionBindings } from "./actionBindings";
|
import { defaultActionBindings } from "./actionBindings";
|
||||||
import { deriveCharacterStats } from "./wotlkStats";
|
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ describe("combat store", () => {
|
|||||||
const first = useCombatStore.getState().castSlot(0, undefined, 1000);
|
const first = useCombatStore.getState().castSlot(0, undefined, 1000);
|
||||||
expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 });
|
expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 });
|
||||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||||
expect(startingResource).toBe(165);
|
expect(startingResource).toBe(165 * MANA_POOL_MULTIPLIER);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource);
|
expect(useCombatStore.getState().resource).toBe(startingResource);
|
||||||
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" });
|
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" });
|
||||||
|
|
||||||
@@ -92,11 +93,42 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||||
useCombatStore.getState().tick(0.1, 2500);
|
useCombatStore.getState().tick(0.1, 2500);
|
||||||
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14));
|
expect(useCombatStore.getState().resource).toBe(
|
||||||
|
startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.14),
|
||||||
|
);
|
||||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
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 / MANA_POOL_MULTIPLIER * 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", () => {
|
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||||
registerTarget("presentation-target", [10, 0, 0]);
|
registerTarget("presentation-target", [10, 0, 0]);
|
||||||
@@ -181,8 +213,8 @@ describe("combat store", () => {
|
|||||||
const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human");
|
const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human");
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 });
|
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 });
|
||||||
expect(useCombatStore.getState()).toMatchObject({
|
expect(useCombatStore.getState()).toMatchObject({
|
||||||
resource: Math.round(chronomancerBase.mana * 1.12),
|
resource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
|
||||||
maxResource: Math.round(chronomancerBase.mana * 1.12),
|
maxResource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
|
||||||
});
|
});
|
||||||
|
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 });
|
useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 });
|
||||||
@@ -1038,7 +1070,9 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
||||||
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
||||||
useCombatStore.getState().tick(0.1, boltCompletion);
|
useCombatStore.getState().tick(0.1, boltCompletion);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85));
|
expect(useCombatStore.getState().resource).toBe(
|
||||||
|
startingMana - Math.round(startingMana / MANA_POOL_MULTIPLIER * 0.1 * 0.85),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes a cast-time direct heal to the selected living party member", () => {
|
it("routes a cast-time direct heal to the selected living party member", () => {
|
||||||
|
|||||||
+259
-26
@@ -155,6 +155,8 @@ import {
|
|||||||
deriveCharacterStats,
|
deriveCharacterStats,
|
||||||
equipmentAttackPower,
|
equipmentAttackPower,
|
||||||
hastedDuration,
|
hastedDuration,
|
||||||
|
healthFromTotalStamina,
|
||||||
|
MANA_POOL_MULTIPLIER,
|
||||||
ratingPercent,
|
ratingPercent,
|
||||||
type CharacterStats,
|
type CharacterStats,
|
||||||
type WotlkAttackKind,
|
type WotlkAttackKind,
|
||||||
@@ -673,6 +675,8 @@ export interface CombatState {
|
|||||||
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
|
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
|
||||||
cancelGroundTarget: () => void;
|
cancelGroundTarget: () => void;
|
||||||
synchronizeActors: () => void;
|
synchronizeActors: () => void;
|
||||||
|
/** Grants learned long-term class stat buffs to the full party without setup casts. */
|
||||||
|
applyDefaultPartyBuffs: (now?: number) => void;
|
||||||
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
|
/** Commits companion simulation results to authoritative actors and mirrors party frames. */
|
||||||
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
|
||||||
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
|
||||||
@@ -1504,17 +1508,23 @@ function initialResourcePools(
|
|||||||
return { pools, maximums };
|
return { pools, maximums };
|
||||||
}
|
}
|
||||||
|
|
||||||
function abilityCosts(
|
export function resolvedAbilityCosts(
|
||||||
ability: AbilityDefinition,
|
ability: AbilityDefinition,
|
||||||
modifiers: readonly TalentModifier[],
|
modifiers: readonly TalentModifier[],
|
||||||
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
||||||
): readonly { resource: ResourceType; amount: number }[] {
|
): readonly { resource: ResourceType; amount: number }[] {
|
||||||
const aggregated = new Map<ResourceType, number>();
|
const aggregated = new Map<ResourceType, number>();
|
||||||
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||||
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
||||||
const maximum = cost.resource === "health"
|
const maximum = cost.resource === "health"
|
||||||
? state.maxHealth
|
? state.maxHealth
|
||||||
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
: cost.resource === activeResourceType
|
||||||
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
|
? state.maxResource
|
||||||
|
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
||||||
|
const percentageMaximum = cost.resource === "mana" && ability.source === "wow335-progression"
|
||||||
|
? maximum / MANA_POOL_MULTIPLIER
|
||||||
|
: maximum;
|
||||||
|
const baseAmount = cost.percentage ? percentageMaximum * cost.amount / 100 : cost.amount;
|
||||||
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
|
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
|
||||||
baseAmount,
|
baseAmount,
|
||||||
modifiers,
|
modifiers,
|
||||||
@@ -1886,11 +1896,16 @@ function auraDefinitionForAbility(
|
|||||||
ability: AbilityDefinition,
|
ability: AbilityDefinition,
|
||||||
durationMs = definition.durationMs,
|
durationMs = definition.durationMs,
|
||||||
): AuraDefinition {
|
): AuraDefinition {
|
||||||
|
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
|
||||||
return {
|
return {
|
||||||
...definition,
|
...definition,
|
||||||
description: definition.description ?? ability.description,
|
description: definition.description ?? ability.description,
|
||||||
icon: definition.icon ?? ability.icon,
|
icon: definition.icon ?? ability.icon,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
...(defaultPartyBuffFamily ? {
|
||||||
|
uniqueBySource: false,
|
||||||
|
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1913,14 +1928,16 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
|
|||||||
"all-resistance",
|
"all-resistance",
|
||||||
"magical-defense",
|
"magical-defense",
|
||||||
);
|
);
|
||||||
|
const stamina = primary(base.stamina, "stamina");
|
||||||
|
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
strength: primary(base.strength, "strength"),
|
strength: primary(base.strength, "strength"),
|
||||||
agility: primary(base.agility, "agility", "dexterity"),
|
agility: primary(base.agility, "agility", "dexterity"),
|
||||||
stamina: primary(base.stamina, "stamina"),
|
stamina,
|
||||||
intellect: primary(base.intellect, "intellect"),
|
intellect: primary(base.intellect, "intellect"),
|
||||||
spirit: primary(base.spirit, "spirit", "wisdom"),
|
spirit: primary(base.spirit, "spirit", "wisdom"),
|
||||||
health: stat(base.health, "maximum-health"),
|
health: stat(base.health + staminaHealth, "maximum-health"),
|
||||||
mana: stat(base.mana, "maximum-mana"),
|
mana: stat(base.mana, "maximum-mana"),
|
||||||
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
|
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
|
||||||
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
|
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
|
||||||
@@ -1948,6 +1965,53 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function auraMaximumHealthBonus(
|
||||||
|
base: CharacterStats,
|
||||||
|
auras: readonly ActiveAura[],
|
||||||
|
targetId: string,
|
||||||
|
): number {
|
||||||
|
return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auraAdjustedPlayerMaxHealth(
|
||||||
|
classId: ClassId,
|
||||||
|
level: number,
|
||||||
|
modifiers: readonly TalentModifier[],
|
||||||
|
gearStats: ItemStats,
|
||||||
|
raceId: RaceId,
|
||||||
|
secondaryClassId: ClassId | null | undefined,
|
||||||
|
auras: readonly ActiveAura[],
|
||||||
|
): number {
|
||||||
|
const baseStats = playerCharacterStats(classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers);
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId)
|
||||||
|
+ auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map<string, string>([
|
||||||
|
["power word: fortitude", "fortitude"],
|
||||||
|
["prayer of fortitude", "fortitude"],
|
||||||
|
["divine spirit", "spirit"],
|
||||||
|
["prayer of spirit", "spirit"],
|
||||||
|
["shadow protection", "shadow-protection"],
|
||||||
|
["prayer of shadow protection", "shadow-protection"],
|
||||||
|
["arcane intellect", "arcane-intellect"],
|
||||||
|
["arcane brilliance", "arcane-intellect"],
|
||||||
|
["mark of the wild", "mark-of-the-wild"],
|
||||||
|
["gift of the wild", "mark-of-the-wild"],
|
||||||
|
]));
|
||||||
|
|
||||||
|
function defaultPartyBuffPotency(
|
||||||
|
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[],
|
||||||
|
): number {
|
||||||
|
return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce(
|
||||||
|
(subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1),
|
||||||
|
0,
|
||||||
|
), 0);
|
||||||
|
}
|
||||||
|
|
||||||
function syncPassiveAuras(
|
function syncPassiveAuras(
|
||||||
current: readonly ActiveAura[],
|
current: readonly ActiveAura[],
|
||||||
abilities: readonly AbilityDefinition[],
|
abilities: readonly AbilityDefinition[],
|
||||||
@@ -2196,13 +2260,15 @@ function grantMobExperience(
|
|||||||
progression.levelsGained += result.levelsGained;
|
progression.levelsGained += result.levelsGained;
|
||||||
if (result.levelsGained > 0) {
|
if (result.levelsGained > 0) {
|
||||||
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
|
work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks);
|
||||||
progression.maxHealth = talentAdjustedMaxHealth(
|
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
||||||
|
progression.maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
classId,
|
classId,
|
||||||
result.level,
|
result.level,
|
||||||
work.talentModifiers,
|
work.talentModifiers,
|
||||||
work.gearStats,
|
work.gearStats,
|
||||||
work.raceId,
|
work.raceId,
|
||||||
work.secondaryClassId,
|
work.secondaryClassId,
|
||||||
|
work.auras,
|
||||||
);
|
);
|
||||||
progression.health = progression.maxHealth;
|
progression.health = progression.maxHealth;
|
||||||
work.health = progression.health;
|
work.health = progression.health;
|
||||||
@@ -2215,7 +2281,6 @@ function grantMobExperience(
|
|||||||
work.raceId,
|
work.raceId,
|
||||||
work.secondaryClassId,
|
work.secondaryClassId,
|
||||||
);
|
);
|
||||||
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
|
|
||||||
}
|
}
|
||||||
return scaledAward;
|
return scaledAward;
|
||||||
}
|
}
|
||||||
@@ -2719,12 +2784,14 @@ function executeTriggeredSpellInWork(
|
|||||||
const recipient = effect.recipient === "caster"
|
const recipient = effect.recipient === "caster"
|
||||||
? sourceId
|
? sourceId
|
||||||
: targetId ?? sourceId;
|
: targetId ?? sourceId;
|
||||||
work.auras = [...applyCombatAura(work.auras, {
|
const previousAuras = work.auras;
|
||||||
|
work.auras = [...applyCombatAura(previousAuras, {
|
||||||
definition: auraDefinitionForAbility(effect.aura, ability),
|
definition: auraDefinitionForAbility(effect.aura, ability),
|
||||||
sourceId,
|
sourceId,
|
||||||
targetId: recipient,
|
targetId: recipient,
|
||||||
now,
|
now,
|
||||||
}).auras];
|
}).auras];
|
||||||
|
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
|
||||||
if (effect.control && work.mobs[recipient]) {
|
if (effect.control && work.mobs[recipient]) {
|
||||||
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
|
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
|
||||||
kind: "status",
|
kind: "status",
|
||||||
@@ -2963,6 +3030,47 @@ function mirrorCompanionActor(actor: CombatActorState): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAuraHealthTransitionInWork(
|
||||||
|
work: MutableCastWork,
|
||||||
|
targetId: string,
|
||||||
|
previousAuras: readonly ActiveAura[],
|
||||||
|
nextAuras: readonly ActiveAura[],
|
||||||
|
): void {
|
||||||
|
if (targetId === PLAYER_AURA_ENTITY_ID) {
|
||||||
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
|
work.classId,
|
||||||
|
work.progression.level,
|
||||||
|
work.talentModifiers,
|
||||||
|
work.gearStats,
|
||||||
|
work.raceId,
|
||||||
|
work.secondaryClassId,
|
||||||
|
nextAuras,
|
||||||
|
);
|
||||||
|
const delta = maxHealth - work.maxHealth;
|
||||||
|
if (!delta) return;
|
||||||
|
work.maxHealth = maxHealth;
|
||||||
|
work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth);
|
||||||
|
work.progression.maxHealth = work.maxHealth;
|
||||||
|
work.progression.health = work.health;
|
||||||
|
const actor = work.actors[targetId];
|
||||||
|
if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId);
|
||||||
|
const actor = ensureCompanionActorInWork(work, targetId);
|
||||||
|
if (!member || !actor) return;
|
||||||
|
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
|
||||||
|
const delta = auraMaximumHealthBonus(base, nextAuras, targetId)
|
||||||
|
- auraMaximumHealthBonus(base, previousAuras, targetId);
|
||||||
|
if (!delta) return;
|
||||||
|
const maxHealth = Math.max(1, actor.maxHealth + delta);
|
||||||
|
const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth);
|
||||||
|
const nextActor = Object.freeze({ ...actor, health, maxHealth });
|
||||||
|
work.actors[targetId] = nextActor;
|
||||||
|
mirrorCompanionActor(nextActor);
|
||||||
|
}
|
||||||
|
|
||||||
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number {
|
||||||
const actor = ensureCompanionActorInWork(work, id);
|
const actor = ensureCompanionActorInWork(work, id);
|
||||||
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
|
||||||
@@ -3265,13 +3373,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
||||||
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
||||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
state.talentModifiers,
|
state.talentModifiers,
|
||||||
gearStats,
|
gearStats,
|
||||||
state.raceId,
|
state.raceId,
|
||||||
state.secondaryClassId,
|
state.secondaryClassId,
|
||||||
|
state.auras,
|
||||||
);
|
);
|
||||||
const maxResource = talentAdjustedMaxResource(
|
const maxResource = talentAdjustedMaxResource(
|
||||||
state.classId,
|
state.classId,
|
||||||
@@ -3539,13 +3648,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
role: state.gearRole,
|
role: state.gearRole,
|
||||||
level: state.level,
|
level: state.level,
|
||||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
state.talentModifiers,
|
state.talentModifiers,
|
||||||
gearStats,
|
gearStats,
|
||||||
state.raceId,
|
state.raceId,
|
||||||
state.secondaryClassId,
|
state.secondaryClassId,
|
||||||
|
state.auras,
|
||||||
);
|
);
|
||||||
const maxResource = talentAdjustedMaxResource(
|
const maxResource = talentAdjustedMaxResource(
|
||||||
state.classId,
|
state.classId,
|
||||||
@@ -3588,13 +3698,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
role: state.gearRole,
|
role: state.gearRole,
|
||||||
level: state.level,
|
level: state.level,
|
||||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
state.talentModifiers,
|
state.talentModifiers,
|
||||||
gearStats,
|
gearStats,
|
||||||
state.raceId,
|
state.raceId,
|
||||||
state.secondaryClassId,
|
state.secondaryClassId,
|
||||||
|
state.auras,
|
||||||
);
|
);
|
||||||
const maxResource = talentAdjustedMaxResource(
|
const maxResource = talentAdjustedMaxResource(
|
||||||
state.classId,
|
state.classId,
|
||||||
@@ -3811,6 +3922,59 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
|
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
applyDefaultPartyBuffs: (now = combatNow()) => {
|
||||||
|
get().synchronizeActors();
|
||||||
|
const state = get();
|
||||||
|
const partyMembers = usePartyStore.getState().members;
|
||||||
|
const sources = [
|
||||||
|
{
|
||||||
|
id: PLAYER_AURA_ENTITY_ID,
|
||||||
|
level: state.level,
|
||||||
|
abilities: state.abilities,
|
||||||
|
},
|
||||||
|
...partyMembers.filter((member) => member.health > 0).map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
level: member.level,
|
||||||
|
abilities: abilitiesForClass(member.classId),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const selected = new Map<string, {
|
||||||
|
sourceId: string;
|
||||||
|
ability: AbilityDefinition;
|
||||||
|
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
|
||||||
|
potency: number;
|
||||||
|
}>();
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const catalogAbility of source.abilities) {
|
||||||
|
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase());
|
||||||
|
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
|
||||||
|
const ability = abilityAtLevel(catalogAbility, source.level);
|
||||||
|
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
|
||||||
|
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
|
||||||
|
));
|
||||||
|
if (!effects.length) continue;
|
||||||
|
const potency = defaultPartyBuffPotency(effects);
|
||||||
|
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
|
||||||
|
selected.set(family, { sourceId: source.id, ability, effects, potency });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)];
|
||||||
|
for (const [family, buff] of selected) {
|
||||||
|
for (const recipientId of recipients) {
|
||||||
|
for (const effect of buff.effects) {
|
||||||
|
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
|
||||||
|
...effect,
|
||||||
|
aura: {
|
||||||
|
...effect.aura,
|
||||||
|
uniqueBySource: false,
|
||||||
|
exclusiveGroup: `default-party-buff:${family}`,
|
||||||
|
},
|
||||||
|
}, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get().synchronizeActors();
|
||||||
|
},
|
||||||
commitCompanionActors: (members) => {
|
commitCompanionActors: (members) => {
|
||||||
let actors: Readonly<Record<string, CombatActorState>> = {};
|
let actors: Readonly<Record<string, CombatActorState>> = {};
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -3866,7 +4030,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
: ability?.target;
|
: ability?.target;
|
||||||
const modifiers = state.talentModifiers;
|
const modifiers = state.talentModifiers;
|
||||||
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
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 costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
||||||
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
||||||
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
||||||
@@ -3895,7 +4059,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
|
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"
|
else if (costs.some((cost) => cost.resource === "health"
|
||||||
? state.health <= cost.amount
|
? 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";
|
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
|
||||||
|
|
||||||
const playerOrigin = currentOrigin(state, origin);
|
const playerOrigin = currentOrigin(state, origin);
|
||||||
@@ -4060,7 +4226,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
state.classId,
|
state.classId,
|
||||||
);
|
);
|
||||||
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
|
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
|
||||||
const spentPools = { ...state.resourcePools };
|
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||||
|
const spentPools: Partial<Record<ResourceType, number>> = {
|
||||||
|
...state.resourcePools,
|
||||||
|
[activeResourceType]: state.resource,
|
||||||
|
};
|
||||||
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
|
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
|
||||||
let healthAfterCosts = state.health;
|
let healthAfterCosts = state.health;
|
||||||
for (const cost of costsToSpend) {
|
for (const cost of costsToSpend) {
|
||||||
@@ -4073,7 +4243,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const spentRunes = runeCost && completingCast?.mode !== "channel"
|
const spentRunes = runeCost && completingCast?.mode !== "channel"
|
||||||
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
|
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
|
||||||
: state.deathKnightRunes;
|
: state.deathKnightRunes;
|
||||||
const activeResourceType = resourceProfileForClass(state.classId).type;
|
|
||||||
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
|
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
|
||||||
if (!completingCast && castTimeMs > 0) {
|
if (!completingCast && castTimeMs > 0) {
|
||||||
castSequence += 1;
|
castSequence += 1;
|
||||||
@@ -4752,12 +4921,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
? affectedMobIds
|
? affectedMobIds
|
||||||
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
: [targetId ?? PLAYER_AURA_ENTITY_ID];
|
||||||
for (const recipientId of recipients) {
|
for (const recipientId of recipients) {
|
||||||
work.auras = [...applyCombatAura(work.auras, {
|
const previousAuras = work.auras;
|
||||||
|
work.auras = [...applyCombatAura(previousAuras, {
|
||||||
definition: auraDefinition,
|
definition: auraDefinition,
|
||||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||||
targetId: recipientId,
|
targetId: recipientId,
|
||||||
now,
|
now,
|
||||||
}).auras];
|
}).auras];
|
||||||
|
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
|
||||||
if (auraDefinition.controlImmunities?.length) {
|
if (auraDefinition.controlImmunities?.length) {
|
||||||
if (
|
if (
|
||||||
recipientId === PLAYER_AURA_ENTITY_ID
|
recipientId === PLAYER_AURA_ENTITY_ID
|
||||||
@@ -5725,8 +5896,49 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
targetId: targetActorId,
|
targetId: targetActorId,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
let health = state.health;
|
||||||
|
let maxHealth = state.maxHealth;
|
||||||
|
const actors = { ...state.actors };
|
||||||
|
let companionActor: CombatActorState | null = null;
|
||||||
|
if (targetActorId === PLAYER_AURA_ENTITY_ID) {
|
||||||
|
const nextMaxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
|
state.classId,
|
||||||
|
state.level,
|
||||||
|
state.talentModifiers,
|
||||||
|
state.gearStats,
|
||||||
|
state.raceId,
|
||||||
|
state.secondaryClassId,
|
||||||
|
result.auras,
|
||||||
|
);
|
||||||
|
const delta = nextMaxHealth - maxHealth;
|
||||||
|
maxHealth = nextMaxHealth;
|
||||||
|
health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth);
|
||||||
|
const actor = actors[targetActorId];
|
||||||
|
if (actor) actors[targetActorId] = { ...actor, health, maxHealth };
|
||||||
|
} else {
|
||||||
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId);
|
||||||
|
const actor = member ? companionActorFromMember(
|
||||||
|
member,
|
||||||
|
entityAbsorbAmount(state.auras, targetActorId),
|
||||||
|
state.actors[targetActorId],
|
||||||
|
) : null;
|
||||||
|
if (member && actor) {
|
||||||
|
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
|
||||||
|
const delta = auraMaximumHealthBonus(base, result.auras, targetActorId)
|
||||||
|
- auraMaximumHealthBonus(base, state.auras, targetActorId);
|
||||||
|
if (delta) {
|
||||||
|
const actorMaxHealth = Math.max(1, actor.maxHealth + delta);
|
||||||
|
const actorHealth = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, actorMaxHealth);
|
||||||
|
companionActor = Object.freeze({ ...actor, health: actorHealth, maxHealth: actorMaxHealth });
|
||||||
|
actors[targetActorId] = companionActor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
auras: result.auras,
|
auras: result.auras,
|
||||||
|
health,
|
||||||
|
maxHealth,
|
||||||
|
actors,
|
||||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
kind: "aura",
|
kind: "aura",
|
||||||
@@ -5741,6 +5953,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
critical: false,
|
critical: false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (companionActor) mirrorCompanionActor(companionActor);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -7020,6 +7233,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
|
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
|
||||||
const resourceProfile = resourceProfileForClass(state.classId);
|
const resourceProfile = resourceProfileForClass(state.classId);
|
||||||
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
|
const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead);
|
||||||
|
const auraExpiration = expireAuras(state.auras, now);
|
||||||
|
const activeAuras = [...auraExpiration.active];
|
||||||
const baseRegeneration = applyTalentModifierValue(
|
const baseRegeneration = applyTalentModifierValue(
|
||||||
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
|
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
|
||||||
? 0
|
? 0
|
||||||
@@ -7029,7 +7244,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
state.talentModifiers,
|
state.talentModifiers,
|
||||||
"resource-regeneration-percent",
|
"resource-regeneration-percent",
|
||||||
);
|
);
|
||||||
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
|
||||||
const regeneration = resolveAuraResourceValue(
|
const regeneration = resolveAuraResourceValue(
|
||||||
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
|
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
|
||||||
playerAuras,
|
playerAuras,
|
||||||
@@ -7089,7 +7304,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
abilities: state.abilities,
|
abilities: state.abilities,
|
||||||
mobs: { ...state.mobs },
|
mobs: { ...state.mobs },
|
||||||
effects: [],
|
effects: [],
|
||||||
auras: [...expireAuras(state.auras, now).active],
|
auras: activeAuras,
|
||||||
summons: state.summons.filter((summon) => summon.expiresAt > now),
|
summons: state.summons.filter((summon) => summon.expiresAt > now),
|
||||||
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
|
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
|
||||||
combatEvents: [...state.combatEvents],
|
combatEvents: [...state.combatEvents],
|
||||||
@@ -7124,6 +7339,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
raceId: state.raceId,
|
raceId: state.raceId,
|
||||||
secondaryClassId: state.secondaryClassId,
|
secondaryClassId: state.secondaryClassId,
|
||||||
};
|
};
|
||||||
|
for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) {
|
||||||
|
applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras);
|
||||||
|
}
|
||||||
|
|
||||||
let autoAttackTargetId = state.autoAttackTargetId;
|
let autoAttackTargetId = state.autoAttackTargetId;
|
||||||
let nextAutoAttackAt = state.nextAutoAttackAt;
|
let nextAutoAttackAt = state.nextAutoAttackAt;
|
||||||
@@ -7839,9 +8057,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
activeCast: null,
|
activeCast: null,
|
||||||
schoolLockedUntil: {},
|
schoolLockedUntil: {},
|
||||||
nextWoundAnimationAt: 0,
|
nextWoundAnimationAt: 0,
|
||||||
}]));
|
}]));
|
||||||
|
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
|
||||||
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
|
state.classId,
|
||||||
|
state.level,
|
||||||
|
state.talentModifiers,
|
||||||
|
state.gearStats,
|
||||||
|
state.raceId,
|
||||||
|
state.secondaryClassId,
|
||||||
|
auras,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
health: state.maxHealth,
|
health: maxHealth,
|
||||||
|
maxHealth,
|
||||||
controlledUntil: 0,
|
controlledUntil: 0,
|
||||||
controlMechanic: null,
|
controlMechanic: null,
|
||||||
shield: 0,
|
shield: 0,
|
||||||
@@ -7868,7 +8097,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
selectedTargetId: null,
|
selectedTargetId: null,
|
||||||
selectedFriendlyActorId: null,
|
selectedFriendlyActorId: null,
|
||||||
effects: [],
|
effects: [],
|
||||||
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
|
auras,
|
||||||
summons: [],
|
summons: [],
|
||||||
areaEffects: [],
|
areaEffects: [],
|
||||||
pendingGroundTarget: null,
|
pendingGroundTarget: null,
|
||||||
@@ -7904,13 +8133,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
|
const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow());
|
||||||
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
talentModifiers,
|
talentModifiers,
|
||||||
state.gearStats,
|
state.gearStats,
|
||||||
state.raceId,
|
state.raceId,
|
||||||
state.secondaryClassId,
|
state.secondaryClassId,
|
||||||
|
auras,
|
||||||
);
|
);
|
||||||
const maxResource = talentAdjustedMaxResource(
|
const maxResource = talentAdjustedMaxResource(
|
||||||
state.classId,
|
state.classId,
|
||||||
@@ -7927,7 +8158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||||
maxResource,
|
maxResource,
|
||||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
|
auras,
|
||||||
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
feedback: nextFeedback("talent", "Conquest node rank allocated."),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -7936,13 +8167,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
resetTalents: () => set((state) => {
|
resetTalents: () => set((state) => {
|
||||||
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
|
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
|
||||||
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
|
const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks);
|
||||||
const maxHealth = talentAdjustedMaxHealth(
|
const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow());
|
||||||
|
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||||
state.classId,
|
state.classId,
|
||||||
state.level,
|
state.level,
|
||||||
talentModifiers,
|
talentModifiers,
|
||||||
state.gearStats,
|
state.gearStats,
|
||||||
state.raceId,
|
state.raceId,
|
||||||
state.secondaryClassId,
|
state.secondaryClassId,
|
||||||
|
auras,
|
||||||
);
|
);
|
||||||
const maxResource = talentAdjustedMaxResource(
|
const maxResource = talentAdjustedMaxResource(
|
||||||
state.classId,
|
state.classId,
|
||||||
@@ -7959,7 +8192,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
|
||||||
maxResource,
|
maxResource,
|
||||||
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
|
||||||
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
|
auras,
|
||||||
feedback: nextFeedback("talent", "Talent points reset."),
|
feedback: nextFeedback("talent", "Talent points reset."),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCombatStore } from "./combatStore";
|
|||||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
import { MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||||
|
|
||||||
describe("death respawn", () => {
|
describe("death respawn", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -24,7 +25,9 @@ describe("death respawn", () => {
|
|||||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||||
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
||||||
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15));
|
expect(useCombatStore.getState().resource).toBe(
|
||||||
|
startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.15),
|
||||||
|
);
|
||||||
|
|
||||||
const revisionBefore = useGameStore.getState().resetRevision;
|
const revisionBefore = useGameStore.getState().resetRevision;
|
||||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
|
||||||
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
|
||||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
import { deriveCharacterStats } from "./wotlkStats";
|
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
||||||
|
|
||||||
const RACE_MATRIX = {
|
const RACE_MATRIX = {
|
||||||
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
|
"rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"],
|
||||||
@@ -118,8 +118,12 @@ describe("generated RuneWaker player catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("gives level-one healers native mana pools and imports their real spell costs", () => {
|
it("gives level-one healers native mana pools and imports their real spell costs", () => {
|
||||||
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(103);
|
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
|
||||||
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(86);
|
103 * MANA_POOL_MULTIPLIER,
|
||||||
|
);
|
||||||
|
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
|
||||||
|
86 * MANA_POOL_MULTIPLIER,
|
||||||
|
);
|
||||||
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
|
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
|
||||||
{ resource: "mana", amount: 12, percentage: false },
|
{ resource: "mana", amount: 12, percentage: false },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deriveCharacterStats,
|
deriveCharacterStats,
|
||||||
hastedDuration,
|
hastedDuration,
|
||||||
healthFromTotalStamina,
|
healthFromTotalStamina,
|
||||||
|
MANA_POOL_MULTIPLIER,
|
||||||
ratingMultiplier,
|
ratingMultiplier,
|
||||||
ratingPerPercent,
|
ratingPerPercent,
|
||||||
} from "./wotlkStats";
|
} from "./wotlkStats";
|
||||||
@@ -50,7 +51,7 @@ describe("WoW 3.3.5a character stats", () => {
|
|||||||
spellPower: 7,
|
spellPower: 7,
|
||||||
mp5: 4,
|
mp5: 4,
|
||||||
});
|
});
|
||||||
expect(mageGeared.mana - mageBase.mana).toBe(150);
|
expect(mageGeared.mana - mageBase.mana).toBe(150 * MANA_POOL_MULTIPLIER);
|
||||||
expect(mageGeared.spellPower).toBe(7);
|
expect(mageGeared.spellPower).toBe(7);
|
||||||
expect(mageGeared.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent);
|
expect(mageGeared.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent);
|
||||||
expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4);
|
expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4);
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ interface CombatRatingsData {
|
|||||||
|
|
||||||
const BASE_STATS = baseStatsData as BaseStatsData;
|
const BASE_STATS = baseStatsData as BaseStatsData;
|
||||||
const COMBAT_RATINGS = combatRatingsData as CombatRatingsData;
|
const COMBAT_RATINGS = combatRatingsData as CombatRatingsData;
|
||||||
|
/** Healer Man's larger combat pool; imported Wrath costs keep their pre-boost baseline. */
|
||||||
|
export const MANA_POOL_MULTIPLIER = 3;
|
||||||
const CLASS_DBC = Object.fromEntries(
|
const CLASS_DBC = Object.fromEntries(
|
||||||
CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]),
|
CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]),
|
||||||
) as Record<ClassId, number>;
|
) as Record<ClassId, number>;
|
||||||
@@ -279,7 +281,7 @@ export function deriveCharacterStats(
|
|||||||
modifiers.maxHealth,
|
modifiers.maxHealth,
|
||||||
);
|
);
|
||||||
const mana = classUsesMana(classId)
|
const mana = classUsesMana(classId)
|
||||||
? percentAdjusted(
|
? MANA_POOL_MULTIPLIER * percentAdjusted(
|
||||||
resources.mana
|
resources.mana
|
||||||
+ (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect))
|
+ (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect))
|
||||||
+ gear.mana,
|
+ gear.mana,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import { useCallback, useEffect, type ReactNode } from "react";
|
import { useCallback, type ReactNode } from "react";
|
||||||
import type { WebGLRenderer } from "three";
|
import type { WebGLRenderer } from "three";
|
||||||
import { KTX2Loader, type GLTFLoader } from "three-stdlib";
|
import { KTX2Loader, type GLTFLoader } from "three-stdlib";
|
||||||
import { resolveContentUrl } from "../content/contentManager";
|
import { resolveContentUrl } from "../content/contentManager";
|
||||||
@@ -20,15 +20,12 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
|
|||||||
return loader;
|
return loader;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Owns the decoder worker pool for one Canvas and releases it on renderer teardown. */
|
/**
|
||||||
|
* Marks the part of a Canvas that shares its GLTF decoder. KTX2Loader instances
|
||||||
|
* intentionally live with their weakly referenced renderer: disposing one from
|
||||||
|
* React cleanup can abort a GLB that is still decoding or cached by useGLTF.
|
||||||
|
*/
|
||||||
export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) {
|
export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) {
|
||||||
const renderer = useThree((state) => state.gl);
|
|
||||||
useEffect(() => () => {
|
|
||||||
const loader = ktx2Loaders.get(renderer);
|
|
||||||
if (!loader) return;
|
|
||||||
loader.dispose();
|
|
||||||
ktx2Loaders.delete(renderer);
|
|
||||||
}, [renderer]);
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3327,6 +3327,51 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
|||||||
.arena-world,
|
.arena-world,
|
||||||
.arena-world > div { position: absolute; inset: 0; }
|
.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 {
|
.arena-match-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: .7rem;
|
top: .7rem;
|
||||||
|
|||||||
+14
-5
@@ -4,7 +4,7 @@ import { classById, raceById } from "../app/characterCatalog";
|
|||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
||||||
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
import { resurrectManastormSession } from "../game/manastormSession";
|
import { resurrectManastormSession } from "../game/manastormSession";
|
||||||
@@ -137,9 +137,12 @@ export function Hud() {
|
|||||||
const resource = useCombatStore((state) => state.resource);
|
const resource = useCombatStore((state) => state.resource);
|
||||||
const maxResource = useCombatStore((state) => state.maxResource);
|
const maxResource = useCombatStore((state) => state.maxResource);
|
||||||
const resourcePools = useCombatStore((state) => state.resourcePools);
|
const resourcePools = useCombatStore((state) => state.resourcePools);
|
||||||
|
const maxResourcePools = useCombatStore((state) => state.maxResourcePools);
|
||||||
const resourceName = useCombatStore((state) => state.resourceName);
|
const resourceName = useCombatStore((state) => state.resourceName);
|
||||||
const abilities = useCombatStore((state) => state.abilities);
|
const abilities = useCombatStore((state) => state.abilities);
|
||||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
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 actionBindings = useCombatStore((state) => state.actionBindings);
|
||||||
const cooldowns = useCombatStore((state) => state.cooldowns);
|
const cooldowns = useCombatStore((state) => state.cooldowns);
|
||||||
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
||||||
@@ -190,11 +193,17 @@ export function Hud() {
|
|||||||
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
||||||
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
||||||
const remaining = Math.max(personalRemaining, globalRemaining);
|
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"
|
const cannotPay = abilityCosts.some((cost) => cost.resource === "health"
|
||||||
? health <= cost.amount
|
? health <= cost.amount
|
||||||
: (resourcePools[cost.resource] ?? (cost.resource === resourceTone ? resource : 0)) < cost.amount);
|
: (cost.resource === resourceTone ? resource : resourcePools[cost.resource] ?? 0) < cost.amount);
|
||||||
const displayCost = abilityCosts[0] ?? ability.cost;
|
const displayCost = abilityCosts[0] ?? { resource: ability.cost.resource, amount: 0 };
|
||||||
const item: ActionBarItem = {
|
const item: ActionBarItem = {
|
||||||
id: ability.id,
|
id: ability.id,
|
||||||
name: ability.name,
|
name: ability.name,
|
||||||
@@ -211,7 +220,7 @@ export function Hud() {
|
|||||||
target: ability.target,
|
target: ability.target,
|
||||||
};
|
};
|
||||||
return [control, item];
|
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(
|
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
|
||||||
useGameStore.getState().actionLayer,
|
useGameStore.getState().actionLayer,
|
||||||
|
|||||||
Reference in new issue
Block a user