Step 9.9: add locations screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 02:17:32 -04:00
parent 78fb6e7d69
commit ea0d023b97
5 changed files with 147 additions and 1 deletions

View File

@ -441,3 +441,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 9.8 ## 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. - **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.

View File

@ -9,6 +9,7 @@ import { SCREENS } from "./routes";
import { Contracts } from "./screens/Contracts"; import { Contracts } from "./screens/Contracts";
import { Fleet } from "./screens/Fleet"; import { Fleet } from "./screens/Fleet";
import { HQ } from "./screens/HQ"; import { HQ } from "./screens/HQ";
import { Locations } from "./screens/Locations";
import { Market } from "./screens/Market"; import { Market } from "./screens/Market";
import { Pilots } from "./screens/Pilots"; import { Pilots } from "./screens/Pilots";
import { Placeholder } from "./screens/Placeholder"; import { Placeholder } from "./screens/Placeholder";
@ -22,6 +23,7 @@ const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactN
"/fleet": <Fleet />, "/fleet": <Fleet />,
"/pilots": <Pilots />, "/pilots": <Pilots />,
"/market": <Market />, "/market": <Market />,
"/locations": <Locations />,
}; };
/** Syncs the clock with the server once, then ticks it every second. */ /** Syncs the clock with the server once, then ticks it every second. */

View File

@ -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<Location["type"], string> = {
"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 (
<section className="space-y-3 p-4">
<h1 className="text-xl font-semibold">Locations</h1>
{lease.isError && (
<p role="alert" className="text-sm text-red-400">
{errorMessage(lease.error)}
</p>
)}
<ul className="space-y-2">
{content.locations.map((location) => {
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
if (!discovered) {
return (
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="font-medium">{location.name}</div>
<div className="text-slate-400">Undiscovered — deliver here to unlock.</div>
</li>
);
}
const hasBay = state.bays.some((b) => b.locationId === location.id);
const affordable = state.company.credits >= location.bayLeasePrice;
return (
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{location.name}</span>
<span className="text-slate-400">
{location.id === "earth"
? "Home"
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
</span>
</div>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-slate-300">
<dt className="text-slate-400">Type</dt>
<dd>{TYPE_LABELS[location.type]}</dd>
<dt className="text-slate-400">Landing</dt>
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
<dt className="text-slate-400">Produces</dt>
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
<dt className="text-slate-400">Demands</dt>
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
</dl>
{hasBay ? (
<p className="mt-2 font-medium text-emerald-400">Bay owned</p>
) : (
<button
type="button"
aria-label={`Lease bay at ${location.name}`}
disabled={!affordable || lease.isPending}
onClick={() => lease.mutate({ locationId: location.id })}
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
>
Lease bay ({formatCredits(location.bayLeasePrice)})
</button>
)}
</li>
);
})}
</ul>
</section>
);
}

View File

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

View File

@ -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.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.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. - [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 - [ ] Step 9.10 — Empty and error states