forked from phenom/healer-man
Release Healer Man 0.1.36
This commit is contained in:
1 parent
40d9f346d8
commit
03a4e3f51c
22 files changed
+1389
-153
No files matched your search
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user