130 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|