diff --git a/DECISIONS.md b/DECISIONS.md
index 0fb0956..ae4af25 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -592,9 +592,15 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The summary lists every goal currently ready to claim,** not only ones that became ready since the last visit. The server does not track when a goal became ready, and an unclaimed ready goal is worth pointing out on any return.
- **"Claim on HQ" acknowledges the deliveries and navigates to `/#goals`.**
-- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`); the 1 s default occasionally ran out under a fully parallel run.
+- **Web tests wait up to 3 s for elements** (`asyncUtilTimeout`). *Correction:* the intermittent onboarding failure this was meant to fix was really a race in the test (it checked the intro card before the tutorial board had loaded); that is fixed in the playtest fixes below. The longer wait is kept as harmless headroom.
## Step 10A.18
- **Plan amendments are appended to each affected step** as "Amended by CR-001: …" lines, keeping the original text readable next to the change.
- **Known issue found in the regression (not fixed in this phase):** after the deal the player owns only a Courier (20 t), but bay boards draw contract mass uniformly from 5–400 t, so about three boards in four have nothing a Courier can carry. Before CR-001 the player chose their first ship, which hid this; now every player starts with a Courier and would usually have no flyable contract. Fixing it changes contract generation, so it is left for the user to decide.
+
+## Playtest fixes after Phase 10A
+
+- **"4 of 4" with no deal:** the user's account played through code being changed mid-phase (the dev server reloads), so its first tutorial run was flown before the run counter existed and the count ran one behind; after the fourth counted run the loaner was at the Moon. On finished code the deal appears after run 4. HQ now explains the remaining step when enough runs are done but the loaner is not at Earth (`tutorialHint`).
+- **The dispatch sheet looked "stuck" on the first job:** it did change per job, but showed only "Earth → Moon" and the first failing check, so every over-heavy job looked identical. The sheet now shows the job's cargo, tier, mass and required skills, and lists every problem with numbers ("Too heavy: 228 t (holds 20 t)", "Needs Expensive Cargo 2 (pilot has 0)", range, Long Haul, fuel). Eligibility is still decided by sim `validateDispatch`.
+- **Still open:** most post-deal boards have no contract a Courier can carry (see Step 10A.18).
diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx
index 3abec26..b878ea8 100644
--- a/apps/web/src/screens/DispatchSheet.tsx
+++ b/apps/web/src/screens/DispatchSheet.tsx
@@ -1,10 +1,18 @@
-import { content } from "@deadhead/content";
-import type { Contract, PlayerState } from "@deadhead/schema";
-import { realToGame, tripFuelCost, validateDispatch, type DispatchFailure } from "@deadhead/sim";
+import { content, type ShipModel } from "@deadhead/content";
+import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
+import {
+ checkRoute,
+ LONG_HAUL_SKILL_ID,
+ realToGame,
+ tripFuelCost,
+ validateDispatch,
+ type DispatchFailure,
+} from "@deadhead/sim";
import { useState } from "react";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
-import { formatCredits, formatDuration, locationName } from "../format";
+import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
+import { requirementsText } from "./BayBoards";
import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
@@ -23,6 +31,57 @@ export const INELIGIBLE_REASONS: Record Payout {formatCredits(contract.payout)}
+ {cargoName(contract.cargoTypeId)}
+ {contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t · payout{" "}
+ {formatCredits(contract.payout)}
+ {" · "}
+ {requirementsText(contract) ? `needs ${requirementsText(contract)}` : "no skills needed"}
+
@@ -151,9 +223,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
{options.map((option) => (
{locationName(contract.originId)} → {locationName(contract.destinationId)}
-
- Keep flying — the old man has an offer for you. +
+ {tutorialHint(state.tutorial, runsBeforeDeal)}
)} diff --git a/apps/web/test/dispatch-problems.test.tsx b/apps/web/test/dispatch-problems.test.tsx new file mode 100644 index 0000000..129952e --- /dev/null +++ b/apps/web/test/dispatch-problems.test.tsx @@ -0,0 +1,134 @@ +import type { Contract, PlayerState } 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 { tutorialHint } from "../src/screens/HQ"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const job = ( + id: string, + massTonnes: number, + skill: { skillId: string; level: number } | null, +): Contract => ({ + id, + originId: "earth", + destinationId: "moon", + cargoTypeId: skill ? "high-value" : "standard", + tier: skill?.level ?? 0, + massTonnes, + payout: 1000, + requiredSkill: skill, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 3_600_000, + firstContact: false, +}); + +/** Just after the deal: one crewed Courier at Earth, as in the user's report. */ +function afterDeal(): PlayerState { + const base = samplePlayerState({ tutorial: null }); + return { + ...base, + company: { ...base.company, credits: 1652, loanBalance: 5000 }, + bays: [{ id: "b1", locationId: "earth" }], + ships: [ + { + id: "s1", + modelId: "courier", + name: "Copper Wren", + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId: "avatar", + }, + ], + pilots: [{ ...base.pilots[0]!, assignedShipId: "s1" }], + }; +} + +describe("dispatch sheet explains each job", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("shows each job's own details and every problem, so different jobs look different", async () => { + const board = [ + job("skill", 228, { skillId: "expensive-cargo", level: 2 }), + job("heavy", 69, null), + ]; + renderApp( + fakeApi({ + getPlayerState: afterDeal, + getContractBoard: () => ({ contracts: board, periodEndsAt: NOW + 3_600_000 }), + }), + "/contracts", + ); + + const [skillJob, heavyJob] = await screen.findAllByRole("button", { name: "Dispatch" }); + await userEvent.click(skillJob!); + let sheet = screen.getByRole("dialog"); + expect(within(sheet).getByLabelText("Job details")).toHaveTextContent( + "High-Value (tier 2) · 228 t", + ); + expect(within(sheet).getByLabelText("Job details")).toHaveTextContent( + "needs Expensive Cargo 2", + ); + const problems = within(sheet).getByRole("list", { name: "Problems" }); + expect(problems).toHaveTextContent("Too heavy: 228 t (holds 20 t)"); + expect(problems).toHaveTextContent("Needs Expensive Cargo 2 (pilot has 0)"); + await userEvent.click(within(sheet).getByRole("button", { name: "Cancel" })); + + await userEvent.click(heavyJob!); + sheet = screen.getByRole("dialog"); + expect(within(sheet).getByLabelText("Job details")).toHaveTextContent("Standard · 69 t"); + expect(within(sheet).getByLabelText("Job details")).toHaveTextContent("no skills needed"); + const heavyProblems = within(sheet).getByRole("list", { name: "Problems" }); + expect(heavyProblems).toHaveTextContent("Too heavy: 69 t (holds 20 t)"); + expect(heavyProblems).not.toHaveTextContent("Expensive Cargo"); + }); + + it("lets a job the Courier can carry be confirmed", async () => { + renderApp( + fakeApi({ + getPlayerState: afterDeal, + getContractBoard: () => ({ + contracts: [job("ok", 15, null)], + periodEndsAt: NOW + 3_600_000, + }), + }), + "/contracts", + ); + await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); + const sheet = screen.getByRole("dialog"); + expect(within(sheet).queryByRole("list", { name: "Problems" })).not.toBeInTheDocument(); + expect(within(sheet).getByRole("button", { name: "Confirm" })).toBeEnabled(); + }); +}); + +describe("tutorial hint on HQ", () => { + const tutorial = (runsCompleted: number, loaner: "earth" | "moon", extra = {}) => ({ + loanerLocationId: loaner, + runInFlight: false, + runsCompleted, + dealAvailable: false, + ...extra, + }); + + it("asks for one more run back to Earth when enough runs are done at the Moon", () => { + expect(tutorialHint(tutorial(4, "moon"), 4)).toMatch( + /One more run: bring the ship back to Earth/, + ); + expect(tutorialHint(tutorial(4, "moon", { runInFlight: true }), 4)).toMatch(/land at Earth/); + }); + + it("says keep flying before enough runs, and points at the offer when it is ready", () => { + expect(tutorialHint(tutorial(2, "earth"), 4)).toMatch(/Keep flying/); + expect(tutorialHint(tutorial(4, "earth", { dealAvailable: true }), 4)).toMatch(/offer/); + }); +}); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index aad93fb..510c5aa 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -25,7 +25,7 @@ describe("guest onboarding", () => { // Lands on Contracts with the intro and the light contract suggested. expect(await screen.findByRole("heading", { level: 1, name: "Contracts" })).toBeInTheDocument(); - expect(screen.getByLabelText("Intro")).toHaveTextContent( + expect(await screen.findByLabelText("Intro")).toHaveTextContent( "Halvorsen Lunar Freight needs a pilot.", ); const suggested = (await screen.findByText("Suggested")).closest("li")!; diff --git a/memory-bank/progress.md b/memory-bank/progress.md index c812ff3..601e141 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -124,3 +124,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a - [x] **Step 10A.16 — Client: whole map from the start.** The Map tab works from the first screen (tutorial included); undiscovered locations are labelled with a lock; tapping one shows "Undiscovered — deliver here to unlock" and "Reachable with: …" from the sim route check over purchasable models from Earth (`src/map/reach.ts`). - [x] **Step 10A.17 — Client: something waiting on return.** The "while you were away" dialog lists goals ready to claim, with a "Claim on HQ" link that dismisses it and opens HQ's goals. - [x] **Step 10A.18 — Plan amendments and regression.** CR-001 §6 amendments added to `implementation-plan.md` (steps 2.5, 3.5, 3.10, 6.3–6.8, 8.4, 9.1, 9.3, 12.1–12.3). Full suite and CI green. End-to-end smoke against the real server: four light tutorial runs take 1.9 real minutes of flying, the deal appears at Earth and produces the expected state. **Open issue:** after the deal the Earth board usually has no contract a Courier (20 t) can carry, because bay contracts draw mass uniformly from 5–400 t; awaiting the user's decision. Manual check pending. + +### Playtest fixes after Phase 10A + +- [x] HQ tutorial hint when enough runs are done but the loaner is away from Earth; dispatch sheet shows job details and lists every problem with numbers; onboarding test race fixed. Open: post-deal boards rarely have Courier-sized contracts (awaiting decision).