diff --git a/DECISIONS.md b/DECISIONS.md
index 1c9b339..b5d3387 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -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.
- **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`.
+
+## 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.
diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx
index 37f3ef9..b155d94 100644
--- a/apps/web/src/components/AwaySummary.tsx
+++ b/apps/web/src/components/AwaySummary.tsx
@@ -1,4 +1,5 @@
import { content } from "@deadhead/content";
+import { formatFu } from "./Fuel";
import { useState } from "react";
import { useNavigate } from "react-router";
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.
- Deadhead · {formatCredits(s.fuelCost)} fuel
+ Deadhead · {formatCredits(s.fuelCost)} fuel used
) : (
@@ -81,6 +82,12 @@ export function AwaySummary() {
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "the pilot"}
)}
+ {s.topUp && (
+
+ Topped up {formatFu(s.topUp.boughtFu)} at {locationName(s.topUp.locationId)},{" "}
+ {formatCredits(s.topUp.cost)}
+
+ )}
{s.loanRepayment > 0 && (
{formatCredits(s.loanRepayment)} went to the lender
diff --git a/apps/web/src/components/Fuel.tsx b/apps/web/src/components/Fuel.tsx
new file mode 100644
index 0000000..5b8c3a4
--- /dev/null
+++ b/apps/web/src/components/Fuel.tsx
@@ -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
{
+ 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 None sold ;
+ const cheapest = cheapestFuelIds().has(locationId);
+ return (
+
+ {formatFuelPrice(fuelPriceOf(locationId))}
+ {cheapest && " · cheapest"}
+
+ );
+}
+
+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 (
+
+
+
+
+ {formatFu(ship.fuelFu)} · {percent}%
+
+ {POLICY_LABELS[ship.refuelPolicy]}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+ Average price paid: {formatFuelPrice(Math.round(ship.fuelAvgPrice * 10) / 10)}
+
+
+
+ {!here ? (
+ Refuel when the ship has landed.
+ ) : !here.sellsFuel ? (
+ {here.name} sells no fuel.
+ ) : (
+
+
+ Fuel at {here.name}: {formatFuelPrice(price)}
+
+ refuel.mutate({ shipId: ship.id })}
+ >
+ {space <= 0 ? "Tank full" : `Fill (${formatFu(space)}, ${formatCredits(fillCost)})`}
+
+
+
+ )}
+
+
+ Refuel policy
+
+ {(["auto", "manual"] as const).map((value) => (
+
+ policy.mutate({ shipId: ship.id, policy: value })}
+ className="sr-only"
+ />
+ {POLICY_LABELS[value]}
+
+ ))}
+
+
+ {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."}
+
+
+
+ {offer && (
+
+
+ {ship.name} can't reach anywhere from {locationName(ship.locationId ?? "")} and you
+ can't afford the fuel.
+
+
emergency.mutate({ shipId: ship.id })}
+ >
+ Emergency fuel (added to loan): {formatFu(offer.amountFu)}, {formatCredits(offer.cost)}
+
+
+ )}
+
+ {error && (
+
+ {errorMessage(error)}
+
+ )}
+
+ Done
+
+
+ );
+}
diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx
index 2916da6..a33fca7 100644
--- a/apps/web/src/screens/DispatchSheet.tsx
+++ b/apps/web/src/screens/DispatchSheet.tsx
@@ -17,6 +17,7 @@ import {
type DispatchFailure,
} from "@deadhead/sim";
import { useState } from "react";
+import { formatFu, refuelForLeg } from "../components/Fuel";
import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock";
import {
@@ -168,7 +169,12 @@ export interface PairOption {
/** Why this pair cannot take the contract; empty when it can. */
problems: string[];
tripMs: number;
+ /** The fuel used, at the tank's average price (decision 0.5.98). */
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). */
pilotPay: 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;
let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]];
+ let shortOfFuel = false;
if (pilot) {
const check = validateDispatch(
{
@@ -237,6 +244,8 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
if (check.ok) {
problems = [];
} else {
+ // Fuel is checked last, so this reason means nothing else is wrong.
+ shortOfFuel = check.reason === "not-enough-fuel";
const all = explainProblems(
state,
contract,
@@ -264,6 +273,8 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
problems,
tripMs,
fuelCost,
+ fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu },
+ shortOfFuel,
pilotPay,
netProfit: contract.payout - fuelCost - pilotPay,
arrivalAt: now + tripMs,
@@ -285,6 +296,7 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
const prefsCountdown = usePreferences((p) => p.countdown);
const now = useClock((s) => s.now);
const dispatch = useGameAction("createDispatch");
+ const refuel = useGameAction("refuelShip");
const options = pairOptions(state, contract, now);
const [chosen, setChosen] = useState(
options.find((o) => o.problems.length === 0)?.shipId ?? null,
@@ -381,7 +393,10 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
) : (
|
- |
+ |
{option.pilotId !== null &&
!state.pilots.find((p) => p.id === option.pilotId)?.isAvatar && (
|
@@ -403,6 +418,30 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)}
)}
+
+ Fuel needed {formatFu(option.fuel.neededFu)} · on board{" "}
+ {formatFu(option.fuel.onBoardFu)} · left after{" "}
+ {formatFu(Math.max(0, option.fuel.leftFu))}
+
+ {option.shortOfFuel && (
+ {
+ // 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
+
+ )}
{!blocked && option.priority && (
Priority: {formatGameHoursSpan(option.priority.tripHours, prefsCountdown)}{" "}
@@ -425,9 +464,9 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
)}
- {dispatch.isError && (
+ {(dispatch.isError || refuel.isError) && (
- {errorMessage(dispatch.error)}
+ {errorMessage(dispatch.error ?? refuel.error)}
)}
diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx
index 2a68733..078a2f6 100644
--- a/apps/web/src/screens/Fleet.tsx
+++ b/apps/web/src/screens/Fleet.tsx
@@ -10,6 +10,7 @@ import { useState } from "react";
import { Link } from "react-router";
import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown";
+import { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel";
import { ShipUpgrades } from "./ShipUpgrades";
import { strandedText } from "./DispatchSheet";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
@@ -33,6 +34,7 @@ export function Fleet() {
const state = useLoadedPlayer();
const [deadheading, setDeadheading] = useState(null);
const [upgrading, setUpgrading] = useState(null);
+ const [fueling, setFueling] = useState(null);
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}
onDeadhead={() => setDeadheading(ship)}
onUpgrades={() => setUpgrading(ship.id)}
+ onFuel={() => setFueling(ship.id)}
/>
))}
)}
{deadheading && setDeadheading(null)} />}
{upgrading && setUpgrading(null)} />}
+ {fueling && setFueling(null)} />}
);
}
@@ -84,6 +88,7 @@ function ShipRow(props: {
pilotName: string | null;
onDeadhead: () => void;
onUpgrades: () => void;
+ onFuel: () => void;
}) {
const { ship, dispatch, pilotName } = props;
const now = useClock((s) => s.now);
@@ -142,6 +147,7 @@ function ShipRow(props: {
)}
+
{inShop && (
In the shop —
@@ -169,16 +175,28 @@ function ShipRow(props: {
)}
-
- Upgrades
- {Object.keys(ship.upgrades).length > 0 &&
- ` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`}
-
+
+
+ Fuel
+
+
+ Upgrades
+ {Object.keys(ship.upgrades).length > 0 &&
+ ` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`}
+
+
);
}
@@ -203,6 +221,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
const state = useLoadedPlayer();
const now = useClock((s) => s.now);
const reposition = useGameAction("createDispatch");
+ const refuel = useGameAction("refuelShip");
// Effective stats: upgraded fuel tanks reach farther (CR-009).
const model = shipStatsOf(ship);
const origin = ship.locationId!;
@@ -230,21 +249,25 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
},
content,
);
+ const neededFu = tripFuel(model, location(origin), location(id), content.config);
return {
id,
+ neededFu,
+ shortOfFuel: !check.ok && check.reason === "not-enough-fuel",
problem: check.ok
? null
: check.reason === "would-strand"
? strandedText(id)
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
// The fuel used, at the tank's average price (decision 0.5.98).
- fuelCost: Math.round(
- tripFuel(model, location(origin), location(id), content.config) * ship.fuelAvgPrice,
- ),
+ fuelCost: Math.round(neededFu * ship.fuelAvgPrice),
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 (
v
title={`Deadhead ${ship.name}`}
subtitle={`Fly empty from ${locationName(origin)} to another place you know. You pay fuel only.`}
>
+
+ On board: {formatFu(ship.fuelFu)} of {formatFu(ship.stats.tankCapacityFu)}
+
+ {shortLegFu > 0 && (
+ refuel.mutate(refuelForLeg(ship, shortLegFu, state.company.credits))}
+ >
+ Refuel here first
+
+ )}
{options.map((option) => {
const sprite = content.location(option.id)?.sprite;
@@ -281,9 +318,13 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
{locationName(option.id)}
- {formatCredits(option.fuelCost)} fuel · {formatDuration(option.tripMs)}
+ {formatCredits(option.fuelCost)} fuel used · {formatDuration(option.tripMs)}
+
+ Needs {formatFu(option.neededFu)} · left after{" "}
+ {formatFu(Math.max(0, ship.fuelFu - option.neededFu))}
+
{option.problem && (
{option.problem}
)}
@@ -293,9 +334,9 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
);
})}
- {reposition.isError && (
+ {(reposition.isError || refuel.isError) && (
- {errorMessage(reposition.error)}
+ {errorMessage(reposition.error ?? refuel.error)}
)}
diff --git a/apps/web/src/screens/Locations.tsx b/apps/web/src/screens/Locations.tsx
index f716f09..9269f23 100644
--- a/apps/web/src/screens/Locations.tsx
+++ b/apps/web/src/screens/Locations.tsx
@@ -1,4 +1,5 @@
import { content, type Location } from "@deadhead/content";
+import { FuelPrice } from "../components/Fuel";
import { Link } from "react-router";
import { cargoName, formatCredits, formatMillionKm } from "../format";
import { distanceAt } from "../game";
@@ -106,6 +107,12 @@ export function Locations() {
Demands
{location.cargoClassMix.demands.map(cargoName).join(", ") || "—"}
+
+ Fuel
+
+
+
+
+
+ Fuel
+
{hasBay ? "Your HQ is here." : "No HQ here."}
diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx
index 68a7596..90002ea 100644
--- a/apps/web/test/dispatch-sheet.test.tsx
+++ b/apps/web/test/dispatch-sheet.test.tsx
@@ -126,7 +126,7 @@ describe("dispatch sheet", () => {
expect(within(freighter).getByRole("radio")).toBeEnabled();
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(
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`,
diff --git a/apps/web/test/fuel-ui.test.tsx b/apps/web/test/fuel-ui.test.tsx
new file mode 100644
index 0000000..e2d6dc8
--- /dev/null
+++ b/apps/web/test/fuel-ui.test.tsx
@@ -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();
+ });
+});
diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md
index ef68280..812f9fb 100644
--- a/memory-bank/architecture.md
+++ b/memory-bank/architecture.md
@@ -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/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `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/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 |
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index e99ee67..8b409e2 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -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.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.
-- [ ] 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.9 — Plan amendments and regression