Step 26.7: client: hull and results
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 00:43:01 -04:00
parent db5bdf6f2d
commit 9b2fa19767
19 changed files with 465 additions and 7 deletions

View File

@ -1948,3 +1948,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 26.7
- **"Ship detail" is the Upgrades sheet**: it's the one per-ship sheet, and repairs use the same shop, so the hull and its Repair button sit at its top. Its subtitle now says repairs are done there too.
- **Which events are highlights**: good events (whatever was chosen) and any that hurt a hull, ranked just below level-ups. A detour or a fee without damage is in the Logbook and the short list, not a highlight.
- **The short list shows a trip's event next to the trip**, without it counting as one of the 3 events: a delivery with a detour still reads as a quick check-in.
- **The hull bar colours** follow the rules: amber below 50% (no Dangerous cargo), red below 25% (Needs repair).
- **Developer tools: Damage hull** sets a ship to 20%, to try the warning and repairs (the CR's manual check).

View File

@ -15,6 +15,7 @@ import { useClock } from "../clock/clock";
import { fill, formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Icon, Sheet } from "../ui";
import { eventResultText } from "./EventResults";
import { SleepBanner } from "./FleetSleep";
const words = content.awayWording;
@ -106,6 +107,8 @@ export function highlightText(highlight: AwayHighlight, state: PlayerState): str
});
case "problem":
return problemText(highlight.problem, name);
case "event":
return eventResultText(highlight.entry, state);
}
}
@ -143,6 +146,8 @@ export function directText(entry: LogEntry, state: PlayerState): string {
level: entry.level,
points: plural(entry.skillPoints, "skill point"),
});
case "event":
return eventResultText(entry, state);
default:
return "";
}

View File

@ -0,0 +1,45 @@
import { content } from "@deadhead/content";
import type { LogEntry, PlayerState } from "@deadhead/schema";
import { MS_PER_HOUR } from "@deadhead/sim";
import { formatCredits } from "../format";
type EventEntry = Extract<LogEntry, { kind: "event" }>;
/** Game hours for a real-time delay, to one decimal ("3 h", "1.5 h"). */
function hours(delayMs: number): string {
const h = Math.abs(delayMs * content.config.timeCompression) / MS_PER_HOUR;
return `${Math.round(h * 10) / 10} h`;
}
/** What an event actually changed, in a few words: "+3 h", "hull −14%", "+1,200 cr". */
export function eventEffects(entry: EventEntry): string[] {
const parts: string[] = [];
if (entry.delayMs > 0) parts.push(`+${hours(entry.delayMs)}`);
if (entry.delayMs < 0) parts.push(`${hours(entry.delayMs)} sooner`);
if (entry.hullLoss > 0) parts.push(`hull −${entry.hullLoss}%`);
if (entry.payLoss > 0) parts.push(`−${formatCredits(entry.payLoss)} pay`);
if (entry.fee > 0) parts.push(`${formatCredits(entry.fee)} fee`);
if (entry.bonus > 0) parts.push(`+${formatCredits(entry.bonus)}`);
if (entry.priorityLost > 0) {
parts.push(`missed the Priority window (−${formatCredits(entry.priorityLost)})`);
}
if (entry.xpBonusPct > 0) parts.push(`+${entry.xpBonusPct}% XP`);
if (entry.extraFuelFu > 0) parts.push(`+${Math.round(entry.extraFuelFu)} fu fuel`);
return parts;
}
/**
* An event's result in a line (CR-026 §5): "Debris field ahead — Rosa: Go
* around (no answer) · +3 h". Automatic events read "Solar flare — Rosa: +3 h".
*/
export function eventResultText(entry: EventEntry, state: PlayerState): string {
const def = content.tripEvent(entry.eventId);
const pilot = state.pilots.find((p) => p.id === entry.pilotId)?.name ?? "A pilot";
const effects = eventEffects(entry);
const choice =
def?.kind === "radio"
? `${def.options.find((o) => o.id === entry.optionId)?.label ?? entry.optionId}${entry.answered ? "" : " (no answer)"}`
: null;
const what = [choice, ...effects].filter((p): p is string => p !== null);
return `${def?.title ?? entry.eventId} — ${pilot}: ${what.length > 0 ? what.join(" · ") : "no harm done"}`;
}

View File

@ -7,6 +7,7 @@ import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { Button, Icon } from "../ui";
import { pilotNow } from "./AwaySummary";
import { eventResultText } from "./EventResults";
import { formatSleepTime } from "./FleetSleep";
/** One pilot's part of the logbook (decision 0.5.141), or the company's own entries. */
@ -157,6 +158,19 @@ function EntryLine({ entry, state }: { entry: LogEntry; state: PlayerState }) {
{entry.skillPoints === 1 ? "skill point" : "skill points"}
</span>
);
case "event":
return (
<span className={entry.good ? "text-emerald-200" : "text-amber-200"}>
{eventResultText(entry, state)}
</span>
);
case "repair":
return (
<span className="text-cyan-100">
{shipName(state, entry.shipId)}: hull repaired from {entry.fromPct}% to 100%,{" "}
{formatCredits(entry.cost)}
</span>
);
case "goal-ready":
return (
<span className="text-amber-200">

View File

@ -62,6 +62,7 @@ export function simShip(ship: Ship): ShipState {
upgrades: ship.upgrades,
inShopUntil: ship.inShopUntil,
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
hullConditionPct: ship.hullConditionPct,
};
}

View File

@ -187,6 +187,14 @@ export function DevTools() {
>
Finish shop work
</Button>
<Button
variant="secondary"
size="sm"
disabled={busy || !ship}
onClick={() => shipTool.mutate({ shipId, action: "damage-hull" })}
>
Damage hull
</Button>
</div>
<div className="flex gap-2">
<select

View File

@ -89,6 +89,8 @@ export function explainProblems(
fuel: { neededFu: number; onBoardFu: number },
gameNow: number,
now: number,
/** The ship's hull, in percent (CR-026); absent: whole. */
hullConditionPct = 100,
): string[] {
const problems: string[] = [];
// Resting blocks flying (CR-004): first, with the time left.
@ -146,6 +148,13 @@ export function explainProblems(
if (longHaul < contract.requiredLongHaulLevel) {
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
}
// A damaged hull can't carry Dangerous cargo until repaired (decision 0.5.148).
if (
contract.cargoTypeId === "dangerous" &&
hullConditionPct < content.config.hull.noDangerousBelowPct
) {
problems.push(INELIGIBLE_REASONS["hull-too-damaged"]);
}
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
if (fuel.onBoardFu < fuel.neededFu) {
problems.push(
@ -256,6 +265,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
{ neededFu: fuelFu, onBoardFu: ship.fuelFu },
gameNow,
now,
ship.hullConditionPct,
);
// In the shop (CR-009): first, with the time left.
if (isInShop(ship, now))

View File

@ -160,6 +160,7 @@ function ShipRow(props: {
</div>
)}
<FuelGauge ship={ship} />
<HullGauge ship={ship} />
{low && (
<p
role="status"
@ -174,7 +175,8 @@ function ShipRow(props: {
)}
{inShop && (
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
{ship.repairing ? "Under repair" : "In the shop"} —{" "}
<Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
</p>
)}
{ship.status === "idle" && !inShop && (
@ -369,3 +371,32 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
</Sheet>
);
}
/**
* A ship's hull condition (decision 0.5.148), with a red "Needs repair" below
* 25%: repairs are done at an HQ from the ship's Upgrades sheet.
*/
export function HullGauge({ ship }: { ship: Ship }) {
const pct = ship.hullConditionPct;
const { needsRepairBelowPct, noDangerousBelowPct } = content.config.hull;
const tone = pct < needsRepairBelowPct ? "rose" : pct < noDangerousBelowPct ? "amber" : "emerald";
return (
<div>
<div className="flex items-baseline justify-between font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<span>Hull</span>
<span className="tracking-normal normal-case">{Math.round(pct)}%</span>
</div>
<Meter label={`${ship.name} hull`} value={pct} tone={tone} size="sm" className="mt-1" />
{pct < needsRepairBelowPct && (
<p
role="status"
aria-label={`${ship.name} needs repair`}
className="mt-2 flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
>
<span className="font-display font-semibold tracking-wide uppercase">Needs repair</span>
<span className="text-rose-200/80">Repair it at an HQ</span>
</p>
)}
</div>
);
}

View File

@ -8,6 +8,8 @@ import {
validateUpgrade,
type EffectiveStats,
type UpgradeFailure,
repairCost,
repairShopMs,
} from "@deadhead/sim";
import { useState } from "react";
import { useClock } from "../clock/clock";
@ -66,6 +68,59 @@ export function upgradeEffect(ship: Ship, upgrade: Upgrade): string | null {
return `${label} ${format(before)} → ${format(after)}`;
}
/** The hull and its repair (decision 0.5.148): at an HQ, on the ground, below 100%. */
function HullRepair({ shipId, atHq }: { shipId: string; atHq: boolean }) {
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const repair = useGameAction("repairShip");
const ship = state.ships.find((s) => s.id === shipId)!;
const pct = ship.hullConditionPct;
const model = shipModel(ship.modelId);
const cost = repairCost(model, pct, content.config);
const inShop = ship.inShopUntil !== null && ship.inShopUntil > now;
const can = atHq && ship.status === "idle" && !inShop && pct < 100;
return (
<section
aria-label="Hull"
className="mt-4 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-display font-bold tracking-wide">Hull</span>
<span className="tabular-nums">{Math.round(pct)}%</span>
</div>
{pct >= 100 ? (
<p className="mt-1 text-ink-400">In perfect shape.</p>
) : can ? (
<Button
size="sm"
className="mt-2"
disabled={repair.isPending || state.company.credits < cost}
onClick={() => repair.mutate({ shipId: ship.id })}
>
Repair to 100% · {formatCredits(cost)} ·{" "}
{formatDuration(repairShopMs(pct, content.config))}
</Button>
) : (
<p className="mt-1 text-ink-400">
{ship.repairing
? "Under repair."
: !atHq
? "Repairs are done at one of your HQs."
: "The ship must be on the ground and out of the shop."}
</p>
)}
{can && state.company.credits < cost && (
<p className="mt-1 text-ink-400">Not enough credits for the repair yet.</p>
)}
{repair.isError && (
<p role="alert" className="mt-2 text-rose-300">
{errorMessage(repair.error)}
</p>
)}
</section>
);
}
/** The ship's detail view: its upgrades, the next level of each, and starting one (CR-009). */
export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: () => void }) {
const state = useLoadedPlayer();
@ -82,8 +137,9 @@ export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: ()
titleId="upgrades-title"
eyebrow={model.name}
title={ship.name}
subtitle="Upgrades are installed at an HQ. The ship stays in the shop until the work is done."
subtitle="Upgrades and hull repairs are done at an HQ. The ship stays in the shop until the work is done."
>
<HullRepair shipId={ship.id} atHq={atHq} />
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
Upgrades
</h3>

View File

@ -0,0 +1,249 @@
import { content } from "@deadhead/content";
import type { Contract, LogEntry, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { buildAwaySummary, MS_PER_HOUR, repairCost } from "@deadhead/sim";
import { screen, 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 { directText, highlightText } from "../src/components/AwaySummary";
import { pilotLogbook } from "../src/components/PilotLogbook";
import { formatCredits } from "../src/format";
import { pairOptions } from "../src/screens/DispatchSheet";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const GAME_HOUR = MS_PER_HOUR / content.config.timeCompression;
const ship = (id: string, name: string, over: Partial<Ship> = {}): Ship => ({
id,
name,
modelId: "courier",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "avatar",
lastArrivalAt: 0,
...shipExtras("courier"),
...over,
});
const rosa: Pilot = {
...samplePlayerState().pilots[0]!,
id: "p1",
name: "Rosa",
isAvatar: false,
assignedShipId: "s1",
...pilotExtras,
};
const state = (ships: Ship[], over: Partial<PlayerState> = {}) =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships,
pilots: [{ ...samplePlayerState().pilots[0]!, assignedShipId: ships[0]?.id ?? null }, rosa],
...over,
});
const event = (over: Partial<Extract<LogEntry, { kind: "event" }>>): LogEntry => ({
id: "e1",
at: NOW - 60_000,
pilotId: "p1",
shipId: "s1",
kind: "event",
dispatchId: "d1",
eventId: "debris-field",
optionId: "go-around",
good: false,
answered: false,
delayMs: 3 * GAME_HOUR,
hullLoss: 0,
payLoss: 0,
fee: 0,
bonus: 0,
priorityLost: 0,
xpBonusPct: 0,
extraFuelFu: 0,
...over,
});
describe("hull condition and event results on screen (Step 26.7, decisions 0.5.146, 0.5.148)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows a hull bar on Fleet cards, and Needs repair below 25%", async () => {
renderApp(
fakeApi({
getPlayerState: () =>
state([
ship("s1", "Dented Wren", { hullConditionPct: 20 }),
ship("s2", "Fine Kite", { hullConditionPct: 60, pilotId: null }),
]),
}),
"/fleet",
);
const dented = (await screen.findByText("Dented Wren")).closest("li")!;
expect(within(dented).getByRole("progressbar", { name: "Dented Wren hull" })).toHaveAttribute(
"aria-valuenow",
"20",
);
expect(within(dented).getByLabelText("Dented Wren needs repair")).toHaveTextContent(
"Needs repair",
);
const fine = screen.getByText("Fine Kite").closest("li")!;
expect(within(fine).queryByText("Needs repair")).not.toBeInTheDocument();
});
it("offers Repair in the ship's sheet at an HQ, with its cost and time, and calls the engine", async () => {
const api = fakeApi({
getPlayerState: () => state([ship("s1", "Dented Wren", { hullConditionPct: 60 })]),
repairShip: () => ({
ship: ship("s1", "Dented Wren", { hullConditionPct: 60, repairing: true }),
}),
});
renderApp(api, "/fleet");
const card = (await screen.findByText("Dented Wren")).closest("li")!;
await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Dented Wren" }));
const hull = within(screen.getByRole("dialog", { name: "Dented Wren" })).getByLabelText("Hull");
const cost = repairCost(content.shipModel("courier")!, 60, content.config);
const button = within(hull).getByRole("button", { name: /^Repair to 100%/ });
expect(button).toHaveTextContent(`Repair to 100% · ${formatCredits(cost)} · 20m 00s`);
await userEvent.click(button);
expect(api.call).toHaveBeenCalledWith("repairShip", { shipId: "s1" });
});
it("says repairs are done at an HQ away from one, and lists Hull Plating in the shop", async () => {
renderApp(
fakeApi({
getPlayerState: () =>
state([ship("s1", "Far Wren", { hullConditionPct: 60, locationId: "moon" })]),
}),
"/fleet",
);
const card = (await screen.findByText("Far Wren")).closest("li")!;
await userEvent.click(within(card).getByRole("button", { name: "Upgrades for Far Wren" }));
const sheet = screen.getByRole("dialog", { name: "Far Wren" });
expect(within(sheet).getByLabelText("Hull")).toHaveTextContent(
"Repairs are done at one of your HQs.",
);
const plating = within(sheet).getByRole("listitem", { name: "Hull Plating" });
const rating = content.shipModel("courier")!.hullRating;
expect(plating).toHaveTextContent(`Hull rating ${rating} → ${rating + 1}`);
});
it("gives the dispatch reason for Dangerous cargo on a damaged hull", () => {
const hazmat: Contract = {
id: "c",
originId: "earth",
destinationId: "moon",
cargoTypeId: "dangerous",
goodId: "helium-3",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 5,
payout: 1_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 600_000,
firstContact: false,
};
const [pair] = pairOptions(state([ship("s1", "Wren", { hullConditionPct: 40 })]), hazmat, NOW);
expect(pair!.problems).toContain("Hull too damaged for hazardous cargo: repair at an HQ");
const [whole] = pairOptions(state([ship("s1", "Wren", { hullConditionPct: 50 })]), hazmat, NOW);
expect(whole!.problems).not.toContain("Hull too damaged for hazardous cargo: repair at an HQ");
});
it("words event results for the summary and the Logbook, and logs repairs", () => {
const s = state([ship("s1", "Wren")]);
expect(directText(event({}), s)).toBe(
"Debris field ahead — Rosa: Go around (no answer) · +3 h",
);
expect(
directText(event({ optionId: "push-through", answered: true, delayMs: 0, hullLoss: 14 }), s),
).toBe("Debris field ahead — Rosa: Push through · hull −14%");
expect(
directText(
event({
eventId: "solar-tailwind",
optionId: "ride",
good: true,
delayMs: -1.5 * GAME_HOUR,
}),
s,
),
).toBe("Solar tailwind — Rosa: 1.5 h sooner");
const book = pilotLogbook(s, [
event({ eventId: "grateful-client", optionId: "tip", good: true, delayMs: 0, bonus: 800 }),
{ id: "r1", at: NOW, pilotId: "p1", shipId: "s1", kind: "repair", fromPct: 60, cost: 2_000 },
]);
const lines = book.pilots[0]!.entries;
expect(lines.map((e) => e.kind)).toEqual(["repair", "event"]);
});
it("ranks good outcomes and hull damage just below discoveries and level-ups", () => {
const s = state([ship("s1", "Wren")]);
const flights = Array.from({ length: 4 }, (_, i): LogEntry => ({
id: `f${i}`,
at: NOW - 30_000 + i,
pilotId: "p1",
shipId: "s1",
kind: "flight",
dispatchId: `d${i}`,
deadhead: false,
tutorial: false,
independent: true,
originId: "earth",
destinationId: "moon",
goodId: "consumer-goods",
massTonnes: 5,
shipper: "Pacific Orbital Logistics",
payout: 1_000,
pilotPay: 0,
fuelCost: 0,
loanRepayment: 0,
xp: 5,
scriptedId: null,
}));
const summary = buildAwaySummary({
from: NOW - MS_PER_HOUR,
to: NOW,
entries: [
...flights,
{
id: "g",
at: NOW - 50_000,
pilotId: null,
shipId: null,
kind: "goal-ready",
goalId: "first-own-delivery",
},
event({ id: "bad", hullLoss: 14, optionId: "push-through", answered: true, delayMs: 0 }),
event({ id: "quiet" }),
{
id: "l",
at: NOW - 40_000,
pilotId: "p1",
shipId: "s1",
kind: "level-up",
level: 2,
skillPoints: 1,
},
],
pilots: [{ id: "p1", name: "Rosa", now: "At Moon" }],
problems: [],
});
// The detour without harm isn't a highlight; the hull hit is, after the level-up.
expect(summary.highlights.map((h) => h.kind)).toEqual(["level-up", "event", "goal-ready"]);
expect(highlightText(summary.highlights[1]!, s)).toBe(
"Debris field ahead — Rosa: Push through · hull −14%",
);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -553,7 +553,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
- [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).
- [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
- [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
- [ ] Step 26.9 — Balance
- [ ] Step 26.10 — Plan amendments and regression

View File

@ -152,6 +152,10 @@ export function commandTable(): CommandTable {
// A smaller tank holds no more than it can (CR-015).
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
break;
case "damage-hull":
// To try repairs and the "Needs repair" warning (CR-026).
ship.hullConditionPct = 20;
break;
case "finish-shop":
// Done now: the "out of the shop" note still shows, as for a finished job.
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;

View File

@ -292,3 +292,14 @@ describe("repairs (Step 26.5, decision 0.5.148)", () => {
});
});
});
describe("developer tools: damage a hull (Step 26.7)", () => {
it("drops a ship's hull to 20% to try repairs", async () => {
const test = newTestApp();
const owner = await signUpOwner(test.app);
await test.app.call("devShip", { shipId: owner.shipId, action: "damage-hull" });
expect(
(await readSave(test.app)).ships.find((s) => s.id === owner.shipId)!.hullConditionPct,
).toBe(20);
});
});

View File

@ -157,7 +157,7 @@ export const devPilotRequestSchema = z.object({
export const devShipRequestSchema = z.discriminatedUnion("action", [
z.object({
shipId: idSchema,
action: z.enum(["max-upgrades", "reset-upgrades", "finish-shop"]),
action: z.enum(["max-upgrades", "reset-upgrades", "finish-shop", "damage-hull"]),
}),
z.object({ shipId: idSchema, action: z.literal("move"), locationId: idSchema }),
]);

View File

@ -83,6 +83,8 @@ export type AwayProblem =
export type AwayHighlight =
| { kind: "discovery"; locationId: string; pilotId: string | null }
| { kind: "level-up"; pilotId: string | null; level: number; skillPoints: number }
/** A good event, or one that hurt a hull (CR-026 §5): just below level-ups. */
| { kind: "event"; entry: Extract<LogbookEntry, { kind: "event" }> }
| { kind: "goal-ready"; goalId: string }
| { kind: "scripted"; scriptedId: string }
| {
@ -119,7 +121,8 @@ export interface AwaySummary {
idlePilots: number;
/**
* A short absence (3 events or fewer: flights, discoveries, level-ups,
* finished upgrades): those entries, oldest first, to list directly; else null.
* finished upgrades): those entries and the trips' events (CR-026), oldest
* first, to list directly; else null.
*/
direct: LogbookEntry[] | null;
}
@ -139,7 +142,12 @@ export const MAX_PILOT_LINES = 5;
export const SHORT_ABSENCE_EVENTS = 3;
const EVENT_KINDS = new Set<LogbookEntry["kind"]>(["flight", "discovery", "level-up", "upgrade"]);
const LISTED_DIRECTLY = new Set<AwayHighlight["kind"]>(["discovery", "level-up", "best-job"]);
const LISTED_DIRECTLY = new Set<AwayHighlight["kind"]>([
"discovery",
"level-up",
"best-job",
"event",
]);
/**
* "While you were away" from the logbook (decision 0.5.140): totals, at most 3
@ -190,6 +198,10 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
level: l.level,
skillPoints: l.skillPoints,
})),
// Good outcomes and hull damage rank just below level-ups (CR-026 §5).
...of("event")
.filter((e) => e.good || e.hullLoss > 0)
.map((entry) => ({ kind: "event" as const, entry })),
...of("goal-ready").map((g) => ({ kind: "goal-ready" as const, goalId: g.goalId })),
...flights.flatMap((f) =>
f.scriptedId ? [{ kind: "scripted" as const, scriptedId: f.scriptedId }] : [],
@ -227,7 +239,11 @@ export function buildAwaySummary(input: AwayInput): AwaySummary {
const rest = lines.slice(MAX_PILOT_LINES);
const events = entries.filter((e) => EVENT_KINDS.has(e.kind));
const direct = events.length <= SHORT_ABSENCE_EVENTS ? events : null;
// A short absence also lists its trips' events (CR-026), which don't count towards the 3.
const direct =
events.length <= SHORT_ABSENCE_EVENTS
? entries.filter((e) => EVENT_KINDS.has(e.kind) || e.kind === "event")
: null;
// A short absence lists discoveries, level-ups and jobs itself: not again as highlights.
const shown = direct ? highlights.filter((h) => !LISTED_DIRECTLY.has(h.kind)) : highlights;
return {