Step 10B.8: deadhead any idle ship to a discovered location
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
962c0601c8
commit
2ba6721200
|
|
@ -656,3 +656,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **The selector is a wrapping row of location buttons (`aria-pressed`), not a `<select>`**, because each entry carries an idle-ship badge; a native option cannot hold one. Locations without idle ships show no badge.
|
- **The selector is a wrapping row of location buttons (`aria-pressed`), not a `<select>`**, because each entry carries an idle-ship badge; a native option cannot hold one. Locations without idle ships show no badge.
|
||||||
- **The URL is the source of truth for the selection** (`?location=`, replacing `?bay=`), so "Find work here" and the map link work even when Contracts is already open. An undiscovered or unknown id falls back to the default.
|
- **The URL is the source of truth for the selection** (`?location=`, replacing `?bay=`), so "Find work here" and the map link work even when Contracts is already open. An undiscovered or unknown id falls back to the default.
|
||||||
- **A hint shows when no idle ship is at the selected location**, pointing to deadheading from the Fleet. The old "no bays yet" message is gone: every player can see Earth's market.
|
- **A hint shows when no idle ship is at the selected location**, pointing to deadheading from the Fleet. The old "no bays yet" message is gone: every player can see Earth's market.
|
||||||
|
|
||||||
|
## Step 10B.8
|
||||||
|
|
||||||
|
- **The picker runs `validateReposition` per destination**, so the client and server agree on what is possible. Fuel is `tripFuelCost` and time is `tripRealMs` with zero cargo, the same inputs the sim's reposition record uses.
|
||||||
|
- **The action shows on every idle ship, crewed or not.** An uncrewed ship's options are all disabled with "No pilot assigned", which tells the player what to fix rather than hiding the action.
|
||||||
|
- **Disabled destinations still show fuel and time**, so the player can see how far off they are.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, Ship } from "@deadhead/schema";
|
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||||
import { dispatchProgress } from "@deadhead/sim";
|
import {
|
||||||
|
dispatchProgress,
|
||||||
|
realToGame,
|
||||||
|
tripFuelCost,
|
||||||
|
validateReposition,
|
||||||
|
type RepositionFailure,
|
||||||
|
} from "@deadhead/sim";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
import { locationName, shipModelName } from "../format";
|
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||||
|
import { location, shipModel, tripRealMs } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
/** All ships, their pilots and flights; idle ships at a bay can reposition (Step 9.6). */
|
/** All ships, their pilots and flights; any idle ship can deadhead (Steps 9.6, 10B.8). */
|
||||||
export function Fleet() {
|
export function Fleet() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const [repositioning, setRepositioning] = useState<Ship | null>(null);
|
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<section className="space-y-3 p-4">
|
||||||
|
|
@ -31,20 +39,12 @@ export function Fleet() {
|
||||||
ship={ship}
|
ship={ship}
|
||||||
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
||||||
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
||||||
canReposition={
|
onDeadhead={() => setDeadheading(ship)}
|
||||||
ship.status === "idle" &&
|
|
||||||
ship.pilotId !== null &&
|
|
||||||
state.bays.some((b) => b.locationId === ship.locationId) &&
|
|
||||||
state.bays.length > 1
|
|
||||||
}
|
|
||||||
onReposition={() => setRepositioning(ship)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{repositioning && (
|
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
||||||
<RepositionPicker ship={repositioning} onClose={() => setRepositioning(null)} />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +53,7 @@ function ShipRow(props: {
|
||||||
ship: Ship;
|
ship: Ship;
|
||||||
dispatch: Dispatch | undefined;
|
dispatch: Dispatch | undefined;
|
||||||
pilotName: string | null;
|
pilotName: string | null;
|
||||||
canReposition: boolean;
|
onDeadhead: () => void;
|
||||||
onReposition: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { ship, dispatch, pilotName } = props;
|
const { ship, dispatch, pilotName } = props;
|
||||||
const now = useClock((s) => s.now);
|
const now = useClock((s) => s.now);
|
||||||
|
|
@ -98,57 +97,114 @@ function ShipRow(props: {
|
||||||
Find work here
|
Find work here
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{props.canReposition && (
|
{ship.status === "idle" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={props.onReposition}
|
onClick={props.onDeadhead}
|
||||||
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
className="mt-2 rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||||
>
|
>
|
||||||
Reposition
|
Deadhead (fly empty)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Choose another bay to fly an idle ship to, empty (fuel only). */
|
/** Why a deadhead destination is unavailable, in plain words. */
|
||||||
function RepositionPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
||||||
|
"cannot-land": "Can't land there",
|
||||||
|
"out-of-range": "Out of range",
|
||||||
|
"insufficient-credits": "Not enough credits for fuel",
|
||||||
|
"pilot-not-idle": "Pilot is flying",
|
||||||
|
"pilot-not-assigned": "No pilot assigned",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fly an idle ship empty to any other discovered location, paying fuel only
|
||||||
|
* (CR-002). The sim decides which destinations are possible, as the server will.
|
||||||
|
*/
|
||||||
|
function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
const now = useClock((s) => s.now);
|
||||||
const reposition = useGameAction("createDispatch");
|
const reposition = useGameAction("createDispatch");
|
||||||
const destinations = state.bays.filter((b) => b.locationId !== ship.locationId);
|
const model = shipModel(ship.modelId);
|
||||||
|
const origin = ship.locationId!;
|
||||||
|
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||||||
|
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||||
|
const discoveredIds = new Set(discovered);
|
||||||
|
const gameNow = realToGame(now, content.config);
|
||||||
|
|
||||||
|
const options = discovered
|
||||||
|
.filter((id) => id !== origin)
|
||||||
|
.map((id) => {
|
||||||
|
const check = validateReposition(
|
||||||
|
{
|
||||||
|
ship: { id: ship.id, modelId: ship.modelId, locationId: origin, inFlight: false },
|
||||||
|
pilot: {
|
||||||
|
id: pilot?.id ?? "",
|
||||||
|
isAvatar: pilot?.isAvatar ?? false,
|
||||||
|
assignedShipId: pilot ? ship.id : null,
|
||||||
|
inFlight: pilot?.status === "in-flight",
|
||||||
|
skills: pilot?.skills ?? {},
|
||||||
|
},
|
||||||
|
destinationId: id,
|
||||||
|
discoveredIds,
|
||||||
|
credits: state.company.credits,
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
problem: check.ok ? null : (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
||||||
|
fuelCost: tripFuelCost(model, location(origin), location(id), gameNow),
|
||||||
|
tripMs: tripRealMs(model, origin, id, 0, now),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 sm:items-center">
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="reposition-title"
|
aria-labelledby="deadhead-title"
|
||||||
className="w-full max-w-md rounded-t-xl border border-slate-700 bg-slate-900 p-4 sm:rounded-xl"
|
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">
|
<h2 id="deadhead-title" className="text-lg font-semibold">
|
||||||
Reposition {ship.name}
|
Deadhead {ship.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400">Fly empty to another bay. You pay fuel only.</p>
|
<p className="text-sm text-slate-400">
|
||||||
|
Fly empty from {locationName(origin)} to another place you know. You pay fuel only.
|
||||||
|
</p>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
{destinations.map((bay) => (
|
{options.map((option) => (
|
||||||
<li key={bay.id}>
|
<li key={option.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reposition.isPending}
|
aria-label={locationName(option.id)}
|
||||||
|
disabled={option.problem !== null || reposition.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reposition.mutate(
|
reposition.mutate(
|
||||||
{
|
{
|
||||||
kind: "reposition",
|
kind: "reposition",
|
||||||
shipId: ship.id,
|
shipId: ship.id,
|
||||||
pilotId: ship.pilotId!,
|
pilotId: pilot!.id,
|
||||||
destinationId: bay.locationId,
|
destinationId: option.id,
|
||||||
},
|
},
|
||||||
{ onSuccess: onClose },
|
{ onSuccess: onClose },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="w-full rounded-lg border border-slate-700 p-3 text-left"
|
className="w-full rounded-lg border border-slate-700 p-3 text-left disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{locationName(bay.locationId)}
|
<span className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium">{locationName(option.id)}</span>
|
||||||
|
<span className="text-sm tabular-nums text-slate-300">
|
||||||
|
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{option.problem && (
|
||||||
|
<span className="mt-1 block text-sm text-red-400">{option.problem}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
|
import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
|
||||||
import { dispatchProgress } from "@deadhead/sim";
|
import { createRepositionRecord, dispatchProgress } from "@deadhead/sim";
|
||||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useClock } from "../src/clock/clock";
|
import { useClock } from "../src/clock/clock";
|
||||||
|
import { formatCredits, formatDuration } from "../src/format";
|
||||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
|
@ -106,33 +108,79 @@ describe("fleet screen", () => {
|
||||||
expect(idle).toHaveTextContent("Captain");
|
expect(idle).toHaveTextContent("Captain");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repositions to a destination chosen from the player's other bays only", async () => {
|
/** A crewed Courier idle at the Moon with no Moon bay; Mars discovered but out of range. */
|
||||||
const api = fakeApi({ getPlayerState: () => fleetState() });
|
const atMoon = () =>
|
||||||
|
fleetState({
|
||||||
|
bays: [{ id: "b1", locationId: "earth" }],
|
||||||
|
locations: [
|
||||||
|
{ id: "earth", discovered: true },
|
||||||
|
{ id: "moon", discovered: true },
|
||||||
|
{ id: "mars", discovered: true },
|
||||||
|
{ id: "ceres-station", discovered: false },
|
||||||
|
{ id: "jupiter-cloud-platform", discovered: false },
|
||||||
|
],
|
||||||
|
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
|
||||||
|
ships: [
|
||||||
|
ship({ id: "s-idle", name: "Dusty Mule", locationId: "moon", pilotId: "avatar" }),
|
||||||
|
fleetState().ships[1]!,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an idle ship with no bay where it is deadhead to a discovered location", async () => {
|
||||||
|
const api = fakeApi({ getPlayerState: atMoon });
|
||||||
renderApp(api, "/fleet");
|
renderApp(api, "/fleet");
|
||||||
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
|
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||||
await userEvent.click(within(idle).getByRole("button", { name: "Reposition" }));
|
await userEvent.click(within(idle).getByRole("button", { name: "Deadhead (fly empty)" }));
|
||||||
|
|
||||||
const picker = screen.getByRole("dialog", { name: "Reposition Dusty Mule" });
|
const picker = screen.getByRole("dialog", { name: "Deadhead Dusty Mule" });
|
||||||
const options = within(picker)
|
const options = within(picker)
|
||||||
.getAllByRole("button")
|
.getAllByRole("button")
|
||||||
.map((b) => b.textContent)
|
.map((b) => b.getAttribute("aria-label"))
|
||||||
.filter((t) => t !== "Cancel");
|
.filter((label) => label !== null);
|
||||||
expect(options).toEqual(["Moon", "Mars"]);
|
expect(options).toEqual(["Earth", "Mars"]);
|
||||||
|
|
||||||
await userEvent.click(within(picker).getByRole("button", { name: "Moon" }));
|
const mars = within(picker).getByRole("button", { name: "Mars" });
|
||||||
|
expect(mars).toBeDisabled();
|
||||||
|
expect(mars).toHaveTextContent("Out of range");
|
||||||
|
|
||||||
|
const earth = within(picker).getByRole("button", { name: "Earth" });
|
||||||
|
const record = createRepositionRecord(
|
||||||
|
{
|
||||||
|
id: "r",
|
||||||
|
ship: { id: "s-idle", modelId: "courier", locationId: "moon", inFlight: false },
|
||||||
|
pilot: {
|
||||||
|
id: "avatar",
|
||||||
|
isAvatar: true,
|
||||||
|
assignedShipId: "s-idle",
|
||||||
|
inFlight: false,
|
||||||
|
skills: {},
|
||||||
|
},
|
||||||
|
destinationId: "earth",
|
||||||
|
now: NOW,
|
||||||
|
worldSeed: 1,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(earth).toBeEnabled();
|
||||||
|
expect(earth).toHaveTextContent(`${formatCredits(record.fuelCost)} fuel`);
|
||||||
|
expect(earth).toHaveTextContent(formatDuration(record.arrivalAt - record.departureAt));
|
||||||
|
|
||||||
|
await userEvent.click(earth);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
||||||
kind: "reposition",
|
kind: "reposition",
|
||||||
shipId: "s-idle",
|
shipId: "s-idle",
|
||||||
pilotId: "avatar",
|
pilotId: "avatar",
|
||||||
destinationId: "moon",
|
destinationId: "earth",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not offer reposition to in-flight ships", async () => {
|
it("does not offer a deadhead to in-flight ships", async () => {
|
||||||
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
|
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
|
||||||
const flying = (await screen.findByText("Slow Ox")).closest("li")!;
|
const flying = (await screen.findByText("Slow Ox")).closest("li")!;
|
||||||
expect(within(flying).queryByRole("button", { name: "Reposition" })).not.toBeInTheDocument();
|
expect(
|
||||||
|
within(flying).queryByRole("button", { name: "Deadhead (fly empty)" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,6 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
|
||||||
- [x] **Step 10B.5 — Server: no more stranded ships.** Contract and deadhead dispatch never consulted bays after 10B.2/10B.4; this step proves it. `stranded.test.ts` covers the original bug: a Courier delivers Earth→Moon with no Moon bay, takes a Moon→Earth job from the Moon board and lands at Earth; separately it deadheads Moon→Earth. The Step 6.11 test now deadheads to the Moon without a Moon bay.
|
- [x] **Step 10B.5 — Server: no more stranded ships.** Contract and deadhead dispatch never consulted bays after 10B.2/10B.4; this step proves it. `stranded.test.ts` covers the original bug: a Courier delivers Earth→Moon with no Moon bay, takes a Moon→Earth job from the Moon board and lands at Earth; separately it deadheads Moon→Earth. The Step 6.11 test now deadheads to the Moon without a Moon bay.
|
||||||
- [x] **Step 10B.6 — Server: ship delivery to a bay.** `buyShip` takes `{ modelId, bayId }`; the ship appears idle and uncrewed at that bay's location. Missing `bayId` is `400 validation-error`; a bay the player does not own is `404 not-found` with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return `earthBayId`.
|
- [x] **Step 10B.6 — Server: ship delivery to a bay.** `buyShip` takes `{ modelId, bayId }`; the ship appears idle and uncrewed at that bay's location. Missing `bayId` is `400 validation-error`; a bay the player does not own is `404 not-found` with nothing changed. The Market sends the first bay's id until the 10B.9 picker. Test helpers return `earthBayId`.
|
||||||
- [x] **Step 10B.7 — Client: Contracts screen by location.** `LocationBoards` (was `BayBoards`) shows a Location button group of every discovered location with an idle-ship count badge, defaulting to the most recently arrived idle ship's location (else Earth); `?location=` selects one (map link and Fleet's new "Find work here"). API: `Ship.lastArrivalAt`. Tests: `location-boards.test.tsx`, `last-arrival.test.ts`.
|
- [x] **Step 10B.7 — Client: Contracts screen by location.** `LocationBoards` (was `BayBoards`) shows a Location button group of every discovered location with an idle-ship count badge, defaulting to the most recently arrived idle ship's location (else Earth); `?location=` selects one (map link and Fleet's new "Find work here"). API: `Ship.lastArrivalAt`. Tests: `location-boards.test.tsx`, `last-arrival.test.ts`.
|
||||||
- [ ] Step 10B.8 — Client: deadhead action
|
- [x] **Step 10B.8 — Client: deadhead action.** Fleet's "Reposition" is now "Deadhead (fly empty)" on every idle ship. The picker lists discovered locations except the ship's own, each with sim fuel cost and trip time; destinations the sim rejects (out of range, can't land, fuel, no pilot) are disabled with the reason. Test: `fleet.test.tsx`.
|
||||||
- [ ] Step 10B.9 — Client: bays as HQs
|
- [ ] Step 10B.9 — Client: bays as HQs
|
||||||
- [ ] Step 10B.10 — Plan amendments and regression
|
- [ ] Step 10B.10 — Plan amendments and regression
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue