239 lines
12 KiB
TypeScript
239 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { abilityAtLevel } from "./abilityCatalog";
|
|
import { applyAura } from "./combatAuras";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type TimedCombatEffect } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { respawnDefeatedPlayer } from "./deathRespawn";
|
|
import { useGameStore } from "./store";
|
|
|
|
function killPlayer(): void {
|
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("death");
|
|
}
|
|
|
|
function selfHot(): TimedCombatEffect {
|
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
|
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot" && entry.targetId === null);
|
|
expect(effect).toBeDefined();
|
|
return effect!;
|
|
}
|
|
|
|
function setMemberHealth(id: string, health: number): void {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id)!;
|
|
useCombatStore.getState().commitCompanionActors([{ ...member, health, status: health <= 0 ? "down" : "ready" }]);
|
|
}
|
|
|
|
describe("healing must not implicitly resurrect defeated players", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1, xp: 0 });
|
|
usePartyStore.getState().initializeParty("death-healing", 1, useCombatStore.getState().health);
|
|
usePartyStore.getState().selectMember(null);
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useGameStore.getState().resetAtEntrance();
|
|
});
|
|
|
|
it("reproduces death with a self HoT and rejects every overdue tick", () => {
|
|
const effect = selfHot();
|
|
killPlayer();
|
|
const deathEvent = useCombatStore.getState().playerAnimationEvent;
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt + effect.intervalMs * 10);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().playerAnimationEvent).toBe(deathEvent);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 100_000))
|
|
.toMatchObject({ ok: false, reason: "player-dead" });
|
|
});
|
|
|
|
it("removes a dead player's HoT even before its next tick is due", () => {
|
|
const effect = selfHot();
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0, effect.nextTickAt - 1);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects the actual Priest Renew tick after lethal damage", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
|
|
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew"
|
|
&& ability.effects.some((effect) => effect.kind === "hot"));
|
|
expect(renew).toBeDefined();
|
|
expect(useCombatStore.getState().castAbility(renew!.id, undefined, 1_000).ok).toBe(true);
|
|
const effect = useCombatStore.getState().effects.find((entry) => entry.kind === "hot")!;
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
});
|
|
|
|
it("does not let passive spirit or equipment regeneration heal a corpse", () => {
|
|
const state = useCombatStore.getState();
|
|
useCombatStore.setState({ gearStats: { ...state.gearStats, healthPer5: 500, spirit: 500 } });
|
|
killPlayer();
|
|
for (let frame = 0; frame < 30; frame += 1) {
|
|
useCombatStore.getState().tick(0.1, 10_000 + frame * 100);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("rejects companion HoTs with an explicit player target after death", () => {
|
|
const effect = selfHot();
|
|
const source = usePartyStore.getState().members[0].id;
|
|
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
});
|
|
|
|
it("living explicit player HoTs retain future ticks and healing combat events", () => {
|
|
const effect = selfHot();
|
|
const source = usePartyStore.getState().members[0].id;
|
|
useCombatStore.setState({ effects: [{ ...effect, sourceActorId: source, targetId: PLAYER_AURA_ENTITY_ID }] });
|
|
useCombatStore.getState().damagePlayer(50);
|
|
const before = useCombatStore.getState().health;
|
|
useCombatStore.getState().tick(0, effect.nextTickAt);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
|
expect(useCombatStore.getState().effects).toHaveLength(1);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
|
&& event.sourceActorId === source && event.targetActorId === PLAYER_AURA_ENTITY_ID
|
|
&& event.effectiveAmount > 0)).toBe(true);
|
|
});
|
|
|
|
it("rejects newly applied teammate HoTs on dead targets", () => {
|
|
const ability = abilityAtLevel(useCombatStore.getState().abilities.find((entry) => entry.id === "druid-rejuvenation")!, 1);
|
|
const hot = ability.effects.find((effect) => effect.kind === "hot")!;
|
|
expect(hot.kind).toBe("hot");
|
|
if (hot.kind !== "hot") throw new Error("Expected Rejuvenation HoT");
|
|
useCombatStore.getState().synchronizeActors();
|
|
const source = usePartyStore.getState().members[0].id;
|
|
killPlayer();
|
|
expect(useCombatStore.getState().applyTimedActorEffect(source, PLAYER_AURA_ENTITY_ID, ability, hot, 1_000)).toBe(false);
|
|
const member = usePartyStore.getState().members[1];
|
|
setMemberHealth(member.id, 0);
|
|
expect(useCombatStore.getState().applyTimedActorEffect(source, member.id, ability, hot, 1_000)).toBe(false);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
});
|
|
|
|
it("removes a fallen ally's HoT without changing other party members", () => {
|
|
const member = usePartyStore.getState().members[0];
|
|
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
|
const effect = useCombatStore.getState().effects[0];
|
|
setMemberHealth(member.id, 0);
|
|
const others = usePartyStore.getState().members.slice(1);
|
|
useCombatStore.getState().tick(0, effect.nextTickAt);
|
|
expect(usePartyStore.getState().members[0].health).toBe(0);
|
|
expect(usePartyStore.getState().members.slice(1)).toEqual(others);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
});
|
|
|
|
it("ground healing skips the dead player while continuing to heal living allies", () => {
|
|
const effect = selfHot();
|
|
const member = usePartyStore.getState().members[0];
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
setMemberHealth(member.id, 1);
|
|
useCombatStore.setState({ effects: [], areaEffects: [{
|
|
id: "healing-ground", sourceActorId: member.id, sourceAbilityId: effect.sourceAbilityId,
|
|
position: [0, 0, 0], radius: 10, createdAt: 1_000, expiresAt: 20_000,
|
|
nextTickAt: 4_000, tickIntervalMs: 3_000, fixedHealingPerTick: 50,
|
|
}] });
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0.1, 4_000);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "healing"
|
|
&& event.targetActorId === PLAYER_AURA_ENTITY_ID && event.effectiveAmount > 0)).toBe(false);
|
|
});
|
|
|
|
it("a HoT on a living ally continues even when its player caster dies", () => {
|
|
const member = usePartyStore.getState().members[0];
|
|
registerPartyRuntimePosition(member.id, [2, 0, 0]);
|
|
setMemberHealth(member.id, 1);
|
|
usePartyStore.getState().selectMember(member.id);
|
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 1_000).ok).toBe(true);
|
|
const effect = useCombatStore.getState().effects[0];
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(usePartyStore.getState().members[0].health).toBeGreaterThan(1);
|
|
expect(useCombatStore.getState().effects).toHaveLength(1);
|
|
});
|
|
|
|
it("periodic damage cannot proc an ordinary self heal after its caster dies", () => {
|
|
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1_000, xpReward: 0 });
|
|
setMobPosition("dot-target", [2, 0, 0]);
|
|
const aura = applyAura([], { sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID,
|
|
now: 1_000, definition: {
|
|
id: "periodic-heal-proc", name: "Periodic heal", disposition: "buff", durationMs: null,
|
|
procs: [{ id: "heal", triggers: "periodic", chance: 1,
|
|
action: { kind: "heal", target: "aura-target", amount: 100 } }],
|
|
},
|
|
}).auras;
|
|
useCombatStore.setState({ auras: [...aura], effects: [{
|
|
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
|
fixedAmount: 1, remainingTicks: 3, intervalMs: 3_000, nextTickAt: 4_000,
|
|
}] });
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0, 4_000);
|
|
expect(useCombatStore.getState().mobs["dot-target"].health).toBeLessThan(1_000);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
});
|
|
|
|
it("a lingering damage tick can award a level without resurrecting its dead caster", () => {
|
|
useCombatStore.getState().registerMob("dot-target", { name: "Target", maxHealth: 1, xpReward: 100_000 });
|
|
setMobPosition("dot-target", [2, 0, 0]);
|
|
useCombatStore.setState({ effects: [{
|
|
id: "dot", kind: "dot", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
|
sourceAbilityId: "druid-rejuvenation", targetId: "dot-target", coefficient: 1,
|
|
fixedAmount: 100, remainingTicks: 1, intervalMs: 3_000, nextTickAt: 4_000,
|
|
}] });
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0, 4_000);
|
|
expect(useCombatStore.getState().level).toBeGreaterThan(1);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
});
|
|
|
|
it("living self HoTs and regeneration still restore health", () => {
|
|
useCombatStore.getState().damagePlayer(50);
|
|
const effect = selfHot();
|
|
const before = useCombatStore.getState().health;
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
|
expect(useCombatStore.getState().health).toBeLessThanOrEqual(useCombatStore.getState().maxHealth);
|
|
});
|
|
|
|
it("explicit resurrection and normal respawn still work after discarding corpse HoTs", () => {
|
|
const effect = selfHot();
|
|
killPlayer();
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
|
const restored = useCombatStore.getState().revivePlayer(0.35);
|
|
expect(restored).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", undefined, 20_000).ok).toBe(true);
|
|
killPlayer();
|
|
expect(respawnDefeatedPlayer()).toBe(true);
|
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
|
expect(useCombatStore.getState().effects).toHaveLength(0);
|
|
});
|
|
|
|
it("actor-based party resurrection clears the corpse animation and actor death state", () => {
|
|
const effect = selfHot();
|
|
killPlayer();
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().tick(0.1, effect.nextTickAt);
|
|
const restored = useCombatStore.getState().reviveActor(PLAYER_AURA_ENTITY_ID, 0.35,
|
|
usePartyStore.getState().members[0].id, "party-resurrection", 10_000);
|
|
expect(restored).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().playerAnimationEvent?.kind).toBe("ability-cancel");
|
|
expect(useCombatStore.getState().actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: restored });
|
|
});
|
|
});
|