forked from phenom/healer-man
480 lines
18 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
});
|
|
});
|