Release Healer Man 0.1.6
This commit is contained in:
1 parent
55cfd43d66
commit
b12fb84859
96 files changed
+10874
-609
No files matched your search
@@ -0,0 +1,195 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user