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 ` setSelected(e.target.value)} - className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1" - > - {state.bays.map((bay) => ( - - ))} - - - +
+ {discovered.map((id) => { + const idle = idleAt(id); + return ( + + ); + })} +
+ {idleAt(locationId) === 0 && ( +

+ No idle ships here. Deadhead one in from the Fleet screen to take these jobs. +

+ )} + ); } -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 }), }); diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 8f45057..9215e81 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -97,7 +97,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (

{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",