diff --git a/DECISIONS.md b/DECISIONS.md index 242c576..1c9b339 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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`. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 86d32d9..ef68280 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 8abe4b3..e99ee67 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index 2d5f9f3..1ab8e78 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -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), }), diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index 6cebeac..444665e 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -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); +} diff --git a/packages/engine/test/fuel.test.ts b/packages/engine/test/fuel.test.ts index bb75cba..7197c3b 100644 --- a/packages/engine/test/fuel.test.ts +++ b/packages/engine/test/fuel.test.ts @@ -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[]; + dispatches: { record: Record; resolution: Record | 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); + }); +}); diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index cda43ae..614b9be 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -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 = diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 9a41a82..ad4df43 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -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 }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index e9e6f04..8b8de87 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -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", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index fec5881..565a7c5 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -21,6 +21,9 @@ describe("route catalog", () => { "unassignPilot", "restPilot", "startUpgrade", + "refuelShip", + "setRefuelPolicy", + "orderEmergencyFuel", "spendSkillPoint", "acknowledgeSummaries", "acceptDeal",