132 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|