Files
healer-man/src/game/deathHealing.test.ts
T
2026-09-12 21:14:41 -04:00

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 });
});
});