Step 9.6: add fleet screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7101b26abf
commit
9ef45fec79
|
|
@ -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).
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { formatCredits } from "./format";
|
||||||
import { Layout } from "./layout/Layout";
|
import { Layout } from "./layout/Layout";
|
||||||
import { SCREENS } from "./routes";
|
import { SCREENS } from "./routes";
|
||||||
import { Contracts } from "./screens/Contracts";
|
import { Contracts } from "./screens/Contracts";
|
||||||
|
import { Fleet } from "./screens/Fleet";
|
||||||
import { HQ } from "./screens/HQ";
|
import { HQ } from "./screens/HQ";
|
||||||
import { Placeholder } from "./screens/Placeholder";
|
import { Placeholder } from "./screens/Placeholder";
|
||||||
import { StartScreen } from "./screens/StartScreen";
|
import { StartScreen } from "./screens/StartScreen";
|
||||||
|
|
@ -16,6 +17,7 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
|
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
|
||||||
"/": <HQ />,
|
"/": <HQ />,
|
||||||
"/contracts": <Contracts />,
|
"/contracts": <Contracts />,
|
||||||
|
"/fleet": <Fleet />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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,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<Ship | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3 p-4">
|
||||||
|
<h1 className="text-xl font-semibold">Fleet</h1>
|
||||||
|
{state.ships.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
No ships yet —{" "}
|
||||||
|
<Link to="/market" className="text-amber-400 underline">
|
||||||
|
visit the Market
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{state.ships.map((ship) => (
|
||||||
|
<ShipRow
|
||||||
|
key={ship.id}
|
||||||
|
ship={ship}
|
||||||
|
dispatch={state.activeDispatches.find((d) => 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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{repositioning && (
|
||||||
|
<RepositionPicker ship={repositioning} onClose={() => setRepositioning(null)} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="rounded-lg border border-slate-800 p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium">{ship.name}</span>
|
||||||
|
<span className="text-sm text-slate-400">{shipModelName(ship.modelId)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-300">
|
||||||
|
{dispatch
|
||||||
|
? `${locationName(dispatch.originId)} → ${locationName(dispatch.destinationId)}`
|
||||||
|
: `At ${locationName(ship.locationId ?? "")}`}
|
||||||
|
{" · "}
|
||||||
|
<span className="text-slate-400">{pilotName ?? "No pilot"}</span>
|
||||||
|
</div>
|
||||||
|
{dispatch && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${ship.name} progress`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
className="h-2 overflow-hidden rounded bg-slate-800"
|
||||||
|
>
|
||||||
|
<div className="h-full bg-sky-400" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-right text-xs text-slate-400">
|
||||||
|
Arrives in <Countdown to={dispatch.arrivalAt} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{props.canReposition && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onReposition}
|
||||||
|
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
Reposition
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reposition-title"
|
||||||
|
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
||||||
|
>
|
||||||
|
<h2 id="reposition-title" className="text-lg font-semibold">
|
||||||
|
Reposition {ship.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400">Fly empty to another bay. You pay fuel only.</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{destinations.map((bay) => (
|
||||||
|
<li key={bay.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={reposition.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
reposition.mutate(
|
||||||
|
{
|
||||||
|
kind: "reposition",
|
||||||
|
shipId: ship.id,
|
||||||
|
pilotId: ship.pilotId!,
|
||||||
|
destinationId: bay.locationId,
|
||||||
|
},
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-slate-700 p-3 text-left"
|
||||||
|
>
|
||||||
|
{locationName(bay.locationId)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{reposition.isError && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-red-400">
|
||||||
|
{errorMessage(reposition.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-4 w-full rounded-lg border border-slate-700 py-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>): Ship => ({
|
||||||
|
id: "s",
|
||||||
|
modelId: "courier",
|
||||||
|
name: "Ship",
|
||||||
|
status: "idle",
|
||||||
|
locationId: "earth",
|
||||||
|
dispatchId: null,
|
||||||
|
pilotId: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function fleetState(overrides: Partial<PlayerState> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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.
|
- [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.7 — Pilots screen
|
||||||
- [ ] Step 9.8 — Market screen
|
- [ ] Step 9.8 — Market screen
|
||||||
- [ ] Step 9.9 — Locations screen
|
- [ ] Step 9.9 — Locations screen
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue