diff --git a/DECISIONS.md b/DECISIONS.md index 4996260..c5c2812 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1984,3 +1984,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **A job's length depends on the ship**, so the board shows it for the player's fastest crewed ship that could take the job (the permanent checks: hold, range, landing, skills), preferring ships already at the job's origin, since those are the ones that would fly it. Its label says it's game time, and a tooltip says which ship it's for. - **It's the whole trip in game hours** (travel plus loading and takeoff/landing), the figure the Priority window and duty use. - **Long catch-up and simulation tests get 60 s** (`vi.setConfig` in `balance.test.ts`, `catch-up.test.ts`, `fleet-sleep.test.ts`): with events, a week's replay sometimes passed the 5 s default under coverage on CI. + +## Fix: refuel cost and credits in view + +- **The refuel button is priced with the sim's own \`refuel\`**, for exactly what the button will buy: a full tank when the credits cover it, else the leg's fuel (Step 15.7's rule). When even the leg's fuel is too dear, the button is disabled and says why, instead of failing on tap. +- **Every sheet opens below the top bar**, rather than drawing a backdrop over it, so the credits and the clock stay in view and change as the player spends. The sheet's backdrop covers the rest, the bottom navigation included. The bar's height (3.5rem) is written in the sheet, since sheets render outside the layout that sets \`--header-h\`. diff --git a/apps/web/src/components/Fuel.tsx b/apps/web/src/components/Fuel.tsx index 8211232..6fa53cb 100644 --- a/apps/web/src/components/Fuel.tsx +++ b/apps/web/src/components/Fuel.tsx @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { PlayerState, Ship } from "@deadhead/schema"; -import { emergencyFuel, fuelPriceAt } from "@deadhead/sim"; +import { emergencyFuel, fuelPriceAt, refuel } from "@deadhead/sim"; import { useState } from "react"; import { formatCredits, locationName } from "../format"; import { simShip } from "../game"; @@ -67,6 +67,33 @@ export function FuelGauge({ ship }: { ship: Ship }) { ); } +/** + * "Refuel here first", priced: how much fuel it buys and what it costs, with + * the same sim rules the engine charges by; `affordable` is false when the + * credits don't cover even what the leg needs. + */ +export function refuelQuote( + ship: Ship, + neededFu: number, + credits: number, +): { + request: { shipId: string; amountFu?: number }; + boughtFu: number; + cost: number; + affordable: boolean; +} { + const request = refuelForLeg(ship, neededFu, credits); + const space = ship.stats.tankCapacityFu - ship.fuelFu; + const bought = refuel( + { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, + ship.stats.tankCapacityFu, + request.amountFu ?? space, + fuelPriceOf(ship.locationId ?? ""), + ); + const boughtFu = bought.tank.fuelFu - ship.fuelFu; + return { request, boughtFu, cost: bought.cost, affordable: bought.cost <= credits }; +} + /** * What "Refuel here first" buys (Step 15.7): a full tank when the credits * cover it, otherwise just enough for the leg. diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index d8f3622..d650fe1 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -18,7 +18,7 @@ import { } from "@deadhead/sim"; import { useEffect, useState } from "react"; import { useApi } from "../api/context"; -import { formatFu, refuelForLeg } from "../components/Fuel"; +import { formatFu, refuelQuote } from "../components/Fuel"; import { useClock } from "../clock/clock"; import { formatGameTime } from "../clock/GameClock"; import { @@ -302,6 +302,40 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) }); } +/** + * "Refuel here first" with its price (Step 15.7): the fuel it buys and what + * it costs, disabled when the credits don't cover the leg's fuel. + */ +function RefuelFirst(props: { + shipId: string; + neededFu: number; + pending: boolean; + onRefuel: (request: { shipId: string; amountFu?: number }) => void; +}) { + const state = useLoadedPlayer(); + const ship = state.ships.find((s) => s.id === props.shipId)!; + const quote = refuelQuote(ship, props.neededFu, state.company.credits); + return ( +
+ + {!quote.affordable && ( +

Not enough credits for the fuel this trip needs.

+ )} +
+ ); +} + /** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) { const api = useApi(); @@ -442,23 +476,14 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo {formatFu(Math.max(0, option.fuel.leftFu))}

{option.shortOfFuel && ( - + + refuel.mutate(request, { onSuccess: () => setChosen(option.shipId) }) + } + /> )} {!blocked && option.priority && (

diff --git a/apps/web/src/ui/Sheet.tsx b/apps/web/src/ui/Sheet.tsx index d26ee27..5b4282f 100644 --- a/apps/web/src/ui/Sheet.tsx +++ b/apps/web/src/ui/Sheet.tsx @@ -17,7 +17,8 @@ const BORDER_TONES: Partial> = { * A modal dialog: a bottom sheet on phones, a centred card from `sm` up. * Labelled by its title, which it renders as an `h2` with `titleId`. Portalled * to `body`: a transformed ancestor (the screens' rise-in) would otherwise - * become the containing block of `position: fixed`. + * become the containing block of `position: fixed`. It opens below the top + * bar (3.5rem, `--header-h` in Layout), so the credits there stay in view. */ export function Sheet({ titleId, @@ -39,13 +40,13 @@ export function Sheet({ }) { return createPortal(