Step 28.2: client: presence tracking
CI / check (push) Failing after 20m59s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 14:21:43 -04:00
parent 2826223f74
commit b431969243
7 changed files with 261 additions and 10 deletions

View File

@ -2052,3 +2052,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`seen` is on the entry itself**, not a list of seen ids: it's pruned with the entry after 7 days, and catch-up writes entries with the same ids however it's split, so a replayed entry can't come back unseen. - **`seen` is on the entry itself**, not a list of seen ids: it's pruned with the entry after 7 days, and catch-up writes entries with the same ids however it's split, so a replayed entry can't come back unseen.
- **Marking seen takes ids** (what was toasted or shown), ignores unknown ones, and returns how many were newly seen; it's not a manual decision, so a toast doesn't wake the fleet. - **Marking seen takes ids** (what was toasted or shown), ignores unknown ones, and returns how many were newly seen; it's not a manual decision, so a toast doesn't wake the fleet.
- **The away-period fields (`awaySince`, `lastAbsence`) stay for now**: Step 28.4 decides what the pop-up uses. - **The away-period fields (`awaySince`, `lastAbsence`) stay for now**: Step 28.4 decides what the pop-up uses.
## Step 28.2
- **Presence times come from the game clock**, not `Date.now()`: logbook entries are stamped with it, and test builds move it forward with the developer tools.
- **A cold start returns from the save's `lastTimeSeen`**: while the game is open the resolution loop runs a command every few seconds, so that's when the game was last on screen (or running in a background tab).
- **On iOS and Android, `appStateChange` decides**; the web build uses the Page Visibility API. A page opened in a background tab is away until it's shown.

View File

@ -6,6 +6,7 @@ import { ApiProvider } from "./api/context";
import { CatchingUp } from "./components/PilotLogbook"; import { CatchingUp } from "./components/PilotLogbook";
import { GameContext } from "./platform/context"; import { GameContext } from "./platform/context";
import { startResolutionLoop, type Game } from "./platform/game"; import { startResolutionLoop, type Game } from "./platform/game";
import { platformPresence, startPresence } from "./platform/presence";
import { PLAYER_STATE_KEY } from "./state/player"; import { PLAYER_STATE_KEY } from "./state/player";
import { Button } from "./ui"; import { Button } from "./ui";
@ -43,6 +44,14 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
useEffect(() => { useEffect(() => {
if (status !== "here") return; if (status !== "here") return;
let cancelled = false;
let stop = () => {};
// The save's last time seen, read before the loop moves it on: a cold start
// counts as coming back from then (decision 0.5.162).
void game.storage.load().then((loaded) => {
if (cancelled) return;
const savedAt = "save" in loaded ? loaded.save.lastTimeSeen : null;
const stopPresence = startPresence(platformPresence(), () => game.clock.now(), savedAt);
const loop = startResolutionLoop( const loop = startResolutionLoop(
game, game,
() => { () => {
@ -51,10 +60,18 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode }
}, },
setCatchingUp, setCatchingUp,
); );
stop = () => {
loop.stop();
stopPresence();
};
});
if (game.devTools) { if (game.devTools) {
window.deadhead = { call: (name, input) => game.engine.call(name, input as never) }; window.deadhead = { call: (name, input) => game.engine.call(name, input as never) };
} }
return () => loop.stop(); return () => {
cancelled = true;
stop();
};
}, [status, game, queryClient]); }, [status, game, queryClient]);
const useHere = async () => { const useHere = async () => {

View File

@ -0,0 +1,21 @@
/**
* Client settings that aren't game content (CR-028 §3): how toasts behave and
* when coming back counts as a real absence. Tests may read them; nothing in
* the engine does.
*/
export const clientConfig = {
presence: {
/** Away for less than this: unseen events show as toasts, not the pop-up (decision 0.5.162). */
shortAbsenceMs: 60_000,
},
toasts: {
/** Each toast closes on its own after this (decision 0.5.161). */
durationMs: 5_000,
/** At most this many at once; the rest become one "+N more". */
maxVisible: 3,
/** Independent pilots' routine deliveries this close together share one toast. */
batchWindowMs: 10_000,
/** From this viewport width up, toasts sit top-right; below, top-center. */
desktopMinWidthPx: 768,
},
} as const;

View File

@ -0,0 +1,97 @@
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<PresenceState>(() => ({
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()));
});
}

View File

@ -0,0 +1,108 @@
import { afterEach, describe, expect, it } from "vitest";
import {
browserPresence,
presenceAtStart,
presenceChange,
startPresence,
usePresence,
type PresenceHost,
} from "../src/platform/presence";
const T = 1_000_000;
/** A host whose visibility the test flips. */
function fakeHost(here = true) {
let listener: ((here: boolean) => void) | null = null;
const host: PresenceHost = {
isHere: () => here,
onChange(fn) {
listener = fn;
return () => (listener = null);
},
};
return {
host,
set(value: boolean) {
here = value;
listener?.(value);
},
get listening() {
return listener !== null;
},
};
}
let visibility: DocumentVisibilityState = "visible";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
});
afterEach(() => {
visibility = "visible";
});
describe("presence (Step 28.2, decision 0.5.160)", () => {
it("records when the player went away and came back", () => {
let state = presenceAtStart(true, T, null);
expect(state).toEqual({ here: true, awaySince: null, lastReturn: null, returns: 0 });
state = presenceChange(state, false, T + 1_000);
expect(state).toMatchObject({ here: false, awaySince: T + 1_000 });
// Hidden again (a second event) changes nothing.
expect(presenceChange(state, false, T + 2_000)).toBe(state);
state = presenceChange(state, true, T + 301_000);
expect(state).toEqual({
here: true,
awaySince: null,
lastReturn: { from: T + 1_000, to: T + 301_000 },
returns: 1,
});
});
it("counts a cold start as coming back from the save's last time seen", () => {
expect(presenceAtStart(true, T, T - 3_600_000)).toEqual({
here: true,
awaySince: null,
lastReturn: { from: T - 3_600_000, to: T },
returns: 1,
});
// Opened in a background tab: away since then, until it's shown.
const hidden = presenceAtStart(false, T, T - 3_600_000);
expect(presenceChange(hidden, true, T + 5_000).lastReturn).toEqual({
from: T - 3_600_000,
to: T + 5_000,
});
});
it("follows the host into the store, with times from the game clock", () => {
const fake = fakeHost();
let now = T;
const stop = startPresence(fake.host, () => now, T - 60_000);
expect(usePresence.getState().lastReturn).toEqual({ from: T - 60_000, to: T });
now = T + 10_000;
fake.set(false);
expect(usePresence.getState()).toMatchObject({ here: false, awaySince: T + 10_000 });
now = T + 40_000;
fake.set(true);
expect(usePresence.getState()).toMatchObject({
here: true,
lastReturn: { from: T + 10_000, to: T + 40_000 },
returns: 2,
});
stop();
expect(fake.listening).toBe(false);
});
it("reads the browser tab's visibility", () => {
const seen: boolean[] = [];
const stop = browserPresence.onChange((here) => seen.push(here));
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
expect(browserPresence.isHere()).toBe(false);
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
stop();
document.dispatchEvent(new Event("visibilitychange"));
expect(seen).toEqual([false, true]);
});
});

View File

@ -167,3 +167,5 @@ Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off"
Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted booking (`{ hqId }`) to `chooseAction`; `nextDecision` lets a pilot on a service trip (`onServiceTrip`: booked, reachable, not there yet) decide up to the 7-day cap even while the fleet is asleep. Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted booking (`{ hqId }`) to `chooseAction`; `nextDecision` lets a pilot on a service trip (`onServiceTrip`: booked, reachable, not there yet) decide up to the 7-day cap even while the fleet is asleep.
Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` and `service-cant-reach` (ship and HQ) for unstarted bookings; the client words them from `awayWording.highlights.serviceWaitingFunds` / `serviceCantReach`. Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` and `service-cant-reach` (ship and HQ) for unstarted bookings; the client words them from `awayWording.highlights.serviceWaitingFunds` / `serviceCantReach`.
Presence (CR-028 Step 28.2): `apps/web/src/platform/presence.ts` keeps `usePresence` (here, `awaySince`, `lastReturn`, `returns`) from the tab's visibility or Capacitor's app state, in game-clock time. `GameRoot` starts it with the stored save's `lastTimeSeen` (read before the resolution loop's first command), so opening the game is a return from then. Client-only settings (toast timing, the short-absence threshold) live in `apps/web/src/client-config.ts`.

View File

@ -582,7 +582,7 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-
Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026 … Phase 26"). New decisions recorded as plan §0.5.160–0.5.163. Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026 … Phase 26"). New decisions recorded as plan §0.5.160–0.5.163.
- [x] **Step 28.1 — Engine: seen tracking.** Every logbook entry has a saved `seen` flag (schema, and the sim's `LogbookEntry`); `logEntry` writes new entries unseen. Command `markLogbookSeen` (`POST /api/logbook/seen`, `{ entryIds }` → `{ marked }`), not a manual decision, so it doesn't wake the fleet. Save version 13: the 12 → 13 migration marks every existing entry seen. Tests: `packages/engine/test/logbook.test.ts` (new entries unseen; marking one, unknown ids ignored, marking again changes nothing, the fleet not woken, kept through export and import); `packages/schema/test/save.test.ts` (12 → 13); the route catalog. - [x] **Step 28.1 — Engine: seen tracking.** Every logbook entry has a saved `seen` flag (schema, and the sim's `LogbookEntry`); `logEntry` writes new entries unseen. Command `markLogbookSeen` (`POST /api/logbook/seen`, `{ entryIds }` → `{ marked }`), not a manual decision, so it doesn't wake the fleet. Save version 13: the 12 → 13 migration marks every existing entry seen. Tests: `packages/engine/test/logbook.test.ts` (new entries unseen; marking one, unknown ids ignored, marking again changes nothing, the fleet not woken, kept through export and import); `packages/schema/test/save.test.ts` (12 → 13); the route catalog.
- [ ] Step 28.2 — Client: presence tracking - [x] **Step 28.2 — Client: presence tracking.** `apps/web/src/platform/presence.ts`: `usePresence` (here, away since, the last absence that ended, a count of comebacks) fed by `startPresence` from the Page Visibility API (`browserPresence`) or Capacitor's `appStateChange` on iOS and Android (`nativePresence`), with times from the game clock so they compare with logbook times; pure `presenceAtStart` and `presenceChange`. `GameRoot` reads the save's `lastTimeSeen` before the resolution loop moves it on, so a cold start counts as coming back from then. New `apps/web/src/client-config.ts` holds the toast timings and the 60-second short absence (CR-028 §3). Tests (`apps/web/test/presence.test.ts`): going away and coming back give the right times, repeats ignored; a cold start (and one in a background tab); the store following a host with the game clock; the browser's `visibilitychange`.
- [ ] Step 28.3 — Client: toasts - [ ] Step 28.3 — Client: toasts
- [ ] Step 28.4 — Client: pop-up only after being away - [ ] Step 28.4 — Client: pop-up only after being away
- [ ] Step 28.5 — Regression - [ ] Step 28.5 — Regression