Step 10B.7: Contracts screen by location with idle-ship badges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61781aa790
commit
962c0601c8
|
|
@ -649,3 +649,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Another player's bay id is `404 not-found` ("You have no such bay")**, the same as an id that does not exist, so ids of other players' bays are not confirmed. It is checked after the model and before any credits are spent.
|
||||
- **`SHIPYARD_LOCATION_ID` is gone:** the delivery location is always the chosen bay's.
|
||||
- **Interim client:** the Market buys for the player's first bay (always Earth today) so the build stays green; Step 10B.9 replaces this with the "Deliver to which HQ?" picker.
|
||||
|
||||
## Step 10B.7
|
||||
|
||||
- **`Ship.lastArrivalAt` is never null:** the latest resolved arrival of that ship, else its delivery time (`created_at`). A newly bought ship counts as having just arrived at its bay, which is what the default-location rule wants.
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -123,5 +123,6 @@ export async function buyShip(
|
|||
locationId: ship!.locationId,
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: ship!.createdAt,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq, isNull, max } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import {
|
||||
bays,
|
||||
|
|
@ -32,6 +32,11 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
.select()
|
||||
.from(dispatches)
|
||||
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "in_flight")));
|
||||
const arrivals = await db
|
||||
.select({ shipId: dispatches.shipId, at: max(dispatches.arrivalAt) })
|
||||
.from(dispatches)
|
||||
.where(and(eq(dispatches.playerId, playerId), eq(dispatches.status, "resolved")))
|
||||
.groupBy(dispatches.shipId);
|
||||
const unacknowledged = await db
|
||||
.select()
|
||||
.from(dispatches)
|
||||
|
|
@ -46,6 +51,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
const discoveredIds = new Set(discovered.map((d) => d.locationId));
|
||||
const shipById = new Map(shipRows.map((s) => [s.id, s]));
|
||||
const activeByShip = new Map(active.filter((d) => d.shipId).map((d) => [d.shipId!, d.id]));
|
||||
const lastArrivalByShip = new Map(arrivals.map((a) => [a.shipId, a.at]));
|
||||
const pilotByShip = new Map(
|
||||
pilotRows.filter((p) => p.assignedShipId).map((p) => [p.assignedShipId!, p.id]),
|
||||
);
|
||||
|
|
@ -65,6 +71,7 @@ export async function loadPlayerState(db: Queryable, playerId: string): Promise<
|
|||
locationId: s.status === "in_flight" ? null : s.locationId,
|
||||
dispatchId: activeByShip.get(s.id) ?? null,
|
||||
pilotId: pilotByShip.get(s.id) ?? null,
|
||||
lastArrivalAt: lastArrivalByShip.get(s.id) ?? s.createdAt,
|
||||
})),
|
||||
pilots: pilotRows.map((p) =>
|
||||
toApiPilot(p, p.assignedShipId ? (shipById.get(p.assignedShipId)?.locationId ?? null) : null),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { TEST_NOW, useTestDatabase } from "./db";
|
||||
import { getState, insertDispatch, signUpOwner, testContract, useTestApp } from "./game";
|
||||
|
||||
const database = useTestDatabase();
|
||||
const testApp = useTestApp(database);
|
||||
|
||||
describe("ship lastArrivalAt (CR-002)", () => {
|
||||
it("is the delivery time before a flight and the latest arrival after flights", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const owner = await signUpOwner(app, database().db);
|
||||
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(TEST_NOW);
|
||||
|
||||
const out = await insertDispatch(database().db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract(),
|
||||
});
|
||||
clock.now = out.arrivalAt;
|
||||
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(out.arrivalAt);
|
||||
|
||||
const back = await insertDispatch(database().db, {
|
||||
playerId: owner.playerId,
|
||||
shipId: owner.shipId,
|
||||
pilotId: owner.avatarId,
|
||||
departAt: clock.now,
|
||||
contract: testContract({ originId: "moon", destinationId: "earth" }),
|
||||
});
|
||||
clock.now = back.arrivalAt + 1000;
|
||||
expect((await getState(app, owner)).ships[0]!.lastArrivalAt).toBe(back.arrivalAt);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { useState } from "react";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { BayBoards } from "./BayBoards";
|
||||
import { LocationBoards } from "./LocationBoards";
|
||||
import { DispatchSheet } from "./DispatchSheet";
|
||||
import { TutorialBoard } from "./TutorialBoard";
|
||||
|
||||
/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */
|
||||
/** Contracts: the tutorial board during the tutorial, location boards afterwards. */
|
||||
export function Contracts() {
|
||||
const state = useLoadedPlayer();
|
||||
const [selected, setSelected] = useState<Contract | null>(null);
|
||||
return (
|
||||
<section className="space-y-3 p-4">
|
||||
<h1 className="text-xl font-semibold">Contracts</h1>
|
||||
{state.tutorial ? <TutorialBoard /> : <BayBoards onSelect={setSelected} />}
|
||||
{state.tutorial ? <TutorialBoard /> : <LocationBoards onSelect={setSelected} />}
|
||||
{selected && <DispatchSheet contract={selected} onClose={() => setSelected(null)} />}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { useState } from "react";
|
|||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
|
||||
import { requirementsText } from "./BayBoards";
|
||||
import { requirementsText } from "./LocationBoards";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,14 @@ function ShipRow(props: {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ship.status === "idle" && ship.locationId && (
|
||||
<Link
|
||||
to={`/contracts?location=${ship.locationId}`}
|
||||
className="mt-2 mr-2 inline-block rounded-md border border-slate-700 px-3 py-1 text-sm"
|
||||
>
|
||||
Find work here
|
||||
</Link>
|
||||
)}
|
||||
{props.canReposition && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { useClock } from "../clock/clock";
|
||||
|
|
@ -9,7 +8,7 @@ import { cargoName, formatCredits, formatMillionKm, locationName, skillName } fr
|
|||
import { distanceAt } from "../game";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
|
||||
export const bayBoardKey = (locationId: string) => ["board", "bay", locationId] as const;
|
||||
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
|
||||
|
||||
/** Skills a contract needs, e.g. "Heavy Cargo 2, Long Haul 1", or null. */
|
||||
export function requirementsText(contract: Contract): string | null {
|
||||
|
|
@ -22,47 +21,65 @@ export function requirementsText(contract: Contract): string | 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();
|
||||
// `?bay=moon` (e.g. from the map) preselects that bay.
|
||||
const [params] = useSearchParams();
|
||||
const [selected, setSelected] = useState(params.get("bay") ?? state.bays[0]?.locationId);
|
||||
const bayId = state.bays.some((b) => b.locationId === selected)
|
||||
? selected
|
||||
: state.bays[0]?.locationId;
|
||||
/**
|
||||
* The location the Contracts screen opens on: where the most recently arrived
|
||||
* idle ship is, otherwise Earth (CR-002).
|
||||
*/
|
||||
export function defaultBoardLocation(state: PlayerState): string {
|
||||
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId);
|
||||
idle.sort((a, b) => b.lastArrivalAt - a.lastArrivalAt);
|
||||
return idle[0]?.locationId ?? "earth";
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
/** Freight markets of every discovered location, no bay needed (Steps 9.4, 10B.7). */
|
||||
export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
// `?location=moon` (from the map or Fleet) selects that location.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||
const requested = params.get("location");
|
||||
const locationId =
|
||||
requested && discovered.includes(requested) ? requested : defaultBoardLocation(state);
|
||||
const idleAt = (id: string) =>
|
||||
state.ships.filter((s) => s.status === "idle" && s.locationId === id).length;
|
||||
|
||||
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"
|
||||
<div role="group" aria-label="Location" className="flex flex-wrap gap-2">
|
||||
{discovered.map((id) => {
|
||||
const idle = idleAt(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={id === locationId}
|
||||
onClick={() => setParams({ location: id }, { replace: true })}
|
||||
className="flex items-center gap-1.5 rounded-full border border-slate-700 px-3 py-1 text-sm aria-pressed:border-amber-400 aria-pressed:bg-amber-500/15 aria-pressed:text-amber-200"
|
||||
>
|
||||
{state.bays.map((bay) => (
|
||||
<option key={bay.id} value={bay.locationId}>
|
||||
{locationName(bay.locationId)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<BayBoard locationId={bayId} onSelect={onSelect} />
|
||||
{locationName(id)}
|
||||
{idle > 0 && (
|
||||
<span
|
||||
aria-label={`${idle} idle ${idle === 1 ? "ship" : "ships"}`}
|
||||
className="rounded-full bg-sky-500/25 px-1.5 text-xs tabular-nums text-sky-200"
|
||||
>
|
||||
{idle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{idleAt(locationId) === 0 && (
|
||||
<p className="text-sm text-slate-400">
|
||||
No idle ships here. Deadhead one in from the Fleet screen to take these jobs.
|
||||
</p>
|
||||
)}
|
||||
<LocationBoard locationId={locationId} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BayBoard({
|
||||
function LocationBoard({
|
||||
locationId,
|
||||
onSelect,
|
||||
}: {
|
||||
|
|
@ -72,7 +89,7 @@ function BayBoard({
|
|||
const api = useApi();
|
||||
const now = useClock((s) => s.now);
|
||||
const board = useQuery({
|
||||
queryKey: bayBoardKey(locationId),
|
||||
queryKey: locationBoardKey(locationId),
|
||||
queryFn: () => api.call("getContractBoard", { locationId }),
|
||||
});
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
|||
</p>
|
||||
{hasBay ? (
|
||||
<Link
|
||||
to={`/contracts?bay=${location.id}`}
|
||||
to={`/contracts?location=${location.id}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
View contract board
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const ship: Ship = {
|
|||
status: "idle",
|
||||
locationId: "mars",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ const afterDeal = () =>
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ function afterDeal(): PlayerState {
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "avatar",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const ship = (id: string, name: string, modelId: string, pilotId: string | null)
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ship = (id: string, status: Ship["status"]): Ship => ({
|
|||
status,
|
||||
locationId: status === "idle" ? "earth" : null,
|
||||
dispatchId: status === "idle" ? null : `d-${id}`,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
import type { Contract, Ship } 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 ship = (id: string, overrides: Partial<Ship>): Ship => ({
|
||||
id,
|
||||
modelId: "courier",
|
||||
name: id,
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: NOW - 60_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Only an Earth bay; one idle ship at Earth, two at the Moon (the newest arrival). */
|
||||
const owner = () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships: [
|
||||
ship("s1", { locationId: "earth", lastArrivalAt: NOW - 120_000 }),
|
||||
ship("s2", { locationId: "moon", lastArrivalAt: NOW - 1000 }),
|
||||
ship("s3", { locationId: "moon", lastArrivalAt: NOW - 90_000 }),
|
||||
ship("s4", { status: "in-flight", locationId: null, dispatchId: "d", lastArrivalAt: NOW }),
|
||||
],
|
||||
});
|
||||
|
||||
const boardApi = () =>
|
||||
fakeApi({
|
||||
getPlayerState: owner,
|
||||
getContractBoard: ({ locationId }) => ({
|
||||
contracts: boards[locationId] ?? [],
|
||||
periodEndsAt: NOW + 30 * 60_000,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("location contract boards", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("lists every discovered location, none undiscovered, with idle-ship badges", async () => {
|
||||
renderApp(boardApi(), "/contracts");
|
||||
const group = await screen.findByRole("group", { name: "Location" });
|
||||
const names = within(group)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent?.replace(/[0-9]+$/, ""));
|
||||
expect(names).toEqual(["Earth", "Moon"]);
|
||||
expect(within(group).getByRole("button", { name: /Earth/ })).toContainElement(
|
||||
within(group).getByLabelText("1 idle ship"),
|
||||
);
|
||||
expect(within(group).getByRole("button", { name: /Moon/ })).toContainElement(
|
||||
within(group).getByLabelText("2 idle ships"),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens on the most recently arrived idle ship's location, and switches on tap", async () => {
|
||||
renderApp(boardApi(), "/contracts");
|
||||
expect(await screen.findByText("1,234 cr")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Earth/ }));
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to Earth when no ship is idle", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => samplePlayerState({ tutorial: null }),
|
||||
getContractBoard: ({ locationId }) => ({
|
||||
contracts: boards[locationId] ?? [],
|
||||
periodEndsAt: NOW + 30 * 60_000,
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/contracts");
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No idle ships here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the location named in the address", async () => {
|
||||
renderApp(boardApi(), "/contracts?location=earth");
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("'Find work here' on the Fleet opens that ship's location board", async () => {
|
||||
renderApp(boardApi(), "/fleet");
|
||||
const row = (await screen.findByText("s1")).closest("li")!;
|
||||
await userEvent.click(within(row).getByRole("link", { name: "Find work here" }));
|
||||
expect(await screen.findByText("1,999 cr")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contract details, requirements, expiry and the first-contact badge", async () => {
|
||||
renderApp(boardApi(), "/contracts?location=earth");
|
||||
|
||||
await screen.findByText("1,999 cr");
|
||||
const items = screen.getAllByRole("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();
|
||||
});
|
||||
});
|
||||
|
|
@ -41,6 +41,7 @@ function mapState(): PlayerState {
|
|||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "p-rae",
|
||||
},
|
||||
],
|
||||
|
|
@ -106,7 +107,7 @@ describe("map panels", () => {
|
|||
expect(panel).toHaveTextContent(/from Earth/);
|
||||
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
|
||||
"href",
|
||||
"/contracts?bay=moon",
|
||||
"/contracts?location=moon",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -146,10 +147,10 @@ describe("map panels", () => {
|
|||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the board link opens Contracts with that bay selected", async () => {
|
||||
it("the board link opens Contracts with that location selected", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
|
||||
expect(await screen.findByLabelText("Bay")).toHaveValue("moon");
|
||||
expect(await screen.findByRole("button", { name: /Moon/, pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ describe("ship markers", () => {
|
|||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
lastArrivalAt: 0,
|
||||
pilotId: "p1",
|
||||
},
|
||||
{
|
||||
|
|
@ -70,6 +71,7 @@ describe("ship markers", () => {
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
},
|
||||
{
|
||||
|
|
@ -79,6 +81,7 @@ describe("ship markers", () => {
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ describe("market screen", () => {
|
|||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
};
|
||||
let owned: Ship[] = [];
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const ship = (overrides: Partial<Ship>): Ship => ({
|
|||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
lastArrivalAt: 0,
|
||||
pilotId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -96,9 +96,9 @@ describe("empty states", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("Contracts: no bays explains how to get contracts", async () => {
|
||||
it("Contracts: no idle ships explains how to take the jobs", async () => {
|
||||
show("/contracts");
|
||||
expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/No idle ships here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Contracts: an empty bay board says to check back", async () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ Living reference for how the code is organised. Update it after any database sch
|
|||
|---|---|
|
||||
| `ServerTime` | `realTime`, `gameTime` |
|
||||
| `Company` | `id`, `name`, `credits`, `loanBalance` |
|
||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
|
||||
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId`, `lastArrivalAt` (latest resolved arrival, else delivery time; picks the default Contracts location) |
|
||||
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
|
||||
| `Bay` | `id`, `locationId` |
|
||||
| `LocationState` | `id`, `discovered` |
|
||||
|
|
@ -450,7 +450,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
|||
| `src/App.tsx` | Clock sync; loading / start screen / routed layout |
|
||||
| `src/layout/Layout.tsx` | Header (credits, game clock), screen outlet, bottom tab bar |
|
||||
| `src/routes.ts` | Screen list shared by router and navigation |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
||||
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions record
|
|||
- [x] **Step 10B.4 — Server: board endpoint for any discovered location.** `getContractBoard` is now `GET /api/locations/:locationId/board` (`locationBoard` in `src/game/boards.ts`): any discovered location, no bay needed; `409 location-undiscovered` for undiscovered, `404` for unknown. Step 6.9 tests moved to `location-board.test.ts`.
|
||||
- [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`.
|
||||
- [ ] Step 10B.7 — Client: Contracts screen by location
|
||||
- [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
|
||||
- [ ] Step 10B.9 — Client: bays as HQs
|
||||
- [ ] Step 10B.10 — Plan amendments and regression
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ export const shipSchema = z.object({
|
|||
/** The active dispatch while in flight; null when idle. */
|
||||
dispatchId: idSchema.nullable(),
|
||||
pilotId: idSchema.nullable(),
|
||||
/**
|
||||
* When the ship last arrived somewhere: its latest resolved flight, or its
|
||||
* delivery if it has not flown. Picks the default Contracts location (CR-002).
|
||||
*/
|
||||
lastArrivalAt: timeSchema,
|
||||
});
|
||||
export type Ship = z.infer<typeof shipSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const ship = {
|
|||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: "p1",
|
||||
lastArrivalAt: 4_102_444_800_000,
|
||||
};
|
||||
const pilot = {
|
||||
id: "p1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue