diff --git a/DECISIONS.md b/DECISIONS.md index ad9c39f..d185c1c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1839,3 +1839,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The banner shows inside the away screen when it opens**, which needs unseen summaries. A fleet that went quiet with nothing new to report still shows the Bridge status. - **Changing the threshold is itself a manual decision**: it's a game command the player makes, so it also wakes the fleet. - **"3 days", not "72 h"**: thresholds over 48 hours that are whole days are written in days. + +## Step 24.6 + +- **Scheduled from the save, like the others:** the notification scheduler already rebuilds the whole plan after every saved change and replaces what was scheduled. So "each manual decision reschedules it" and "cancelled on new company or import" need no code of their own: the sleep time moves with the decision, and a new or imported save plans its own. +- **Only with an Independent pilot:** with none, nobody stops working, and "Your pilots are waiting for orders" would be untrue. +- **Not grouped by minute:** it's a separate notification, so it never merges with an arrival in the same minute. diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index de69dcf..a3370d4 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -74,7 +74,7 @@ export function Settings() {
- label="Arrivals, rest and upgrades" + label="Arrivals, rest, upgrades and a quiet fleet" value={prefs.notifications ? "on" : "off"} options={[ ["on", "On"], diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index c05d25c..31ad4f8 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -143,4 +143,6 @@ On the device the client keeps the save (IndexedDB, or files on native) and, in ### Independent pilots and catch-up (CR-021) +Notifications (`packages/engine/src/notifications.ts`) also plan the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024). + `packages/engine/src/game/catchup.ts`: `catchUp(save, until, { decideUntil, maxEvents, viewedContractId })` replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's `nextDecisionAt`, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes `nextDecisionAt` later), then asks the sim's `chooseAction` about the pilot's board. It acts through the normal `refuelShip`, `dispatchContract` and `dispatchReposition`, with ids made from the pilot and the moment (`ind-…`), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop `maxCatchUpRealDays` after the save's `lastTimeSeen` (`decideUntil`) and at the fleet's sleep time (`sleepsAt` in `state.ts`: `company.lastManualDecisionAt` plus `sleepAfterHours`, CR-024), and later ones resume from `until`, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null `lastManualDecisionAt` (a save just migrated) is set to `until` first. Every command runs `catchUp` to its `now` before it acts. `GameEngine.resolveDue(since, { chunkEvents, onProgress })` runs the loop's pass in chunks (500 events, one save each), with `onProgress(0…1)` in between. `GameEngine.setViewedContract(id)` tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged `independent`, and no notification is scheduled for them. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 20f70ee..2c1f494 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -526,5 +526,5 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022 - [x] **Step 24.3 — Engine: sleep in live play and catch-up.** `catchUp` processes Independent pilots' decisions only before the sleep time (`sleepsAt`, in `state.ts`), alongside the 7-day cap; arrivals, rest and shop ends resolve as before, and pilots due later wait at `until`. Live play is the same catch-up run by every command and by the loop. `PlayerState.fleetSleep` (`afterHours`, `sleepsAt`, `asleep`) tells the client, which shows "Waiting for orders" from it (Step 24.5). Tests (`packages/engine/test/fleet-sleep.test.ts`): two Independent pilots left 7 days start nothing at or after 24 hours, and the trips in flight then still resolve with pay; one go equals 5-hour pieces over 3 days; a trip the player sent lands after the sleep time as normal; a 48-hour threshold moves the cutoff. The 7-day cap test now uses a 30-day threshold so only the cap stops the pilots. - [x] **Step 24.4 — Engine: waking and "Back to work".** New command `backToWork` (`POST /api/fleet/wake`, a manual decision): it sets the last decision to now and returns the player state, awake. Any manual decision does the same, and pilots waiting at that time decide from it. Tests (`packages/engine/test/fleet-sleep.test.ts`): after two days asleep, "Back to work" brings Independent trips again, none before the moment of waking; so does buying fuel. `packages/schema/test/routes.test.ts` lists it as manual. - [x] **Step 24.5 — Client: return banner, status, and setting.** `apps/web/src/components/FleetSleep.tsx`: `SleepBanner` at the top of "While you were away" ("Your fleet went quiet on [date, time] after [24 h] without orders." and Back to work) and `FleetAsleep` under the Bridge's header ("Fleet asleep — waiting for your orders", the same button), both only while `fleetSleep.asleep` and some pilot is Independent. `independentStatus` reads "Waiting for orders" while asleep (after flying, the shop and rest). Settings → Company: "Fleet goes quiet after" (12 h / 24 h / 48 h / 3 days) through the new `setSleepThreshold` command (one of the offered times; a manual decision). Tests: `apps/web/test/fleet-sleep.test.tsx` (the Bridge status asleep only, not awake or with no Independent pilot, and Back to work clears it; the banner on the away screen and its button; no banner awake; the pilot status; the setting saves 3 days and shows it checked; the labels); `packages/engine/test/fleet-sleep.test.ts` (the setting takes an offered time, wakes the fleet and refuses 36 h). Screenshots: `docs/screenshots/24-5-away-banner-{phone,desktop}.png`, `24-5-bridge-asleep-{phone,desktop}.png`, `24-5-settings-{phone,desktop}.png`. -- [ ] Step 24.6 — Client: sleep notification (native) +- [x] **Step 24.6 — Client: sleep notification (native).** `plannedNotifications` adds one notification at the sleep time ("Your pilots are waiting for orders."), on its own rather than in a minute's group, while it's ahead and some pilot is Independent (`sleepNotificationAt`). The schedule is rebuilt from the save after every change, so each manual decision moves it, and a new company or an import drops it; the Notifications toggle and the web's no-op scheduler apply as for the rest. The toggle now reads "Arrivals, rest, upgrades and a quiet fleet". Tests (`packages/engine/test/notifications.test.ts`): one at the decision plus 24 hours; a later decision moves it, still one; none without an Independent pilot, with notifications off, or for a new company; none once asleep. The web's no-op scheduler is checked in `apps/web/test/notifications.test.ts`. - [ ] Step 24.7 — Plan amendments and regression diff --git a/packages/engine/src/notifications.ts b/packages/engine/src/notifications.ts index 32c9ab9..1b64dc8 100644 --- a/packages/engine/src/notifications.ts +++ b/packages/engine/src/notifications.ts @@ -1,11 +1,13 @@ import { content } from "@deadhead/content"; import type { SaveDocument } from "@deadhead/schema"; import { MS_PER_MINUTE, type RealTime } from "@deadhead/sim"; +import { sleepsAt } from "./game/state"; /** * Local notifications (decision 0.5.91): the app schedules one for each * arrival, each pilot's rest ending and each upgrade finishing; events in the - * same minute share one notification. The schedule is worked out from the save + * same minute share one notification. One more marks the fleet going quiet + * (decision 0.5.139). The schedule is worked out from the save * after every change and replaces whatever was scheduled before, so changed or * cancelled events (a new company, an import) are dropped automatically. */ @@ -76,13 +78,34 @@ export function plannedNotifications(save: SaveDocument, now: RealTime): Schedul const minute = Math.floor(event.at / MS_PER_MINUTE); byMinute.set(minute, [...(byMinute.get(minute) ?? []), event]); } - return [...byMinute.values()].map((events, i) => ({ + const planned = [...byMinute.values()].map((events, i) => ({ id: i + 1, // The latest event of the minute, so everything in it has happened. at: events.at(-1)!.at, title: events.length === 1 ? "Deadhead" : `Deadhead: ${events.length} updates`, body: events.map((e) => e.text).join("\n"), })); + const sleep = sleepNotificationAt(save, now); + if (sleep !== null) { + planned.push({ + id: planned.length + 1, + at: sleep, + title: "Deadhead", + body: "Your pilots are waiting for orders.", + }); + } + return planned; +} + +/** + * When the fleet goes quiet (decision 0.5.139), if that's ahead and there are + * Independent pilots for it to stop. A notification of its own: every manual + * decision moves it, so it never joins a minute's group. + */ +export function sleepNotificationAt(save: SaveDocument, now: RealTime): RealTime | null { + if (!save.pilots.some((p) => p.mode === "independent")) return null; + const at = sleepsAt(save, now); + return at > now ? at : null; } /** Keeps the platform's schedule matching the save; off (the Settings toggle) schedules nothing. */ diff --git a/packages/engine/test/notifications.test.ts b/packages/engine/test/notifications.test.ts index 3e83546..b51951b 100644 --- a/packages/engine/test/notifications.test.ts +++ b/packages/engine/test/notifications.test.ts @@ -1,3 +1,4 @@ +import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { fullSave } from "../../schema/test/save-fixture"; import { @@ -10,7 +11,16 @@ import { type NotificationScheduler, type ScheduledNotification, } from "../src/index"; -import { makeOwner, putBoard, setCredits, signUp, TestClock, testContract } from "./support"; +import { + addPilot, + addShip, + makeOwner, + putBoard, + setCredits, + signUp, + TestClock, + testContract, +} from "./support"; /** The platform plugin, recording what is scheduled. */ class FakeScheduler implements NotificationScheduler { @@ -116,3 +126,63 @@ describe("local notifications (Step 14.9, decision 0.5.91)", () => { await expect(app.requestNotificationPermission()).resolves.toBeUndefined(); }); }); + +describe("the sleep notification (Step 24.6, decision 0.5.139)", () => { + const SLEEP = "Your pilots are waiting for orders."; + const DAY = 24 * 60 * 60_000; + const sleepNotes = (list: ScheduledNotification[]) => list.filter((n) => n.body === SLEEP); + + /** An owner with an Independent pilot and a Courier dispatched by hand. */ + async function withIndependent(enabled = () => true) { + const test = setup(enabled); + const dispatch = await dispatchOne(test.app); + await setCredits(test.app, content.config.independentPilots.dispatchOfficePrice); + await test.app.call("buyDispatchOffice", {}); + await addShip(test.app, { id: "s2", modelId: "courier", name: "Wren" }); + await addPilot(test.app, { id: "p2", name: "Ada", assignedShipId: "s2" }); + await test.app.call("setPilotMode", { pilotId: "p2", mode: "independent" }); + return { ...test, dispatch }; + } + + it("schedules one at the last decision plus the threshold", async () => { + const { scheduler, clock } = await withIndependent(); + expect(sleepNotes(scheduler.scheduled)).toEqual([ + expect.objectContaining({ at: clock.now + DAY, title: "Deadhead", body: SLEEP }), + ]); + }); + + it("moves it with the next manual decision, keeping just one", async () => { + const { app, scheduler, clock } = await withIndependent(); + clock.now += 3 * 60 * 60_000; + await app.call("setRefuelPolicy", { shipId: "s2", policy: "auto" }); + expect(sleepNotes(scheduler.scheduled)).toEqual([ + expect.objectContaining({ at: clock.now + DAY }), + ]); + }); + + it("schedules none without an Independent pilot, with notifications off, or for a new company", async () => { + const plain = setup(); + await dispatchOne(plain.app); + expect(sleepNotes(plain.scheduler.scheduled)).toEqual([]); + + let enabled = true; + const off = await withIndependent(() => enabled); + enabled = false; + await off.app.resyncNotifications(); + expect(off.scheduler.scheduled).toEqual([]); + + const fresh = await withIndependent(); + await fresh.app.call("newCompany", {}); + expect(sleepNotes(fresh.scheduler.scheduled)).toEqual([]); + }); + + it("drops it once the fleet is asleep", () => { + const save = fullSave(); + save.pilots[0]!.mode = "independent"; + save.company.lastManualDecisionAt = 0; + expect(sleepNotes(plannedNotifications(save, DAY + 1))).toEqual([]); + expect(sleepNotes(plannedNotifications(save, DAY - 1))).toEqual([ + expect.objectContaining({ at: DAY }), + ]); + }); +});