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 = { "insufficient-credits": "Not enough credits for fuel", }; +/** + * Every reason a ship and pilot cannot take a contract, in plain words with the + * numbers involved, e.g. "Too heavy: 228 t (holds 20 t)". Display only: the + * sim's validateDispatch decides eligibility. + */ +export function explainProblems( + state: PlayerState, + contract: Contract, + model: ShipModel, + pilot: Pilot, + fuelCost: number, + gameNow: number, + now: number, +): string[] { + const problems: string[] = []; + if (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]); + if (contract.massTonnes > model.cargoCapacityTonnes) { + problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); + } + const route = checkRoute( + model, + location(contract.originId), + location(contract.destinationId), + gameNow, + ); + if (!route.ok) { + problems.push( + route.reason === "out-of-range" + ? `Out of range right now (${model.name} reaches ${model.fuelRangeMillionKm}M km)` + : INELIGIBLE_REASONS["cannot-land"], + ); + } + if (contract.requiredSkill) { + const have = pilot.skills[contract.requiredSkill.skillId] ?? 0; + if (have < contract.requiredSkill.level) { + problems.push( + `Needs ${skillName(contract.requiredSkill.skillId)} ${contract.requiredSkill.level} (pilot has ${have})`, + ); + } + } + const longHaul = pilot.skills[LONG_HAUL_SKILL_ID] ?? 0; + if (longHaul < contract.requiredLongHaulLevel) { + problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`); + } + if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]); + if (state.company.credits < fuelCost) { + problems.push(`Not enough credits for fuel (${formatCredits(fuelCost)})`); + } + return problems; +} + /** Real-world arrival time on the device, e.g. "14:05". */ export function formatLocalTime(time: number): string { return new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); @@ -34,7 +93,8 @@ export interface PairOption { modelName: string; pilotId: string | null; pilotName: string | null; - reason: string | null; + /** Why this pair cannot take the contract; empty when it can. */ + problems: string[]; tripMs: number; fuelCost: number; netProfit: number; @@ -64,7 +124,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) ); const pilotPay = pilot && !pilot.isAvatar ? content.config.pilotPayPerDelivery : 0; - let reason: string | null = INELIGIBLE_REASONS["pilot-not-assigned"]; + let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]]; if (pilot) { const check = validateDispatch( { @@ -88,7 +148,13 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) }, content, ); - reason = check.ok ? null : INELIGIBLE_REASONS[check.reason]; + // The sim decides eligibility (as the server will); the list explains every problem. + if (check.ok) { + problems = []; + } else { + const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now); + problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]]; + } } return { shipId: ship.id, @@ -96,7 +162,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) modelName: model.name, pilotId: pilot?.id ?? null, pilotName: pilot?.name ?? null, - reason, + problems, tripMs, fuelCost, netProfit: contract.payout - fuelCost - pilotPay, @@ -112,9 +178,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo const dispatch = useGameAction("createDispatch"); const options = pairOptions(state, contract, now); const [chosen, setChosen] = useState( - options.find((o) => o.reason === null)?.shipId ?? null, + options.find((o) => o.problems.length === 0)?.shipId ?? null, ); - const selected = options.find((o) => o.shipId === chosen && o.reason === null); + const selected = options.find((o) => o.shipId === chosen && o.problems.length === 0); const confirm = () => { if (!selected?.pilotId) return; @@ -140,7 +206,13 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo

{locationName(contract.originId)} → {locationName(contract.destinationId)}

-

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"} +

{options.length === 0 ? (

@@ -151,9 +223,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo {options.map((option) => (