import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { installCombatClock } from "./combatClock"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { partyFightStillActive, respawnDefeatedPlayer } from "./deathRespawn"; import { activateDungeonEncounterDefinitions, advanceDungeonEncounters, resetDungeonEncounters } from "./dungeonEncounterRuntime"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { clearPartyRuntimeRegistry, getPartyRuntimePosition, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; const dungeon = requireDungeonDefinition("stormwind-stockade"); const fightPosition = [2, 0, 0] as const; function startFight(boss = false): string { const id = boss ? dungeon.bosses[1].id : "ongoing-pack"; useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, boss, xpReward: 0 }); setMobPosition(id, fightPosition); const tank = usePartyStore.getState().members[0]; useCombatStore.getState().damageMob(id, 1, 100, { actorId: tank.id, role: "tank" }); for (const member of usePartyStore.getState().members) { updatePartyRuntimeTargetVisibility(member.id, id, true); } return id; } function killPlayer(): void { useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10); useCombatStore.getState().synchronizeActors(); expect(useCombatStore.getState().health).toBe(0); } describe("personal checkpoint resurrection during an ongoing party fight", () => { let restoreClock: () => void; beforeEach(() => { restoreClock = installCombatClock(() => 1_000); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useGameStore.getState().activateDungeon(dungeon.id); activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []); resetDungeonEncounters("dungeon", dungeon.id); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 }); usePartyStore.getState().initializeParty("personal-respawn", 16, useCombatStore.getState().health); usePartyStore.getState().selectMember(null); for (const member of usePartyStore.getState().members) { registerPartyRuntimePosition(member.id, [0, 0, 0]); } useCombatStore.getState().synchronizeActors(); }); afterEach(() => restoreClock()); it("revives only the player without restoring companions, resetting the pull, or triggering Recall", () => { const id = startFight(); advanceDungeonPartyCombat(1_000); useCombatStore.getState().advanceMobCombat(1_000); const members = usePartyStore.getState().members; useCombatStore.getState().commitCompanionActors([ { ...members[0], health: Math.round(members[0].maxHealth / 2) }, { ...members[1], health: 0, status: "down" }, { ...members[2], activeCast: { abilityId: "ongoing-party-cast", school: "fire", mode: "cast", startedAt: 1_000, completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0, targetId: id, }, activeCastTargetId: id }, ]); killPlayer(); const combatBefore = useCombatStore.getState(); const partyBefore = usePartyStore.getState(); const gameBefore = useGameStore.getState(); const positions = partyBefore.members.map((member) => getPartyRuntimePosition(member.id)); expect(respawnDefeatedPlayer("entrance")).toBe(true); const after = useCombatStore.getState(); expect(after.health).toBe(after.maxHealth); expect(after.resource).toBe(after.maxResource); expect(after.playerPosition).toEqual(dungeon.entrance.footPosition); expect(after.actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: after.maxHealth }); expect(after.playerAnimationEvent?.kind).toBe("ability-cancel"); expect(after.mobs).toBe(combatBefore.mobs); expect(usePartyStore.getState()).toBe(partyBefore); for (const [index, member] of partyBefore.members.entries()) { expect(after.actors[member.id]).toBe(combatBefore.actors[member.id]); expect(getPartyRuntimePosition(member.id)).toEqual(positions[index]); } expect(useGameStore.getState()).toMatchObject({ resetRevision: gameBefore.resetRevision, encounterResetRevision: gameBefore.encounterResetRevision, relocationRevision: gameBefore.relocationRevision + 1, playerPosition: dungeon.entrance.footPosition, }); expect(usePartyStore.getState().recallRevision).toBe(partyBefore.recallRevision); }); it("allows personal resurrection during a boss fight at an already unlocked boss checkpoint", () => { const completed = dungeon.bosses[0]; advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 100, facts: { deadEntityIds: [completed.id], registeredEntityIds: [completed.id] }, }); const currentBoss = startFight(true); advanceDungeonPartyCombat(1_000); killPlayer(); const before = useCombatStore.getState().mobs[currentBoss]; const revision = useGameStore.getState().resetRevision; expect(respawnDefeatedPlayer(`boss:${completed.id}`)).toBe(true); expect(useGameStore.getState().playerPosition).toEqual(completed.position); expect(useCombatStore.getState().mobs[currentBoss]).toBe(before); expect(before.engaged).toBe(true); expect(useGameStore.getState().resetRevision).toBe(revision); }); it("continues the actual party and enemy combat ticks after the player leaves for the entrance", () => { const id = startFight(); advanceDungeonPartyCombat(1_000); useCombatStore.getState().advanceMobCombat(1_000); killPlayer(); expect(respawnDefeatedPlayer("entrance")).toBe(true); const beforeMobHealth = useCombatStore.getState().mobs[id].health; const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!; const beforeAttackRevision = useCombatStore.getState().mobs[id].attackRevision; advanceDungeonPartyCombat(3_000); const nextAttackTime = Math.max(3_000, ...useCombatStore.getState().mobs[id].statuses.map((status) => status.endsAt)) + 1_000; useCombatStore.getState().advanceMobCombat(nextAttackTime, dungeon.entrance.footPosition); expect(useCombatStore.getState().mobs[id].engaged).toBe(true); expect(useCombatStore.getState().mobs[id].health).toBeLessThan(beforeMobHealth); expect(useCombatStore.getState().mobs[id].attackRevision).toBeGreaterThan(beforeAttackRevision); expect(useCombatStore.getState().mobs[id].targetActorId).toBe(tank.id); expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth); }); it("keeps companions' periodic healing and enemies' ongoing damage while removing old player HoTs", () => { const id = startFight(); const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!; expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000).ok).toBe(true); const selfHot = useCombatStore.getState().effects.find((effect) => effect.kind === "hot")!; const allyHot = { ...selfHot, id: "preserved-ally-hot", targetId: usePartyStore.getState().members[0].id }; const enemyDot = { ...selfHot, id: "preserved-enemy-dot", kind: "dot" as const, targetId: id }; const explicitPlayerHot = { ...selfHot, id: "removed-player-hot", targetId: PLAYER_AURA_ENTITY_ID }; useCombatStore.setState({ effects: [selfHot, allyHot, enemyDot, explicitPlayerHot] }); killPlayer(); useCombatStore.setState({ cooldowns: { "ongoing-player-cooldown": 100_000 } }); const cooldowns = useCombatStore.getState().cooldowns; expect(respawnDefeatedPlayer("entrance")).toBe(true); expect(useCombatStore.getState().effects).toEqual([allyHot, enemyDot]); expect(useCombatStore.getState().cooldowns).toBe(cooldowns); }); it.each(["party wiped", "fight ended"])("retains normal reset recovery when the %s", (reason) => { const id = startFight(); if (reason === "party wiped") { useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members.map((member) => ({ ...member, health: 0, status: "down" as const, }))); } else { useCombatStore.getState().damageMob(id, 100_000, 200); } killPlayer(); const before = useGameStore.getState().resetRevision; expect(respawnDefeatedPlayer("entrance")).toBe(true); expect(useGameStore.getState().resetRevision).toBe(before + 1); expect(useCombatStore.getState().mobs[id].engaged).toBe(false); }); it("uses canonical companion health and ignores defeated or despawned enemies", () => { const id = startFight(); const combat = useCombatStore.getState(); const members = usePartyStore.getState().members; expect(partyFightStillActive(members, combat.mobs, combat.actors)).toBe(true); const actors = Object.fromEntries(members.map((member) => [member.id, { health: 0 }])); expect(partyFightStillActive(members, combat.mobs, actors)).toBe(false); expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], despawned: true } })).toBe(false); expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], dead: true } })).toBe(false); }); it("rejects locked checkpoints and leaves Manastorm's shared-charge resurrection separate", () => { startFight(true); killPlayer(); const before = useGameStore.getState(); expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false); expect(useGameStore.getState()).toBe(before); expect(useCombatStore.getState().health).toBe(0); useGameStore.setState({ gameMode: "manastorm" }); expect(respawnDefeatedPlayer("entrance")).toBe(false); expect(useCombatStore.getState().health).toBe(0); }); it("cannot use player-only recovery as a living-player teleport or with invalid coordinates", () => { const before = useCombatStore.getState(); expect(before.respawnPlayerOnly([0, 0, 0], 1_000)).toBe(false); expect(useCombatStore.getState()).toBe(before); killPlayer(); const dead = useCombatStore.getState(); expect(dead.respawnPlayerOnly([NaN, 0, 0], 1_000)).toBe(false); expect(dead.respawnPlayerOnly([0, 0, 0], NaN)).toBe(false); expect(useCombatStore.getState()).toBe(dead); }); });