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 && ( + + )} - {reposition.isError && ( + {(reposition.isError || refuel.isError) && (

- {errorMessage(reposition.error)} + {errorMessage(reposition.error ?? refuel.error)}

)}