Step 26.6: client: radio calls
CI / check (push) Successful in 8m5s
Details
CI / check (push) Successful in 8m5s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
71ea3c6b8e
commit
db5bdf6f2d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</Route>
|
||||
</Routes>
|
||||
<AwaySummary />
|
||||
<RadioCallSheet />
|
||||
<DealModal />
|
||||
<WelcomeGate />
|
||||
<SkillPointPrompt />
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
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 (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-ink-100">{fill(def.text, { pilot })}</p>
|
||||
{ship && <p className="text-ink-400">{ship}</p>}
|
||||
<p aria-label="Time to answer" className="text-amber-200">
|
||||
<Icon name="clock" className="mr-1.5 inline size-4 align-[-3px]" />
|
||||
Answer within <Countdown to={call.windowEndsAt} format={formatClock} done="now" /> — then{" "}
|
||||
{pilot} will {fallback?.label.toLowerCase()}.
|
||||
</p>
|
||||
<ul aria-label="Options" className="space-y-2">
|
||||
{def.options.map((option) => {
|
||||
const available = call.options.find((o) => o.id === option.id)?.available ?? false;
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<Button
|
||||
variant={option.id === call.defaultOptionId ? "secondary" : "primary"}
|
||||
className="w-full flex-col items-start text-left normal-case"
|
||||
disabled={!available || answer.isPending}
|
||||
onClick={() => answer.mutate({ dispatchId: call.dispatchId, optionId: option.id })}
|
||||
>
|
||||
<span className="font-display tracking-wide">
|
||||
{option.label}
|
||||
{option.id === call.defaultOptionId && " (if you don't answer)"}
|
||||
</span>
|
||||
<span className="text-xs font-normal tracking-normal normal-case opacity-80">
|
||||
{available ? optionText(option) : "Not possible on this trip"}
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{answer.isError && (
|
||||
<p role="alert" className="text-rose-300">
|
||||
{errorMessage(answer.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The radio call on top of the queue (decision 0.5.144), as a sheet: "Radio
|
||||
* calls (2)" when more are waiting. "Decide later" tucks it away; the Bridge
|
||||
* lists every waiting call.
|
||||
*/
|
||||
export function RadioCallSheet() {
|
||||
const state = useLoadedPlayer();
|
||||
const later = useRadioLater((s) => s.later);
|
||||
const putOff = useRadioLater((s) => s.putOff);
|
||||
const now = useClock((s) => s.now);
|
||||
const open = state.radioCalls.filter((c) => c.windowEndsAt > now);
|
||||
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).
|
||||
const awayOpen =
|
||||
state.summaries.length > 0 ||
|
||||
state.ships.some((sh) => sh.shop !== null && sh.shop.until <= now);
|
||||
if (!call || awayOpen) return null;
|
||||
const def = content.tripEvent(call.eventId);
|
||||
return (
|
||||
<Sheet
|
||||
titleId="radio-title"
|
||||
title={def?.title ?? "Radio call"}
|
||||
eyebrow={open.length > 1 ? `Radio calls (${open.length})` : "Radio call"}
|
||||
tone="amber"
|
||||
layer="z-30"
|
||||
>
|
||||
<CallBody call={call} state={state} />
|
||||
<Button variant="secondary" className="mt-3 w-full" onClick={() => putOff(call.dispatchId)}>
|
||||
Decide later
|
||||
</Button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Bridge's list of waiting calls, each reopening its card (decision 0.5.144). */
|
||||
export function RadioCallsPanel() {
|
||||
const state = useLoadedPlayer();
|
||||
const open = useRadioLater((s) => s.open);
|
||||
if (state.radioCalls.length === 0) return null;
|
||||
return (
|
||||
<Panel tone="amber" className="text-sm" aria-label="Radio calls">
|
||||
<p className="flex items-center gap-2 font-display font-semibold tracking-wide text-amber-100">
|
||||
<Icon name="alert" className="size-4" />
|
||||
Radio calls ({state.radioCalls.length})
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{state.radioCalls.map((call) => (
|
||||
<li key={call.dispatchId} className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
{content.tripEvent(call.eventId)?.title} · {pilotName(state, call.pilotId)} ·{" "}
|
||||
<Countdown to={call.windowEndsAt} format={formatClock} done="now" />
|
||||
</span>
|
||||
<Button size="sm" onClick={() => open(call.dispatchId)}>
|
||||
Answer
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number>());
|
||||
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]);
|
||||
}
|
||||
|
|
@ -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<Record<ScreenPath, number>> {
|
||||
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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
|
||||
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
||||
<FleetAsleep />
|
||||
{/* Calls waiting for an answer, each reopening its card (CR-026). */}
|
||||
<RadioCallsPanel />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<YourShip className="md:col-span-2" />
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): 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,
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue