From 9ef45fec79b6ca8130f8b0129e02eddc292073fc Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 02:14:16 -0400 Subject: [PATCH] Step 9.6: add fleet screen Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 + apps/web/src/App.tsx | 2 + apps/web/src/screens/Fleet.tsx | 163 +++++++++++++++++++++++++++++++++ apps/web/test/fleet.test.tsx | 137 +++++++++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/screens/Fleet.tsx create mode 100644 apps/web/test/fleet.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 7f1e5d3..7ebb3cc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -426,3 +426,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Net profit = payout − fuel − pilot pay** (no pilot pay when the avatar flies; decision 0.5.12). - **ETA is shown twice:** in-game (`YYYY-MM-DD HH:MM`) and real local time on the device. - **The first eligible pair is preselected**; Confirm is disabled when none is eligible. + +## Step 9.6 + +- **Reposition is offered only for idle, crewed ships at one of the player's bays, when another bay exists.** The ship's assigned pilot flies it. Other cases do not show the button, rather than showing a button that always fails. +- **Flight progress comes from sim `dispatchProgress`** with the synced clock, updating every second. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3adc6e9..8c4a888 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { formatCredits } from "./format"; import { Layout } from "./layout/Layout"; import { SCREENS } from "./routes"; import { Contracts } from "./screens/Contracts"; +import { Fleet } from "./screens/Fleet"; import { HQ } from "./screens/HQ"; import { Placeholder } from "./screens/Placeholder"; import { StartScreen } from "./screens/StartScreen"; @@ -16,6 +17,7 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player"; const SCREEN_COMPONENTS: Partial> = { "/": , "/contracts": , + "/fleet": , }; /** Syncs the clock with the server once, then ticks it every second. */ diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx new file mode 100644 index 0000000..46fb08e --- /dev/null +++ b/apps/web/src/screens/Fleet.tsx @@ -0,0 +1,163 @@ +import type { Dispatch, Ship } from "@deadhead/schema"; +import { dispatchProgress } from "@deadhead/sim"; +import { useState } from "react"; +import { Link } from "react-router"; +import { useClock } from "../clock/clock"; +import { Countdown } from "../components/Countdown"; +import { locationName, shipModelName } from "../format"; +import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; + +/** All ships, their pilots and flights; idle ships at a bay can reposition (Step 9.6). */ +export function Fleet() { + const state = useLoadedPlayer(); + const [repositioning, setRepositioning] = useState(null); + + return ( +
+

Fleet

+ {state.ships.length === 0 ? ( +

+ No ships yet —{" "} + + visit the Market + + . +

+ ) : ( +
    + {state.ships.map((ship) => ( + d.id === ship.dispatchId)} + pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null} + canReposition={ + ship.status === "idle" && + ship.pilotId !== null && + state.bays.some((b) => b.locationId === ship.locationId) && + state.bays.length > 1 + } + onReposition={() => setRepositioning(ship)} + /> + ))} +
+ )} + {repositioning && ( + setRepositioning(null)} /> + )} +
+ ); +} + +function ShipRow(props: { + ship: Ship; + dispatch: Dispatch | undefined; + pilotName: string | null; + canReposition: boolean; + onReposition: () => void; +}) { + const { ship, dispatch, pilotName } = props; + const now = useClock((s) => s.now); + const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0; + + return ( +
  • +
    + {ship.name} + {shipModelName(ship.modelId)} +
    +
    + {dispatch + ? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}` + : `At ${locationName(ship.locationId ?? "")}`} + {" · "} + {pilotName ?? "No pilot"} +
    + {dispatch && ( +
    +
    +
    +
    +
    + Arrives in +
    +
    + )} + {props.canReposition && ( + + )} +
  • + ); +} + +/** Choose another bay to fly an idle ship to, empty (fuel only). */ +function RepositionPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) { + const state = useLoadedPlayer(); + const reposition = useGameAction("createDispatch"); + const destinations = state.bays.filter((b) => b.locationId !== ship.locationId); + + return ( +
    +
    +

    + Reposition {ship.name} +

    +

    Fly empty to another bay. You pay fuel only.

    +
      + {destinations.map((bay) => ( +
    • + +
    • + ))} +
    + {reposition.isError && ( +

    + {errorMessage(reposition.error)} +

    + )} + +
    +
    + ); +} diff --git a/apps/web/test/fleet.test.tsx b/apps/web/test/fleet.test.tsx new file mode 100644 index 0000000..a8f69f9 --- /dev/null +++ b/apps/web/test/fleet.test.tsx @@ -0,0 +1,137 @@ +import type { Dispatch, PlayerState, Ship } from "@deadhead/schema"; +import { dispatchProgress } from "@deadhead/sim"; +import { act, screen, waitFor, 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 flight: Dispatch = { + id: "d1", + shipId: "s-fly", + pilotId: "p2", + contract: null, + originId: "earth", + destinationId: "moon", + tutorial: false, + departureAt: NOW - 60_000, + arrivalAt: NOW + 180_000, + distanceMillionKm: 0.39, + fuelCost: 50, + expectedPayout: 0, + seed: 1, + status: "in-flight", + resolvedAt: null, +}; + +const ship = (overrides: Partial): Ship => ({ + id: "s", + modelId: "courier", + name: "Ship", + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId: null, + ...overrides, +}); + +function fleetState(overrides: Partial = {}): PlayerState { + const base = samplePlayerState({ tutorial: null }); + return { + ...base, + bays: [ + { id: "b1", locationId: "earth" }, + { id: "b2", locationId: "moon" }, + { id: "b3", locationId: "mars" }, + ], + ships: [ + ship({ id: "s-idle", name: "Dusty Mule", pilotId: "avatar" }), + ship({ + id: "s-fly", + name: "Slow Ox", + status: "in-flight", + locationId: null, + dispatchId: "d1", + pilotId: "p2", + }), + ], + pilots: [ + { ...base.pilots[0]!, assignedShipId: "s-idle" }, + { + ...base.pilots[0]!, + id: "p2", + name: "Rae Okafor", + isAvatar: false, + assignedShipId: "s-fly", + status: "in-flight", + locationId: null, + }, + ], + activeDispatches: [flight], + ...overrides, + }; +} + +describe("fleet screen", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("shows an in-flight ship's progress from the sim, and it advances", async () => { + renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet"); + const bar = await screen.findByRole("progressbar", { name: "Slow Ox progress" }); + expect(bar).toHaveAttribute( + "aria-valuenow", + String(Math.round(dispatchProgress(flight, NOW) * 100)), + ); + expect(bar).toHaveAttribute("aria-valuenow", "25"); + + act(() => vi.advanceTimersByTime(60_000)); + expect(bar).toHaveAttribute("aria-valuenow", "50"); + expect(screen.getByText("Earth → Moon", { exact: false })).toBeInTheDocument(); + expect(screen.getByText("2m 00s")).toBeInTheDocument(); + }); + + it("lists each ship with model, location and pilot", async () => { + renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet"); + const idle = (await screen.findByText("Dusty Mule")).closest("li")!; + expect(idle).toHaveTextContent("Courier"); + expect(idle).toHaveTextContent("At Earth"); + expect(idle).toHaveTextContent("Captain"); + }); + + it("repositions to a destination chosen from the player's other bays only", async () => { + const api = fakeApi({ getPlayerState: () => fleetState() }); + renderApp(api, "/fleet"); + const idle = (await screen.findByText("Dusty Mule")).closest("li")!; + await userEvent.click(within(idle).getByRole("button", { name: "Reposition" })); + + const picker = screen.getByRole("dialog", { name: "Reposition Dusty Mule" }); + const options = within(picker) + .getAllByRole("button") + .map((b) => b.textContent) + .filter((t) => t !== "Cancel"); + expect(options).toEqual(["Moon", "Mars"]); + + await userEvent.click(within(picker).getByRole("button", { name: "Moon" })); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("createDispatch", { + kind: "reposition", + shipId: "s-idle", + pilotId: "avatar", + destinationId: "moon", + }), + ); + }); + + it("does not offer reposition to in-flight ships", async () => { + renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet"); + const flying = (await screen.findByText("Slow Ox")).closest("li")!; + expect(within(flying).queryByRole("button", { name: "Reposition" })).not.toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index f2c5026..82e6937 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -90,7 +90,7 @@ - [x] **Step 9.3 — Tutorial contract board.** Contracts screen shows the tutorial board during the tutorial (`src/screens/TutorialBoard.tsx`): route, cargo, mass, payout, sim-predicted trip time (`src/game.ts`), and "Fly it" (disabled while a run is in flight; server errors shown). - [x] **Step 9.4 — Bay contract boards.** After the tutorial the Contracts screen shows `BayBoards`: a bay selector and, per contract, route, cargo (and tier), mass, current distance, payout, required skills, live expiry countdown and a first-contact badge. - [x] **Step 9.5 — Dispatch flow.** `src/screens/DispatchSheet.tsx`: each idle ship at the pickup with its pilot, checked with sim `validateDispatch`; eligible pairs show trip time, fuel, net profit and game/real ETA; ineligible pairs are disabled with a short reason; Confirm sends the dispatch and shows server errors. -- [ ] Step 9.6 — Fleet screen +- [x] **Step 9.6 — Fleet screen.** `src/screens/Fleet.tsx`: ships with model, location or route, pilot; in-flight progress bar (sim `dispatchProgress`) and countdown; "Reposition" for idle crewed ships at a bay opens a picker of the player's other bays. - [ ] Step 9.7 — Pilots screen - [ ] Step 9.8 — Market screen - [ ] Step 9.9 — Locations screen