Update Healer Man server

This commit is contained in:
phenom committed 2026-09-23 22:25:39 -04:00
1 parent d869d6b51c
commit a64b2cb575
4 files changed
+98 -17

No files matched your search

+27
View File
@@ -4,6 +4,7 @@ import {
createCharacter, createCharacter,
createLocalAccount, createLocalAccount,
createOfflineSession, createOfflineSession,
clearSession,
listCharacters, listCharacters,
loadAccountBossCards, loadAccountBossCards,
saveAccountBossCards, saveAccountBossCards,
@@ -12,6 +13,7 @@ import {
loadCombatPresentationSettings, loadCombatPresentationSettings,
loginLocalAccount, loginLocalAccount,
restoreSession, restoreSession,
saveSession,
updateCharacterActionBindings, updateCharacterActionBindings,
updateCharacterEquipment, updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint, updateCharacterDungeonRunCheckpoint,
@@ -165,6 +167,31 @@ describe("local profile repository", () => {
expect(restoreSession(storage)?.roles).toEqual([]); expect(restoreSession(storage)?.roles).toEqual([]);
}); });
it("keeps a login across browser sessions and clears it on sign-out", () => {
const persistent = new MemoryStorage();
const firstTab = new MemoryStorage();
const nextTab = new MemoryStorage();
const session = { kind: "account" as const, ownerId: "account-1", displayName: "Healer", accessToken: "server-token", roles: ["gm" as const] };
saveSession(session, persistent, firstTab);
expect(firstTab.getItem(PROFILE_STORAGE_KEYS.session)).toBeNull();
expect(restoreSession(persistent, nextTab)).toMatchObject({ ...session, roles: [] });
clearSession(persistent, nextTab);
expect(restoreSession(persistent, nextTab)).toBeNull();
});
it("moves a previous tab-scoped session into persistent storage", () => {
const persistent = new MemoryStorage();
const previousTab = new MemoryStorage();
const session = { kind: "offline" as const, ownerId: "offline-roster", displayName: "Offline" };
previousTab.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify(session));
expect(restoreSession(persistent, previousTab)).toMatchObject(session);
expect(previousTab.getItem(PROFILE_STORAGE_KEYS.session)).toBeNull();
expect(restoreSession(persistent, new MemoryStorage())).toMatchObject(session);
});
it("normalizes legacy profiles and persists progression atomically", () => { it("normalizes legacy profiles and persists progression atomically", () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
+37 -5
View File
@@ -429,11 +429,25 @@ export function createOfflineSession(): PlayerSession {
return { kind: "offline", ownerId: OFFLINE_OWNER_ID, displayName: "Offline" }; return { kind: "offline", ownerId: OFFLINE_OWNER_ID, displayName: "Offline" };
} }
export function saveSession(session: PlayerSession, storage: StorageLike | null = browserSessionStorage()): void { export function saveSession(
storage?.setItem(SESSION_KEY, JSON.stringify(session)); session: PlayerSession,
storage: StorageLike | null = browserLocalStorage(),
transientStorage: StorageLike | null = browserSessionStorage(),
): void {
const serialized = JSON.stringify(session);
try {
if (storage) {
storage.setItem(SESSION_KEY, serialized);
if (transientStorage !== storage) {
try { transientStorage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ }
}
return;
}
} catch { /* Fall back to tab-scoped storage when persistent storage is unavailable. */ }
try { transientStorage?.setItem(SESSION_KEY, serialized); } catch { /* Storage may be disabled. */ }
} }
export function restoreSession(storage: StorageLike | null = browserSessionStorage()): PlayerSession | null { function readStoredSession(storage: StorageLike | null): PlayerSession | null {
if (!storage) return null; if (!storage) return null;
try { try {
const session = JSON.parse(storage.getItem(SESSION_KEY) ?? "null") as Partial<PlayerSession> | null; const session = JSON.parse(storage.getItem(SESSION_KEY) ?? "null") as Partial<PlayerSession> | null;
@@ -451,8 +465,26 @@ export function restoreSession(storage: StorageLike | null = browserSessionStora
} }
} }
export function clearSession(storage: StorageLike | null = browserSessionStorage()): void { export function restoreSession(
storage?.removeItem(SESSION_KEY); storage: StorageLike | null = browserLocalStorage(),
transientStorage: StorageLike | null = browserSessionStorage(),
): PlayerSession | null {
const persistent = readStoredSession(storage);
if (persistent) return persistent;
if (transientStorage === storage) return null;
const transient = readStoredSession(transientStorage);
if (transient) saveSession(transient, storage, transientStorage);
return transient;
}
export function clearSession(
storage: StorageLike | null = browserLocalStorage(),
transientStorage: StorageLike | null = browserSessionStorage(),
): void {
try { storage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ }
if (transientStorage !== storage) {
try { transientStorage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ }
}
} }
export function listCharacters(ownerId: string, storage: StorageLike | null = browserLocalStorage()): CharacterProfile[] { export function listCharacters(ownerId: string, storage: StorageLike | null = browserLocalStorage()): CharacterProfile[] {
+7 -3
View File
@@ -38,6 +38,10 @@ class OnlineApiError extends Error {
} }
} }
export function isOnlineAuthenticationError(error: unknown): boolean {
return error instanceof OnlineApiError && error.status === 401;
}
let activeSession: PlayerSession | null = null; let activeSession: PlayerSession | null = null;
let suppressUpload = false; let suppressUpload = false;
let pendingCharacters: CharacterProfile[] | null = null; let pendingCharacters: CharacterProfile[] | null = null;
@@ -135,11 +139,11 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]
setRosterChangeListener(queueCloudSave); setRosterChangeListener(queueCloudSave);
export function activateOnlineSession(session: PlayerSession): void { export function activateOnlineSession(session: PlayerSession | null): void {
if (activeSession?.ownerId !== session.ownerId && saveTimer) { if (activeSession?.ownerId !== session?.ownerId && saveTimer) {
clearTimeout(saveTimer); saveTimer = null; flushCloudSave(); clearTimeout(saveTimer); saveTimer = null; flushCloudSave();
} }
activeSession = session.accessToken ? session : null; activeSession = session?.accessToken ? session : null;
} }
export function retryCloudSave(): void { export function retryCloudSave(): void {
+27 -9
View File
@@ -56,6 +56,7 @@ import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import { import {
activateOnlineSession, activateOnlineSession,
hydrateOnlineSession, hydrateOnlineSession,
isOnlineAuthenticationError,
logoutOnlineSession, logoutOnlineSession,
refreshOnlineSession, refreshOnlineSession,
} from "./onlineAccountClient"; } from "./onlineAccountClient";
@@ -642,15 +643,17 @@ export function selectedCharacter(state: ShellState): CharacterProfile | null {
return state.characters.find((character) => character.id === state.selectedCharacterId) ?? null; return state.characters.find((character) => character.id === state.selectedCharacterId) ?? null;
} }
if (restored.session?.accessToken) { const restoredOnlineSession = restored.session?.accessToken ? restored.session : null;
activateOnlineSession(restored.session); if (restoredOnlineSession) {
void Promise.all([ activateOnlineSession(restoredOnlineSession);
hydrateOnlineSession(restored.session), void (async () => {
refreshOnlineSession(restored.session), const refreshedSession = await refreshOnlineSession(restoredOnlineSession);
]).then(([characters, refreshedSession]) => { if (useShellStore.getState().session?.accessToken !== restoredOnlineSession.accessToken) return;
const current = useShellStore.getState().session;
if (current?.ownerId !== restored.session?.ownerId) return;
saveSession(refreshedSession); saveSession(refreshedSession);
useShellStore.setState({ session: refreshedSession });
const characters = await hydrateOnlineSession(refreshedSession);
const current = useShellStore.getState().session;
if (current?.accessToken !== restoredOnlineSession.accessToken) return;
useShellStore.setState({ useShellStore.setState({
session: refreshedSession, session: refreshedSession,
characters, characters,
@@ -660,7 +663,22 @@ if (restored.session?.accessToken) {
: characters[0]?.id ?? null, : characters[0]?.id ?? null,
notice: "Cloud roster synchronized.", notice: "Cloud roster synchronized.",
}); });
}).catch((error) => { })().catch((error) => {
if (useShellStore.getState().session?.accessToken !== restoredOnlineSession.accessToken) return;
if (isOnlineAuthenticationError(error)) {
clearSession();
activateOnlineSession(null);
useShellStore.setState({
phase: "login",
session: null,
characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null,
activeCharacter: null,
notice: "Your session has expired. Please sign in again.",
});
return;
}
useShellStore.setState({ useShellStore.setState({
notice: error instanceof Error notice: error instanceof Error
? `${error.message} Using the cached roster.` ? `${error.message} Using the cached roster.`