Step 15.6: engine: fuel commands
CI / check (push) Successful in 5m50s Details

Refuel a ship where it is (fill or an amount, at the local price),
switch its refuel policy, and order emergency fuel when the sim offers
it (fuel at 2.5x the local price, cost added to the loan). The save
fields and migration landed in Step 15.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 01:41:18 -04:00
parent 63192f9129
commit 201f25a935
10 changed files with 283 additions and 2 deletions

View File

@ -1475,3 +1475,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"The fuel it could have there" is a full tank wherever fuel is sold**, regardless of the player's credits: the no-strand rule is about whether a ship can ever leave, and running short of cash is what emergency fuel is for.
- **Emergency fuel covers the cheapest leg the ship could fly on a full tank**, among discovered places it can land. A leg no tank could fly doesn't count, so a ship that can't leave even when full gets no offer (the no-strand rule prevents that state).
- **"Can't afford" means the credits don't cover the shortfall at the local price**, rounded up to whole credits; at a location that sells no fuel the player can't buy at all, so emergency fuel is offered whenever the tank is short.
## Step 15.6
- **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`.

View File

@ -72,6 +72,9 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
| `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes |
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `refuelShip` | `POST /api/ships/:shipId/refuel` | `shipId`, optional `amountFu` (absent fills) | `{ ship }` | yes |
| `setRefuelPolicy` | `POST /api/ships/:shipId/refuel-policy` | `shipId`, `policy` (`auto` / `manual`) | `{ ship }` | yes |
| `orderEmergencyFuel` | `POST /api/ships/:shipId/emergency-fuel` | `shipId` | `{ ship }`; cost added to the loan | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes |
## Save document (`packages/schema/src/save.ts`, CR-014)

View File

@ -375,7 +375,7 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as
- [x] **Step 15.3 — Content: fuel.** ShipModel: `fuelRangeMillionKm` and `fuelCostPerMillionKm` replaced by `tankCapacityFu` and `burnFuPerMillionKm` (Courier 40/20, Lighter 110/35, Clipper 1,170/45, Freighter 1,540/40, Bulk Hauler 5,900/80, loaner 40/20). Location: `launchCostMultiplier` replaced by `fuelPrice` (× base: Jupiter 0.6, Ceres 0.9, Moon 1.1, Mars 1.4, Earth 1.6) and `sellsFuel` (all true). GameConfig: `baseFuelPricePerFu` 10, `emergencyFuelMultiplier` 2.5. Extended Fuel Tanks raises `tankCapacityFu`: 11 %/level by default, Courier 380 %, Freighter 9 %. Sim (`fuel.ts`): `legFuel` (burn × economic distance of distance ÷ origin factor, decision 0.5.95 as the user decided), `plainDistance` (the curve's inverse), `fullTankReach` (factor × plainDistance(tank ÷ burn)), `fuelPriceAt`; `rangeFrom` and `checkRoute` now take the config and use full-tank reach. Until Step 15.4 adds tanks to ships, a trip is still charged at departure: its fuel at the origin's price. API `stats` carry `tankCapacityFu` and `burnFuPerMillionKm` instead of range. Web: the Market's "Range" is now "Reach" (a full tank from Earth), the upgrade screen shows "Tank" in fu. Balance re-check passes: first purchase 64 min, discovery 64, loan 156, four ships 151 vs 158. Tests: `locations.test.ts` (fuel prices ordered Jupiter < Ceres < Moon < Mars < Earth; every base location sells fuel); `fuel.test.ts` (leg fuel, the curve's inverse, full-tank reach burns exactly a tank and scales by the origin factor, the interim cost by hand); the §4 table (`reach-table.test.ts`) now runs on full-tank reach; `courier-mars`, `upgrade-caps` and the content distinctness test in tank form; `ships.test.ts` (the Clipper burns most per M km). Also removed four empty `import {}` lines left by Step 15.2's cleanup.
- [x] **Step 15.4 — Sim: tank, burn, and refuel.** Sim (`fuel.ts`): `Tank` (fuel and weighted average price), `refuel` (up to capacity, new average, whole-credit cost), `affordableRefuel` (the most the credits cover, never over), `legFuelCost` (burned × average price). Ships carry `tank` in `ShipState`; dispatch and deadhead validation fail with `not-enough-fuel` (replacing `insufficient-credits`) when fuel on board is short; proposals no longer take credits. Records gain `fuelBurnedFu`; `fuelCost` is that fuel at the tank's average price (pilot pay uses it, decision 0.5.98); tutorial runs burn none. `tripFuelCost` became `tripFuel` (fu). Resolution takes an optional `fuel` block (tank, capacity, policy, credits) and returns `topUp` (Auto: the most the credits after this delivery cover, at the destination's price; Manual or no tank: none). Pulled forward from 15.6 so the game keeps working: saved ships have `fuelFu`, `fuelAvgPrice`, `refuelPolicy`; save version 2 with the 1 → 2 migration (every ship full at the price where it is, the base price in flight, Auto; old records burned 0 fu, old resolutions no top-up); the engine takes fuel from the tank at departure (no credits) and applies the top-up on landing; Market ships arrive full at the bay's price; the deal's Courier and Clipper come with a quarter tank (`config.deal.tankShare`, user decision: with full tanks the Clipper left for Mars at once and the first purchase moved to 79 min); the API ship carries the fuel fields and summaries carry `topUp`. Web: the sim sees each ship's tank, the dispatch sheet and deadhead picker cost fuel at the average price and say "Not enough fuel (needs N fu, has M fu)". The balance simulator flies tanks too (Auto top-up, refuel before choosing work); results: first purchase 64 min, discovery 49, loan 136, four ships 136 vs 136. Tests: `packages/sim/test/tank.test.ts` (Moon leg = Earth amount at distance ÷ 1.3; 50 fu at 1.0 into 50 fu at 2.0 averages 1.5; never past capacity; affordable refuel exact; record burn and cost; pilot pay from burned × average; Auto top-up to full at the local price; Manual buys nothing; half the credits buys half; the tutorial loaner untouched); `dispatch.test.ts` (`not-enough-fuel`, passes after refuelling); `packages/schema/test/save.test.ts` (1 → 2 migration: tanks full at the right price, Auto, nothing else changed); `packages/engine/test/fuel.test.ts` (Market ship full; deal ships a quarter; burn then Auto top-up with the exact credits; Manual leaves the tank). Updated tests that expected fuel in credits (contract dispatch, deadhead, concurrency, browser 12.4); browser 12.5 fills the Clipper with an Earth–Moon round trip before its Mars run.
- [x] **Step 15.5 — Sim: stranding and emergency fuel.** `wouldStrand(model, destination, fuelOnArrivalFu, discoveredIds, content)` (decision 0.5.79 as amended): from the destination, some other discovered location must be within reach on a full tank where fuel is sold, otherwise on what is left after the leg (`fuelAfterLeg`). Dispatch and deadhead validation pass the fuel left; the dispatch sheet's explanation does too. `emergencyFuel({ ship, credits, discoveredIds })` (decision 0.5.99) returns `{ amountFu, price, cost }` or null: offered only for an idle ship whose fuel can't reach any other discovered location and whose company can't afford the shortfall for the cheapest leg it could fly on a full tank; the amount is that shortfall, the price 2.5 × local. Tests: `no-strand.test.ts` (no base ship is blocked on any leg it can fly between the five locations; with a fixture Mars that sells no fuel, a full-tank Clipper Earth → Mars is refused as `would-strand`, the same leg passes where Mars sells fuel, and fuel to spare after the leg passes); `emergency-fuel.test.ts` (offered when stuck and broke, with the exact amount, price and cost; not when the tank reaches somewhere, not when the credits cover the shortfall (one credit short does offer it), not in flight; only the cheapest leg's fuel when there are several ways out).
- [ ] Step 15.6 — Engine: commands and save migration
- [x] **Step 15.6 — Engine: commands and save migration.** (The save fields, the 1 → 2 migration and full tanks on new ships came in 15.4.) New commands in the route catalog: `refuelShip` (`{ shipId, amountFu? }`: fill, or buy an amount, at the local price; the ship must be idle at a location that sells fuel; refused with `insufficient-funds` unless the player can pay for all of it, `tank-full`, `no-fuel-here`, `ship-not-idle-here`), `setRefuelPolicy` (`{ shipId, policy }`), `orderEmergencyFuel` (`{ shipId }`: only when the sim offers it, else `emergency-fuel-unavailable`; adds the fuel at 2.5 × the local price into the tank's average and the cost to the loan). Tests (`packages/engine/test/fuel.test.ts`): an older (version-1) save imported through the engine has every ship full on Auto; refuel of an amount charges the local price and keeps the weighted average, fill tops up to capacity, a full tank is refused; refuel with no credits fails and changes nothing; the policy toggles; emergency fuel is refused when not needed, and when needed adds exactly the shortfall and raises the loan by exactly its cost, credits untouched. A new ship arriving full is tested since 15.4.
- [ ] Step 15.7 — Client: fuel in the UI
- [ ] Step 15.8 — Client: fixed map
- [ ] Step 15.9 — Plan amendments and regression

View File

@ -13,6 +13,9 @@ import {
restPilot,
spendSkillPoint,
startUpgrade,
refuelShip,
setRefuelPolicy,
orderEmergencyFuel,
unassignPilot,
} from "./fleet";
import { companyView, goalStates, playerState, requirePilot, requireShip } from "./state";
@ -38,6 +41,11 @@ export function commandTable(): CommandTable {
buyShip: ({ save, random, now }, input) => ({ ship: buyShip(save, input, random, now) }),
startUpgrade: ({ save, now }, input) => ({ ship: startUpgrade(save, input, now) }),
refuelShip: ({ save, now }, input) => ({ ship: refuelShip(save, input, now) }),
setRefuelPolicy: ({ save, now }, input) => ({ ship: setRefuelPolicy(save, input, now) }),
orderEmergencyFuel: ({ save, now }, input) => ({
ship: orderEmergencyFuel(save, input, now),
}),
leaseBay: ({ save, random, now }, input) => ({
bay: leaseBay(save, input.locationId, random, now),
}),

View File

@ -1,8 +1,12 @@
import { content } from "@deadhead/content";
import type { Bay, Pilot, SaveDocument, Ship } from "@deadhead/schema";
import {
effectiveStats,
emergencyFuel,
fuelPriceAt,
isResting,
pilotLevel,
refuel,
restEndsAt,
skillPointsAvailable,
validateUpgrade,
@ -14,6 +18,7 @@ import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages";
import { pilotName, shipName } from "./names";
import {
deliveredTank,
discoveredIds,
hasBayAt,
requirePilot,
requireShip,
@ -256,3 +261,90 @@ export function acknowledge(
}
return count;
}
type SavedShip = SaveDocument["ships"][number];
/** An idle ship's location; refuelling happens only where the ship stands. */
function idleAt(ship: SavedShip) {
const location =
ship.status === "idle" && ship.locationId ? content.location(ship.locationId) : undefined;
if (!location)
throw fail("ship-not-idle-here", "The ship has to be idle at a location to take on fuel.");
return location;
}
function tankCapacity(ship: SavedShip): number {
return effectiveStats(content.shipModel(ship.modelId)!, ship.upgrades, content).tankCapacityFu;
}
/**
* Buys fuel where the ship is (decision 0.5.97): fills the tank, or adds
* `amountFu` (never past full), at the local price. Refused if the player
* can't pay for all of it.
*/
export function refuelShip(
save: SaveDocument,
input: { shipId: string; amountFu?: number },
now: RealTime,
): Ship {
const ship = requireShip(save, input.shipId);
const location = idleAt(ship);
if (!location.sellsFuel) throw fail("no-fuel-here", `${location.name} sells no fuel.`);
const capacity = tankCapacity(ship);
const space = capacity - ship.fuelFu;
if (space <= 1e-9) throw fail("tank-full", "The tank is already full.");
const bought = refuel(
{ fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
capacity,
input.amountFu ?? space,
fuelPriceAt(location, content.config),
);
spend(save, bought.cost, "fuel");
ship.fuelFu = bought.tank.fuelFu;
ship.fuelAvgPrice = bought.tank.avgPrice;
return toApiShip(save, ship, now);
}
/** Auto top-up on landing, or Manual (decision 0.5.97). */
export function setRefuelPolicy(
save: SaveDocument,
input: { shipId: string; policy: "auto" | "manual" },
now: RealTime,
): Ship {
const ship = requireShip(save, input.shipId);
ship.refuelPolicy = input.policy;
return toApiShip(save, ship, now);
}
/**
* Emergency fuel (decision 0.5.99): only when the sim offers it. Adds fuel for
* the cheapest way out at 2.5 × the local price, and that cost to the loan.
*/
export function orderEmergencyFuel(
save: SaveDocument,
input: { shipId: string },
now: RealTime,
): Ship {
const ship = requireShip(save, input.shipId);
idleAt(ship);
const offer = emergencyFuel(
{ ship: shipState(ship), credits: save.company.credits, discoveredIds: discoveredIds(save) },
content,
);
if (!offer) {
throw fail(
"emergency-fuel-unavailable",
"Emergency fuel is only for a ship that can't reach anywhere when you can't afford the fuel.",
);
}
const bought = refuel(
{ fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
tankCapacity(ship),
offer.amountFu,
offer.price,
);
ship.fuelFu = bought.tank.fuelFu;
ship.fuelAvgPrice = bought.tank.avgPrice;
save.company.loanBalance += offer.cost;
return toApiShip(save, ship, now);
}

View File

@ -1,7 +1,11 @@
import { content } from "@deadhead/content";
import { fuelPriceAt } from "@deadhead/sim";
import { emergencyFuel, fuelPriceAt, tripFuel } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import { fullSave } from "../../schema/test/save-fixture";
import { shipState } from "../src/game/state";
import { encodeSave } from "../src/index";
import {
call,
edit,
getState,
putBoard,
@ -94,3 +98,126 @@ describe("fuel tanks in the engine (Step 15.4)", () => {
expect(saved.fuelFu).toBeCloseTo(tank("courier") - dispatch.fuelBurnedFu);
});
});
/** The fixture as a version-1 save, before fuel tanks (CR-015). */
function versionOneSave() {
const old = JSON.parse(JSON.stringify(fullSave())) as {
saveVersion: number;
ships: Record<string, unknown>[];
dispatches: { record: Record<string, unknown>; resolution: Record<string, unknown> | null }[];
};
old.saveVersion = 1;
for (const ship of old.ships) {
delete ship.fuelFu;
delete ship.fuelAvgPrice;
delete ship.refuelPolicy;
}
for (const dispatch of old.dispatches) {
delete dispatch.record.fuelBurnedFu;
if (dispatch.resolution) delete dispatch.resolution.topUp;
}
return old;
}
describe("fuel commands (Step 15.6)", () => {
it("opens an older save with every ship full, on Auto top-up", async () => {
const { app } = testApp();
const { state } = await app.call("importSave", {
code: await encodeSave(versionOneSave() as never),
});
for (const ship of state.ships) {
expect(ship.fuelFu, ship.name).toBeCloseTo(ship.stats.tankCapacityFu);
expect(ship.refuelPolicy).toBe("auto");
}
});
it("refuels where the ship is, charging the local price, and keeps the average", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 1_000);
await edit(app, (save) => {
Object.assign(
save.ships.find((s) => s.id === owner.shipId)!,
{
fuelFu: 10,
fuelAvgPrice: 11,
},
);
});
const { ship } = await app.call("refuelShip", { shipId: owner.shipId, amountFu: 20 });
expect(ship.fuelFu).toBeCloseTo(30);
// 10 fu bought at 11 cr/fu, then 20 fu at Earth's price.
expect(ship.fuelAvgPrice).toBeCloseTo((10 * 11 + 20 * price("earth")) / 30);
expect((await getState(app)).company.credits).toBe(1_000 - Math.round(20 * price("earth")));
const filled = await app.call("refuelShip", { shipId: owner.shipId });
expect(filled.ship.fuelFu).toBeCloseTo(tank("courier"));
await expect(app.call("refuelShip", { shipId: owner.shipId })).rejects.toMatchObject({
code: "tank-full",
});
});
it("refuses to refuel without the credits, changing nothing", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 0);
await edit(app, (save) => {
save.ships.find((s) => s.id === owner.shipId)!.fuelFu = 1;
});
const before = await readSave(app);
const response = await call(app, owner, "POST", `/api/ships/${owner.shipId}/refuel`, {});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ error: { code: "insufficient-funds" } });
expect(await readSave(app)).toEqual(before);
});
it("switches a ship between Auto top-up and Manual", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
const manual = await app.call("setRefuelPolicy", { shipId: owner.shipId, policy: "manual" });
expect(manual.ship.refuelPolicy).toBe("manual");
const auto = await app.call("setRefuelPolicy", { shipId: owner.shipId, policy: "auto" });
expect(auto.ship.refuelPolicy).toBe("auto");
});
it("refuses emergency fuel when it isn't needed", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await expect(app.call("orderEmergencyFuel", { shipId: owner.shipId })).rejects.toMatchObject({
code: "emergency-fuel-unavailable",
});
});
it("adds emergency fuel and exactly its cost to the loan", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 0);
await edit(app, (save) => {
save.ships.find((s) => s.id === owner.shipId)!.fuelFu = 1;
save.company.loanBalance = 5_000;
});
const save = await readSave(app);
const offer = emergencyFuel(
{
ship: shipState(save.ships.find((s) => s.id === owner.shipId)!),
credits: 0,
discoveredIds: new Set(save.discoveredLocations.map((d) => d.locationId)),
},
content,
)!;
const needed = tripFuel(
content.shipModel("courier")!,
content.location("earth")!,
content.location("moon")!,
content.config,
);
expect(offer.amountFu).toBeCloseTo(needed - 1);
expect(offer.cost).toBe(Math.round((needed - 1) * price("earth") * 2.5));
const { ship } = await app.call("orderEmergencyFuel", { shipId: owner.shipId });
expect(ship.fuelFu).toBeCloseTo(needed);
const state = await getState(app);
expect(state.company.loanBalance).toBe(5_000 + offer.cost);
expect(state.company.credits).toBe(0);
});
});

View File

@ -36,6 +36,9 @@ export const SERVER_ERROR_CODES = [
"goal-not-ready",
"goal-already-claimed",
"invalid-save",
"no-fuel-here",
"tank-full",
"emergency-fuel-unavailable",
] as const;
export type ErrorCode =

View File

@ -52,6 +52,19 @@ export const shipResponseSchema = z.object({ ship: shipSchema });
/** CR-009: start the next level of an upgrade on a ship at an HQ. */
export const startUpgradeRequestSchema = z.object({ shipId: idSchema, upgradeId: idSchema });
/** CR-015: buy fuel where the ship is; no amount fills the tank. */
export const refuelRequestSchema = z.object({
shipId: idSchema,
amountFu: z.number().positive().optional(),
});
/** CR-015: Auto top-up on landing, or Manual. */
export const refuelPolicyRequestSchema = z.object({
shipId: idSchema,
policy: z.enum(["auto", "manual"]),
});
/** CR-015: emergency fuel for a ship that can't get anywhere (decision 0.5.99). */
export const emergencyFuelRequestSchema = z.object({ shipId: idSchema });
export const leaseBayRequestSchema = z.object({ locationId: idSchema });
export const bayResponseSchema = z.object({ bay: baySchema });

View File

@ -30,6 +30,9 @@ import {
spendSkillPointRequestSchema,
restPilotRequestSchema,
startUpgradeRequestSchema,
refuelRequestSchema,
refuelPolicyRequestSchema,
emergencyFuelRequestSchema,
unassignPilotRequestSchema,
} from "./requests";
@ -103,6 +106,29 @@ export const routes = {
response: shipResponseSchema,
auth: true,
},
/** CR-015: fill the tank (or buy `amountFu`) at the ship's location. */
refuelShip: {
method: "POST",
path: "/api/ships/:shipId/refuel",
request: refuelRequestSchema,
response: shipResponseSchema,
auth: true,
},
setRefuelPolicy: {
method: "POST",
path: "/api/ships/:shipId/refuel-policy",
request: refuelPolicyRequestSchema,
response: shipResponseSchema,
auth: true,
},
/** CR-015: fuel for the cheapest way out, added to the loan (decision 0.5.99). */
orderEmergencyFuel: {
method: "POST",
path: "/api/ships/:shipId/emergency-fuel",
request: emergencyFuelRequestSchema,
response: shipResponseSchema,
auth: true,
},
leaseBay: {
method: "POST",
path: "/api/bays",

View File

@ -21,6 +21,9 @@ describe("route catalog", () => {
"unassignPilot",
"restPilot",
"startUpgrade",
"refuelShip",
"setRefuelPolicy",
"orderEmergencyFuel",
"spendSkillPoint",
"acknowledgeSummaries",
"acceptDeal",