diff --git a/DECISIONS.md b/DECISIONS.md index 241aed1..1eb9a1a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1772,3 +1772,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Decision 0.5.133: the qualifying fleet is the player's own.** The player can't send an Independent pilot, so jobs only that pilot could fly were noise on the board. They are left out of both generation and the read filter. Independent pilots aren't harmed: they choose with `validateDispatch` against their own pair, and when they have no fitting job they wait or head home as before. - **Fallback when no Manual pair exists**: the whole fleet counts, so a company whose only flyable ships are Independent still gets a board for them. - **An Independent pilot's ship stays out even when another pilot is spare**: it stays assigned to the Independent pilot, so pairing it with an unassigned pilot would offer jobs the player can't send without first taking that pilot off it. + +## Fix: ready ships, not idle ships + +- **Decision 0.5.134: the Contracts chips count ships ready for a job.** "Idle" counted ships whose pilot was resting, so a location looked like it had a pilot available when none could fly. Uncrewed ships, ships in the shop and Independent pilots' ships don't count either: none of them can take a job the player picks. The chip's label is now "N ship(s) ready". +- **Rest is checked against the clock on the board**, not the `resting` flag from the last state fetch, so the count changes when rest ends. The Jobs tab badge keeps using the flag, to avoid re-rendering the whole app every second. +- **The Dispatch sheet is unchanged**: it already lists a resting pilot's ship as blocked, with the time left. diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index 984071b..3419455 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -1,8 +1,9 @@ import { content, type Location, type ShipModel } from "@deadhead/content"; -import type { Ship } from "@deadhead/schema"; +import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; import { checkRoute, effectiveStats, + isInShop, MS_PER_HOUR, routeDistance, travelTimeGameHours, @@ -152,3 +153,39 @@ export function shipReach(model: ShipModel, originIds: string[]): ShipReach { byHopping: ids.filter((id) => reached.has(id) && !direct.has(id) && !origins.has(id)), }; } + +/** + * Why an idle ship can't take a job now, or null when it can: no pilot, the + * pilot resting, the ship in the shop, or an Independent pilot flying it on + * their own (decision 0.5.134). Without `now`, rest is the server's `resting` + * flag and the shop isn't checked. + */ +export function notReady( + state: Pick, + ship: Ship, + now?: number, +): { reason: "no-pilot" | "independent" | "in-shop" } | { reason: "resting"; pilot: Pilot } | null { + const pilot = state.pilots.find((p) => p.assignedShipId === ship.id); + if (!pilot) return { reason: "no-pilot" }; + if (pilot.mode === "independent") return { reason: "independent" }; + const resting = + now === undefined ? pilot.resting : pilot.restEndsAt !== null && pilot.restEndsAt > now; + if (resting) return { reason: "resting", pilot }; + if (now !== undefined && isInShop(ship, now)) return { reason: "in-shop" }; + return null; +} + +/** Ships docked at `locationId` (or anywhere) that a job could go out on now. */ +export function readyShips( + state: Pick, + locationId?: string, + now?: number, +): Ship[] { + return state.ships.filter( + (s) => + s.status === "idle" && + s.locationId !== null && + (locationId === undefined || s.locationId === locationId) && + notReady(state, s, now) === null, + ); +} diff --git a/apps/web/src/layout/attention.ts b/apps/web/src/layout/attention.ts index 4fa6cf0..62128d6 100644 --- a/apps/web/src/layout/attention.ts +++ b/apps/web/src/layout/attention.ts @@ -1,23 +1,17 @@ import type { PlayerState } from "@deadhead/schema"; +import { readyShips } from "../game"; import type { ScreenPath } from "../routes"; /** * Counts for the navigation's attention badges (decision 0.5.49): goals ready - * to claim, ships ready for a job, and unspent skill points across all pilots - * (decision 0.5.109). + * to claim, ships ready for a job (decision 0.5.134), and unspent skill points + * across all pilots (decision 0.5.109). */ export function tabAttention(state: PlayerState): Partial> { - const restingIds = new Set(state.pilots.filter((p) => p.resting).map((p) => p.id)); - const readyShips = state.tutorial - ? state.tutorial.runInFlight - ? 0 - : 1 - : state.ships.filter( - (s) => s.status === "idle" && s.pilotId !== null && !restingIds.has(s.pilotId), - ).length; + const ready = state.tutorial ? (state.tutorial.runInFlight ? 0 : 1) : readyShips(state).length; return { "/": state.goals.filter((g) => g.status === "ready").length, - "/contracts": readyShips, + "/contracts": ready, "/pilots": state.pilots.reduce((sum, p) => sum + p.skillPointsAvailable, 0), }; } diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index e299888..ec10ba6 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -6,7 +6,7 @@ import { useApi } from "../api/context"; import { serverNow, useClock } from "../clock/clock"; import { Countdown } from "../components/Countdown"; import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format"; -import { distanceAt } from "../game"; +import { distanceAt, notReady, readyShips } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { pairOptions } from "./DispatchSheet"; import { DeadheadPicker } from "./Fleet"; @@ -89,8 +89,9 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) = 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; + const now = useClock((s) => s.now); + // Only ships a job could go out on now count (decision 0.5.134). + const readyAt = (id: string) => readyShips(state, id, now).length; return (
@@ -100,7 +101,7 @@ export function LocationBoards({ onSelect }: { onSelect?: (contract: Contract) = className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1" > {discovered.map((id) => { - const idle = idleAt(id); + const ready = readyAt(id); const sprite = content.location(id)?.sprite; return ( ); })}
- {idleAt(locationId) === 0 && ( -

- - No idle ships here. Deadhead one in from the Fleet screen to take these jobs. -

- )} + {readyAt(locationId) === 0 && } ); @@ -298,6 +294,34 @@ function LocationBoard({ ); } +/** + * Why no ship here can take a job now (decision 0.5.134): none docked, or the + * pilots here resting (with who wakes first), or none crewed for the player. + */ +function NoneReady({ locationId, now }: { locationId: string; now: number }) { + const state = useLoadedPlayer(); + const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === locationId); + const waking = idle + .map((s) => notReady(state, s, now)) + .flatMap((r) => (r?.reason === "resting" ? [r.pilot] : [])) + .sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0]; + return ( +

+ + {idle.length === 0 ? ( + "No idle ships here. Deadhead one in from the Fleet screen to take these jobs." + ) : waking ? ( + + No pilot here is ready to fly. {waking.name} is resting, ready in{" "} + . + + ) : ( + "No ship here is ready for a job right now." + )} +

+ ); +} + /** * Idle, crewed ships here that cannot take any contract on this board (CR-004): * offer to rest the pilot, who wakes to a new board, or to deadhead elsewhere. diff --git a/apps/web/test/location-boards.test.tsx b/apps/web/test/location-boards.test.tsx index 5aab2a6..95c6fae 100644 --- a/apps/web/test/location-boards.test.tsx +++ b/apps/web/test/location-boards.test.tsx @@ -1,4 +1,4 @@ -import type { Contract, Ship } from "@deadhead/schema"; +import type { Contract, Pilot, 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"; @@ -61,18 +61,28 @@ const ship = (id: string, overrides: Partial): Ship => ({ ...overrides, }); -/** Only an Earth bay; one idle ship at Earth, two at the Moon (the newest arrival). */ -const owner = () => - samplePlayerState({ +/** Only an Earth bay; one crewed idle ship at Earth, two at the Moon (the newest arrival). */ +const owner = () => { + const base = samplePlayerState(); + const crew = (id: string, shipId: string, locationId: string): Pilot => ({ + ...base.pilots[0]!, + id, + isAvatar: id === "avatar", + assignedShipId: shipId, + locationId, + }); + return 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("s1", { locationId: "earth", pilotId: "avatar", lastArrivalAt: NOW - 120_000 }), + ship("s2", { locationId: "moon", pilotId: "p2", lastArrivalAt: NOW - 1000 }), + ship("s3", { locationId: "moon", pilotId: "p3", lastArrivalAt: NOW - 90_000 }), ship("s4", { status: "in-flight", locationId: null, dispatchId: "d", lastArrivalAt: NOW }), ], + pilots: [crew("avatar", "s1", "earth"), crew("p2", "s2", "moon"), crew("p3", "s3", "moon")], }); +}; const boardApi = () => fakeApi({ @@ -91,7 +101,7 @@ describe("location contract boards", () => { }); afterEach(() => vi.useRealTimers()); - it("lists every discovered location, none undiscovered, with idle-ship badges", async () => { + it("lists every discovered location, none undiscovered, with ready-ship badges", async () => { renderApp(boardApi(), "/contracts"); const group = await screen.findByRole("group", { name: "Location" }); const names = within(group) @@ -99,10 +109,10 @@ describe("location contract boards", () => { .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"), + within(group).getByLabelText("1 ship ready"), ); expect(within(group).getByRole("button", { name: /Moon/ })).toContainElement( - within(group).getByLabelText("2 idle ships"), + within(group).getByLabelText("2 ships ready"), ); }); diff --git a/apps/web/test/ready-ships.test.tsx b/apps/web/test/ready-ships.test.tsx new file mode 100644 index 0000000..3abea54 --- /dev/null +++ b/apps/web/test/ready-ships.test.tsx @@ -0,0 +1,96 @@ +import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; +import { screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { tabAttention } from "../src/layout/attention"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const ship = (id: string, pilotId: string): Ship => ({ + id, + modelId: "courier", + name: id, + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: NOW - 60_000, + ...shipExtras("courier"), + pilotId, +}); + +const base = samplePlayerState(); +const pilot = (id: string, shipId: string, overrides: Partial = {}): Pilot => ({ + ...base.pilots[0]!, + id, + name: id, + isAvatar: id === "avatar", + assignedShipId: shipId, + locationId: null, + ...overrides, +}); + +const resting = { resting: true, restEndsAt: NOW + 2 * 60 * 60_000 }; + +/** Two ships at Earth: the avatar's and Rosa's, with the pilots as given. */ +const owner = (avatar: Partial, rosa: Partial): PlayerState => + samplePlayerState({ + tutorial: null, + bays: [{ id: "b1", locationId: "earth" }], + ships: [ship("s1", "avatar"), ship("s2", "rosa")], + pilots: [pilot("avatar", "s1", avatar), pilot("Rosa", "s2", { id: "rosa", ...rosa })], + }); + +const show = (state: PlayerState) => + renderApp( + fakeApi({ + getPlayerState: () => state, + getContractBoard: () => ({ contracts: [], periodEndsAt: NOW + 30 * 60_000 }), + }), + "/contracts?location=earth", + ); + +describe("ready ships on the Contracts screen (decision 0.5.134)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("doesn't count a ship whose pilot is resting", async () => { + show(owner({}, resting)); + const group = await screen.findByRole("group", { name: "Location" }); + const earth = within(group).getByRole("button", { name: /Earth/ }); + expect(within(earth).getByLabelText("1 ship ready")).toHaveTextContent("1"); + }); + + it("doesn't count an Independent pilot's ship", async () => { + show(owner({}, { mode: "independent" })); + const group = await screen.findByRole("group", { name: "Location" }); + expect(within(group).getByLabelText("1 ship ready")).toBeInTheDocument(); + }); + + it("says who is resting and when they're ready when no pilot here can fly", async () => { + show(owner(resting, { ...resting, restEndsAt: NOW + 30 * 60_000 })); + const group = await screen.findByRole("group", { name: "Location" }); + expect(within(group).queryByLabelText(/ready/)).not.toBeInTheDocument(); + const status = await screen.findByText(/No pilot here is ready to fly/); + // The first to wake. + expect(status).toHaveTextContent("Rosa is resting, ready in 30:00"); + expect(screen.queryByText(/No idle ships here/)).not.toBeInTheDocument(); + }); + + it("counts a pilot once their rest is over, without waiting for a new state", async () => { + show(owner({ resting: true, restEndsAt: NOW - 1 }, resting)); + const group = await screen.findByRole("group", { name: "Location" }); + expect(within(group).getByLabelText("1 ship ready")).toBeInTheDocument(); + }); + + it("leaves resting and Independent pilots' ships out of the Jobs badge", () => { + expect(tabAttention(owner({}, {}))["/contracts"]).toBe(2); + expect(tabAttention(owner({}, resting))["/contracts"]).toBe(1); + expect(tabAttention(owner({}, { mode: "independent" }))["/contracts"]).toBe(1); + expect(tabAttention(owner(resting, resting))["/contracts"]).toBe(0); + }); +}); diff --git a/docs/screenshots/fix-ready-ships-resting-desktop.png b/docs/screenshots/fix-ready-ships-resting-desktop.png new file mode 100644 index 0000000..d4fc3b4 Binary files /dev/null and b/docs/screenshots/fix-ready-ships-resting-desktop.png differ diff --git a/docs/screenshots/fix-ready-ships-resting-phone.png b/docs/screenshots/fix-ready-ships-resting-phone.png new file mode 100644 index 0000000..2c13347 Binary files /dev/null and b/docs/screenshots/fix-ready-ships-resting-phone.png differ diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index d742547..669d0f3 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -189,6 +189,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 131. **Welcome on every new company** (CR-022). Three welcome cards (what the game is, how it plays, where you start), with text in content, shown once per company right after it's created ("Start your company" or "Start a new company"), before the CR-001 tutorial intro card. The save records that this company's welcome was seen; saves from before CR-022 count as seen. 132. **How to play** (CR-022). Settings → Help reopens the three cards any time, ending in "Close"; it doesn't change the saved flag. 133. **Boards are for the player's pilots** (fix after CR-021). The qualifying fleet (0.5.115) counts only Manual pilots and the ships not assigned to an Independent pilot, so the player never sees jobs only an Independent pilot could fly; Independent pilots check each job against their own pair. With no Manual pair at all, the whole fleet counts. +134. **Ready ships** (fix after CR-022). The Contracts screen's location chips and the Jobs tab badge count only ships a job could go out on now: idle, with an assigned pilot who isn't resting or Independent, and not in the shop. Where idle ships are docked but none is ready, the board says so and names the pilot who wakes first. ### 0.6 Translating change requests written for the server (CR-014 §7) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 3c21d71..cd28c7d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -504,3 +504,4 @@ Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-0 ## Fixes after Phase 22 - [x] **Boards ignore Independent pilots (decision 0.5.133).** Reported: with Rosa Independent, the board still showed jobs only that pilot could fly. The board's qualifying fleet now leaves out Independent pilots and their ships, both when a board is generated and when it's read, so a board already stored this period hides those jobs at once. Independent pilots still check each job against their own pair; with no Manual pair at all, the whole fleet counts. Tests (`packages/engine/test/completable-boards.test.ts`): heavy jobs vanish from the board when the Freighter's pilot goes Independent, this period and the next five; the fallback to the whole fleet. +- [x] **Ready ships, not idle ships (decision 0.5.134).** Reported: a resting pilot still showed as available on the board. The location chips on Contracts counted every idle ship, so a ship whose pilot was resting still showed as ready. They now count ships ready for a job (`readyShips` in `apps/web/src/game.ts`): crewed, the pilot not resting (checked against the clock, so the count updates the moment rest ends) or Independent, and not in the shop. The Jobs tab badge uses the same rule. With idle ships here but none ready, the board says "No pilot here is ready to fly" and who wakes first. Tests (`apps/web/test/ready-ships.test.tsx`, `location-boards.test.tsx`). Screenshots: `docs/screenshots/fix-ready-ships-resting-{phone,desktop}.png`.