Compare commits

..

No commits in common. "7d8eb3fcfe3114e2afa7a470b5b071a69a128faa" and "3dba7c20190b6f7287c9c0607b87bcc4cccf9274" have entirely different histories.

15 changed files with 56 additions and 791 deletions

View File

@ -794,32 +794,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot. - **Resting is refused during the tutorial (`tutorial-active`)**, like purchases and hiring: tutorial runs accrue no duty, and a resting avatar would stall the tutorial with no other pilot.
- **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes. - **Resting a pilot already resting is `pilot-resting`**, and resting one in flight is `pilot-not-idle`, reusing the dispatch codes.
- **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away. - **Crew swaps:** assign and unassign never check rest (decision 0.5.44), so a resting pilot can be left at a bay and a fresh pilot can take the ship straight away.
## Step 10D.6
- **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".
## Step 10D.8
- **The countdown uses the board's own `periodEndsAt`** from the server, and the board query refetches half a second after it, so a pilot who wakes up sees the new board without reloading.
- **"Eligible" means the dispatch sheet would allow it:** a ship is stuck when `pairOptions` finds no contract on this board with no problems for that ship and its pilot. Uncrewed ships and resting pilots get no prompt (resting pilots already show their countdown elsewhere).
- **One prompt per stuck ship**, naming the ship and pilot, so a location with several idle ships stays clear.
- **`Countdown` takes an optional `format`**; the board refresh uses `mm:ss` (`formatClock`) as the CR asks.
## Step 10D.9
- **The summary gives the wake time as the device's local clock time** (like the dispatch sheet's real ETA), since the player reads it on returning; HQ gives a live countdown instead.
- **The HQ rest card is always shown after the tutorial**, with "Everyone is ready to fly." when nobody rests, so the player learns the mechanic exists before it first bites.
## Step 10D.10
- **The CR's manual check was scripted** against a live dev server with the test clock (scratchpad `cr004-smoke.mjs`): mandatory rest after the sixth Earth↔Moon trip, `pilot-resting` while resting, and a different board on waking. The user's own check on a device is still the review gate.
- **Observed for Phase 12 balance (not changed here):** a board's only guaranteed Courier job is used by the first trip from that location in a period, so later trips in the same period are usually deadheads. Resting, which wakes the pilot to a new board, is now the natural answer, which is what the CR intends; the size of that effect is for the balance pass.

View File

@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { formatLocalTime } from "../screens/DispatchSheet";
import { useGameAction, useLoadedPlayer } from "../state/actions"; import { useGameAction, useLoadedPlayer } from "../state/actions";
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */ /** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
@ -73,12 +72,6 @@ export function AwaySummary() {
Discovered {locationName(s.discoveredLocationId)}! Discovered {locationName(s.discoveredLocationId)}!
</div> </div>
)} )}
{s.restEndsAt !== null && (
<div className="mt-1 text-sky-300">
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
until {formatLocalTime(s.restEndsAt)}
</div>
)}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -2,16 +2,8 @@ import { useClock } from "../clock/clock";
import { formatDuration } from "../format"; import { formatDuration } from "../format";
/** Live time remaining until `to` (server clock); shows `done` once it has passed. */ /** Live time remaining until `to` (server clock); shows `done` once it has passed. */
export function Countdown({ export function Countdown({ to, done = "Arriving…" }: { to: number; done?: string }) {
to,
done = "Arriving…",
format = formatDuration,
}: {
to: number;
done?: string;
format?: (ms: number) => string;
}) {
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const remaining = to - now; const remaining = to - now;
return <span className="tabular-nums">{remaining > 0 ? format(remaining) : done}</span>; return <span className="tabular-nums">{remaining > 0 ? formatDuration(remaining) : done}</span>;
} }

View File

@ -16,15 +16,6 @@ export function formatDuration(ms: number): string {
return `${seconds}s`; 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". */ /** A game-hours duration, e.g. "5.4 h" or "250 h". */
export function formatGameHours(hours: number): string { export function formatGameHours(hours: number): string {
return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`; return hours < 10 ? `${hours.toFixed(1)} h` : `${Math.round(hours)} h`;

View File

@ -2,27 +2,16 @@ import { content, type ShipModel } from "@deadhead/content";
import type { Contract, Pilot, PlayerState } from "@deadhead/schema"; import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
import { import {
checkRoute, checkRoute,
dutyAfterTrip,
LONG_HAUL_SKILL_ID, LONG_HAUL_SKILL_ID,
needsRest,
realToGame, realToGame,
tripFuelCost, tripFuelCost,
tripGameHours,
validateDispatch, validateDispatch,
type DispatchFailure, type DispatchFailure,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { useState } from "react"; import { useState } from "react";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { import { cargoName, formatCredits, formatDuration, locationName, skillName } from "../format";
cargoName,
formatClock,
formatCredits,
formatDuration,
formatGameHours,
locationName,
skillName,
} from "../format";
import { requirementsText } from "./LocationBoards"; import { requirementsText } from "./LocationBoards";
import { location, shipModel, tripRealMs } from "../game"; import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
@ -58,10 +47,6 @@ export function explainProblems(
now: number, now: number,
): string[] { ): string[] {
const problems: 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 (pilot.status === "in-flight") problems.push(INELIGIBLE_REASONS["pilot-not-idle"]);
if (contract.massTonnes > model.cargoCapacityTonnes) { if (contract.massTonnes > model.cargoCapacityTonnes) {
problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`); problems.push(`Too heavy: ${contract.massTonnes} t (holds ${model.cargoCapacityTonnes} t)`);
@ -115,15 +100,6 @@ export interface PairOption {
fuelCost: number; fuelCost: number;
netProfit: number; netProfit: number;
arrivalAt: 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. */ /** Every ship at the contract's origin, with its pilot, numbers and eligibility. */
@ -165,7 +141,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
assignedShipId: pilot.assignedShipId, assignedShipId: pilot.assignedShipId,
inFlight: pilot.status === "in-flight", inFlight: pilot.status === "in-flight",
skills: pilot.skills, skills: pilot.skills,
restEndsAt: pilot.restEndsAt, restEndsAt: null,
}, },
contract, contract,
tutorial: false, tutorial: false,
@ -193,7 +169,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
fuelCost, fuelCost,
netProfit: contract.payout - fuelCost - pilotPay, netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs, arrivalAt: now + tripMs,
duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null,
}; };
}); });
} }
@ -293,20 +268,8 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
</dd> </dd>
<dt>ETA (real)</dt> <dt>ETA (real)</dt>
<dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd> <dd className="tabular-nums">{formatLocalTime(option.arrivalAt)}</dd>
{option.duty && (
<>
<dt>Duty after</dt>
<dd className="tabular-nums">
{formatGameHours(option.duty.after)} of{" "}
{formatGameHours(option.duty.limit)}
</dd>
</>
)}
</dl> </dl>
)} )}
{option.problems.length === 0 && option.duty?.willRest && (
<p className="mt-1 text-amber-300">Will need rest on arrival</p>
)}
</label> </label>
</li> </li>
))} ))}

View File

@ -124,7 +124,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
* Fly an idle ship empty to any other discovered location, paying fuel only * Fly an idle ship empty to any other discovered location, paying fuel only
* (CR-002). The sim decides which destinations are possible, as the server will. * (CR-002). The sim decides which destinations are possible, as the server will.
*/ */
export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) { function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => void }) {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const reposition = useGameAction("createDispatch"); const reposition = useGameAction("createDispatch");
@ -147,7 +147,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
assignedShipId: pilot ? ship.id : null, assignedShipId: pilot ? ship.id : null,
inFlight: pilot?.status === "in-flight", inFlight: pilot?.status === "in-flight",
skills: pilot?.skills ?? {}, skills: pilot?.skills ?? {},
restEndsAt: pilot?.restEndsAt ?? null, restEndsAt: null,
}, },
destinationId: id, destinationId: id,
discoveredIds, discoveredIds,

View File

@ -28,10 +28,6 @@ export function HQ() {
const flying = state.ships.filter((s) => s.status === "in-flight").length; const flying = state.ships.filter((s) => s.status === "in-flight").length;
const runsBeforeDeal = content.config.tutorialRunsBeforeDeal; const runsBeforeDeal = content.config.tutorialRunsBeforeDeal;
const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0]; const next = [...state.activeDispatches].sort((a, b) => a.arrivalAt - b.arrivalAt)[0];
// Resting pilots and who wakes first (CR-004).
const resting = state.pilots
.filter((p) => p.resting && p.restEndsAt !== null)
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
return ( return (
<section className="space-y-4 p-4"> <section className="space-y-4 p-4">
@ -56,19 +52,6 @@ export function HQ() {
)} )}
</div> </div>
{!state.tutorial && (
<div aria-label="Pilot rest" className="rounded-lg border border-slate-800 p-3">
<h2 className="text-sm font-semibold text-slate-300">Pilots resting: {resting.length}</h2>
{resting[0] ? (
<p className="mt-1">
{resting[0].name} wakes in <Countdown to={resting[0].restEndsAt!} done="now" />
</p>
) : (
<p className="mt-1 text-sm text-slate-400">Everyone is ready to fly.</p>
)}
</div>
)}
{!state.tutorial && state.ships.length === 0 && ( {!state.tutorial && state.ships.length === 0 && (
<p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p> <p className="text-sm text-slate-400">No ships yet — buy one in the Market.</p>
)} )}

View File

@ -1,22 +1,12 @@
import type { Contract, PlayerState, Ship } from "@deadhead/schema"; import type { Contract, PlayerState } from "@deadhead/schema";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { serverNow, useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { import { cargoName, formatCredits, formatMillionKm, locationName, skillName } from "../format";
cargoName,
formatClock,
formatCredits,
formatMillionKm,
locationName,
skillName,
} from "../format";
import { distanceAt } from "../game"; import { distanceAt } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { useLoadedPlayer } from "../state/actions";
import { pairOptions } from "./DispatchSheet";
import { DeadheadPicker } from "./Fleet";
export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const; export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const;
@ -101,11 +91,6 @@ function LocationBoard({
const board = useQuery({ const board = useQuery({
queryKey: locationBoardKey(locationId), queryKey: locationBoardKey(locationId),
queryFn: () => api.call("getContractBoard", { locationId }), queryFn: () => api.call("getContractBoard", { locationId }),
// Fetch the new board as soon as the period rolls over (CR-004).
refetchInterval: (query) => {
const endsAt = query.state.data?.periodEndsAt;
return endsAt === undefined ? false : Math.max(1000, endsAt - serverNow() + 500);
},
}); });
if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>; if (board.isPending) return <p className="text-slate-400">Loading contracts…</p>;
@ -119,140 +104,54 @@ function LocationBoard({
</div> </div>
); );
} }
const refresh = (
<p aria-label="Board refresh" className="text-sm text-slate-400">
New contracts in{" "}
<Countdown to={board.data.periodEndsAt} format={formatClock} done="a moment" />
</p>
);
const stuck = <NothingHere locationId={locationId} contracts={board.data.contracts} now={now} />;
if (board.data.contracts.length === 0) { if (board.data.contracts.length === 0) {
return ( return (
<div className="space-y-2"> <p className="text-sm text-slate-400">No contracts left this period. Check back soon.</p>
{refresh}
{stuck}
<p className="text-sm text-slate-400">No contracts left this period.</p>
</div>
); );
} }
return ( return (
<div className="space-y-2"> <ul className="space-y-2">
{refresh} {board.data.contracts.map((contract) => {
{stuck} const requirements = requirementsText(contract);
<ul className="space-y-2">
{board.data.contracts.map((contract) => {
const requirements = requirementsText(contract);
return (
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{contract.firstContact && (
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
First contact
</span>
)}
</span>
<span className="font-semibold tabular-nums text-emerald-400">
{formatCredits(contract.payout)}
</span>
</div>
<div className="mt-1 text-sm text-slate-400">
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
<span className="text-slate-400">
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
<Countdown to={contract.expiresAt} done="expired" />
</span>
{onSelect && (
<button
type="button"
onClick={() => onSelect(contract)}
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
>
Dispatch
</button>
)}
</div>
</li>
);
})}
</ul>
</div>
);
}
/**
* 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.
*/
function NothingHere({
locationId,
contracts,
now,
}: {
locationId: string;
contracts: Contract[];
now: number;
}) {
const state = useLoadedPlayer();
const rest = useGameAction("restPilot");
const [deadheading, setDeadheading] = useState<Ship | null>(null);
const stuck = state.ships.filter((ship) => {
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id);
if (ship.status !== "idle" || ship.locationId !== locationId || !pilot || pilot.resting) {
return false;
}
return !contracts.some((c) =>
pairOptions(state, c, now).some((o) => o.shipId === ship.id && o.problems.length === 0),
);
});
if (stuck.length === 0) return null;
return (
<>
{stuck.map((ship) => {
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id)!;
return ( return (
<div <li key={contract.id} className="rounded-lg border border-slate-800 p-3">
key={ship.id} <div className="flex items-baseline justify-between gap-2">
role="group" <span className="font-medium">
aria-label={`Nothing for ${ship.name}`} {locationName(contract.originId)} → {locationName(contract.destinationId)}
className="rounded-lg border border-sky-900 bg-sky-950/40 p-3 text-sm" {contract.firstContact && (
> <span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
<p> First contact
No job here that {ship.name} and {pilot.name} can take. </span>
</p> )}
<div className="mt-2 flex flex-wrap gap-2"> </span>
<button <span className="font-semibold tabular-nums text-emerald-400">
type="button" {formatCredits(contract.payout)}
disabled={rest.isPending || state.tutorial !== null} </span>
onClick={() => rest.mutate({ pilotId: pilot.id })}
className="rounded-md border border-slate-700 px-3 py-1"
>
Rest {pilot.name} — wakes to a new board
</button>
<button
type="button"
onClick={() => setDeadheading(ship)}
className="rounded-md border border-slate-700 px-3 py-1"
>
Deadhead (fly empty)
</button>
</div> </div>
</div> <div className="mt-1 text-sm text-slate-400">
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} · {contract.massTonnes} t ·{" "}
{formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-sm">
<span className="text-slate-400">
{requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "}
<Countdown to={contract.expiresAt} done="expired" />
</span>
{onSelect && (
<button
type="button"
onClick={() => onSelect(contract)}
className="shrink-0 rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
>
Dispatch
</button>
)}
</div>
</li>
); );
})} })}
{rest.isError && ( </ul>
<p role="alert" className="text-sm text-red-400">
{errorMessage(rest.error)}
</p>
)}
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
</>
); );
} }

View File

@ -1,15 +1,7 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Pilot } from "@deadhead/schema"; import type { Pilot } from "@deadhead/schema";
import { useState } from "react"; import { useState } from "react";
import { MS_PER_HOUR } from "@deadhead/sim"; import { formatCredits, locationName, shipModelName } from "../format";
import { Countdown } from "../components/Countdown";
import {
formatCredits,
formatDuration,
formatGameHours,
locationName,
shipModelName,
} from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
/** XP needed for the pilot's current and next level, or null at max level. */ /** XP needed for the pilot's current and next level, or null at max level. */
@ -21,7 +13,7 @@ export function levelProgress(xp: number, level: number) {
return { xp: xp - previous, needed: next - previous }; return { xp: xp - previous, needed: next - previous };
} }
/** Pilots: levels, skills, assignment, duty and rest; hire, assign, rest and spend skill points (Steps 9.7, 10D.6). */ /** Pilots: levels, skills, assignment; hire, assign and spend skill points (Step 9.7). */
export function Pilots() { export function Pilots() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
return ( return (
@ -157,8 +149,6 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
)} )}
</div> </div>
<Duty pilot={pilot} canRest={!lockedToLoaner && state.tutorial === null} />
{!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && ( {!lockedToLoaner && pilot.assignedShipId === null && pilot.status === "idle" && (
<AssignShip pilot={pilot} /> <AssignShip pilot={pilot} />
)} )}
@ -166,95 +156,6 @@ function PilotCard({ pilot }: { pilot: Pilot }) {
); );
} }
/** Real time a rest lasts: at least one contract board period (decision 0.5.46). */
export function restRealMs(): number {
const { restGameHours, timeCompression } = content.config;
return (restGameHours * MS_PER_HOUR) / timeCompression;
}
/** Duty used of the limit, or the rest countdown; and "Rest now" with a confirmation (CR-004). */
function Duty({ pilot, canRest }: { pilot: Pilot; canRest: boolean }) {
const rest = useGameAction("restPilot");
const [confirming, setConfirming] = useState(false);
if (pilot.resting && pilot.restEndsAt !== null) {
return (
<div className="mt-2 text-sky-300" aria-label={`${pilot.name} rest`}>
Resting · wakes in <Countdown to={pilot.restEndsAt} done="any moment" />
</div>
);
}
const used = Math.min(pilot.dutyHours, pilot.dutyLimit);
return (
<div className="mt-2">
<div className="flex items-baseline justify-between text-xs text-slate-400">
<span>Duty</span>
<span>
{formatGameHours(pilot.dutyHours)} of {formatGameHours(pilot.dutyLimit)}
</span>
</div>
<div
role="progressbar"
aria-label={`${pilot.name} duty`}
aria-valuemin={0}
aria-valuemax={pilot.dutyLimit}
aria-valuenow={used}
className="mt-1 h-1.5 overflow-hidden rounded bg-slate-800"
>
<div
className="h-full bg-amber-400"
style={{ width: `${(used / pilot.dutyLimit) * 100}%` }}
/>
</div>
{canRest && pilot.status === "idle" && !confirming && (
<button
type="button"
onClick={() => setConfirming(true)}
className="mt-2 rounded-md border border-slate-700 px-3 py-1"
>
Rest now
</button>
)}
{confirming && (
<div
role="group"
aria-label={`Rest ${pilot.name}?`}
className="mt-2 rounded-md bg-slate-900 p-2"
>
<p className="text-slate-300">
{pilot.name} rests for {formatDuration(restRealMs())} and can&apos;t fly until then.
Rest always lasts at least one board refresh, so they wake to new contracts.
</p>
<div className="mt-2 flex gap-2">
<button
type="button"
disabled={rest.isPending}
onClick={() =>
rest.mutate({ pilotId: pilot.id }, { onSettled: () => setConfirming(false) })
}
className="rounded-md bg-amber-500 px-3 py-1 font-semibold text-slate-950"
>
Rest {pilot.name}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-md border border-slate-700 px-3 py-1"
>
Cancel
</button>
</div>
</div>
)}
{rest.isError && (
<p role="alert" className="mt-1 text-red-400">
{errorMessage(rest.error)}
</p>
)}
</div>
);
}
/** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */ /** Assign an unassigned pilot to an idle, uncrewed ship at the same location. */
function AssignShip({ pilot }: { pilot: Pilot }) { function AssignShip({ pilot }: { pilot: Pilot }) {
const state = useLoadedPlayer(); const state = useLoadedPlayer();

View File

@ -1,105 +0,0 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, Ship } from "@deadhead/schema";
import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim";
import { screen, waitFor, 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 { fakeApi, renderApp, samplePlayerState } from "./render";
const period = boardPeriodMs(content.config);
// 17.5 minutes into a board period.
const NOW = Math.ceil(Date.parse("2100-01-01T00:00:00Z") / period) * period + 17.5 * 60_000;
const periodEndsAt = (boardPeriodIndex(NOW, content.config) + 1) * period;
const courier: Ship = {
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
pilotId: "p1",
};
const rae: Pilot = {
id: "p1",
name: "Rae Okafor",
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: "s1",
status: "idle",
locationId: "earth",
dutyHours: 3,
dutyLimit: 12,
resting: false,
restEndsAt: null,
};
/** Too heavy for a Courier. */
const heavyJob: Contract = {
id: "c-heavy",
originId: "earth",
destinationId: "moon",
cargoTypeId: "heavy",
tier: 0,
massTonnes: 300,
payout: 9000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: periodEndsAt,
firstContact: false,
};
const api = (contracts: Contract[]) =>
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [courier],
pilots: [rae],
}),
getContractBoard: () => ({ contracts, periodEndsAt }),
restPilot: () => ({ pilot: { ...rae, resting: true, restEndsAt: NOW + period } }),
});
describe("board refresh and the nothing-here case (Step 10D.8)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("counts down to the next board period", async () => {
renderApp(api([heavyJob]), "/contracts?location=earth");
expect(await screen.findByLabelText("Board refresh")).toHaveTextContent(
"New contracts in 42:30",
);
});
it("offers rest or a deadhead when no contract fits the idle ship, and each one acts", async () => {
const client = api([heavyJob]);
renderApp(client, "/contracts?location=earth");
const prompt = await screen.findByRole("group", { name: "Nothing for Dusty Mule" });
await userEvent.click(
within(prompt).getByRole("button", { name: "Rest Rae Okafor — wakes to a new board" }),
);
await waitFor(() => expect(client.call).toHaveBeenCalledWith("restPilot", { pilotId: "p1" }));
await userEvent.click(within(prompt).getByRole("button", { name: "Deadhead (fly empty)" }));
expect(screen.getByRole("dialog", { name: "Deadhead Dusty Mule" })).toBeInTheDocument();
});
it("does not prompt when the ship has a job it can take", async () => {
renderApp(api([{ ...heavyJob, id: "c-light", massTonnes: 10 }]), "/contracts?location=earth");
await screen.findByLabelText("Board refresh");
expect(screen.queryByRole("group", { name: /Nothing for/ })).not.toBeInTheDocument();
});
});

View File

@ -1,133 +0,0 @@
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<Pilot>): Pilot => ({
id,
name,
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: shipId,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
...extra,
});
const moonJob: Contract = {
id: "c-moon",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: 10,
payout: 900,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
const state = samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [
ship("s-rest", "Sleepy Gull", "p-rest"),
ship("s-tired", "Tired Heron", "p-tired"),
ship("s-fresh", "Fresh Lark", "p-fresh"),
],
pilots: [
pilot("p-rest", "Ana Resting", "s-rest", {
resting: true,
restEndsAt: NOW + 12 * 60_000 + 5_000,
}),
pilot("p-tired", "Bo Tired", "s-tired", { dutyHours: 11 }),
pilot("p-fresh", "Cy Fresh", "s-fresh", { dutyHours: 1 }),
],
});
/** The Courier's game hours for the Moon job, straight from the sim. */
const tripHours = () =>
travelTimeGameHours(
content.shipModel("courier")!,
routeDistance(
content.location("earth")!,
content.location("moon")!,
realToGame(NOW, content.config),
),
moonJob.massTonnes,
content.config,
);
async function openSheet() {
renderApp(
fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts: [moonJob], periodEndsAt: NOW + 3_600_000 }),
}),
"/contracts?location=earth",
);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
return screen.getByRole("dialog");
}
const option = (dialog: HTMLElement, shipName: string) =>
within(dialog).getByText(shipName).closest("li")!;
describe("duty in the dispatch sheet (Step 10D.7)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("disables a resting pilot's pair with the time left", async () => {
const resting = option(await openSheet(), "Sleepy Gull");
expect(within(resting).getByRole("radio")).toBeDisabled();
expect(resting).toHaveTextContent("Resting — 12:05");
});
it("warns when the trip takes the pilot to the limit, with the sim's duty numbers", async () => {
const dialog = await openSheet();
const tired = option(dialog, "Tired Heron");
expect(tired).toHaveTextContent(
`Duty after${formatGameHours(11 + tripHours())} of ${formatGameHours(12)}`,
);
expect(tired).toHaveTextContent("Will need rest on arrival");
});
it("shows no warning when the pilot stays under the limit", async () => {
const fresh = option(await openSheet(), "Fresh Lark");
expect(fresh).toHaveTextContent(
`Duty after${formatGameHours(1 + tripHours())} of ${formatGameHours(12)}`,
);
expect(fresh).not.toHaveTextContent("Will need rest");
});
});

View File

@ -1,92 +0,0 @@
import type { Pilot, PlayerState } from "@deadhead/schema";
import { screen, waitFor, 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 { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const pilot = (overrides: Partial<Pilot>): Pilot => ({
id: "p",
name: "Pilot",
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
...overrides,
});
const state = (): PlayerState =>
samplePlayerState({
tutorial: null,
bays: [{ id: "b1", locationId: "earth" }],
pilots: [
pilot({ id: "p-rae", name: "Rae Okafor", dutyHours: 9, dutyLimit: 14 }),
pilot({
id: "p-jun",
name: "Jun Sato",
resting: true,
restEndsAt: NOW + 25 * 60_000,
}),
],
});
const card = async (name: string) => (await screen.findByText(name)).closest("li")!;
describe("pilot duty on the Pilots screen (Step 10D.6)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows a duty bar matching the pilot's hours and limit", async () => {
renderApp(fakeApi({ getPlayerState: state }), "/pilots");
const rae = await card("Rae Okafor");
const bar = within(rae).getByRole("progressbar", { name: "Rae Okafor duty" });
expect(bar).toHaveAttribute("aria-valuenow", "9");
expect(bar).toHaveAttribute("aria-valuemax", "14");
expect(rae).toHaveTextContent("9.0 h of 14 h");
});
it("shows a resting pilot's countdown and no Rest now action", async () => {
renderApp(fakeApi({ getPlayerState: state }), "/pilots");
const jun = await card("Jun Sato");
expect(jun).toHaveTextContent("Resting · wakes in 25m 00s");
expect(within(jun).queryByRole("button", { name: "Rest now" })).not.toBeInTheDocument();
expect(within(jun).queryByRole("progressbar")).not.toBeInTheDocument();
});
it("rests a pilot after confirming that rest lasts at least one board refresh", async () => {
const api = fakeApi({
getPlayerState: state,
restPilot: ({ pilotId }) => ({
pilot: pilot({
id: pilotId,
name: "Rae Okafor",
resting: true,
restEndsAt: NOW + 3_600_000,
}),
}),
});
renderApp(api, "/pilots");
const rae = await card("Rae Okafor");
await userEvent.click(within(rae).getByRole("button", { name: "Rest now" }));
const confirm = within(rae).getByRole("group", { name: "Rest Rae Okafor?" });
expect(confirm).toHaveTextContent("at least one board refresh");
expect(api.call).not.toHaveBeenCalledWith("restPilot", expect.anything());
await userEvent.click(within(confirm).getByRole("button", { name: "Rest Rae Okafor" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("restPilot", { pilotId: "p-rae" }));
});
});

View File

@ -1,90 +0,0 @@
import type { Pilot, ResolutionSummary } 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 { formatLocalTime } from "../src/screens/DispatchSheet";
import { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const pilot = (id: string, name: string, extra: Partial<Pilot> = {}): Pilot => ({
id,
name,
isAvatar: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: null,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
...extra,
});
const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
dispatchId: "d1",
shipId: "s1",
pilotId: "p-rae",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 500,
loanRepayment: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: NOW,
...extra,
});
const pilots = [
pilot("p-rae", "Rae Okafor", { resting: true, restEndsAt: NOW + 40 * 60_000 }),
pilot("p-jun", "Jun Sato", { resting: true, restEndsAt: NOW + 9 * 60_000 }),
pilot("p-cy", "Cy Fresh"),
];
describe("rest in summaries and on HQ (Step 10D.9)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("says in the away summary that a pilot is resting, only for trips that ended in rest", async () => {
const restEndsAt = NOW + 40 * 60_000;
renderApp(
fakeApi({
getPlayerState: () =>
samplePlayerState({
tutorial: null,
pilots,
summaries: [
summary({ dispatchId: "d1", restEndsAt }),
summary({ dispatchId: "d2", pilotId: "p-cy" }),
],
}),
}),
"/",
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
const [rested, plain] = within(dialog).getAllByRole("listitem");
expect(rested).toHaveTextContent(`Rae Okafor is resting until ${formatLocalTime(restEndsAt)}`);
expect(plain).not.toHaveTextContent("resting");
});
it("shows how many pilots are resting and who wakes first", async () => {
renderApp(
fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null, pilots }) }),
"/",
);
const card = await screen.findByLabelText("Pilot rest");
expect(card).toHaveTextContent("Pilots resting: 2");
expect(card).toHaveTextContent("Jun Sato wakes in 9m 00s");
});
});

View File

@ -145,7 +145,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Skill fields: id, display name, max level, description. - Skill fields: id, display name, max level, description.
- GameConfig fields: time compression factor; game epoch as two fields (real start timestamp and the in-game ephemeris date it maps to); starting credits; tutorial payout share; contract board size; contract board period (real minutes — contracts expire at the end of their period); first-contact chance; first-contact payout multiplier; contract mass range; Long Haul distance cut-offs (one per level); XP per delivery formula inputs; XP needed per pilot level; skill points per level; hire cost per pilot; pilot pay per delivery (flat fee). - GameConfig fields: time compression factor; game epoch as two fields (real start timestamp and the in-game ephemeris date it maps to); starting credits; tutorial payout share; contract board size; contract board period (real minutes — contracts expire at the end of their period); first-contact chance; first-contact payout multiplier; contract mass range; Long Haul distance cut-offs (one per level); XP per delivery formula inputs; XP needed per pilot level; skill points per level; hire cost per pilot; pilot pay per delivery (flat fee).
- **Test:** Unit tests confirm each schema accepts a valid example and rejects an object missing a required field. - **Test:** Unit tests confirm each schema accepts a valid example and rejects an object missing a required field.
- **Amended by CR-004:** GameConfig gains `dutyLimitGameHours`, `longHaulDutyBonusGameHours` and `restGameHours` (Step 10D.1).
### Step 2.2 — Add the five locations ### Step 2.2 — Add the five locations
- Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD. - Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD.
@ -234,7 +233,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add a function that validates a proposed dispatch: ship is idle at the contract's origin, pilot is idle and assigned to that ship (or the tutorial loaner case), cargo mass fits ship capacity, route passes range and landing checks, pilot is eligible, contract has not expired, player can afford fuel (skipped for tutorial dispatches, where the company pays). - Add a function that validates a proposed dispatch: ship is idle at the contract's origin, pilot is idle and assigned to that ship (or the tutorial loaner case), cargo mass fits ship capacity, route passes range and landing checks, pilot is eligible, contract has not expired, player can afford fuel (skipped for tutorial dispatches, where the company pays).
- Return "valid" or the first failing reason. - Return "valid" or the first failing reason.
- **Test:** One test per failure reason, each constructing a scenario that triggers exactly that reason. One test for a fully valid dispatch. - **Test:** One test per failure reason, each constructing a scenario that triggers exactly that reason. One test for a fully valid dispatch.
- **Amended by CR-004:** Validation (contract and deadhead) gains `pilot-resting`, checked before the other pilot checks (Step 10D.3).
### Step 3.13 — Dispatch record creation ### Step 3.13 — Dispatch record creation
- Add a function that turns a validated dispatch into a Dispatch record: id, ship, pilot, contract (or none for reposition), origin, destination, departure real time, arrival real time (computed from travel time and clock conversion), fuel cost, expected payout, seed. - Add a function that turns a validated dispatch into a Dispatch record: id, ship, pilot, contract (or none for reposition), origin, destination, departure real time, arrival real time (computed from travel time and clock conversion), fuel cost, expected payout, seed.
@ -256,7 +254,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 3.17 — Dispatch resolution ### Step 3.17 — Dispatch resolution
- Add a function that, given a dispatch and current real time, returns either "still in flight" or a Resolution: credits to add (payout minus pilot pay, with the avatar exempt from pilot pay; for tutorial runs, exactly the tutorial share), XP to add to the pilot, ship's new location, and whether the destination becomes discovered. - Add a function that, given a dispatch and current real time, returns either "still in flight" or a Resolution: credits to add (payout minus pilot pay, with the avatar exempt from pilot pay; for tutorial runs, exactly the tutorial share), XP to add to the pilot, ship's new location, and whether the destination becomes discovered.
- **Test:** Before arrival → in flight. At or after arrival → Resolution. A delivery to an undiscovered location marks it discovered. A delivery flown by the avatar deducts no pilot pay; one flown by a hired pilot does. A reposition gives no credits or XP. Resolving the same dispatch twice with the same inputs returns identical results. - **Test:** Before arrival → in flight. At or after arrival → Resolution. A delivery to an undiscovered location marks it discovered. A delivery flown by the avatar deducts no pilot pay; one flown by a hired pilot does. A reposition gives no credits or XP. Resolving the same dispatch twice with the same inputs returns identical results.
- **Amended by CR-004:** Resolution returns the pilot's new duty hours and, on mandatory rest, rest-ends-at from the arrival time (Step 10D.3).
### Step 3.18 — Sim package public API and coverage ### Step 3.18 — Sim package public API and coverage
- Export only the public functions from a single entry point. Configure coverage reporting for `packages/sim`. - Export only the public functions from a single entry point. Configure coverage reporting for `packages/sim`.
@ -293,7 +290,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Add a migration command. - Add a migration command.
- **Test:** Running migrations against the empty test database succeeds; running them a second time is a no-op. - **Test:** Running migrations against the empty test database succeeds; running them a second time is a no-op.
- **Amended by CR-002:** `contract_boards` is keyed by player, kind (`location` / `tutorial`), location and period (plus tutorial run); one board per player, location and period (Step 10B.3). - **Amended by CR-002:** `contract_boards` is keyed by player, kind (`location` / `tutorial`), location and period (plus tutorial run); one board per player, location and period (Step 10B.3).
- **Amended by CR-004:** The pilots table gains `duty_hours` and `rest_ends_at` (Step 10D.4).
### Step 5.4 — Test database harness ### Step 5.4 — Test database harness
- Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.). - Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.).
@ -448,7 +444,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
- Selecting a contract opens a dispatch sheet: choose an eligible ship+pilot pair at the origin. Show trip time, fuel cost, net profit, and ETA in both game time and real time. Ineligible pairs are listed but disabled with the reason shown (out of range, can't land, skill too low, too heavy). - Selecting a contract opens a dispatch sheet: choose an eligible ship+pilot pair at the origin. Show trip time, fuel cost, net profit, and ETA in both game time and real time. Ineligible pairs are listed but disabled with the reason shown (out of range, can't land, skill too low, too heavy).
- Confirm sends the dispatch; on error, show the server's message. - Confirm sends the dispatch; on error, show the server's message.
- **Test:** Component test: an eligible pair shows correct numbers from the sim; an ineligible pair shows the correct reason; confirming calls dispatch; a server error message is displayed. - **Test:** Component test: an eligible pair shows correct numbers from the sim; an ineligible pair shows the correct reason; confirming calls dispatch; a server error message is displayed.
- **Amended by CR-004:** The dispatch sheet disables resting pilots with a countdown and shows duty after the trip, warning "Will need rest on arrival" (Step 10D.7).
### Step 9.6 — Fleet screen ### Step 9.6 — Fleet screen
- List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location. - List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location.
@ -458,7 +453,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
### Step 9.7 — Pilots screen ### Step 9.7 — Pilots screen
- List pilots: name, level, XP progress to next level, skill levels, available skill points, assignment, status. Actions: hire pilot (shows cost), assign to ship, spend skill point. - List pilots: name, level, XP progress to next level, skill levels, available skill points, assignment, status. Actions: hire pilot (shows cost), assign to ship, spend skill point.
- **Test:** Component test: spending a point calls the endpoint and the UI updates; hire is disabled with insufficient credits; assign lists only idle ships at the pilot's location. - **Test:** Component test: spending a point calls the endpoint and the UI updates; hire is disabled with insufficient credits; assign lists only idle ships at the pilot's location.
- **Amended by CR-004:** Pilot cards show a duty bar or a rest countdown, and "Rest now" with a confirmation (Step 10D.6).
### Step 9.8 — Market screen ### Step 9.8 — Market screen
- List the three ship models with stats (speed, capacity, range, landing class, price). "Buy" is disabled with insufficient credits. - List the three ship models with stats (speed, capacity, range, landing class, price). "Buy" is disabled with insufficient credits.
@ -541,7 +535,6 @@ Steps 10D.1–10D.10 are specified in `memory-bank/change-requests/CR-004-pilot-
- **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.) - **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.)
- **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds. - **Amended by CR-001:** Change the thresholds to: deal accepted under 10 real minutes; first pilot hired between 30 and 45 minutes; first discovery under 90 minutes; and a new check that after the deal the player always either has a ship in flight or can afford fuel for at least one contract. The simulated player accepts the deal as soon as it is available, takes the highest credits-per-minute contract, and pays down the loan only through automatic repayment. Adjust content values, not thresholds.
- **Amended by CR-002:** The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination. - **Amended by CR-002:** The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination.
- **Amended by CR-004:** The simulated player follows rest rules: it rests a pilot when no eligible contract exists at its location and deadheading isn't better. Re-check all CR-001 timing targets with rest in place; adjust content values, not thresholds. Add a check that no pilot is ever dispatched while resting.
### Step 12.2 — Revisit the tutorial economy test ### Step 12.2 — Revisit the tutorial economy test
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script. - Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.
@ -567,7 +560,6 @@ Steps 10D.1–10D.10 are specified in `memory-bank/change-requests/CR-004-pilot-
- **Test:** The contract's ship/pilot pair changes from disabled ("skill too low") to enabled. - **Test:** The contract's ship/pilot pair changes from disabled ("skill too low") to enabled.
--- ---
- **Amended by CR-004:** After leveling Long Haul, confirm the pilot's duty limit increased.
## Phase 13 — Deployment (Base Game) ## Phase 13 — Deployment (Base Game)

View File

@ -169,8 +169,8 @@ Spec: `memory-bank/change-requests/CR-004-pilot-duty-rest.md`. New decisions rec
- [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`. - [x] **Step 10D.3 — Sim: validation and resolution.** `PilotState.restEndsAt`; `validateDispatch` and `validateReposition` fail with `pilot-resting` before the other pilot checks. `ResolveInput` takes `pilotDutyHours` and `pilotSkills`; `Resolution` returns `pilotDutyHours` and `pilotRestEndsAt` (rest from the arrival time). Server and client pass interim values (no rest, zero duty) until the columns exist (10D.4–10D.5). Test: `duty-dispatch.test.ts`.
- [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated. - [x] **Step 10D.4 — Schema and database.** Migration `0004_cr004_pilot_duty`: `pilots.duty_hours` (double, default 0) and `pilots.rest_ends_at` (nullable). API `Pilot` gains `dutyHours`, `dutyLimit`, `resting`, `restEndsAt` (null unless resting); `ResolutionSummary` gains `restEndsAt` (mandatory rest). Route `restPilot` (`POST /api/pilots/:pilotId/rest`). `pilot-resting` is an API code via the sim's failure list. `toApiPilot` and `loadPlayerState` take `now`. Dev database migrated.
- [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`. - [x] **Step 10D.5 — Server: apply duty and rest.** `resolveArrivedDispatches` (used by both lazy and job resolution) writes the pilot's duty hours and, on mandatory rest, rest-ends-at from the arrival. Dispatch validation reads the stored rest. `restPilot` (`POST /api/pilots/:pilotId/rest`): idle and not resting, any duty level, no bay; `pilot-not-idle` / `pilot-resting` / `tutorial-active` otherwise. Assign and unassign ignore rest. Test: `duty-rest.test.ts`.
- [x] **Step 10D.6 — Client: pilot duty on the Pilots screen.** Each pilot card shows a duty bar ("9.0 h of 14 h", `progressbar` with the hours and limit); resting pilots show "Resting · wakes in …" instead. Idle, non-resting pilots (after the tutorial) have "Rest now" with an inline confirmation stating the rest length and that it covers at least one board refresh, then call `restPilot`. Test: `pilots-duty.test.tsx`. - [ ] Step 10D.6 — Client: pilot duty on the Pilots screen
- [x] **Step 10D.7 — Client: duty in the dispatch flow.** The dispatch sheet disables a resting pilot's pair with "Resting — mm:ss" (new `formatClock`). Eligible pairs show "Duty after" (sim `tripGameHours` + `dutyAfterTrip`) and "Will need rest on arrival" when `needsRest`. Client validation now passes the pilot's real `restEndsAt` (dispatch sheet and deadhead picker, which shows "Pilot is resting"). Test: `dispatch-duty.test.tsx`. - [ ] Step 10D.7 — Client: duty in the dispatch flow
- [x] **Step 10D.8 — Client: board refresh and the "nothing here" case.** Every location board shows "New contracts in mm:ss" to `periodEndsAt` and refetches itself just after it. For each idle, crewed, non-resting ship at the location with no contract it can take (by the sim, via `pairOptions`), a prompt offers "Rest [pilot] — wakes to a new board" (`restPilot`) and "Deadhead (fly empty)" (opens the Fleet's deadhead picker, now exported). Test: `board-refresh.test.tsx`. - [ ] Step 10D.8 — Client: board refresh and the "nothing here" case
- [x] **Step 10D.9 — Client: rest in summaries and on HQ.** The "While you were away" entry for a trip that ended in mandatory rest says "[Pilot] is resting until [local time]". HQ (after the tutorial) has a "Pilot rest" card: "Pilots resting: N" and the soonest to wake with a countdown, or "Everyone is ready to fly." Test: `rest-summary-hq.test.tsx`. - [ ] Step 10D.9 — Client: rest in summaries and on HQ
- [x] **Step 10D.10 — Plan amendments and regression.** CR-004 §6 amendments added to plan steps 2.1, 3.12, 3.17, 5.3, 9.5, 9.7, 12.1 and 12.6 (§0.5.42–46 were added with the CR). Full suite, coverage, lint and web build green. Scripted manual check against a live server: guest → 4 runs → deal → the avatar chains Earth↔Moon trips (2.1–2.5 game h each) and rests on arrival after the sixth (12 h limit); dispatch while resting is refused with `pilot-resting`; on waking, Earth's board is a new set. **Balance note for Phase 12:** each board has one guaranteed Courier-sized job per period, so after the first trip from a location in a period the Courier often has to deadhead or rest. **Phase 10D complete — stopped for review.** - [ ] Step 10D.10 — Plan amendments and regression