Step 18.4: client: low-fuel warning and policy display
CI / check (push) Has been cancelled Details

A Fleet card warns Low fuel when the tank can't fly the ship's longest
leg to a discovered place; the fuel sheet lists Manual first as the
default (decisions 0.5.112, 0.5.113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 19:19:13 -04:00
parent 51e3177689
commit 424f4583fb
9 changed files with 148 additions and 4 deletions

View File

@ -1604,3 +1604,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`config.deal.tankShare` removed** rather than set to 1: the deal's ships are now full like every other new ship, so there's no setting left to tune. - **`config.deal.tankShare` removed** rather than set to 1: the deal's ships are now full like every other new ship, so there's no setting left to tune.
- **Save version 3** carries the Manual migration: a version-2 save can't tell a chosen Auto from the default, and CR-018 §3 says nobody chose it. - **Save version 3** carries the Manual migration: a version-2 save can't tell a chosen Auto from the default, and CR-018 §3 says nobody chose it.
- **The simulated player refuels lazily**: before a job it compares the best job on the fuel aboard with the best after topping up (as far as credits go), and buys only if that opens a better job; with no job here it fills up to go looking. The milestones match always filling up (36.7 min first purchase), and no world needs emergency fuel. - **The simulated player refuels lazily**: before a job it compares the best job on the fuel aboard with the best after topping up (as far as credits go), and buys only if that opens a better job; with no job here it fills up to go looking. The milestones match always filling up (36.7 min first purchase), and no world needs emergency fuel.
## Step 18.4
- **"Longest yes leg" is measured to discovered places only**: a warning about somewhere the player can't fly yet would be noise. The ship's effective stats count, so Extended Fuel Tanks raise the bar along with the reach.
- **The warning names the leg** ("Ceres Station needs 928 fu"), so the player knows what the fuel is for; it stays a notice, not a block, and "Refuel here first" in the dispatch sheet is still the main way to refuel.
- **No warning in flight**: the check is about leaving from where the ship is.
- **Manual comes first in the toggle** and its hint begins "The default".

View File

@ -176,7 +176,7 @@ export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => v
<fieldset className="mt-4 text-sm"> <fieldset className="mt-4 text-sm">
<legend className="text-ink-400">Refuel policy</legend> <legend className="text-ink-400">Refuel policy</legend>
<div className="mt-1 flex overflow-hidden rounded-lg border border-space-600"> <div className="mt-1 flex overflow-hidden rounded-lg border border-space-600">
{(["auto", "manual"] as const).map((value) => ( {(["manual", "auto"] as const).map((value) => (
<label <label
key={value} key={value}
className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2" className="flex-1 cursor-pointer px-3 py-1.5 text-center has-checked:bg-amber-500/20 has-checked:text-amber-100 has-focus-visible:ring-2"
@ -196,7 +196,7 @@ export function ShipFuel({ shipId, onClose }: { shipId: string; onClose: () => v
<p className="mt-1 text-xs text-ink-400"> <p className="mt-1 text-xs text-ink-400">
{ship.refuelPolicy === "auto" {ship.refuelPolicy === "auto"
? "Fills the tank on every landing, as far as your credits go." ? "Fills the tank on every landing, as far as your credits go."
: "Never buys fuel on its own: wait for a cheaper place."} : "The default: never buys fuel on its own. Refuel here, or with Refuel here first when a job needs it."}
</p> </p>
</fieldset> </fieldset>

View File

@ -6,6 +6,7 @@ import {
MS_PER_HOUR, MS_PER_HOUR,
routeDistance, routeDistance,
travelTimeGameHours, travelTimeGameHours,
tripFuel,
type EffectiveStats, type EffectiveStats,
type ShipState, type ShipState,
} from "@deadhead/sim"; } from "@deadhead/sim";
@ -63,6 +64,36 @@ export function simShip(ship: Ship): ShipState {
}; };
} }
/** A ship's longest leg from where it is: the destination and the fuel it burns. */
export interface LongestLeg {
destinationId: string;
fuelFu: number;
}
/**
* The longest leg an idle ship can fly from where it is on a full tank, to a
* discovered place (decision 0.5.113); null in flight or with nowhere in range.
*/
export function longestLeg(ship: Ship, discoveredIds: ReadonlySet<string>): LongestLeg | null {
if (!ship.locationId) return null;
const stats = shipStatsOf(ship);
const from = location(ship.locationId);
let longest: LongestLeg | null = null;
for (const to of content.locations) {
if (to.id === from.id || !discoveredIds.has(to.id)) continue;
if (!checkRoute(stats, from, to, content.config).ok) continue;
const fuelFu = tripFuel(stats, from, to, content.config);
if (!longest || fuelFu > longest.fuelFu) longest = { destinationId: to.id, fuelFu };
}
return longest;
}
/** "Low fuel" (decision 0.5.113): the tank can't fly the ship's longest leg from here. */
export function lowFuel(ship: Ship, discoveredIds: ReadonlySet<string>): LongestLeg | null {
const leg = longestLeg(ship, discoveredIds);
return leg && ship.fuelFu < leg.fuelFu ? leg : null;
}
const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`; const legKey = (fromId: string, toId: string) => `${fromId}>${toId}`;
const legCache = new Map<string, Set<string>>(); const legCache = new Map<string, Set<string>>();

View File

@ -14,7 +14,7 @@ import { formatFu, FuelGauge, refuelForLeg, ShipFuel } from "../components/Fuel"
import { ShipUpgrades } from "./ShipUpgrades"; import { ShipUpgrades } from "./ShipUpgrades";
import { strandedText } from "./DispatchSheet"; import { strandedText } from "./DispatchSheet";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game"; import { location, lowFuel, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { import {
Art, Art,
@ -37,6 +37,7 @@ export function Fleet() {
const [fueling, setFueling] = useState<string | null>(null); const [fueling, setFueling] = useState<string | null>(null);
const flying = state.ships.filter((s) => s.status === "in-flight").length; const flying = state.ships.filter((s) => s.status === "in-flight").length;
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
return ( return (
<Screen className="max-w-4xl"> <Screen className="max-w-4xl">
@ -68,6 +69,7 @@ export function Fleet() {
ship={ship} ship={ship}
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)} dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null} pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
discoveredIds={discoveredIds}
onDeadhead={() => setDeadheading(ship)} onDeadhead={() => setDeadheading(ship)}
onUpgrades={() => setUpgrading(ship.id)} onUpgrades={() => setUpgrading(ship.id)}
onFuel={() => setFueling(ship.id)} onFuel={() => setFueling(ship.id)}
@ -86,6 +88,7 @@ function ShipRow(props: {
ship: Ship; ship: Ship;
dispatch: Dispatch | undefined; dispatch: Dispatch | undefined;
pilotName: string | null; pilotName: string | null;
discoveredIds: ReadonlySet<string>;
onDeadhead: () => void; onDeadhead: () => void;
onUpgrades: () => void; onUpgrades: () => void;
onFuel: () => void; onFuel: () => void;
@ -96,6 +99,8 @@ function ShipRow(props: {
const model = shipModel(ship.modelId); const model = shipModel(ship.modelId);
// In the upgrade shop until then (CR-009); the clock decides, so it ends on time. // In the upgrade shop until then (CR-009); the clock decides, so it ends on time.
const inShop = ship.inShopUntil !== null && now < ship.inShopUntil; const inShop = ship.inShopUntil !== null && now < ship.inShopUntil;
// Ships no longer refuel on their own (CR-018): say when the tank is short.
const low = dispatch ? null : lowFuel(ship, props.discoveredIds);
return ( return (
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3"> <Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
@ -148,6 +153,18 @@ function ShipRow(props: {
</div> </div>
)} )}
<FuelGauge ship={ship} /> <FuelGauge ship={ship} />
{low && (
<p
role="status"
aria-label={`${ship.name} low fuel`}
className="flex items-center gap-2 rounded-lg border border-rose-400/40 bg-rose-950/30 px-3 py-2 text-sm text-rose-200"
>
<span className="font-display font-semibold tracking-wide uppercase">Low fuel</span>
<span className="text-rose-200/80">
{locationName(low.destinationId)} needs {formatFu(low.fuelFu)}
</span>
</p>
)}
{inShop && ( {inShop && (
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200"> <p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" /> In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" />

View File

@ -0,0 +1,89 @@
import { content } from "@deadhead/content";
import type { PlayerState, Ship } from "@deadhead/schema";
import { tripFuel } from "@deadhead/sim";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const [earth, mars] = [content.location("earth")!, content.location("mars")!];
const clipper = content.shipModel("clipper")!;
const marsLegFu = tripFuel(clipper, earth, mars, content.config);
const ship = (fuelFu: number): Ship => ({
id: "s1",
name: "Swift Heron",
modelId: "clipper",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("clipper"),
fuelFu,
fuelAvgPrice: 8,
refuelPolicy: "manual",
pilotId: null,
});
/** A Clipper at Earth with Mars discovered, `fuelFu` aboard; refuels fill the tank. */
function company(fuelFu: number, discovered = ["earth", "moon", "mars"]) {
let state: PlayerState = samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 100_000, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [ship(fuelFu)],
locations: content.locations.map((l) => ({ id: l.id, discovered: discovered.includes(l.id) })),
});
const refuelShip = vi.fn(() => {
const refuelled = { ...state.ships[0]!, fuelFu: clipper.tankCapacityFu };
state = { ...state, ships: [refuelled] };
return { ship: refuelled };
});
return fakeApi({ getPlayerState: () => state, refuelShip });
}
describe("low-fuel warning (Step 18.4, decision 0.5.113)", () => {
it("warns on a Clipper at Earth that can't fly to Mars, and clears once refuelled", async () => {
renderApp(company(marsLegFu - 1), "/fleet");
const warning = await screen.findByLabelText("Swift Heron low fuel");
expect(warning).toHaveTextContent("Low fuel");
// Earth's longest leg for a Clipper among the places known here is Mars.
expect(warning).toHaveTextContent(
`Mars needs ${Math.round(marsLegFu).toLocaleString("en-US")} fu`,
);
await userEvent.click(screen.getByRole("button", { name: "Fuel for Swift Heron" }));
const sheet = screen.getByRole("dialog", { name: "Swift Heron" });
await userEvent.click(within(sheet).getByRole("button", { name: /^Fill/ }));
await waitFor(() =>
expect(screen.queryByLabelText("Swift Heron low fuel")).not.toBeInTheDocument(),
);
});
it("doesn't warn with enough for the longest leg", async () => {
renderApp(company(marsLegFu + 1), "/fleet");
await screen.findByRole("progressbar", { name: "Swift Heron fuel" });
expect(screen.queryByLabelText("Swift Heron low fuel")).not.toBeInTheDocument();
});
it("measures only legs to discovered places", async () => {
// Mars undiscovered: the Moon is the longest leg, and a little fuel covers it.
renderApp(company(marsLegFu - 1, ["earth", "moon"]), "/fleet");
await screen.findByRole("progressbar", { name: "Swift Heron fuel" });
expect(screen.queryByLabelText("Swift Heron low fuel")).not.toBeInTheDocument();
});
it("shows Manual selected, as the default, in a new ship's fuel sheet", async () => {
renderApp(company(clipper.tankCapacityFu), "/fleet");
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Swift Heron" }));
const policy = within(screen.getByRole("dialog")).getByRole("group", { name: "Refuel policy" });
const radios = within(policy).getAllByRole("radio");
// Manual comes first and is checked.
expect(radios.map((r) => r.parentElement!.textContent)).toEqual([
"Manual refuel",
"Auto top-up",
]);
expect(within(policy).getByRole("radio", { name: "Manual refuel" })).toBeChecked();
expect(policy).toHaveTextContent("The default");
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@ -431,5 +431,5 @@ Spec: `memory-bank/change-requests/CR-018-fuel-tuning.md` (received as "CR-017
- [x] **Step 18.1 — Sim: fuel-to-payout balance check.** `packages/sim/balance/fuel-share.ts`: for every purchasable ship model and every route it can fly, boards at every origin (all locations discovered, that ship as the fleet, 40 periods × 3 worlds), the contracts that fit its hold, and the leg's fuel at the origin's price against their median payout. The balance script prints the table. At the old price (10 cr/fu) the shares ran from 0.7% (Bulk Hauler Jupiter → Moon) to 77.5% (Clipper Mars → Earth); the Courier's Earth → Moon was 20.9%. No single price can put every row in 15–25%, so the user chose to halve the price and gate only rows above 25% (Step 18.2). Tests (`packages/sim/test/fuel-share.test.ts`): a row for every purchasable ship, each on a route it can fly with contracts offered, Earth → Moon for every ship; the Courier's Earth → Moon row is its leg fuel × Earth's price over the median; the table has one line per row. - [x] **Step 18.1 — Sim: fuel-to-payout balance check.** `packages/sim/balance/fuel-share.ts`: for every purchasable ship model and every route it can fly, boards at every origin (all locations discovered, that ship as the fleet, 40 periods × 3 worlds), the contracts that fit its hold, and the leg's fuel at the origin's price against their median payout. The balance script prints the table. At the old price (10 cr/fu) the shares ran from 0.7% (Bulk Hauler Jupiter → Moon) to 77.5% (Clipper Mars → Earth); the Courier's Earth → Moon was 20.9%. No single price can put every row in 15–25%, so the user chose to halve the price and gate only rows above 25% (Step 18.2). Tests (`packages/sim/test/fuel-share.test.ts`): a row for every purchasable ship, each on a route it can fly with contracts offered, Earth → Moon for every ship; the Courier's Earth → Moon row is its leg fuel × Earth's price over the median; the table has one line per row.
- [x] **Step 18.2 — Content: lower the base fuel price.** `baseFuelPricePerFu` 10 → 5 (Earth 8 cr/fu, Jupiter 3); location multiples unchanged. Every ship and route is now at most 25% of the median fitting payout except the Clipper's Mars → Earth (38.8%); the Courier's Earth → Moon is 10.5%. The first Market purchase target is now 30–95 min (user decision): cheaper fuel brings the Clipper's first long landing, and so the first purchase, forward. Balance medians: deal 3.7 min, first purchase 41.6, discovery 39.3, loan 128.1, no violations. Tests: `packages/sim/test/fuel-share.test.ts` (only the Clipper's Mars → Earth is above 25%); `balance.test.ts` (30–95 window); fuel prices updated in `tank.test.ts`, the save 1 → 2 migration test and the Locations fuel test. - [x] **Step 18.2 — Content: lower the base fuel price.** `baseFuelPricePerFu` 10 → 5 (Earth 8 cr/fu, Jupiter 3); location multiples unchanged. Every ship and route is now at most 25% of the median fitting payout except the Clipper's Mars → Earth (38.8%); the Courier's Earth → Moon is 10.5%. The first Market purchase target is now 30–95 min (user decision): cheaper fuel brings the Clipper's first long landing, and so the first purchase, forward. Balance medians: deal 3.7 min, first purchase 41.6, discovery 39.3, loan 128.1, no violations. Tests: `packages/sim/test/fuel-share.test.ts` (only the Clipper's Mars → Earth is above 25%); `balance.test.ts` (30–95 window); fuel prices updated in `tank.test.ts`, the save 1 → 2 migration test and the Locations fuel test.
- [x] **Step 18.3 — Engine: deal ships start full; Manual by default.** `deliveredTank` gives every new ship (deal or Market) a full tank at the bay's price on Manual; `config.deal.tankShare` is gone. Save version 3: the 2 → 3 migration puts every ship on Manual, leaving fuel, prices and credits as they were. The balance simulator gives the deal's ships full tanks, flies on Manual and taps "Refuel here first" only when the best job needs more fuel than is aboard, or to go looking for work. Balance medians (7 worlds): deal 3.7 min, first purchase 36.7 (30–95), discovery 36.6, loan 123.9, four ships 122.7 vs 122.9, no violations. Tests: `packages/engine/test/fuel.test.ts` (the deal's Courier and Clipper full at Earth's price on Manual; a Market ship full on Manual; landing on Manual buys nothing and changes the tank only by the leg; switched to Auto it still tops up on landing; an old save opens with every ship full on Manual); `packages/schema/test/save.test.ts` (2 → 3: every ship Manual, fuel and credits unchanged; 1 → 3 end to end); the deadhead and About tests updated. - [x] **Step 18.3 — Engine: deal ships start full; Manual by default.** `deliveredTank` gives every new ship (deal or Market) a full tank at the bay's price on Manual; `config.deal.tankShare` is gone. Save version 3: the 2 → 3 migration puts every ship on Manual, leaving fuel, prices and credits as they were. The balance simulator gives the deal's ships full tanks, flies on Manual and taps "Refuel here first" only when the best job needs more fuel than is aboard, or to go looking for work. Balance medians (7 worlds): deal 3.7 min, first purchase 36.7 (30–95), discovery 36.6, loan 123.9, four ships 122.7 vs 122.9, no violations. Tests: `packages/engine/test/fuel.test.ts` (the deal's Courier and Clipper full at Earth's price on Manual; a Market ship full on Manual; landing on Manual buys nothing and changes the tank only by the leg; switched to Auto it still tops up on landing; an old save opens with every ship full on Manual); `packages/schema/test/save.test.ts` (2 → 3: every ship Manual, fuel and credits unchanged; 1 → 3 end to end); the deadhead and About tests updated.
- [ ] Step 18.4 — Client: low-fuel warning and policy display - [x] **Step 18.4 — Client: low-fuel warning and policy display.** `longestLeg`/`lowFuel` in `apps/web/src/game.ts`: an idle ship's longest leg on a full tank (its effective stats) to a discovered place, and whether the tank holds less than that leg burns. A Fleet card then shows "Low fuel · {place} needs {n} fu" under the gauge. The fuel sheet's policy toggle lists Manual first and calls it the default. Screenshots: `docs/screenshots/18-4-low-fuel-desktop.png`, `18-4-low-fuel-phone.png` (the deal's Clipper after flying empty to Mars: 160 fu, Ceres needs 928), `18-4-fuel-sheet-phone.png`. Tests (`apps/web/test/low-fuel.test.tsx`): a Clipper at Earth with 1 fu less than its Earth → Mars leg shows Low fuel naming Mars, and Fill clears it; with enough fuel, or with Mars undiscovered, no warning; the fuel sheet shows Manual first and checked, marked as the default.
- [ ] Step 18.5 — Plan amendments and regression - [ ] Step 18.5 — Plan amendments and regression