forked from phenom/healer-man
Release Healer Man 0.1.5
This commit is contained in:
1 parent
f4c9cf356c
commit
55cfd43d66
62 files changed
+3030
-153
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
listCharacters,
|
||||
loadCombatPresentationSettings,
|
||||
loginLocalAccount,
|
||||
restoreSession,
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterEquipment,
|
||||
updateCharacterInventory,
|
||||
@@ -86,6 +87,17 @@ describe("local profile repository", () => {
|
||||
expect(listCharacters("offline-roster", storage)).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops spoofed GM roles from offline sessions", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify({
|
||||
kind: "offline",
|
||||
ownerId: "offline-roster",
|
||||
displayName: "Offline",
|
||||
roles: ["gm"],
|
||||
}));
|
||||
expect(restoreSession(storage)?.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes legacy profiles and persists progression atomically", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
|
||||
@@ -374,7 +374,11 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora
|
||||
if (session.kind === "account"
|
||||
&& typeof session.accessToken !== "string"
|
||||
&& !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null;
|
||||
return session as PlayerSession;
|
||||
return {
|
||||
...(session as PlayerSession),
|
||||
// Session storage is client-controlled. /api/me re-derives online roles.
|
||||
roles: [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||
import {
|
||||
isGameMasterSession,
|
||||
resolvedBossAdminCatalog,
|
||||
} from "../game/manastormAdminConfig";
|
||||
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 { 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";
|
||||
|
||||
export function enterGmManastormTest(bossKey: string): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
if (!isGameMasterSession(shell.session) || !shell.selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
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." });
|
||||
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) {
|
||||
useShellStore.setState({ notice: entry?.validationIssues[0] ?? "That boss is not available for GM testing." });
|
||||
return false;
|
||||
}
|
||||
|
||||
registerGeneratedManastormStageAssets();
|
||||
registerManastormStageBindingResolver(installedManastormStageBinding);
|
||||
const modeId = manastormModeForCharacterLevel(activeCharacter.level, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const { milestoneLevel: _milestoneLevel, ...stageWithoutMilestone } = entry.stage;
|
||||
const testStage = Object.freeze({
|
||||
...stageWithoutMilestone,
|
||||
modeIds: [modeId],
|
||||
stageKind: "standard" as const,
|
||||
unlockLevel: 1,
|
||||
chaoticLinkEnabled: adminConfig.global.chaoticLinkEnabled,
|
||||
});
|
||||
const testCatalog = Object.freeze({
|
||||
...INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
stages: [testStage],
|
||||
fallbackPolicy: "none" as const,
|
||||
runtimeOptions: Object.freeze({ ...adminConfig.global }),
|
||||
});
|
||||
setManastormCatalog(testCatalog);
|
||||
useManastormStore.getState().resetRun();
|
||||
const progress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||
const modeProgress = manastormModeProgress(progress, modeId);
|
||||
const spellLoadout = manastormActiveSpellLoadoutModel(
|
||||
activeCharacter.classId,
|
||||
activeCharacter.level,
|
||||
activeCharacter.talentRanks,
|
||||
);
|
||||
let encounter;
|
||||
try {
|
||||
encounter = useManastormStore.getState().startRun({
|
||||
partySize: 5,
|
||||
startingLevel: 1,
|
||||
unlockedThroughLevel: 1,
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadout.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadout.maxSelections,
|
||||
provenance: spellLoadout.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
random: () => 0,
|
||||
});
|
||||
} catch {
|
||||
useShellStore.setState({ notice: "The configured boss could not be prepared for testing." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false, true).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The configured dungeon or stage package is not loadable." });
|
||||
return false;
|
||||
}
|
||||
|
||||
useGameStore.getState().setCompanionOpen(false);
|
||||
useShellStore.setState({
|
||||
phase: "game",
|
||||
activeCharacter,
|
||||
activeDungeonId: encounter.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(activeCharacter.classId),
|
||||
notice: `GM test: ${entry.name}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { useGameStore } from "../game/store";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { manastormModeForCharacterLevel, setManastormCatalog } from "../game/manastorm";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
|
||||
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
||||
import { registerGeneratedManastormStageAssets } from "../game/manastormAssetRegistry";
|
||||
import { installedManastormStageBinding } from "../game/manastormInstalledStages";
|
||||
import { registerManastormStageBindingResolver } from "../game/manastormStagePopulation";
|
||||
@@ -71,11 +73,28 @@ function enterManastorm(
|
||||
|
||||
const partySize = isManastormPartySize(requestedPartySize) ? requestedPartySize : 5;
|
||||
registerGeneratedManastormStageAssets();
|
||||
setManastormCatalog(INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const adminConfig = useManastormAdminStore.getState().config;
|
||||
if (!adminConfig.global.enabled) {
|
||||
useShellStore.setState({ notice: "Manastorms are currently disabled by a Game Master." });
|
||||
return false;
|
||||
}
|
||||
const effectiveCatalog = effectiveManastormCatalog(adminConfig, INSTALLED_MANASTORM_RUNTIME_CATALOG);
|
||||
const modeId = manastormModeForCharacterLevel(
|
||||
activeCharacter.level,
|
||||
INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
effectiveCatalog,
|
||||
);
|
||||
if (
|
||||
(modeId === 0 && !adminConfig.global.levelingEnabled)
|
||||
|| (modeId === 2 && !adminConfig.global.endgameEnabled)
|
||||
) {
|
||||
useShellStore.setState({ notice: `${modeId === 2 ? "End-game" : "Leveling"} Manastorms are currently disabled.` });
|
||||
return false;
|
||||
}
|
||||
if (!effectiveCatalog.stages?.length) {
|
||||
useShellStore.setState({ notice: "No bosses are currently marked Ready for Manastorms." });
|
||||
return false;
|
||||
}
|
||||
setManastormCatalog(effectiveCatalog);
|
||||
const storedProgress = activeCharacter.manastormProgress ?? createEmptyManastormProgress();
|
||||
const progress = modeId === 2
|
||||
? applyManastormEndgameHandoff(storedProgress)
|
||||
@@ -106,20 +125,26 @@ function enterManastorm(
|
||||
const startingLevel = requestedStartingLevel === 1
|
||||
? 1
|
||||
: availableCheckpoint;
|
||||
const encounter = useManastormStore.getState().startRun({
|
||||
partySize,
|
||||
startingLevel,
|
||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadoutModel.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadoutModel.maxSelections,
|
||||
provenance: spellLoadoutModel.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
});
|
||||
let encounter;
|
||||
try {
|
||||
encounter = useManastormStore.getState().startRun({
|
||||
partySize,
|
||||
startingLevel,
|
||||
unlockedThroughLevel: Math.max(startingLevel, savedParty.highestLevel + 1),
|
||||
modeId,
|
||||
activeSpellLoadout: {
|
||||
available: spellLoadoutModel.options,
|
||||
selectedIds: progress.loadout,
|
||||
maxSelections: spellLoadoutModel.maxSelections,
|
||||
provenance: spellLoadoutModel.provenance,
|
||||
},
|
||||
cacheMeter: modeProgress.cacheMeter,
|
||||
rewardCaches: modeProgress.rewardCaches,
|
||||
});
|
||||
} catch {
|
||||
useShellStore.setState({ notice: "No Ready boss is eligible for this Manastorm level and party size." });
|
||||
return false;
|
||||
}
|
||||
if (!activateManastormStage(encounter, undefined, false).activated) {
|
||||
useManastormStore.getState().resetRun();
|
||||
useShellStore.setState({ notice: "The first Manastorm could not be opened." });
|
||||
|
||||
@@ -11,7 +11,7 @@ const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
interface AuthResponse {
|
||||
account: { id: string; username: string };
|
||||
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ interface CloudSaveResponse {
|
||||
data: { version?: number; characters?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
account: { id: string; username: string; roles?: readonly "gm"[] };
|
||||
}
|
||||
|
||||
class OnlineApiError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
@@ -40,7 +44,7 @@ function apiOrigin(): string {
|
||||
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
||||
}
|
||||
|
||||
async function requestJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||
export async function requestOnlineJson<T>(pathname: string, init: RequestInit = {}, token?: string): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Accept", "application/json");
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
@@ -63,6 +67,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
||||
kind: "account",
|
||||
ownerId: response.account.id,
|
||||
displayName: response.account.username,
|
||||
roles: response.account.roles ?? [],
|
||||
accessToken: response.token,
|
||||
},
|
||||
};
|
||||
@@ -70,7 +75,7 @@ function accountResult(response: AuthResponse): AccountResult {
|
||||
|
||||
async function authenticate(pathname: string, username: string, password: string): Promise<AccountResult> {
|
||||
try {
|
||||
const response = await requestJson<AuthResponse>(pathname, {
|
||||
const response = await requestOnlineJson<AuthResponse>(pathname, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
@@ -94,7 +99,7 @@ function flushCloudSave(): void {
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||
}, session.accessToken);
|
||||
@@ -122,7 +127,7 @@ export function activateOnlineSession(session: PlayerSession): void {
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
try {
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
@@ -131,6 +136,17 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||
if (!session.accessToken) return { ...session, roles: [] };
|
||||
const response = await requestOnlineJson<MeResponse>("/api/me", {}, session.accessToken);
|
||||
return {
|
||||
...session,
|
||||
ownerId: response.account.id,
|
||||
displayName: response.account.username,
|
||||
roles: response.account.roles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function logoutOnlineSession(session: PlayerSession | null): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
@@ -139,7 +155,7 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
|
||||
}
|
||||
if (session?.accessToken) {
|
||||
await saveQueue;
|
||||
await requestJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||
await requestOnlineJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined);
|
||||
}
|
||||
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
||||
}
|
||||
@@ -14,6 +14,12 @@ 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();
|
||||
@@ -37,6 +43,29 @@ const character = {
|
||||
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",
|
||||
@@ -61,6 +90,7 @@ describe("shell store", () => {
|
||||
});
|
||||
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", () => {
|
||||
|
||||
+22
-1
@@ -51,7 +51,9 @@ import {
|
||||
activateOnlineSession,
|
||||
hydrateOnlineSession,
|
||||
logoutOnlineSession,
|
||||
refreshOnlineSession,
|
||||
} from "./onlineAccountClient";
|
||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||
|
||||
export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
@@ -77,6 +79,7 @@ export interface ShellState {
|
||||
openMainMenu: () => boolean;
|
||||
openDungeons: () => boolean;
|
||||
openManastorms: () => boolean;
|
||||
openGmAdmin: () => boolean;
|
||||
openClassHall: () => boolean;
|
||||
chooseRomSecondaryClass: (classId: RomClassId) => boolean;
|
||||
swapSelectedRomClasses: () => boolean;
|
||||
@@ -294,6 +297,19 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ phase: "manastorms", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openGmAdmin: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
if (!isGameMasterSession(session)) {
|
||||
set({ notice: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
if (!selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
|
||||
set({ phase: "characters", notice: "Select a character before opening GM Tools." });
|
||||
return false;
|
||||
}
|
||||
set({ phase: "gm-admin", activeCharacter: null, notice: "" });
|
||||
return true;
|
||||
},
|
||||
openClassHall: () => {
|
||||
const { session, selectedCharacterId, characters } = get();
|
||||
const character = characters.find((entry) => entry.id === selectedCharacterId);
|
||||
@@ -500,10 +516,15 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null {
|
||||
|
||||
if (restored.session?.accessToken) {
|
||||
activateOnlineSession(restored.session);
|
||||
void hydrateOnlineSession(restored.session).then((characters) => {
|
||||
void Promise.all([
|
||||
hydrateOnlineSession(restored.session),
|
||||
refreshOnlineSession(restored.session),
|
||||
]).then(([characters, refreshedSession]) => {
|
||||
const current = useShellStore.getState().session;
|
||||
if (current?.ownerId !== restored.session?.ownerId) return;
|
||||
saveSession(refreshedSession);
|
||||
useShellStore.setState({
|
||||
session: refreshedSession,
|
||||
characters,
|
||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||
? useShellStore.getState().selectedCharacterId
|
||||
|
||||
+3
-1
@@ -5,13 +5,15 @@ import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import type { ManastormProgress } from "../game/manastormProgress";
|
||||
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "game";
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "gm-admin" | "game";
|
||||
export type SessionKind = "account" | "offline";
|
||||
export type AccountRole = "gm";
|
||||
|
||||
export interface PlayerSession {
|
||||
kind: SessionKind;
|
||||
ownerId: string;
|
||||
displayName: string;
|
||||
roles?: readonly AccountRole[];
|
||||
/** Present for server-backed accounts; kept in session storage only. */
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user