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

132 lines
7.6 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { activateDungeonEncounterDefinitions, resetDungeonEncounters } from "./dungeonEncounterRuntime";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { configureOnlineSessionRuntime } from "./onlineSessionRuntime";
import { clearPartyRuntimeRegistry, consumePartyRuntimeRejoin, getPartyRuntimePosition, partyRuntimeIsRejoining, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { resurrectDeadPartyMembers } from "./partyResurrection";
import { derivePartySpawnSlots } from "./partySpawnSlots";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
const dungeon = requireDungeonDefinition("stormwind-stockade");
function killMember(index: number) {
const member = usePartyStore.getState().members[index];
useCombatStore.getState().commitCompanionActors([{ ...member, health: 0, status: "down" }]);
return member.id;
}
describe("fallen NPC checkpoint resurrection", () => {
beforeEach(() => {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
clearPartyRuntimeRegistry();
useGameStore.getState().activateDungeon("stormwind-stockade");
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
resetDungeonEncounters("dungeon", dungeon.id);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
useCombatStore.setState({ mobs: {} });
usePartyStore.getState().initializeParty("resurrection-party", 16, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [99, 0, 99]);
});
it("restores only fallen members at the original entrance, not the current saved spawn", () => {
const deadId = killMember(0);
usePartyStore.getState().setCommand("stop");
const living = usePartyStore.getState().members.slice(1);
const livingActors = living.map((member) => useCombatStore.getState().actors[member.id]);
const gameBefore = useGameStore.getState();
const playerBefore = useCombatStore.getState().health;
useGameStore.setState({ activeSpawn: { footPosition: [80, 5, 80], yaw: 1 } });
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
expect(usePartyStore.getState().members[0]).toMatchObject({ health: usePartyStore.getState().members[0].maxHealth, status: "advancing" });
expect(getPartyRuntimePosition(deadId)).toEqual(derivePartySpawnSlots(dungeon.entrance, 5)[1].footPosition);
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
expect(consumePartyRuntimeRejoin(deadId)).not.toBeNull();
expect(consumePartyRuntimeRejoin(deadId)).toBeNull();
expect(partyRuntimeIsRejoining(deadId)).toBe(true);
expect(usePartyStore.getState().members.slice(1)).toEqual(living);
expect(living.map((member) => useCombatStore.getState().actors[member.id])).toEqual(livingActors);
expect(usePartyStore.getState().command).toBe("stop");
expect(useCombatStore.getState().health).toBe(playerBefore);
expect(useGameStore.getState().playerPosition).toEqual(gameBefore.playerPosition);
expect(useGameStore.getState().resetRevision).toBe(gameBefore.resetRevision);
expect(useCombatStore.getState().combatEvents.at(-1)).toMatchObject({ kind: "resurrection", targetActorId: deadId });
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(0);
});
it("restores all fallen members at a killed boss without resetting any living encounter", () => {
const boss = dungeon.bosses[0];
useCombatStore.getState().registerMob(boss.id, { name: boss.name, maxHealth: 1, boss: true });
useCombatStore.getState().damageMob(boss.id, 1, 100);
const mobsBefore = useCombatStore.getState().mobs;
const ids = usePartyStore.getState().members.map((_, index) => killMember(index));
expect(resurrectDeadPartyMembers(`boss:${boss.id}`, 1_000)).toBe(ids.length);
const slots = derivePartySpawnSlots({ footPosition: boss.position, yaw: dungeon.entrance.yaw }, ids.length + 1);
ids.forEach((id, index) => expect(getPartyRuntimePosition(id)).toEqual(slots[index + 1].footPosition));
expect(useCombatStore.getState().mobs).toBe(mobsBefore);
});
it("enforces boss combat at action time and remains usable during trash combat", () => {
const id = killMember(0);
useCombatStore.getState().registerMob("active-boss", { name: "Boss", maxHealth: 100, boss: true });
useCombatStore.getState().damageMob("active-boss", 1, 100);
const before = useCombatStore.getState();
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(0);
expect(useCombatStore.getState()).toBe(before);
expect(partyRuntimeIsRejoining(id)).toBe(false);
useCombatStore.getState().damageMob("active-boss", 99, 101);
useCombatStore.getState().registerMob("trash", { name: "Trash", maxHealth: 100 });
useCombatStore.getState().damageMob("trash", 1, 102);
expect(resurrectDeadPartyMembers("entrance", 1_001)).toBe(1);
});
it("rejects locked, stale, or arbitrary checkpoint IDs without any mutation", () => {
killMember(0);
const before = useCombatStore.getState();
for (const id of [`boss:${dungeon.bosses[0].id}`, "boss:other-dungeon", "20,30,40"]) {
expect(resurrectDeadPartyMembers(id, 1_000)).toBe(0);
expect(useCombatStore.getState()).toBe(before);
}
});
it("cannot spend or bypass Manastorm revival charges or run on a non-authority client", () => {
killMember(0);
useGameStore.setState({ gameMode: "manastorm" });
expect(resurrectDeadPartyMembers()).toBe(0);
useGameStore.setState({ gameMode: "dungeon" });
configureOnlineSessionRuntime({ active: true, authority: false, localAccountId: "guest" });
expect(resurrectDeadPartyMembers()).toBe(0);
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
expect(resurrectDeadPartyMembers("entrance", NaN)).toBe(0);
});
it("does not resurrect the dead player with the NPC button", () => {
killMember(0);
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
expect(resurrectDeadPartyMembers("entrance", 1_000)).toBe(1);
expect(useCombatStore.getState().health).toBe(0);
expect(useCombatStore.getState().combatEvents.at(-1)?.targetActorId).not.toBe(PLAYER_AGGRO_ID);
});
it("removes lingering effects only on the resurrected member, leaving living allies' HoTs intact", () => {
const members = usePartyStore.getState().members;
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
usePartyStore.getState().selectMember(members[0].id);
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000)).toMatchObject({ ok: true });
usePartyStore.getState().selectMember(members[1].id);
expect(useCombatStore.getState().castAbility(renew.id, undefined, 5_000).ok).toBe(true);
const livingEffects = useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id);
expect(livingEffects.length).toBeGreaterThan(0);
killMember(0);
expect(resurrectDeadPartyMembers("entrance", 6_000)).toBe(1);
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === members[0].id)).toBe(false);
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === members[1].id)).toEqual(livingEffects);
});
});