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. - **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. - **`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. - **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, locationId: ship!.locationId,
dispatchId: null, dispatchId: null,
pilotId: null, pilotId: null,
lastArrivalAt: ship!.createdAt,
}; };
} }

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema"; 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 type { Queryable } from "../db/client";
import { import {
bays, bays,
@ -32,6 +32,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
.select() .select()
.from(dispatches) .from(dispatches)
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight"))); .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 const unacknowledged = await db
.select() .select()
.from(dispatches) .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 discoveredIds = new Set(discovered.map((d) => d.locationId));
const shipById = new Map(shipRows.map((s) => [s.id, s])); 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 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( const pilotByShip = new Map(
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]), 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, locationId: s.status === "in_flight" ? null : s.locationId,
dispatchId: activeByShip.get(s.id) ?? null, dispatchId: activeByShip.get(s.id) ?? null,
pilotId: pilotByShip.get(s.id) ?? null, pilotId: pilotByShip.get(s.id) ?? null,
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
})), })),
pilots: pilotRows.map((p) => pilots: pilotRows.map((p) =>
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null), 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 type { Contract } from "@deadhead/schema";
import { useState } from "react"; import { useState } from "react";
import { useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
import { BayBoards } from "./BayBoards"; import { LocationBoards } from "./LocationBoards";
import { DispatchSheet } from "./DispatchSheet"; import { DispatchSheet } from "./DispatchSheet";
import { TutorialBoard } from "./TutorialBoard"; 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() { export function Contracts() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const [selected, setSelected] = useState<Contract | null>(null); const [selected, setSelected] = useState<Contract | null>(null);
return ( return (
<section className="space-y-3 p-4"> <section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Contracts</h1> <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)} />} {selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
</section> </section>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ const ship: Ship = {
status: "idle", status: "idle",
locationId: "mars", locationId: "mars",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar", 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", status: "idle",
locationId: "earth", locationId: "earth",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0,
pilotId: "avatar", pilotId: "avatar",
}, },
], ],

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ const ship = (id: string, status: Ship["status"]): Ship => ({
status, status,
locationId: status === "idle" ? "earth" : null, locationId: status === "idle" ? "earth" : null,
dispatchId: status === "idle" ? null : `d-${id}`, dispatchId: status === "idle" ? null : `d-${id}`,
lastArrivalAt: 0,
pilotId: null, 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", status: "in-flight",
locationId: null, locationId: null,
dispatchId: "d1", dispatchId: "d1",
lastArrivalAt: 0,
pilotId: "p-rae", pilotId: "p-rae",
}, },
], ],
@ -106,7 +107,7 @@ describe("map panels", () => {
expect(panel).toHaveTextContent(/from Earth/); expect(panel).toHaveTextContent(/from Earth/);
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute( expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
"href", "href",
"/contracts?bay=moon", "/contracts?location=moon",
); );
}); });
@ -146,10 +147,10 @@ describe("map panels", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 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(); const canvas = await openMap();
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" })); await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
await userEvent.click(screen.getByRole("link", { name: "View contract board" })); 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", status: "in-flight",
locationId: null, locationId: null,
dispatchId: "d1", dispatchId: "d1",
lastArrivalAt: 0,
pilotId: "p1", pilotId: "p1",
}, },
{ {
@ -70,6 +71,7 @@ describe("ship markers", () => {
status: "idle", status: "idle",
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0,
pilotId: null, pilotId: null,
}, },
{ {
@ -79,6 +81,7 @@ describe("ship markers", () => {
status: "idle", status: "idle",
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0,
pilotId: null, pilotId: null,
}, },
], ],

View File

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

View File

@ -13,6 +13,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
status: "idle", status: "idle",
locationId: "moon", locationId: "moon",
dispatchId: null, dispatchId: null,
lastArrivalAt: 0,
pilotId: null, pilotId: null,
...overrides, ...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"); 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 () => { 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` | | `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits`, `loanBalance` | | `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) | | `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
| `Bay` | `id`, `locationId` | | `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` | | `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/App.tsx` | Clock sync; loading / start screen / routed layout |
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar | | `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
| `src/routes.ts` | Screen list shared by router and navigation | | `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/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `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) | | `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.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.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`. - [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.8 — Client: deadhead action
- [ ] Step 10B.9 — Client: bays as HQs - [ ] Step 10B.9 — Client: bays as HQs
- [ ] Step 10B.10 — Plan amendments and regression - [ ] 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. */ /** The active dispatch while in flight; null when idle. */
dispatchId: idSchema.nullable(), dispatchId: idSchema.nullable(),
pilotId: 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>; export type Ship = z.infer<typeof shipSchema>;

View File

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