forked from phenom/healer-man
Release Healer Man 0.1.28
This commit is contained in:
1 parent
ed8b9e3719
commit
8c9809d3e0
23 files changed
+975
-55
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
isRomClassId,
|
||||
@@ -64,6 +65,7 @@ interface ProfileDatabase {
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
uiPreferencesByOwner?: Record<string, UiPreferences>;
|
||||
}
|
||||
|
||||
export interface AccountResult {
|
||||
@@ -202,6 +204,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
accounts?: unknown;
|
||||
rosters?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
@@ -243,7 +246,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
return [ownerId, normalized];
|
||||
}),
|
||||
);
|
||||
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner };
|
||||
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
@@ -254,11 +259,23 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
||||
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
||||
if (!storage || !rosterChangeListener) return;
|
||||
for (const [ownerId, characters] of Object.entries(database.rosters)) {
|
||||
rosterChangeListener(ownerId, characters);
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadUiPreferences(ownerId: string, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
|
||||
return normalizeUiPreferences(readDatabase(storage).uiPreferencesByOwner?.[ownerId]);
|
||||
}
|
||||
|
||||
export function saveUiPreferences(ownerId: string, value: unknown, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
|
||||
const database = readDatabase(storage);
|
||||
const preferences = normalizeUiPreferences(value);
|
||||
database.uiPreferencesByOwner = { ...database.uiPreferencesByOwner, [ownerId]: preferences };
|
||||
writeDatabase(database, storage);
|
||||
return preferences;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let result = "";
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
listCharacters,
|
||||
replaceCharactersForOwner,
|
||||
setRosterChangeListener,
|
||||
loadUiPreferences,
|
||||
saveUiPreferences,
|
||||
type AccountResult,
|
||||
} from "./accountRepository";
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
@@ -20,7 +23,7 @@ interface AuthResponse {
|
||||
interface CloudSaveResponse {
|
||||
revision: number;
|
||||
updatedAt: number | null;
|
||||
data: { version?: number; characters?: unknown };
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
@@ -39,6 +42,8 @@ let suppressUpload = false;
|
||||
let pendingCharacters: CharacterProfile[] | null = null;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saveQueue = Promise.resolve();
|
||||
export const useCloudSaveStatus = create<{ state: "local" | "pending" | "synced" | "error"; message: string }>(() => ({ state: "local", message: "" }));
|
||||
let saveSequence = 0;
|
||||
|
||||
export function onlineApiOrigin(): string {
|
||||
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||
@@ -100,18 +105,24 @@ function flushCloudSave(): void {
|
||||
const characters = pendingCharacters;
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
const uiPreferences = loadUiPreferences(session.ownerId);
|
||||
const sequence = saveSequence;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
||||
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
|
||||
}, session.accessToken);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
|
||||
}).catch((error) => {
|
||||
console.error("Healer Man cloud save failed", error);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "error", message: "Saved on this device. Cloud upload failed; retry before switching devices." });
|
||||
});
|
||||
}
|
||||
|
||||
function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void {
|
||||
if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return;
|
||||
saveSequence += 1;
|
||||
useCloudSaveStatus.setState({ state: "pending", message: "Uploading to your account…" });
|
||||
pendingCharacters = characters.map((character) => ({ ...character }));
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
@@ -123,15 +134,23 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]
|
||||
setRosterChangeListener(queueCloudSave);
|
||||
|
||||
export function activateOnlineSession(session: PlayerSession): void {
|
||||
if (activeSession?.ownerId !== session.ownerId && saveTimer) {
|
||||
clearTimeout(saveTimer); saveTimer = null; flushCloudSave();
|
||||
}
|
||||
activeSession = session.accessToken ? session : null;
|
||||
}
|
||||
|
||||
export function retryCloudSave(): void {
|
||||
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
|
||||
}
|
||||
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
try {
|
||||
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
} finally {
|
||||
suppressUpload = false;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadUiPreferences, saveUiPreferences, setRosterChangeListener } from "./accountRepository";
|
||||
import { normalizeUiPreferences } from "../game/uiLayout";
|
||||
import type { PlayerSession } from "./types";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||
}
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setRosterChangeListener(null); });
|
||||
|
||||
describe("profile UI persistence", () => {
|
||||
it("isolates owners and persists templates without requiring a character", () => {
|
||||
const local = storage(); const ui = normalizeUiPreferences(null);
|
||||
ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc };
|
||||
saveUiPreferences("one", ui, local);
|
||||
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
|
||||
expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull();
|
||||
ui.slots.pc[1].name = "Unsaved change";
|
||||
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
|
||||
});
|
||||
it("uploads all six slots and restores them into a separate device cache", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = storage(); const second = storage();
|
||||
vi.stubGlobal("window", { localStorage: first });
|
||||
let cloudData: unknown = { version: 1, characters: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}));
|
||||
const client = await import("./onlineAccountClient");
|
||||
const session: PlayerSession = { ownerId: "cloud-user", kind: "account", displayName: "Player", accessToken: "test-token" };
|
||||
client.activateOnlineSession(session);
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc };
|
||||
ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile };
|
||||
saveUiPreferences(session.ownerId, ui);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
|
||||
vi.stubGlobal("window", { localStorage: second });
|
||||
await client.hydrateOnlineSession(session);
|
||||
expect(loadUiPreferences(session.ownerId)).toEqual(ui);
|
||||
expect(loadUiPreferences("different-account").slots.pc[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user