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); }); });