Show the refuel cost in the dispatch sheet; keep credits in view
CI / check (push) Successful in 8m44s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-17 21:44:47 -04:00
parent 521c2b7655
commit dbae91c2cb
8 changed files with 117 additions and 25 deletions

View File

@ -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. - **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. - **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. - **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\`.

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema"; 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 { useState } from "react";
import { formatCredits, locationName } from "../format"; import { formatCredits, locationName } from "../format";
import { simShip } from "../game"; 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 * What "Refuel here first" buys (Step 15.7): a full tank when the credits
* cover it, otherwise just enough for the leg. * cover it, otherwise just enough for the leg.

View File

@ -18,7 +18,7 @@ import {
} from "@deadhead/sim"; } from "@deadhead/sim";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useApi } from "../api/context"; import { useApi } from "../api/context";
import { formatFu, refuelForLeg } from "../components/Fuel"; import { formatFu, refuelQuote } from "../components/Fuel";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { formatGameTime } from "../clock/GameClock"; import { formatGameTime } from "../clock/GameClock";
import { 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 (
<div className="mt-2">
<Button
variant="secondary"
size="sm"
disabled={props.pending || !quote.affordable}
onClick={(e) => {
// Inside the pair's label: don't let the click pick the radio.
e.preventDefault();
props.onRefuel(quote.request);
}}
>
Refuel here first · {formatFu(quote.boughtFu)} for {formatCredits(quote.cost)}
</Button>
{!quote.affordable && (
<p className="mt-1 text-rose-300">Not enough credits for the fuel this trip needs.</p>
)}
</div>
);
}
/** Choose a ship and pilot for a contract, see the numbers, and confirm (Step 9.5). */ /** 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 }) { export function DispatchSheet({ contract, onClose }: { contract: Contract; onClose: () => void }) {
const api = useApi(); const api = useApi();
@ -442,23 +476,14 @@ export function DispatchSheet({ contract, onClose }: { contract: Contract; onClo
{formatFu(Math.max(0, option.fuel.leftFu))} {formatFu(Math.max(0, option.fuel.leftFu))}
</p> </p>
{option.shortOfFuel && ( {option.shortOfFuel && (
<Button <RefuelFirst
variant="secondary" shipId={option.shipId}
size="sm" neededFu={option.fuel.neededFu}
className="mt-2" pending={refuel.isPending}
disabled={refuel.isPending} onRefuel={(request) =>
onClick={(e) => { refuel.mutate(request, { onSuccess: () => setChosen(option.shipId) })
// 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
</Button>
)} )}
{!blocked && option.priority && ( {!blocked && option.priority && (
<p aria-label="Priority margin" className="mt-2 text-amber-200"> <p aria-label="Priority margin" className="mt-2 text-amber-200">

View File

@ -17,7 +17,8 @@ const BORDER_TONES: Partial<Record<Tone, string>> = {
* A modal dialog: a bottom sheet on phones, a centred card from `sm` up. * 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 * Labelled by its title, which it renders as an `h2` with `titleId`. Portalled
* to `body`: a transformed ancestor (the screens' rise-in) would otherwise * 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({ export function Sheet({
titleId, titleId,
@ -39,13 +40,13 @@ export function Sheet({
}) { }) {
return createPortal( return createPortal(
<div <div
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`} className={`fixed inset-x-0 top-14 bottom-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
> >
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
className={`relative max-h-[88dvh] w-full max-w-md animate-sheet overflow-y-auto rounded-t-2xl border bg-space-900/95 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl ${BORDER_TONES[tone] ?? "border-space-600"}`} className={`relative max-h-full w-full max-w-md animate-sheet overflow-y-auto rounded-t-2xl border bg-space-900/95 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl ${BORDER_TONES[tone] ?? "border-space-600"}`}
> >
<div <div
aria-hidden="true" aria-hidden="true"

View File

@ -1,7 +1,8 @@
import { pilotExtras } from "./fixtures"; import { pilotExtras } from "./fixtures";
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema"; import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { emergencyFuel, fuelPriceAt, tripFuel } from "@deadhead/sim"; import { emergencyFuel, fuelPriceAt, refuel, tripFuel } from "@deadhead/sim";
import { formatFu } from "../src/components/Fuel";
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -128,7 +129,18 @@ describe("fuel in the client (Step 15.7)", () => {
`Fuel needed ${Math.round(legFu)} fu · on board 2 fu`, `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. // A full tank: the credits cover it.
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" }); expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" });
// The sheet stays open and the pair is now flyable and picked. // 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"); renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!; 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 }); 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 () => { it("prices the fuel used at the tank's average price, as the sim does", async () => {
renderApp(company(40).api, "/contracts"); renderApp(company(40).api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@ -563,3 +563,4 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C
## Fixes after Phase 26 ## 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] **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`.