196 lines
10 KiB
TypeScript
196 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { abilitiesForClass, abilityAtLevel, abilityById, type AbilityDefinition } from "./abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
|
import { targetingRulesForAbility } from "./abilityRules";
|
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
|
|
function registerMob(id: string, position: readonly [number, number, number], boss = false): void {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000, xpReward: 0, boss });
|
|
setMobPosition(id, position);
|
|
}
|
|
|
|
describe("extended dungeon combat fidelity", () => {
|
|
beforeEach(() => {
|
|
resetCombatSpatialProvider();
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty("actor-authority", 80, useCombatStore.getState().health);
|
|
useCombatStore.getState().synchronizeActors();
|
|
});
|
|
|
|
afterEach(resetCombatSpatialProvider);
|
|
|
|
it("keeps companion vitals authoritative when compatibility party records are stale", () => {
|
|
const member = usePartyStore.getState().members[0];
|
|
const actor = useCombatStore.getState().actors[member.id];
|
|
useCombatStore.setState((state) => ({
|
|
actors: { ...state.actors, [member.id]: { ...actor, health: 7, alive: true } },
|
|
}));
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((candidate) => candidate.id === member.id
|
|
? { ...candidate, health: candidate.maxHealth }
|
|
: candidate),
|
|
}));
|
|
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
expect(useCombatStore.getState().actors[member.id].health).toBe(7);
|
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(7);
|
|
});
|
|
|
|
it("binds combo points to the enemy that generated them", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
|
registerMob("combo-a", [0, 0, 3]);
|
|
registerMob("combo-b", [1, 0, 2]);
|
|
useCombatStore.getState().selectMob("combo-a");
|
|
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 1_000 }).ok).toBe(true);
|
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-a" });
|
|
|
|
useCombatStore.setState((state) => ({
|
|
globalCooldownEndsAt: 0,
|
|
cooldowns: {},
|
|
resource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools, energy: state.maxResource },
|
|
}));
|
|
useCombatStore.getState().selectMob("combo-b");
|
|
const secondStrike = useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 2_000 });
|
|
expect(secondStrike.ok, secondStrike.reason).toBe(true);
|
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-b" });
|
|
});
|
|
|
|
it("applies boss control and taunt diminishing returns through immunity", () => {
|
|
registerMob("dr-boss", [0, 0, 3], true);
|
|
const durations: number[] = [];
|
|
for (const now of [1_000, 2_000, 3_000]) {
|
|
expect(useCombatStore.getState().controlMob("dr-boss", `stun-${now}`, "stun", 8_000, now)).toBe(true);
|
|
const status = useCombatStore.getState().mobs["dr-boss"].statuses.find((entry) => entry.kind === "stun")!;
|
|
durations.push(status.endsAt - now);
|
|
}
|
|
expect(durations).toEqual([3_000, 1_500, 750]);
|
|
expect(useCombatStore.getState().controlMob("dr-boss", "stun-immune", "stun", 8_000, 4_000)).toBe(false);
|
|
|
|
const tauntDurations: number[] = [];
|
|
for (const now of [5_000, 6_000, 7_000]) {
|
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, now)).toBe(true);
|
|
tauntDurations.push(useCombatStore.getState().mobs["dr-boss"].forcedTarget!.endsAt - now);
|
|
}
|
|
expect(tauntDurations).toEqual([3_000, 1_950, 1_268]);
|
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, 8_000)).toBe(false);
|
|
});
|
|
|
|
it("moves and confirms a collision-projected ground reticle", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
const groundAbility = abilitiesForClass("mage")
|
|
.map((ability) => abilityAtLevel(ability, 80))
|
|
.find((ability) => targetingRulesForAbility(ability).shape === "ground" && ability.range.max > 1)!;
|
|
expect(groundAbility).toBeDefined();
|
|
registerCombatSpatialProvider({
|
|
actor: (id) => ({ id, position: [0, 0, 0], yaw: 0 }),
|
|
hasLineOfSight: () => true,
|
|
projectGroundPoint: (point, maximumDistance, origin = [0, 0, 0]) => {
|
|
const dx = point[0] - origin[0];
|
|
const dz = point[2] - origin[2];
|
|
const distance = Math.hypot(dx, dz);
|
|
const scale = distance > maximumDistance ? maximumDistance / distance : 1;
|
|
return [origin[0] + dx * scale, 0, origin[2] + dz * scale];
|
|
},
|
|
});
|
|
expect(useCombatStore.getState().beginGroundTarget(groundAbility.id)).toBe(true);
|
|
const before = useCombatStore.getState().pendingGroundTarget!;
|
|
expect(before.valid).toBe(true);
|
|
useCombatStore.getState().moveGroundTarget(1, 0, 0, 0.1);
|
|
const moved = useCombatStore.getState().pendingGroundTarget!;
|
|
expect(moved.position).not.toEqual(before.position);
|
|
expect(Math.hypot(moved.position[0], moved.position[2])).toBeLessThanOrEqual(moved.maximumRange);
|
|
expect(useCombatStore.getState().confirmGroundTarget(undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().pendingGroundTarget).toBeNull();
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps summons targetable, credits their owner, replaces totems, and expires them", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerMob("summon-target", [0, 0, 3]);
|
|
useCombatStore.getState().selectMob("summon-target");
|
|
expect(useCombatStore.getState().castAbility("rom-warden-primary-1", { now: 1_000 }).ok).toBe(true);
|
|
useCombatStore.getState().tick(0.1, 5_000);
|
|
const summon = useCombatStore.getState().summons[0];
|
|
expect(summon).toBeDefined();
|
|
expect(useCombatStore.getState().actors[summon.id]).toMatchObject({ kind: "summon", ownerId: PLAYER_AGGRO_ID, alive: true });
|
|
expect(useCombatStore.getState().selectFriendlyActor(summon.id)).toBe(true);
|
|
|
|
const beforeDamage = summon.health;
|
|
expect(useCombatStore.getState().damageSummon(summon.id, 5)).toBe(5);
|
|
expect(useCombatStore.getState().actors[summon.id].health).toBe(beforeDamage - 5);
|
|
expect(useCombatStore.getState().healActor(summon.id, 5, { sourceActorId: PLAYER_AGGRO_ID }).effectiveAmount).toBe(5);
|
|
|
|
const baseAbility = abilityById("rom-warden-primary-1")!;
|
|
const combatSummonAbility: AbilityDefinition = { ...baseAbility, id: "test-guardian", name: "Test Guardian" };
|
|
useCombatStore.getState().summonActor(
|
|
PLAYER_AGGRO_ID,
|
|
combatSummonAbility,
|
|
{ kind: "summon", creatureId: 2, coefficient: 0.5, durationMs: 10_000 },
|
|
[0, 0, 0],
|
|
"summon-target",
|
|
6_000,
|
|
);
|
|
useCombatStore.getState().tick(1, 7_500);
|
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[PLAYER_AGGRO_ID]).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[summon.id] ?? 0).toBe(0);
|
|
|
|
const totemAbility: AbilityDefinition = { ...baseAbility, id: "test-totem", name: "Test Totem" };
|
|
const totemEffect = { kind: "summon" as const, creatureId: 1, coefficient: 0.2, durationMs: 10_000 };
|
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [0, 0, 0], null, 8_000);
|
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [1, 0, 0], null, 8_100);
|
|
expect(useCombatStore.getState().summons.filter((candidate) => candidate.exclusiveGroup === `${PLAYER_AGGRO_ID}:totem`)).toHaveLength(1);
|
|
|
|
useCombatStore.getState().tick(0.1, 20_001);
|
|
expect(useCombatStore.getState().summons.some((candidate) => candidate.id === summon.id)).toBe(false);
|
|
expect(useCombatStore.getState().actors[summon.id]).toBeUndefined();
|
|
});
|
|
|
|
it("executes damaging abilities from Classic, RuneWaker, and CoA catalogs through one runtime", () => {
|
|
const cases = [
|
|
{ classId: "mage" as const, level: 20, source: "classic" },
|
|
{ classId: "rom-mage" as const, level: 20, source: "runewaker" },
|
|
{ classId: "stormbringer" as const, level: 60, source: "coa" },
|
|
];
|
|
registerCombatSpatialProvider({
|
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
|
hasLineOfSight: () => true,
|
|
projectGroundPoint: (point) => point,
|
|
});
|
|
for (const entry of cases) {
|
|
useCombatStore.getState().initializeCharacter({ classId: entry.classId, level: entry.level });
|
|
const ability = abilitiesForClass(entry.classId)
|
|
.map((candidate) => abilityAtLevel(candidate, entry.level))
|
|
.find((candidate) => (
|
|
!candidate.passive
|
|
&& candidate.target === "hostile"
|
|
&& candidate.talentEntryId === undefined
|
|
&& candidate.effects.some((effect) => effect.kind === "damage")
|
|
));
|
|
expect(ability, `${entry.source} executable damage ability`).toBeDefined();
|
|
const id = `${entry.source}-target`;
|
|
const range = Math.max(1, Math.min(10, ability!.range.max));
|
|
registerMob(id, [0, 0, range]);
|
|
useCombatStore.getState().selectMob(id);
|
|
const before = useCombatStore.getState().mobs[id].health;
|
|
const cast = useCombatStore.getState().castAbility(ability!.id, { now: 1_000 });
|
|
expect(cast.ok, `${entry.source} cast: ${cast.reason ?? "ok"}`).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
|
expect(useCombatStore.getState().mobs[id].health, `${entry.source} damage`).toBeLessThan(before);
|
|
clearMobRuntimeRegistry();
|
|
}
|
|
});
|
|
});
|