Files
healer-man/src/game/dungeonCombatFidelity.test.ts
T
2026-09-09 11:21:33 -04:00

130 lines
6.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function registerTarget(id = "target", position: readonly [number, number, number] = [0, 0, 10]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
describe("dungeon combat fidelity integration", () => {
beforeEach(() => {
resetCombatSpatialProvider();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 10 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("fidelity", 10, useCombatStore.getState().health);
});
afterEach(resetCombatSpatialProvider);
it("rejects obstructed and rear-arc hostile casts while friendly healing ignores facing", () => {
registerTarget();
let blocked = true;
registerCombatSpatialProvider({
actor: (id) => id === PLAYER_AGGRO_ID
? { id, position: [0, 0, 0], yaw: Math.PI }
: { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 },
hasLineOfSight: () => !blocked,
projectGroundPoint: (point) => point,
});
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "line-of-sight" });
blocked = false;
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "not-facing" });
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
const ally = usePartyStore.getState().members[0];
registerPartyRuntimePosition(ally.id, [0, 0, 10]);
usePartyStore.getState().selectMember(ally.id);
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === ally.id ? { ...member, health: 1 } : member) }));
const friendlyResult = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", { now: 2_000 });
expect(friendlyResult.reason).toBeUndefined();
});
it("cycles only living enemy targets that are in range and line of sight", () => {
for (const [id, position] of [
["blocked-near", [2, 0, 0]],
["visible", [4, 0, 0]],
["too-far", [41, 0, 0]],
] as const) {
useCombatStore.getState().registerMob(id, { name: id });
setMobPosition(id, position);
}
let blockVisibleTarget = false;
registerCombatSpatialProvider({
actor: (id) => ({ id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
hasLineOfSight: (_origin, target) => target[0] !== 2 && (!blockVisibleTarget || target[0] !== 4),
projectGroundPoint: (point) => point,
});
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
blockVisibleTarget = true;
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBeNull();
expect(useCombatStore.getState().selectedTargetId).toBeNull();
});
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
registerTarget();
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
const original = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().damagePlayer(1);
useCombatStore.getState().damagePlayer(1);
useCombatStore.getState().damagePlayer(1);
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: original + 1_000, pushbackHits: 2 });
useCombatStore.getState().cancelCast();
useCombatStore.setState({ globalCooldownEndsAt: 0, shield: 10_000, shieldExpiresAt: Number.MAX_SAFE_INTEGER });
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 5_000 }).ok).toBe(true);
const absorbedOriginal = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().damagePlayer(100);
expect(useCombatStore.getState().activeCast!.completesAt).toBe(absorbedOriginal);
});
it("creates persistent collision-safe ground effects and dynamically ticks actors in their radius", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
registerTarget("late-target", [20, 0, 0]);
registerCombatSpatialProvider({
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
hasLineOfSight: () => true,
projectGroundPoint: (point) => [point[0], 0, point[2]],
});
const groundResult = useCombatStore.getState().castAbility("paladin-consecration", { now: 1_000, groundPosition: [0, 3, 0] });
expect(groundResult.reason).toBeUndefined();
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
setMobPosition("late-target", [2, 0, 0]);
const before = useCombatStore.getState().mobs["late-target"].health;
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["late-target"].health).toBeLessThan(before);
});
it("spends independent Death Knight runes and exposes every combatant through actor snapshots", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 10 });
registerTarget("rune-target", [0, 0, 10]);
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState().deathKnightRunes.filter((rune) => rune.readyAt > 1_000)).toHaveLength(1);
useCombatStore.getState().synchronizeActors();
const actors = useCombatStore.getState().actors;
expect(actors[PLAYER_AGGRO_ID].runes).toHaveLength(6);
expect(actors["rune-target"].kind).toBe("enemy");
for (const member of usePartyStore.getState().members) expect(actors[member.id].kind).toBe("companion");
});
it("emits structured effective damage and healing records", () => {
registerTarget("event-target", [0, 0, 10]);
useCombatStore.getState().damageMob("event-target", 25, 1_000);
useCombatStore.getState().damagePlayer(10);
useCombatStore.getState().healPlayer(100);
const events = useCombatStore.getState().combatEvents;
expect(events.some((event) => event.kind === "damage" && event.targetActorId === "event-target" && event.effectiveAmount > 0)).toBe(true);
expect(events.some((event) => event.kind === "healing" && event.overheal >= 0)).toBe(true);
expect(events.every((event) => event.rawAmount >= event.effectiveAmount)).toBe(true);
});
});