diff --git a/DECISIONS.md b/DECISIONS.md index 3a03d74..a6e1c6d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -800,3 +800,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The confirmation is inline in the pilot card**, not a browser `confirm()`, so it is styled and testable. It names the real rest length (one hour with current settings, computed from config) and says the pilot wakes to new contracts. - **"Rest now" is hidden during the tutorial and for in-flight pilots**, matching what the server allows. - **The duty bar caps at full** when a pilot finished a trip over the limit; the text shows the actual hours. + +## Step 10D.7 + +- **The resting line is computed from the clock each render** ("Resting — 12:05"), so it counts down while the sheet is open, and it is the first problem listed. +- **Duty after uses the same sim functions the server uses on arrival** (`tripGameHours` from departure and arrival, `dutyAfterTrip`, `needsRest`), so the warning matches what will happen. +- **The deadhead picker also respects rest** (it validates with the pilot's real `restEndsAt`), disabling every destination with "Pilot is resting". diff --git a/apps/web/src/format.ts b/apps/web/src/format.ts index 782fb6a..2a72084 100644 --- a/apps/web/src/format.ts +++ b/apps/web/src/format.ts @@ -16,6 +16,15 @@ export function formatDuration(ms: number): string { return `${seconds}s`; } +/** A countdown as "mm:ss", or "h:mm:ss" from an hour up. */ +export function formatClock(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const pad = (n: number) => String(n).padStart(2, "0"); + const mmss = `${pad(Math.floor((totalSeconds % 3600) / 60))}:${pad(totalSeconds % 60)}`; + return hours > 0 ? `${hours}:${mmss}` : mmss; +} + /** A game-hours duration, e.g. "5.4 h" or "250 h". */ export function formatGameHours(hours: number): string { return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`; diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 5cfbc68..989f4a7 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -2,16 +2,27 @@ import { content, type ShipModel } from "@deadhead/content"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import { checkRoute, + dutyAfterTrip, LONG_HAUL_SKILL_ID, + needsRest, realToGame, tripFuelCost, + tripGameHours, validateDispatch, type DispatchFailure, } from "@deadhead/sim"; import { useState } from "react"; import { useClock } from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; -import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format"; +import { + cargoName, + formatClock, + formatCredits, + formatDuration, + formatGameHours, + locationName, + skillName, +} from "../format"; import { requirementsText } from "./LocationBoards"; import { location, shipModel, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; @@ -47,6 +58,10 @@ export function explainProblems( now: number, ): string[] { const problems: string[] = []; + // Resting blocks flying (CR-004): first, with the time left. + if (pilot.resting && pilot.restEndsAt !== null) { + problems.push(`Resting — ${formatClock(pilot.restEndsAt - now)}`); + } 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)`); @@ -100,6 +115,15 @@ export interface PairOption { fuelCost: number; netProfit: number; arrivalAt: number; + /** The pilot's duty after this trip and their limit (CR-004); null without a pilot. */ + duty: { after: number; limit: number; willRest: boolean } | null; +} + +/** A pilot's duty after a trip, with the same sim rules the server applies on arrival. */ +export function dutyOutlook(pilot: Pilot, departureAt: number, arrivalAt: number) { + const hours = tripGameHours(departureAt, arrivalAt, content.config); + const after = dutyAfterTrip(pilot.dutyHours, hours, false); + return { after, limit: pilot.dutyLimit, willRest: needsRest(after, pilot.dutyLimit) }; } /** Every ship at the contract's origin, with its pilot, numbers and eligibility. */ @@ -141,7 +165,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) assignedShipId: pilot.assignedShipId, inFlight: pilot.status === "in-flight", skills: pilot.skills, - restEndsAt: null, + restEndsAt: pilot.restEndsAt, }, contract, tutorial: false, @@ -169,6 +193,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) fuelCost, netProfit: contract.payout - fuelCost - pilotPay, arrivalAt: now + tripMs, + duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, }; }); } @@ -268,8 +293,20 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
Will need rest on arrival
+ )} ))} diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index f0190f1..3cd5808 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -147,7 +147,7 @@ function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) assignedShipId: pilot ? ship.id : null, inFlight: pilot?.status === "in-flight", skills: pilot?.skills ?? {}, - restEndsAt: null, + restEndsAt: pilot?.restEndsAt ?? null, }, destinationId: id, discoveredIds, diff --git a/apps/web/test/dispatch-duty.test.tsx b/apps/web/test/dispatch-duty.test.tsx new file mode 100644 index 0000000..a35323d --- /dev/null +++ b/apps/web/test/dispatch-duty.test.tsx @@ -0,0 +1,133 @@ +import { content } from "@deadhead/content"; +import type { Contract, Pilot, Ship } from "@deadhead/schema"; +import { realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; +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 { formatGameHours } from "../src/format"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const ship = (id: string, name: string, pilotId: string): Ship => ({ + id, + name, + modelId: "courier", + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: 0, + pilotId, +}); + +const pilot = (id: string, name: string, shipId: string, extra: Partial