import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createClassInventory } from "./healers"; import { HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER, HOCKEY_PVP_GOAL_DAMAGE, HOCKEY_PVP_GOAL_Z, hockeyPvpBossAt, type HockeyPvpRemoteSnapshot } from "./hockeyHealingPvp"; import { upcomingEncounterMechanic, useGameStore } from "./store"; import { freshParty } from "./data"; import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL } from "./progression/gear"; import { EMPTY_MEMBER_GEAR_MODIFIERS } from "./progression/gearEffects"; const MATCH = { matchId: null, seed: 4242, opponentName: "CPU Aster", role: "cpu" as const, }; function createMaxedGear() { const gear = createDefaultGearProgress(); for (const ownerId of GEAR_OWNER_ORDER) { for (const slotId of GEAR_SLOT_ORDER) gear[ownerId].slots[slotId].level = MAX_GEAR_LEVEL; } gear.priest.passiveInfusionId = "mend-echo"; return gear; } describe("Healing Hockey PVP encounter integration", () => { afterEach(() => vi.restoreAllMocks()); beforeEach(() => { useGameStore.getState().configureHealer( "priest", "Aelia", createClassInventory("priest"), [hockeyPvpBossAt(MATCH.seed, 0)], "hockey-healing-pvp", undefined, "initiate", MATCH, ); }); it("starts exactly two copies of same boss across mirrored parties", () => { const state = useGameStore.getState(); expect(state.additionalBosses).toHaveLength(0); expect(state.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0)); expect(state.hockeyPvpOpponent.boss.id).toBe(state.boss.id); expect(state.hockeyPvp.opponentName).toBe("CPU Aster"); expect(state.difficultyDamageMultiplier).toBe(HOCKEY_PVP_BOSS_DAMAGE_MULTIPLIER); }); it("ignores start input until shared five-second countdown ends", () => { const now = vi.spyOn(Date, "now").mockReturnValue(5_000); useGameStore.getState().configureHealer( "priest", "Aelia", createClassInventory("priest"), [hockeyPvpBossAt(MATCH.seed, 0)], "hockey-healing-pvp", undefined, "initiate", { ...MATCH, countdownEndsAtMs: 10_000 }, ); useGameStore.getState().startEncounter(); expect(useGameStore.getState().phase).toBe("briefing"); now.mockReturnValue(10_000); useGameStore.getState().startEncounter(); expect(useGameStore.getState().phase).toBe("combat"); }); it("normalizes both parties to default base gear without changing saved upgrades", () => { const maxedGear = createMaxedGear(); const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp); useGameStore.getState().configureHealer( "priest", "Aelia", createClassInventory("priest"), [hockeyPvpBossAt(MATCH.seed, 0)], "hockey-healing-pvp", maxedGear, "initiate", MATCH, ); const briefing = useGameStore.getState(); expect(briefing.party.map((member) => member.maxHp)).toEqual(baseHealth); expect(briefing.hockeyPvpOpponent.party.map((member) => member.maxHp)).toEqual(baseHealth); expect(briefing.healingMultiplier).toBe(1); expect(briefing.passiveRunBuffId).toBeNull(); expect(briefing.gearProgress).toBe(maxedGear); for (const modifiers of Object.values(briefing.gearModifiers)) { expect(modifiers).toEqual(EMPTY_MEMBER_GEAR_MODIFIERS); } briefing.startEncounter(); const combat = useGameStore.getState(); expect(combat.party.map((member) => member.maxHp)).toEqual(baseHealth); expect(combat.hockeyPvpOpponent.party.map((member) => member.maxHp)).toEqual(baseHealth); expect(combat.healingMultiplier).toBe(1); expect(combat.passiveRunBuffId).toBeNull(); expect(combat.gearProgress).toBe(maxedGear); }); it("continues applying saved gear outside PVP", () => { const maxedGear = createMaxedGear(); const baseHealth = freshParty("priest", "Aelia").map((member) => member.maxHp); useGameStore.getState().configureHealer( "priest", "Aelia", createClassInventory("priest"), "bulldrome", "encounter", maxedGear, ); const state = useGameStore.getState(); expect(state.party.map((member) => member.maxHp)).not.toEqual(baseHealth); expect(state.healingMultiplier).toBeGreaterThan(1); expect(state.passiveRunBuffId).toBe("mend-echo"); }); it("deals exactly 45 partywide damage when local net is breached", () => { useGameStore.getState().startEncounter(); const startingHp = useGameStore.getState().party.map((member) => member.hp); useGameStore.setState((state) => ({ hockeyPvp: { ...state.hockeyPvp, puckPosition: [0, HOCKEY_PVP_GOAL_Z - 0.1], puckVelocity: [0, 8], }, playerPosition: [8.8, 8.5], partyPositions: { ...state.partyPositions, aelia: [8.8, 8.5] }, boss: { ...state.boss, nextMeleeAt: 999 }, bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, hockeyPvpOpponent: { ...state.hockeyPvpOpponent, boss: { ...state.hockeyPvpOpponent.boss, nextMeleeAt: 999 }, bossMotion: { ...state.hockeyPvpOpponent.bossMotion, nextMechanicAt: 999 }, }, })); useGameStore.getState().tick(0.1); useGameStore.getState().party.forEach((member, index) => { expect(member.hp).toBe(startingHp[index] - HOCKEY_PVP_GOAL_DAMAGE); }); expect(useGameStore.getState().phase).toBe("combat"); }); it("reduces both local and CPU healing by combined boss-kill dampening", () => { useGameStore.getState().startEncounter(); useGameStore.setState((state) => ({ endlessBossKills: 1, party: state.party.map((member) => ({ ...member, hp: Math.max(1, member.hp - 40) })), boss: { ...state.boss, nextMeleeAt: 999 }, bossMotion: { ...state.bossMotion, nextMechanicAt: 999 }, hockeyPvp: { ...state.hockeyPvp, opponentBossKills: 1, nextCpuHealAt: 0 }, hockeyPvpOpponent: { ...state.hockeyPvpOpponent, party: state.hockeyPvpOpponent.party.map((member, index) => ({ ...member, hp: index === 0 ? 50 : member.hp })), boss: { ...state.hockeyPvpOpponent.boss, nextMeleeAt: 999 }, bossMotion: { ...state.hockeyPvpOpponent.bossMotion, nextMechanicAt: 999 }, }, })); const localBefore = useGameStore.getState().party[0].hp; expect(useGameStore.getState().castAbility("ability5")).toBe(true); expect(useGameStore.getState().party[0].hp - localBefore).toBeCloseTo(22 * 0.9, 5); useGameStore.getState().tick(0.01); expect(useGameStore.getState().hockeyPvpOpponent.party[0].hp).toBeCloseTo(50 + 32 * 0.9, 5); }); it("keeps faster and slower parties on same deterministic boss order", () => { useGameStore.getState().startEncounter(); useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0, defeatedAt: state.time }, endlessBossKills: 1, })); useGameStore.getState().tick(0.01); expect(useGameStore.getState().boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 1)); expect(useGameStore.getState().hockeyPvpOpponent.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 0)); useGameStore.setState((state) => ({ hockeyPvp: { ...state.hockeyPvp, opponentBossKills: 1 }, hockeyPvpOpponent: { ...state.hockeyPvpOpponent, boss: { ...state.hockeyPvpOpponent.boss, hp: 0, defeatedAt: state.time }, }, })); useGameStore.getState().tick(0.01); expect(useGameStore.getState().hockeyPvpOpponent.boss.id).toBe(hockeyPvpBossAt(MATCH.seed, 1)); expect(useGameStore.getState().boss.id).toBe(useGameStore.getState().hockeyPvpOpponent.boss.id); }); it("wins when opponent companions fall while rival healer remains alive", () => { useGameStore.getState().startEncounter(); useGameStore.getState().setActiveTab("map"); useGameStore.setState((state) => ({ hockeyPvpOpponent: { ...state.hockeyPvpOpponent, party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: member.id === "aelia" ? member.hp : 0, })), }, })); useGameStore.getState().tick(0.01); expect(useGameStore.getState().phase).toBe("victory"); expect(useGameStore.getState().hockeyPvp.status).toBe("won"); expect(useGameStore.getState().activeTab).toBe("combat"); }); it("loses when local companions fall while healer remains alive", () => { useGameStore.getState().startEncounter(); useGameStore.setState((state) => ({ party: state.party.map((member) => ({ ...member, hp: member.id === "aelia" ? member.hp : 0, })), })); useGameStore.getState().tick(0.01); expect(useGameStore.getState().party.find((member) => member.id === "aelia")?.hp).toBeGreaterThan(0); expect(useGameStore.getState().phase).toBe("defeat"); expect(useGameStore.getState().hockeyPvp.status).toBe("lost"); }); it("wins an online match when remote companions fall while rival healer remains alive", () => { useGameStore.getState().configureHealer( "priest", "Aelia", createClassInventory("priest"), [hockeyPvpBossAt(MATCH.seed, 0)], "hockey-healing-pvp", undefined, "initiate", { ...MATCH, matchId: "online-match", role: "guest" }, ); useGameStore.getState().startEncounter(); const state = useGameStore.getState(); const snapshot: HockeyPvpRemoteSnapshot = { sequence: 1, time: state.time, party: state.hockeyPvpOpponent.party.map((member) => ({ ...member, hp: member.id === "aelia" ? member.hp : 0, })), partyPositions: structuredClone(state.hockeyPvpOpponent.partyPositions), boss: { id: state.hockeyPvpOpponent.boss.id, name: state.hockeyPvpOpponent.boss.name, hp: state.hockeyPvpOpponent.boss.hp, maxHp: state.hockeyPvpOpponent.boss.maxHp, }, bossPosition: [...state.hockeyPvpOpponent.bossMotion.position], bossMode: state.hockeyPvpOpponent.bossMotion.mode, bossKills: 0, playerPosition: [...state.hockeyPvp.opponentPlayerPosition], aimDirection: [...state.hockeyPvp.opponentAimDirection], }; useGameStore.getState().applyHockeyPvpRemoteSnapshot(snapshot); expect(useGameStore.getState().phase).toBe("victory"); expect(useGameStore.getState().hockeyPvp.status).toBe("won"); }); it("keeps HUD mechanic data defined during instant boss replacement", () => { useGameStore.setState((state) => ({ boss: { ...state.boss, hp: 0 } })); expect(upcomingEncounterMechanic(useGameStore.getState())).toEqual({ name: "Replacement incoming", remaining: 0, cycle: 1, urgent: false, }); }); });