From 7a061d0e4011d276e50f6674e3f9866962989c58 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 10 Oct 2026 01:50:33 -0400 Subject: [PATCH] Step 15.7: client: fuel in the UI 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 --- DECISIONS.md | 7 + apps/web/src/components/AwaySummary.tsx | 9 +- apps/web/src/components/Fuel.tsx | 231 ++++++++++++++++++++++++ apps/web/src/screens/DispatchSheet.tsx | 45 ++++- apps/web/src/screens/Fleet.tsx | 73 ++++++-- apps/web/src/screens/Locations.tsx | 7 + apps/web/src/screens/MapScreen.tsx | 4 + apps/web/test/dispatch-sheet.test.tsx | 2 +- apps/web/test/fuel-ui.test.tsx | 196 ++++++++++++++++++++ memory-bank/architecture.md | 1 + memory-bank/progress.md | 2 +- 11 files changed, 555 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/Fuel.tsx create mode 100644 apps/web/test/fuel-ui.test.tsx 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)} +

+ +
{ + e.preventDefault(); + if (amountFu > 0) refuel.mutate({ shipId: ship.id, amountFu }); + }} + > + + +
+
+ )} + +
+ Refuel policy +
+ {(["auto", "manual"] as const).map((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. +

+ +
+ )} + + {error && ( +

+ {errorMessage(error)} +

+ )} + +
+ ); +} 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 && ( + + )} {!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: { )} - +

+ + +
); } @@ -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 && ( + + )}
    {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)}

)}