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); }); });