- {contract.shipper} ·{" "}
-
- {contract.massTonnes} t
- {" "}
- · {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
+ {contract.shipper} · {contract.massTonnes} t ·{" "}
+ {formatMillionKm(distanceAt(contract.originId, contract.destinationId))}
diff --git a/apps/web/test/board-cards.test.tsx b/apps/web/test/board-cards.test.tsx
new file mode 100644
index 0000000..d248f8b
--- /dev/null
+++ b/apps/web/test/board-cards.test.tsx
@@ -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 => ({
+ 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();
+ });
+});
diff --git a/apps/web/test/contract-card.test.tsx b/apps/web/test/contract-card.test.tsx
index 89b861d..5723c97 100644
--- a/apps/web/test/contract-card.test.tsx
+++ b/apps/web/test/contract-card.test.tsx
@@ -81,10 +81,4 @@ describe("contract card (CR-007)", () => {
expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`);
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");
- });
});
diff --git a/apps/web/test/effective-stats-ui.test.tsx b/apps/web/test/effective-stats-ui.test.tsx
index f015d11..35c0c81 100644
--- a/apps/web/test/effective-stats-ui.test.tsx
+++ b/apps/web/test/effective-stats-ui.test.tsx
@@ -84,15 +84,6 @@ describe("effective stats in the client (Step 10I.7)", () => {
});
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 () => {
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.
diff --git a/apps/web/test/states.test.tsx b/apps/web/test/states.test.tsx
index 7babf8d..25f5a39 100644
--- a/apps/web/test/states.test.tsx
+++ b/apps/web/test/states.test.tsx
@@ -74,7 +74,9 @@ describe("load errors", () => {
const alert = await screen.findByText("Could not load contracts.", { exact: false });
online = true;
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();
});
- 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(
"/contracts",
{ ...afterTutorial, bays: [{ id: "b", locationId: "earth" }] },
{ 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 () => {
diff --git a/apps/web/test/weight-warning.test.tsx b/apps/web/test/weight-warning.test.tsx
deleted file mode 100644
index a669538..0000000
--- a/apps/web/test/weight-warning.test.tsx
+++ /dev/null
@@ -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 => ({
- 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();
- });
-});
diff --git a/docs/screenshots/19-4-earth-board-desktop.png b/docs/screenshots/19-4-earth-board-desktop.png
new file mode 100644
index 0000000..227faac
Binary files /dev/null and b/docs/screenshots/19-4-earth-board-desktop.png differ
diff --git a/docs/screenshots/19-4-earth-board-phone.png b/docs/screenshots/19-4-earth-board-phone.png
new file mode 100644
index 0000000..0f4267d
Binary files /dev/null and b/docs/screenshots/19-4-earth-board-phone.png differ
diff --git a/docs/screenshots/19-4-empty-board-desktop.png b/docs/screenshots/19-4-empty-board-desktop.png
new file mode 100644
index 0000000..fb5628c
Binary files /dev/null and b/docs/screenshots/19-4-empty-board-desktop.png differ
diff --git a/docs/screenshots/19-4-empty-board-phone.png b/docs/screenshots/19-4-empty-board-phone.png
new file mode 100644
index 0000000..78b346b
Binary files /dev/null and b/docs/screenshots/19-4-empty-board-phone.png differ
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index ff61894..400a1ae 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -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.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.
-- [ ] 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