339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { createDefaultAppearance } from "./characterCatalog";
|
|
import { useShellStore } from "./shellStore";
|
|
import { installDungeonShellRuntime } from "./dungeonShellRuntime";
|
|
import { installManastormShellRuntime } from "./manastormShellRuntime";
|
|
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
|
|
import { useGameStore } from "../game/store";
|
|
import { useManastormStore } from "../game/manastormStore";
|
|
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
|
|
import {
|
|
manastormActiveSpellLoadoutModel,
|
|
} from "../game/manastormSpellLoadout";
|
|
import {
|
|
mergeManastormUnlockedContent,
|
|
setManastormLoadout,
|
|
} from "../game/manastormProgress";
|
|
import {
|
|
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
|
UNIFIED_MANASTORM_BOSS_CATALOG,
|
|
resolveBossAdminEntry,
|
|
} from "../game/manastormAdminConfig";
|
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
|
|
|
installDungeonShellRuntime();
|
|
installManastormShellRuntime();
|
|
|
|
const character = {
|
|
id: "character-1",
|
|
ownerId: "offline-roster",
|
|
name: "Remedy",
|
|
raceId: "human" as const,
|
|
classId: "priest" as const,
|
|
gender: "female" as const,
|
|
appearance: createDefaultAppearance(),
|
|
level: 1,
|
|
experience: 0,
|
|
talentRanks: {},
|
|
actionBindings: {},
|
|
inventory: [],
|
|
equipment: {},
|
|
location: "Wailing Caverns Entrance",
|
|
createdAt: 1,
|
|
lastPlayedAt: 1,
|
|
};
|
|
|
|
function readyBossForMode(modeId: number) {
|
|
return UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
|
(entry.baseStage?.unlockLevel ?? 1) <= 1
|
|
&& (entry.baseStage?.minimumPartySize ?? 1) <= 1
|
|
&& (entry.baseStage?.maximumPartySize ?? 5) >= 5
|
|
&& (!entry.baseStage?.modeIds?.length || entry.baseStage.modeIds.includes(modeId))
|
|
&& resolveBossAdminEntry(entry, {
|
|
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
|
bosses: { [entry.key]: { status: "ready" } },
|
|
}).runnable
|
|
));
|
|
}
|
|
const readyLevelingBoss = readyBossForMode(0);
|
|
const readyEndgameBoss = readyBossForMode(2);
|
|
if (!readyLevelingBoss || !readyEndgameBoss) throw new Error("Shell tests require runnable Manastorm bosses for both modes.");
|
|
const readyTestConfig = {
|
|
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
|
bosses: {
|
|
[readyLevelingBoss.key]: { status: "ready" as const },
|
|
[readyEndgameBoss.key]: { status: "ready" as const },
|
|
},
|
|
};
|
|
|
|
function prepareSelectedCharacter(): void {
|
|
useShellStore.setState({
|
|
phase: "characters",
|
|
session: { kind: "offline", ownerId: "offline-roster", displayName: "Offline" },
|
|
characters: [character],
|
|
selectedCharacterId: character.id,
|
|
activeCharacter: null,
|
|
notice: "",
|
|
});
|
|
}
|
|
|
|
describe("shell store", () => {
|
|
beforeEach(() => {
|
|
useShellStore.setState({
|
|
phase: "login",
|
|
session: null,
|
|
characters: [],
|
|
selectedCharacterId: null,
|
|
activeCharacter: null,
|
|
activeDungeonRole: "healer",
|
|
notice: "",
|
|
});
|
|
useGameStore.getState().resetAtEntrance();
|
|
useManastormStore.getState().resetRun();
|
|
useManastormAdminStore.setState({ config: readyTestConfig, draft: readyTestConfig, dirty: false });
|
|
});
|
|
|
|
it("does not open the character hub or a dungeon without a selected character", () => {
|
|
expect(useShellStore.getState().openMainMenu()).toBe(false);
|
|
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(false);
|
|
expect(useShellStore.getState().phase).toBe("login");
|
|
expect(useShellStore.getState().notice).toMatch(/select or create/i);
|
|
});
|
|
|
|
it("opens the main menu and dungeon list without resetting gameplay", () => {
|
|
prepareSelectedCharacter();
|
|
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
|
|
|
expect(useShellStore.getState().openMainMenu()).toBe(true);
|
|
expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null });
|
|
expect(useGameStore.getState()).toMatchObject({
|
|
paused: true,
|
|
mapOpen: true,
|
|
companionOpen: true,
|
|
distanceTravelled: 42,
|
|
});
|
|
|
|
expect(useShellStore.getState().openDungeons()).toBe(true);
|
|
expect(useShellStore.getState().phase).toBe("dungeons");
|
|
expect(useGameStore.getState().distanceTravelled).toBe(42);
|
|
});
|
|
|
|
it("mounts the game and resets it only after choosing the available dungeon", () => {
|
|
prepareSelectedCharacter();
|
|
useShellStore.getState().openMainMenu();
|
|
useShellStore.getState().openDungeons();
|
|
useGameStore.setState({ paused: true, mapOpen: true, companionOpen: true, distanceTravelled: 42 });
|
|
|
|
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "damage")).toBe(true);
|
|
expect(useShellStore.getState()).toMatchObject({
|
|
phase: "game",
|
|
activeCharacter: character,
|
|
activeDungeonRole: "damage",
|
|
});
|
|
expect(useGameStore.getState()).toMatchObject({
|
|
paused: false,
|
|
mapOpen: false,
|
|
companionOpen: false,
|
|
distanceTravelled: 0,
|
|
});
|
|
});
|
|
|
|
it("rejects a role the selected character's class cannot fill", () => {
|
|
prepareSelectedCharacter();
|
|
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id, "tank")).toBe(false);
|
|
expect(useShellStore.getState().phase).toBe("characters");
|
|
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
|
});
|
|
|
|
it("rejects an unknown dungeon without resetting or leaving the list", () => {
|
|
prepareSelectedCharacter();
|
|
useShellStore.getState().openMainMenu();
|
|
useShellStore.getState().openDungeons();
|
|
useGameStore.setState({ distanceTravelled: 42 });
|
|
|
|
expect(useShellStore.getState().enterDungeon("missing-dungeon")).toBe(false);
|
|
expect(useShellStore.getState().phase).toBe("dungeons");
|
|
expect(useShellStore.getState().notice).toMatch(/not available/i);
|
|
expect(useGameStore.getState().distanceTravelled).toBe(42);
|
|
});
|
|
|
|
it("returns from gameplay to the main menu with the same character selected", () => {
|
|
prepareSelectedCharacter();
|
|
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(true);
|
|
|
|
useShellStore.getState().returnToMainMenu();
|
|
|
|
expect(useShellStore.getState()).toMatchObject({
|
|
phase: "main-menu",
|
|
selectedCharacterId: character.id,
|
|
activeCharacter: null,
|
|
notice: "",
|
|
});
|
|
});
|
|
|
|
it("keeps the original single-dungeon entry point as a compatibility alias", () => {
|
|
prepareSelectedCharacter();
|
|
expect(useShellStore.getState().enterWorld()).toBe(true);
|
|
expect(useShellStore.getState().phase).toBe("game");
|
|
});
|
|
|
|
it("returns cleanly from the creator without touching game state", () => {
|
|
useShellStore.getState().openCreator();
|
|
expect(useShellStore.getState().phase).toBe("create-character");
|
|
useShellStore.getState().cancelCreator();
|
|
expect(useShellStore.getState().phase).toBe("characters");
|
|
});
|
|
|
|
it("normalizes creator gender to the models shipped for an extended race", () => {
|
|
useShellStore.getState().openCreator();
|
|
useShellStore.getState().updateDraft({ raceId: "vrykul" });
|
|
expect(useShellStore.getState().draft).toMatchObject({ raceId: "vrykul", gender: "male" });
|
|
|
|
useShellStore.getState().updateDraft({ gender: "female" });
|
|
expect(useShellStore.getState().draft.gender).toBe("male");
|
|
});
|
|
|
|
it("resets the creator to a valid draft when switching among WoW, RoM, and CoA", () => {
|
|
useShellStore.getState().openCreator();
|
|
useShellStore.getState().updateDraft({ name: "Category Keeper", categoryId: "rom" });
|
|
expect(useShellStore.getState().draft).toMatchObject({
|
|
name: "Categorykeep",
|
|
categoryId: "rom",
|
|
raceId: "rom-human",
|
|
classId: "rom-priest",
|
|
gender: "female",
|
|
});
|
|
useShellStore.getState().updateDraft({ raceId: "rom-dwarf", gender: "female", classId: "rom-champion" });
|
|
expect(useShellStore.getState().draft).toMatchObject({
|
|
raceId: "rom-dwarf",
|
|
classId: "rom-champion",
|
|
gender: "male",
|
|
});
|
|
useShellStore.getState().updateDraft({ categoryId: "coa" });
|
|
expect(useShellStore.getState().draft).toMatchObject({
|
|
name: "Categorykeep",
|
|
categoryId: "coa",
|
|
raceId: "human",
|
|
classId: "barbarian",
|
|
gender: "female",
|
|
});
|
|
useShellStore.getState().updateDraft({ classId: "priest" });
|
|
expect(useShellStore.getState().draft.classId).toBe("barbarian");
|
|
useShellStore.getState().updateDraft({ categoryId: "wow" });
|
|
expect(useShellStore.getState().draft).toMatchObject({
|
|
name: "Categorykeep",
|
|
categoryId: "wow",
|
|
raceId: "human",
|
|
classId: "priest",
|
|
gender: "female",
|
|
});
|
|
});
|
|
|
|
it("opens Manastorms, validates the role, and starts a fresh level-one run", () => {
|
|
prepareSelectedCharacter();
|
|
expect(useShellStore.getState().openManastorms()).toBe(true);
|
|
expect(useShellStore.getState().phase).toBe("manastorms");
|
|
expect(useShellStore.getState().enterManastorm("tank")).toBe(false);
|
|
expect(useShellStore.getState().notice).toMatch(/cannot enter as tank/i);
|
|
|
|
expect(useShellStore.getState().enterManastorm("healer")).toBe(true);
|
|
expect(useShellStore.getState()).toMatchObject({
|
|
phase: "game",
|
|
activeDungeonRole: "healer",
|
|
});
|
|
expect(useGameStore.getState()).toMatchObject({ gameMode: "manastorm" });
|
|
expect(useManastormStore.getState()).toMatchObject({
|
|
status: "entering",
|
|
phase: "enter",
|
|
level: 1,
|
|
completed: 0,
|
|
});
|
|
expect(useGameStore.getState().playerPosition).toEqual(
|
|
useManastormStore.getState().currentEncounter?.playerSpawn.footPosition,
|
|
);
|
|
});
|
|
|
|
it("starts each party size from only its own unlocked checkpoint", () => {
|
|
const progress = recordManastormCheckpoint(createEmptyManastormProgress(), 3, 5, 6);
|
|
useShellStore.setState({
|
|
phase: "manastorms",
|
|
session: { kind: "offline", ownerId: "checkpoint-owner", displayName: "Offline" },
|
|
characters: [{
|
|
...character,
|
|
id: "checkpoint-character",
|
|
ownerId: "checkpoint-owner",
|
|
manastormProgress: progress,
|
|
}],
|
|
selectedCharacterId: "checkpoint-character",
|
|
activeCharacter: null,
|
|
notice: "",
|
|
});
|
|
|
|
expect(useShellStore.getState().enterManastorm("healer", 3, 6)).toBe(true);
|
|
expect(useManastormStore.getState()).toMatchObject({
|
|
partySize: 3,
|
|
level: 6,
|
|
checkpointLevel: 6,
|
|
});
|
|
});
|
|
|
|
it("selects end-game mode at the character cap and hands off an eligible checkpoint", () => {
|
|
const progress = recordManastormCheckpoint(
|
|
createEmptyManastormProgress(),
|
|
3,
|
|
105,
|
|
106,
|
|
);
|
|
useShellStore.setState({
|
|
phase: "manastorms",
|
|
session: { kind: "offline", ownerId: "endgame-owner", displayName: "Offline" },
|
|
characters: [{
|
|
...character,
|
|
id: "endgame-character",
|
|
ownerId: "endgame-owner",
|
|
level: 60,
|
|
manastormProgress: progress,
|
|
}],
|
|
selectedCharacterId: "endgame-character",
|
|
activeCharacter: null,
|
|
notice: "",
|
|
});
|
|
|
|
expect(useShellStore.getState().enterManastorm("healer", 3, 106)).toBe(true);
|
|
expect(useManastormStore.getState()).toMatchObject({
|
|
partySize: 3,
|
|
modeId: 2,
|
|
level: 106,
|
|
checkpointLevel: 106,
|
|
});
|
|
});
|
|
|
|
it("passes a persisted client-grounded spell selection into the run runtime", () => {
|
|
const model = manastormActiveSpellLoadoutModel(character.classId, character.level);
|
|
const selectedIds = model.options.slice(0, 2).map((option) => option.id);
|
|
const unlocked = mergeManastormUnlockedContent(
|
|
createEmptyManastormProgress(),
|
|
model.options.map((option) => option.id),
|
|
);
|
|
const progress = setManastormLoadout(unlocked, selectedIds, model.maxSelections).progress;
|
|
useShellStore.setState({
|
|
phase: "manastorms",
|
|
session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" },
|
|
characters: [{ ...character, manastormProgress: progress }],
|
|
selectedCharacterId: character.id,
|
|
activeCharacter: null,
|
|
notice: "",
|
|
});
|
|
|
|
expect(useShellStore.getState().enterManastorm("healer", 2, 1)).toBe(true);
|
|
expect(useManastormStore.getState().activeSpellLoadout).toMatchObject({
|
|
selectedIds,
|
|
validationError: null,
|
|
provenance: {
|
|
fidelity: "substitution",
|
|
exactServerParity: false,
|
|
},
|
|
});
|
|
});
|
|
});
|