Update Healer Man server
This commit is contained in:
1 parent
d869d6b51c
commit
a64b2cb575
4 files changed
+98
-17
No files matched your search
@@ -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({
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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
@@ -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.`
|
||||||
|
|||||||
Reference in new issue
Block a user