Step 28.4: client: pop-up only after being away
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-18 17:31:57 -04:00
parent 43859765eb
commit d6f782c444
30 changed files with 503 additions and 62 deletions

View File

@ -2066,3 +2066,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Batching is by time on screen**: an Independent pilot's routine delivery (not a deadhead, tutorial run or scripted job) joins a batch toast shown in the last 10 s; one alone reads like any delivery. The player's own deliveries, discoveries, level-ups and problems are never batched.
- **Entries with no toast are marked seen too** (deadheads, refuelling, rest, automatic bookings): the player was here when they happened. A repair or upgrade a booked service finished is toasted once, as "service finished".
- **Only entries stamped after the player came back are toasted** (`hereSince`); earlier unseen ones belong to the absence (Step 28.4). "Can't reach" isn't an entry, so it's toasted when it first appears among the problems while the game is open.
## Step 28.4
- **The pop-up covers every unseen entry from before the return**, not only those after the time the player left: what happened while the player was here is always marked seen (toasted, or silently for deadheads and the like), so anything still unseen happened while away. That also covers a game closed in a hidden tab.
- **A cold start comes back from when the player went away**, remembered on the device: a hidden tab keeps the game running and saving, so the save's last time seen can be hours after the player left. Without that memory (a new device, storage blocked), the save's last time seen is used.
- **Only entries worth a toast open the pop-up**: a return to only refuelling, rest or automatic bookings shows nothing (and marks them seen).
- **Delivery summaries are still acknowledged**, now by whichever showed the news (the feed, the toasts after a short absence, or the pop-up), because the deal, the skill-point prompt and radio calls wait for them; a new `useAwayOpen` flag also holds those back while the pop-up decides or shows.
- **The Logbook's "This absence"** is the absence the player last came back from in this session; before any, the save's own.

View File

@ -4,7 +4,8 @@ import {
advanceTo,
dismissLogbook,
dispatchFromEarthBoard,
logbook,
expectNews,
goAway,
playToDeal,
state,
} from "./support";
@ -27,10 +28,11 @@ test.describe("end to end (Phase 12)", () => {
// One contract, dispatched in the UI and resolved.
const dispatchId = await dispatchFromEarthBoard(page);
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
await goAway(page);
await advanceTo(page, flight.arrivalAt);
await page.reload();
// The net, after the lender's cut (CR-025); the cut itself is in the Logbook.
await expect(logbook(page)).toContainText(/delivered .+ to Moon, \+[\d,]+ cr/);
await expectNews(page, /delivered .+ to Moon, \+[\d,]+ cr/);
await dismissLogbook(page);
const after = await state(page);

View File

@ -3,12 +3,13 @@ import { content } from "@deadhead/content";
import type { Contract, Dispatch } from "@deadhead/schema";
import {
acknowledgeAll,
advance,
addCredits,
advance,
advanceTo,
board,
dismissLogbook,
logbook,
expectNews,
goAway,
playToDeal,
run,
state,
@ -58,9 +59,10 @@ test.describe("end to end (Phase 12)", () => {
expect(offer, "a first contact to Mars on an Earth board").toBeDefined();
const flight = await tryDispatch(page, offer!.id, clipper.id, veteran.id);
expect(flight, "the Clipper and veteran can take it").not.toBeNull();
await goAway(page);
await advanceTo(page, flight!.arrivalAt);
await page.goto("/");
await expect(logbook(page)).toContainText("opened a new market: Mars");
await expectNews(page, "opened a new market: Mars");
await dismissLogbook(page);
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).

View File

@ -1,5 +1,12 @@
import { expect, test } from "@playwright/test";
import { advanceTo, dismissLogbook, dispatchFromEarthBoard, playToDeal, state } from "./support";
import {
advanceTo,
dismissLogbook,
dispatchFromEarthBoard,
goAway,
playToDeal,
state,
} from "./support";
test.describe("the game runs on the device (CR-014 Step 14.7)", () => {
test("plays the opening with every network request outside the app's own files blocked", async ({
@ -22,6 +29,7 @@ test.describe("the game runs on the device (CR-014 Step 14.7)", () => {
expect(owner.ships.map((s) => s.modelId)).toEqual(["courier", "clipper"]);
const dispatchId = await dispatchFromEarthBoard(page);
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
await goAway(page);
await advanceTo(page, flight.arrivalAt);
await page.reload();
await dismissLogbook(page);

View File

@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import type { PlayerState } from "@deadhead/schema";
import { addCredits, advance, logbook, playToDeal, run, state } from "./support";
import { addCredits, advance, goAway, logbook, playToDeal, run, state } from "./support";
const HOUR = 3_600_000;
const PILOTS = 7;
@ -28,6 +28,8 @@ test.describe("the highlights pop-up (CR-025 Step 25.3)", () => {
(p: PlayerState["pilots"][number]) => p.isVeteran,
)!;
await run(page, "setPilotMode", { pilotId: rosa.id, mode: "independent" });
// Away for the four hours (CR-028), back with a fresh load.
await goAway(page);
for (let h = 0; h < 4; h++) await advance(page, HOUR);
await page.goto("/");

View File

@ -57,16 +57,43 @@ export const logbook = (page: Page) =>
});
/** Waits for the logbook when the server has unseen summaries, and dismisses it. */
/**
* Closes "While you were away" if coming back opened it (CR-028: after a
* short absence the news shows as toasts instead, and closes the summaries
* itself). Waits until no delivery summary is left.
*/
export async function dismissLogbook(page: Page) {
if ((await state(page)).summaries.length === 0) return;
const sheet = logbook(page);
await expect(sheet).toBeVisible();
await sheet.getByRole("button").last().click();
await expect(sheet).toBeHidden();
await expect
.poll(async () => (await sheet.isVisible()) || (await state(page)).summaries.length === 0, {
timeout: 15_000,
})
.toBe(true);
if (!(await sheet.isVisible())) {
// Nothing to close yet: give a summary that is still reading the logbook a moment.
await sheet.waitFor({ state: "visible", timeout: 1_500 }).catch(() => {});
}
if (await sheet.isVisible()) {
await sheet.getByRole("button").last().click();
await expect(sheet).toBeHidden();
}
// The acknowledgement is saved in the page; don't navigate away before it lands.
await expect.poll(async () => (await state(page)).summaries.length).toBe(0);
}
/** The tab hidden (CR-028): the player is away; the game keeps running. */
export async function goAway(page: Page) {
await page.evaluate(() => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" });
document.dispatchEvent(new Event("visibilitychange"));
});
}
/** News, as a toast (the player was here, or briefly away) or in "While you were away". */
export async function expectNews(page: Page, text: RegExp | string) {
await expect(page.getByText(text).first()).toBeVisible();
}
/** A brand-new company: "Start your company" from the start screen. */
export async function startCompany(page: Page) {
await page.goto("/");
@ -88,6 +115,8 @@ export async function flyTutorial(page: Page) {
await page.getByRole("button", { name: "Fly it" }).first().click();
await expect.poll(async () => (await state(page)).activeDispatches.length).toBe(1);
const [flight] = (await state(page)).activeDispatches;
// Away while it flies (CR-028), back with a reload.
await goAway(page);
await advanceTo(page, flight!.arrivalAt);
await page.reload();
await dismissLogbook(page);

View File

@ -8,9 +8,13 @@ import {
type AwaySummary as Summary,
} from "@deadhead/sim";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { useNavigate } from "react-router";
import { useApi } from "../api/context";
import { clientConfig } from "../client-config";
import { usePresence, type Absence } from "../platform/presence";
import { useToasts } from "../state/toasts";
import { useClock } from "../clock/clock";
import { fill, formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions";
@ -18,6 +22,7 @@ import { Button, Icon, Sheet } from "../ui";
import { eventResultText } from "./EventResults";
import { SleepBanner } from "./FleetSleep";
import { serviceItems } from "./Service";
import { entryToasts } from "./ToastFeed";
const words = content.awayWording;
@ -192,45 +197,97 @@ export function useAwaySummary(
});
}
/** Whether "While you were away" is deciding or open: other sheets wait for it. */
export const useAwayOpen = create<{ open: boolean }>(() => ({ open: false }));
/** An absence's unseen news: the entries the summary covers, and what needs the player now. */
interface News {
absence: Absence;
entries: LogEntry[];
problems: AwayProblem[];
}
/**
* "While you were away" (Step 9.2, decision 0.5.140): highlights on one phone
* screen, the detail in the Logbook. Opens on unseen deliveries and finished
* upgrades, as before.
* "While you were away" (Step 9.2, decisions 0.5.140, 0.5.162): on coming
* back (the tab shown again, the app in front, or a fresh launch), the unseen
* entries from that absence. With none worth a toast, nothing shows; away
* under 60 seconds, they show as toasts; otherwise the pop-up, and they're
* marked seen when it closes. Never while the player is here: that's toasts.
*/
export function AwaySummary() {
const state = useLoadedPlayer();
const acknowledge = useGameAction("acknowledgeSummaries");
// Hide as soon as the player dismisses; the refetch clears the list for real.
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
const api = useApi();
const navigate = useNavigate();
const acknowledge = useGameAction("acknowledgeSummaries");
const push = useToasts((s) => s.push);
const returns = usePresence((p) => p.returns);
const lastReturn = usePresence((p) => p.lastReturn);
const now = useClock((s) => s.now);
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
// Upgrades finished and not yet seen: "out of the shop" (CR-009).
const outOfShop = state.ships.filter(
(s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`),
);
const open = summaries.length > 0 || outOfShop.length > 0;
const refresh = [...summaries.map((s) => s.dispatchId), ...outOfShop.map((s) => s.id)].join();
const summary = useAwaySummary(state, refresh, open);
if (!open) return null;
const [news, setNews] = useState<News | null>(null);
const handled = useRef(0);
// The latest state and time, for work that finishes after a refresh.
const latest = useRef({ state, now });
latest.current = { state, now };
const dismiss = () => {
const ids = summaries.map((s) => s.dispatchId);
const shipIds = outOfShop.map((s) => s.id);
setDismissed(new Set([...dismissed, ...ids, ...shipIds.map((id) => `ship:${id}`)]));
acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) });
/** The news is dealt with: its entries seen, and the delivery summaries behind it closed. */
const settle = useCallback(
(entries: readonly LogEntry[]) => {
if (entries.length > 0) {
void api.call("markLogbookSeen", { entryIds: entries.map((e) => e.id) }).catch(() => {});
}
const { state: s, now: t } = latest.current;
const dispatchIds = s.summaries.map((x) => x.dispatchId);
const shipIds = s.ships.filter((x) => x.shop !== null && x.shop.until <= t).map((x) => x.id);
if (dispatchIds.length > 0 || shipIds.length > 0) {
acknowledge.mutate({ dispatchIds, ...(shipIds.length > 0 ? { shipIds } : {}) });
}
useAwayOpen.setState({ open: false });
},
[api, acknowledge],
);
useEffect(() => {
if (!lastReturn || returns === handled.current) return;
handled.current = returns;
useAwayOpen.setState({ open: true });
void api
.call("getLogbook", {})
.then((book) => {
// Everything from while the player was here is seen (toasted), so what's
// unseen from before coming back happened while away.
const entries = book.entries.filter((e) => !e.seen && e.at < lastReturn.to);
const toasts = entryToasts(entries, latest.current.state);
if (toasts.length === 0) return settle(entries);
if (lastReturn.to - lastReturn.from < clientConfig.presence.shortAbsenceMs) {
// A quick look elsewhere: toasts, not a summary.
push(toasts);
return settle(entries);
}
setNews({ absence: lastReturn, entries, problems: book.problems });
})
.catch(() => useAwayOpen.setState({ open: false }));
}, [returns, lastReturn, api, push, settle]);
if (!news) return null;
const summary = buildAwaySummary({
...news.absence,
entries: news.entries,
pilots: state.pilots.map((p) => ({ id: p.id, name: p.name, now: pilotNow(state, p) })),
problems: news.problems,
});
const close = () => {
settle(news.entries);
setNews(null);
};
const title = fill(words.title, { duration: summary ? formatAbsence(summary.durationMs) : "…" });
const highlights = summary?.highlights ?? [];
const title = fill(words.title, { duration: formatAbsence(summary.durationMs) });
const highlights = summary.highlights;
return (
<Sheet titleId="away-title" title={title}>
<div className="space-y-3 text-sm">
<SleepBanner />
{!summary ? (
<p className="text-ink-400">Reading the logbook…</p>
) : summary.direct ? (
{summary.direct ? (
<ul aria-label="What happened" className="space-y-1.5">
{summary.direct.map((entry) => (
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
@ -242,7 +299,7 @@ export function AwaySummary() {
) : (
<Totals summary={summary} />
)}
{summary && highlights.length > 0 && (
{highlights.length > 0 && (
<ul aria-label="Highlights" className="space-y-1.5">
{highlights.map((h, i) => (
<li key={i} className="flex items-start gap-2 text-amber-100">
@ -260,20 +317,20 @@ export function AwaySummary() {
)}
</ul>
)}
{summary && !summary.direct && <PilotLines summary={summary} />}
{!summary.direct && <PilotLines summary={summary} />}
</div>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
className="flex-1"
onClick={() => {
dismiss();
close();
navigate("/logbook?range=absence");
}}
>
{words.viewLogbook}
</Button>
<Button className="flex-1" onClick={dismiss}>
<Button className="flex-1" onClick={close}>
{words.done}
</Button>
</div>

View File

@ -6,6 +6,7 @@ import { formatCredits } from "../format";
import { errorMessage, useLoadedPlayer } from "../state/actions";
import { PLAYER_STATE_KEY } from "../state/player";
import { Art, Button, Sheet } from "../ui";
import { useAwayOpen } from "./AwaySummary";
/**
* The one-time deal (decision 0.5.30): shown when the server says it is
@ -13,6 +14,7 @@ import { Art, Button, Sheet } from "../ui";
* decline option.
*/
export function DealModal() {
const awayOpen = useAwayOpen((s) => s.open);
const state = useLoadedPlayer();
const api = useApi();
const queryClient = useQueryClient();
@ -25,7 +27,7 @@ export function DealModal() {
},
});
if (!state.tutorial?.dealAvailable || state.summaries.length > 0) return null;
if (!state.tutorial?.dealAvailable || state.summaries.length > 0 || awayOpen) return null;
const { dealLoanAmount } = content;
const { loanRepaymentShare } = content.config;

View File

@ -7,6 +7,7 @@ import { fill, formatClock, formatCredits } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Panel, Sheet } from "../ui";
import { Countdown } from "./Countdown";
import { useAwayOpen } from "./AwaySummary";
/** Calls the player has put off for now ("Decide later"); the Bridge still lists them. */
export const useRadioLater = create<{
@ -113,6 +114,8 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
*/
export function RadioCallSheet() {
const state = useLoadedPlayer();
// "While you were away" deciding or open (CR-028).
const away = useAwayOpen((s) => s.open);
const later = useRadioLater((s) => s.later);
const putOff = useRadioLater((s) => s.putOff);
const now = useClock((s) => s.now);
@ -120,6 +123,7 @@ export function RadioCallSheet() {
const call = open.find((c) => !later.has(c.dispatchId));
// After "While you were away" is closed, as the deal waits (one sheet at a time).
const awayOpen =
away ||
state.summaries.length > 0 ||
state.ships.some((sh) => sh.shop !== null && sh.shop.until <= now);
if (!call || awayOpen) return null;

View File

@ -5,6 +5,7 @@ import { useApi } from "../api/context";
import { useLoadedPlayer } from "../state/actions";
import { usePreferences } from "../state/preferences";
import { Button, Sheet } from "../ui";
import { useAwayOpen } from "./AwaySummary";
/** The id of the skill point pop-up in dismissed tips (CR-011 one-time tips). */
export const SKILL_POINTS_TIP = "skill-points";
@ -27,6 +28,7 @@ function listNames(names: string[]): string {
*/
export function SkillPointPrompt() {
const state = useLoadedPlayer();
const awayOpen = useAwayOpen((s) => s.open);
const api = useApi();
const navigate = useNavigate();
const dismissed = usePreferences((p) => p.dismissedTips.includes(SKILL_POINTS_TIP));
@ -48,7 +50,7 @@ export function SkillPointPrompt() {
const ready = state.pilots.filter((p) => p.skillPointsAvailable > 0);
if (!owner || !promptAnswered || dismissed || ready.length === 0) return null;
// Let delivery summaries go first.
if (state.summaries.length > 0) return null;
if (state.summaries.length > 0 || awayOpen) return null;
const names = ready.map(callName);
const subject =

View File

@ -4,7 +4,8 @@ import { flightNet, type AwayProblem } from "@deadhead/sim";
import { useEffect, useRef } from "react";
import { useApi } from "../api/context";
import { usePresence } from "../platform/presence";
import { useLoadedPlayer } from "../state/actions";
import { useClock } from "../clock/clock";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { useToasts, type NewToast } from "../state/toasts";
import { directText, highlightText } from "./AwaySummary";
import { eventResultText } from "./EventResults";
@ -149,6 +150,33 @@ export function ToastFeed() {
};
}, [state, here, hereSince, api, push]);
// Deliveries and finished upgrades from while the player is here were toasted: close
// their summaries, as the pop-up used to, so the sheets waiting on them can show.
const acknowledge = useGameAction("acknowledgeSummaries");
const acked = useRef(new Set<string>());
const clock = useClock((c) => c.now);
useEffect(() => {
if (!here || hereSince === null) return;
const dispatchIds = state.summaries
.filter((s) => s.resolvedAt >= hereSince && !acked.current.has(s.dispatchId))
.map((s) => s.dispatchId);
const shipIds = state.ships
.filter(
(s) =>
s.shop !== null &&
s.shop.until >= hereSince &&
s.shop.until <= clock &&
!acked.current.has(`ship:${s.id}:${s.shop.until}`),
)
.map((s) => s.id);
if (dispatchIds.length === 0 && shipIds.length === 0) return;
for (const id of dispatchIds) acked.current.add(id);
for (const s of state.ships) {
if (shipIds.includes(s.id)) acked.current.add(`ship:${s.id}:${s.shop!.until}`);
}
acknowledge.mutate({ dispatchIds, ...(shipIds.length > 0 ? { shipIds } : {}) });
}, [state, here, hereSince, clock, acknowledge]);
// The fleet going quiet while the game is open: a toast, beside the Bridge's panel (CR-024).
useEffect(() => {
const now = fleetQuiet(state);

View File

@ -87,17 +87,57 @@ export function platformPresence(): PresenceHost {
return Capacitor.isNativePlatform() ? nativePresence : browserPresence;
}
/**
* When the player went away, kept on the device: the game keeps running in a
* hidden tab (and saving), so the save's last time seen can be later than
* that. Null while the player is here.
*/
export interface PresenceMemory {
read(): number | null;
write(awaySince: number | null): void;
}
const AWAY_KEY = "deadhead:away-since";
/** This device's storage; with storage blocked, nothing is remembered. */
export const devicePresenceMemory: PresenceMemory = {
read() {
try {
const value = Number(localStorage.getItem(AWAY_KEY));
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
},
write(awaySince) {
try {
if (awaySince === null) localStorage.removeItem(AWAY_KEY);
else localStorage.setItem(AWAY_KEY, String(awaySince));
} catch {
// Storage blocked: a cold start falls back to the save's last time seen.
}
},
};
/**
* 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.
* game's clock so they compare with logbook times. A cold start comes back
* from when the player went away, if this device remembers, else from the
* save's last time seen. Returns a stop function.
*/
export function startPresence(
host: PresenceHost,
now: () => number,
savedAt: number | null,
memory: PresenceMemory = devicePresenceMemory,
): () => void {
usePresence.setState(presenceAtStart(host.isHere(), now(), savedAt));
return host.onChange((here) => {
usePresence.setState((state) => presenceChange(state, here, now()));
const here = host.isHere();
const awaySince = memory.read() ?? savedAt;
const start = presenceAtStart(here, now(), awaySince);
usePresence.setState(start);
memory.write(start.awaySince);
return host.onChange((isHere) => {
usePresence.setState((state) => presenceChange(state, isHere, now()));
memory.write(usePresence.getState().awaySince);
});
}

View File

@ -6,6 +6,7 @@ import { logbookKey } from "../components/AwaySummary";
import { PilotLogbook, pilotLogbook } from "../components/PilotLogbook";
import { useLoadedPlayer } from "../state/actions";
import { Panel, Screen, ScreenHeader } from "../ui";
import { usePresence } from "../platform/presence";
const HOUR = 3_600_000;
@ -26,6 +27,7 @@ export function Logbook() {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const [params, setParams] = useSearchParams();
const lastReturn = usePresence((p) => p.lastReturn);
const range = (RANGES.find(([value]) => value === params.get("range"))?.[0] ?? "24h") as LogRange;
const pilotId = params.get("pilot") ?? "";
const book = useQuery({
@ -42,11 +44,11 @@ export function Logbook() {
let body = <p className="text-ink-400">Reading the logbook…</p>;
if (book.data) {
const pending = state.summaries.length > 0;
// The absence the player last came back from (CR-028); before any, the save's own.
const window =
range === "absence"
? pending
? book.data.absence
: (book.data.lastAbsence ?? book.data.absence)
? (lastReturn ??
(pending ? book.data.absence : (book.data.lastAbsence ?? book.data.absence)))
: { from: now - (range === "24h" ? 24 : 7 * 24) * HOUR, to: now };
const entries = book.data.entries.filter(
(e) => e.at > window.from && e.at <= window.to && (pilotId === "" || e.pilotId === pilotId),

View File

@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
function returningPlayer(acknowledged: boolean): PlayerState {
return samplePlayerState({
@ -59,6 +59,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
describe("something waiting on return", () => {
it("highlights a goal ready to claim (claimed on the Bridge's goals card, CR-025)", async () => {
comeBack();
let acknowledged = false;
const api = fakeApi({
getPlayerState: () => returningPlayer(acknowledged),
@ -79,6 +80,7 @@ describe("something waiting on return", () => {
});
it("does not mention goals when none are ready", async () => {
comeBack();
const state = returningPlayer(false);
const quiet = {
...state,

View File

@ -0,0 +1,188 @@
import type { LogEntry, PlayerState, Ship } from "@deadhead/schema";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { presenceChange, usePresence } from "../src/platform/presence";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const MINUTE = 60_000;
const T = Date.now();
const ship: Ship = {
id: "s1",
name: "Wren",
modelId: "courier",
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: "avatar",
lastArrivalAt: 0,
...shipExtras("courier"),
};
const state = (): PlayerState =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 50_000, loanBalance: 0 },
ships: [ship],
pilots: [{ ...samplePlayerState().pilots[0]!, assignedShipId: "s1", locationId: null }],
});
const delivery = (id: string, at: number, goodId = "consumer-goods"): LogEntry => ({
id,
at,
pilotId: "avatar",
shipId: "s1",
seen: false,
kind: "flight",
dispatchId: id,
deadhead: false,
tutorial: false,
independent: false,
originId: "earth",
destinationId: "moon",
goodId,
massTonnes: 5,
shipper: "Pacific Orbital Logistics",
payout: 1_000,
pilotPay: 0,
fuelCost: 100,
loanRepayment: 0,
xp: 5,
scriptedId: null,
});
/** A fake engine whose logbook remembers what was marked seen. */
function game(entries: LogEntry[]) {
const book = entries.map((e) => ({ ...e }));
const api = fakeApi({
getPlayerState: () => state(),
getLogbook: () => ({
entries: book.map((e) => ({ ...e })),
absence: { from: T - 60 * MINUTE, to: T },
lastAbsence: null,
problems: [],
}),
markLogbookSeen: ({ entryIds }) => {
let marked = 0;
for (const e of book) {
if (entryIds.includes(e.id) && !e.seen) {
e.seen = true;
marked++;
}
}
return { marked };
},
});
return { api, book };
}
/** The player has been here since `since`. */
function hereSince(since: number) {
usePresence.setState({
here: true,
hereSince: since,
awaySince: null,
lastReturn: null,
returns: 0,
});
}
const goAway = (at: number) => act(() => usePresence.setState((p) => presenceChange(p, false, at)));
const comeBackAt = (at: number) =>
act(() => usePresence.setState((p) => presenceChange(p, true, at)));
const popup = () => screen.queryByRole("dialog", { name: /While you were away/ });
const notifications = () => screen.findByRole("region", { name: "Notifications" });
describe("the pop-up only after being away (Step 28.4, decision 0.5.162)", () => {
it("toasts a delivery while the game is on screen, with no pop-up", async () => {
hereSince(T - 10 * MINUTE);
const { api, book } = game([delivery("d1", T - MINUTE)]);
renderApp(api, "/fleet");
const region = await notifications();
await waitFor(() => expect(region).toHaveTextContent("Wren delivered Consumer Goods to Moon"));
expect(popup()).not.toBeInTheDocument();
await waitFor(() => expect(book[0]!.seen).toBe(true));
});
it("after 5 minutes away, shows exactly what happened while away, and not again after a reload", async () => {
hereSince(T - 30 * MINUTE);
const { api, book } = game([
{ ...delivery("seen", T - 20 * MINUTE), seen: true },
delivery("before", T - 15 * MINUTE, "helium-3"),
]);
// "before" was toasted while here; then the tab is hidden for 5 minutes.
book[1]!.seen = true;
const view = renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
goAway(T - 5 * MINUTE);
// Two deliveries land while the tab is hidden.
book.push(
delivery("d1", T - 4 * MINUTE, "mail"),
delivery("d2", T - 2 * MINUTE, "spare-parts"),
);
comeBackAt(T);
const sheet = await screen.findByRole("dialog", { name: /While you were away · 5 m/ });
const list = await within(sheet).findByRole("list", { name: "What happened" });
expect(
within(list)
.getAllByRole("listitem")
.map((li) => li.textContent),
).toEqual([
expect.stringMatching(/delivered Mail & Parcels to Moon/),
expect.stringMatching(/delivered Spare Parts to Moon/),
]);
await userEvent.click(within(sheet).getByRole("button", { name: "Done" }));
expect(popup()).not.toBeInTheDocument();
await waitFor(() => expect(book.every((e) => e.seen)).toBe(true));
// A reload: a cold start counts as coming back, but there's nothing unseen.
view.unmount();
usePresence.setState({
here: true,
hereSince: T,
awaySince: null,
returns: 0,
lastReturn: null,
});
act(() =>
usePresence.setState({ lastReturn: { from: T - 10 * MINUTE, to: T + 1 }, returns: 1 }),
);
renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
await waitFor(() => expect(api.call).toHaveBeenCalledWith("getLogbook", {}));
await new Promise((r) => setTimeout(r, 50));
expect(popup()).not.toBeInTheDocument();
});
it("after 30 seconds away, shows the news as toasts instead", async () => {
hereSince(T - 30 * MINUTE);
const { api, book } = game([delivery("d1", T - 20_000)]);
renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
goAway(T - 30_000);
comeBackAt(T);
const region = await notifications();
await waitFor(() => expect(region).toHaveTextContent("Wren delivered Consumer Goods to Moon"));
expect(popup()).not.toBeInTheDocument();
await waitFor(() => expect(book[0]!.seen).toBe(true));
});
it("shows nothing on coming back when nothing happened", async () => {
hereSince(T - 30 * MINUTE);
const { api } = game([{ ...delivery("old", T - 20 * MINUTE), seen: true }]);
renderApp(api, "/fleet");
await screen.findByRole("heading", { level: 1, name: "Fleet" });
goAway(T - 10 * MINUTE);
comeBackAt(T);
await waitFor(() =>
expect(api.call.mock.calls.filter(([name]) => name === "getLogbook").length).toBeGreaterThan(
1,
),
);
await new Promise((r) => setTimeout(r, 50));
expect(popup()).not.toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Notifications" })).queryAllByRole("listitem"),
).toEqual([]);
});
});

View File

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatAbsence } from "../src/components/AwaySummary";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T12:00:00Z");
const FROM = NOW - (9 * 60 + 40) * 60_000;
@ -87,6 +87,8 @@ const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}):
}) as LogEntry;
function render(pilots: number, entries: LogEntry[], over: Partial<LogbookResponse> = {}) {
const absence = over.absence ?? { from: FROM, to: NOW };
comeBack({ from: absence.from, to: absence.to + 1 });
let seen = false;
const all = Array.from({ length: pilots }, (_, i) => pilot(i));
const api = fakeApi({

View File

@ -4,7 +4,7 @@ import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -140,6 +140,7 @@ describe("loan on HQ", () => {
});
it("shows the lender's cut in a delivery summary", async () => {
comeBack();
const state: PlayerState = {
...owner(2000, 4000),
summaries: [

View File

@ -2,7 +2,14 @@ import { content } from "@deadhead/content";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, shipExtras } from "./render";
import {
fakeApi,
renderApp,
samplePlayerState,
sampleTutorialBoard,
shipExtras,
comeBack,
} from "./render";
const offered = () =>
samplePlayerState({
@ -90,6 +97,7 @@ describe("the deal", () => {
});
it("waits until the delivery summary is dismissed", async () => {
comeBack();
const withSummary = () => ({
...offered(),
summaries: [

View File

@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock";
import { sleepLabel } from "../src/components/FleetSleep";
import { independentStatus } from "../src/components/IndependentPilots";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-03T12:00:00Z");
const SLEPT = Date.parse("2100-01-02T09:30:00Z");
@ -120,6 +120,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
});
it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => {
comeBack();
const api = sleepyApi({ summaries: [summary] });
renderApp(api, "/");
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
@ -134,6 +135,7 @@ describe("fleet sleep on screen (Step 24.5, decisions 0.5.137, 0.5.138)", () =>
});
it("leaves the banner off the away screen when the fleet didn't go quiet", async () => {
comeBack();
renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/");
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument();

View File

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatLocalTime } from "../src/screens/DispatchSheet";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-08T12:00:00Z");
const HOUR = 3_600_000;
@ -243,6 +243,7 @@ describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
});
it("opens from View logbook on This absence, with the pop-up's totals", async () => {
comeBack({ from: ABSENCE.from, to: ABSENCE.to + 1 });
renderApp(app(false), "/");
const popup = await screen.findByRole("dialog", { name: /While you were away/ });
const figures = await within(popup).findByLabelText("Company totals");

View File

@ -7,7 +7,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatCredits } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -132,6 +132,7 @@ describe("pilot share in the client (Step 10J.6)", () => {
});
it("takes the pilot's share out of a delivery's net in the summary", async () => {
comeBack();
renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/");
const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
// 600 cr after the share, less 150 cr of fuel (CR-025); the share itself is in the Logbook.

View File

@ -6,10 +6,24 @@ import {
startPresence,
usePresence,
type PresenceHost,
type PresenceMemory,
} from "../src/platform/presence";
const T = 1_000_000;
/** Device memory for the time the player went away. */
function fakeMemory(value: number | null = null): PresenceMemory & { value: number | null } {
return {
value,
read() {
return this.value;
},
write(awaySince) {
this.value = awaySince;
},
};
}
/** A host whose visibility the test flips. */
function fakeHost(here = true) {
let listener: ((here: boolean) => void) | null = null;
@ -85,11 +99,14 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
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);
const memory = fakeMemory();
const stop = startPresence(fake.host, () => now, T - 60_000, memory);
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 });
// Remembered on the device, in case the game is closed while hidden.
expect(memory.value).toBe(T + 10_000);
now = T + 40_000;
fake.set(true);
expect(usePresence.getState()).toMatchObject({
@ -97,10 +114,19 @@ describe("presence (Step 28.2, decision 0.5.160)", () => {
lastReturn: { from: T + 10_000, to: T + 40_000 },
returns: 2,
});
expect(memory.value).toBeNull();
stop();
expect(fake.listening).toBe(false);
});
it("comes back from when the player went away, not the later save, after closing a hidden tab", () => {
// Hidden at T − 2 h; the game kept running (and saving) until T − 1 h, then was closed.
const memory = fakeMemory(T - 2 * 3_600_000);
startPresence(fakeHost().host, () => T, T - 3_600_000, memory);
expect(usePresence.getState().lastReturn).toEqual({ from: T - 2 * 3_600_000, to: T });
expect(memory.value).toBeNull();
});
it("reads the browser tab's visibility", () => {
const seen: boolean[] = [];
const stop = browserPresence.onChange((here) => seen.push(here));

View File

@ -6,6 +6,7 @@ import { vi } from "vitest";
import { App } from "../src/App";
import { ApiError, type ApiClient } from "../src/api/client";
import { ApiProvider } from "../src/api/context";
import { usePresence } from "../src/platform/presence";
import { logbookFrom } from "./fixtures";
type Handlers = {
@ -43,6 +44,23 @@ export const unauthorized = () => {
export { samplePlayerState, shipExtras } from "./fixtures";
/**
* The player comes back from `absence` (CR-028): "While you were away" looks
* for unseen news from then. Call before rendering. By default, everything
* before counts as the absence.
*/
export function comeBack(
absence: { from: number; to: number } = { from: 0, to: Number.MAX_SAFE_INTEGER },
) {
usePresence.setState((p) => ({
here: true,
hereSince: absence.to,
awaySince: null,
lastReturn: absence,
returns: p.returns + 1,
}));
}
/** Renders the whole app at `path` with a fresh query cache and the given API. */
export function renderApp(api: ApiClient, path = "/") {
const queryClient = new QueryClient({

View File

@ -1,6 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { useAwayOpen } from "../src/components/AwaySummary";
import { usePresence } from "../src/platform/presence";
import { usePreferences } from "../src/state/preferences";
import { useToasts } from "../src/state/toasts";
@ -20,6 +21,7 @@ afterEach(() => {
returns: 0,
});
useToasts.setState({ toasts: [] });
useAwayOpen.setState({ open: false });
});
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.

View File

@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
import { fakeApi, renderApp, samplePlayerState, shipExtras, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
@ -29,6 +29,7 @@ describe("the shop in summaries and on HQ (Step 10I.8)", () => {
afterEach(() => vi.useRealTimers());
it("says a ship is out of the shop for an upgrade that finished, and acknowledges it", async () => {
comeBack();
const api = fakeApi({
getPlayerState: () =>
samplePlayerState({

View File

@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../src/api/client";
import { useClock } from "../src/clock/clock";
import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, comeBack } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const loaner = content.shipModel("company-courier")!;
@ -126,6 +126,7 @@ describe("tutorial contract board", () => {
describe("scripted delivery summary", () => {
it("shows the scripted contract's flavor message after it resolves", async () => {
comeBack();
const flavor = content.scriptedTutorialContracts.find(
(s) => s.id === "unmarked-crates",
)!.flavorMessage;

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -171,3 +171,5 @@ Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` an
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`.
Toasts (CR-028 Step 28.3): `apps/web/src/state/toasts.ts` (`useToasts`: push, dismiss, drop the overflow; batch toasts merge within `batchWindowMs`), `components/Toasts.tsx` (the stack) and `components/ToastFeed.tsx` (`entryToasts` builds toasts from logbook entries; `ToastFeed` reads `getLogbook` after each player-state refresh while here, toasts unseen entries stamped since `hereSince` and marks them seen with `markLogbookSeen`; it also toasts a booked service newly out of reach and the fleet going quiet). Both are mounted in `App`.
The away pop-up (CR-028 Step 28.4): `AwaySummary` runs on each return in `usePresence` (`returns`, `lastReturn`): it reads `getLogbook`, keeps the unseen entries stamped before the return, and shows nothing, toasts (away under `clientConfig.presence.shortAbsenceMs`) or the pop-up, then marks them seen (`markLogbookSeen`) and acknowledges the delivery summaries. `useAwayOpen` tells the other sheets to wait. `ToastFeed` acknowledges the summaries of what it toasted. Presence keeps the time the player went away in local storage (`deadhead:away-since`) for a cold start.

View File

@ -584,5 +584,5 @@ Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026
- [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.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`.
- [x] **Step 28.3 — Client: toasts.** `apps/web/src/state/toasts.ts` (the store: newest first; an Independent delivery joins a batch toast shown in the last 10 s, "3 deliveries · +4,200 cr"), `components/Toasts.tsx` (top-right at 768 px and wider, top-center below the safe area on phones, both under the top bar; 5 s each, paused by hover or touch; ✕ and a 60 px swipe close early; at most 3, the rest as "+N more" opening the Logbook; tapping opens the toast's screen; `aria-live="polite"`; no slide with reduced motion) and `components/ToastFeed.tsx` (after each refresh while the player is here: the logbook's unseen entries from `hereSince` on become toasts (`entryToasts`: deliveries, discoveries, level-ups, goals ready, event outcomes, service started, finished and waiting for funds, plain upgrades and repairs; none for deadheads, refuelling, rest or automatic bookings) and are marked seen; a booked service newly out of reach and the fleet going quiet get toasts too). Timings in `client-config.ts`. The away pop-up still opens as before until Step 28.4. Tests (`apps/web/test/toasts.test.tsx`): placement at 1280 and 375 px; 5 s, not while hovered; ✕ and swipe; a fourth toast giving "+1 more" and opening the Logbook; tapping a discovery opening Places; reduced motion; three Independent deliveries within 10 s in one toast with a discovery on its own, and a new toast past the window; player deliveries alone, deadheads none; the feed toasting only entries from while here and marking them seen. Screenshots: `docs/screenshots/28-3-toasts-*`.
- [ ] Step 28.4 — Client: pop-up only after being away
- [x] **Step 28.4 — Client: pop-up only after being away.** `AwaySummary` no longer opens on unacknowledged deliveries: on each return (presence `returns`, a cold start included) it reads the logbook and takes the unseen entries from before coming back (anything from while the player was here is already seen). None worth a toast: nothing shows (they're marked seen). Away under 60 s: they show as toasts. Otherwise the pop-up, built from exactly those entries, marks them seen and closes the delivery summaries when it closes. `useAwayOpen` holds the deal, the skill-point prompt and radio calls back while it decides or shows. `ToastFeed` closes the summaries of deliveries and upgrades toasted while here, which the pop-up used to do. Presence remembers on the device when the player went away (`devicePresenceMemory`), because a hidden tab keeps running and saving: a cold start comes back from then, else from the save's last time seen. The Logbook's "This absence" is the absence the player last came back from. End-to-end helpers: `goAway` (the tab hidden), `expectNews` (a toast or the pop-up), and `dismissLogbook` copes with either. End-to-end suite green locally (11 tests). Screenshots: `docs/screenshots/28-4-away-*`. Tests (`apps/web/test/away-return.test.tsx`): a delivery while here is a toast with no pop-up; 5 minutes hidden with two deliveries shows the pop-up with exactly those two, and a reload afterwards shows nothing; 30 seconds hidden gives toasts; coming back to nothing shows nothing; `presence.test.ts` (closing a hidden tab). Tests that open the pop-up now come back first (`comeBack` in `test/render.tsx`).
- [ ] Step 28.5 — Regression