From 962c0601c81a163a517183709b56dec5be0a92e0 Mon Sep 17 00:00:00 2001
From: fireshaper
Date: Sat, 3 Oct 2026 15:08:48 -0400
Subject: [PATCH] Step 10B.7: Contracts screen by location with idle-ship
badges
Co-Authored-By: Claude Opus 5.5
---
DECISIONS.md | 7 +
apps/server/src/game/fleet.ts | 1 +
apps/server/src/game/player-state.ts | 9 +-
apps/server/test/last-arrival.test.ts | 34 ++++
apps/web/src/screens/Contracts.tsx | 6 +-
apps/web/src/screens/DispatchSheet.tsx | 2 +-
apps/web/src/screens/Fleet.tsx | 8 +
.../{BayBoards.tsx => LocationBoards.tsx} | 91 ++++++-----
apps/web/src/screens/MapScreen.tsx | 2 +-
apps/web/test/away-goals.test.tsx | 1 +
apps/web/test/away-summary.test.tsx | 1 +
apps/web/test/bay-boards.test.tsx | 107 ------------
apps/web/test/deal-modal.test.tsx | 1 +
apps/web/test/dispatch-problems.test.tsx | 1 +
apps/web/test/dispatch-sheet.test.tsx | 1 +
apps/web/test/fleet.test.tsx | 1 +
apps/web/test/hq.test.tsx | 1 +
apps/web/test/location-boards.test.tsx | 153 ++++++++++++++++++
apps/web/test/map-interactions.test.tsx | 7 +-
apps/web/test/map-scene.test.ts | 3 +
apps/web/test/market.test.tsx | 1 +
apps/web/test/pilots.test.tsx | 1 +
apps/web/test/states.test.tsx | 4 +-
memory-bank/architecture.md | 4 +-
memory-bank/progress.md | 2 +-
packages/schema/src/entities.ts | 5 +
packages/schema/test/schemas.test.ts | 1 +
27 files changed, 297 insertions(+), 158 deletions(-)
create mode 100644 apps/server/test/last-arrival.test.ts
rename apps/web/src/screens/{BayBoards.tsx => LocationBoards.tsx} (59%)
delete mode 100644 apps/web/test/bay-boards.test.tsx
create mode 100644 apps/web/test/location-boards.test.tsx
diff --git a/DECISIONS.md b/DECISIONS.md
index 90a22c5..48c785d 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -649,3 +649,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Another player's bay id is `404 not-found` ("You have no such bay")**, the same as an id that does not exist, so ids of other players' bays are not confirmed. It is checked after the model and before any credits are spent.
- **`SHIPYARD_LOCATION_ID` is gone:** the delivery location is always the chosen bay's.
- **Interim client:** the Market buys for the player's first bay (always Earth today) so the build stays green; Step 10B.9 replaces this with the "Deliver to which HQ?" picker.
+
+## Step 10B.7
+
+- **`Ship.lastArrivalAt` is never null:** the latest resolved arrival of that ship, else its delivery time (`created_at`). A newly bought ship counts as having just arrived at its bay, which is what the default-location rule wants.
+- **The selector is a wrapping row of location buttons (`aria-pressed`), not a `
{hasBay ? (
View contract board
diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx
index 0f8e8de..6afd7b9 100644
--- a/apps/web/test/away-goals.test.tsx
+++ b/apps/web/test/away-goals.test.tsx
@@ -17,6 +17,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
status: "idle",
locationId: "moon",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: "avatar",
},
],
diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx
index b3773a6..0bbc557 100644
--- a/apps/web/test/away-summary.test.tsx
+++ b/apps/web/test/away-summary.test.tsx
@@ -11,6 +11,7 @@ const ship: Ship = {
status: "idle",
locationId: "mars",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: "avatar",
};
diff --git a/apps/web/test/bay-boards.test.tsx b/apps/web/test/bay-boards.test.tsx
deleted file mode 100644
index 2a59310..0000000
--- a/apps/web/test/bay-boards.test.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import type { Contract } from "@deadhead/schema";
-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 { fakeApi, renderApp, samplePlayerState } from "./render";
-
-const NOW = Date.parse("2100-01-01T00:00:00Z");
-
-const contract = (overrides: Partial): Contract => ({
- id: "c",
- originId: "earth",
- destinationId: "moon",
- cargoTypeId: "standard",
- tier: 0,
- massTonnes: 50,
- payout: 2000,
- requiredSkill: null,
- requiredLongHaulLevel: 0,
- offeredAt: NOW,
- expiresAt: NOW + 30 * 60_000,
- firstContact: false,
- ...overrides,
-});
-
-const boards: Record = {
- earth: [
- contract({ id: "e1", payout: 1999 }),
- contract({
- id: "e2",
- destinationId: "mars",
- cargoTypeId: "heavy",
- tier: 2,
- requiredSkill: { skillId: "heavy-cargo", level: 2 },
- requiredLongHaulLevel: 1,
- firstContact: true,
- payout: 88_000,
- }),
- ],
- moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })],
-};
-
-const owner = () =>
- samplePlayerState({
- tutorial: null,
- bays: [
- { id: "b1", locationId: "earth" },
- { id: "b2", locationId: "moon" },
- ],
- });
-
-describe("bay contract boards", () => {
- beforeEach(() => {
- vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
- vi.setSystemTime(NOW);
- useClock.setState({ offsetMs: 0, synced: true, now: NOW });
- });
- afterEach(() => vi.useRealTimers());
-
- it("shows the first bay's board, and switching bays shows that bay's board", async () => {
- const api = fakeApi({
- getPlayerState: owner,
- getContractBoard: ({ locationId }) => ({
- contracts: boards[locationId] ?? [],
- periodEndsAt: NOW + 30 * 60_000,
- }),
- });
- renderApp(api, "/contracts");
-
- expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
- expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
-
- await userEvent.selectOptions(screen.getByLabelText("Bay"), "moon");
- expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
- expect(screen.getByText("Moon → Earth")).toBeInTheDocument();
- expect(screen.queryByText("1,999 cr")).not.toBeInTheDocument();
- });
-
- it("shows contract details, requirements, expiry and the first-contact badge", async () => {
- const api = fakeApi({
- getPlayerState: owner,
- getContractBoard: () => ({ contracts: boards.earth!, periodEndsAt: NOW + 30 * 60_000 }),
- });
- renderApp(api, "/contracts");
-
- const items = await screen.findAllByRole("listitem");
- const [plain, special] = items;
- expect(plain).toHaveTextContent("Standard · 50 t");
- expect(plain).toHaveTextContent("No skills needed");
- expect(plain).toHaveTextContent("expires in 30m 00s");
- expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
-
- expect(special).toHaveTextContent("Earth → Mars");
- expect(special).toHaveTextContent("Heavy (tier 2)");
- expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
- expect(special).toHaveTextContent(/M km/);
- expect(within(special!).getByText("First contact")).toBeInTheDocument();
- });
-
- it("explains how to get contracts when the player has no bays", async () => {
- renderApp(
- fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }),
- "/contracts",
- );
- expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
- });
-});
diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx
index ee0279a..bff8e3b 100644
--- a/apps/web/test/deal-modal.test.tsx
+++ b/apps/web/test/deal-modal.test.tsx
@@ -32,6 +32,7 @@ const afterDeal = () =>
status: "idle",
locationId: "earth",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: "avatar",
},
],
diff --git a/apps/web/test/dispatch-problems.test.tsx b/apps/web/test/dispatch-problems.test.tsx
index 129952e..52a4c04 100644
--- a/apps/web/test/dispatch-problems.test.tsx
+++ b/apps/web/test/dispatch-problems.test.tsx
@@ -42,6 +42,7 @@ function afterDeal(): PlayerState {
status: "idle",
locationId: "earth",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: "avatar",
},
],
diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx
index 4aa8ed8..df47989 100644
--- a/apps/web/test/dispatch-sheet.test.tsx
+++ b/apps/web/test/dispatch-sheet.test.tsx
@@ -26,6 +26,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
status: "idle",
locationId: "earth",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId,
});
diff --git a/apps/web/test/fleet.test.tsx b/apps/web/test/fleet.test.tsx
index a8f69f9..8df2196 100644
--- a/apps/web/test/fleet.test.tsx
+++ b/apps/web/test/fleet.test.tsx
@@ -33,6 +33,7 @@ const ship = (overrides: Partial): Ship => ({
status: "idle",
locationId: "earth",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: null,
...overrides,
});
diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx
index 5a2ed2e..3cc45f5 100644
--- a/apps/web/test/hq.test.tsx
+++ b/apps/web/test/hq.test.tsx
@@ -15,6 +15,7 @@ const ship = (id: string, status: Ship["status"]): Ship => ({
status,
locationId: status === "idle" ? "earth" : null,
dispatchId: status === "idle" ? null : `d-${id}`,
+ lastArrivalAt: 0,
pilotId: null,
});
diff --git a/apps/web/test/location-boards.test.tsx b/apps/web/test/location-boards.test.tsx
new file mode 100644
index 0000000..7d08820
--- /dev/null
+++ b/apps/web/test/location-boards.test.tsx
@@ -0,0 +1,153 @@
+import type { Contract, Ship } from "@deadhead/schema";
+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 { fakeApi, renderApp, samplePlayerState } from "./render";
+
+const NOW = Date.parse("2100-01-01T00:00:00Z");
+
+const contract = (overrides: Partial): Contract => ({
+ id: "c",
+ originId: "earth",
+ destinationId: "moon",
+ cargoTypeId: "standard",
+ tier: 0,
+ massTonnes: 50,
+ payout: 2000,
+ requiredSkill: null,
+ requiredLongHaulLevel: 0,
+ offeredAt: NOW,
+ expiresAt: NOW + 30 * 60_000,
+ firstContact: false,
+ ...overrides,
+});
+
+const boards: Record = {
+ earth: [
+ contract({ id: "e1", payout: 1999 }),
+ contract({
+ id: "e2",
+ destinationId: "mars",
+ cargoTypeId: "heavy",
+ tier: 2,
+ requiredSkill: { skillId: "heavy-cargo", level: 2 },
+ requiredLongHaulLevel: 1,
+ firstContact: true,
+ payout: 88_000,
+ }),
+ ],
+ moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })],
+};
+
+const ship = (id: string, overrides: Partial): Ship => ({
+ id,
+ modelId: "courier",
+ name: id,
+ status: "idle",
+ locationId: "earth",
+ dispatchId: null,
+ pilotId: null,
+ lastArrivalAt: NOW - 60_000,
+ ...overrides,
+});
+
+/** Only an Earth bay; one idle ship at Earth, two at the Moon (the newest arrival). */
+const owner = () =>
+ samplePlayerState({
+ tutorial: null,
+ bays: [{ id: "b1", locationId: "earth" }],
+ ships: [
+ ship("s1", { locationId: "earth", lastArrivalAt: NOW - 120_000 }),
+ ship("s2", { locationId: "moon", lastArrivalAt: NOW - 1000 }),
+ ship("s3", { locationId: "moon", lastArrivalAt: NOW - 90_000 }),
+ ship("s4", { status: "in-flight", locationId: null, dispatchId: "d", lastArrivalAt: NOW }),
+ ],
+ });
+
+const boardApi = () =>
+ fakeApi({
+ getPlayerState: owner,
+ getContractBoard: ({ locationId }) => ({
+ contracts: boards[locationId] ?? [],
+ periodEndsAt: NOW + 30 * 60_000,
+ }),
+ });
+
+describe("location contract boards", () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
+ vi.setSystemTime(NOW);
+ useClock.setState({ offsetMs: 0, synced: true, now: NOW });
+ });
+ afterEach(() => vi.useRealTimers());
+
+ it("lists every discovered location, none undiscovered, with idle-ship badges", async () => {
+ renderApp(boardApi(), "/contracts");
+ const group = await screen.findByRole("group", { name: "Location" });
+ const names = within(group)
+ .getAllByRole("button")
+ .map((b) => b.textContent?.replace(/[0-9]+$/, ""));
+ expect(names).toEqual(["Earth", "Moon"]);
+ expect(within(group).getByRole("button", { name: /Earth/ })).toContainElement(
+ within(group).getByLabelText("1 idle ship"),
+ );
+ expect(within(group).getByRole("button", { name: /Moon/ })).toContainElement(
+ within(group).getByLabelText("2 idle ships"),
+ );
+ });
+
+ it("opens on the most recently arrived idle ship's location, and switches on tap", async () => {
+ renderApp(boardApi(), "/contracts");
+ expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: /Earth/ }));
+ expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
+ expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
+ });
+
+ it("defaults to Earth when no ship is idle", async () => {
+ const api = fakeApi({
+ getPlayerState: () => samplePlayerState({ tutorial: null }),
+ getContractBoard: ({ locationId }) => ({
+ contracts: boards[locationId] ?? [],
+ periodEndsAt: NOW + 30 * 60_000,
+ }),
+ });
+ renderApp(api, "/contracts");
+ expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
+ expect(screen.getByText(/No idle ships here/)).toBeInTheDocument();
+ });
+
+ it("opens the location named in the address", async () => {
+ renderApp(boardApi(), "/contracts?location=earth");
+ expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
+ });
+
+ it("'Find work here' on the Fleet opens that ship's location board", async () => {
+ renderApp(boardApi(), "/fleet");
+ const row = (await screen.findByText("s1")).closest("li")!;
+ await userEvent.click(within(row).getByRole("link", { name: "Find work here" }));
+ expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument();
+ });
+
+ it("shows contract details, requirements, expiry and the first-contact badge", async () => {
+ renderApp(boardApi(), "/contracts?location=earth");
+
+ await screen.findByText("1,999 cr");
+ const items = screen.getAllByRole("listitem");
+ const [plain, special] = items;
+ expect(plain).toHaveTextContent("Standard · 50 t");
+ expect(plain).toHaveTextContent("No skills needed");
+ expect(plain).toHaveTextContent("expires in 30m 00s");
+ expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
+
+ expect(special).toHaveTextContent("Earth → Mars");
+ expect(special).toHaveTextContent("Heavy (tier 2)");
+ expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
+ expect(special).toHaveTextContent(/M km/);
+ expect(within(special!).getByText("First contact")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx
index 2439887..049afe3 100644
--- a/apps/web/test/map-interactions.test.tsx
+++ b/apps/web/test/map-interactions.test.tsx
@@ -41,6 +41,7 @@ function mapState(): PlayerState {
status: "in-flight",
locationId: null,
dispatchId: "d1",
+ lastArrivalAt: 0,
pilotId: "p-rae",
},
],
@@ -106,7 +107,7 @@ describe("map panels", () => {
expect(panel).toHaveTextContent(/from Earth/);
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
"href",
- "/contracts?bay=moon",
+ "/contracts?location=moon",
);
});
@@ -146,10 +147,10 @@ describe("map panels", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
- it("the board link opens Contracts with that bay selected", async () => {
+ it("the board link opens Contracts with that location selected", async () => {
const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
- expect(await screen.findByLabelText("Bay")).toHaveValue("moon");
+ expect(await screen.findByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
});
});
diff --git a/apps/web/test/map-scene.test.ts b/apps/web/test/map-scene.test.ts
index 239d0a2..0afbe9c 100644
--- a/apps/web/test/map-scene.test.ts
+++ b/apps/web/test/map-scene.test.ts
@@ -61,6 +61,7 @@ describe("ship markers", () => {
status: "in-flight",
locationId: null,
dispatchId: "d1",
+ lastArrivalAt: 0,
pilotId: "p1",
},
{
@@ -70,6 +71,7 @@ describe("ship markers", () => {
status: "idle",
locationId: "moon",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: null,
},
{
@@ -79,6 +81,7 @@ describe("ship markers", () => {
status: "idle",
locationId: "moon",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: null,
},
],
diff --git a/apps/web/test/market.test.tsx b/apps/web/test/market.test.tsx
index 5178628..47abee8 100644
--- a/apps/web/test/market.test.tsx
+++ b/apps/web/test/market.test.tsx
@@ -46,6 +46,7 @@ describe("market screen", () => {
status: "idle",
locationId: "earth",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: null,
};
let owned: Ship[] = [];
diff --git a/apps/web/test/pilots.test.tsx b/apps/web/test/pilots.test.tsx
index d2af1e1..13820ed 100644
--- a/apps/web/test/pilots.test.tsx
+++ b/apps/web/test/pilots.test.tsx
@@ -13,6 +13,7 @@ const ship = (overrides: Partial): Ship => ({
status: "idle",
locationId: "moon",
dispatchId: null,
+ lastArrivalAt: 0,
pilotId: null,
...overrides,
});
diff --git a/apps/web/test/states.test.tsx b/apps/web/test/states.test.tsx
index 3271ceb..77811e6 100644
--- a/apps/web/test/states.test.tsx
+++ b/apps/web/test/states.test.tsx
@@ -96,9 +96,9 @@ describe("empty states", () => {
);
});
- it("Contracts: no bays explains how to get contracts", async () => {
+ it("Contracts: no idle ships explains how to take the jobs", async () => {
show("/contracts");
- expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
+ expect(await screen.findByText(/No idle ships here/)).toBeInTheDocument();
});
it("Contracts: an empty bay board says to check back", async () => {
diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md
index 1b47e6a..352f423 100644
--- a/memory-bank/architecture.md
+++ b/memory-bank/architecture.md
@@ -25,7 +25,7 @@ Living reference for how the code is organised. Update it after any database sch
|---|---|
| `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` |
-| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
+| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
| `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` |
@@ -450,7 +450,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `src/App.tsx` | Clock sync; loading / start screen / routed layout |
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
| `src/routes.ts` | Screen list shared by router and navigation |
-| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
+| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 4627f68..246b49b 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -140,7 +140,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
- [x] **Step 10B.4 — Server: board endpoint for any discovered location.** `getContractBoard` is now `GET /api/locations/:locationId/board` (`locationBoard` in `src/game/boards.ts`): any discovered location, no bay needed; `409 location-undiscovered` for undiscovered, `404` for unknown. Step 6.9 tests moved to `location-board.test.ts`.
- [x] **Step 10B.5 — Server: no more stranded ships.** Contract and deadhead dispatch never consulted bays after 10B.2/10B.4; this step proves it. `stranded.test.ts` covers the original bug: a Courier delivers Earth→Moon with no Moon bay, takes a Moon→Earth job from the Moon board and lands at Earth; separately it deadheads Moon→Earth. The Step 6.11 test now deadheads to the Moon without a Moon bay.
- [x] **Step 10B.6 — Server: ship delivery to a bay.** `buyShip` takes `{ modelId, bayId }`; the ship appears idle and uncrewed at that bay's location. Missing `bayId` is `400 validation-error`; a bay the player does not own is `404 not-found` with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return `earthBayId`.
-- [ ] Step 10B.7 — Client: Contracts screen by location
+- [x] **Step 10B.7 — Client: Contracts screen by location.** `LocationBoards` (was `BayBoards`) shows a Location button group of every discovered location with an idle-ship count badge, defaulting to the most recently arrived idle ship's location (else Earth); `?location=` selects one (map link and Fleet's new "Find work here"). API: `Ship.lastArrivalAt`. Tests: `location-boards.test.tsx`, `last-arrival.test.ts`.
- [ ] Step 10B.8 — Client: deadhead action
- [ ] Step 10B.9 — Client: bays as HQs
- [ ] Step 10B.10 — Plan amendments and regression
diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts
index 120c524..70c9e88 100644
--- a/packages/schema/src/entities.ts
+++ b/packages/schema/src/entities.ts
@@ -37,6 +37,11 @@ export const shipSchema = z.object({
/** The active dispatch while in flight; null when idle. */
dispatchId: idSchema.nullable(),
pilotId: idSchema.nullable(),
+ /**
+ * When the ship last arrived somewhere: its latest resolved flight, or its
+ * delivery if it has not flown. Picks the default Contracts location (CR-002).
+ */
+ lastArrivalAt: timeSchema,
});
export type Ship = z.infer;
diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts
index c59c225..3ef1475 100644
--- a/packages/schema/test/schemas.test.ts
+++ b/packages/schema/test/schemas.test.ts
@@ -54,6 +54,7 @@ const ship = {
locationId: "earth",
dispatchId: null,
pilotId: "p1",
+ lastArrivalAt: 4_102_444_800_000,
};
const pilot = {
id: "p1",