Files
healer-man/src/game/manastormSession.test.ts
T
2026-09-05 15:33:06 -04:00

395 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore } from "./combatStore";
import {
advanceManastormSession,
manastormPartyIsDefeated,
resolveManastormActorDown,
resurrectManastormSession,
synchronizeManastormChaoticLink,
synchronizeManastormCombat,
synchronizeManastormPartySize,
} from "./manastormSession";
import { lootCurrentManastormBoss } from "./manastormLoot";
import { useManastormStore } from "./manastormStore";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import type { InventoryItem } from "./lootTypes";
import { EMPTY_ITEM_STATS } from "./itemStats";
import {
MANASTORM_ENCOUNTERS,
setManastormCatalog,
type ManastormCatalogLike,
} from "./manastorm";
describe("Manastorm encounter sessions", () => {
beforeEach(() => {
setManastormCatalog(null);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 12, xp: 123 });
usePartyStore.getState().initializeParty("manastorm-session", 12, useCombatStore.getState().health, "healer");
useManastormStore.getState().resetRun();
const encounter = useManastormStore.getState().startRun(() => 0.15);
useGameStore.getState().activateDungeon(encounter.dungeonId, undefined, {
gameMode: "manastorm",
spawn: encounter.playerSpawn,
});
});
it("preserves progression while restoring the party and rebuilding the world", () => {
const first = useManastormStore.getState().currentEncounter!;
const revision = useGameStore.getState().sessionRevision;
useCombatStore.setState({ health: 1, resource: 2 });
const firstPartyMember = usePartyStore.getState().members[0];
const cloak: InventoryItem = {
instanceId: "storm-cloak",
itemId: 1,
itemKey: "storm-cloak",
name: "Storm Cloak",
quality: "rare",
inventorySlot: "back",
armorType: "cloth",
weaponType: null,
itemLevel: 12,
sourceItemLevel: 20,
stats: { ...EMPTY_ITEM_STATS, spellPower: 3, stamina: 2, armor: 8 },
quantity: 1,
sourceBossKey: "deviate-faerie-dragon",
sourceBossName: "Deviate Faerie Dragon",
acquiredAt: 1,
};
useCombatStore.setState({
inventory: [cloak],
equipment: { player: { back: cloak.instanceId } },
});
usePartyStore.getState().damageMember(firstPartyMember.id, firstPartyMember.maxHealth - 1);
useCombatStore.getState().registerMob(first.bossRuntimeId, { name: first.bossName, boss: true });
useManastormStore.getState().enterStage();
useManastormStore.getState().observeTrash(true);
useManastormStore.getState().observeBoss(true, true);
expect(advanceManastormSession(() => 0.8)).toBe(true);
const next = useManastormStore.getState().currentEncounter!;
expect(useManastormStore.getState()).toMatchObject({
level: 2,
completed: 1,
status: "entering",
phase: "enter",
});
expect(next.id).not.toBe(first.id);
expect(useCombatStore.getState()).toMatchObject({
level: 12,
xp: 123,
health: useCombatStore.getState().maxHealth,
resource: useCombatStore.getState().maxResource,
mobs: {},
inventory: [cloak],
equipment: { player: { back: cloak.instanceId } },
});
expect(usePartyStore.getState().members.every((member) => member.health === member.maxHealth)).toBe(true);
expect(useGameStore.getState()).toMatchObject({
gameMode: "manastorm",
playerPosition: next.playerSpawn.footPosition,
sessionRevision: revision + 1,
});
});
it("uses one shared resurrection charge to revive only the player in place", () => {
const encounter = useManastormStore.getState().currentEncounter!;
const revision = useGameStore.getState().sessionRevision;
const companion = usePartyStore.getState().members[0];
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === companion.id ? { ...member, health: 1 } : member),
}));
useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
name: encounter.bossName,
boss: true,
maxHealth: 100,
});
useCombatStore.getState().damageMob(encounter.bossRuntimeId, 25, 1_000);
const mobBefore = useCombatStore.getState().mobs[encounter.bossRuntimeId];
useCombatStore.setState({ health: 0, resource: 0 });
expect(resurrectManastormSession({ kind: "player" })).toBe(true);
expect(useManastormStore.getState()).toMatchObject({
currentEncounter: encounter,
resurrectionCharges: 4,
});
expect(useCombatStore.getState()).toMatchObject({
health: useCombatStore.getState().maxHealth,
resource: 0,
});
expect(usePartyStore.getState().members[0].health).toBe(1);
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId]).toEqual(mobBefore);
expect(useGameStore.getState().sessionRevision).toBe(revision);
});
it("uses the same shared charge to revive only the requested companion", () => {
const companion = usePartyStore.getState().members[0];
const otherCompanion = usePartyStore.getState().members[1];
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === companion.id
? { ...member, health: 0, status: "down" }
: member.id === otherCompanion.id ? { ...member, health: 1 } : member),
}));
expect(usePartyStore.getState().members[0].health).toBe(0);
expect(resurrectManastormSession({
kind: "companion",
memberId: companion.id,
})).toBe(true);
expect(useManastormStore.getState().resurrectionCharges).toBe(4);
expect(usePartyStore.getState().members[0]).toMatchObject({
health: companion.maxHealth,
status: "ready",
nextActionAt: 0,
});
expect(usePartyStore.getState().members[1].health).toBe(1);
});
it("leaves an individual actor down when charges are exhausted but allies remain", () => {
for (let charge = 5; charge > 0; charge -= 1) {
expect(useManastormStore.getState().useResurrectionCharge()).toBe(true);
}
useCombatStore.setState({ health: 0 });
expect(resolveManastormActorDown({ kind: "player" })).toBe("downed");
expect(useCombatStore.getState().health).toBe(0);
expect(manastormPartyIsDefeated()).toBe(false);
expect(useManastormStore.getState().status).not.toBe("failed");
});
it("fails only after the player and every active companion are down with no charge", () => {
for (let charge = 5; charge > 0; charge -= 1) {
expect(useManastormStore.getState().useResurrectionCharge()).toBe(true);
}
useCombatStore.setState({ health: 0 });
const companions = usePartyStore.getState().members;
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
}));
const companion = companions.at(-1)!;
expect(manastormPartyIsDefeated()).toBe(true);
expect(resolveManastormActorDown({
kind: "companion",
memberId: companion.id,
})).toBe("failed");
expect(useManastormStore.getState().status).toBe("failed");
});
it("waits for an explicit shared resurrection when the full party is down", () => {
useCombatStore.setState({ health: 0 });
const companions = usePartyStore.getState().members;
usePartyStore.setState((state) => ({
members: state.members.map((member) => ({ ...member, health: 0, status: "down" })),
}));
const companion = companions.at(-1)!;
expect(resolveManastormActorDown({
kind: "companion",
memberId: companion.id,
})).toBe("downed");
expect(usePartyStore.getState().members.at(-1)!.health).toBe(0);
expect(useCombatStore.getState().health).toBe(0);
expect(useManastormStore.getState()).toMatchObject({
status: "entering",
resurrectionCharges: 5,
});
expect(resurrectManastormSession({ kind: "player" })).toBe(true);
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
expect(useManastormStore.getState().resurrectionCharges).toBe(4);
});
it("drives real trash, boss, loot, portal, and next-stage state end to end", () => {
const kresh = MANASTORM_ENCOUNTERS.find((encounter) => encounter.id === "kresh")!;
const stage = {
...kresh,
id: "combat-integration-stage",
bossRuntimeId: "combat-integration-boss",
trashRuntimeIds: ["combat-integration-trash-a", "combat-integration-trash-b"],
};
const catalog: ManastormCatalogLike = {
id: "combat-integration-catalog",
maximumLevel: 2,
stages: [stage],
rewards: [{ id: "clear-reward", currencyId: "bonzoBolts", currencyBase: 2 }],
};
setManastormCatalog(catalog);
useManastormStore.getState().resetRun();
const encounter = useManastormStore.getState().startRun({ partySize: 2, random: () => 0 });
useGameStore.getState().activateDungeon(encounter.dungeonId, undefined, {
gameMode: "manastorm",
spawn: encounter.playerSpawn,
});
useCombatStore.getState().registerMob("combat-integration-trash-a", {
name: "Trash A",
maxHealth: 1,
});
useCombatStore.getState().registerMob("combat-integration-trash-b", {
name: "Trash B",
maxHealth: 1,
});
useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
name: encounter.bossName,
boss: true,
maxHealth: 1,
hasLoot: true,
});
expect(useManastormStore.getState().enterStage()).toBe(true);
synchronizeManastormCombat();
expect(useManastormStore.getState().status).toBe("clearing-trash");
useCombatStore.getState().damageMob("combat-integration-trash-a", 1, 1_000);
synchronizeManastormCombat();
expect(useManastormStore.getState().status).toBe("fighting");
useCombatStore.getState().damageMob("combat-integration-trash-b", 1, 1_001);
synchronizeManastormCombat();
expect(useManastormStore.getState().status).toBe("fighting");
useCombatStore.getState().damageMob(encounter.bossRuntimeId, 1, 1_002);
synchronizeManastormCombat();
expect(useManastormStore.getState()).toMatchObject({
status: "awaiting-loot",
phase: "loot",
});
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId].looted).toBe(false);
expect(lootCurrentManastormBoss(1_003)).toBe(true);
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId].looted).toBe(true);
synchronizeManastormCombat();
expect(useManastormStore.getState()).toMatchObject({
status: "portal-ready",
phase: "portal",
currency: { bonzoBolts: 2 },
});
expect(advanceManastormSession(() => 0)).toBe(true);
expect(useManastormStore.getState()).toMatchObject({
status: "entering",
phase: "enter",
level: 2,
completed: 1,
});
});
it("weakens the boss one Chaotic Link stack per guardian until half are defeated", () => {
const base = MANASTORM_ENCOUNTERS[0];
const guardianIds = [
"manastorm:guardian-spawn:link-fixture:1",
"manastorm:guardian-spawn:link-fixture:2",
"manastorm:guardian-spawn:link-fixture:3",
"manastorm:guardian-spawn:link-fixture:4",
] as const;
const stage = {
...base,
id: "chaotic-link-combat-stage",
bossRuntimeId: "manastorm:spawn:link-fixture:boss",
trashRuntimeIds: guardianIds,
};
setManastormCatalog({ id: "chaotic-link-catalog", stages: [stage] });
useManastormStore.getState().resetRun();
const encounter = useManastormStore.getState().startRun(() => 0);
for (const guardianId of guardianIds) {
useCombatStore.getState().registerMob(guardianId, {
name: "Linked Guardian",
maxHealth: 1,
});
}
useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
name: encounter.bossName,
boss: true,
maxHealth: 100,
});
const baseAttackDamage = useCombatStore.getState().mobs[encounter.bossRuntimeId].attackDamage;
useManastormStore.getState().enterStage();
expect(synchronizeManastormChaoticLink()).toMatchObject({
requiredGuardianKills: 2,
activeStacks: 2,
});
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId]).toMatchObject({
maxHealth: 150,
health: 150,
attackDamage: Math.round(baseAttackDamage * 1.2),
});
useCombatStore.getState().damageMob(guardianIds[0], 1, 1_000);
synchronizeManastormCombat();
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId]).toMatchObject({
maxHealth: 125,
health: 125,
attackDamage: Math.round(baseAttackDamage * 1.1),
engaged: false,
});
expect(useCombatStore.getState().mobs[guardianIds[1]].engaged).toBe(true);
expect(useManastormStore.getState().status).toBe("clearing-trash");
useCombatStore.getState().damageMob(guardianIds[1], 1, 1_001);
synchronizeManastormCombat();
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId]).toMatchObject({
maxHealth: 100,
health: 100,
attackDamage: baseAttackDamage,
});
expect(useManastormStore.getState()).toMatchObject({
status: "fighting",
phase: "boss",
});
});
it("allows the fully linked boss to be defeated without clearing guardians", () => {
const base = MANASTORM_ENCOUNTERS[0];
const stage = {
...base,
id: "chaotic-link-skip-stage",
bossRuntimeId: "manastorm:spawn:skip-fixture:boss",
trashRuntimeIds: [
"manastorm:guardian-spawn:skip-fixture:1",
"manastorm:guardian-spawn:skip-fixture:2",
"manastorm:guardian-spawn:skip-fixture:3",
],
};
setManastormCatalog({ id: "chaotic-link-skip-catalog", stages: [stage] });
useManastormStore.getState().resetRun();
const encounter = useManastormStore.getState().startRun(() => 0);
for (const guardianId of encounter.trashRuntimeIds ?? []) {
useCombatStore.getState().registerMob(guardianId, {
name: "Skipped Guardian",
maxHealth: 1,
});
}
useCombatStore.getState().registerMob(encounter.bossRuntimeId, {
name: encounter.bossName,
boss: true,
maxHealth: 100,
hasLoot: false,
});
useManastormStore.getState().enterStage();
synchronizeManastormCombat();
expect(useCombatStore.getState().mobs[encounter.bossRuntimeId].maxHealth).toBe(150);
useCombatStore.getState().damageMob(encounter.bossRuntimeId, 1_000, 2_000);
synchronizeManastormCombat();
expect(useManastormStore.getState()).toMatchObject({
status: "awaiting-loot",
phase: "loot",
});
expect((encounter.trashRuntimeIds ?? []).every(
(guardianId) => !useCombatStore.getState().mobs[guardianId].dead,
)).toBe(true);
});
it("sizes the companion roster to party size minus the player", () => {
expect(usePartyStore.getState().members).toHaveLength(4);
useManastormStore.getState().resetRun();
useManastormStore.getState().setPartySize(2);
synchronizeManastormPartySize();
expect(usePartyStore.getState().members).toHaveLength(1);
});
});