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(
{
`Fuel needed ${Math.round(legFu)} fu · on board 2 fu`,
);
- await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
+ // The button says what it buys and what it costs: here a full tank.
+ const fill = refuel(
+ { fuelFu: 2, avgPrice: 16 },
+ courier.tankCapacityFu,
+ courier.tankCapacityFu - 2,
+ earthPrice,
+ );
+ const button = within(pair).getByRole("button", { name: /^Refuel here first/ });
+ expect(button).toHaveTextContent(
+ `Refuel here first · ${formatFu(courier.tankCapacityFu - 2)} for ${formatCredits(fill.cost)}`,
+ );
+ await userEvent.click(button);
// A full tank: the credits cover it.
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" });
// The sheet stays open and the pair is now flyable and picked.
@@ -142,10 +154,31 @@ describe("fuel in the client (Step 15.7)", () => {
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" }));
+ const leg = refuel({ fuelFu: 2, avgPrice: 16 }, courier.tankCapacityFu, legFu - 2, earthPrice);
+ const button = within(pair).getByRole("button", { name: /^Refuel here first/ });
+ expect(button).toHaveTextContent(`· ${formatFu(legFu - 2)} for ${formatCredits(leg.cost)}`);
+ await userEvent.click(button);
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1", amountFu: legFu - 2 });
});
+ it("disables Refuel here first, saying why, when the credits can't cover the leg's fuel", async () => {
+ const { api } = company(2, 5);
+ renderApp(api, "/contracts");
+ await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
+ const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
+ expect(within(pair).getByRole("button", { name: /^Refuel here first/ })).toBeDisabled();
+ expect(pair).toHaveTextContent("Not enough credits for the fuel this trip needs.");
+ });
+
+ it("opens sheets below the top bar, so the credits stay in view", async () => {
+ renderApp(company(2).api, "/contracts");
+ await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
+ const backdrop = screen.getByRole("dialog").parentElement!;
+ expect(backdrop.className).toContain("top-14");
+ expect(backdrop.className).not.toContain("inset-0");
+ expect(screen.getByText(formatCredits(10_000))).toBeInTheDocument();
+ });
+
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" }));
diff --git a/docs/screenshots/fix-refuel-cost-desktop.png b/docs/screenshots/fix-refuel-cost-desktop.png
new file mode 100644
index 0000000..684a085
Binary files /dev/null and b/docs/screenshots/fix-refuel-cost-desktop.png differ
diff --git a/docs/screenshots/fix-refuel-cost-phone.png b/docs/screenshots/fix-refuel-cost-phone.png
new file mode 100644
index 0000000..99334de
Binary files /dev/null and b/docs/screenshots/fix-refuel-cost-phone.png differ
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index b2bbfab..a42e1d3 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -563,3 +563,4 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
## Fixes after Phase 26
- [x] **Job length on the board.** Location board cards show how long the job takes in game hours beside the weight and distance ("Atlas Heavy Industries · 6 t · 0.38M km · 2.2 h"), for the fastest of the player's crewed ships that could take it, preferring one at the job's origin (`jobGameHours` in `apps/web/src/game.ts`); none is shown if no ship could. The tutorial board already showed its trip time. Tests: `apps/web/test/job-hours.test.tsx`. Screenshots: `docs/screenshots/fix-job-hours-{phone,desktop}.png`.
+- [x] **Refuel cost, and credits always in view.** "Refuel here first" in the dispatch sheet says what it buys and costs ("Refuel here first · 36 fu for 198 cr", from `refuelQuote` in `apps/web/src/components/Fuel.tsx`, priced with the sim's `refuel` as the engine charges), and is disabled with "Not enough credits for the fuel this trip needs." when the credits can't cover the leg. Sheets now open below the top bar (`top-14` in `apps/web/src/ui/Sheet.tsx`), so the credits stay visible, and update, while one is open. Tests: `apps/web/test/fuel-ui.test.tsx`. Screenshots: `docs/screenshots/fix-refuel-cost-{phone,desktop}.png`.