Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
@@ -139,7 +139,7 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates v1/v2 databases and stores combat presentation settings by owner", async () => {
|
||||
it("migrates legacy databases and stores combat presentation settings by owner", async () => {
|
||||
for (const version of [1, 2]) {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
@@ -152,11 +152,33 @@ describe("local profile repository", () => {
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(3);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
legacyV3Storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 3,
|
||||
accounts: [],
|
||||
rosters: { "offline-roster": [] },
|
||||
presentationSettingsByOwner: {
|
||||
"offline-roster": {
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
audioVolume: { player: 0.75, party: 0.4, enemy: 0.2 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", legacyV3Storage)).toMatchObject({
|
||||
vfxMode: "generated",
|
||||
vfxQuality: "low",
|
||||
});
|
||||
expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!)
|
||||
.presentationSettingsByOwner["offline-roster"].vfxMode).toBe("generated");
|
||||
|
||||
const storage = new MemoryStorage();
|
||||
const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId;
|
||||
const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId;
|
||||
const customized = updateCombatPresentationSettings(firstOwner, {
|
||||
...DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
vfxMode: "original",
|
||||
vfxQuality: "low",
|
||||
vfxEnabled: { player: true, party: false, enemy: true },
|
||||
audioEnabled: { player: true, party: true, enemy: false },
|
||||
|
||||
@@ -209,14 +209,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
||||
? parsed.presentationSettingsByOwner as Record<string, unknown>
|
||||
: {};
|
||||
let presentationSettingsMigrated = false;
|
||||
const presentationSettingsByOwner = Object.fromEntries(
|
||||
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => [
|
||||
ownerId,
|
||||
normalizeCombatPresentationSettings(settings),
|
||||
]),
|
||||
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => {
|
||||
const normalized = normalizeCombatPresentationSettings(settings);
|
||||
const storedMode = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||
? (settings as { vfxMode?: unknown }).vfxMode
|
||||
: undefined;
|
||||
if (storedMode !== normalized.vfxMode) presentationSettingsMigrated = true;
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
||||
if (parsed.version !== 3) writeDatabase(database, storage);
|
||||
if (parsed.version !== 3 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDefaultAppearance } from "./characterCatalog";
|
||||
import { enterRandomGmReview } from "./gmManastormRuntime";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
const character = {
|
||||
id: "gm-character",
|
||||
ownerId: "gm-account",
|
||||
name: "Verifier",
|
||||
raceId: "human" as const,
|
||||
classId: "priest" as const,
|
||||
gender: "female" as const,
|
||||
appearance: createDefaultAppearance(),
|
||||
level: 20,
|
||||
experience: 0,
|
||||
talentRanks: {},
|
||||
actionBindings: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
location: "GM Tools",
|
||||
createdAt: 1,
|
||||
lastPlayedAt: 1,
|
||||
};
|
||||
|
||||
function prepareGm(config: ManastormAdminConfig): void {
|
||||
useShellStore.setState({
|
||||
phase: "gm-admin",
|
||||
session: {
|
||||
kind: "account",
|
||||
ownerId: "gm-account",
|
||||
displayName: "Game Master",
|
||||
accessToken: "gm-token",
|
||||
roles: ["gm"],
|
||||
},
|
||||
characters: [character],
|
||||
selectedCharacterId: character.id,
|
||||
activeCharacter: null,
|
||||
notice: "",
|
||||
});
|
||||
useManastormAdminStore.setState({ config, draft: config, dirty: false, saving: false, error: null });
|
||||
}
|
||||
|
||||
describe("GM random review runtime", () => {
|
||||
beforeEach(() => {
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
useManastormStore.getState().resetRun();
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
});
|
||||
|
||||
it("launches the exact remaining dungeon in GM test mode", () => {
|
||||
const available = DUNGEON_CATALOG.filter((dungeon) => dungeon.available);
|
||||
const selected = available[0];
|
||||
const config: ManastormAdminConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
dungeons: Object.fromEntries(available.map((dungeon) => [
|
||||
dungeon.id,
|
||||
{ status: dungeon.id === selected.id ? "needs-tested" : "ready" },
|
||||
])),
|
||||
};
|
||||
prepareGm(config);
|
||||
|
||||
expect(enterRandomGmReview("dungeon")).toBe(true);
|
||||
expect(useShellStore.getState()).toMatchObject({ phase: "game", activeDungeonId: selected.id });
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
gmTestMode: true,
|
||||
activeGmReviewTarget: { kind: "dungeon", dungeonId: selected.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a runnable Not ready Manastorm in an isolated GM review", () => {
|
||||
const catalog = resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
const selected = catalog.find((entry) => entry.runnable && entry.source.includes("installed-manastorm"));
|
||||
expect(selected).toBeTruthy();
|
||||
const config: ManastormAdminConfig = {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: Object.fromEntries(catalog.map((entry) => [
|
||||
entry.key,
|
||||
{ status: entry.key === selected!.key ? "not-ready" : "ready" },
|
||||
])),
|
||||
};
|
||||
prepareGm(config);
|
||||
|
||||
expect(enterRandomGmReview("manastorm")).toBe(true);
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
gameMode: "manastorm",
|
||||
gmTestMode: true,
|
||||
activeGmReviewTarget: { kind: "manastorm", bossKey: selected!.key },
|
||||
});
|
||||
expect(useManastormStore.getState().currentEncounter?.bossName).toBe(selected!.name);
|
||||
});
|
||||
|
||||
it("rejects non-GM sessions and clears review context when leaving", () => {
|
||||
prepareGm(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
useShellStore.setState({
|
||||
session: { kind: "offline", ownerId: "gm-account", displayName: "Offline", roles: ["gm"] },
|
||||
});
|
||||
expect(enterRandomGmReview("dungeon")).toBe(false);
|
||||
expect(useGameStore.getState().activeGmReviewTarget).toBeNull();
|
||||
|
||||
prepareGm(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
useGameStore.setState({
|
||||
gmTestMode: true,
|
||||
activeGmReviewTarget: { kind: "dungeon", dungeonId: DUNGEON_CATALOG[0].id },
|
||||
});
|
||||
useShellStore.getState().returnToMainMenu();
|
||||
expect(useGameStore.getState()).toMatchObject({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
});
|
||||
});
|
||||
@@ -5,34 +5,87 @@ import {
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
} from "../game/manastormAdminConfig";
|
||||
import {
|
||||
drawRandomGmReviewTarget,
|
||||
type GmReviewKind,
|
||||
type GmReviewTarget,
|
||||
} from "../game/gmContentReview";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
import { activateManastormStage } from "../game/manastormStageLoader";
|
||||
import { activateManastormStage, releaseActiveManastormStage } from "../game/manastormStageLoader";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { createEmptyManastormProgress, manastormModeProgress } from "../game/manastormProgress";
|
||||
import { manastormActiveSpellLoadoutModel } from "../game/manastormSpellLoadout";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import type { CharacterProfile } from "./types";
|
||||
|
||||
export function enterGmManastormTest(bossKey: string): boolean {
|
||||
function activeGmCharacter(): CharacterProfile | null {
|
||||
const shell = useShellStore.getState();
|
||||
if (!isGameMasterSession(shell.session) || !shell.selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const activeCharacter = touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((character) => character.id === shell.selectedCharacterId)
|
||||
?? null;
|
||||
if (!activeCharacter) {
|
||||
useShellStore.setState({ notice: "Select a character before testing a boss." });
|
||||
useShellStore.setState({ notice: "Select a character before testing content." });
|
||||
return null;
|
||||
}
|
||||
return activeCharacter;
|
||||
}
|
||||
|
||||
function enterGmDungeonTest(target: Extract<GmReviewTarget, { readonly kind: "dungeon" }>): boolean {
|
||||
const activeCharacter = activeGmCharacter();
|
||||
if (!activeCharacter) return false;
|
||||
useManastormStore.getState().resetRun();
|
||||
releaseActiveManastormStage();
|
||||
if (!activateDungeonSession(target.dungeonId, undefined, {
|
||||
gmTestMode: true,
|
||||
gmReviewTarget: target,
|
||||
})) {
|
||||
useShellStore.setState({ notice: "That dungeon did not pass its offline runtime gate." });
|
||||
return false;
|
||||
}
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter,
|
||||
activeDungeonId: target.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(activeCharacter.classId),
|
||||
notice: "GM dungeon review",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function enterRandomGmReview(kind: GmReviewKind): boolean {
|
||||
const admin = useManastormAdminStore.getState();
|
||||
if (admin.dirty || admin.saving) {
|
||||
useShellStore.setState({ notice: "Save or discard GM changes before testing another target." });
|
||||
return false;
|
||||
}
|
||||
const previous = useGameStore.getState().activeGmReviewTarget;
|
||||
const target = drawRandomGmReviewTarget(kind, admin.config, previous);
|
||||
if (!target) {
|
||||
const label = kind === "dungeon" ? "dungeons" : kind === "boss" ? "bosses" : "Manastorm encounters";
|
||||
useShellStore.setState({ notice: `All runnable ${label} have been marked Ready.` });
|
||||
return false;
|
||||
}
|
||||
if (target.kind === "dungeon") return enterGmDungeonTest(target);
|
||||
return enterGmManastormTest(target.bossKey, target);
|
||||
}
|
||||
|
||||
export function enterGmManastormTest(bossKey: string, reviewTarget: GmReviewTarget | null = null): boolean {
|
||||
const activeCharacter = activeGmCharacter();
|
||||
if (!activeCharacter) return false;
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
const entry = resolvedBossAdminCatalog(adminConfig).find((candidate) => candidate.key === bossKey);
|
||||
if (!entry || entry.status === "not-ready" || !entry.runnable || !entry.stage) {
|
||||
if (!entry || !entry.runnable || !entry.stage) {
|
||||
useShellStore.setState({ notice: entry?.validationIssues[0] ?? "That boss is not available for GM testing." });
|
||||
return false;
|
||||
}
|
||||
@@ -84,7 +137,7 @@ export function enterGmManastormTest(bossKey: string): boolean {
|
||||
useShellStore.setState({ notice: "The configured boss could not be prepared for testing." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false, true).activated) {
|
||||
if (!activateManastormStage(encounter, undefined, false, true, reviewTarget).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The configured dungeon or stage package is not loadable." });
|
||||
return false;
|
||||
|
||||
@@ -166,6 +166,7 @@ function abandonRun(): void {
|
||||
const state = useManastormStore.getState();
|
||||
const completed = state.completed;
|
||||
state.resetRun();
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useShellStore.setState({
|
||||
phase: "manastorms",
|
||||
activeCharacter: null,
|
||||
@@ -179,6 +180,7 @@ function failRun(): void {
|
||||
const state = useManastormStore.getState();
|
||||
if (state.status !== "failed" && !state.failRun()) return;
|
||||
useGameStore.getState().setPaused(true);
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useShellStore.setState({
|
||||
phase: "manastorms",
|
||||
activeCharacter: null,
|
||||
@@ -190,6 +192,7 @@ function leaveActiveGame(): void {
|
||||
if (useGameStore.getState().gameMode !== "manastorm") return;
|
||||
useManastormStore.getState().resetRun();
|
||||
releaseActiveManastormStage();
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
}
|
||||
|
||||
function saveSelectedLoadout(loadout: readonly string[]): boolean {
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -164,6 +165,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
signOut: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
const session = get().session;
|
||||
void logoutOnlineSession(session);
|
||||
clearSession();
|
||||
@@ -352,6 +354,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
returnToMainMenu: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
manastormShellRuntime()?.leaveActiveGame();
|
||||
const { session, characters, activeCharacter, selectedCharacterId } = get();
|
||||
if (!session) {
|
||||
@@ -381,6 +384,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
failActiveManastorm: () => manastormShellRuntime()?.failRun(),
|
||||
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
|
||||
returnToCharacters: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
const session = get().session;
|
||||
if (!session) {
|
||||
set({ phase: "login", activeCharacter: null });
|
||||
|
||||
Reference in new issue
Block a user