201 lines
10 KiB
TypeScript
201 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|