395 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|