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