Step 26.8: notifications
CI / check (push) Successful in 7m55s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-17 01:09:24 -04:00
parent 0c52a1b532
commit e5511188f1
6 changed files with 112 additions and 3 deletions

View File

@ -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).

View File

@ -74,7 +74,7 @@ export function Settings() {
<Section title="Notifications">
<Choice<"on" | "off">
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"],

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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<typeof setup>,
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([]);
});
});