Compare commits

..
2 Commits
Author SHA1 Message Date
phenom d9bebb4e81 Release Healer Man 0.1.19 2026-09-06 11:06:03 -04:00
phenom eaddad4134 Release Healer Man 0.1.18 2026-09-05 22:15:48 -04:00
22 changed files with 445 additions and 74 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.17",
"version": "0.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.17",
"version": "0.1.19",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.17",
"version": "0.1.19",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.17",
"versionCode": 1017
"versionName": "0.1.19",
"versionCode": 1019
}
@@ -32,6 +32,10 @@ const sources = [
directory: path.join(PROJECT_ROOT, "public", "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"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
@@ -33,6 +33,10 @@ const sources = [
directory: path.join(PROJECT_ROOT, "public", "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"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
+3
View File
@@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
@@ -79,6 +80,7 @@ function packLabel(packId) {
if (packId === "core:equipment") return "Starter Equipment";
if (packId === "core:character-basic") return "Basic Human Character Model";
if (packId === "core:runtime") return "Game Runtime";
if (packId === "core:arenas") return "Rated Arenas";
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
return name;
}
@@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) {
if (segments[1] === "game" && segments[2] === "dungeons" && 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]) {
return `manastorm:${segments[3]}`;
}
+2
View File
@@ -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/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/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/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
+46 -28
View File
@@ -8,6 +8,7 @@ import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore";
import { arenaCameraPose } from "./game/arenaCamera";
import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
@@ -36,7 +37,7 @@ function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onRea
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return (
<>
<ProceduralStage stage={stage} />
<ProceduralStage stage={stage} floorOnly />
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
</>
);
@@ -55,26 +56,49 @@ function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
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 depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
const wallHeight = 2.4;
return (
<group>
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color="#364038" roughness={0.92} />
<meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
</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="#4e5146" roughness={0.78} />
</mesh>
))}
{!floorOnly && (
<>
<gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
{[
[centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
[centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
[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>
);
}
@@ -165,23 +189,18 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
useFrame(({ camera }, delta) => {
if (!actor) return;
const spawn = stage.teamSpawns[actor.team];
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
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);
target.set(...pose.target);
desired.set(...pose.position);
camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target);
});
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; onStageReady: () => void; onStageError: (error: unknown) => void }) {
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 (
<Canvas
shadows
@@ -194,13 +213,11 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle>
<ArenaStageErrorBoundary
key={stage.id}
fallback={<ProceduralStage stage={stage} />}
onError={onStageError}
>
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
{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} />
{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} />
@@ -331,9 +348,10 @@ 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>;
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
const detailedAsset = stageFailure?.stageId !== stage.id;
return (
<main className="arena-runtime">
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} onStageReady={handleStageReady} onStageError={handleStageError} /></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">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
+2
View File
@@ -16,6 +16,7 @@ const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
@@ -23,6 +24,7 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:equipment",
"core:character-basic",
"core:runtime",
"core:arenas",
"dungeon:wailing-caverns",
]);
+6
View File
@@ -36,6 +36,8 @@ export function PartyBridge() {
character.categoryId ?? "wow",
);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
@@ -44,6 +46,8 @@ export function PartyBridge() {
const combat = useCombatStore.getState();
party.syncLevel(level);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
}, [level]);
useEffect(() => {
@@ -52,6 +56,8 @@ export function PartyBridge() {
const combat = useCombatStore.getState();
usePartyStore.getState().resetCombat(combat.level, combat.health);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors();
combat.applyDefaultPartyBuffs();
resetPartyRuntimeClock();
}, [resetRevision]);
+19
View File
@@ -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);
}
}
});
});
+16
View File
@@ -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],
};
}
+42
View File
@@ -24,6 +24,8 @@ describe("named aura combat runtime", () => {
it("applies and expires an imported named player buff", () => {
const now = 10_000;
const baseHealth = useCombatStore.getState().health;
const baseMaxHealth = useCombatStore.getState().maxHealth;
const result = useCombatStore.getState().castAbility(
"wow335-priest-power-word-fortitude",
undefined,
@@ -46,9 +48,49 @@ describe("named aura combat runtime", () => {
))?.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);
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", () => {
+12
View File
@@ -27,6 +27,18 @@ describe("authentic combat resources", () => {
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", () => {
const rage = normalizeActorResourcePool("rage", 20, 100);
const runic = normalizeActorResourcePool("runic-power", 20, 100);
+4
View File
@@ -6,6 +6,7 @@ import {
} from "./combatActors";
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 interface CombatResourceAdvanceRules {
@@ -24,6 +25,9 @@ export function advanceCombatResourcePool(
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
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) {
rate = pool.id === "rage" ? -3 : -2.5;
}
+14 -7
View File
@@ -21,7 +21,7 @@ import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats";
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
@@ -83,7 +83,7 @@ describe("combat store", () => {
const first = useCombatStore.getState().castSlot(0, undefined, 1000);
expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 });
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().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" });
@@ -93,7 +93,9 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
useCombatStore.getState().tick(0.1, 2500);
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().mobs.ravager.engaged).toBe(true);
});
@@ -104,7 +106,10 @@ describe("combat store", () => {
const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt");
expect(frostbolt).toBeDefined();
const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial);
expect(cost).toEqual({ resource: "mana", amount: Math.round(initial.maxResource * 0.14) });
expect(cost).toEqual({
resource: "mana",
amount: Math.round(initial.maxResource / MANA_POOL_MULTIPLIER * 0.14),
});
registerTarget("mana-target", [10, 0, 0]);
useCombatStore.setState({
@@ -208,8 +213,8 @@ describe("combat store", () => {
const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human");
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 });
expect(useCombatStore.getState()).toMatchObject({
resource: Math.round(chronomancerBase.mana * 1.12),
maxResource: Math.round(chronomancerBase.mana * 1.12),
resource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
maxResource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
});
useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 });
@@ -1065,7 +1070,9 @@ describe("combat store", () => {
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
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", () => {
+244 -19
View File
@@ -155,6 +155,8 @@ import {
deriveCharacterStats,
equipmentAttackPower,
hastedDuration,
healthFromTotalStamina,
MANA_POOL_MULTIPLIER,
ratingPercent,
type CharacterStats,
type WotlkAttackKind,
@@ -673,6 +675,8 @@ export interface CombatState {
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
cancelGroundTarget: () => 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. */
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
@@ -1517,7 +1521,10 @@ export function resolvedAbilityCosts(
: cost.resource === activeResourceType
? state.maxResource
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
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(
baseAmount,
modifiers,
@@ -1889,11 +1896,16 @@ function auraDefinitionForAbility(
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase());
return {
...definition,
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
...(defaultPartyBuffFamily ? {
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}),
};
}
@@ -1916,14 +1928,16 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha
"all-resistance",
"magical-defense",
);
const stamina = primary(base.stamina, "stamina");
const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina);
return {
...base,
strength: primary(base.strength, "strength"),
agility: primary(base.agility, "agility", "dexterity"),
stamina: primary(base.stamina, "stamina"),
stamina,
intellect: primary(base.intellect, "intellect"),
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"),
attackPower: stat(base.attackPower, "attack-power", "physical-attack"),
rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"),
@@ -1951,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(
current: readonly ActiveAura[],
abilities: readonly AbilityDefinition[],
@@ -2199,13 +2260,15 @@ function grantMobExperience(
progression.levelsGained += result.levelsGained;
if (result.levelsGained > 0) {
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,
result.level,
work.talentModifiers,
work.gearStats,
work.raceId,
work.secondaryClassId,
work.auras,
);
progression.health = progression.maxHealth;
work.health = progression.health;
@@ -2218,7 +2281,6 @@ function grantMobExperience(
work.raceId,
work.secondaryClassId,
);
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
}
return scaledAward;
}
@@ -2722,12 +2784,14 @@ function executeTriggeredSpellInWork(
const recipient = effect.recipient === "caster"
? sourceId
: targetId ?? sourceId;
work.auras = [...applyCombatAura(work.auras, {
const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
}).auras];
applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras);
if (effect.control && work.mobs[recipient]) {
work.mobs[recipient] = applyStatus(work.mobs[recipient], {
kind: "status",
@@ -2966,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 {
const actor = ensureCompanionActorInWork(work, id);
const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0));
@@ -3268,13 +3373,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -3542,13 +3648,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -3591,13 +3698,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
const maxHealth = talentAdjustedMaxHealth(
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
state.talentModifiers,
gearStats,
state.raceId,
state.secondaryClassId,
state.auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -3814,6 +3922,59 @@ export const useCombatStore = create<CombatState>((set, get) => ({
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) => {
let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => {
@@ -4760,12 +4921,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? affectedMobIds
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients) {
work.auras = [...applyCombatAura(work.auras, {
const previousAuras = work.auras;
work.auras = [...applyCombatAura(previousAuras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
}).auras];
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) {
if (
recipientId === PLAYER_AURA_ENTITY_ID
@@ -5733,8 +5896,49 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: targetActorId,
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({
auras: result.auras,
health,
maxHealth,
actors,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
@@ -5749,6 +5953,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
critical: false,
}),
});
if (companionActor) mirrorCompanionActor(companionActor);
return true;
},
@@ -7028,6 +7233,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1);
const resourceProfile = resourceProfileForClass(state.classId);
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(
(resourceProfile.type === "rage" || resourceProfile.type === "runic-power")
? 0
@@ -7037,7 +7244,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.talentModifiers,
"resource-regeneration-percent",
);
const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID);
const regeneration = resolveAuraResourceValue(
resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"),
playerAuras,
@@ -7097,7 +7304,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [],
auras: [...expireAuras(state.auras, now).active],
auras: activeAuras,
summons: state.summons.filter((summon) => summon.expiresAt > now),
areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now),
combatEvents: [...state.combatEvents],
@@ -7132,6 +7339,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId: state.raceId,
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 nextAutoAttackAt = state.nextAutoAttackAt;
@@ -7847,9 +8057,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
activeCast: null,
schoolLockedUntil: {},
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 {
health: state.maxHealth,
health: maxHealth,
maxHealth,
controlledUntil: 0,
controlMechanic: null,
shield: 0,
@@ -7876,7 +8097,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selectedTargetId: null,
selectedFriendlyActorId: null,
effects: [],
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
auras,
summons: [],
areaEffects: [],
pendingGroundTarget: null,
@@ -7912,13 +8133,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return false;
}
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.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -7935,7 +8158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
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."),
});
return true;
@@ -7944,13 +8167,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
resetTalents: () => set((state) => {
const talentRanks = resetTalentRanks(state.talentRanks, state.classId);
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.level,
talentModifiers,
state.gearStats,
state.raceId,
state.secondaryClassId,
auras,
);
const maxResource = talentAdjustedMaxResource(
state.classId,
@@ -7967,7 +8192,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth),
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."),
};
}),
+4 -1
View File
@@ -5,6 +5,7 @@ import { useCombatStore } from "./combatStore";
import { respawnDefeatedPlayer } from "./deathRespawn";
import { useGameStore } from "./store";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import { MANA_POOL_MULTIPLIER } from "./wotlkStats";
describe("death respawn", () => {
beforeEach(() => {
@@ -24,7 +25,9 @@ describe("death respawn", () => {
useCombatStore.getState().selectMob("respawn-pack:a");
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().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;
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
+7 -3
View File
@@ -9,7 +9,7 @@ import {
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { deriveCharacterStats } from "./wotlkStats";
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
const RACE_MATRIX = {
"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", () => {
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(103);
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(86);
expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(
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([
{ resource: "mana", amount: 12, percentage: false },
]);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
deriveCharacterStats,
hastedDuration,
healthFromTotalStamina,
MANA_POOL_MULTIPLIER,
ratingMultiplier,
ratingPerPercent,
} from "./wotlkStats";
@@ -50,7 +51,7 @@ describe("WoW 3.3.5a character stats", () => {
spellPower: 7,
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.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent);
expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4);
+3 -1
View File
@@ -105,6 +105,8 @@ interface CombatRatingsData {
const BASE_STATS = baseStatsData as BaseStatsData;
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(
CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]),
) as Record<ClassId, number>;
@@ -279,7 +281,7 @@ export function deriveCharacterStats(
modifiers.maxHealth,
);
const mana = classUsesMana(classId)
? percentAdjusted(
? MANA_POOL_MULTIPLIER * percentAdjusted(
resources.mana
+ (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect))
+ gear.mana,
+6 -9
View File
@@ -1,6 +1,6 @@
import { useGLTF } from "@react-three/drei";
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 { KTX2Loader, type GLTFLoader } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager";
@@ -20,15 +20,12 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
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 }) {
const renderer = useThree((state) => state.gl);
useEffect(() => () => {
const loader = ktx2Loaders.get(renderer);
if (!loader) return;
loader.dispose();
ktx2Loaders.delete(renderer);
}, [renderer]);
return children;
}