import { Capacitor } from "@capacitor/core"; import { create } from "zustand"; import { listCharacters, replaceCharactersForOwner, setRosterChangeListener, loadUiPreferences, loadAccountBossCards, saveUiPreferences, type AccountResult, } from "./accountRepository"; import type { CharacterProfile, PlayerSession } from "./types"; import type { OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes"; import type { OnlineSessionSnapshot, OnlineSessionSyncInput } from "./onlineSessionTypes"; const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com"; const SAVE_DEBOUNCE_MS = 500; interface AuthResponse { account: { id: string; username: string; roles?: readonly "gm"[] }; token: string; } interface CloudSaveResponse { revision: number; updatedAt: number | null; data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown }; } interface MeResponse { account: { id: string; username: string; roles?: readonly "gm"[] }; } class OnlineApiError extends Error { constructor(message: string, readonly status: number) { super(message); this.name = "OnlineApiError"; } } export function isOnlineAuthenticationError(error: unknown): boolean { return error instanceof OnlineApiError && error.status === 401; } let activeSession: PlayerSession | null = null; let suppressUpload = false; let pendingCharacters: CharacterProfile[] | null = null; let saveTimer: ReturnType | 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(/\/+$/, ""); if (configured) return configured; return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : ""; } export async function requestOnlineJson(pathname: string, init: RequestInit = {}, token?: string): Promise { const headers = new Headers(init.headers); headers.set("Accept", "application/json"); if (init.body !== undefined) headers.set("Content-Type", "application/json"); if (token) headers.set("Authorization", `Bearer ${token}`); let response: Response; try { response = await fetch(`${onlineApiOrigin()}${pathname}`, { ...init, headers }); } catch { throw new OnlineApiError("The online server is unavailable. Check your connection or play offline.", 0); } const body = await response.json().catch(() => ({})) as { error?: string }; if (!response.ok) throw new OnlineApiError(body.error ?? `Server request failed (${response.status}).`, response.status); return body as T; } function accountResult(response: AuthResponse): AccountResult { return { ok: true, session: { kind: "account", ownerId: response.account.id, displayName: response.account.username, roles: response.account.roles ?? [], accessToken: response.token, }, }; } async function authenticate(pathname: string, username: string, password: string): Promise { try { const response = await requestOnlineJson(pathname, { method: "POST", body: JSON.stringify({ username, password }), }); return accountResult(response); } catch (error) { return { ok: false, error: error instanceof Error ? error.message : "Online authentication failed." }; } } export function loginOnlineAccount(username: string, password: string): Promise { return authenticate("/api/auth/login", username, password); } export function createOnlineAccount(username: string, password: string): Promise { return authenticate("/api/auth/register", username, password); } function flushCloudSave(): void { const session = activeSession; const characters = pendingCharacters; pendingCharacters = null; if (!session?.accessToken || !characters) return; const uiPreferences = loadUiPreferences(session.ownerId); const bossCards = loadAccountBossCards(session.ownerId); const sequence = saveSequence; saveQueue = saveQueue.then(async () => { await requestOnlineJson("/api/cloud-save", { method: "PUT", body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }), }, 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(() => { saveTimer = null; flushCloudSave(); }, SAVE_DEBOUNCE_MS); } setRosterChangeListener(queueCloudSave); export function activateOnlineSession(session: PlayerSession | null): 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 { if (!session.accessToken) return listCharacters(session.ownerId); activateOnlineSession(session); const cloud = await requestOnlineJson("/api/cloud-save", {}, session.accessToken); suppressUpload = true; let characters: CharacterProfile[]; try { saveUiPreferences(session.ownerId, cloud.data?.uiPreferences); characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards); } finally { suppressUpload = false; } // Publish migrated logs even if the player has not earned another card yet. if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) { queueCloudSave(session.ownerId, characters); } return characters; } export async function refreshOnlineSession(session: PlayerSession): Promise { if (!session.accessToken) return { ...session, roles: [] }; const response = await requestOnlineJson("/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 { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; flushCloudSave(); } if (session?.accessToken) { await saveQueue; await requestOnlineJson("/api/auth/logout", { method: "POST" }, session.accessToken).catch(() => undefined); } if (activeSession?.ownerId === session?.ownerId) activeSession = null; } function onlineGroupRequest(session: PlayerSession, pathname: string, init: RequestInit = {}): Promise { if (!session.accessToken) throw new OnlineApiError("Online grouping requires a signed-in account.", 401); return requestOnlineJson(pathname, init, session.accessToken); } export function fetchOnlineGroup(session: PlayerSession): Promise { return onlineGroupRequest(session, "/api/online-group"); } export async function inviteOnlineGroupPlayer(session: PlayerSession, username: string): Promise { const response = await onlineGroupRequest<{ state: OnlineGroupSnapshot }>(session, "/api/online-group/invite", { method: "POST", body: JSON.stringify({ username }), }); return response.state; } export function respondToOnlineGroupInvite( session: PlayerSession, inviteId: string, accept: boolean, ): Promise { return onlineGroupRequest(session, "/api/online-group/invite/respond", { method: "POST", body: JSON.stringify({ inviteId, accept }), }); } export function leaveOnlineGroup(session: PlayerSession): Promise { return onlineGroupRequest(session, "/api/online-group/leave", { method: "POST" }); } export function removeOnlineGroupPlayer(session: PlayerSession, accountId: string): Promise { return onlineGroupRequest(session, "/api/online-group/remove", { method: "POST", body: JSON.stringify({ accountId }), }); } export function updateOnlineGroupMember( session: PlayerSession, character: CharacterProfile, role: string, ): Promise { return onlineGroupRequest(session, "/api/online-group/member", { method: "PUT", body: JSON.stringify({ character, role }), }); } export function startOnlineGroupActivity( session: PlayerSession, activity: StartOnlineGroupActivityInput, ): Promise { return onlineGroupRequest(session, "/api/online-group/activity", { method: "POST", body: JSON.stringify(activity), }); } export function clearOnlineGroupActivity(session: PlayerSession): Promise { return onlineGroupRequest(session, "/api/online-group/activity/clear", { method: "POST" }); } export function synchronizeOnlineSession( session: PlayerSession, input: OnlineSessionSyncInput, ): Promise { return onlineGroupRequest(session, "/api/online-session/sync", { method: "POST", body: JSON.stringify(input), }); }