302 lines
15 KiB
TypeScript
302 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { defaultActionBarForCharacter, resourceProfileForClass } from "./abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
} from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority";
|
|
|
|
describe("RuneWaker combat runtime", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
});
|
|
|
|
it("creates keyed pools for both classes and changes the active resource on orientation swap", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-warrior",
|
|
secondaryClassId: "rom-scout",
|
|
raceId: "rom-human",
|
|
level: 70,
|
|
xp: 456,
|
|
});
|
|
expect(useCombatStore.getState()).toMatchObject({
|
|
classId: "rom-warrior",
|
|
secondaryClassId: "rom-scout",
|
|
resourceName: "Rage",
|
|
xp: 456,
|
|
});
|
|
expect(useCombatStore.getState().maxResourcePools).toMatchObject({ rage: 100, focus: 100 });
|
|
expect(new Set(Object.values(useCombatStore.getState().actionBindings).filter(Boolean))).toEqual(
|
|
new Set(defaultActionBarForCharacter("rom-warrior", "rom-scout", 70).slice(0, 24)),
|
|
);
|
|
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-scout",
|
|
secondaryClassId: "rom-warrior",
|
|
raceId: "rom-human",
|
|
level: 70,
|
|
xp: 456,
|
|
});
|
|
expect(useCombatStore.getState()).toMatchObject({
|
|
classId: "rom-scout",
|
|
secondaryClassId: "rom-warrior",
|
|
resourceName: "Focus",
|
|
xp: 456,
|
|
});
|
|
expect(useCombatStore.getState().maxResourcePools).toMatchObject({ focus: 100, rage: 100 });
|
|
});
|
|
|
|
it("aggregates multi-cost rows by resource before validating a cast", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warden",
|
|
raceId: "rom-elf",
|
|
level: 50,
|
|
});
|
|
const maximumMana = useCombatStore.getState().maxResourcePools.mana!;
|
|
const availableMana = Math.max(100, Math.round(maximumMana * 0.05));
|
|
useCombatStore.setState((state) => ({
|
|
resource: availableMana,
|
|
resourcePools: { ...state.resourcePools, mana: availableMana },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("rom-mage-rom-warden-elite-50", undefined, 1_000)).toMatchObject({
|
|
ok: false,
|
|
reason: "not-enough-resource",
|
|
});
|
|
});
|
|
|
|
it.each(["rom-warrior", "rom-scout", "rom-rogue"] as const)(
|
|
"preserves %s secondary mana in player and companion resource snapshots",
|
|
(classId) => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId, secondaryClassId: "rom-warden", raceId: "rom-elf", level: 80,
|
|
});
|
|
const state = useCombatStore.getState();
|
|
expect(state.maxResourcePools.mana).toBe(100);
|
|
expect(state.resourcePools.mana).toBe(100);
|
|
state.synchronizeActors();
|
|
expect(useCombatStore.getState().actors[PLAYER_AGGRO_ID].resources.mana).toMatchObject({ current: 100, maximum: 100 });
|
|
|
|
usePartyStore.getState().initializeParty("rom-secondary-mana", 80, state.health);
|
|
const member = {
|
|
...usePartyStore.getState().members[0]!, classId, raceId: state.raceId,
|
|
resourceType: resourceProfileForClass(classId).type, maxResource: state.maxResource,
|
|
resourcePools: state.resourcePools, gearStats: state.gearStats,
|
|
};
|
|
const actor = companionActorFromMember(member);
|
|
expect(actor.resources.mana).toMatchObject({ current: 100, maximum: 100 });
|
|
expect(companionActorWithMemberRuntime(actor, member).resources.mana)
|
|
.toMatchObject({ current: 100, maximum: 100 });
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["rom-priest", "rom-human", "rom-priest-general-1"],
|
|
["rom-druid", "rom-elf", "rom-druid-primary-0"],
|
|
] as const)("lets a level-one %s cast its starting attack", (classId, raceId, abilityId) => {
|
|
useCombatStore.getState().initializeCharacter({ classId, raceId, level: 1 });
|
|
useCombatStore.getState().registerMob("starter-dummy", { name: "Starter Dummy", maxHealth: 1_000 });
|
|
setMobPosition("starter-dummy", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("starter-dummy");
|
|
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("uses source coefficients for direct damage even without equipment", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 20 });
|
|
useCombatStore.getState().registerMob("coefficient-dummy", { name: "Coefficient Dummy", maxHealth: 1_000 });
|
|
setMobPosition("coefficient-dummy", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("coefficient-dummy");
|
|
useCombatStore.setState((state) => ({
|
|
resource: 100,
|
|
resourcePools: { ...state.resourcePools, rage: 100 },
|
|
}));
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().mobs["coefficient-dummy"]!.health).toBeLessThan(1_000);
|
|
});
|
|
|
|
it("finishes a RuneWaker direct heal on the selected party member", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 });
|
|
usePartyStore.getState().initializeParty("rom-urgent-heal", 20, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.getState().damageMember(member.id, 500);
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
const injuredHealth = usePartyStore.getState().members[0]!.health;
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-priest-general-0", undefined, 1_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 2_100);
|
|
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
|
|
});
|
|
|
|
it("uses native RoM Rage gains from auto attacks and health damage", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-warrior",
|
|
raceId: "rom-human",
|
|
level: 1,
|
|
});
|
|
useCombatStore.getState().registerMob("rage-dummy", { name: "Rage Dummy", maxHealth: 1_000 });
|
|
setMobPosition("rage-dummy", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("rage-dummy");
|
|
|
|
useCombatStore.setState({ shield: 100 });
|
|
expect(useCombatStore.getState().damagePlayer(5)).toBe(0);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(0);
|
|
useCombatStore.setState({ shield: 0 });
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-warrior-general-0", undefined, 1_000)).toMatchObject({
|
|
ok: false,
|
|
reason: "not-enough-resource",
|
|
});
|
|
expect(useCombatStore.getState().autoAttackTargetId).toBe("rage-dummy");
|
|
|
|
useCombatStore.getState().tick(0.1, 1_000);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(6);
|
|
expect(useCombatStore.getState().resource).toBe(6);
|
|
const healthAfterFirstSwing = useCombatStore.getState().mobs["rage-dummy"]!.health;
|
|
expect(healthAfterFirstSwing).toBeLessThan(1_000);
|
|
|
|
useCombatStore.getState().tick(1, 2_000);
|
|
expect(useCombatStore.getState().mobs["rage-dummy"]!.health).toBe(healthAfterFirstSwing);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(6);
|
|
|
|
expect(useCombatStore.getState().damagePlayer(5)).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(13);
|
|
|
|
useCombatStore.getState().tick(1, 3_400);
|
|
const secondSwing = useCombatStore.getState().lastCombatResult!;
|
|
if (secondSwing.finalDamage > 0) {
|
|
expect(useCombatStore.getState().mobs["rage-dummy"]!.health).toBeLessThan(healthAfterFirstSwing);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(19);
|
|
} else {
|
|
expect(useCombatStore.getState().mobs["rage-dummy"]!.health).toBe(healthAfterFirstSwing);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(13);
|
|
}
|
|
});
|
|
|
|
it("uses Champion's attack cadence instead of Warrior's per-hit Rage rules", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 1 });
|
|
useCombatStore.getState().registerMob("champion-dummy", { name: "Champion Dummy", maxHealth: 1_000 });
|
|
setMobPosition("champion-dummy", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("champion-dummy");
|
|
useCombatStore.getState().castAbility("rom-champion-general-0", undefined, 1_000);
|
|
|
|
useCombatStore.getState().tick(1, 1_000);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(6);
|
|
|
|
useCombatStore.getState().damagePlayer(5);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(6);
|
|
|
|
useCombatStore.getState().tick(1, 2_000);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(12);
|
|
});
|
|
|
|
it("decays native RoM Rage while out of combat", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 1 });
|
|
useCombatStore.setState((state) => ({
|
|
resource: 20,
|
|
resourcePools: { ...state.resourcePools, rage: 20 },
|
|
}));
|
|
|
|
useCombatStore.getState().tick(1, 1_000);
|
|
|
|
expect(useCombatStore.getState().resource).toBe(17);
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(17);
|
|
});
|
|
|
|
it("keeps native summons as temporary allied combat actors", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
|
|
const maximumMana = useCombatStore.getState().maxResourcePools.mana!;
|
|
useCombatStore.getState().castAbility("rom-warden-primary-1", undefined, 1_000);
|
|
useCombatStore.getState().tick(0.1, 5_000);
|
|
expect(useCombatStore.getState().summons).toEqual([
|
|
expect.objectContaining({ sourceAbilityId: "rom-warden-primary-1", creatureId: 102297 }),
|
|
]);
|
|
expect(useCombatStore.getState().resourcePools.mana).toBe(maximumMana - Math.round(maximumMana * 0.15));
|
|
useCombatStore.getState().tick(0.1, 20_001);
|
|
expect(useCombatStore.getState().summons).toEqual([]);
|
|
});
|
|
|
|
it("raises an incapacitated party member with source resurrection behavior", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 });
|
|
usePartyStore.getState().initializeParty("rom-resurrection", 20, useCombatStore.getState().health, "healer", 2, "rom");
|
|
const member = usePartyStore.getState().members[0]!;
|
|
usePartyStore.getState().damageMember(member.id, member.maxHealth * 10);
|
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
expect(usePartyStore.getState().members[0]!.health).toBe(0);
|
|
expect(useCombatStore.getState().castAbility("rom-priest-primary-5", undefined, 1_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 7_000);
|
|
expect(useCombatStore.getState()).toMatchObject({ activeCast: null, feedback: { kind: "success" } });
|
|
expect(usePartyStore.getState().members[0]).toMatchObject({
|
|
health: Math.round(member.maxHealth * 0.35),
|
|
status: "ready",
|
|
});
|
|
});
|
|
|
|
it("chains Plasma Arrow, Electric Compression, and Static Field through their source states", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", raceId: "rom-human", level: 20 });
|
|
useCombatStore.getState().registerMob("static-dummy", { name: "Static Dummy", maxHealth: 10_000 });
|
|
setMobPosition("static-dummy", [3, 0, 0]);
|
|
useCombatStore.getState().selectMob("static-dummy");
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-55", undefined, 1_000)).toMatchObject({
|
|
ok: false,
|
|
reason: "condition-not-met",
|
|
});
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-52", undefined, 2_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 4_100);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-charged"))).toBe(true);
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-55", undefined, 4_200)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-charged"))).toBe(false);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-static-field-charge"))).toBe(true);
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-56", undefined, 5_800)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 6_900);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-static-field-charge"))).toBe(false);
|
|
});
|
|
|
|
it("lets Remodeled Body prevent lethal damage once", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 80 });
|
|
const now = Date.now();
|
|
expect(useCombatStore.getState().castAbility("rom-champion-primary-16", undefined, now)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-remodeled-body"))).toBe(true);
|
|
|
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
|
|
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-remodeled-body"))).toBe(false);
|
|
});
|
|
|
|
it("builds and consumes Revival charges from damage taken", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-rogue",
|
|
secondaryClassId: "rom-warden",
|
|
raceId: "rom-elf",
|
|
level: 80,
|
|
});
|
|
useCombatStore.getState().damagePlayer(100);
|
|
useCombatStore.getState().damagePlayer(100);
|
|
useCombatStore.getState().damagePlayer(100);
|
|
const revival = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"));
|
|
expect(revival?.stacks).toBe(3);
|
|
const injuredHealth = useCombatStore.getState().health;
|
|
const manaBefore = useCombatStore.getState().resourcePools.mana!;
|
|
|
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warden-elite-60", undefined, Date.now())).toMatchObject({ ok: true });
|
|
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(injuredHealth);
|
|
expect(useCombatStore.getState().resourcePools.mana).toBe(manaBefore - 15);
|
|
expect(useCombatStore.getState().resourcePools.energy).toBe(100);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"))).toBe(false);
|
|
});
|
|
});
|