Step 10B.7: Contracts screen by location with idle-ship badges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 15:08:48 -04:00
parent 61781aa790
commit 962c0601c8
27 changed files with 297 additions and 158 deletions

View File

@ -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 `<select>`**, because each entry carries an idle-ship badge; a native option cannot hold one. Locations without idle ships show no badge.
- **The URL is the source of truth for the selection** (`?location=`, replacing `?bay=`), so "Find work here" and the map link work even when Contracts is already open. An undiscovered or unknown id falls back to the default.
- **A hint shows when no idle ship is at the selected location**, pointing to deadheading from the Fleet. The old "no bays yet" message is gone: every player can see Earth's market.

View File

@ -123,5 +123,6 @@ export async function buyShip(
locationId: ship!.locationId,
dispatchId: null,
pilotId: null,
lastArrivalAt: ship!.createdAt,
};
}

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema";
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, isNull, max } from "drizzle-orm";
import type { Queryable } from "../db/client";
import {
bays,
@ -32,6 +32,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
.select()
.from(dispatches)
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight")));
const arrivals = await db
.select({ shipId: dispatches.shipId, at: max(dispatches.arrivalAt) })
.from(dispatches)
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "resolved")))
.groupBy(dispatches.shipId);
const unacknowledged = await db
.select()
.from(dispatches)
@ -46,6 +51,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
const discoveredIds = new Set(discovered.map((d) => d.locationId));
const shipById = new Map(shipRows.map((s) => [s.id, s]));
const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at]));
const pilotByShip = new Map(
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
);
@ -65,6 +71,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
locationId: s.status === "in_flight" ? null : s.locationId,
dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null,
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
})),
pilots: pilotRows.map((p) =>
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { TEST_NOW, useTestDatabase } from "./db";
import { getState, insertDispatch, signUpOwner, testContract, useTestApp } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
describe("ship lastArrivalAt (CR-002)", () => {
it("is the delivery time before a flight and the latest arrival after flights", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app, database().db);
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(TEST_NOW);
const out = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract(),
});
clock.now = out.arrivalAt;
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(out.arrivalAt);
const back = await insertDispatch(database().db, {
playerId: owner.playerId,
shipId: owner.shipId,
pilotId: owner.avatarId,
departAt: clock.now,
contract: testContract({ originId: "moon", destinationId: "earth" }),
});
clock.now = back.arrivalAt + 1000;
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(back.arrivalAt);
});
});

View File

@ -1,18 +1,18 @@
import type { Contract } from "@deadhead/schema";
import { useState } from "react";
import { useLoadedPlayer } from "../state/actions";
import { BayBoards } from "./BayBoards";
import { LocationBoards } from "./LocationBoards";
import { DispatchSheet } from "./DispatchSheet";
import { TutorialBoard } from "./TutorialBoard";
/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */
/** Contracts: the tutorial board during the tutorial, location boards afterwards. */
export function Contracts() {
const state = useLoadedPlayer();
const [selected, setSelected] = useState<Contract | null>(null);
return (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Contracts</h1>
{state.tutorial ? <TutorialBoard /> : <BayBoards onSelect={setSelected} />}
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
</section>
);

View File

@ -12,7 +12,7 @@ import { useState } from "react";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
import { requirementsText } from "./BayBoards";
import { requirementsText } from "./LocationBoards";
import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";

View File

@ -90,6 +90,14 @@ function ShipRow(props: {
</div>
</div>
)}
{ship.status === "idle" && ship.locationId && (
<Link
to={`/contracts?location=${ship.locationId}`}
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
>
Find work here
</Link>
)}
{props.canReposition && (
<button
type="button"

View File

@ -1,6 +1,5 @@
import type { Contract } from "@deadhead/schema";
import type { Contract, PlayerState } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useSearchParams } from "react-router";
import { useApi } from "../api/context";
import { useClock } from "../clock/clock";
@ -9,7 +8,7 @@ import { cargoName, formatCredits, formatMillionKm, locationName, skillName } fr
import { distanceAt } from "../game";
import { useLoadedPlayer } from "../state/actions";
export const bayBoardKey = (locationId: string) => ["board", "bay", locationId] as const;
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
export function requirementsText(contract: Contract): string | null {
@ -22,47 +21,65 @@ export function requirementsText(contract: Contract): string | null {
return parts.length > 0 ? parts.join(", ") : null;
}
/** Contract boards for the player's bays (Step 9.4). */
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
const state = useLoadedPlayer();
// `?bay=moon` (e.g. from the map) preselects that bay.
const [params] = useSearchParams();
const [selected, setSelected] = useState(params.get("bay") ?? state.bays[0]?.locationId);
const bayId = state.bays.some((b) => b.locationId === selected)
? selected
: state.bays[0]?.locationId;
/**
* The location the Contracts screen opens on: where the most recently arrived
* idle ship is, otherwise Earth (CR-002).
*/
export function defaultBoardLocation(state: PlayerState): string {
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId);
idle.sort((a, b) => b.lastArrivalAt - a.lastArrivalAt);
return idle[0]?.locationId ?? "earth";
}
if (!bayId) {
return (
<p className="text-sm text-slate-400">
You have no bays yet. Lease one on the Places screen to get contracts.
</p>
);
}
/** Freight markets of every discovered location, no bay needed (Steps 9.4, 10B.7). */
export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
const state = useLoadedPlayer();
// `?location=moon` (from the map or Fleet) selects that location.
const [params, setParams] = useSearchParams();
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
const requested = params.get("location");
const locationId =
requested && discovered.includes(requested) ? requested : defaultBoardLocation(state);
const idleAt = (id: string) =>
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
return (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<span className="text-slate-400">Bay</span>
<select
aria-label="Bay"
value={bayId}
onChange={(e) => setSelected(e.target.value)}
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
{discovered.map((id) => {
const idle = idleAt(id);
return (
<button
key={id}
type="button"
aria-pressed={id === locationId}
onClick={() => setParams({ location: id }, { replace: true })}
className="flex items-center gap-1.5 rounded-full border border-slate-700 px-3 py-1 text-sm aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-200"
>
{state.bays.map((bay) => (
<option key={bay.id} value={bay.locationId}>
{locationName(bay.locationId)}
</option>
))}
</select>
</label>
<BayBoard locationId={bayId} onSelect={onSelect} />
{locationName(id)}
{idle > 0 && (
<span
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
>
{idle}
</span>
)}
</button>
);
})}
</div>
{idleAt(locationId) === 0 && (
<p className="text-sm text-slate-400">
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
</p>
)}
<LocationBoard locationId={locationId} onSelect={onSelect} />
</div>
);
}
function BayBoard({
function LocationBoard({
locationId,
onSelect,
}: {
@ -72,7 +89,7 @@ function BayBoard({
const api = useApi();
const now = useClock((s) => s.now);
const board = useQuery({
queryKey: bayBoardKey(locationId),
queryKey: locationBoardKey(locationId),
queryFn: () => api.call("getContractBoard", { locationId }),
});

View File

@ -97,7 +97,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
</p>
{hasBay ? (
<Link
to={`/contracts?bay=${location.id}`}
to={`/contracts?location=${location.id}`}
className="mt-2 inline-block font-medium text-amber-400 underline"
>
View contract board

View File

@ -17,6 +17,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
status: "idle",
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar",
},
],

View File

@ -11,6 +11,7 @@ const ship: Ship = {
status: "idle",
locationId: "mars",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar",
};

View File

@ -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>): 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<string, Contract[]> = {
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();
});
});

View File

@ -32,6 +32,7 @@ const afterDeal = () =>
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar",
},
],

View File

@ -42,6 +42,7 @@ function afterDeal(): PlayerState {
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar",
},
],

View File

@ -26,6 +26,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId,
});

View File

@ -33,6 +33,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: null,
...overrides,
});

View File

@ -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,
});

View File

@ -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>): 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<string, Contract[]> = {
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>): 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();
});
});

View File

@ -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();
});
});

View File

@ -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,
},
],

View File

@ -46,6 +46,7 @@ describe("market screen", () => {
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: null,
};
let owned: Ship[] = [];

View File

@ -13,6 +13,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
status: "idle",
locationId: "moon",
dispatchId: null,
lastArrivalAt: 0,
pilotId: null,
...overrides,
});

View File

@ -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 () => {

View File

@ -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) |

View File

@ -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

View File

@ -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<typeof shipSchema>;

View File

@ -54,6 +54,7 @@ const ship = {
locationId: "earth",
dispatchId: null,
pilotId: "p1",
lastArrivalAt: 4_102_444_800_000,
};
const pilot = {
id: "p1",