import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { useCombatStore } from "./combatStore"; import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks"; import { createMobPursuit } from "./mobPursuit"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; let visible = true; const melee = { ...DEFAULT_ENEMY_ATTACK, id: "swing", range: 4, initialCooldownMs: [0, 0] as const }; const spell: EnemyAttackDefinition = { ...melee, id: "bolt", range: 30, school: "shadow", animation: "cast", castTimeMs: 1_000 }; function register(id = "enemy", attacks: readonly EnemyAttackDefinition[] = [melee]) { setMobPosition(id, [2, 0, 0]); useCombatStore.getState().registerMob(id, { name: id, attacks, maxHealth: 1_000, xpReward: 0 }); useCombatStore.getState().damageMob(id, 1, 1_000); } describe("dungeon wall combat authorization", () => { beforeEach(() => { resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("walls", 20, useCombatStore.getState().health); visible = true; registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => visible, projectGroundPoint: (point) => point }); }); afterEach(resetCombatSpatialProvider); it("does not swing or consume an attack cooldown through a wall despite melee distance", () => { register(); visible = false; const health = useCombatStore.getState().health; const events: CombatPresentationEvent[] = []; const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event") events.push(message.event); }); expect(useCombatStore.getState().advanceMobCombat(2_000)).toBe(0); expect(useCombatStore.getState().mobs.enemy).toMatchObject({ combatPhase: "chasing", attackRevision: 0 }); expect(useCombatStore.getState().health).toBe(health); expect(events.filter((event) => event.sourceActorId === "enemy")).toHaveLength(0); visible = true; expect(useCombatStore.getState().advanceMobCombat(2_100)).toBeGreaterThan(0); expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("attacking"); unsubscribe(); }); it("does not start an obstructed cast and cancels a pending cast when sight is lost", () => { register("enemy", [spell]); visible = false; useCombatStore.getState().advanceMobCombat(1_000); expect(useCombatStore.getState().mobs.enemy.activeCast).toBeNull(); visible = true; useCombatStore.getState().advanceMobCombat(1_100); expect(useCombatStore.getState().mobs.enemy.activeCast).not.toBeNull(); const health = useCombatStore.getState().health; visible = false; expect(useCombatStore.getState().advanceMobCombat(1_200)).toBe(0); expect(useCombatStore.getState().mobs.enemy).toMatchObject({ activeCast: null, combatPhase: "chasing", attackRevision: 0 }); expect(useCombatStore.getState().health).toBe(health); }); it("does not let a self buff keep a wall-blocked melee mob in attack mode", () => { register("enemy", [melee, { ...melee, id: "buff", target: "self", effect: { kind: "aura", aura: "enrage", durationMs: 10_000 } }]); visible = false; useCombatStore.getState().advanceMobCombat(2_000); expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("chasing"); }); it("does not proximity-pull a visible-distance mob through a wall", () => { setMobPosition("idle", [2, 0, 0]); useCombatStore.getState().registerMob("idle", { name: "idle", attacks: [melee] }); visible = false; expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(0); visible = true; expect(useCombatStore.getState().engageNearbyMobs(1_001)).toBe(1); }); it("resets only the stuck living mob, clears its threat/cast, restores health, and delays repulls", () => { register("enemy", [spell]); register("other"); useCombatStore.getState().advanceMobCombat(1_000); const other = useCombatStore.getState().mobs.other; expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000)).toBe(true); expect(useCombatStore.getState().mobs.enemy).toMatchObject({ health: 1_000, engaged: false, combatPhase: "idle", activeCast: null, targetActorId: null, threatByActor: {}, navigationEvadeUntil: 12_000, dead: false, experienceGranted: false }); expect(useCombatStore.getState().mobs.other).toBe(other); expect(useCombatStore.getState().engageNearbyMobs(11_999)).toBe(0); expect(useCombatStore.getState().engageNearbyMobs(12_000)).toBe(1); }); it("does not recover or resurrect a dead mob", () => { register(); useCombatStore.getState().damageMob("enemy", 10_000, 2_000); expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 3_000)).toBe(false); expect(useCombatStore.getState().mobs.enemy.dead).toBe(true); }); it("rejects damage against a navigation-reset mob during its evade cooldown", () => { register(); useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000); expect(useCombatStore.getState().damageMob("enemy", 500, 3_000).amount).toBe(0); expect(useCombatStore.getState().tauntMob("enemy", { actorId: "__player__", role: "healer" }, 3_000, 3_000)).toBe(false); expect(useCombatStore.getState().mobs.enemy.health).toBe(1_000); }); it("cancels an NPC teammate's hostile cast immediately on lost sight so it can pursue", () => { register(); const member = usePartyStore.getState().members[0]; registerPartyRuntimePosition(member.id, [0, 0, 0]); updatePartyRuntimeTargetVisibility(member.id, "enemy", true); usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id ? { ...candidate, activeCastTargetId: "enemy", activeCast: { abilityId: "mage-frostbolt", school: "frost", mode: "cast", startedAt: 1_000, completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0, origin: [0, 0, 0] as const, targetId: "enemy", } } : candidate) })); visible = false; advanceDungeonPartyCombat(1_100); expect(usePartyStore.getState().members[0]).toMatchObject({ activeCast: null, status: "advancing" }); }); it("nearby teleport recovery leaves mob health, threat, target, and other mobs unchanged", () => { register(); register("other"); const before = useCombatStore.getState().mobs; const navigator = createMobPursuit({ graph: () => null, hasLineOfSight: (from) => from[0] > 2, directAllowed: () => false, routeAllowed: () => true, validateLanding: (point) => point }); const position: [number, number, number] = [0, 0, 0]; const sample = { id: "enemy", targetId: "tank", position, target: [4, 0, 0] as const, home: [0, 0, 0] as const, phase: "chasing" as const, health: before.enemy.health, engaged: true, active: true, casting: false, controlled: false, attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0 }; navigator.step(sample); navigator.step({ ...sample, nowMs: 2_000 }); expect(navigator.step({ ...sample, nowMs: 5_000 })).toBe("teleported"); expect(position[0]).toBeGreaterThan(2); expect(useCombatStore.getState().mobs.enemy).toBe(before.enemy); expect(useCombatStore.getState().mobs.other).toBe(before.other); }); });