Step 9.4: add bay contract boards
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8f7444b7ec
commit
ea31eeab59
|
|
@ -412,3 +412,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **Trip-time predictions use sim with the content the server uses** (`src/game.ts`: distance at the current server time, `travelTimeGameHours`, converted to real time). They are predictions only (rule 0.1.6).
|
- **Trip-time predictions use sim with the content the server uses** (`src/game.ts`: distance at the current server time, `travelTimeGameHours`, converted to real time). They are predictions only (rule 0.1.6).
|
||||||
- **Boards are TanStack queries under the `["board", …]` key**, which every game action invalidates, so a flown contract disappears from the list.
|
- **Boards are TanStack queries under the `["board", …]` key**, which every game action invalidates, so a flown contract disappears from the list.
|
||||||
|
|
||||||
|
## Step 9.4
|
||||||
|
|
||||||
|
- **The distance shown on a contract is the current distance** (sim, at the server clock), which is what fuel and travel time use when dispatching now. The payout was fixed at the distance when the contract was offered (decision 0.5.11).
|
||||||
|
- **Requirements are shown as text** ("Needs Heavy Cargo 2, Long Haul 1"); whether a particular pilot meets them is shown in the dispatch sheet (Step 9.5).
|
||||||
|
- **The bay selector is a native `<select>`**, which works well on phones.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { Contract } from "@deadhead/schema";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useApi } from "../api/context";
|
||||||
|
import { useClock } from "../clock/clock";
|
||||||
|
import { Countdown } from "../components/Countdown";
|
||||||
|
import { cargoName, formatCredits, formatMillionKm, locationName, skillName } from "../format";
|
||||||
|
import { distanceAt } from "../game";
|
||||||
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
|
export const bayBoardKey = (locationId: string) => ["board", "bay", locationId] as const;
|
||||||
|
|
||||||
|
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
|
||||||
|
export function requirementsText(contract: Contract): string | null {
|
||||||
|
const parts = [
|
||||||
|
contract.requiredSkill
|
||||||
|
? `${skillName(contract.requiredSkill.skillId)} ${contract.requiredSkill.level}`
|
||||||
|
: null,
|
||||||
|
contract.requiredLongHaulLevel > 0 ? `Long Haul ${contract.requiredLongHaulLevel}` : null,
|
||||||
|
].filter((p): p is string => p !== null);
|
||||||
|
return parts.length > 0 ? parts.join(", ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contract boards for the player's bays (Step 9.4). */
|
||||||
|
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const [selected, setSelected] = useState(state.bays[0]?.locationId);
|
||||||
|
const bayId = state.bays.some((b) => b.locationId === selected)
|
||||||
|
? selected
|
||||||
|
: state.bays[0]?.locationId;
|
||||||
|
|
||||||
|
if (!bayId) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
You have no bays yet. Lease one on the Places screen to get contracts.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-slate-400">Bay</span>
|
||||||
|
<select
|
||||||
|
aria-label="Bay"
|
||||||
|
value={bayId}
|
||||||
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
|
className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1"
|
||||||
|
>
|
||||||
|
{state.bays.map((bay) => (
|
||||||
|
<option key={bay.id} value={bay.locationId}>
|
||||||
|
{locationName(bay.locationId)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<BayBoard locationId={bayId} onSelect={onSelect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BayBoard({
|
||||||
|
locationId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
locationId: string;
|
||||||
|
onSelect?: (contract: Contract) => void;
|
||||||
|
}) {
|
||||||
|
const api = useApi();
|
||||||
|
const now = useClock((s) => s.now);
|
||||||
|
const board = useQuery({
|
||||||
|
queryKey: bayBoardKey(locationId),
|
||||||
|
queryFn: () => api.call("getContractBoard", { locationId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
|
||||||
|
if (board.isError) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="text-red-400">
|
||||||
|
Could not load contracts.{" "}
|
||||||
|
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (board.data.contracts.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-slate-400">No contracts left this period. Check back soon.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{board.data.contracts.map((contract) => {
|
||||||
|
const requirements = requirementsText(contract);
|
||||||
|
return (
|
||||||
|
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
|
{contract.firstContact && (
|
||||||
|
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
|
||||||
|
First contact
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold tabular-nums text-emerald-400">
|
||||||
|
{formatCredits(contract.payout)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-400">
|
||||||
|
{cargoName(contract.cargoTypeId)}
|
||||||
|
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
|
||||||
|
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
|
||||||
|
<Countdown to={contract.expiresAt} done="expired" />
|
||||||
|
</span>
|
||||||
|
{onSelect && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(contract)}
|
||||||
|
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
|
||||||
|
>
|
||||||
|
Dispatch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
import { BayBoards } from "./BayBoards";
|
||||||
import { TutorialBoard } from "./TutorialBoard";
|
import { TutorialBoard } from "./TutorialBoard";
|
||||||
|
|
||||||
/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */
|
/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */
|
||||||
|
|
@ -7,11 +8,7 @@ export function Contracts() {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<section className="space-y-3 p-4">
|
||||||
<h1 className="text-xl font-semibold">Contracts</h1>
|
<h1 className="text-xl font-semibold">Contracts</h1>
|
||||||
{state.tutorial ? (
|
{state.tutorial ? <TutorialBoard /> : <BayBoards />}
|
||||||
<TutorialBoard />
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-400">Bay boards coming soon.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import type { Contract } from "@deadhead/schema";
|
||||||
|
import { screen, 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 contract = (overrides: Partial<Contract>): Contract => ({
|
||||||
|
id: "c",
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
cargoTypeId: "standard",
|
||||||
|
tier: 0,
|
||||||
|
massTonnes: 50,
|
||||||
|
payout: 2000,
|
||||||
|
requiredSkill: null,
|
||||||
|
requiredLongHaulLevel: 0,
|
||||||
|
offeredAt: NOW,
|
||||||
|
expiresAt: NOW + 30 * 60_000,
|
||||||
|
firstContact: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const boards: Record<string, Contract[]> = {
|
||||||
|
earth: [
|
||||||
|
contract({ id: "e1", payout: 1999 }),
|
||||||
|
contract({
|
||||||
|
id: "e2",
|
||||||
|
destinationId: "mars",
|
||||||
|
cargoTypeId: "heavy",
|
||||||
|
tier: 2,
|
||||||
|
requiredSkill: { skillId: "heavy-cargo", level: 2 },
|
||||||
|
requiredLongHaulLevel: 1,
|
||||||
|
firstContact: true,
|
||||||
|
payout: 88_000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })],
|
||||||
|
};
|
||||||
|
|
||||||
|
const owner = () =>
|
||||||
|
samplePlayerState({
|
||||||
|
tutorial: null,
|
||||||
|
bays: [
|
||||||
|
{ id: "b1", locationId: "earth" },
|
||||||
|
{ id: "b2", locationId: "moon" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bay contract boards", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("shows the first bay's board, and switching bays shows that bay's board", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: owner,
|
||||||
|
getContractBoard: ({ locationId }) => ({
|
||||||
|
contracts: boards[locationId] ?? [],
|
||||||
|
periodEndsAt: NOW + 30 * 60_000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
renderApp(api, "/contracts");
|
||||||
|
|
||||||
|
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText("Bay"), "moon");
|
||||||
|
expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Moon → Earth")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("1,999 cr")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
getPlayerState: owner,
|
||||||
|
getContractBoard: () => ({ contracts: boards.earth!, periodEndsAt: NOW + 30 * 60_000 }),
|
||||||
|
});
|
||||||
|
renderApp(api, "/contracts");
|
||||||
|
|
||||||
|
const items = await screen.findAllByRole("listitem");
|
||||||
|
const [plain, special] = items;
|
||||||
|
expect(plain).toHaveTextContent("Standard · 50 t");
|
||||||
|
expect(plain).toHaveTextContent("No skills needed");
|
||||||
|
expect(plain).toHaveTextContent("expires in 30m 00s");
|
||||||
|
expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(special).toHaveTextContent("Earth → Mars");
|
||||||
|
expect(special).toHaveTextContent("Heavy (tier 2)");
|
||||||
|
expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1");
|
||||||
|
expect(special).toHaveTextContent(/M km/);
|
||||||
|
expect(within(special!).getByText("First contact")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains how to get contracts when the player has no bays", async () => {
|
||||||
|
renderApp(
|
||||||
|
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }),
|
||||||
|
"/contracts",
|
||||||
|
);
|
||||||
|
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
- [x] **Step 9.1 — HQ screen.** `src/screens/HQ.tsx`: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: `src/format.ts`, `Countdown`, `useLoadedPlayer` / `useGameAction` (`src/state/actions.ts`).
|
- [x] **Step 9.1 — HQ screen.** `src/screens/HQ.tsx`: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: `src/format.ts`, `Countdown`, `useLoadedPlayer` / `useGameAction` (`src/state/actions.ts`).
|
||||||
- [x] **Step 9.2 — "While you were away" summary.** `src/components/AwaySummary.tsx`: dialog over every screen listing each unacknowledged delivery (ship or "Company loaner", route, credits, XP, discovery); "Got it" hides it at once and calls `acknowledgeSummaries`.
|
- [x] **Step 9.2 — "While you were away" summary.** `src/components/AwaySummary.tsx`: dialog over every screen listing each unacknowledged delivery (ship or "Company loaner", route, credits, XP, discovery); "Got it" hides it at once and calls `acknowledgeSummaries`.
|
||||||
- [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).
|
||||||
- [ ] Step 9.4 — Bay contract boards
|
- [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.
|
||||||
- [ ] Step 9.5 — Dispatch flow
|
- [ ] Step 9.5 — Dispatch flow
|
||||||
- [ ] Step 9.6 — Fleet screen
|
- [ ] Step 9.6 — Fleet screen
|
||||||
- [ ] Step 9.7 — Pilots screen
|
- [ ] Step 9.7 — Pilots screen
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue