Step 9.9: add locations screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
78fb6e7d69
commit
ea0d023b97
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue