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