forked from phenom/healer-man
140 lines
7.8 KiB
TypeScript
140 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|