Step 15.7: client: fuel in the UI
CI / check (push) Successful in 5m50s Details

Ship cards show a fuel gauge and refuel policy, with a Fuel sheet to
fill or buy an amount, switch policy, and order emergency fuel when it
is offered. The dispatch sheet and deadhead picker show fuel needed,
on board and left after, price fuel at the tank's average, and offer
"Refuel here first". Locations show fuel prices with the cheapest
marked, and delivery summaries list Auto top-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 01:50:33 -04:00
parent 201f25a935
commit 7a061d0e40
11 changed files with 555 additions and 22 deletions

View File

@ -1481,3 +1481,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **A refuel is all or nothing**: asking for more than the credits cover is refused (`insufficient-funds`) rather than buying part of it. Only Auto top-up buys "what the player can afford"; a manual purchase does what was asked. The dispatch sheet's "Refuel here first" (15.7) asks for exactly what is needed or a fill. - **A refuel is all or nothing**: asking for more than the credits cover is refused (`insufficient-funds`) rather than buying part of it. Only Auto top-up buys "what the player can afford"; a manual purchase does what was asked. The dispatch sheet's "Refuel here first" (15.7) asks for exactly what is needed or a fill.
- **Emergency fuel goes into the tank's average at its marked-up price**, so the next legs' "fuel used" (and the pilot's share) reflect what it really cost, even though it was paid through the loan. - **Emergency fuel goes into the tank's average at its marked-up price**, so the next legs' "fuel used" (and the pilot's share) reflect what it really cost, even though it was paid through the loan.
- **The new error codes** are `no-fuel-here`, `tank-full` and `emergency-fuel-unavailable`; refuelling an in-flight ship reuses `ship-not-idle-here`. - **The new error codes** are `no-fuel-here`, `tank-full` and `emergency-fuel-unavailable`; refuelling an in-flight ship reuses `ship-not-idle-here`.
## Step 15.7
- **Fuel lives in its own sheet, opened from the ship card**, beside Upgrades: the app has no ship detail screen, and the Upgrades sheet already set the pattern for per-ship actions.
- **"Refuel here first" fills the tank if the player can pay for it, else buys only the leg's shortfall**, so the button works whenever the leg is affordable at all. The engine's all-or-nothing refuel (15.6) then never refuses it for want of credits unless even the shortfall is unaffordable, which is what emergency fuel is for.
- **"Blocked only by fuel" is read from the sim**: fuel is `validateDispatch`'s last check, so a `not-enough-fuel` result means every other rule passed. The deadhead picker shows one "Refuel here first" for the ship, sized for its hungriest destination that is short only of fuel.
- **The cheapest fuel is marked in text ("· cheapest") as well as colour**, so it reads without the colour.

View File

@ -1,4 +1,5 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import { formatFu } from "./Fuel";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
@ -64,7 +65,7 @@ export function AwaySummary() {
// An empty flight earns nothing: say what it was and what it cost. // An empty flight earns nothing: say what it was and what it cost.
<div className="mt-2 flex items-center gap-1.5 font-display text-ink-300"> <div className="mt-2 flex items-center gap-1.5 font-display text-ink-300">
<Icon name="send" className="size-4 shrink-0 text-cyan-300" /> <Icon name="send" className="size-4 shrink-0 text-cyan-300" />
Deadhead · {formatCredits(s.fuelCost)} fuel Deadhead · {formatCredits(s.fuelCost)} fuel used
</div> </div>
) : ( ) : (
<div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums"> <div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums">
@ -81,6 +82,12 @@ export function AwaySummary() {
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"} {state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"}
</div> </div>
)} )}
{s.topUp && (
<div className="mt-1 text-amber-200/90">
Topped up {formatFu(s.topUp.boughtFu)} at {locationName(s.topUp.locationId)},{" "}
{formatCredits(s.topUp.cost)}
</div>
)}
{s.loanRepayment > 0 && ( {s.loanRepayment > 0 && (
<div className="mt-1 text-ink-400"> <div className="mt-1 text-ink-400">
{formatCredits(s.loanRepayment)} went to the lender {formatCredits(s.loanRepayment)} went to the lender

View File

@ -0,0 +1,231 @@
import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { emergencyFuel, fuelPriceAt } from "@deadhead/sim";
import { useState } from "react";
import { formatCredits, locationName } from "../format";
import { simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Button, Meter, Sheet } from "../ui";
/** Fuel in whole fu, e.g. "1,170 fu" (CR-015). */
export function formatFu(fu: number): string {
return `${Math.round(fu).toLocaleString("en-US")} fu`;
}
/** Credits per fu at a location (decision 0.5.96). */
export function fuelPriceOf(locationId: string): number {
return fuelPriceAt(content.location(locationId)!, content.config);
}
/** "16 cr/fu"; prices are whole credits in the base game but may not be. */
export function formatFuelPrice(price: number): string {
return `${Number.isInteger(price) ? price : price.toFixed(1)} cr/fu`;
}
/** The location (or locations) selling the cheapest fuel (decision 0.5.96). */
export function cheapestFuelIds(): Set<string> {
const selling = content.locations.filter((l) => l.sellsFuel);
const lowest = Math.min(...selling.map((l) => l.fuelPrice));
return new Set(selling.filter((l) => l.fuelPrice === lowest).map((l) => l.id));
}
/** A location's fuel price; the cheapest in the system is highlighted (Step 15.7). */
export function FuelPrice({ locationId }: { locationId: string }) {
const location = content.location(locationId);
if (!location?.sellsFuel) return <span className="text-ink-400">None sold</span>;
const cheapest = cheapestFuelIds().has(locationId);
return (
<span className={cheapest ? "font-semibold text-emerald-300" : undefined}>
{formatFuelPrice(fuelPriceOf(locationId))}
{cheapest && " · cheapest"}
</span>
);
}
const POLICY_LABELS = { auto: "Auto top-up", manual: "Manual refuel" } as const;
/** A ship's fuel: fu, percent and refuel policy (Step 15.7). */
export function FuelGauge({ ship }: { ship: Ship }) {
const capacity = ship.stats.tankCapacityFu;
const percent = capacity > 0 ? Math.round((ship.fuelFu / capacity) * 100) : 0;
return (
<div>
<Meter
label={`${ship.name} fuel`}
value={ship.fuelFu}
max={capacity}
tone={percent < 25 ? "rose" : "amber"}
size="sm"
/>
<div className="mt-1 flex justify-between font-display text-xs text-ink-400">
<span aria-label={`${ship.name} fuel level`} className="tabular-nums">
{formatFu(ship.fuelFu)} · {percent}%
</span>
<span>{POLICY_LABELS[ship.refuelPolicy]}</span>
</div>
</div>
);
}
/**
* What "Refuel here first" buys (Step 15.7): a full tank when the credits
* cover it, otherwise just enough for the leg.
*/
export function refuelForLeg(
ship: Ship,
neededFu: number,
credits: number,
): { shipId: string; amountFu?: number } {
const price = fuelPriceOf(ship.locationId ?? "");
const space = ship.stats.tankCapacityFu - ship.fuelFu;
if (credits >= Math.round(space * price)) return { shipId: ship.id };
return { shipId: ship.id, amountFu: Math.max(0, neededFu - ship.fuelFu) };
}
/** The emergency fuel offer for a ship, or null (decision 0.5.99). */
export function emergencyOffer(state: PlayerState, ship: Ship) {
if (ship.status !== "idle" || !ship.locationId) return null;
return emergencyFuel(
{
ship: simShip(ship),
credits: state.company.credits,
discoveredIds: new Set(state.locations.filter((l) => l.discovered).map((l) => l.id)),
},
content,
);
}
/** A ship's fuel: refuel here (fill or an amount), refuel policy, and emergency fuel (Step 15.7). */
export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => void }) {
const state = useLoadedPlayer();
const refuel = useGameAction("refuelShip");
const policy = useGameAction("setRefuelPolicy");
const emergency = useGameAction("orderEmergencyFuel");
const [amount, setAmount] = useState("");
const ship = state.ships.find((s) => s.id === shipId);
if (!ship) return null;
const here = ship.status === "idle" && ship.locationId ? content.location(ship.locationId) : null;
const price = here ? fuelPriceOf(here.id) : 0;
const space = Math.max(0, ship.stats.tankCapacityFu - ship.fuelFu);
const fillCost = Math.round(space * price);
const offer = emergencyOffer(state, ship);
const amountFu = Number(amount);
const error = refuel.error ?? policy.error ?? emergency.error;
return (
<Sheet titleId="fuel-title" eyebrow="Fuel" title={ship.name}>
<div className="mt-3">
<FuelGauge ship={ship} />
<p className="mt-1 text-xs text-ink-400">
Average price paid: {formatFuelPrice(Math.round(ship.fuelAvgPrice * 10) / 10)}
</p>
</div>
{!here ? (
<p className="mt-3 text-sm text-ink-400">Refuel when the ship has landed.</p>
) : !here.sellsFuel ? (
<p className="mt-3 text-sm text-ink-400">{here.name} sells no fuel.</p>
) : (
<section aria-label="Refuel" className="mt-3 space-y-2 text-sm">
<p className="text-ink-200">
Fuel at {here.name}: {formatFuelPrice(price)}
</p>
<Button
block
disabled={space <= 0 || refuel.isPending || state.company.credits < fillCost}
onClick={() => refuel.mutate({ shipId: ship.id })}
>
{space <= 0 ? "Tank full" : `Fill (${formatFu(space)}, ${formatCredits(fillCost)})`}
</Button>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (amountFu > 0) refuel.mutate({ shipId: ship.id, amountFu });
}}
>
<label className="flex-1">
<span className="sr-only">Fuel to buy (fu)</span>
<input
type="number"
min={1}
step={1}
max={Math.floor(space)}
inputMode="numeric"
placeholder="Amount (fu)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 text-ink-50 focus:border-amber-400 focus:outline-none"
/>
</label>
<Button
type="submit"
variant="secondary"
disabled={!(amountFu > 0) || space <= 0 || refuel.isPending}
>
Buy
{amountFu > 0
? ` (${formatCredits(Math.round(Math.min(amountFu, space) * price))})`
: ""}
</Button>
</form>
</section>
)}
<fieldset className="mt-4 text-sm">
<legend className="text-ink-400">Refuel policy</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
{(["auto", "manual"] as const).map((value) => (
<label
key={value}
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
>
<input
type="radio"
name={`policy-${ship.id}`}
value={value}
checked={ship.refuelPolicy === value}
onChange={() => policy.mutate({ shipId: ship.id, policy: value })}
className="sr-only"
/>
{POLICY_LABELS[value]}
</label>
))}
</div>
<p className="mt-1 text-xs text-ink-400">
{ship.refuelPolicy === "auto"
? "Fills the tank on every landing, as far as your credits go."
: "Never buys fuel on its own: wait for a cheaper place."}
</p>
</fieldset>
{offer && (
<div className="mt-4 rounded-xl border border-rose-400/40 bg-rose-950/30 p-3 text-sm">
<p className="text-rose-200">
{ship.name} can&apos;t reach anywhere from {locationName(ship.locationId ?? "")} and you
can&apos;t afford the fuel.
</p>
<Button
block
variant="danger"
className="mt-2"
disabled={emergency.isPending}
onClick={() => emergency.mutate({ shipId: ship.id })}
>
Emergency fuel (added to loan): {formatFu(offer.amountFu)}, {formatCredits(offer.cost)}
</Button>
</div>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(error)}
</p>
)}
<Button variant="secondary" block className="mt-4" onClick={onClose}>
Done
</Button>
</Sheet>
);
}

View File

@ -17,6 +17,7 @@ import {
type DispatchFailure, type DispatchFailure,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { useState } from "react"; import { useState } from "react";
import { formatFu, refuelForLeg } from "../components/Fuel";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { import {
@ -168,7 +169,12 @@ export interface PairOption {
/** Why this pair cannot take the contract; empty when it can. */ /** Why this pair cannot take the contract; empty when it can. */
problems: string[]; problems: string[];
tripMs: number; tripMs: number;
/** The fuel used, at the tank's average price (decision 0.5.98). */
fuelCost: number; fuelCost: number;
/** The leg's fuel, what is on board, and what would be left (fu, CR-015). */
fuel: { neededFu: number; onBoardFu: number; leftFu: number };
/** Blocked only for want of fuel: "Refuel here first" makes it flyable (Step 15.7). */
shortOfFuel: boolean;
/** A hired pilot's profit share; 0 for the avatar (CR-010). */ /** A hired pilot's profit share; 0 for the avatar (CR-010). */
pilotPay: number; pilotPay: number;
netProfit: number; netProfit: number;
@ -214,6 +220,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0; pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]]; let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
let shortOfFuel = false;
if (pilot) { if (pilot) {
const check = validateDispatch( const check = validateDispatch(
{ {
@ -237,6 +244,8 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
if (check.ok) { if (check.ok) {
problems = []; problems = [];
} else { } else {
// Fuel is checked last, so this reason means nothing else is wrong.
shortOfFuel = check.reason === "not-enough-fuel";
const all = explainProblems( const all = explainProblems(
state, state,
contract, contract,
@ -264,6 +273,8 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
problems, problems,
tripMs, tripMs,
fuelCost, fuelCost,
fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
shortOfFuel,
pilotPay, pilotPay,
netProfit: contract.payout - fuelCost - pilotPay, netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs, arrivalAt: now + tripMs,
@ -285,6 +296,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
const prefsCountdown = usePreferences((p) => p.countdown); const prefsCountdown = usePreferences((p) => p.countdown);
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const dispatch = useGameAction("createDispatch"); const dispatch = useGameAction("createDispatch");
const refuel = useGameAction("refuelShip");
const options = pairOptions(state, contract, now); const options = pairOptions(state, contract, now);
const [chosen, setChosen] = useState<string | null>( const [chosen, setChosen] = useState<string | null>(
options.find((o) => o.problems.length === 0)?.shipId ?? null, options.find((o) => o.problems.length === 0)?.shipId ?? null,
@ -381,7 +393,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
) : ( ) : (
<dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3"> <dl className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
<Cell label="Trip" value={formatDuration(option.tripMs)} /> <Cell label="Trip" value={formatDuration(option.tripMs)} />
<Cell label="Fuel" value={formatCredits(option.fuelCost)} /> <Cell
label="Fuel used (at your average price)"
value={formatCredits(option.fuelCost)}
/>
{option.pilotId !== null && {option.pilotId !== null &&
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && ( !state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
<Cell label="Pilot share" value={formatCredits(option.pilotPay)} /> <Cell label="Pilot share" value={formatCredits(option.pilotPay)} />
@ -403,6 +418,30 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)} )}
</dl> </dl>
)} )}
<p aria-label="Fuel" className="mt-2 text-xs text-ink-400 tabular-nums">
Fuel needed {formatFu(option.fuel.neededFu)} · on board{" "}
{formatFu(option.fuel.onBoardFu)} · left after{" "}
{formatFu(Math.max(0, option.fuel.leftFu))}
</p>
{option.shortOfFuel && (
<Button
variant="secondary"
size="sm"
className="mt-2"
disabled={refuel.isPending}
onClick={(e) => {
// Inside the pair's label: don't let the click pick the radio.
e.preventDefault();
const ship = state.ships.find((s) => s.id === option.shipId)!;
refuel.mutate(
refuelForLeg(ship, option.fuel.neededFu, state.company.credits),
{ onSuccess: () => setChosen(option.shipId) },
);
}}
>
Refuel here first
</Button>
)}
{!blocked && option.priority && ( {!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200"> <p aria-label="Priority margin" className="mt-2 text-amber-200">
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "} Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
@ -425,9 +464,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
</ul> </ul>
)} )}
{dispatch.isError && ( {(dispatch.isError || refuel.isError) && (
<p role="alert" className="mt-3 text-sm text-rose-300"> <p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(dispatch.error)} {errorMessage(dispatch.error ?? refuel.error)}
</p> </p>
)} )}
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">

View File

@ -10,6 +10,7 @@ import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel";
import { ShipUpgrades } from "./ShipUpgrades"; import { ShipUpgrades } from "./ShipUpgrades";
import { strandedText } from "./DispatchSheet"; import { strandedText } from "./DispatchSheet";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
@ -33,6 +34,7 @@ export function Fleet() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const [deadheading, setDeadheading] = useState<Ship | null>(null); const [deadheading, setDeadheading] = useState<Ship | null>(null);
const [upgrading, setUpgrading] = useState<string | null>(null); const [upgrading, setUpgrading] = useState<string | null>(null);
const [fueling, setFueling] = useState<string | null>(null);
const flying = state.ships.filter((s) => s.status === "in-flight").length; const flying = state.ships.filter((s) => s.status === "in-flight").length;
@ -68,12 +70,14 @@ export function Fleet() {
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null} pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
onDeadhead={() => setDeadheading(ship)} onDeadhead={() => setDeadheading(ship)}
onUpgrades={() => setUpgrading(ship.id)} onUpgrades={() => setUpgrading(ship.id)}
onFuel={() => setFueling(ship.id)}
/> />
))} ))}
</ul> </ul>
)} )}
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />} {deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
{upgrading && <ShipUpgrades shipId={upgrading} onClose={() => setUpgrading(null)} />} {upgrading && <ShipUpgrades shipId={upgrading} onClose={() => setUpgrading(null)} />}
{fueling && <ShipFuel shipId={fueling} onClose={() => setFueling(null)} />}
</Screen> </Screen>
); );
} }
@ -84,6 +88,7 @@ function ShipRow(props: {
pilotName: string | null; pilotName: string | null;
onDeadhead: () => void; onDeadhead: () => void;
onUpgrades: () => void; onUpgrades: () => void;
onFuel: () => void;
}) { }) {
const { ship, dispatch, pilotName } = props; const { ship, dispatch, pilotName } = props;
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
@ -142,6 +147,7 @@ function ShipRow(props: {
</div> </div>
</div> </div>
)} )}
<FuelGauge ship={ship} />
{inShop && ( {inShop && (
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200"> <p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" /> In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
@ -169,16 +175,28 @@ function ShipRow(props: {
</Button> </Button>
</div> </div>
)} )}
<Button <div className="flex gap-2">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={props.onUpgrades} size="sm"
aria-label={`Upgrades for ${ship.name}`} onClick={props.onFuel}
> aria-label={`Fuel for ${ship.name}`}
Upgrades className="flex-1"
{Object.keys(ship.upgrades).length > 0 && >
` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`} Fuel
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={props.onUpgrades}
aria-label={`Upgrades for ${ship.name}`}
className="flex-1"
>
Upgrades
{Object.keys(ship.upgrades).length > 0 &&
` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`}
</Button>
</div>
</Panel> </Panel>
); );
} }
@ -203,6 +221,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
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");
const refuel = useGameAction("refuelShip");
// Effective stats: upgraded fuel tanks reach farther (CR-009). // Effective stats: upgraded fuel tanks reach farther (CR-009).
const model = shipStatsOf(ship); const model = shipStatsOf(ship);
const origin = ship.locationId!; const origin = ship.locationId!;
@ -230,21 +249,25 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
}, },
content, content,
); );
const neededFu = tripFuel(model, location(origin), location(id), content.config);
return { return {
id, id,
neededFu,
shortOfFuel: !check.ok && check.reason === "not-enough-fuel",
problem: check.ok problem: check.ok
? null ? null
: check.reason === "would-strand" : check.reason === "would-strand"
? strandedText(id) ? strandedText(id)
: (DEADHEAD_REASONS[check.reason] ?? check.reason), : (DEADHEAD_REASONS[check.reason] ?? check.reason),
// The fuel used, at the tank's average price (decision 0.5.98). // The fuel used, at the tank's average price (decision 0.5.98).
fuelCost: Math.round( fuelCost: Math.round(neededFu * ship.fuelAvgPrice),
tripFuel(model, location(origin), location(id), content.config) * ship.fuelAvgPrice,
),
tripMs: tripRealMs(model, origin, id, 0), tripMs: tripRealMs(model, origin, id, 0),
}; };
}); });
// Enough for the hungriest leg that is only short of fuel (Step 15.7).
const shortLegFu = Math.max(0, ...options.filter((o) => o.shortOfFuel).map((o) => o.neededFu));
return ( return (
<Sheet <Sheet
titleId="deadhead-title" titleId="deadhead-title"
@ -252,6 +275,20 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
title={`Deadhead ${ship.name}`} title={`Deadhead ${ship.name}`}
subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`} subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`}
> >
<p aria-label="Fuel on board" className="mt-3 text-sm text-ink-300">
On board: {formatFu(ship.fuelFu)} of {formatFu(ship.stats.tankCapacityFu)}
</p>
{shortLegFu > 0 && (
<Button
variant="secondary"
size="sm"
className="mt-2"
disabled={refuel.isPending}
onClick={() => refuel.mutate(refuelForLeg(ship, shortLegFu, state.company.credits))}
>
Refuel here first
</Button>
)}
<ul className="mt-4 space-y-2"> <ul className="mt-4 space-y-2">
{options.map((option) => { {options.map((option) => {
const sprite = content.location(option.id)?.sprite; const sprite = content.location(option.id)?.sprite;
@ -281,9 +318,13 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
{locationName(option.id)} {locationName(option.id)}
</span> </span>
<span className="font-display text-sm text-ink-300 tabular-nums"> <span className="font-display text-sm text-ink-300 tabular-nums">
{formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)} {formatCredits(option.fuelCost)} fuel used · {formatDuration(option.tripMs)}
</span> </span>
</span> </span>
<span className="block text-xs text-ink-400 tabular-nums">
Needs {formatFu(option.neededFu)} · left after{" "}
{formatFu(Math.max(0, ship.fuelFu - option.neededFu))}
</span>
{option.problem && ( {option.problem && (
<span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span> <span className="mt-0.5 block text-sm text-rose-300">{option.problem}</span>
)} )}
@ -293,9 +334,9 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
); );
})} })}
</ul> </ul>
{reposition.isError && ( {(reposition.isError || refuel.isError) && (
<p role="alert" className="mt-3 text-sm text-rose-300"> <p role="alert" className="mt-3 text-sm text-rose-300">
{errorMessage(reposition.error)} {errorMessage(reposition.error ?? refuel.error)}
</p> </p>
)} )}
<Button variant="secondary" block onClick={onClose} className="mt-4"> <Button variant="secondary" block onClick={onClose} className="mt-4">

View File

@ -1,4 +1,5 @@
import { content, type Location } from "@deadhead/content"; import { content, type Location } from "@deadhead/content";
import { FuelPrice } from "../components/Fuel";
import { Link } from "react-router"; import { Link } from "react-router";
import { cargoName, formatCredits, formatMillionKm } from "../format"; import { cargoName, formatCredits, formatMillionKm } from "../format";
import { distanceAt } from "../game"; import { distanceAt } from "../game";
@ -106,6 +107,12 @@ export function Locations() {
Demands Demands
</dt> </dt>
<dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd> <dd>{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}</dd>
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
Fuel
</dt>
<dd aria-label={`Fuel at ${location.name}`}>
<FuelPrice locationId={location.id} />
</dd>
</dl> </dl>
<Link <Link
to={`/contracts?location=${location.id}`} to={`/contracts?location=${location.id}`}

View File

@ -1,4 +1,5 @@
import { content, type Location } from "@deadhead/content"; import { content, type Location } from "@deadhead/content";
import { FuelPrice } from "../components/Fuel";
import { realToGame } from "@deadhead/sim"; import { realToGame } from "@deadhead/sim";
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
@ -120,6 +121,9 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} · Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·
demands {location.cargoClassMix.demands.map(cargoName).join(", ") || "nothing"} demands {location.cargoClassMix.demands.map(cargoName).join(", ") || "nothing"}
</p> </p>
<p aria-label={`Fuel at ${location.name}`} className="text-ink-200">
Fuel <FuelPrice locationId={location.id} />
</p>
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}> <p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
{hasBay ? "Your HQ is here." : "No HQ here."} {hasBay ? "Your HQ is here." : "No HQ here."}
</p> </p>

View File

@ -126,7 +126,7 @@ describe("dispatch sheet", () => {
expect(within(freighter).getByRole("radio")).toBeEnabled(); expect(within(freighter).getByRole("radio")).toBeEnabled();
expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`); expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`);
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`); expect(freighter).toHaveTextContent(`Fuel used (at your average price)${formatCredits(fuel)}`);
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`); expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
expect(freighter).toHaveTextContent( expect(freighter).toHaveTextContent(
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`, `ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`,

View File

@ -0,0 +1,196 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { emergencyFuel, fuelPriceAt, tripFuel } 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 { formatCredits } from "../src/format";
import { simShip } from "../src/game";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
const courier = content.shipModel("courier")!;
const earthPrice = fuelPriceAt(earth, content.config);
const legFu = tripFuel(courier, earth, moon, content.config);
const ship = (fuelFu: number, refuelPolicy: Ship["refuelPolicy"] = "auto"): Ship => ({
id: "s1",
name: "Dusty Mule",
modelId: "courier",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
fuelFu,
fuelAvgPrice: 15,
refuelPolicy,
pilotId: "avatar",
});
const avatar: Pilot = {
id: "avatar",
name: "Captain",
isAvatar: true,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: "s1",
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
};
const moonJob: Contract = {
id: "c-moon",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 5,
payout: 900,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
/** A one-ship company with `fuelFu` on board and `credits`, whose refuels really fill the tank. */
function company(fuelFu: number, credits = 10_000) {
let state: PlayerState = samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [ship(fuelFu)],
pilots: [avatar],
});
const refuelShip = vi.fn((input: { shipId: string; amountFu?: number }) => {
const s = state.ships[0]!;
const bought = input.amountFu ?? s.stats.tankCapacityFu - s.fuelFu;
const refuelled = { ...s, fuelFu: s.fuelFu + bought };
state = {
...state,
company: {
...state.company,
credits: state.company.credits - Math.round(bought * earthPrice),
},
ships: [refuelled],
};
return { ship: refuelled };
});
const api = fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts: [moonJob], periodEndsAt: NOW + 3_600_000 }),
refuelShip,
});
return { api, refuelShip, state: () => state };
}
describe("fuel in the client (Step 15.7)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows each ship's fuel gauge and refuel policy on the Fleet screen", async () => {
renderApp(company(30).api, "/fleet");
const gauge = await screen.findByRole("progressbar", { name: "Dusty Mule fuel" });
// The gauge runs from empty to the tank's capacity, in fu.
expect(gauge).toHaveAttribute("aria-valuenow", "30");
expect(gauge).toHaveAttribute("aria-valuemax", String(courier.tankCapacityFu));
expect(screen.getByLabelText("Dusty Mule fuel level")).toHaveTextContent("30 fu · 75%");
expect(screen.getByText("Auto top-up")).toBeInTheDocument();
});
it('offers "Refuel here first" for a pair short of fuel, fills the tank and enables it', async () => {
const { api, refuelShip } = company(2);
renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const dialog = screen.getByRole("dialog");
const pair = within(dialog).getByText("Dusty Mule").closest("li")!;
expect(within(pair).getByRole("radio")).toBeDisabled();
expect(pair).toHaveTextContent("Not enough fuel");
expect(within(pair).getByLabelText("Fuel")).toHaveTextContent(
`Fuel needed ${Math.round(legFu)} fu · on board 2 fu`,
);
await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
// A full tank: the credits cover it.
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" });
// The sheet stays open and the pair is now flyable and picked.
await waitFor(() => expect(within(dialog).getByRole("radio")).toBeEnabled());
expect(within(dialog).getByRole("radio")).toBeChecked();
expect(screen.getByRole("dialog")).toBe(dialog);
});
it("buys just the leg's fuel when the credits can't fill the tank", async () => {
const { api, refuelShip } = company(2, Math.round(10 * earthPrice));
renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1", amountFu: legFu - 2 });
});
it("prices the fuel used at the tank's average price, as the sim does", async () => {
renderApp(company(40).api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
expect(pair).toHaveTextContent(
`Fuel used (at your average price)${formatCredits(Math.round(legFu * 15))}`,
);
});
it("highlights the cheapest fuel on the Locations screen", async () => {
const all = samplePlayerState({
tutorial: null,
locations: content.locations.map((l) => ({ id: l.id, discovered: true })),
});
renderApp(fakeApi({ getPlayerState: () => all }), "/locations");
const jupiter = await screen.findByLabelText("Fuel at Jupiter Cloud Platform");
expect(jupiter).toHaveTextContent("6 cr/fu · cheapest");
expect(screen.getByLabelText("Fuel at Earth")).toHaveTextContent("16 cr/fu");
expect(screen.getByLabelText("Fuel at Earth")).not.toHaveTextContent("cheapest");
});
it("shows emergency fuel only when the sim offers it", async () => {
// Stuck at the Moon with 1 fu and no credits: the only way out is Earth.
const stuck = samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 0, loanBalance: 0 },
ships: [{ ...ship(1), locationId: "moon" }],
pilots: [avatar],
});
const offer = emergencyFuel(
{
ship: simShip(stuck.ships[0]!),
credits: 0,
discoveredIds: new Set(["earth", "moon"]),
},
content,
)!;
const { unmount } = renderApp(fakeApi({ getPlayerState: () => stuck }), "/fleet");
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" }));
expect(
screen.getByRole("button", { name: /Emergency fuel \(added to loan\)/ }),
).toHaveTextContent(formatCredits(offer.cost));
unmount();
const fine = samplePlayerState({ tutorial: null, ships: [ship(40)], pilots: [avatar] });
renderApp(fakeApi({ getPlayerState: () => fine }), "/fleet");
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" }));
expect(screen.queryByRole("button", { name: /Emergency fuel/ })).toBeNull();
});
});

View File

@ -127,6 +127,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). The game runs
| `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera, notifications on/off (CR-014); local storage, every access wrapped | | `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera, notifications on/off (CR-014); local storage, every access wrapped |
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/components/Fuel.tsx` | Fuel UI (CR-015): `FuelGauge`, the `ShipFuel` sheet (refuel, policy, emergency fuel), `FuelPrice` (cheapest marked), `refuelForLeg` ("Refuel here first") |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) | | `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting | | `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |

View File

@ -376,6 +376,6 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as
- [x] **Step 15.4 — Sim: tank, burn, and refuel.** Sim (`fuel.ts`): `Tank` (fuel and weighted average price), `refuel` (up to capacity, new average, whole-credit cost), `affordableRefuel` (the most the credits cover, never over), `legFuelCost` (burned × average price). Ships carry `tank` in `ShipState`; dispatch and deadhead validation fail with `not-enough-fuel` (replacing `insufficient-credits`) when fuel on board is short; proposals no longer take credits. Records gain `fuelBurnedFu`; `fuelCost` is that fuel at the tank's average price (pilot pay uses it, decision 0.5.98); tutorial runs burn none. `tripFuelCost` became `tripFuel` (fu). Resolution takes an optional `fuel` block (tank, capacity, policy, credits) and returns `topUp` (Auto: the most the credits after this delivery cover, at the destination's price; Manual or no tank: none). Pulled forward from 15.6 so the game keeps working: saved ships have `fuelFu`, `fuelAvgPrice`, `refuelPolicy`; save version 2 with the 1 → 2 migration (every ship full at the price where it is, the base price in flight, Auto; old records burned 0 fu, old resolutions no top-up); the engine takes fuel from the tank at departure (no credits) and applies the top-up on landing; Market ships arrive full at the bay's price; the deal's Courier and Clipper come with a quarter tank (`config.deal.tankShare`, user decision: with full tanks the Clipper left for Mars at once and the first purchase moved to 79 min); the API ship carries the fuel fields and summaries carry `topUp`. Web: the sim sees each ship's tank, the dispatch sheet and deadhead picker cost fuel at the average price and say "Not enough fuel (needs N fu, has M fu)". The balance simulator flies tanks too (Auto top-up, refuel before choosing work); results: first purchase 64 min, discovery 49, loan 136, four ships 136 vs 136. Tests: `packages/sim/test/tank.test.ts` (Moon leg = Earth amount at distance ÷ 1.3; 50 fu at 1.0 into 50 fu at 2.0 averages 1.5; never past capacity; affordable refuel exact; record burn and cost; pilot pay from burned × average; Auto top-up to full at the local price; Manual buys nothing; half the credits buys half; the tutorial loaner untouched); `dispatch.test.ts` (`not-enough-fuel`, passes after refuelling); `packages/schema/test/save.test.ts` (1 → 2 migration: tanks full at the right price, Auto, nothing else changed); `packages/engine/test/fuel.test.ts` (Market ship full; deal ships a quarter; burn then Auto top-up with the exact credits; Manual leaves the tank). Updated tests that expected fuel in credits (contract dispatch, deadhead, concurrency, browser 12.4); browser 12.5 fills the Clipper with an Earth–Moon round trip before its Mars run. - [x] **Step 15.4 — Sim: tank, burn, and refuel.** Sim (`fuel.ts`): `Tank` (fuel and weighted average price), `refuel` (up to capacity, new average, whole-credit cost), `affordableRefuel` (the most the credits cover, never over), `legFuelCost` (burned × average price). Ships carry `tank` in `ShipState`; dispatch and deadhead validation fail with `not-enough-fuel` (replacing `insufficient-credits`) when fuel on board is short; proposals no longer take credits. Records gain `fuelBurnedFu`; `fuelCost` is that fuel at the tank's average price (pilot pay uses it, decision 0.5.98); tutorial runs burn none. `tripFuelCost` became `tripFuel` (fu). Resolution takes an optional `fuel` block (tank, capacity, policy, credits) and returns `topUp` (Auto: the most the credits after this delivery cover, at the destination's price; Manual or no tank: none). Pulled forward from 15.6 so the game keeps working: saved ships have `fuelFu`, `fuelAvgPrice`, `refuelPolicy`; save version 2 with the 1 → 2 migration (every ship full at the price where it is, the base price in flight, Auto; old records burned 0 fu, old resolutions no top-up); the engine takes fuel from the tank at departure (no credits) and applies the top-up on landing; Market ships arrive full at the bay's price; the deal's Courier and Clipper come with a quarter tank (`config.deal.tankShare`, user decision: with full tanks the Clipper left for Mars at once and the first purchase moved to 79 min); the API ship carries the fuel fields and summaries carry `topUp`. Web: the sim sees each ship's tank, the dispatch sheet and deadhead picker cost fuel at the average price and say "Not enough fuel (needs N fu, has M fu)". The balance simulator flies tanks too (Auto top-up, refuel before choosing work); results: first purchase 64 min, discovery 49, loan 136, four ships 136 vs 136. Tests: `packages/sim/test/tank.test.ts` (Moon leg = Earth amount at distance ÷ 1.3; 50 fu at 1.0 into 50 fu at 2.0 averages 1.5; never past capacity; affordable refuel exact; record burn and cost; pilot pay from burned × average; Auto top-up to full at the local price; Manual buys nothing; half the credits buys half; the tutorial loaner untouched); `dispatch.test.ts` (`not-enough-fuel`, passes after refuelling); `packages/schema/test/save.test.ts` (1 → 2 migration: tanks full at the right price, Auto, nothing else changed); `packages/engine/test/fuel.test.ts` (Market ship full; deal ships a quarter; burn then Auto top-up with the exact credits; Manual leaves the tank). Updated tests that expected fuel in credits (contract dispatch, deadhead, concurrency, browser 12.4); browser 12.5 fills the Clipper with an Earth–Moon round trip before its Mars run.
- [x] **Step 15.5 — Sim: stranding and emergency fuel.** `wouldStrand(model, destination, fuelOnArrivalFu, discoveredIds, content)` (decision 0.5.79 as amended): from the destination, some other discovered location must be within reach on a full tank where fuel is sold, otherwise on what is left after the leg (`fuelAfterLeg`). Dispatch and deadhead validation pass the fuel left; the dispatch sheet's explanation does too. `emergencyFuel({ ship, credits, discoveredIds })` (decision 0.5.99) returns `{ amountFu, price, cost }` or null: offered only for an idle ship whose fuel can't reach any other discovered location and whose company can't afford the shortfall for the cheapest leg it could fly on a full tank; the amount is that shortfall, the price 2.5 × local. Tests: `no-strand.test.ts` (no base ship is blocked on any leg it can fly between the five locations; with a fixture Mars that sells no fuel, a full-tank Clipper Earth → Mars is refused as `would-strand`, the same leg passes where Mars sells fuel, and fuel to spare after the leg passes); `emergency-fuel.test.ts` (offered when stuck and broke, with the exact amount, price and cost; not when the tank reaches somewhere, not when the credits cover the shortfall (one credit short does offer it), not in flight; only the cheapest leg's fuel when there are several ways out). - [x] **Step 15.5 — Sim: stranding and emergency fuel.** `wouldStrand(model, destination, fuelOnArrivalFu, discoveredIds, content)` (decision 0.5.79 as amended): from the destination, some other discovered location must be within reach on a full tank where fuel is sold, otherwise on what is left after the leg (`fuelAfterLeg`). Dispatch and deadhead validation pass the fuel left; the dispatch sheet's explanation does too. `emergencyFuel({ ship, credits, discoveredIds })` (decision 0.5.99) returns `{ amountFu, price, cost }` or null: offered only for an idle ship whose fuel can't reach any other discovered location and whose company can't afford the shortfall for the cheapest leg it could fly on a full tank; the amount is that shortfall, the price 2.5 × local. Tests: `no-strand.test.ts` (no base ship is blocked on any leg it can fly between the five locations; with a fixture Mars that sells no fuel, a full-tank Clipper Earth → Mars is refused as `would-strand`, the same leg passes where Mars sells fuel, and fuel to spare after the leg passes); `emergency-fuel.test.ts` (offered when stuck and broke, with the exact amount, price and cost; not when the tank reaches somewhere, not when the credits cover the shortfall (one credit short does offer it), not in flight; only the cheapest leg's fuel when there are several ways out).
- [x] **Step 15.6 — Engine: commands and save migration.** (The save fields, the 1 → 2 migration and full tanks on new ships came in 15.4.) New commands in the route catalog: `refuelShip` (`{ shipId, amountFu? }`: fill, or buy an amount, at the local price; the ship must be idle at a location that sells fuel; refused with `insufficient-funds` unless the player can pay for all of it, `tank-full`, `no-fuel-here`, `ship-not-idle-here`), `setRefuelPolicy` (`{ shipId, policy }`), `orderEmergencyFuel` (`{ shipId }`: only when the sim offers it, else `emergency-fuel-unavailable`; adds the fuel at 2.5 × the local price into the tank's average and the cost to the loan). Tests (`packages/engine/test/fuel.test.ts`): an older (version-1) save imported through the engine has every ship full on Auto; refuel of an amount charges the local price and keeps the weighted average, fill tops up to capacity, a full tank is refused; refuel with no credits fails and changes nothing; the policy toggles; emergency fuel is refused when not needed, and when needed adds exactly the shortfall and raises the loan by exactly its cost, credits untouched. A new ship arriving full is tested since 15.4. - [x] **Step 15.6 — Engine: commands and save migration.** (The save fields, the 1 → 2 migration and full tanks on new ships came in 15.4.) New commands in the route catalog: `refuelShip` (`{ shipId, amountFu? }`: fill, or buy an amount, at the local price; the ship must be idle at a location that sells fuel; refused with `insufficient-funds` unless the player can pay for all of it, `tank-full`, `no-fuel-here`, `ship-not-idle-here`), `setRefuelPolicy` (`{ shipId, policy }`), `orderEmergencyFuel` (`{ shipId }`: only when the sim offers it, else `emergency-fuel-unavailable`; adds the fuel at 2.5 × the local price into the tank's average and the cost to the loan). Tests (`packages/engine/test/fuel.test.ts`): an older (version-1) save imported through the engine has every ship full on Auto; refuel of an amount charges the local price and keeps the weighted average, fill tops up to capacity, a full tank is refused; refuel with no credits fails and changes nothing; the policy toggles; emergency fuel is refused when not needed, and when needed adds exactly the shortfall and raises the loan by exactly its cost, credits untouched. A new ship arriving full is tested since 15.4.
- [ ] Step 15.7 — Client: fuel in the UI - [x] **Step 15.7 — Client: fuel in the UI.** `src/components/Fuel.tsx`: `FuelGauge` (a meter from empty to capacity, "N fu · P%", the refuel policy), `ShipFuel` sheet (price here; Fill with its cost; buy an amount; Auto top-up / Manual; "Emergency fuel (added to loan)" with its amount and cost, shown only when the sim offers it), `FuelPrice` (a location's price, the cheapest marked "cheapest" in green), `refuelForLeg` ("Refuel here first" buys a full tank when the credits cover it, otherwise just the leg's shortfall). Fleet: every ship card shows the gauge and a Fuel button beside Upgrades. Dispatch sheet: every pair shows "Fuel needed · on board · left after"; the cost cell is "Fuel used (at your average price)"; a pair blocked only by fuel (the sim's last check) gets "Refuel here first", which refuels, keeps the sheet open and picks the pair once flyable. Deadhead picker: fuel on board, per destination "Needs N fu · left after M fu", and "Refuel here first" when any destination is short only of fuel. Locations screen and map panel: a Fuel line per location. Delivery summaries: "Topped up N fu at X, C cr" for Auto top-ups; deadheads say "fuel used". Tests (`apps/web/test/fuel-ui.test.tsx`): the gauge's values and text match the ship; "Refuel here first" fills the tank and enables and picks the pair with the sheet still open; it buys just the shortfall when a fill is unaffordable; the fuel-used cell matches the sim at the tank's average price; Jupiter's fuel is marked cheapest and Earth's isn't; emergency fuel shows (with the sim's cost) only for a stranded, broke ship.
- [ ] Step 15.8 — Client: fixed map - [ ] Step 15.8 — Client: fixed map
- [ ] Step 15.9 — Plan amendments and regression - [ ] Step 15.9 — Plan amendments and regression