import { App } from "@capacitor/app"; import { Capacitor } from "@capacitor/core"; import { create } from "zustand"; /** A stretch of time the player was away: the game hidden, in the background, or closed. */ export interface Absence { from: number; to: number; } /** * Whether the player is "here" (decision 0.5.160): the tab visible or the app * in the foreground. `lastReturn` is the absence that ended most recently * (a cold start counts as coming back from the save's last time seen); * `returns` counts comebacks, so a component can react to each one. */ export interface PresenceState { here: boolean; awaySince: number | null; lastReturn: Absence | null; returns: number; } /** The state when the game starts: back from `savedAt` (null for a new game) if it opens in front. */ export function presenceAtStart(here: boolean, now: number, savedAt: number | null): PresenceState { if (!here) return { here, awaySince: savedAt ?? now, lastReturn: null, returns: 0 }; return { here, awaySince: null, lastReturn: savedAt !== null && savedAt < now ? { from: savedAt, to: now } : null, returns: savedAt !== null && savedAt < now ? 1 : 0, }; } /** Going away or coming back at `now`; anything else changes nothing. */ export function presenceChange(state: PresenceState, here: boolean, now: number): PresenceState { if (here === state.here) return state; if (!here) return { ...state, here, awaySince: now }; return { here, awaySince: null, lastReturn: { from: state.awaySince ?? now, to: now }, returns: state.returns + 1, }; } export const usePresence = create(() => ({ here: true, awaySince: null, lastReturn: null, returns: 0, })); /** Where "here" comes from: the page's visibility, or the native app's state. */ export interface PresenceHost { isHere(): boolean; /** Calls `fn` with each change; returns an unsubscribe. */ onChange(fn: (here: boolean) => void): () => void; } /** The browser tab (Page Visibility API). */ export const browserPresence: PresenceHost = { isHere: () => document.visibilityState === "visible", onChange(fn) { const listener = () => fn(document.visibilityState === "visible"); document.addEventListener("visibilitychange", listener); return () => document.removeEventListener("visibilitychange", listener); }, }; /** The native app (Capacitor app state): in the foreground or not. */ export const nativePresence: PresenceHost = { isHere: () => document.visibilityState === "visible", onChange(fn) { const handle = App.addListener("appStateChange", ({ isActive }) => fn(isActive)); return () => void handle.then((h) => h.remove()); }, }; export function platformPresence(): PresenceHost { return Capacitor.isNativePlatform() ? nativePresence : browserPresence; } /** * Tracks presence into `usePresence` (CR-028 Step 28.2), with times from the * game's clock so they compare with logbook times. Returns a stop function. */ export function startPresence( host: PresenceHost, now: () => number, savedAt: number | null, ): () => void { usePresence.setState(presenceAtStart(host.isHere(), now(), savedAt)); return host.onChange((here) => { usePresence.setState((state) => presenceChange(state, here, now())); }); }