diff --git a/DECISIONS.md b/DECISIONS.md
index 92aa475..b91fd3f 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1940,3 +1940,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **A repair is paid when it starts**, like an upgrade, and the hull is whole when the shop time ends; the shop time counts as rest when it's long enough (decision 0.5.135), which a repair of 20 points or more is.
- **Migration:** a company that has made the deal starts past its grace trips (it has flown plenty), but still gets the derelict pod first.
- **Fix from Step 25.1:** a goal already ready when a catch-up starts was logged at whatever moment first touched the save, so one long catch-up and several short ones logged it at different times. It's now logged at the save's last-seen time, before anything else.
+
+## Step 26.6
+
+- **A radio call is a sheet**, since it's time-limited: it opens on its own wherever the player is, and waits for "While you were away" to close first (one sheet at a time, as the deal does). Its countdown keeps running meanwhile.
+- **Options show what they might do, never the pre-rolled outcome**: the effects and chances come from content, so answering stays a choice under uncertainty, and the outcome is still fixed (decision 0.5.143).
+- **The default is labelled "(if you don't answer)"**, and the countdown line says what the pilot will do when it runs out.
+- **Decide later** puts a call off for the session; the Bridge lists every waiting call with Answer, and its tab badge counts them alongside goals to claim.
+- **`PlayerState.nextRadioCallAt`** tells the app when to refresh for the next call (a call appears just by time passing, which no save change marks). It only gives the time, not which event.
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index a3afa30..d1e3e54 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -22,6 +22,7 @@ import { Settings } from "./screens/Settings";
import { useMotionPreference } from "./state/preferences";
import { StartScreen } from "./screens/StartScreen";
import { usePlayerState, useRefetchOnArrival } from "./state/player";
+import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls";
import { Button } from "./ui";
// The map pulls in PixiJS; load it only when the Map tab is opened.
@@ -57,6 +58,7 @@ export function App() {
useMotionPreference();
const state = usePlayerState();
useRefetchOnArrival(state.data ?? null, state.refetch);
+ useRefetchOnRadio(state.data ?? null, state.refetch);
const retry = () => void state.refetch();
if (state.isPending) {
@@ -119,6 +121,7 @@ export function App() {
+
diff --git a/apps/web/src/components/RadioCalls.tsx b/apps/web/src/components/RadioCalls.tsx
new file mode 100644
index 0000000..3c1b7f0
--- /dev/null
+++ b/apps/web/src/components/RadioCalls.tsx
@@ -0,0 +1,188 @@
+import { content, type EventEffects, type TripEventDef } from "@deadhead/content";
+import type { PlayerState, RadioCall } from "@deadhead/schema";
+import { useEffect, useRef } from "react";
+import { create } from "zustand";
+import { useClock } from "../clock/clock";
+import { fill, formatClock, formatCredits } from "../format";
+import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
+import { Button, Icon, Panel, Sheet } from "../ui";
+import { Countdown } from "./Countdown";
+
+/** Calls the player has put off for now ("Decide later"); the Bridge still lists them. */
+export const useRadioLater = create<{
+ later: ReadonlySet;
+ putOff: (dispatchId: string) => void;
+ open: (dispatchId: string) => void;
+}>((set) => ({
+ later: new Set(),
+ putOff: (id) => set((s) => ({ later: new Set([...s.later, id]) })),
+ open: (id) => set((s) => ({ later: new Set([...s.later].filter((x) => x !== id)) })),
+}));
+
+const range = ([low, high]: readonly [number, number], unit: string) =>
+ low === high ? `${low}${unit}` : `${low}–${high}${unit}`;
+
+/** What some effects do, in a few words: "+3 h", "hull −10–20%", "+15–40% of the pay". */
+export function effectsText(effects: EventEffects): string[] {
+ const parts: string[] = [];
+ if (effects.delayGameHours) {
+ const [low, high] = effects.delayGameHours;
+ parts.push(
+ high < 0 ? `${range([-high, -low], " h")} sooner` : `+${range(effects.delayGameHours, " h")}`,
+ );
+ }
+ if (effects.hullLoss) parts.push(`hull −${range(effects.hullLoss, "%")}`);
+ if (effects.payLossPct) parts.push(`lose ${effects.payLossPct}% of the pay`);
+ if (effects.feePct) {
+ const min = effects.feeMin ? ` (at least ${formatCredits(effects.feeMin)})` : "";
+ parts.push(`fee ${effects.feePct}% of the pay${min}`);
+ }
+ if (effects.bonusPct) parts.push(`+${range(effects.bonusPct, "%")} of the pay`);
+ if (effects.xpBonusPct) parts.push(`+${effects.xpBonusPct}% XP`);
+ if (effects.extraFuelPct) parts.push(`${effects.extraFuelPct}% more fuel`);
+ return parts;
+}
+
+/** An option's possible effects: what always happens, then each chance (CR-026 §3). */
+export function optionText(option: TripEventDef["options"][number]): string {
+ const parts = effectsText(option.effects);
+ for (const chance of option.chances) {
+ parts.push(
+ `${Math.round(chance.chance * 100)}% chance: ${effectsText(chance.effects).join(", ")}`,
+ );
+ }
+ return parts.length > 0 ? parts.join(" · ") : "Nothing else happens";
+}
+
+const pilotName = (state: PlayerState, id: string) =>
+ state.pilots.find((p) => p.id === id)?.name ?? "Your pilot";
+
+/** One radio call: what's happening, the options and what they might do, and the time left. */
+function CallBody({ call, state }: { call: RadioCall; state: PlayerState }) {
+ const answer = useGameAction("answerRadioCall");
+ const def = content.tripEvent(call.eventId);
+ if (!def) return null;
+ const pilot = pilotName(state, call.pilotId);
+ const ship = state.ships.find((s) => s.id === call.shipId)?.name;
+ const fallback = def.options.find((o) => o.id === call.defaultOptionId);
+ return (
+
+
{fill(def.text, { pilot })}
+ {ship &&
{ship}
}
+
+
+ Answer within — then{" "}
+ {pilot} will {fallback?.label.toLowerCase()}.
+
+
+ );
+}
+
+/**
+ * Refetches when a radio call comes in and when a waiting one's window ends,
+ * so calls appear and go on time (decision 0.5.144). Each moment once.
+ */
+export function useRefetchOnRadio(state: PlayerState | null, refetch: () => unknown) {
+ const now = useClock((s) => s.now);
+ const done = useRef(new Set());
+ useEffect(() => {
+ if (!state) return;
+ const moments = [
+ ...(state.nextRadioCallAt !== null ? [state.nextRadioCallAt] : []),
+ ...state.radioCalls.map((c) => c.windowEndsAt),
+ ].filter((t) => t <= now && !done.current.has(t));
+ if (moments.length === 0) return;
+ for (const t of moments) done.current.add(t);
+ void refetch();
+ }, [state, now, refetch]);
+}
diff --git a/apps/web/src/layout/attention.ts b/apps/web/src/layout/attention.ts
index 62128d6..437e441 100644
--- a/apps/web/src/layout/attention.ts
+++ b/apps/web/src/layout/attention.ts
@@ -4,13 +4,14 @@ import type { ScreenPath } from "../routes";
/**
* Counts for the navigation's attention badges (decision 0.5.49): goals ready
- * to claim, ships ready for a job (decision 0.5.134), and unspent skill points
+ * to claim and radio calls waiting (decision 0.5.144), ships ready for a job (decision 0.5.134), and unspent skill points
* across all pilots (decision 0.5.109).
*/
export function tabAttention(state: PlayerState): Partial> {
const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length;
return {
- "/": state.goals.filter((g) => g.status === "ready").length,
+ // Goals to claim and radio calls to answer (CR-026).
+ "/": state.goals.filter((g) => g.status === "ready").length + state.radioCalls.length,
"/contracts": ready,
"/pilots": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0),
};
diff --git a/apps/web/src/screens/Dashboard.tsx b/apps/web/src/screens/Dashboard.tsx
index 21fc28d..7bda6a1 100644
--- a/apps/web/src/screens/Dashboard.tsx
+++ b/apps/web/src/screens/Dashboard.tsx
@@ -1,5 +1,6 @@
import { content } from "@deadhead/content";
import { FleetAsleep } from "../components/FleetSleep";
+import { RadioCallsPanel } from "../components/RadioCalls";
import { DispatchOfficePanel } from "../components/IndependentPilots";
import type { Dispatch, Pilot, PlayerState, Ship, TutorialState } from "@deadhead/schema";
import { dispatchProgress } from "@deadhead/sim";
@@ -96,6 +97,8 @@ export function Dashboard() {
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
+ {/* Calls waiting for an answer, each reopening its card (CR-026). */}
+
diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts
index d6844bb..c30aa18 100644
--- a/apps/web/test/fixtures.ts
+++ b/apps/web/test/fixtures.ts
@@ -107,6 +107,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS
dispatchOffice: { owned: false, spendingFloor: 500 },
// Awake: a day after the usual test time (CR-024).
radioCalls: [],
+ nextRadioCallAt: null,
fleetSleep: { afterHours: 24, sleepsAt: Date.parse("2100-01-02T00:00:00Z"), asleep: false },
welcomeSeen: true,
...overrides,
diff --git a/apps/web/test/radio-calls.test.tsx b/apps/web/test/radio-calls.test.tsx
new file mode 100644
index 0000000..2ea32f4
--- /dev/null
+++ b/apps/web/test/radio-calls.test.tsx
@@ -0,0 +1,156 @@
+import type { Pilot, PlayerState, RadioCall, Ship } from "@deadhead/schema";
+import { act, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useClock } from "../src/clock/clock";
+import { useRadioLater } from "../src/components/RadioCalls";
+import { tabAttention } from "../src/layout/attention";
+import { pilotExtras } from "./fixtures";
+import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
+
+const NOW = Date.parse("2100-01-01T12:00:00Z");
+
+const rosa: Pilot = {
+ ...samplePlayerState().pilots[0]!,
+ id: "p1",
+ name: "Rosa",
+ isAvatar: false,
+ assignedShipId: "s1",
+ status: "in-flight",
+ locationId: null,
+ ...pilotExtras,
+};
+const ship: Ship = {
+ id: "s1",
+ modelId: "clipper",
+ name: "Heron",
+ status: "in-flight",
+ locationId: null,
+ dispatchId: "d1",
+ lastArrivalAt: 0,
+ ...shipExtras("clipper"),
+ pilotId: "p1",
+};
+
+const call = (over: Partial = {}): RadioCall => ({
+ dispatchId: "d1",
+ shipId: "s1",
+ pilotId: "p1",
+ eventId: "debris-field",
+ fireAt: NOW - 60_000,
+ windowEndsAt: NOW + 12 * 60_000 + 34_000,
+ options: [
+ { id: "go-around", available: true },
+ { id: "push-through", available: true },
+ ],
+ defaultOptionId: "go-around",
+ independent: false,
+ ...over,
+});
+
+const owner = (calls: RadioCall[]): PlayerState =>
+ samplePlayerState({
+ tutorial: null,
+ pilots: [samplePlayerState().pilots[0]!, rosa],
+ ships: [ship],
+ radioCalls: calls,
+ });
+
+/** The app with `calls` waiting; answering removes the call answered. */
+function render(calls: RadioCall[], path = "/fleet") {
+ let waiting = calls;
+ const api = fakeApi({
+ getPlayerState: () => owner(waiting),
+ answerRadioCall: ({ dispatchId }) => {
+ waiting = waiting.filter((c) => c.dispatchId !== dispatchId);
+ return { radioCalls: waiting };
+ },
+ });
+ renderApp(api, path);
+ return { api, expire: () => (waiting = []) };
+}
+
+describe("radio calls (Step 26.6, decision 0.5.144)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
+ vi.setSystemTime(NOW);
+ useClock.setState({ offsetMs: 0, synced: true, now: NOW });
+ useRadioLater.setState({ later: new Set() });
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("shows a waiting call's story, options with what they might do, and the time left", async () => {
+ render([call()]);
+ const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
+ expect(sheet).toHaveTextContent("Rosa reports a field of rock and wreckage across the route.");
+ expect(within(sheet).getByLabelText("Time to answer")).toHaveTextContent(
+ "Answer within 12:34 — then Rosa will go around.",
+ );
+ const options = within(sheet).getByRole("list", { name: "Options" });
+ expect(
+ within(options)
+ .getAllByRole("button")
+ .map((b) => b.textContent),
+ ).toEqual([
+ "Go around (if you don't answer)+3 h",
+ "Push through35% chance: hull −10–20% · 10% chance: lose 10% of the pay",
+ ]);
+ });
+
+ it("answers through the engine, and the call leaves the queue", async () => {
+ const { api } = render([call()]);
+ const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
+ await userEvent.click(within(sheet).getByRole("button", { name: /^Push through/ }));
+ expect(api.call).toHaveBeenCalledWith("answerRadioCall", {
+ dispatchId: "d1",
+ optionId: "push-through",
+ });
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ });
+
+ it("goes away when the window closes unanswered", async () => {
+ const { expire } = render([call()]);
+ await screen.findByRole("dialog", { name: "Debris field ahead" });
+ expire();
+ act(() => useClock.setState({ now: NOW + 13 * 60_000 }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ });
+
+ it("queues several calls as Radio calls (2)", async () => {
+ render([
+ call(),
+ call({ dispatchId: "d2", eventId: "storm-approach", windowEndsAt: NOW + 20 * 60_000 }),
+ ]);
+ const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
+ expect(sheet).toHaveTextContent("Radio calls (2)");
+ });
+
+ it("badges the Bridge and lists the calls there, reopening one put off", async () => {
+ expect(tabAttention(owner([call(), call({ dispatchId: "d2" })]))["/"]).toBe(2);
+ render([call()], "/");
+ const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
+ await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ const panel = screen.getByLabelText("Radio calls");
+ expect(panel).toHaveTextContent("Radio calls (1)");
+ await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
+ expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
+ });
+
+ it("shows an option the trip can't take as not possible", async () => {
+ render([
+ call({
+ eventId: "derelict-pod",
+ options: [
+ { id: "salvage", available: false },
+ { id: "log-it", available: true },
+ ],
+ defaultOptionId: "log-it",
+ }),
+ ]);
+ const sheet = await screen.findByRole("dialog", { name: "Derelict cargo pod" });
+ const salvage = within(sheet).getByRole("button", { name: /^Salvage it/ });
+ expect(salvage).toBeDisabled();
+ expect(salvage).toHaveTextContent("Not possible on this trip");
+ });
+});
diff --git a/docs/screenshots/26-6-radio-call-desktop.png b/docs/screenshots/26-6-radio-call-desktop.png
new file mode 100644
index 0000000..9b6ed29
Binary files /dev/null and b/docs/screenshots/26-6-radio-call-desktop.png differ
diff --git a/docs/screenshots/26-6-radio-call-phone.png b/docs/screenshots/26-6-radio-call-phone.png
new file mode 100644
index 0000000..4917936
Binary files /dev/null and b/docs/screenshots/26-6-radio-call-phone.png differ
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index e567c0c..efb67c9 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -552,7 +552,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
- [x] **Step 26.3 — Sim: applying outcomes.** `applyEventOption` turns an option's pre-rolled outcome into an `AppliedEvent`: the new arrival (the delay added to the original arrival; a speed-up never below half the trip or before the event), hull loss, pay lost, fee, bonus, the Priority bonus lost when the new trip misses the window, extra XP and extra fuel; it refuses an unavailable option. `payoutAfterEvent` takes losses, the fee and a lost Priority bonus off the payout (never below zero) and adds the bonus. `resolveDispatch` takes the applied event: the pilot's share and the loan come from the new payout, and XP gets the extra percent. Tests (`packages/sim/test/events.test.ts`): a 3-hour detour moves arrival by exactly 3 game hours; a tailwind stops at half the trip; pay never below zero, bonus on top; a missed Priority window costs only the bonus; a full hold makes salvage unavailable and refused; an option's outcome is its pre-rolled one; resolution pays the bonus and extra XP and shares the new payout.
- [x] **Step 26.4 — Sim: hull condition and repairs.** `packages/sim/src/hull.ts`: `hullRatingReduction` (10% a point, at most 50%), `damageAfterRating`, `damageChance` (cut by the rating, doubled below 25%), `hullAfterDamage` (never below 5%), `repairCost` (missing points × model price × 0.5%) and `repairShopMs` (half a minute a point, at least 5), `hullBarsDangerous` (below 50%). The event roll uses them. `validateDispatch` has a new reason, `hull-too-damaged`, for Dangerous cargo on a hull below 50% (after the skill checks, before expiry and fuel), with messages in the engine and the dispatch sheet; it isn't a permanent check, so boards still show those jobs (CR-019). Tests (`packages/sim/test/hull.test.ts`): rating 3 turns a 20% hit into 14%, and the cut stops at half; chances on a rated and a battered hull; the 5% floor; Dangerous refused at 40%, allowed at 50% and for other cargo at 40%; repair cost and time.
- [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).
-- [ ] Step 26.6 — Client: radio calls
+- [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).
- [ ] Step 26.7 — Client: hull and results
- [ ] Step 26.8 — Notifications
- [ ] Step 26.9 — Balance
diff --git a/packages/engine/src/game/events.ts b/packages/engine/src/game/events.ts
index 8b1e183..75f873d 100644
--- a/packages/engine/src/game/events.ts
+++ b/packages/engine/src/game/events.ts
@@ -178,6 +178,16 @@ export function radioCalls(save: SaveDocument, now: RealTime): RadioCall[] {
.sort((a, b) => a.windowEndsAt - b.windowEndsAt);
}
+/** When the next radio call comes in (its fire time), for the app to refresh; null if none. */
+export function nextRadioCallAt(save: SaveDocument, now: RealTime): RealTime | null {
+ const times = save.dispatches.flatMap((d) =>
+ d.status === "in-flight" && d.event?.kind === "radio" && !d.eventApplied && d.event.fireAt > now
+ ? [d.event.fireAt]
+ : [],
+ );
+ return times.length > 0 ? Math.min(...times) : null;
+}
+
/** The player answers a radio call within its window (decision 0.5.144). */
export function answerRadioCall(
save: SaveDocument,
diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts
index 139a976..14b1e15 100644
--- a/packages/engine/src/game/state.ts
+++ b/packages/engine/src/game/state.ts
@@ -24,7 +24,7 @@ import {
type ShipState,
} from "@deadhead/sim";
import { fail } from "./errors";
-import { radioCalls } from "./events";
+import { nextRadioCallAt, radioCalls } from "./events";
type SavedShip = SaveDocument["ships"][number];
type SavedPilot = SaveDocument["pilots"][number];
@@ -301,6 +301,7 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState {
goals: goalStates(save),
welcomeSeen: save.player.welcomeSeen,
radioCalls: radioCalls(save, now),
+ nextRadioCallAt: nextRadioCallAt(save, now),
fleetSleep: {
afterHours: save.company.sleepAfterHours,
sleepsAt: sleepsAt(save, now),
diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts
index 976e2b2..b0eb9a7 100644
--- a/packages/engine/test/start.test.ts
+++ b/packages/engine/test/start.test.ts
@@ -79,6 +79,7 @@ describe("company creation on the device (Step 14.8)", () => {
spendingFloor: content.config.independentPilots.defaultSpendingFloor,
},
radioCalls: [],
+ nextRadioCallAt: null,
fleetSleep: {
afterHours: 24,
sleepsAt: TEST_NOW + 24 * 60 * 60_000,
diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts
index e621839..773286c 100644
--- a/packages/schema/src/entities.ts
+++ b/packages/schema/src/entities.ts
@@ -284,6 +284,8 @@ export const playerStateSchema = z.object({
fleetSleep: fleetSleepSchema,
/** Radio calls waiting for an answer, soonest window end first (decision 0.5.144). */
radioCalls: z.array(radioCallSchema),
+ /** When the next radio call comes in, so the app can refresh then; null if none is due. */
+ nextRadioCallAt: timeSchema.nullable(),
/** This company's welcome cards have been seen (decision 0.5.131). */
welcomeSeen: z.boolean(),
});
diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts
index a6820cd..f15c750 100644
--- a/packages/schema/test/schemas.test.ts
+++ b/packages/schema/test/schemas.test.ts
@@ -184,6 +184,7 @@ const playerState = {
dispatchOffice: { owned: true, spendingFloor: 500 },
fleetSleep: { afterHours: 24, sleepsAt: 90_000_000, asleep: false },
radioCalls: [],
+ nextRadioCallAt: null,
welcomeSeen: true,
};