269 lines
9.9 KiB
TypeScript
269 lines
9.9 KiB
TypeScript
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<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(/\/+$/, "");
|
|
if (configured) return configured;
|
|
return Capacitor.isNativePlatform() ? NATIVE_API_ORIGIN : "";
|
|
}
|
|
|
|
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");
|
|
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<AccountResult> {
|
|
try {
|
|
const response = await requestOnlineJson<AuthResponse>(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<AccountResult> {
|
|
return authenticate("/api/auth/login", username, password);
|
|
}
|
|
|
|
export function createOnlineAccount(username: string, password: string): Promise<AccountResult> {
|
|
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<CloudSaveResponse>("/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<CharacterProfile[]> {
|
|
if (!session.accessToken) return listCharacters(session.ownerId);
|
|
activateOnlineSession(session);
|
|
const cloud = await requestOnlineJson<CloudSaveResponse>("/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<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);
|
|
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<T>(session: PlayerSession, pathname: string, init: RequestInit = {}): Promise<T> {
|
|
if (!session.accessToken) throw new OnlineApiError("Online grouping requires a signed-in account.", 401);
|
|
return requestOnlineJson<T>(pathname, init, session.accessToken);
|
|
}
|
|
|
|
export function fetchOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group");
|
|
}
|
|
|
|
export async function inviteOnlineGroupPlayer(session: PlayerSession, username: string): Promise<OnlineGroupSnapshot> {
|
|
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<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group/invite/respond", {
|
|
method: "POST",
|
|
body: JSON.stringify({ inviteId, accept }),
|
|
});
|
|
}
|
|
|
|
export function leaveOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group/leave", { method: "POST" });
|
|
}
|
|
|
|
export function removeOnlineGroupPlayer(session: PlayerSession, accountId: string): Promise<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group/remove", {
|
|
method: "POST",
|
|
body: JSON.stringify({ accountId }),
|
|
});
|
|
}
|
|
|
|
export function updateOnlineGroupMember(
|
|
session: PlayerSession,
|
|
character: CharacterProfile,
|
|
role: string,
|
|
): Promise<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group/member", {
|
|
method: "PUT",
|
|
body: JSON.stringify({ character, role }),
|
|
});
|
|
}
|
|
|
|
export function startOnlineGroupActivity(
|
|
session: PlayerSession,
|
|
activity: StartOnlineGroupActivityInput,
|
|
): Promise<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group/activity", {
|
|
method: "POST",
|
|
body: JSON.stringify(activity),
|
|
});
|
|
}
|
|
|
|
export function clearOnlineGroupActivity(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-group/activity/clear", { method: "POST" });
|
|
}
|
|
|
|
export function synchronizeOnlineSession(
|
|
session: PlayerSession,
|
|
input: OnlineSessionSyncInput,
|
|
): Promise<OnlineSessionSnapshot> {
|
|
return onlineGroupRequest(session, "/api/online-session/sync", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|