From e5511188f1401ffd2f35e1b8a2dafdaa3c4fb688 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 17 Oct 2026 01:09:24 -0400 Subject: [PATCH] Step 26.8: notifications Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/screens/Settings.tsx | 2 +- memory-bank/architecture.md | 2 +- memory-bank/progress.md | 2 +- packages/engine/src/notifications.ts | 23 +++++++ packages/engine/test/notifications.test.ts | 80 ++++++++++++++++++++++ 6 files changed, 112 insertions(+), 3 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 1414664..a7c27c6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1958,3 +1958,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Developer tools: Damage hull** sets a ship to 20%, to try the warning and repairs (the CR's manual check). - **A sleep test's world is fixed now** (`fleet-sleep.test.ts`, "trips in flight still pay"): each test company gets a random world, and with events shifting trip times, a world sometimes had no trip in the air at the sleep time, failing the check that one lands after it. Seen as an intermittent failure after Step 26.7's commit; fixed in a follow-up commit. + +## Step 26.8 + +- **Scheduled from the save, like every notification**: the plan is rebuilt after each change, so "cancel if the trip or company changes" needs nothing of its own, and an answered or fired call simply drops out. +- **Only trips the player sent**: Independent pilots' trips are chosen during catch-up, so their calls aren't known ahead (as their arrivals aren't announced, decision 0.5.130). +- **Fleet sleep doesn't silence them**: a call on a trip already flying still happens when the fleet is asleep (CR-026 §5). diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index a3370d4..1bb2472 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, upgrades and a quiet fleet" + label="Arrivals, radio calls, 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 3dbd9cb..476756a 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -146,7 +146,7 @@ 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). +Notifications (`packages/engine/src/notifications.ts`) also plan each radio call to come on a trip the player sent (`upcomingRadioCalls`, CR-026), and the fleet's sleep time ("Your pilots are waiting for orders.") while it's ahead and some pilot is Independent (CR-024). `packages/sim/src/events.ts` (CR-026): `rollTripEvent` rolls a trip's event from its seed at dispatch (null when quiet): fire time, window end, every option's outcome, availability and the default; `eventChance` and `eventWeight` hold the pacing and conditions; `applyEventOption` gives the `AppliedEvent` (new arrival, hull loss, pay changes, Priority bonus lost, extra XP and fuel); `payoutAfterEvent` and `resolveDispatch`'s `event` input apply it to the delivery. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index b5aa503..ba461bd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -554,6 +554,6 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C - [x] **Step 26.5 — Engine: events in play and catch-up.** Contract and deadhead dispatches (the player's and Independent pilots') roll their event with `rollForDispatch` and store it on the dispatch, counting the pacing. Catch-up settles events in time order before shop ends, arrivals and decisions at the same moment: automatic events at their fire time, radio calls with their default when the window closes unanswered. `applyTripEvent` sets the new arrival, the hull (never below 5%) and extra fuel, and logs an `event` entry; resolution pays the event's bonuses and losses. `answerRadioCall` (manual decision) applies the chosen option within the window; `repairShip` (manual decision) charges the repair at an HQ, puts the ship in the shop (counting as rest) and `finishRepair` makes it 100% at the shop end with a `repair` entry. API: `PlayerState.radioCalls`, ship `hullConditionPct` and `repairing`, summary `event`. Save version 11 (10 → 11 per CR-026 §6). Also fixed: goals already ready when a catch-up starts are logged at the save's last-seen time, so they're the same however the catch-up is split. Tests: `packages/engine/test/trip-events.test.ts` (answering in the window, refused before and after; the default and its 3-hour detour when unanswered; an automatic tip paid on delivery; an Independent pilot's call defaults and can be answered; one go equals 7-minute pieces with a defaulted call and an automatic event; the first event after the grace trips is the derelict pod, with the counters; grace trips are quiet; a repair at an HQ: cost, shop, 100% and the entry, refused when intact; refused away from an HQ); `packages/schema/test/save.test.ts` (10 → 11). - [x] **Step 26.6 — Client: radio calls.** `apps/web/src/components/RadioCalls.tsx`: `RadioCallSheet` shows the first waiting call (after "While you were away" is closed): the event's title and text with the pilot, the ship, "Answer within mm:ss — then Rosa will …" (the default), and each option with what it might do from content ("+3 h", "35% chance: hull −10–20%"), the default marked, unavailable ones disabled ("Not possible on this trip"); "Radio calls (2)" when more wait; Decide later tucks it away. The Bridge lists the waiting calls (`RadioCallsPanel`), each reopening its card, and its tab badge counts them. `PlayerState.nextRadioCallAt` (new) and the waiting calls' window ends refetch the state on time (`useRefetchOnRadio`). Tests (`apps/web/test/radio-calls.test.tsx`): a call's text, options, effects and countdown; answering calls the engine and the call leaves; the window closing removes it; two calls read Radio calls (2); the Bridge badge, Decide later and Answer from the Bridge; an unavailable option is disabled. The result in the summary is Step 26.7's. Screenshots: `docs/screenshots/26-6-radio-call-{phone,desktop}.png` (a fresh company's first event, the derelict pod). - [x] **Step 26.7 — Client: hull and results.** Fleet cards have a hull bar (`HullGauge`: green, amber below 50%, red below 25% with "Needs repair") and say "Under repair" in the shop. The ship's Upgrades sheet has a Hull section: Repair to 100% with its cost and shop time at an HQ (with "Not enough credits" when short), "Repairs are done at one of your HQs" elsewhere; Hull Plating shows in the upgrade list ("Hull rating 1 → 2"). The client's ship state carries the hull, and the dispatch sheet lists "Hull too damaged for hazardous cargo: repair at an HQ". Event results (`apps/web/src/components/EventResults.tsx`): "Debris field ahead — Rosa: Go around (no answer) · +3 h" in the pop-up's short list, the Logbook (and repairs there), and as highlights: good events and hull hits rank just below level-ups (the builder's new `event` highlight; a short absence lists its trips' events, not counted towards the 3). Developer tools: Damage hull (to 20%). Tests: `apps/web/test/hull-events.test.tsx` (the bar and Needs repair; Repair's cost, time and call; away from an HQ; Hull Plating in the shop; the dispatch reason at 40% and not at 50%; event and repair wording; highlight order); `packages/engine/test/trip-events.test.ts` (Damage hull). Screenshots: `docs/screenshots/26-7-hull-card-{phone,desktop}.png`, `26-7-repair-{phone,desktop}.png`. -- [ ] Step 26.8 — Notifications +- [x] **Step 26.8 — Notifications.** `plannedNotifications` adds one notification per radio call still to come on a trip the player sent (`upcomingRadioCalls`), at its fire time: "Debris field ahead — Rosa needs a call.", on its own (not grouped by minute). Independent pilots' trips get none; fleet sleep doesn't stop them. Like the rest, the plan is rebuilt from the save after every change, so a new company, an import or an answered call drops it, and the Notifications toggle (now "Arrivals, radio calls, rest, upgrades and a quiet fleet") turns them off. Tests (`packages/engine/test/notifications.test.ts`): a dispatch with a radio call schedules one at its fire time; a quiet trip and an Independent pilot's trip schedule none; turning notifications off cancels it, and so does the call firing. - [ ] Step 26.9 — Balance - [ ] Step 26.10 — Plan amendments and regression diff --git a/packages/engine/src/notifications.ts b/packages/engine/src/notifications.ts index 1b64dc8..9620d8a 100644 --- a/packages/engine/src/notifications.ts +++ b/packages/engine/src/notifications.ts @@ -94,9 +94,32 @@ export function plannedNotifications(save: SaveDocument, now: RealTime): Schedul body: "Your pilots are waiting for orders.", }); } + for (const call of upcomingRadioCalls(save, now)) { + planned.push({ id: planned.length + 1, at: call.at, title: "Deadhead", body: call.text }); + } return planned; } +/** + * Radio calls still to come on trips the player sent (decision 0.5.150): one + * notification each at its fire time, "Debris field ahead — Rosa needs a + * call." Independent pilots' trips are decided during catch-up, so theirs + * can't be scheduled ahead. Fleet sleep doesn't stop calls on trips in flight. + */ +export function upcomingRadioCalls( + save: SaveDocument, + now: RealTime, +): { at: RealTime; text: string }[] { + return save.dispatches.flatMap((d) => { + const event = d.event; + if (d.status !== "in-flight" || d.independent || event?.kind !== "radio") return []; + if (d.eventApplied || event.fireAt <= now) return []; + const title = content.tripEvent(event.eventId)?.title ?? "A radio call"; + const pilot = save.pilots.find((p) => p.id === d.pilotId)?.name ?? "Your pilot"; + return [{ at: event.fireAt, text: `${title} — ${pilot} needs a call.` }]; + }); +} + /** * 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 diff --git a/packages/engine/test/notifications.test.ts b/packages/engine/test/notifications.test.ts index b51951b..e75c2d2 100644 --- a/packages/engine/test/notifications.test.ts +++ b/packages/engine/test/notifications.test.ts @@ -14,6 +14,7 @@ import { import { addPilot, addShip, + edit, makeOwner, putBoard, setCredits, @@ -186,3 +187,82 @@ describe("the sleep notification (Step 24.6, decision 0.5.139)", () => { ]); }); }); + +describe("radio-call notifications (Step 26.8, decision 0.5.150)", () => { + const none = { + delayMs: 0, + hullLoss: 0, + payLoss: 0, + fee: 0, + bonus: 0, + xpBonusPct: 0, + extraFuelFu: 0, + }; + const radio = (fireAt: number, windowEndsAt: number) => ({ + eventId: "debris-field", + kind: "radio" as const, + good: false, + fireAt, + windowEndsAt, + options: [ + { id: "go-around", available: true, outcome: none }, + { id: "push-through", available: true, outcome: none }, + ], + defaultOptionId: "go-around", + }); + const calls = (list: ScheduledNotification[]) => + list.filter((n) => n.body.endsWith("needs a call.")); + + /** A dispatch by the player, given a radio call 40% into the trip (or none). */ + async function withCall( + test: ReturnType, + options: { independent?: boolean; quiet?: boolean } = {}, + ) { + const dispatch = await dispatchOne(test.app); + const fireAt = + dispatch.departureAt + Math.round((dispatch.arrivalAt - dispatch.departureAt) * 0.4); + await edit(test.app, (save) => { + const d = save.dispatches.find((x) => x.id === dispatch.id)!; + d.event = options.quiet ? null : radio(fireAt, dispatch.arrivalAt); + d.independent = options.independent ?? false; + }); + await test.app.resyncNotifications(); + return { dispatch, fireAt }; + } + + it("schedules one at a radio call's fire time on a trip the player sent", async () => { + const test = setup(); + const { fireAt } = await withCall(test); + const avatar = (await test.app.call("getPlayerState", {})).pilots.find((p) => p.isAvatar)!; + expect(calls(test.scheduler.scheduled)).toEqual([ + expect.objectContaining({ + at: fireAt, + title: "Deadhead", + body: `Debris field ahead — ${avatar.name} needs a call.`, + }), + ]); + }); + + it("schedules none for a quiet trip or an Independent pilot's", async () => { + const quiet = setup(); + await withCall(quiet, { quiet: true }); + expect(calls(quiet.scheduler.scheduled)).toEqual([]); + const own = setup(); + await withCall(own, { independent: true }); + expect(calls(own.scheduler.scheduled)).toEqual([]); + }); + + it("cancels them when notifications are turned off, or the call has fired", async () => { + let enabled = true; + const test = setup(() => enabled); + const { fireAt } = await withCall(test); + expect(calls(test.scheduler.scheduled)).toHaveLength(1); + enabled = false; + await test.app.resyncNotifications(); + expect(test.scheduler.scheduled).toEqual([]); + enabled = true; + test.clock.now = fireAt + 1; + await test.app.resyncNotifications(); + expect(calls(test.scheduler.scheduled)).toEqual([]); + }); +});