Step 28.4: client: pop-up only after being away
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
43859765eb
commit
d6f782c444
|
|
@ -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.
|
- **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".
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import {
|
||||||
advanceTo,
|
advanceTo,
|
||||||
dismissLogbook,
|
dismissLogbook,
|
||||||
dispatchFromEarthBoard,
|
dispatchFromEarthBoard,
|
||||||
logbook,
|
expectNews,
|
||||||
|
goAway,
|
||||||
playToDeal,
|
playToDeal,
|
||||||
state,
|
state,
|
||||||
} from "./support";
|
} from "./support";
|
||||||
|
|
@ -27,10 +28,11 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
// One contract, dispatched in the UI and resolved.
|
// One contract, dispatched in the UI and resolved.
|
||||||
const dispatchId = await dispatchFromEarthBoard(page);
|
const dispatchId = await dispatchFromEarthBoard(page);
|
||||||
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
|
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
|
||||||
|
await goAway(page);
|
||||||
await advanceTo(page, flight.arrivalAt);
|
await advanceTo(page, flight.arrivalAt);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
// The net, after the lender's cut (CR-025); the cut itself is in the Logbook.
|
// 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);
|
await dismissLogbook(page);
|
||||||
|
|
||||||
const after = await state(page);
|
const after = await state(page);
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { content } from "@deadhead/content";
|
||||||
import type { Contract, Dispatch } from "@deadhead/schema";
|
import type { Contract, Dispatch } from "@deadhead/schema";
|
||||||
import {
|
import {
|
||||||
acknowledgeAll,
|
acknowledgeAll,
|
||||||
advance,
|
|
||||||
addCredits,
|
addCredits,
|
||||||
|
advance,
|
||||||
advanceTo,
|
advanceTo,
|
||||||
board,
|
board,
|
||||||
dismissLogbook,
|
dismissLogbook,
|
||||||
logbook,
|
expectNews,
|
||||||
|
goAway,
|
||||||
playToDeal,
|
playToDeal,
|
||||||
run,
|
run,
|
||||||
state,
|
state,
|
||||||
|
|
@ -58,9 +59,10 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
expect(offer, "a first contact to Mars on an Earth board").toBeDefined();
|
expect(offer, "a first contact to Mars on an Earth board").toBeDefined();
|
||||||
const flight = await tryDispatch(page, offer!.id, clipper.id, veteran.id);
|
const flight = await tryDispatch(page, offer!.id, clipper.id, veteran.id);
|
||||||
expect(flight, "the Clipper and veteran can take it").not.toBeNull();
|
expect(flight, "the Clipper and veteran can take it").not.toBeNull();
|
||||||
|
await goAway(page);
|
||||||
await advanceTo(page, flight!.arrivalAt);
|
await advanceTo(page, flight!.arrivalAt);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(logbook(page)).toContainText("opened a new market: Mars");
|
await expectNews(page, "opened a new market: Mars");
|
||||||
await dismissLogbook(page);
|
await dismissLogbook(page);
|
||||||
|
|
||||||
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).
|
// Mars is discovered on the Locations screen and can be leased; its board is open without a bay (CR-002).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { expect, test } from "@playwright/test";
|
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.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 ({
|
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"]);
|
expect(owner.ships.map((s) => s.modelId)).toEqual(["courier", "clipper"]);
|
||||||
const dispatchId = await dispatchFromEarthBoard(page);
|
const dispatchId = await dispatchFromEarthBoard(page);
|
||||||
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
|
const flight = (await state(page)).activeDispatches.find((d) => d.id === dispatchId)!;
|
||||||
|
await goAway(page);
|
||||||
await advanceTo(page, flight.arrivalAt);
|
await advanceTo(page, flight.arrivalAt);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await dismissLogbook(page);
|
await dismissLogbook(page);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
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 HOUR = 3_600_000;
|
||||||
const PILOTS = 7;
|
const PILOTS = 7;
|
||||||
|
|
@ -28,6 +28,8 @@ test.describe("the highlights pop-up (CR-025 Step 25.3)", () => {
|
||||||
(p: PlayerState["pilots"][number]) => p.isVeteran,
|
(p: PlayerState["pilots"][number]) => p.isVeteran,
|
||||||
)!;
|
)!;
|
||||||
await run(page, "setPilotMode", { pilotId: rosa.id, mode: "independent" });
|
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);
|
for (let h = 0; h < 4; h++) await advance(page, HOUR);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
|
||||||
|
|
@ -57,16 +57,43 @@ export const logbook = (page: Page) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Waits for the logbook when the server has unseen summaries, and dismisses it. */
|
/** 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) {
|
export async function dismissLogbook(page: Page) {
|
||||||
if ((await state(page)).summaries.length === 0) return;
|
|
||||||
const sheet = logbook(page);
|
const sheet = logbook(page);
|
||||||
await expect(sheet).toBeVisible();
|
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 sheet.getByRole("button").last().click();
|
||||||
await expect(sheet).toBeHidden();
|
await expect(sheet).toBeHidden();
|
||||||
|
}
|
||||||
// The acknowledgement is saved in the page; don't navigate away before it lands.
|
// 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);
|
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. */
|
/** A brand-new company: "Start your company" from the start screen. */
|
||||||
export async function startCompany(page: Page) {
|
export async function startCompany(page: Page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
@ -88,6 +115,8 @@ export async function flyTutorial(page: Page) {
|
||||||
await page.getByRole("button", { name: "Fly it" }).first().click();
|
await page.getByRole("button", { name: "Fly it" }).first().click();
|
||||||
await expect.poll(async () => (await state(page)).activeDispatches.length).toBe(1);
|
await expect.poll(async () => (await state(page)).activeDispatches.length).toBe(1);
|
||||||
const [flight] = (await state(page)).activeDispatches;
|
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 advanceTo(page, flight!.arrivalAt);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await dismissLogbook(page);
|
await dismissLogbook(page);
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@ import {
|
||||||
type AwaySummary as Summary,
|
type AwaySummary as Summary,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useNavigate } from "react-router";
|
||||||
import { useApi } from "../api/context";
|
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 { useClock } from "../clock/clock";
|
||||||
import { fill, formatCredits, locationName } from "../format";
|
import { fill, formatCredits, locationName } from "../format";
|
||||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
@ -18,6 +22,7 @@ import { Button, Icon, Sheet } from "../ui";
|
||||||
import { eventResultText } from "./EventResults";
|
import { eventResultText } from "./EventResults";
|
||||||
import { SleepBanner } from "./FleetSleep";
|
import { SleepBanner } from "./FleetSleep";
|
||||||
import { serviceItems } from "./Service";
|
import { serviceItems } from "./Service";
|
||||||
|
import { entryToasts } from "./ToastFeed";
|
||||||
|
|
||||||
const words = content.awayWording;
|
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
|
* "While you were away" (Step 9.2, decisions 0.5.140, 0.5.162): on coming
|
||||||
* screen, the detail in the Logbook. Opens on unseen deliveries and finished
|
* back (the tab shown again, the app in front, or a fresh launch), the unseen
|
||||||
* upgrades, as before.
|
* 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() {
|
export function AwaySummary() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const acknowledge = useGameAction("acknowledgeSummaries");
|
const api = useApi();
|
||||||
// Hide as soon as the player dismisses; the refetch clears the list for real.
|
|
||||||
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
|
|
||||||
const navigate = useNavigate();
|
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 now = useClock((s) => s.now);
|
||||||
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
|
const [news, setNews] = useState<News | null>(null);
|
||||||
// Upgrades finished and not yet seen: "out of the shop" (CR-009).
|
const handled = useRef(0);
|
||||||
const outOfShop = state.ships.filter(
|
// The latest state and time, for work that finishes after a refresh.
|
||||||
(s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`),
|
const latest = useRef({ state, now });
|
||||||
);
|
latest.current = { state, now };
|
||||||
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 dismiss = () => {
|
/** The news is dealt with: its entries seen, and the delivery summaries behind it closed. */
|
||||||
const ids = summaries.map((s) => s.dispatchId);
|
const settle = useCallback(
|
||||||
const shipIds = outOfShop.map((s) => s.id);
|
(entries: readonly LogEntry[]) => {
|
||||||
setDismissed(new Set([...dismissed, ...ids, ...shipIds.map((id) => `ship:${id}`)]));
|
if (entries.length > 0) {
|
||||||
acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) });
|
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 title = fill(words.title, { duration: formatAbsence(summary.durationMs) });
|
||||||
const highlights = summary?.highlights ?? [];
|
const highlights = summary.highlights;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet titleId="away-title" title={title}>
|
<Sheet titleId="away-title" title={title}>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<SleepBanner />
|
<SleepBanner />
|
||||||
{!summary ? (
|
{summary.direct ? (
|
||||||
<p className="text-ink-400">Reading the logbook…</p>
|
|
||||||
) : summary.direct ? (
|
|
||||||
<ul aria-label="What happened" className="space-y-1.5">
|
<ul aria-label="What happened" className="space-y-1.5">
|
||||||
{summary.direct.map((entry) => (
|
{summary.direct.map((entry) => (
|
||||||
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
|
<li key={entry.id} className="flex items-start gap-2 text-ink-100">
|
||||||
|
|
@ -242,7 +299,7 @@ export function AwaySummary() {
|
||||||
) : (
|
) : (
|
||||||
<Totals summary={summary} />
|
<Totals summary={summary} />
|
||||||
)}
|
)}
|
||||||
{summary && highlights.length > 0 && (
|
{highlights.length > 0 && (
|
||||||
<ul aria-label="Highlights" className="space-y-1.5">
|
<ul aria-label="Highlights" className="space-y-1.5">
|
||||||
{highlights.map((h, i) => (
|
{highlights.map((h, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-amber-100">
|
<li key={i} className="flex items-start gap-2 text-amber-100">
|
||||||
|
|
@ -260,20 +317,20 @@ export function AwaySummary() {
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{summary && !summary.direct && <PilotLines summary={summary} />}
|
{!summary.direct && <PilotLines summary={summary} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dismiss();
|
close();
|
||||||
navigate("/logbook?range=absence");
|
navigate("/logbook?range=absence");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{words.viewLogbook}
|
{words.viewLogbook}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="flex-1" onClick={dismiss}>
|
<Button className="flex-1" onClick={close}>
|
||||||
{words.done}
|
{words.done}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { formatCredits } from "../format";
|
||||||
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
import { PLAYER_STATE_KEY } from "../state/player";
|
||||||
import { Art, Button, Sheet } from "../ui";
|
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
|
* 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.
|
* decline option.
|
||||||
*/
|
*/
|
||||||
export function DealModal() {
|
export function DealModal() {
|
||||||
|
const awayOpen = useAwayOpen((s) => s.open);
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const queryClient = useQueryClient();
|
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 { dealLoanAmount } = content;
|
||||||
const { loanRepaymentShare } = content.config;
|
const { loanRepaymentShare } = content.config;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { fill, formatClock, formatCredits } from "../format";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
import { Button, Icon, Panel, Sheet } from "../ui";
|
import { Button, Icon, Panel, Sheet } from "../ui";
|
||||||
import { Countdown } from "./Countdown";
|
import { Countdown } from "./Countdown";
|
||||||
|
import { useAwayOpen } from "./AwaySummary";
|
||||||
|
|
||||||
/** Calls the player has put off for now ("Decide later"); the Bridge still lists them. */
|
/** Calls the player has put off for now ("Decide later"); the Bridge still lists them. */
|
||||||
export const useRadioLater = create<{
|
export const useRadioLater = create<{
|
||||||
|
|
@ -113,6 +114,8 @@ function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
|
||||||
*/
|
*/
|
||||||
export function RadioCallSheet() {
|
export function RadioCallSheet() {
|
||||||
const state = useLoadedPlayer();
|
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 later = useRadioLater((s) => s.later);
|
||||||
const putOff = useRadioLater((s) => s.putOff);
|
const putOff = useRadioLater((s) => s.putOff);
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
|
|
@ -120,6 +123,7 @@ export function RadioCallSheet() {
|
||||||
const call = open.find((c) => !later.has(c.dispatchId));
|
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).
|
// After "While you were away" is closed, as the deal waits (one sheet at a time).
|
||||||
const awayOpen =
|
const awayOpen =
|
||||||
|
away ||
|
||||||
state.summaries.length > 0 ||
|
state.summaries.length > 0 ||
|
||||||
state.ships.some((sh) => sh.shop !== null && sh.shop.until <= now);
|
state.ships.some((sh) => sh.shop !== null && sh.shop.until <= now);
|
||||||
if (!call || awayOpen) return null;
|
if (!call || awayOpen) return null;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useApi } from "../api/context";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import { usePreferences } from "../state/preferences";
|
import { usePreferences } from "../state/preferences";
|
||||||
import { Button, Sheet } from "../ui";
|
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). */
|
/** The id of the skill point pop-up in dismissed tips (CR-011 one-time tips). */
|
||||||
export const SKILL_POINTS_TIP = "skill-points";
|
export const SKILL_POINTS_TIP = "skill-points";
|
||||||
|
|
@ -27,6 +28,7 @@ function listNames(names: string[]): string {
|
||||||
*/
|
*/
|
||||||
export function SkillPointPrompt() {
|
export function SkillPointPrompt() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
const awayOpen = useAwayOpen((s) => s.open);
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dismissed = usePreferences((p) => p.dismissedTips.includes(SKILL_POINTS_TIP));
|
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);
|
const ready = state.pilots.filter((p) => p.skillPointsAvailable > 0);
|
||||||
if (!owner || !promptAnswered || dismissed || ready.length === 0) return null;
|
if (!owner || !promptAnswered || dismissed || ready.length === 0) return null;
|
||||||
// Let delivery summaries go first.
|
// 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 names = ready.map(callName);
|
||||||
const subject =
|
const subject =
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { flightNet, type AwayProblem } from "@deadhead/sim";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { usePresence } from "../platform/presence";
|
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 { useToasts, type NewToast } from "../state/toasts";
|
||||||
import { directText, highlightText } from "./AwaySummary";
|
import { directText, highlightText } from "./AwaySummary";
|
||||||
import { eventResultText } from "./EventResults";
|
import { eventResultText } from "./EventResults";
|
||||||
|
|
@ -149,6 +150,33 @@ export function ToastFeed() {
|
||||||
};
|
};
|
||||||
}, [state, here, hereSince, api, push]);
|
}, [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).
|
// The fleet going quiet while the game is open: a toast, beside the Bridge's panel (CR-024).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = fleetQuiet(state);
|
const now = fleetQuiet(state);
|
||||||
|
|
|
||||||
|
|
@ -87,17 +87,57 @@ export function platformPresence(): PresenceHost {
|
||||||
return Capacitor.isNativePlatform() ? nativePresence : browserPresence;
|
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
|
* 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(
|
export function startPresence(
|
||||||
host: PresenceHost,
|
host: PresenceHost,
|
||||||
now: () => number,
|
now: () => number,
|
||||||
savedAt: number | null,
|
savedAt: number | null,
|
||||||
|
memory: PresenceMemory = devicePresenceMemory,
|
||||||
): () => void {
|
): () => void {
|
||||||
usePresence.setState(presenceAtStart(host.isHere(), now(), savedAt));
|
const here = host.isHere();
|
||||||
return host.onChange((here) => {
|
const awaySince = memory.read() ?? savedAt;
|
||||||
usePresence.setState((state) => presenceChange(state, here, now()));
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { logbookKey } from "../components/AwaySummary";
|
||||||
import { PilotLogbook, pilotLogbook } from "../components/PilotLogbook";
|
import { PilotLogbook, pilotLogbook } from "../components/PilotLogbook";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
import { Panel, Screen, ScreenHeader } from "../ui";
|
import { Panel, Screen, ScreenHeader } from "../ui";
|
||||||
|
import { usePresence } from "../platform/presence";
|
||||||
|
|
||||||
const HOUR = 3_600_000;
|
const HOUR = 3_600_000;
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ export function Logbook() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
const lastReturn = usePresence((p) => p.lastReturn);
|
||||||
const range = (RANGES.find(([value]) => value === params.get("range"))?.[0] ?? "24h") as LogRange;
|
const range = (RANGES.find(([value]) => value === params.get("range"))?.[0] ?? "24h") as LogRange;
|
||||||
const pilotId = params.get("pilot") ?? "";
|
const pilotId = params.get("pilot") ?? "";
|
||||||
const book = useQuery({
|
const book = useQuery({
|
||||||
|
|
@ -42,11 +44,11 @@ export function Logbook() {
|
||||||
let body = <p className="text-ink-400">Reading the logbook…</p>;
|
let body = <p className="text-ink-400">Reading the logbook…</p>;
|
||||||
if (book.data) {
|
if (book.data) {
|
||||||
const pending = state.summaries.length > 0;
|
const pending = state.summaries.length > 0;
|
||||||
|
// The absence the player last came back from (CR-028); before any, the save's own.
|
||||||
const window =
|
const window =
|
||||||
range === "absence"
|
range === "absence"
|
||||||
? pending
|
? (lastReturn ??
|
||||||
? book.data.absence
|
(pending ? book.data.absence : (book.data.lastAbsence ?? book.data.absence)))
|
||||||
: (book.data.lastAbsence ?? book.data.absence)
|
|
||||||
: { from: now - (range === "24h" ? 24 : 7 * 24) * HOUR, to: now };
|
: { from: now - (range === "24h" ? 24 : 7 * 24) * HOUR, to: now };
|
||||||
const entries = book.data.entries.filter(
|
const entries = book.data.entries.filter(
|
||||||
(e) => e.at > window.from && e.at <= window.to && (pilotId === "" || e.pilotId === pilotId),
|
(e) => e.at > window.from && e.at <= window.to && (pilotId === "" || e.pilotId === pilotId),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { PlayerState } from "@deadhead/schema";
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
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 {
|
function returningPlayer(acknowledged: boolean): PlayerState {
|
||||||
return samplePlayerState({
|
return samplePlayerState({
|
||||||
|
|
@ -59,6 +59,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
||||||
|
|
||||||
describe("something waiting on return", () => {
|
describe("something waiting on return", () => {
|
||||||
it("highlights a goal ready to claim (claimed on the Bridge's goals card, CR-025)", async () => {
|
it("highlights a goal ready to claim (claimed on the Bridge's goals card, CR-025)", async () => {
|
||||||
|
comeBack();
|
||||||
let acknowledged = false;
|
let acknowledged = false;
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
getPlayerState: () => returningPlayer(acknowledged),
|
getPlayerState: () => returningPlayer(acknowledged),
|
||||||
|
|
@ -79,6 +80,7 @@ describe("something waiting on return", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mention goals when none are ready", async () => {
|
it("does not mention goals when none are ready", async () => {
|
||||||
|
comeBack();
|
||||||
const state = returningPlayer(false);
|
const state = returningPlayer(false);
|
||||||
const quiet = {
|
const quiet = {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatAbsence } from "../src/components/AwaySummary";
|
import { formatAbsence } from "../src/components/AwaySummary";
|
||||||
import { pilotExtras } from "./fixtures";
|
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 NOW = Date.parse("2100-01-01T12:00:00Z");
|
||||||
const FROM = NOW - (9 * 60 + 40) * 60_000;
|
const FROM = NOW - (9 * 60 + 40) * 60_000;
|
||||||
|
|
@ -87,6 +87,8 @@ const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}):
|
||||||
}) as LogEntry;
|
}) as LogEntry;
|
||||||
|
|
||||||
function render(pilots: number, entries: LogEntry[], over: Partial<LogbookResponse> = {}) {
|
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;
|
let seen = false;
|
||||||
const all = Array.from({ length: pilots }, (_, i) => pilot(i));
|
const all = Array.from({ length: pilots }, (_, i) => pilot(i));
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { act, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
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");
|
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 () => {
|
it("shows the lender's cut in a delivery summary", async () => {
|
||||||
|
comeBack();
|
||||||
const state: PlayerState = {
|
const state: PlayerState = {
|
||||||
...owner(2000, 4000),
|
...owner(2000, 4000),
|
||||||
summaries: [
|
summaries: [
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ import { content } from "@deadhead/content";
|
||||||
import { screen, waitFor } from "@testing-library/react";
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it } from "vitest";
|
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 = () =>
|
const offered = () =>
|
||||||
samplePlayerState({
|
samplePlayerState({
|
||||||
|
|
@ -90,6 +97,7 @@ describe("the deal", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits until the delivery summary is dismissed", async () => {
|
it("waits until the delivery summary is dismissed", async () => {
|
||||||
|
comeBack();
|
||||||
const withSummary = () => ({
|
const withSummary = () => ({
|
||||||
...offered(),
|
...offered(),
|
||||||
summaries: [
|
summaries: [
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useClock } from "../src/clock/clock";
|
||||||
import { sleepLabel } from "../src/components/FleetSleep";
|
import { sleepLabel } from "../src/components/FleetSleep";
|
||||||
import { independentStatus } from "../src/components/IndependentPilots";
|
import { independentStatus } from "../src/components/IndependentPilots";
|
||||||
import { pilotExtras } from "./fixtures";
|
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 NOW = Date.parse("2100-01-03T12:00:00Z");
|
||||||
const SLEPT = Date.parse("2100-01-02T09:30: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 () => {
|
it("puts the sleep banner on the while-you-were-away screen, until Back to work", async () => {
|
||||||
|
comeBack();
|
||||||
const api = sleepyApi({ summaries: [summary] });
|
const api = sleepyApi({ summaries: [summary] });
|
||||||
renderApp(api, "/");
|
renderApp(api, "/");
|
||||||
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
|
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 () => {
|
it("leaves the banner off the away screen when the fleet didn't go quiet", async () => {
|
||||||
|
comeBack();
|
||||||
renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/");
|
renderApp(fakeApi({ getPlayerState: () => owner(false, { summaries: [summary] }) }), "/");
|
||||||
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
|
const sheet = await screen.findByRole("dialog", { name: /While you were away/ });
|
||||||
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
|
expect(within(sheet).queryByLabelText("Fleet asleep")).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatLocalTime } from "../src/screens/DispatchSheet";
|
import { formatLocalTime } from "../src/screens/DispatchSheet";
|
||||||
import { pilotExtras } from "./fixtures";
|
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 NOW = Date.parse("2100-01-08T12:00:00Z");
|
||||||
const HOUR = 3_600_000;
|
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 () => {
|
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), "/");
|
renderApp(app(false), "/");
|
||||||
const popup = await screen.findByRole("dialog", { name: /While you were away/ });
|
const popup = await screen.findByRole("dialog", { name: /While you were away/ });
|
||||||
const figures = await within(popup).findByLabelText("Company totals");
|
const figures = await within(popup).findByLabelText("Company totals");
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatCredits } from "../src/format";
|
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");
|
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 () => {
|
it("takes the pilot's share out of a delivery's net in the summary", async () => {
|
||||||
|
comeBack();
|
||||||
renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/");
|
renderApp(fakeApi({ getPlayerState: () => state([summary]) }), "/");
|
||||||
const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
|
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.
|
// 600 cr after the share, less 150 cr of fuel (CR-025); the share itself is in the Logbook.
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,24 @@ import {
|
||||||
startPresence,
|
startPresence,
|
||||||
usePresence,
|
usePresence,
|
||||||
type PresenceHost,
|
type PresenceHost,
|
||||||
|
type PresenceMemory,
|
||||||
} from "../src/platform/presence";
|
} from "../src/platform/presence";
|
||||||
|
|
||||||
const T = 1_000_000;
|
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. */
|
/** A host whose visibility the test flips. */
|
||||||
function fakeHost(here = true) {
|
function fakeHost(here = true) {
|
||||||
let listener: ((here: boolean) => void) | null = null;
|
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", () => {
|
it("follows the host into the store, with times from the game clock", () => {
|
||||||
const fake = fakeHost();
|
const fake = fakeHost();
|
||||||
let now = T;
|
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 });
|
expect(usePresence.getState().lastReturn).toEqual({ from: T - 60_000, to: T });
|
||||||
now = T + 10_000;
|
now = T + 10_000;
|
||||||
fake.set(false);
|
fake.set(false);
|
||||||
expect(usePresence.getState()).toMatchObject({ here: false, awaySince: T + 10_000 });
|
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;
|
now = T + 40_000;
|
||||||
fake.set(true);
|
fake.set(true);
|
||||||
expect(usePresence.getState()).toMatchObject({
|
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 },
|
lastReturn: { from: T + 10_000, to: T + 40_000 },
|
||||||
returns: 2,
|
returns: 2,
|
||||||
});
|
});
|
||||||
|
expect(memory.value).toBeNull();
|
||||||
stop();
|
stop();
|
||||||
expect(fake.listening).toBe(false);
|
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", () => {
|
it("reads the browser tab's visibility", () => {
|
||||||
const seen: boolean[] = [];
|
const seen: boolean[] = [];
|
||||||
const stop = browserPresence.onChange((here) => seen.push(here));
|
const stop = browserPresence.onChange((here) => seen.push(here));
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { vi } from "vitest";
|
||||||
import { App } from "../src/App";
|
import { App } from "../src/App";
|
||||||
import { ApiError, type ApiClient } from "../src/api/client";
|
import { ApiError, type ApiClient } from "../src/api/client";
|
||||||
import { ApiProvider } from "../src/api/context";
|
import { ApiProvider } from "../src/api/context";
|
||||||
|
import { usePresence } from "../src/platform/presence";
|
||||||
import { logbookFrom } from "./fixtures";
|
import { logbookFrom } from "./fixtures";
|
||||||
|
|
||||||
type Handlers = {
|
type Handlers = {
|
||||||
|
|
@ -43,6 +44,23 @@ export const unauthorized = () => {
|
||||||
|
|
||||||
export { samplePlayerState, shipExtras } from "./fixtures";
|
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. */
|
/** Renders the whole app at `path` with a fresh query cache and the given API. */
|
||||||
export function renderApp(api: ApiClient, path = "/") {
|
export function renderApp(api: ApiClient, path = "/") {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, configure } from "@testing-library/react";
|
import { cleanup, configure } from "@testing-library/react";
|
||||||
import { afterEach, vi } from "vitest";
|
import { afterEach, vi } from "vitest";
|
||||||
|
import { useAwayOpen } from "../src/components/AwaySummary";
|
||||||
import { usePresence } from "../src/platform/presence";
|
import { usePresence } from "../src/platform/presence";
|
||||||
import { usePreferences } from "../src/state/preferences";
|
import { usePreferences } from "../src/state/preferences";
|
||||||
import { useToasts } from "../src/state/toasts";
|
import { useToasts } from "../src/state/toasts";
|
||||||
|
|
@ -20,6 +21,7 @@ afterEach(() => {
|
||||||
returns: 0,
|
returns: 0,
|
||||||
});
|
});
|
||||||
useToasts.setState({ toasts: [] });
|
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.
|
// findBy*/waitFor default to 1 s, which a busy parallel run can exceed; wait up to 3 s.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
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");
|
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());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
it("says a ship is out of the shop for an upgrade that finished, and acknowledges it", async () => {
|
it("says a ship is out of the shop for an upgrade that finished, and acknowledges it", async () => {
|
||||||
|
comeBack();
|
||||||
const api = fakeApi({
|
const api = fakeApi({
|
||||||
getPlayerState: () =>
|
getPlayerState: () =>
|
||||||
samplePlayerState({
|
samplePlayerState({
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError } from "../src/api/client";
|
import { ApiError } from "../src/api/client";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
import { formatCredits, formatDuration } from "../src/format";
|
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 NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
const loaner = content.shipModel("company-courier")!;
|
const loaner = content.shipModel("company-courier")!;
|
||||||
|
|
@ -126,6 +126,7 @@ describe("tutorial contract board", () => {
|
||||||
|
|
||||||
describe("scripted delivery summary", () => {
|
describe("scripted delivery summary", () => {
|
||||||
it("shows the scripted contract's flavor message after it resolves", async () => {
|
it("shows the scripted contract's flavor message after it resolves", async () => {
|
||||||
|
comeBack();
|
||||||
const flavor = content.scriptedTutorialContracts.find(
|
const flavor = content.scriptedTutorialContracts.find(
|
||||||
(s) => s.id === "unmarked-crates",
|
(s) => s.id === "unmarked-crates",
|
||||||
)!.flavorMessage;
|
)!.flavorMessage;
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
|
|
@ -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`.
|
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`.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.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.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-*`.
|
- [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
|
- [ ] Step 28.5 — Regression
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue