Files
healer-man/src/app/shellStore.test.ts
T

480 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import { useShellStore } from "./shellStore";
import { installDungeonShellRuntime } from "./dungeonShellRuntime";
import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
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";
import { createCharacter, listCharacters, type StorageLike } from "./accountRepository";
import { useOnlineGroupStore } from "./onlineGroupStore";
installDungeonShellRuntime();
installManastormShellRuntime();
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
const shelvedModeIt = SHELVED_MODES_ENABLED ? it : it.skip;
const character = {
id: "character-1",
ownerId: "offline-roster",
name: "Remedy",
raceId: "human" as const,
classId: "priest" as const,
specializationId: "priest:holy",
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.");
class MemoryStorage implements StorageLike {
private values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
class FailingWriteStorage extends MemoryStorage {
failWrites = false;
setItem(key: string, value: string) {
if (this.failWrites) throw new Error("Storage quota exceeded.");
super.setItem(key, value);
}
}
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", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
beforeEach(() => {
useOnlineGroupStore.getState().reset();
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("saves a Hunter's selected family to the active character and local roster", () => {
const storage = new MemoryStorage();
vi.stubGlobal("window", { localStorage: storage });
const hunter = createCharacter({
ownerId: "offline-roster",
name: "Turtlekeeper",
categoryId: "wow",
raceId: "human",
classId: "hunter",
specializationId: "hunter:marksmanship",
gender: "female",
appearance: createDefaultAppearance(),
}, storage).character!;
useShellStore.setState({
session: { kind: "offline", ownerId: hunter.ownerId, displayName: "Offline" },
characters: [hunter],
activeCharacter: hunter,
});
expect(useShellStore.getState().saveActiveHunterPetFamily("turtle")).toBe(true);
expect(useShellStore.getState().activeCharacter?.hunterPetFamily).toBe("turtle");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("turtle");
expect(useShellStore.getState().saveActiveHunterPetFamily("cat")).toBe(true);
expect(useShellStore.getState().activeCharacter?.hunterPetFamily).toBe("cat");
expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat");
});
it("switches the selected RoM stat profile only from the main menu and reports storage failure", () => {
const storage = new FailingWriteStorage();
vi.stubGlobal("window", { localStorage: storage });
const romCharacter = createCharacter({
ownerId: "offline-roster",
name: "Runekeeper",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: createDefaultAppearance(),
}, storage).character!;
useShellStore.setState({
phase: "main-menu",
session: { kind: "offline", ownerId: romCharacter.ownerId, displayName: "Offline" },
characters: [romCharacter],
selectedCharacterId: romCharacter.id,
activeCharacter: null,
});
expect(useShellStore.getState().setSelectedCharacterStatRuleset("wow")).toBe(true);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
storage.failWrites = true;
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(useShellStore.getState().notice).toMatch(/could not be saved/i);
storage.failWrites = false;
const previousMobs = useCombatStore.getState().mobs;
try {
useCombatStore.setState({ mobs: { "live-fight": { engaged: true, dead: false } as never } });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().notice).toMatch(/combat is active/i);
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
} finally {
useCombatStore.setState({ mobs: previousMobs });
}
useShellStore.setState({ activeCharacter: romCharacter });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
useShellStore.setState({ activeCharacter: null });
useOnlineGroupStore.setState({ snapshot: {
group: { activity: { id: "live-run" } } as never,
invitations: [],
} });
useShellStore.setState({ phase: "main-menu", notice: "" });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(useShellStore.getState().notice).toMatch(/live online group activity/i);
useOnlineGroupStore.getState().reset();
useShellStore.setState({ phase: "game", notice: "" });
expect(useShellStore.getState().setSelectedCharacterStatRuleset("rom")).toBe(false);
expect(useShellStore.getState().characters[0]?.statRuleset).toBe("wow");
expect(listCharacters(romCharacter.ownerId, storage)[0]?.statRuleset).toBe("wow");
});
it("routes a legacy WoW character through the mandatory specialization picker", () => {
prepareSelectedCharacter();
useShellStore.setState({ characters: [{ ...character, specializationId: null }] });
expect(useShellStore.getState().openMainMenu()).toBe(true);
expect(useShellStore.getState().phase).toBe("class-hall");
expect(useShellStore.getState().openDungeons()).toBe(false);
expect(useShellStore.getState().notice).toMatch(/choose a specialization/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, "healer")).toBe(true);
expect(useShellStore.getState()).toMatchObject({
phase: "game",
activeCharacter: character,
activeDungeonRole: "healer",
});
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(/locked to healer/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("rejects a shipped dungeon whose production content pack is not usable", () => {
vi.stubEnv("DEV", false);
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
useShellStore.getState().openDungeons();
expect(useShellStore.getState().enterDungeon("deadmines", "healer")).toBe(false);
expect(useShellStore.getState().phase).toBe("dungeons");
expect(useShellStore.getState().notice).toMatch(/downloads/i);
});
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",
});
});
normalReleaseIt("keeps legacy shelved commands out of the normal shell", () => {
prepareSelectedCharacter();
expect(useShellStore.getState().openManastorms()).toBe(false);
expect(useShellStore.getState().openArenas()).toBe(false);
expect(useShellStore.getState().enterManastorm("healer")).toBe(false);
expect(useShellStore.getState().phase).toBe("characters");
expect(useShellStore.getState().notice).toMatch(/temporarily unavailable/i);
});
shelvedModeIt("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(/locked to healer/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,
);
});
shelvedModeIt("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,
});
});
shelvedModeIt("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,
});
});
shelvedModeIt("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,
},
});
});
});