Files
healer-man/src/game/manastorm.test.ts
2026-08-18 16:22:19 -04:00

627 lines
22 KiB
TypeScript

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> = {},
): 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);
}
});
});