import { beforeEach, describe, expect, it } from "vitest"; import { MANASTORM_ENCOUNTERS, actorEnteredManastormPortal, drawManastormEncounter, drawManastormStage, isManastormSpecialLevel, manastormActiveRuntimeIds, manastormCacheChance, manastormCheckpointForLevel, manastormModeForCharacterLevel, manastormExperienceReward, manastormAffixes, manastormScaling, setManastormCatalog, shuffleManastormEncounterIds, type ManastormCatalogLike, type ManastormEncounterDefinition, } from "./manastorm"; import { useManastormStore } from "./manastormStore"; function stage( id: string, options: Partial = {}, ): ManastormEncounterDefinition { const base = MANASTORM_ENCOUNTERS[0]; return { ...base, id, bossRuntimeId: `${id}-boss`, bossEntityId: `${id}-entity`, bossName: id, trashRuntimeIds: [`${id}-trash-a`, `${id}-trash-b`], ...options, }; } describe("Manastorm rules", () => { beforeEach(() => { setManastormCatalog(null); useManastormStore.getState().resetRun(); }); it("uses level one as baseline and caps the bonus item chance", () => { expect(manastormScaling(1)).toEqual({ statMultiplier: 1, bonusLootChance: 0, experienceMultiplier: 1, }); expect(manastormScaling(2)).toEqual({ statMultiplier: 1.03, bonusLootChance: 0.03, experienceMultiplier: 1.03, }); expect(manastormScaling(34)).toEqual({ statMultiplier: 1.99, bonusLootChance: 0.99, experienceMultiplier: 1.99, }); expect(manastormScaling(35)).toEqual({ statMultiplier: 2.02, bonusLootChance: 1, experienceMultiplier: 2.02, }); expect(manastormScaling(900).bonusLootChance).toBe(1); }); it("applies imported experience, party, and map reward multipliers", () => { const catalog: ManastormCatalogLike = { modeId: 2, modifiers: [{ id: "xp-fixture", modeId: 2, level: 5, tuning: [0, 0, 0, 0, 0, 0, 0, 0, 1.5, 1.75, 2], }], groupModifiers: [{ playerCount: 3, modeId: 2, multipliers: [1.25, 1.4], }], }; expect(manastormExperienceReward(100, 5, 3, 1.5, catalog, 2)).toBe(420); }); it("keeps durably unlocked affixes eligible when replaying a lower stage", () => { const catalog: ManastormCatalogLike = { id: "durable-affix-fixture", stages: [stage("durable-affix-stage")], affixes: [ { id: "early-affix", name: "Early", description: "Unlocked early.", unlockLevel: 2, }, { id: "later-affix", name: "Later", description: "Unlocked later.", unlockLevel: 5, }, ], }; setManastormCatalog(catalog); useManastormStore.getState().resetRun(); useManastormStore.getState().startRun({ startingLevel: 1, unlockedThroughLevel: 5, random: () => 0, }); expect(useManastormStore.getState()).toMatchObject({ level: 1, unlockedThroughLevel: 5, activeAffixes: [{ id: "early-affix" }], }); }); it("draws every legacy boss once and prevents a repeat across bag boundaries", () => { let bag: readonly string[] = []; let previous: string | null = null; const drawn: string[] = []; for (let index = 0; index < MANASTORM_ENCOUNTERS.length; index += 1) { const draw = drawManastormEncounter(bag, previous, () => 0.37); drawn.push(draw.encounter.id); previous = draw.encounter.id; bag = draw.remainingBag; } expect(new Set(drawn).size).toBe(MANASTORM_ENCOUNTERS.length); const nextBag = shuffleManastormEncounterIds(() => 0.37, previous); expect(nextBag[0]).not.toBe(previous); }); it("selects unlocked stages deterministically, honors party gates, and reserves milestone stages", () => { const catalog: ManastormCatalogLike = { id: "selection-fixture", stages: [ stage("alpha", { unlockLevel: 1, minimumPartySize: 1 }), stage("beta", { unlockLevel: 6, minimumPartySize: 3 }), stage("five", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5 }), ], }; const first = drawManastormStage(1, 1, [], null, () => 0.4, catalog); expect(first.encounter.id).toBe("alpha"); const milestone = drawManastormStage(5, 1, [], first.encounter.id, () => 0.4, catalog); expect(milestone.encounter.id).toBe("five"); const smallGroup = drawManastormStage(6, 2, [], milestone.encounter.id, () => 0.4, catalog); expect(smallGroup.encounter.id).toBe("alpha"); const fullGroup = drawManastormStage(6, 3, [], "alpha", () => 0.4, catalog); expect(["beta", "five"]).toContain(fullGroup.encounter.id); expect(fullGroup.encounter.id).not.toBe("alpha"); expect(drawManastormStage(6, 3, [], "alpha", () => 0.4, catalog).encounter.id) .toBe(fullGroup.encounter.id); }); it("does not restore fallback content when a published Ready pool is empty", () => { const emptyPublishedCatalog: ManastormCatalogLike = { stages: [], fallbackPolicy: "none" }; expect(() => drawManastormStage(1, 1, [], null, () => 0, emptyPublishedCatalog)) .toThrow("no unlocked encounter"); }); it("uses the standard pool at milestone levels when forced milestones are disabled", () => { const catalog: ManastormCatalogLike = { stages: [ stage("standard", { unlockLevel: 1 }), stage("milestone", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5 }), ], runtimeOptions: { milestoneEncountersEnabled: false }, }; expect(drawManastormStage(5, 1, [], null, () => 0, catalog).encounter.id).toBe("standard"); }); it("disables both explicit and modifier-derived affixes", () => { const catalog: ManastormCatalogLike = { affixes: [{ id: "explicit", name: "Explicit", description: "Fixture", unlockLevel: 1 }], modifiers: [{ id: "fallback", modeId: 0, level: 1, rawFlag: 7, tuning: [] }], runtimeOptions: { affixesEnabled: false }, }; expect(manastormAffixes(1, catalog, 0)).toEqual([]); }); it("keeps leveling and end-game encounter pools separate by character state", () => { const catalog: ManastormCatalogLike = { id: "split-mode-fixture", modeId: 0, modeIds: [0, 2], stages: [ stage("leveling", { modeIds: [0] }), stage("endgame", { modeIds: [2] }), ], }; expect(manastormModeForCharacterLevel(59, catalog)).toBe(0); expect(manastormModeForCharacterLevel(60, catalog)).toBe(2); expect(drawManastormStage(99, 1, [], null, () => 0, catalog, 0).encounter.id) .toBe("leveling"); expect(drawManastormStage(101, 1, [], null, () => 0, catalog, 2).encounter.id) .toBe("endgame"); }); it("keeps the selected mode invariant when a run crosses level 100", () => { const catalog: ManastormCatalogLike = { id: "immutable-mode-fixture", modeId: 0, modeIds: [0, 2], maximumLevel: 200, stages: [ stage("leveling", { modeIds: [0], stageKind: "milestone", milestoneLevel: 100, }), stage("endgame", { modeIds: [2] }), ], }; setManastormCatalog(catalog); useManastormStore.getState().resetRun(); useManastormStore.getState().startRun({ startingLevel: 100, modeId: 0, random: () => 0, }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true); useManastormStore.getState().beginTransition(); useManastormStore.getState().prepareNextEncounter(() => 0); expect(useManastormStore.getState()).toMatchObject({ level: 101, modeId: 0, currentEncounter: { id: "leveling", modeIds: [0] }, }); }); it("draws later every-fifth levels from the unlocked special pool", () => { const catalog: ManastormCatalogLike = { id: "repeating-milestone-fixture", stages: [ stage("standard", { unlockLevel: 1 }), stage("five", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5 }), stage("ten", { unlockLevel: 10, stageKind: "milestone", milestoneLevel: 10 }), ], }; const first = drawManastormStage(20, 1, [], null, () => 0, catalog); const second = drawManastormStage(25, 1, [], first.encounter.id, () => 0, catalog); expect(["five", "ten"]).toContain(first.encounter.id); expect(["five", "ten"]).toContain(second.encounter.id); expect(second.encounter.id).not.toBe(first.encounter.id); }); it("keeps previously introduced content in a fresh run's random pool", () => { const catalog: ManastormCatalogLike = { id: "durable-unlock-fixture", stages: [ stage("initial", { unlockLevel: 1 }), stage("later-standard", { unlockLevel: 6 }), stage("introduced-milestone", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5, }), ], }; expect(drawManastormStage( 1, 1, ["later-standard"], null, () => 0, catalog, 0, 7, ).encounter.id).toBe("later-standard"); expect(drawManastormStage( 1, 1, ["introduced-milestone"], null, () => 0, catalog, 0, 7, ).encounter.id).toBe("introduced-milestone"); }); it("guards enter, trash, boss, loot, portal, and next as distinct phases", () => { const first = useManastormStore.getState().startRun(() => 0.2); expect(useManastormStore.getState()).toMatchObject({ status: "entering", phase: "enter", level: 1, completed: 0, currentEncounter: first, }); expect(useManastormStore.getState().enterStage()).toBe(true); expect(useManastormStore.getState()).toMatchObject({ status: "clearing-trash", phase: "trash", }); useManastormStore.getState().observeTrash(false); expect(useManastormStore.getState().status).toBe("clearing-trash"); useManastormStore.getState().observeTrash(true); expect(useManastormStore.getState()).toMatchObject({ status: "fighting", phase: "boss" }); useManastormStore.getState().observeBoss(true, false); expect(useManastormStore.getState()).toMatchObject({ status: "awaiting-loot", phase: "loot", pendingReward: { stageId: first.id, level: 1 }, }); expect(useManastormStore.getState().beginTransition()).toBe(false); useManastormStore.getState().observeBoss(true, true); expect(useManastormStore.getState()).toMatchObject({ status: "portal-ready", phase: "portal", completed: 1, pendingReward: null, lastReward: { stageId: first.id }, }); expect(useManastormStore.getState().currency.bonzoBolts).toBeGreaterThan(0); expect(useManastormStore.getState().useResurrectionCharge()).toBe(true); expect(useManastormStore.getState().resurrectionCharges).toBe(4); expect(useManastormStore.getState().beginTransition()).toBe(true); expect(useManastormStore.getState().beginTransition()).toBe(false); const next = useManastormStore.getState().prepareNextEncounter(() => 0.6); expect(next?.id).not.toBe(first.id); expect(useManastormStore.getState()).toMatchObject({ status: "transitioning", phase: "next", level: 2, completed: 1, previousEncounterId: first.id, resurrectionCharges: 5, }); useManastormStore.getState().finishTransition(); expect(useManastormStore.getState()).toMatchObject({ status: "entering", phase: "enter" }); }); it("tracks every-fifth milestones, repeating checkpoints, party sizes, and five shared charges", () => { expect([5, 10, 15, 20, 25].every(isManastormSpecialLevel)).toBe(true); expect(isManastormSpecialLevel(21)).toBe(false); expect([1, 5, 6, 10, 11, 15, 16, 21].map(manastormCheckpointForLevel)) .toEqual([1, 1, 6, 6, 11, 11, 16, 21]); useManastormStore.getState().startRun({ partySize: -4, startingLevel: 20, random: () => 0.1 }); expect(useManastormStore.getState()).toMatchObject({ partySize: 1, level: 20, checkpointLevel: 16, milestone: true, resurrectionCharges: 5, }); for (let charge = 4; charge >= 0; charge -= 1) { expect(useManastormStore.getState().useResurrectionCharge()).toBe(true); expect(useManastormStore.getState().resurrectionCharges).toBe(charge); } expect(useManastormStore.getState().useResurrectionCharge()).toBe(false); }); it("commits the clear and checkpoint atomically with boss loot", () => { useManastormStore.getState().startRun({ partySize: 2, startingLevel: 5, random: () => 0, }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true); const rewarded = useManastormStore.getState(); expect(rewarded).toMatchObject({ status: "portal-ready", level: 5, completed: 5, checkpointLevel: 6, }); const currency = rewarded.currency; useManastormStore.getState().observeBoss(true, true); expect(useManastormStore.getState().currency).toEqual(currency); useManastormStore.getState().beginTransition(); useManastormStore.getState().prepareNextEncounter(() => 0); expect(useManastormStore.getState()).toMatchObject({ level: 6, completed: 5, checkpointLevel: 6, }); }); it("restarts a failed milestone attempt from its checkpoint with restored charges", () => { useManastormStore.getState().startRun({ partySize: 3, startingLevel: 10, random: () => 0.1 }); expect(useManastormStore.getState()).toMatchObject({ level: 10, milestone: true, checkpointLevel: 6, }); expect(useManastormStore.getState().useResurrectionCharge()).toBe(true); expect(useManastormStore.getState().failRun()).toBe(true); const encounter = useManastormStore.getState().restartFromCheckpoint(() => 0.2); expect(encounter).not.toBeNull(); expect(useManastormStore.getState()).toMatchObject({ status: "entering", phase: "enter", level: 6, completed: 5, checkpointLevel: 6, resurrectionCharges: 5, }); }); it("guarantees a bonus cache when the bad-luck meter reaches five clears", () => { useManastormStore.getState().startRun({ cacheMeter: 4, random: () => 0.2 }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true); expect(useManastormStore.getState()).toMatchObject({ cacheMeter: 0 }); expect(useManastormStore.getState().rewardCache).toEqual(expect.arrayContaining([ expect.objectContaining({ cacheId: "manastorm-bonus-cache" }), ])); }); it("stacks end-game cache chance, increases it with level, and guarantees the fifth roll", () => { expect(manastormCacheChance(1, 0)).toBeCloseTo(0.12); expect(manastormCacheChance(101, 0)).toBeGreaterThan(manastormCacheChance(1, 0)); expect(manastormCacheChance(1, 3)).toBeGreaterThan(manastormCacheChance(1, 0)); expect(manastormCacheChance(1, 4)).toBe(1); const catalog: ManastormCatalogLike = { id: "chance-cache-fixture", modeId: 2, stages: [stage("chance-cache-stage", { rewardId: "chance-cache-reward" })], rewards: [{ id: "chance-cache-reward", modeIds: [2], currencyId: "bonzoBolts", currencyBase: 1, cacheId: "endgame-manastorm-cache", cachePolicy: "accumulating-chance", }], }; setManastormCatalog(catalog); useManastormStore.getState().resetRun(); useManastormStore.getState().startRun({ modeId: 2, cacheMeter: 0, random: () => 0.2, }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true, () => 0.99); expect(useManastormStore.getState()).toMatchObject({ cacheMeter: 1, rewardCache: [], }); useManastormStore.getState().resetRun(); useManastormStore.getState().startRun({ modeId: 2, cacheMeter: 4, random: () => 0.2, }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true, () => 0.99); expect(useManastormStore.getState()).toMatchObject({ cacheMeter: 0 }); expect(useManastormStore.getState().rewardCache).toEqual([ expect.objectContaining({ cacheId: "manastorm-bonus-cache" }), ]); }); it("resets bad-luck protection on a normal cache without double-awarding a bonus cache", () => { const catalog: ManastormCatalogLike = { id: "normal-cache-fixture", stages: [stage("normal-cache-stage", { rewardId: "normal-cache-reward" })], rewards: [{ id: "normal-cache-reward", currencyId: "bonzoBolts", currencyBase: 1, cacheId: "normal-cache", }], }; setManastormCatalog(catalog); useManastormStore.getState().resetRun(); useManastormStore.getState().startRun({ cacheMeter: 4, random: () => 0.2 }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true); expect(useManastormStore.getState().cacheMeter).toBe(0); expect(useManastormStore.getState().rewardCache).toEqual([ expect.objectContaining({ cacheId: "normal-cache" }), ]); }); it("keeps replayed cache rewards as distinct unopened instances", () => { const catalog: ManastormCatalogLike = { id: "replayed-cache-fixture", stages: [stage("replayed-cache-stage", { rewardId: "replayed-cache-reward" })], rewards: [{ id: "replayed-cache-reward", currencyId: "bonzoBolts", currencyBase: 1, cacheId: "replayed-cache", }], }; const originalCache = { id: "replayed-cache-stage:1:replayed-cache-reward", cacheId: "replayed-cache", rewardId: "replayed-cache-reward", stageId: "replayed-cache-stage", level: 1, }; setManastormCatalog(catalog); useManastormStore.getState().resetRun(); useManastormStore.getState().startRun({ rewardCaches: [originalCache], random: () => 0.2, }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true); const caches = useManastormStore.getState().rewardCache; expect(caches).toHaveLength(2); expect(new Set(caches.map(({ id }) => id))).toHaveLength(2); expect(useManastormStore.getState().openRewardCache(caches[0].id)).toBe(true); expect(useManastormStore.getState().rewardCache).toEqual([caches[1]]); }); it("derives tuning, group modifiers, affixes, rewards, cache, currency, messages, and loadouts from a catalog", () => { const tuning = [-45, 0, -53, -20, 0, 10, 0, 5, 1.75, 1.1, 1.2] as const; const catalog: ManastormCatalogLike = { id: "runtime-fixture", modeId: 2, maximumLevel: 100, stages: [stage("fixture-stage", { rewardId: "fixture-reward" })], modifiers: [{ id: "fixture-modifier", modeId: 2, level: 1, tuning, rawFlag: 7 }], groupModifiers: [{ playerCount: 2, modeId: 2, multipliers: [1.2, 1.5] }], affixes: [{ id: "fixture-affix", name: "Fixture", description: "Catalog affix.", unlockLevel: 1 }], rewards: [{ id: "fixture-reward", currencyId: "fixture-currency", currencyBase: 2, cacheId: "fixture-cache", }], loadouts: [{ id: "fixture-loadout", unlockLevel: 1 }], defaultLoadoutId: "fixture-loadout", messages: [{ modeId: 2, progressionValue: 1, groupId: 2, title: "Fixture title", description: "Fixture message.", }], }; setManastormCatalog(catalog); useManastormStore.getState().resetRun(); const encounter = useManastormStore.getState().startRun({ partySize: 2, random: () => 0 }); expect(useManastormStore.getState()).toMatchObject({ catalogId: "runtime-fixture", modeId: 2, partySize: 2, groupModifier: { enemyStatMultiplier: 1.2, rewardMultiplier: 1.5 }, scaling: { statMultiplier: 1.75, bonusLootChance: 0, experienceMultiplier: 1.2, enemyStatMultiplier: 2.1, rewardMultiplier: 1.5, }, activeAffixes: [{ id: "fixture-affix" }], availableLoadoutIds: ["fixture-loadout"], loadoutId: "fixture-loadout", message: "Fixture title: Fixture message.", }); useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); useManastormStore.getState().observeBoss(true, true); expect(useManastormStore.getState()).toMatchObject({ currentEncounter: encounter, currency: { "fixture-currency": 3 }, rewardCache: [{ cacheId: "fixture-cache", stageId: "fixture-stage" }], }); const cacheId = useManastormStore.getState().rewardCache[0].id; expect(useManastormStore.getState().openRewardCache(cacheId)).toBe(true); expect(useManastormStore.getState().rewardCache).toEqual([]); }); it("exposes phase-specific runtime ids for renderer filtering", () => { const encounter = stage("runtime-filter"); expect(manastormActiveRuntimeIds(encounter, "trash")).toEqual([ "runtime-filter-trash-a", "runtime-filter-trash-b", ]); expect(manastormActiveRuntimeIds(encounter, "boss")).toEqual([ "runtime-filter-trash-a", "runtime-filter-trash-b", "runtime-filter-boss", ]); expect(manastormActiveRuntimeIds(encounter, "next")).toEqual([]); }); it("detects either a player or companion crossing the snowglobe", () => { const portal = [10, 4, 10] as const; expect(actorEnteredManastormPortal(portal, [[30, 4, 10], [11, 4, 10]])).toBe(true); expect(actorEnteredManastormPortal(portal, [[30, 4, 10], [13, 4, 10]])).toBe(false); }); it("ships finite, unique fallback encounter and spawn identities for all nine bosses", () => { expect(MANASTORM_ENCOUNTERS).toHaveLength(9); expect(new Set(MANASTORM_ENCOUNTERS.map((encounter) => encounter.bossRuntimeId)).size).toBe(9); for (const encounter of MANASTORM_ENCOUNTERS) { expect(encounter.playerSpawn.footPosition.every(Number.isFinite)).toBe(true); expect(encounter.portalPosition.every(Number.isFinite)).toBe(true); expect(encounter.bossEntityId).toBe(encounter.id); } }); });