diff --git a/DECISIONS.md b/DECISIONS.md index e3c4013..b119b6d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2078,3 +2078,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 28.5 - **Amendments go on each amended document** as "Amended by CR-028" lines: plan Step 9.2, CR-024, CR-025 and CR-026. + +## Decision 0.5.164 — "Find work here" when a ship can't go (amendment to CR-002 Step 10B.7) + +- **Received as an amendment, recorded as plan §0.5.164.** Its references map to this repo as: CR-004 (duty and rest) = CR-004; "CR-021/CR-025" (shop rest, service) = **CR-023/CR-027**; "CR-006/CR-024" (upgrades, repairs) = **CR-009/CR-026**. +- **The button stays on the card, disabled**, so the player sees why and when; it's a button rather than a link while disabled (a link can't be). +- **The countdown is the later of the pilot's rest and the ship's shop time**: rest overlapping service runs inside the shop time, so the shop's end is usually the later one. +- **The countdown uses the player's countdown setting** (real, game or both, CR-011), with the same mm:ss as the card's shop line. +- **Unchanged:** an uncrewed ship's Deadhead button (the deadhead sheet already explains "No pilot assigned" per destination), and Deadhead hidden while in the shop. diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 8cf02ae..7578115 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -105,6 +105,7 @@ function ShipRow(props: { const inShop = ship.inShopUntil !== null && now < ship.inShopUntil; // Ships no longer refuel on their own (CR-018): say when the tank is short. const low = dispatch ? null : lowFuel(ship, props.discoveredIds); + const blocked = findWorkBlock(ship, props.pilot, now); return ( @@ -181,26 +182,47 @@ function ShipRow(props: {

)} - {ship.status === "idle" && !inShop && ( + {ship.status === "idle" && (
- {ship.locationId && ( - + + {blocked.until === null ? ( + blocked.label + ) : ( + + {blocked.label} ·{" "} + + + )} + + ) : ( + + + Find work here + + ))} + {!inShop && ( + )} -
)}
@@ -229,6 +251,25 @@ function ShipRow(props: { ); } +/** + * Why "Find work here" can't be used now (decision 0.5.164): no pilot, the + * pilot resting, or the ship in the shop (a rest beside service included), + * with the time it frees up, the later of the two; null when it can go. + */ +export function findWorkBlock( + ship: Ship, + pilot: Pilot | undefined, + now: number, +): { label: string; until: number | null } | null { + if (!pilot) return { label: "No pilot assigned", until: null }; + const rest = pilot.restEndsAt !== null && pilot.restEndsAt > now ? pilot.restEndsAt : null; + const shop = ship.inShopUntil !== null && ship.inShopUntil > now ? ship.inShopUntil : null; + if (rest === null && shop === null) return null; + if (shop !== null && (rest === null || shop >= rest)) + return { label: "In the shop", until: shop }; + return { label: "Resting", until: rest }; +} + /** Why a deadhead destination is unavailable, in plain words. */ const DEADHEAD_REASONS: Partial> = { "cannot-land": "Can't land there", diff --git a/apps/web/test/find-work.test.tsx b/apps/web/test/find-work.test.tsx new file mode 100644 index 0000000..290442b --- /dev/null +++ b/apps/web/test/find-work.test.tsx @@ -0,0 +1,110 @@ +import type { Pilot, PlayerState, Ship } from "@deadhead/schema"; +import { act, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { pilotExtras } from "./fixtures"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); +const MINUTE = 60_000; + +const ship = (over: Partial = {}): Ship => ({ + id: "s1", + name: "Wren", + modelId: "courier", + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId: "p1", + lastArrivalAt: 0, + ...shipExtras("courier"), + ...over, +}); +const pilot = (over: Partial = {}): Pilot => ({ + ...samplePlayerState().pilots[0]!, + id: "p1", + name: "Rosa", + isAvatar: false, + assignedShipId: "s1", + locationId: null, + ...pilotExtras, + ...over, +}); +const state = (s: Ship, pilots: Pilot[]): PlayerState => + samplePlayerState({ + tutorial: null, + company: { id: "c", name: "D", credits: 50_000, loanBalance: 0 }, + ships: [s], + pilots: [samplePlayerState().pilots[0]!, ...pilots], + }); + +async function card(s: Ship, pilots: Pilot[] = [pilot()]) { + renderApp(fakeApi({ getPlayerState: () => state(s, pilots) }), "/fleet"); + return (await screen.findByText("Wren")).closest("li")!; +} +const resting = (minutes: number) => pilot({ resting: true, restEndsAt: NOW + minutes * MINUTE }); + +describe('"Find work here" when the ship can\'t go (decision 0.5.164)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("links to the board when the ship can be sent", async () => { + const li = await card(ship()); + expect(within(li).getByRole("link", { name: "Find work here" })).toHaveAttribute( + "href", + "/contracts?location=earth", + ); + }); + + it("is disabled with the rest countdown while the pilot rests", async () => { + const li = await card(ship(), [resting(4.2)]); + const button = within(li).getByRole("button", { name: /^Find work here: Resting/ }); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent("Resting · 04:12"); + expect(within(li).queryByRole("link", { name: "Find work here" })).not.toBeInTheDocument(); + }); + + it("is disabled with the shop countdown while the ship is in the shop", async () => { + const li = await card(ship({ inShopUntil: NOW + 12.5 * MINUTE })); + expect( + within(li).getByRole("button", { name: /^Find work here: In the shop/ }), + ).toHaveTextContent("In the shop · 12:30"); + }); + + it("counts down to the later of the two when both apply", async () => { + const shopFirst = await card(ship({ inShopUntil: NOW + 5 * MINUTE }), [resting(9)]); + expect(within(shopFirst).getByRole("button", { name: /Find work here/ })).toHaveTextContent( + "Resting · 09:00", + ); + }); + + it("uses the shop's end when it outlasts the rest, as with service overlapping rest", async () => { + const li = await card(ship({ inShopUntil: NOW + 20 * MINUTE }), [resting(10)]); + expect(within(li).getByRole("button", { name: /Find work here/ })).toHaveTextContent( + "In the shop · 20:00", + ); + }); + + it("says No pilot assigned for an uncrewed ship", async () => { + const li = await card(ship({ pilotId: null }), []); + const button = within(li).getByRole("button", { name: /Find work here/ }); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent("No pilot assigned"); + }); + + it("covers an Independent pilot's ship too", async () => { + const li = await card(ship(), [{ ...resting(3), mode: "independent" }]); + expect(within(li).getByRole("button", { name: /Find work here/ })).toBeDisabled(); + }); + + it("re-enables on its own when the countdown reaches zero", async () => { + const li = await card(ship(), [resting(1)]); + expect(within(li).getByRole("button", { name: /Find work here/ })).toBeDisabled(); + act(() => useClock.setState({ now: NOW + MINUTE })); + expect(within(li).getByRole("link", { name: "Find work here" })).toBeInTheDocument(); + }); +}); diff --git a/docs/screenshots/find-work-blocked-desktop.png b/docs/screenshots/find-work-blocked-desktop.png new file mode 100644 index 0000000..5074991 Binary files /dev/null and b/docs/screenshots/find-work-blocked-desktop.png differ diff --git a/docs/screenshots/find-work-blocked-phone.png b/docs/screenshots/find-work-blocked-phone.png new file mode 100644 index 0000000..48f1205 Binary files /dev/null and b/docs/screenshots/find-work-blocked-phone.png differ diff --git a/memory-bank/change-requests/CR-002-open-markets.md b/memory-bank/change-requests/CR-002-open-markets.md index 278f58b..5ce127d 100644 --- a/memory-bank/change-requests/CR-002-open-markets.md +++ b/memory-bank/change-requests/CR-002-open-markets.md @@ -87,6 +87,7 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.4 and §0.5. - Fleet screen: each idle ship gets a "Find work here" action that opens its location's board. - Update the Step 9.4 tests. - **Test:** Component test: the selector lists every discovered location and no undiscovered ones; idle-ship badges match state; the default is the most recently arrived idle ship's location; "Find work here" opens the right board. +- **Amended (decision 0.5.164):** "Find work here" is disabled when the ship can't be dispatched now: its pilot is resting (CR-004; rest beside service, CR-023/CR-027, included), it's in the shop for an upgrade, repair or service (CR-009/CR-026/CR-027), or it has no pilot. The button says why with the countdown that ends last ("Resting · 04:12", "In the shop · 12:30", "No pilot assigned") and re-enables on its own when it ends. Every ship, Independent pilots' included; the Contracts tab stays browsable. ### Step 10B.8 — Client: deadhead action - Rename the Fleet screen's "Reposition" action to "Deadhead (fly empty)". It is available for every idle ship. The destination picker lists discovered locations; ones out of range or not landable are shown disabled with the reason. Show fuel cost and trip time for each. diff --git a/memory-bank/implementation-plan.md b/memory-bank/implementation-plan.md index f4078f8..6bb2bec 100644 --- a/memory-bank/implementation-plan.md +++ b/memory-bank/implementation-plan.md @@ -219,6 +219,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple 161. **Toasts while here** (CR-028). Things that happen while the player is here show as toasts: top-right on desktop (768 px or wider), top-center below the safe area on phones; 5 s each, paused by hover or touch, dismissed by swipe or ✕; at most 3, overflow as "+N more" opening the Logbook; tapping opens the ship, pilot, place or Logbook. Independent pilots' routine deliveries within 10 s combine into one; player deliveries, discoveries, level-ups and problems get their own. Radio calls keep their card. Fleet sleep while here is a toast plus the Bridge's "Fleet asleep" panel. Announced politely; no sliding with reduced motion. 162. **Pop-up only after being away** (CR-028). On coming back (tab visible, app in front, or a fresh launch), "While you were away" opens only for unseen entries from that absence, and covers exactly those; under 60 s away they show as toasts; nothing new, nothing shown. 163. **Seen per entry** (CR-028). Each logbook entry has a saved "seen" flag, set when toasted or when the pop-up showing it closes; marking entries seen isn't a manual decision. +164. **"Find work here" when a ship can't go** (amendment to CR-002 Step 10B.7). On any Fleet card, the button is disabled while the pilot rests (rest beside service included), the ship is in the shop (upgrade, repair or service), or the ship has no pilot; it says why and counts down to the later of rest and shop ("Resting · 04:12", "In the shop · 12:30", "No pilot assigned"), and turns back into the link when the countdown ends. The Contracts tab stays open to browse. ### 0.6 Translating change requests written for the server (CR-014 §7) @@ -620,7 +621,7 @@ The game has had no server since CR-014. When a later or unfinished change reque ### Step 9.4 — Bay contract boards - After tutorial, the Contracts screen has a selector for the player's bays and shows each bay's board. Each contract shows route, cargo type, mass, distance, payout, required skill, expiry countdown, and a first-contact badge where applicable. - **Test:** Component test switches bays and shows the right board. A first-contact contract displays its badge. -- **Amended by CR-002:** The selector lists every discovered location with an idle-ship badge, defaulting to the most recently arrived idle ship's location, else Earth; Fleet gets "Find work here" (Step 10B.7). +- **Amended by CR-002:** The selector lists every discovered location with an idle-ship badge, defaulting to the most recently arrived idle ship's location, else Earth; Fleet gets "Find work here" (Step 10B.7). Amended (decision 0.5.164): disabled, with why and a countdown, while the ship can't be sent. - **Amended by CR-006:** A contract's mass is highlighted, with a "Too heavy" tag, when none of the player's ships at the location (or in the fleet, when none are there) can carry it (decision 0.5.53, Step 10F.1). - **Amended by CR-007:** Cards headline the good with class/tier, Priority and Too slow chips, and show shipper · mass · distance (Step 10G.8). - **Amended by CR-008:** When a location's board changes, the screen says "New contracts posted" and tags contracts not on the last board seen there "New" (Step 10H.3). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e023216..d9d8659 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -586,3 +586,7 @@ Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026 - [x] **Step 28.3 — Client: toasts.** `apps/web/src/state/toasts.ts` (the store: newest first; an Independent delivery joins a batch toast shown in the last 10 s, "3 deliveries · +4,200 cr"), `components/Toasts.tsx` (top-right at 768 px and wider, top-center below the safe area on phones, both under the top bar; 5 s each, paused by hover or touch; ✕ and a 60 px swipe close early; at most 3, the rest as "+N more" opening the Logbook; tapping opens the toast's screen; `aria-live="polite"`; no slide with reduced motion) and `components/ToastFeed.tsx` (after each refresh while the player is here: the logbook's unseen entries from `hereSince` on become toasts (`entryToasts`: deliveries, discoveries, level-ups, goals ready, event outcomes, service started, finished and waiting for funds, plain upgrades and repairs; none for deadheads, refuelling, rest or automatic bookings) and are marked seen; a booked service newly out of reach and the fleet going quiet get toasts too). Timings in `client-config.ts`. The away pop-up still opens as before until Step 28.4. Tests (`apps/web/test/toasts.test.tsx`): placement at 1280 and 375 px; 5 s, not while hovered; ✕ and swipe; a fourth toast giving "+1 more" and opening the Logbook; tapping a discovery opening Places; reduced motion; three Independent deliveries within 10 s in one toast with a discovery on its own, and a new toast past the window; player deliveries alone, deadheads none; the feed toasting only entries from while here and marking them seen. Screenshots: `docs/screenshots/28-3-toasts-*`. - [x] **Step 28.4 — Client: pop-up only after being away.** `AwaySummary` no longer opens on unacknowledged deliveries: on each return (presence `returns`, a cold start included) it reads the logbook and takes the unseen entries from before coming back (anything from while the player was here is already seen). None worth a toast: nothing shows (they're marked seen). Away under 60 s: they show as toasts. Otherwise the pop-up, built from exactly those entries, marks them seen and closes the delivery summaries when it closes. `useAwayOpen` holds the deal, the skill-point prompt and radio calls back while it decides or shows. `ToastFeed` closes the summaries of deliveries and upgrades toasted while here, which the pop-up used to do. Presence remembers on the device when the player went away (`devicePresenceMemory`), because a hidden tab keeps running and saving: a cold start comes back from then, else from the save's last time seen. The Logbook's "This absence" is the absence the player last came back from. End-to-end helpers: `goAway` (the tab hidden), `expectNews` (a toast or the pop-up), and `dismissLogbook` copes with either. End-to-end suite green locally (11 tests). Screenshots: `docs/screenshots/28-4-away-*`. Tests (`apps/web/test/away-return.test.tsx`): a delivery while here is a toast with no pop-up; 5 minutes hidden with two deliveries shows the pop-up with exactly those two, and a reload afterwards shows nothing; 30 seconds hidden gives toasts; coming back to nothing shows nothing; `presence.test.ts` (closing a hidden tab). Tests that open the pop-up now come back first (`comeBack` in `test/render.tsx`). - [x] **Step 28.5 — Regression.** Full suite green (199 files, 1,642 tests), lint and type check clean, and the end-to-end suite green locally (11 tests, with the away flows now hiding the tab or reloading). Amendments (CR-028 §6) added to plan Step 9.2, CR-024, CR-025 and CR-026. CI checked after the push. Manual check left for the user: watch deliveries arrive as toasts with the game open, then switch away for a few minutes and confirm the pop-up covers only that time. + +## Amendment: "Find work here" when a ship can't go (decision 0.5.164) + +- [x] **Disabled "Find work here".** `findWorkBlock` in `apps/web/src/screens/Fleet.tsx`: no pilot, the pilot resting, or the ship in the shop, with the later end time; an idle ship's card shows a disabled button ("Resting · 04:12", "In the shop · 12:30", "No pilot assigned") that becomes the link again when the game clock passes it. Deadhead still hides while the ship is in the shop. Tests (`apps/web/test/find-work.test.tsx`): the link when free; resting; in the shop; both (either order); no pilot; an Independent pilot's ship; re-enabling at zero. Screenshots: `docs/screenshots/find-work-blocked-*`.