Step 19.4: client: cards and empty state
CI / check (push) Failing after 6m39s Details

Contract cards drop the Too heavy and Too slow badges; an empty board
says no job fits the fleet, with the refresh countdown (decision 0.5.114).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 20:56:28 -04:00
parent dbdca56413
commit 2b201e2841
13 changed files with 129 additions and 156 deletions

View File

@ -1644,3 +1644,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **When every ship leaves a location, its board falls back to the whole fleet** (decision 0.5.115), so its jobs show again for deciding where to go. That is why the hiding test keeps the Courier at Earth. - **When every ship leaves a location, its board falls back to the whole fleet** (decision 0.5.115), so its jobs show again for deciding where to go. That is why the hiding test keeps the Courier at Earth.
- **Dispatch doesn't consult the filter**: a contract hidden from the board fails `validateDispatch` for every pair anyway, with its reason. - **Dispatch doesn't consult the filter**: a contract hidden from the board fails `validateDispatch` for every pair anyway, with its reason.
- **Discarding boards mid-period can re-use contract ids**: a regenerated board numbers its jobs from 0 again, so a job accepted before the update hides the regenerated job with its id for the rest of that period. That lasts at most one 10-minute period after the update. - **Discarding boards mid-period can re-use contract ids**: a regenerated board numbers its jobs from 0 again, so a job accepted before the update hides the regenerated job with its id for the rest of that period. That lasts at most one 10-minute period after the update.
## Step 19.4
- **"Needs Heavy Cargo 2" stays on cards**: it describes the job, as distance and mass do, and every job shown can be taken by someone. Only the rose problem pills and the red mass went.
- **One empty-board message for both cases**: a board emptied by filtering and one whose jobs were all taken read the same, "No jobs here your fleet can take right now", with the countdown. The client can't tell the two apart, and the advice is the same.
- **The "No idle ships here" hint above the board is unchanged**; on an empty board it still points to the Fleet screen.

View File

@ -1,9 +1,8 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Contract, PlayerState } from "@deadhead/schema"; import type { Contract } from "@deadhead/schema";
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim"; import { MS_PER_HOUR } from "@deadhead/sim";
import { formatGameHoursSpan } from "../clock/timeFormat"; import { formatGameHoursSpan } from "../clock/timeFormat";
import { usePreferences } from "../state/preferences"; import { usePreferences } from "../state/preferences";
import { shipStatsOf } from "../game";
import { cargoName, formatDuration } from "../format"; import { cargoName, formatDuration } from "../format";
import { Pill, type Tone } from "../ui"; import { Pill, type Tone } from "../ui";
@ -58,19 +57,3 @@ export function PriorityChip({ windowHours }: { windowHours: number }) {
</span> </span>
); );
} }
/**
* True when a Priority contract has idle ships at its origin and none of them
* can make the window (CR-007). Display only; the sim decides at dispatch.
*/
export function tooSlowForAll(state: PlayerState, contract: Contract): boolean {
if (!contract.priority || contract.deliveryWindowGameHours === null) return false;
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
if (idle.length === 0) return false;
// Effective stats: an engines upgrade can make the window (CR-009).
return idle.every(
(ship) =>
contractTripGameHours(shipStatsOf(ship), contract, content) >
contract.deliveryWindowGameHours!,
);
}

View File

@ -6,14 +6,14 @@ import { useApi } from "../api/context";
import { serverNow, useClock } from "../clock/clock"; import { serverNow, useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format"; import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format";
import { distanceAt, shipStatsOf } from "../game"; import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet"; import { pairOptions } from "./DispatchSheet";
import { DeadheadPicker } from "./Fleet"; import { DeadheadPicker } from "./Fleet";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { RouteStrip } from "../components/RouteStrip"; import { RouteStrip } from "../components/RouteStrip";
import { Art, Button, Icon, Panel, Pill } from "../ui"; import { Art, Button, Icon, Panel, Pill } from "../ui";
import { ClassChip, goodName, PriorityChip, tooSlowForAll } from "../components/Goods"; import { ClassChip, goodName, PriorityChip } from "../components/Goods";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const; export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
@ -70,19 +70,6 @@ export function requirementsText(contract: Contract): string | null {
return parts.length > 0 ? parts.join(", ") : null; return parts.length > 0 ? parts.join(", ") : null;
} }
/**
* The most a player's ship can carry for a board at this location: the largest
* ship here, or in the whole fleet when none are here; null with no ships
* (decision 0.5.53).
*/
export function carryLimit(state: PlayerState, locationId: string): number | null {
const here = state.ships.filter((s) => s.locationId === locationId);
const ships = here.length > 0 ? here : state.ships;
if (ships.length === 0) return null;
// Effective capacity: an expanded cargo bay carries more (CR-009).
return Math.max(...ships.map((s) => shipStatsOf(s).cargoCapacityTonnes));
}
/** /**
* The location the Contracts screen opens on: where the most recently arrived * The location the Contracts screen opens on: where the most recently arrived
* idle ship is, otherwise Earth (CR-002). * idle ship is, otherwise Earth (CR-002).
@ -156,9 +143,7 @@ function LocationBoard({
onSelect?: (contract: Contract) => void; onSelect?: (contract: Contract) => void;
}) { }) {
const api = useApi(); const api = useApi();
const state = useLoadedPlayer();
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const limit = carryLimit(state, locationId);
const board = useQuery({ const board = useQuery({
queryKey: locationBoardKey(locationId), queryKey: locationBoardKey(locationId),
queryFn: () => api.call("getContractBoard", { locationId }), queryFn: () => api.call("getContractBoard", { locationId }),
@ -202,14 +187,18 @@ function LocationBoard({
</p> </p>
); );
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />; const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
// Boards show only jobs the fleet can complete (CR-019), so an empty one says so.
if (board.data.contracts.length === 0) { if (board.data.contracts.length === 0) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{refresh} <Panel tone="muted" role="status" className="text-center text-sm text-ink-300">
{stuck} No jobs here your fleet can take right now. New contracts in{" "}
<Panel tone="muted" className="text-center text-sm text-ink-400"> <span className="font-display text-cyan-200 tabular-nums">
No contracts left this period. <Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</span>
.
</Panel> </Panel>
{stuck}
</div> </div>
); );
} }
@ -221,7 +210,6 @@ function LocationBoard({
<ul className="grid gap-3 sm:grid-cols-2"> <ul className="grid gap-3 sm:grid-cols-2">
{board.data.contracts.map((contract) => { {board.data.contracts.map((contract) => {
const requirements = requirementsText(contract); const requirements = requirementsText(contract);
const tooHeavy = limit !== null && contract.massTonnes > limit;
return ( return (
<Panel <Panel
as="li" as="li"
@ -257,16 +245,6 @@ function LocationBoard({
First contact First contact
</Pill> </Pill>
)} )}
{tooHeavy && (
<Pill tone="rose" icon="alert">
Too heavy
</Pill>
)}
{tooSlowForAll(state, contract) && (
<Pill tone="rose" icon="clock">
Too slow
</Pill>
)}
</div> </div>
</div> </div>
<RouteStrip <RouteStrip
@ -277,11 +255,8 @@ function LocationBoard({
<div className="flex items-center gap-1.5 text-sm text-ink-300"> <div className="flex items-center gap-1.5 text-sm text-ink-300">
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" /> <Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
<span> <span>
{contract.shipper} ·{" "} {contract.shipper} · <span>{contract.massTonnes} t</span> ·{" "}
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}> {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
{contract.massTonnes} t
</span>{" "}
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
</span> </span>
</div> </div>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm"> <div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">

View File

@ -0,0 +1,101 @@
import type { Contract, PlayerState, Ship } from "@deadhead/schema";
import { screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const PERIOD_ENDS = NOW + 4 * 60_000 + 5_000;
const job = (overrides: Partial<Contract>): Contract => ({
id: "j",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 6,
payout: 1_000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: PERIOD_ENDS,
firstContact: false,
...overrides,
});
const courier: Ship = {
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "avatar",
lastArrivalAt: 0,
...shipExtras("courier"),
};
function owner(): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
...base,
bays: [{ id: "b", locationId: "earth" }],
ships: [courier],
pilots: [{ ...base.pilots[0]!, assignedShipId: "s1", locationId: null }],
};
}
function show(contracts: Contract[]) {
renderApp(
fakeApi({
getPlayerState: owner,
getContractBoard: () => ({ contracts, periodEndsAt: PERIOD_ENDS }),
}),
"/contracts?location=earth",
);
}
describe("board cards and the empty board (Step 19.4, decision 0.5.114)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows no permanent-problem badge on any card", async () => {
// Jobs a lone level-0 Courier couldn't take: the engine filters them out,
// and even if one reached a card it carries no problem badge.
show([
job({ id: "heavy", massTonnes: 300, payout: 9_001 }),
job({ id: "rushed", priority: true, deliveryWindowGameHours: 0.01, payout: 9_002 }),
job({
id: "skilled",
cargoTypeId: "heavy",
goodId: "machine-parts",
tier: 3,
requiredSkill: { skillId: "heavy-cargo", level: 3 },
payout: 9_003,
}),
]);
for (const payout of ["9,001 cr", "9,002 cr", "9,003 cr"]) {
const card = (await screen.findByText(payout)).closest("li")!;
expect(card).not.toHaveTextContent(/too heavy/i);
expect(card).not.toHaveTextContent(/too slow/i);
expect(card).not.toHaveTextContent(/skill too low/i);
}
});
it("says no job fits the fleet, with the refresh countdown, Rest and Deadhead", async () => {
show([]);
const empty = await screen.findByText(/No jobs here your fleet can take right now/);
expect(empty).toHaveTextContent("New contracts in 04:05.");
const stuck = screen.getByRole("group", { name: "Nothing for Dusty Mule" });
expect(within(stuck).getByRole("button", { name: /^Rest / })).toBeInTheDocument();
expect(within(stuck).getByRole("button", { name: "Deadhead (fly empty)" })).toBeInTheDocument();
});
});

View File

@ -81,10 +81,4 @@ describe("contract card (CR-007)", () => {
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`); expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
expect(c).not.toHaveTextContent("Too slow"); expect(c).not.toHaveTextContent("Too slow");
}); });
it("marks a Priority job too slow when no idle ship here can make the window", async () => {
const tight = job({ priority: true, deliveryWindowGameHours: 0.5 });
render([tight], [ship("bulk-hauler")]);
expect(await card("3,210 cr")).toHaveTextContent("Too slow");
});
}); });

View File

@ -84,15 +84,6 @@ describe("effective stats in the client (Step 10I.7)", () => {
}); });
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it("drops the Too heavy badge once the cargo bay is expanded", async () => {
const { unmount } = renderApp(api({}), "/contracts?location=earth");
expect((await screen.findByText("1,234 cr")).closest("li")!).toHaveTextContent("Too heavy");
unmount();
renderApp(api({ "cargo-bay": 1 }), "/contracts?location=earth");
expect((await screen.findByText("1,234 cr")).closest("li")!).not.toHaveTextContent("Too heavy");
});
it("lets the deadhead picker reach Mars only with upgraded fuel tanks", async () => { it("lets the deadhead picker reach Mars only with upgraded fuel tanks", async () => {
const distance = routeDistance(content.location("earth")!, content.location("mars")!); const distance = routeDistance(content.location("earth")!, content.location("mars")!);
// Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map. // Between a level-2 Courier's reach (145) and a maxed one's (215) on the fixed map.

View File

@ -74,7 +74,9 @@ describe("load errors", () => {
const alert = await screen.findByText("Could not load contracts.", { exact: false }); const alert = await screen.findByText("Could not load contracts.", { exact: false });
online = true; online = true;
await userEvent.click(within(alert).getByRole("button", { name: "Retry" })); await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
expect(await screen.findByText(/No contracts left this period/)).toBeInTheDocument(); expect(
await screen.findByText(/No jobs here your fleet can take right now/),
).toBeInTheDocument();
}); });
}); });
@ -101,13 +103,15 @@ describe("empty states", () => {
expect(await screen.findByText(/No idle ships here/)).toBeInTheDocument(); expect(await screen.findByText(/No idle ships here/)).toBeInTheDocument();
}); });
it("Contracts: an empty bay board says to check back", async () => { it("Contracts: an empty board says no job fits the fleet, and when new ones come (CR-019)", async () => {
show( show(
"/contracts", "/contracts",
{ ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] }, { ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] },
{ getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }) }, { getContractBoard: () => ({ contracts: [], periodEndsAt: 0 }) },
); );
expect(await screen.findByText(/No contracts left this period/)).toBeInTheDocument(); expect(
await screen.findByText(/No jobs here your fleet can take right now/),
).toBeInTheDocument();
}); });
it("Contracts: an empty tutorial board says to check back", async () => { it("Contracts: an empty tutorial board says to check back", async () => {

View File

@ -1,81 +0,0 @@
import type { Contract, Ship } from "@deadhead/schema";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { carryLimit } from "../src/screens/LocationBoards";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.now();
const contract = (id: string, massTonnes: number): Contract => ({
id,
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes,
payout: 1000 + massTonnes,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 30 * 60_000,
firstContact: false,
});
const ship = (id: string, overrides: Partial<Ship>): Ship => ({
id,
modelId: "courier",
name: id,
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
...overrides,
});
const owner = (ships: Ship[]) =>
samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ships });
const render = (ships: Ship[]) =>
renderApp(
fakeApi({
getPlayerState: () => owner(ships),
getContractBoard: () => ({
contracts: [contract("light", 10), contract("heavy", 50)],
periodEndsAt: NOW + 600_000,
}),
}),
"/contracts?location=earth",
);
const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!;
describe("weight warning (Step 10F.1)", () => {
it("flags a job heavier than every ship at the location", async () => {
render([ship("Dusty Mule", {})]);
expect(await card("1,050 cr")).toHaveTextContent("Too heavy");
expect(await card("1,010 cr")).not.toHaveTextContent("Too heavy");
});
it("uses the ships at the location, not elsewhere", async () => {
render([
ship("Dusty Mule", { locationId: "moon" }),
ship("Big Ox", { modelId: "freighter", locationId: "earth" }),
]);
expect(await card("1,050 cr")).not.toHaveTextContent("Too heavy");
});
it("falls back to the whole fleet when no ship is at the location", () => {
const state = owner([
ship("a", { locationId: "moon" }),
ship("b", { modelId: "freighter", locationId: null, status: "in-flight" }),
]);
expect(carryLimit(state, "earth")).toBe(100);
expect(carryLimit(owner([]), "earth")).toBeNull();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

View File

@ -443,5 +443,5 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C
- [x] **Step 19.1 — Sim: completability check.** `packages/sim/src/completable.ts`: `permanentFailure(contract, member, discoveredIds, content)` gives a pair's first failing permanent check: over-capacity, cannot-land, out-of-range, would-strand, too-slow, cargo-skill-too-low, long-haul-too-low. It uses the same rules and order as `validateDispatch`, with the tank counted full where the origin sells fuel and the fuel on board where it doesn't. `completability` returns whether any pair passes plus each pair's failure; `isCompletable` is the shorthand. `qualifyingFleet` builds the pairs for a location (ships there or landing before the period ends, with their pilot or any unassigned pilot there if the player has a bay there; else the whole fleet). `FleetMember` (now with `fuelFu`) moved here from `board.ts`. Tests (`packages/sim/test/completable.test.ts`): one per permanent check (landing and stranding via content with a hardened Earth and a dry Mars); a dry origin counts only the fuel on board; Cargo Bay upgrades count; any one pair passing makes a job completable, with per-pair failures; rest, the shop and an empty tank block dispatch but not completability; the qualifying fleet's local pairs, ships landing before and after the period ends, spare pilots only with a bay, the whole-fleet fallback, and upgrades and fuel carried into the snapshot. - [x] **Step 19.1 — Sim: completability check.** `packages/sim/src/completable.ts`: `permanentFailure(contract, member, discoveredIds, content)` gives a pair's first failing permanent check: over-capacity, cannot-land, out-of-range, would-strand, too-slow, cargo-skill-too-low, long-haul-too-low. It uses the same rules and order as `validateDispatch`, with the tank counted full where the origin sells fuel and the fuel on board where it doesn't. `completability` returns whether any pair passes plus each pair's failure; `isCompletable` is the shorthand. `qualifyingFleet` builds the pairs for a location (ships there or landing before the period ends, with their pilot or any unassigned pilot there if the player has a bay there; else the whole fleet). `FleetMember` (now with `fuelFu`) moved here from `board.ts`. Tests (`packages/sim/test/completable.test.ts`): one per permanent check (landing and stranding via content with a hardened Earth and a dry Mars); a dry origin counts only the fuel on board; Cargo Bay upgrades count; any one pair passing makes a job completable, with per-pair failures; rest, the shop and an empty tank block dispatch but not completability; the qualifying fleet's local pairs, ships landing before and after the period ends, spare pilots only with a bay, the whole-fleet fallback, and upgrades and fuel carried into the snapshot.
- [x] **Step 19.2 — Sim: generation keeps only completable jobs.** `generateContractBoard` with a fleet draws candidates slot by slot (CR-006's band plan), round and round. It keeps those `isCompletable` for the fleet, numbered in order kept, until the board is full or `config.boardMaxAttempts` (200) candidates have been tried; first contacts count only when kept. Without a fleet it is unfiltered as before (tools and the class-mix baseline). The balance simulator keeps a fuel reserve big enough to fill every landed ship before buying another; one world had stranded a Clipper at Venus after a purchase. The fuel-share check's pilot is skilled in everything: only the ship limits what fits, and now no route is above 25%, the Clipper's Mars → Earth included. Balance medians: deal 3.7 min, first purchase 36.7, discovery 36.6, loan 123.3, no violations. Tests (`packages/sim/test/board-completable.test.ts`): 1,000 boards over five assorted fleets and five origins never offer an uncompletable job; a level-0 avatar's Courier at Earth always gets 8 Standard Moon jobs it fits; a Courier with only Earth known gets short boards of Moon first contacts, without error; identical inputs give identical boards; first contacts are filtered too. The CR-006 tests now expect an empty board for an empty fleet, full Courier boards with no previews, and band counts kept for a fleet that can do everything. `board-goods.test.ts`: a full-capability fleet filters nothing. The Venus board test gives its pilot Long Haul 1. - [x] **Step 19.2 — Sim: generation keeps only completable jobs.** `generateContractBoard` with a fleet draws candidates slot by slot (CR-006's band plan), round and round. It keeps those `isCompletable` for the fleet, numbered in order kept, until the board is full or `config.boardMaxAttempts` (200) candidates have been tried; first contacts count only when kept. Without a fleet it is unfiltered as before (tools and the class-mix baseline). The balance simulator keeps a fuel reserve big enough to fill every landed ship before buying another; one world had stranded a Clipper at Venus after a purchase. The fuel-share check's pilot is skilled in everything: only the ship limits what fits, and now no route is above 25%, the Clipper's Mars → Earth included. Balance medians: deal 3.7 min, first purchase 36.7, discovery 36.6, loan 123.3, no violations. Tests (`packages/sim/test/board-completable.test.ts`): 1,000 boards over five assorted fleets and five origins never offer an uncompletable job; a level-0 avatar's Courier at Earth always gets 8 Standard Moon jobs it fits; a Courier with only Earth known gets short boards of Moon first contacts, without error; identical inputs give identical boards; first contacts are filtered too. The CR-006 tests now expect an empty board for an empty fleet, full Courier boards with no previews, and band counts kept for a fleet that can do everything. `board-goods.test.ts`: a full-capability fleet filters nothing. The Venus board test gives its pilot Long Haul 1.
- [x] **Step 19.3 — Engine: boards, filtering, and migration.** `boardFleet(save, locationId, periodEndsAt)` in `packages/engine/src/game/boards.ts` (replacing `playerFleet`) builds the qualifying fleet from the save: ships in creation order with upgrades, fuel, and the destination and arrival of a flying ship, plus pilots and bays. A location board is generated for it and stores it as `fleet` on the saved board (`fleetMemberSchema`; null for tutorial and hand-made boards). Every read hides jobs the current qualifying fleet can't complete. Save version 4: the 3 → 4 migration drops stored boards. Also fixes Step 19.2's unformatted DECISIONS.md line. Tests (`packages/engine/test/completable-boards.test.ts`): a Freighter's heavy jobs vanish from Earth's board once it deadheads to the Moon, leaving the Courier's jobs unchanged; a Freighter bought mid-period adds nothing until the next period, then heavy jobs appear; the stored board carries the fleet snapshot; a version-3 save opens with no boards and regenerates the same board on view. `packages/schema/test/save.test.ts`: 3 → 4 drops boards and changes nothing else. - [x] **Step 19.3 — Engine: boards, filtering, and migration.** `boardFleet(save, locationId, periodEndsAt)` in `packages/engine/src/game/boards.ts` (replacing `playerFleet`) builds the qualifying fleet from the save: ships in creation order with upgrades, fuel, and the destination and arrival of a flying ship, plus pilots and bays. A location board is generated for it and stores it as `fleet` on the saved board (`fleetMemberSchema`; null for tutorial and hand-made boards). Every read hides jobs the current qualifying fleet can't complete. Save version 4: the 3 → 4 migration drops stored boards. Also fixes Step 19.2's unformatted DECISIONS.md line. Tests (`packages/engine/test/completable-boards.test.ts`): a Freighter's heavy jobs vanish from Earth's board once it deadheads to the Moon, leaving the Courier's jobs unchanged; a Freighter bought mid-period adds nothing until the next period, then heavy jobs appear; the stored board carries the fleet snapshot; a version-3 save opens with no boards and regenerates the same board on view. `packages/schema/test/save.test.ts`: 3 → 4 drops boards and changes nothing else.
- [ ] Step 19.4 — Client: cards and empty state - [x] **Step 19.4 — Client: cards and empty state.** Contract cards lose the "Too heavy" and "Too slow" pills and the red mass (`carryLimit` and `tooSlowForAll` removed, with the CR-006 weight-warning test); the "Needs Heavy Cargo 2" requirement line stays, as it describes the job rather than a problem. The dispatch sheet keeps its per-pair reasons. An empty board says "No jobs here your fleet can take right now. New contracts in mm:ss.", above the existing Rest and Deadhead panel for each idle ship there. Screenshots: `docs/screenshots/19-4-earth-board-desktop.png`, `19-4-earth-board-phone.png`, `19-4-empty-board-desktop.png`, `19-4-empty-board-phone.png` (Mars after the deal: no pilot has Long Haul for its legs). Tests (`apps/web/test/board-cards.test.tsx`): heavy, rushed and skilled jobs show no problem badge; an empty board shows the message with the countdown, plus Rest and Deadhead for the idle Courier. The badge tests in `contract-card.test.tsx` and `effective-stats-ui.test.tsx` are gone, and `states.test.tsx` expects the new message.
- [ ] Step 19.5 — Plan amendments and regression - [ ] Step 19.5 — Plan amendments and regression