From ea0d023b970f629c8d370cce2342ad03a09f4f4a Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 02:17:32 -0400 Subject: [PATCH] Step 9.9: add locations screen Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 +++ apps/web/src/App.tsx | 2 + apps/web/src/screens/Locations.tsx | 81 ++++++++++++++++++++++++++++++ apps/web/test/locations.test.tsx | 57 +++++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/screens/Locations.tsx create mode 100644 apps/web/test/locations.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index 2c9618e..4a6917d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -441,3 +441,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 9.8 - **Market lists every content ship model** with its stats straight from `@deadhead/content`; no API call is needed until Buy. After a purchase the player-state refresh adds the ship to Fleet, and a status line says where it was delivered. + +## Step 9.9 + +- **Distance from Earth is live** (sim at the synced clock), so it changes as the planets move; Earth itself shows "Home". +- **Landing requirement "vacuum" is shown as "Any ship"**, since every ship can land at a vacuum-rated location. +- **Lease buttons are disabled when unaffordable** and absent where a bay exists or the location is undiscovered. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fe9182a..9db703d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { SCREENS } from "./routes"; import { Contracts } from "./screens/Contracts"; import { Fleet } from "./screens/Fleet"; import { HQ } from "./screens/HQ"; +import { Locations } from "./screens/Locations"; import { Market } from "./screens/Market"; import { Pilots } from "./screens/Pilots"; import { Placeholder } from "./screens/Placeholder"; @@ -22,6 +23,7 @@ const SCREEN_COMPONENTS: Partial, "/pilots": , "/market": , + "/locations": , }; /** Syncs the clock with the server once, then ticks it every second. */ diff --git a/apps/web/src/screens/Locations.tsx b/apps/web/src/screens/Locations.tsx new file mode 100644 index 0000000..8d5ea8f --- /dev/null +++ b/apps/web/src/screens/Locations.tsx @@ -0,0 +1,81 @@ +import { content, type Location } from "@deadhead/content"; +import { useClock } from "../clock/clock"; +import { cargoName, formatCredits, formatMillionKm } from "../format"; +import { distanceAt } from "../game"; +import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; + +const TYPE_LABELS: Record = { + "atmospheric-world": "Atmospheric world", + "airless-body": "Airless body", + "cloud-platform": "Cloud platform", + "belt-station": "Belt station", +}; +const LANDING_LABELS = { vacuum: "Any ship", atmospheric: "Atmospheric", hardened: "Hardened" }; + +/** Every location; details and bay leasing only once discovered (Step 9.9). */ +export function Locations() { + const state = useLoadedPlayer(); + const now = useClock((s) => s.now); + const lease = useGameAction("leaseBay"); + + return ( +
+

Locations

+ {lease.isError && ( +

+ {errorMessage(lease.error)} +

+ )} +
    + {content.locations.map((location) => { + const discovered = state.locations.find((l) => l.id === location.id)?.discovered; + if (!discovered) { + return ( +
  • +
    {location.name}
    +
    Undiscovered — deliver here to unlock.
    +
  • + ); + } + const hasBay = state.bays.some((b) => b.locationId === location.id); + const affordable = state.company.credits >= location.bayLeasePrice; + return ( +
  • +
    + {location.name} + + {location.id === "earth" + ? "Home" + : `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`} + +
    +
    +
    Type
    +
    {TYPE_LABELS[location.type]}
    +
    Landing
    +
    {LANDING_LABELS[location.landingRequirement]}
    +
    Produces
    +
    {location.produces.map(cargoName).join(", ") || "—"}
    +
    Demands
    +
    {location.demands.map(cargoName).join(", ") || "—"}
    +
    + {hasBay ? ( +

    Bay owned

    + ) : ( + + )} +
  • + ); + })} +
+
+ ); +} diff --git a/apps/web/test/locations.test.tsx b/apps/web/test/locations.test.tsx new file mode 100644 index 0000000..52f38cc --- /dev/null +++ b/apps/web/test/locations.test.tsx @@ -0,0 +1,57 @@ +import { content } from "@deadhead/content"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const item = (name: string) => screen.getByText(name).closest("li")!; + +describe("locations screen", () => { + it("hides details of undiscovered locations", async () => { + renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations"); + await screen.findByText("Mars"); + const mars = item("Mars"); + expect(mars).toHaveTextContent("Undiscovered — deliver here to unlock."); + expect(mars).not.toHaveTextContent("Produces"); + expect(within(mars).queryByRole("button")).not.toBeInTheDocument(); + }); + + it("shows details of discovered locations, with distance from Earth", async () => { + renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations"); + await screen.findByText("Moon"); + const moon = item("Moon"); + expect(moon).toHaveTextContent("TypeAirless body"); + expect(moon).toHaveTextContent("LandingAny ship"); + expect(moon).toHaveTextContent("ProducesStandard, Heavy"); + expect(moon).toHaveTextContent(/M km from Earth/); + expect(item("Earth")).toHaveTextContent("Home"); + }); + + it("offers leasing only at discovered locations without a bay", async () => { + const api = fakeApi({ + getPlayerState: () => + samplePlayerState({ + company: { id: "c", name: "D", credits: 100_000 }, + bays: [{ id: "b1", locationId: "earth" }], + }), + }); + renderApp(api, "/locations"); + await screen.findByText("Moon"); + + expect(item("Earth")).toHaveTextContent("Bay owned"); + expect(within(item("Earth")).queryByRole("button")).not.toBeInTheDocument(); + + const moonPrice = content.location("moon")!.bayLeasePrice; + const lease = within(item("Moon")).getByRole("button", { name: "Lease bay at Moon" }); + expect(lease).toHaveTextContent(`Lease bay (${moonPrice.toLocaleString("en-US")} cr)`); + await userEvent.click(lease); + expect(api.call).toHaveBeenCalledWith("leaseBay", { locationId: "moon" }); + + expect(screen.queryByRole("button", { name: "Lease bay at Mars" })).not.toBeInTheDocument(); + }); + + it("disables leasing the player cannot afford", async () => { + renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }), "/locations"); + expect(await screen.findByRole("button", { name: "Lease bay at Earth" })).toBeDisabled(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ffa4cf9..c30d272 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -93,5 +93,5 @@ - [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. - [x] **Step 9.7 — Pilots screen.** `src/screens/Pilots.tsx`: level, XP progress to next level, skills, points, assignment and status per pilot; hire at a chosen bay (cost shown, disabled when unaffordable); assign to idle uncrewed ships at the pilot's location; "+" to spend a skill point. - [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh. -- [ ] Step 9.9 — Locations screen +- [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock." - [ ] Step 9.10 — Empty and error states