Step 15.4: sim: tank, burn, and refuel
CI / check (push) Has been cancelled Details

Ships carry fuel: a leg burns from the tank at departure (no credits),
dispatch needs the fuel on board (not-enough-fuel), a leg's fuel cost
is fuel burned x the tank's average price, and Auto top-up refills on
landing with what the credits cover. The save gains tank fields (save
version 2, migration fills every tank). Market ships arrive full; the
deal's ships come with a quarter tank to keep the balance targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 01:32:09 -04:00
parent c627bb9954
commit 276cdbe611
71 changed files with 983 additions and 166 deletions

View File

@ -1459,3 +1459,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`checkRoute` and `rangeFrom` take the config**, since reach depends on the economic-distance curve; every caller already had the content bundle at hand.
- **A trip is still charged at departure in this step**, now its leg fuel at the origin's price: the tank state arrives in 15.4. The balance targets all pass with it.
- **The Market shows "Reach" (a full tank from Earth)** instead of "Range"; the upgrade screen shows the tank in fu. The fuel gauge and prices come in 15.7.
## Step 15.4
- **The engine's save fields and migration came in this step, not 15.6**: once the sim requires fuel on board, an engine without tanks can't dispatch anything. 15.6 keeps the refuel, policy and emergency fuel commands and their tests.
- **Deal ships come with a quarter tank** (user decision; `config.deal.tankShare` 0.25). With full tanks the deal's Clipper flew to Mars at once and landed at minute 36; Market purchases happen when it lands, so the first one moved to 79 min, outside 45–75. A quarter tank leaves the Clipper earning locally until the player can fuel a Mars run, as before tanks: first purchase 64, discovery 49, loan 136, four ships 136 vs 136. Market ships still arrive full (CR-015 §3).
- **Auto top-up spends the credits after this delivery's pay**: land, get paid, refuel. `affordableRefuel` never charges more than the credits on hand, rounding included.
- **Costs are whole credits; fuel amounts are fractional fu**: a leg burns what the curve says, and the UI rounds fu for display.
- **`credits` left the dispatch and deadhead proposals**: nothing in validation depends on cash any more, so keeping the field would have been misleading. `insufficient-credits` is replaced by `not-enough-fuel` in both failure lists.
- **The migration fills tanks at the price where each ship sits** (the base price while in flight), so the first legs after an update cost what fuel there costs.
- **The balance simulator models the player's "Refuel here first"**: before choosing work, an idle ship tops up where it is as far as credits go. The no-strand and soft-lock checks now ask "with a full tank" and "with the fuel on board plus what the credits buy" instead of "with any credits".

View File

@ -28,8 +28,11 @@ test.describe("end to end (Phase 12)", () => {
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
await expect(logbook(later)).toContainText(`Delivered ${summary.massTonnes} t`);
const after = await state(later);
// Credits went up by the delivery's net payout; the ship is at the destination.
expect(after.company.credits).toBe(before.company.credits + summary.credits);
// Credits went up by the delivery's net payout, less any Auto top-up on landing
// (CR-015); the ship is at the destination.
expect(after.company.credits).toBe(
before.company.credits + summary.credits - (summary.topUp?.cost ?? 0),
);
expect(after.ships.find((s) => s.id === flight.shipId)!.locationId).toBe(flight.destinationId);
});
});

View File

@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";
import type { Dispatch } from "@deadhead/schema";
import {
acknowledgeAll,
addCredits,
advanceTo,
board,
@ -7,6 +9,7 @@ import {
logbook,
nextMarsWindow,
playToDeal,
run,
serverNow,
state,
tryDispatch,
@ -29,6 +32,19 @@ test.describe("end to end (Phase 12)", () => {
}
await addCredits(page, 100_000);
// The deal's Clipper comes with a quarter tank (CR-015): an Earth–Moon round
// trip on Auto top-up lands it back at Earth full.
for (const destinationId of ["moon", "earth"]) {
const { dispatch } = await run<{ dispatch: Dispatch }>(page, "createDispatch", {
kind: "reposition",
shipId: clipper.id,
pilotId: veteran.id,
destinationId,
});
await advanceTo(page, dispatch.arrivalAt);
}
await acknowledgeAll(page);
// The board offers a first contact the Clipper can fly (decision 0.5.85).
const offer = (await board(page, "earth")).contracts.find(
(c) => c.firstContact && c.destinationId === "mars",

View File

@ -59,6 +59,7 @@ export function simShip(ship: Ship): ShipState {
inFlight: ship.status === "in-flight",
upgrades: ship.upgrades,
inShopUntil: ship.inShopUntil,
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
};
}

View File

@ -10,7 +10,7 @@ import {
LONG_HAUL_SKILL_ID,
needsRest,
realToGame,
tripFuelCost,
tripFuel,
tripGameHours,
validateDispatch,
wouldStrand,
@ -58,7 +58,7 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
"cargo-skill-too-low": "Cargo skill too low",
"long-haul-too-low": "Long Haul too low",
"contract-expired": "Contract expired",
"insufficient-credits": "Not enough credits for fuel",
"not-enough-fuel": "Not enough fuel",
};
/** The no-strand rule in words (CR-012), for the dispatch sheet and deadhead picker. */
@ -82,7 +82,8 @@ export function explainProblems(
contract: Contract,
model: ShipModel,
pilot: Pilot,
fuelCost: number,
/** The leg's fuel and what the ship has on board, in fu (CR-015). */
fuel: { neededFu: number; onBoardFu: number },
gameNow: number,
now: number,
): string[] {
@ -137,8 +138,10 @@ export function explainProblems(
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
}
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
if (state.company.credits < fuelCost) {
problems.push(`Not enough credits for fuel (${formatCredits(fuelCost)})`);
if (fuel.onBoardFu < fuel.neededFu) {
problems.push(
`Not enough fuel (needs ${Math.ceil(fuel.neededFu)} fu, has ${Math.floor(fuel.onBoardFu)} fu)`,
);
}
return problems;
}
@ -192,12 +195,14 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
contract.destinationId,
contract.massTonnes,
);
const fuelCost = tripFuelCost(
const fuelFu = tripFuel(
model,
location(contract.originId),
location(contract.destinationId),
content.config,
);
// The fuel used, at the tank's average price (decision 0.5.98).
const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice);
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
const pilotPay =
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
@ -217,7 +222,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
},
contract,
tutorial: false,
credits: state.company.credits,
now,
discoveredIds: discoveredIdsOf(state),
},
@ -227,7 +231,15 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
if (check.ok) {
problems = [];
} else {
const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now);
const all = explainProblems(
state,
contract,
model,
pilot,
{ neededFu: fuelFu, onBoardFu: ship.fuelFu },
gameNow,
now,
);
// In the shop (CR-009): first, with the time left.
if (isInShop(ship, now))
all.unshift(

View File

@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
import type { Dispatch, Ship } from "@deadhead/schema";
import {
dispatchProgress,
tripFuelCost,
tripFuel,
validateReposition,
type RepositionFailure,
} from "@deadhead/sim";
@ -188,7 +188,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
"cannot-land": "Can't land there",
"out-of-range": "Out of range",
"would-strand": "Would be stranded",
"insufficient-credits": "Not enough credits for fuel",
"not-enough-fuel": "Not enough fuel",
"ship-in-shop": "Ship is in the shop",
"pilot-resting": "Pilot is resting",
"pilot-not-idle": "Pilot is flying",
@ -226,7 +226,6 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
},
destinationId: id,
discoveredIds,
credits: state.company.credits,
now,
},
content,
@ -238,7 +237,10 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
: check.reason === "would-strand"
? strandedText(id)
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
fuelCost: tripFuelCost(model, location(origin), location(id), content.config),
// The fuel used, at the tank's average price (decision 0.5.98).
fuelCost: Math.round(
tripFuel(model, location(origin), location(id), content.config) * ship.fuelAvgPrice,
),
tripMs: tripRealMs(model, origin, id, 0),
};
});

View File

@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => {
expect(text).toContain("Build: test");
// CR-014: the save, its format version and where it is stored; no player id.
expect(text).toContain("Save: player-1");
expect(text).toContain("Save version: 1");
expect(text).toContain("Save version: 2");
expect(text).toContain("Storage: unknown");
expect(text).not.toContain("Player:");
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);

View File

@ -35,6 +35,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
credits: 538,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",

View File

@ -27,6 +27,7 @@ const summaries: ResolutionSummary[] = [
credits: 51_200,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
@ -48,6 +49,7 @@ const summaries: ResolutionSummary[] = [
credits: 240,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
@ -90,6 +92,7 @@ describe("while you were away", () => {
destinationId: "moon",
deadhead: true,
fuelCost: 230,
topUp: null,
goodId: null,
massTonnes: null,
shipper: null,

View File

@ -103,6 +103,7 @@ describe("the deal", () => {
credits: 96,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",

View File

@ -19,6 +19,7 @@ describe("developer tools in Settings (Step 10K.11)", () => {
arrivalAt: now + 5 * 60_000,
distanceMillionKm: 0.4,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",

View File

@ -5,7 +5,7 @@ import {
realToGame,
routeDistance,
travelTimeGameHours,
tripFuelCost,
tripFuel,
pilotShare,
} from "@deadhead/sim";
import { screen, waitFor, within } from "@testing-library/react";
@ -120,7 +120,8 @@ describe("dispatch sheet", () => {
(travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) *
MS_PER_HOUR) /
content.config.timeCompression;
const fuel = tripFuelCost(model, earth, mars, content.config);
// The fixtures' tanks were filled at 16 cr/fu (CR-015).
const fuel = Math.round(tripFuel(model, earth, mars, content.config) * 16);
const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config);
expect(within(freighter).getByRole("radio")).toBeEnabled();

View File

@ -11,7 +11,10 @@ import { effectiveStats } from "@deadhead/sim";
export function shipExtras(
modelId: string,
upgrades: Record<string, number> = {},
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
): Pick<
Ship,
"upgrades" | "stats" | "inShopUntil" | "shop" | "fuelFu" | "fuelAvgPrice" | "refuelPolicy"
> {
const model = content.shipModel(modelId);
if (!model) throw new Error(`Unknown ship model ${modelId}`);
const s = effectiveStats(model, upgrades, content);
@ -27,6 +30,10 @@ export function shipExtras(
},
inShopUntil: null,
shop: null,
// A full tank bought at Earth (CR-015).
fuelFu: s.tankCapacityFu,
fuelAvgPrice: 16,
refuelPolicy: "auto",
};
}

View File

@ -22,6 +22,7 @@ const flight: Dispatch = {
arrivalAt: NOW + 180_000,
distanceMillionKm: 0.39,
fuelCost: 50,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
@ -149,7 +150,14 @@ describe("fleet screen", () => {
const record = createRepositionRecord(
{
id: "r",
ship: { id: "s-idle", modelId: "courier", locationId: "moon", inFlight: false },
// The fixture's tank was filled at 16 cr/fu (CR-015).
ship: {
id: "s-idle",
modelId: "courier",
locationId: "moon",
inFlight: false,
tank: { fuelFu: 40, avgPrice: 16 },
},
pilot: {
id: "avatar",
isAvatar: true,

View File

@ -32,6 +32,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
arrivalAt,
distanceMillionKm: 0.39,
fuelCost: 50,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
@ -149,6 +150,7 @@ describe("loan on HQ", () => {
credits: 538,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",

View File

@ -20,6 +20,7 @@ const flight = (id: string, shipId: string, tutorial = false): Dispatch => ({
arrivalAt: NOW + 60_000,
distanceMillionKm: 0.4,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",

View File

@ -22,6 +22,7 @@ const flight: Dispatch = {
arrivalAt: NOW + 5 * 60_000,
distanceMillionKm: 100,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",

View File

@ -123,6 +123,7 @@ describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => {
arrivalAt: now + 60_000,
distanceMillionKm: 0.4,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",

View File

@ -18,6 +18,7 @@ const flight: Dispatch = {
arrivalAt: NOW + 3_600_000,
distanceMillionKm: 150,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
import { pilotShare, tripFuelCost } from "@deadhead/sim";
import { pilotShare, tripFuel } from "@deadhead/sim";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -65,6 +65,7 @@ const summary: ResolutionSummary = {
tutorial: false,
deadhead: false,
fuelCost: 150,
topUp: null,
goodId: "consumer-goods",
massTonnes: 10,
shipper: "Pacific Orbital Logistics",
@ -106,11 +107,14 @@ describe("pilot share in the client (Step 10J.6)", () => {
);
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const dialog = screen.getByRole("dialog");
const fuel = tripFuelCost(
// The fixtures' tanks were filled at 16 cr/fu (CR-015).
const fuel = Math.round(
tripFuel(
content.shipModel("courier")!,
content.location("earth")!,
content.location("moon")!,
content.config,
) * 16,
);
const share = pilotShare(job.payout, fuel, content.config);

View File

@ -20,6 +20,7 @@ function inFlight(arrivalAt: number): Dispatch {
arrivalAt,
distanceMillionKm: 0.39,
fuelCost: 0,
fuelBurnedFu: 0,
expectedPayout: 240,
seed: 1,
status: "in-flight",

View File

@ -36,6 +36,7 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
credits: 500,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",

View File

@ -144,6 +144,7 @@ describe("scripted delivery summary", () => {
credits: 461,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",

View File

@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format
| Package | Role | Depends on |
|---|---|---|
| `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod |
| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. Fuel (`fuel.ts`, CR-015): `legFuel` = burn × economic distance(distance ÷ origin factor), `fullTankReach` (its inverse for a full tank), `fuelPriceAt`; `checkRoute(ship, from, to, config)` compares the distance with full-tank reach. | content |
| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. Fuel (`fuel.ts`, CR-015): `legFuel` = burn × economic distance(distance ÷ origin factor), `fullTankReach` (its inverse for a full tank), `fuelPriceAt`; `checkRoute(ship, from, to, config)` compares the distance with full-tank reach. Tanks (Step 15.4): `ShipState.tank` (`fuelFu`, `avgPrice`), `refuel`, `affordableRefuel`, `legFuelCost`; validation fails with `not-enough-fuel`; records carry `fuelBurnedFu` and `fuelCost` (burned × average price); `resolveDispatch` takes an optional `fuel` block and returns `topUp` (Auto top-up on landing). | content |
| `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim |
| `packages/schema` | Zod schemas for every API request, response and error. Shared by the engine and the client. | sim (error codes), zod |
| `apps/web` | React client; runs the engine on the device (CR-014). | content, engine, sim, schema |
@ -76,7 +76,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
## Save document (`packages/schema/src/save.ts`, CR-014)
One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
One document per company on the device, validated on every load and save: `saveVersion` (currently 2; 1 → 2 added fuel tanks, CR-015), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map, shop fields, and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned.

View File

@ -373,7 +373,7 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as
- [x] **Step 15.1 — Content: fixed positions.** Locations lose `body` and gain `position` (`{ radiusMillionKm, angleDegrees }`): Earth 150/210°, Mars 228/270°, Ceres Station 414/285°, Jupiter Cloud Platform 778/300°; the Moon is Earth's position moved 0.384M km straight out (`offsetPosition`), recorded as `orbitParent: { locationId: "earth", offsetMillionKm: 0.384 }`. GameConfig's epoch keeps only a displayed start date (`gameStart`, was `ephemerisDate`; same value). `astronomy-engine` is removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim's `locationPosition` already reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (`bodyPosition` is gone; the Moon is placed from `orbitParent`). Fixed distances broke CR-012's tuning, so ranges move into CR-015 §4's bands now: Clipper 198 → 250M km (it couldn't reach Mars at 201), Freighter 450 → 460, the Courier's fuel-tank override 450 % → 1,400 % per level (5 → 75 → 145 → 215: Mars from Earth only at level 3). The balance re-check then put the first Market purchase at 86.7 min (quantized by the Clipper's first Mars landing), so the Lighter's price drops 42,400 → 36,000 (first purchase 53.3, four ships 115 vs 130 min, loan 135 min). Tests: `packages/content/test/fixed-map.test.ts` (every location positioned; the Moon defined as an offset from Earth; child bodies match their parent plus offset; the six §2 distances within 2 % and symmetric; no `astronomy-engine` in the lockfile). Date-sampled tests became yes/no: `reach-table.test.ts` is now CR-015 §4, one test per cell; `courier-mars`, `eligibility`, the distance test ("varies" replaced by "the same at every time"), the upgrade-cap value; the engine's would-strand test and the web stranding test use a maxed-tank Freighter at the Moon going to Jupiter with Mars and Ceres undiscovered (no base ship can strand on the fixed map); the Market test for "Other dates" is removed (the wording goes in 15.8).
- [x] **Step 15.2 — Sim: static positions and distances.** No time arguments left where a time only fed a position: `locationPosition(location)`, `routeDistance(from, to)`, `checkRoute(ship, from, to)`, `tripFuelCost(model, origin, destination, config)`, `contractTripGameHours(model, contract, content)`, `priorityWindow(origin, destination, mass, content)`, and `wouldStrand(model, destination, discoveredIds, content)` (it had taken cargo mass and departure only to find where planets would be on arrival). `dispatchPosition` runs along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig's `noStrandMarginGameHours` is gone (nothing moves in 48 game hours now). Web: `distanceAt`, `tripRealMs`, `locationMapPoint`, `reachersByOrigin`, `tooSlowForAll` and `shipReach` lose their time argument; `shipReach` checks each leg once instead of sampling 16 game years (its `otherDates` is always empty and goes with its wording in 15.8). Callers were updated with a small argument-dropping codemod, then the unused time variables removed. Tests: `distance.test.ts` per the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone), `positions.test.ts` ("never moves"), new `static-routes.test.ts` (an Earth → Ceres contract pays the same in any board period; a Freighter's trip takes the same time, distance and fuel whenever it departs); `no-strand.test.ts` now checks that no base ship is ever blocked on a leg it can fly; `range-from` and `upgrade-caps` lose their date loops.
- [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.
- [ ] Step 15.4 — Sim: tank, burn, and refuel
- [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.
- [ ] Step 15.5 — Sim: stranding and emergency fuel
- [ ] Step 15.6 — Engine: commands and save migration
- [ ] Step 15.7 — Client: fuel in the UI

View File

@ -23,7 +23,14 @@ export const gameConfig: GameConfig = {
loanRepaymentShare: 0.25,
// The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the
// veteran, and the Earth bay. The loan is computed from their prices.
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
// The ships come with a quarter tank (CR-015, user decision): the Clipper earns
// locally before the player can fuel a Mars run, as before fuel tanks.
deal: {
avatarShipModelId: "courier",
veteranShipModelId: "clipper",
bayLocationId: "earth",
tankShare: 0.25,
},
contractBoardPeriodRealMinutes: 10,
// Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips.
boardRetentionPeriods: 24,

View File

@ -238,6 +238,8 @@ export const gameConfigSchema = z
avatarShipModelId: z.string().min(1),
veteranShipModelId: z.string().min(1),
bayLocationId: z.string().min(1),
/** How full the deal's ships' tanks are (CR-015, user decision): Halvorsen's leftovers. */
tankShare: fraction,
}),
contractBoardPeriodRealMinutes: z.number().positive(),
/** Past board periods whose boards and accepted contracts are kept (Step 7.3). */

View File

@ -40,6 +40,8 @@ describe("game config", () => {
avatarShipModelId: "courier",
veteranShipModelId: "clipper",
bayLocationId: "earth",
// Halvorsen's ships come with a quarter tank (CR-015, user decision).
tankShare: 0.25,
});
expect(content.dealLoanAmount).toBe(price("courier") + price("clipper") + earth.bayLeasePrice);
});

View File

@ -114,7 +114,12 @@ const validGameConfig = {
baseFuelPricePerFu: 10,
emergencyFuelMultiplier: 2.5,
economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 },
deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" },
deal: {
avatarShipModelId: "courier",
veteranShipModelId: "clipper",
bayLocationId: "earth",
tankShare: 0.25,
},
};
const validScripted = {

View File

@ -90,6 +90,8 @@ export function commandTable(): CommandTable {
break;
case "reset-upgrades":
ship.upgrades = {};
// A smaller tank holds no more than it can (CR-015).
ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu);
break;
case "finish-shop":
// Done now: the "out of the shop" note still shows, as for a finished job.

View File

@ -4,7 +4,7 @@ import type { RealTime } from "@deadhead/sim";
import type { RandomSource } from "../core";
import { fail } from "./errors";
import { companyName, shipName } from "./names";
import { companyView, isDealAvailable } from "./state";
import { companyView, deliveredTank, isDealAvailable } from "./state";
export const AVATAR_NAME = "Captain";
@ -121,6 +121,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi
fromDeal: true,
createdAt: now,
lastArrivalAt: now,
// Halvorsen's ships come with what's left in their tanks (CR-015, user decision).
...deliveredTank(modelId, bayLocationId, content.config.deal.tankShare),
});
}
save.bays.push({ id: random.id(), locationId: bayLocationId, createdAt: now });

View File

@ -43,7 +43,7 @@ function claimableContract(
throw fail("unknown-contract", "No such contract on your boards.");
}
/** Stores a dispatch, marks its contract accepted, and charges fuel. */
/** Stores a dispatch, marks its contract accepted, and takes the leg's fuel from the tank. */
function storeDispatch(
save: SaveDocument,
args: {
@ -75,7 +75,7 @@ function storeDispatch(
acceptedAt: now,
});
}
save.company.credits -= record.fuelCost;
if (shipId !== null) requireShip(save, shipId).fuelFu -= record.fuelBurnedFu;
return toApiDispatch(dispatch);
}
@ -85,8 +85,8 @@ function setInFlight(save: SaveDocument, shipId: string, pilotId: string): void
}
/**
* The avatar flies the company loaner on a tutorial contract (Step 6.4). The
* company pays fuel, and only one tutorial run may be in flight.
* The avatar flies the company loaner on a tutorial contract (Step 6.4). It
* uses no fuel (decision 0.5.94), and only one tutorial run may be in flight.
*/
export function dispatchTutorial(
save: SaveDocument,
@ -112,10 +112,7 @@ export function dispatchTutorial(
inFlight: false,
};
const pilot = pilotState(avatar);
const check = validateDispatch(
{ ship: loaner, pilot, contract, tutorial: true, credits: save.company.credits, now },
content,
);
const check = validateDispatch({ ship: loaner, pilot, contract, tutorial: true, now }, content);
if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]);
const record = createDispatchRecord(
@ -156,7 +153,6 @@ export function dispatchContract(
// The no-strand rule counts only discovered locations (decision 0.5.79).
discoveredIds: discoveredIds(save),
tutorial: false,
credits: save.company.credits,
now,
};
const check = validateDispatch(proposal, content);
@ -185,7 +181,6 @@ export function dispatchReposition(
pilot: pilotState(pilot),
destinationId: input.destinationId,
discoveredIds: discoveredIds(save),
credits: save.company.credits,
now,
};
const check = validateReposition(proposal, content);

View File

@ -13,6 +13,7 @@ import { fail } from "./errors";
import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages";
import { pilotName, shipName } from "./names";
import {
deliveredTank,
hasBayAt,
requirePilot,
requireShip,
@ -71,6 +72,8 @@ export function buyShip(
fromDeal: false,
createdAt: now,
lastArrivalAt: now,
// Delivered with a full tank, included in the price (decision 0.5.94).
...deliveredTank(model.id, bay.locationId),
};
save.ships.push(ship);
return toApiShip(save, ship, now);

View File

@ -17,7 +17,7 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
"cargo-skill-too-low": "The pilot's cargo skill is too low for this contract.",
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
"contract-expired": "This contract has expired.",
"insufficient-credits": "Not enough credits for fuel.",
"not-enough-fuel": "Not enough fuel on board for this leg. Refuel first.",
"already-there": "The ship is already there.",
"location-undiscovered": "You can only fly empty to places you have discovered.",
};

View File

@ -1,11 +1,12 @@
import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema";
import { boardPeriodIndex, resolveDispatch, type RealTime } from "@deadhead/sim";
import { boardPeriodIndex, effectiveStats, resolveDispatch, type RealTime } from "@deadhead/sim";
import { discoveredIds } from "./state";
/**
* Resolves every in-flight dispatch that has arrived by `now`, in arrival
* order (Steps 6.1, 7.2): credits (net of pilot pay and loan repayment), XP,
* order (Steps 6.1, 7.2): credits (net of pilot pay and loan repayment), Auto
* top-up (CR-015), XP,
* duty and rest, ship and pilot locations, tutorial progress and discovery.
* A resolved dispatch is never resolved again, so running this twice in a
* row changes nothing the second time. Returns how many were resolved.
@ -21,6 +22,8 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
for (const dispatch of due) {
const pilot = save.pilots.find((p) => p.id === dispatch.pilotId);
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined;
const model = ship ? content.shipModel(ship.modelId) : undefined;
const result = resolveDispatch(
{
record: dispatch.record,
@ -31,6 +34,15 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
pilotDutyHours: pilot.dutyHours,
pilotSkills: pilot.skills,
now,
fuel:
ship && model
? {
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
capacityFu: effectiveStats(model, ship.upgrades, content).tankCapacityFu,
refuelPolicy: ship.refuelPolicy,
credits: save.company.credits,
}
: undefined,
},
content,
);
@ -41,6 +53,12 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now });
save.company.credits += r.credits;
save.company.loanBalance -= r.loanRepayment;
// Auto top-up on landing (decision 0.5.97).
if (r.topUp && ship) {
save.company.credits -= r.topUp.cost;
ship.fuelFu = r.topUp.tank.fuelFu;
ship.fuelAvgPrice = r.topUp.tank.avgPrice;
}
pilot.xp += r.pilotXp;
pilot.status = "idle";
@ -50,7 +68,6 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId;
const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined;
if (ship) {
ship.status = "idle";
ship.locationId = r.shipLocationId;

View File

@ -12,6 +12,7 @@ import {
completedGoals,
dutyLimit,
effectiveStats,
fuelPriceAt,
isInShop,
isResting,
pilotLevel,
@ -74,6 +75,23 @@ export function spend(save: SaveDocument, amount: number, what: string): void {
save.company.credits -= amount;
}
/** A ship's tank on delivery: `share` of its capacity, bought at `locationId`'s price. */
export function deliveredTank(
modelId: string,
locationId: string,
share = 1,
): Pick<SavedShip, "fuelFu" | "fuelAvgPrice" | "refuelPolicy"> {
const model = content.shipModel(modelId);
const location = content.location(locationId);
if (!model || !location)
throw new Error(`Unknown ship model or location: ${modelId}, ${locationId}`);
return {
fuelFu: model.tankCapacityFu * share,
fuelAvgPrice: fuelPriceAt(location, content.config),
refuelPolicy: "auto",
};
}
export function shipState(ship: SavedShip): ShipState {
return {
id: ship.id,
@ -83,6 +101,7 @@ export function shipState(ship: SavedShip): ShipState {
inFlight: ship.status === "in-flight",
upgrades: ship.upgrades,
inShopUntil: ship.inShopUntil,
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
};
}
@ -157,6 +176,9 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S
ship.shopUpgradeId && ship.shopLevel !== null && ship.inShopUntil !== null
? { upgradeId: ship.shopUpgradeId, level: ship.shopLevel, until: ship.inShopUntil }
: null,
fuelFu: ship.fuelFu,
fuelAvgPrice: ship.fuelAvgPrice,
refuelPolicy: ship.refuelPolicy,
};
}
@ -234,6 +256,13 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState {
tutorial: d.tutorial,
deadhead: d.record.contract === null,
fuelCost: d.record.fuelCost,
topUp: d.resolution?.topUp
? {
locationId: d.resolution.topUp.locationId,
boughtFu: d.resolution.topUp.boughtFu,
cost: d.resolution.topUp.cost,
}
: null,
goodId: d.record.contract?.goodId ?? null,
massTonnes: d.record.contract?.massTonnes ?? null,
shipper: d.record.contract?.shipper ?? null,

View File

@ -0,0 +1,96 @@
import { content } from "@deadhead/content";
import { fuelPriceAt } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
edit,
getState,
putBoard,
readSave,
setCredits,
signUp,
signUpOwner,
testContract,
useTestApp,
} from "./support";
const testApp = useTestApp();
const tank = (id: string) => content.shipModel(id)!.tankCapacityFu;
const price = (id: string) => fuelPriceAt(content.location(id)!, content.config);
describe("fuel tanks in the engine (Step 15.4)", () => {
it("delivers a Market ship with a full tank, bought at the bay's price", async () => {
const { app } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, content.shipModel("freighter")!.price);
const { ship } = await app.call("buyShip", { modelId: "freighter", bayId: owner.earthBayId });
expect(ship).toMatchObject({
fuelFu: tank("freighter"),
fuelAvgPrice: price("earth"),
refuelPolicy: "auto",
});
// The tank was included in the price.
expect((await getState(app)).company.credits).toBe(0);
});
it("hands over the deal's ships with a quarter tank (CR-015, user decision)", async () => {
const { app } = testApp();
await signUp(app);
await edit(app, (save) => {
save.player.tutorialRunsCompleted = content.config.tutorialRunsBeforeDeal;
save.player.loanerLocationId = "earth";
});
const state = await app.call("acceptDeal", {});
for (const ship of state.ships) {
expect(ship.fuelFu, ship.modelId).toBeCloseTo(tank(ship.modelId) * 0.25);
expect(ship.fuelAvgPrice).toBe(price("earth"));
}
});
it("burns the leg from the tank and, with Auto, tops up on landing at the local price", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 10_000);
const contract = testContract();
await putBoard(app, [contract]);
const { dispatch } = await app.call("createDispatch", {
kind: "contract",
contractId: contract.id,
shipId: owner.shipId,
pilotId: owner.avatarId,
});
expect((await getState(app)).company.credits).toBe(10_000);
clock.now = dispatch.arrivalAt;
const state = await getState(app);
const [summary] = state.summaries;
const bought = summary!.topUp!;
expect(bought.locationId).toBe("moon");
expect(bought.boughtFu).toBeCloseTo(dispatch.fuelBurnedFu);
expect(bought.cost).toBe(Math.round(dispatch.fuelBurnedFu * price("moon")));
expect(state.company.credits).toBe(10_000 + summary!.credits - bought.cost);
expect(state.ships[0]!.fuelFu).toBeCloseTo(tank("courier"));
});
it("leaves the tank as it landed with Manual", async () => {
const { app, clock } = testApp();
const owner = await signUpOwner(app);
await setCredits(app, 10_000);
await edit(app, (save) => {
save.ships.find((s) => s.id === owner.shipId)!.refuelPolicy = "manual";
});
const contract = testContract();
await putBoard(app, [contract]);
const { dispatch } = await app.call("createDispatch", {
kind: "contract",
contractId: contract.id,
shipId: owner.shipId,
pilotId: owner.avatarId,
});
clock.now = dispatch.arrivalAt;
const state = await getState(app);
expect(state.summaries[0]!.topUp).toBeNull();
expect(state.company.credits).toBe(10_000 + state.summaries[0]!.credits);
const saved = (await readSave(app)).ships.find((s) => s.id === owner.shipId)!;
expect(saved.fuelFu).toBeCloseTo(tank("courier") - dispatch.fuelBurnedFu);
});
});

View File

@ -67,7 +67,11 @@ describe("concurrency and money safety", () => {
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
const state = await getState(app, guest);
expect(state.activeDispatches).toHaveLength(1);
expect(state.company.credits).toBe(10_000 - state.activeDispatches[0]!.fuelCost);
// One leg's fuel left the tank, once; no credits were charged (CR-015).
expect(state.company.credits).toBe(10_000);
expect(state.ships[0]!.fuelFu).toBeCloseTo(
state.ships[0]!.stats.tankCapacityFu - state.activeDispatches[0]!.fuelBurnedFu,
);
});
it("leases one bay when the same lease is sent twice at once", async () => {

View File

@ -70,7 +70,7 @@ async function expectRejected(
}
describe("contract dispatch", () => {
it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => {
it("dispatches, burns fuel from the tank, stores the record and removes the contract from the board", async () => {
const { app, clock } = testApp();
const o = await owner();
const contract = testContract();
@ -87,10 +87,14 @@ describe("contract dispatch", () => {
expectedPayout: contract.payout,
tutorial: false,
});
expect(record.fuelBurnedFu).toBeGreaterThan(0);
expect(record.fuelCost).toBeGreaterThan(0);
// Fuel comes out of the tank; no credits change hands (decision 0.5.94).
const state = await getState(app, o.guest);
expect(state.company.credits).toBe(10_000 - record.fuelCost);
expect(state.company.credits).toBe(10_000);
const full = content.shipModel("courier")!.tankCapacityFu;
expect(state.ships[0]!.fuelFu).toBeCloseTo(full - record.fuelBurnedFu);
expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
expect(state.pilots[0]!.status).toBe("in-flight");
const board = contractBoardResponseSchema.parse(
@ -186,13 +190,15 @@ describe("contract dispatch", () => {
await expectRejected(o, await dispatch(o, contract.id), "contract-expired");
});
it("rejects insufficient-credits", async () => {
it("rejects not-enough-fuel (CR-015)", async () => {
const o = await owner();
const contract = testContract();
await putBoard(testApp().app, [contract]);
await setCredits(testApp().app, 0);
await edit(testApp().app, (save) => {
save.ships.find((s) => s.id === o.shipId)!.fuelFu = 0.5;
});
const response = await dispatch(o, contract.id);
expect(errorCode(response)).toBe("insufficient-credits");
expect(errorCode(response)).toBe("not-enough-fuel");
expect((await readSave(testApp().app)).dispatches).toEqual([]);
});

View File

@ -91,7 +91,13 @@ describe("location contract boards", () => {
(contract) =>
validateDispatch(
{
ship: { id: owner.shipId, modelId: "courier", locationId: "earth", inFlight: false },
ship: {
id: owner.shipId,
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: { fuelFu: content.shipModel("courier")!.tankCapacityFu, avgPrice: 16 },
},
pilot: {
id: owner.avatarId,
isAvatar: true,
@ -102,7 +108,6 @@ describe("location contract boards", () => {
},
contract,
tutorial: false,
credits: 1_000_000,
now: clock.now,
},
content,

View File

@ -33,15 +33,19 @@ describe("deadhead dispatch", () => {
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 });
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
// The logbook marks it as a deadhead with its fuel (no payout to show).
// Fuel came out of the tank; landing on Auto top-up bought it back at the Moon (CR-015).
const [summary] = state.summaries;
expect(state.summaries).toHaveLength(1);
expect(state.summaries[0]).toMatchObject({
expect(summary).toMatchObject({
deadhead: true,
fuelCost: dispatch.fuelCost,
credits: 0,
pilotXp: 0,
topUp: { locationId: "moon" },
});
expect(summary!.topUp!.boughtFu).toBeCloseTo(dispatch.fuelBurnedFu);
expect(state.company.credits).toBe(10_000 - summary!.topUp!.cost);
expect(state.ships[0]!.fuelFu).toBeCloseTo(state.ships[0]!.stats.tankCapacityFu);
});
it("refuses an undiscovered destination and changes nothing", async () => {

View File

@ -24,6 +24,7 @@ import {
SaveStorage,
type CommandName,
} from "../src/index";
import { deliveredTank } from "../src/game/state";
/**
* Helpers for the ported server tests (CR-014 Step 14.5): the same names and
@ -305,10 +306,11 @@ export function makeOwner(
inShopUntil: null,
shopUpgradeId: null,
shopLevel: null,
// Stands in for the deal's Courier (not a Market purchase, CR-013).
// Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank.
fromDeal: true,
createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW,
...deliveredTank("courier", "earth"),
});
Object.assign(
save.pilots.find((p) => p.id === avatarId)!,
@ -370,6 +372,8 @@ export function addShip(
fromDeal: false,
createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW,
// A full tank, bought where the ship is (decision 0.5.94).
...deliveredTank(ship.modelId, ship.locationId ?? "earth"),
...ship,
});
});

View File

@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@deadhead/content": "workspace:*",
"@deadhead/sim": "workspace:*",
"zod": "^4.6.5"
}

View File

@ -66,6 +66,11 @@ export const shipSchema = z.object({
shop: z
.object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema })
.nullable(),
/** Fuel on board, in fu, and its average purchase price in credits per fu (CR-015). */
fuelFu: z.number().nonnegative(),
fuelAvgPrice: z.number().nonnegative(),
/** Auto top-up on landing, or Manual (decision 0.5.97). */
refuelPolicy: z.enum(["auto", "manual"]),
});
export type Ship = z.infer<typeof shipSchema>;
@ -143,6 +148,8 @@ export const dispatchSchema = z.object({
departureAt: timeSchema,
arrivalAt: timeSchema,
distanceMillionKm: z.number().nonnegative(),
/** Fuel taken from the tank at departure, in fu (CR-015). */
fuelBurnedFu: z.number().nonnegative(),
fuelCost: credits.nonnegative(),
expectedPayout: credits,
seed: z.number().int().nonnegative(),
@ -161,8 +168,12 @@ export const resolutionSummarySchema = z.object({
tutorial: z.boolean(),
/** An empty reposition flight: no cargo, no pay (CR-002). */
deadhead: z.boolean(),
/** Fuel paid at departure. */
/** The fuel used, at the tank's average price (decision 0.5.98). */
fuelCost: credits.nonnegative(),
/** Auto top-up on landing (decision 0.5.97), or null. */
topUp: z
.object({ locationId: idSchema, boughtFu: z.number().positive(), cost: credits.nonnegative() })
.nullable(),
/** What was delivered and for whom (CR-007); null for a deadhead. */
goodId: idSchema.nullable(),
massTonnes: z.number().positive().nullable(),

View File

@ -1,4 +1,11 @@
import type { Contract as SimContract, DispatchRecord, Resolution } from "@deadhead/sim";
import { content } from "@deadhead/content";
import {
effectiveStats,
fuelPriceAt,
type Contract as SimContract,
type DispatchRecord,
type Resolution,
} from "@deadhead/sim";
import { z } from "zod";
import { contractSchema, idSchema, timeSchema } from "./entities";
@ -23,14 +30,32 @@ export const dispatchRecordSchema = z.object({
departureAt: timeSchema,
arrivalAt: timeSchema,
distanceMillionKm: z.number().nonnegative(),
/** Fuel taken from the tank at departure (CR-015); 0 for tutorial runs and pre-tank trips. */
fuelBurnedFu: z.number().nonnegative(),
fuelCost: credits.nonnegative(),
expectedPayout: credits,
seed: z.number().int().nonnegative(),
});
/** A ship's tank (decision 0.5.94): fuel and its average purchase price. */
const tankSchema = z.object({
fuelFu: z.number().nonnegative(),
avgPrice: z.number().nonnegative(),
});
/** What resolving a dispatch did, kept for the "while you were away" summary. */
export const resolutionSchema = z.object({
credits,
/** Auto top-up on landing (decision 0.5.97). */
topUp: z
.object({
locationId: idSchema,
boughtFu: z.number().positive(),
price: z.number().positive(),
cost: credits.nonnegative(),
tank: tankSchema,
})
.nullable(),
pilotPay: credits.nonnegative(),
loanRepayment: credits.nonnegative(),
pilotXp: z.number().int().nonnegative(),
@ -59,6 +84,12 @@ export const savedShipSchema = z.object({
createdAt: timeSchema,
/** Its latest resolved arrival (the delivery time if none): picks the default Contracts location. */
lastArrivalAt: timeSchema,
/** Fuel on board, in fu (decision 0.5.94). */
fuelFu: z.number().nonnegative(),
/** Credits per fu, weighted by what was bought where (decision 0.5.98). */
fuelAvgPrice: z.number().nonnegative(),
/** Auto top-up on landing, or Manual (decision 0.5.97). */
refuelPolicy: z.enum(["auto", "manual"]),
});
export const savedPilotSchema = z.object({
@ -104,7 +135,7 @@ export const savedDispatchSchema = z.object({
});
/** The save version this build writes. Bump it with a new migration. */
export const CURRENT_SAVE_VERSION = 1;
export const CURRENT_SAVE_VERSION = 2;
export const saveDocumentSchema = z.object({
saveVersion: z.literal(CURRENT_SAVE_VERSION),
@ -167,12 +198,42 @@ export interface SaveMigration {
migrate(save: Record<string, unknown>): Record<string, unknown>;
}
type Doc = Record<string, unknown>;
const records = (value: unknown): Doc[] => (Array.isArray(value) ? (value as Doc[]) : []);
/**
* Every migration, in order. Version 1 is the first local save format, so
* there is nothing before it yet; a format change adds `{ from: 1, … }` here
* and bumps `CURRENT_SAVE_VERSION`.
* Version 1 → 2 (CR-015 §5, fuel tanks): every ship gets a full tank, bought at
* the price where it sits (the base price while in flight), on Auto top-up.
* Earlier trips paid fuel in credits, so their records burned no tank fuel and
* their resolutions had no top-up. Arrival times are untouched.
*/
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [];
function addFuelTanks(save: Doc): Doc {
const { config } = content;
const ships = records(save.ships).map((ship) => {
const model = content.shipModel(String(ship.modelId));
const upgrades = (ship.upgrades ?? {}) as Record<string, number>;
const capacity = model ? effectiveStats(model, upgrades, content).tankCapacityFu : 0;
const at = ship.locationId ? content.location(String(ship.locationId)) : undefined;
return {
...ship,
fuelFu: capacity,
fuelAvgPrice: at ? fuelPriceAt(at, config) : config.baseFuelPricePerFu,
refuelPolicy: "auto",
};
});
const dispatches = records(save.dispatches).map((dispatch) => ({
...dispatch,
record: { ...(dispatch.record as Doc), fuelBurnedFu: 0 },
resolution: dispatch.resolution ? { ...(dispatch.resolution as Doc), topUp: null } : null,
}));
return { ...save, ships, dispatches };
}
/**
* Every migration, in order: `from` → `from + 1`. A format change adds one
* here and bumps `CURRENT_SAVE_VERSION`.
*/
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [{ from: 1, migrate: addFuelTanks }];
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
type Stepped = { ok: true; save: Record<string, unknown> } | { ok: false; error: string };

View File

@ -39,12 +39,13 @@ export function fullSave(): SaveDocument {
departureAt: T,
arrivalAt,
distanceMillionKm: 0.38,
fuelBurnedFu: 7.6,
fuelCost: 160,
expectedPayout: 1500,
seed: 42,
});
return {
saveVersion: 1,
saveVersion: 2,
saveId: "save-1",
lastTimeSeen: T + 3_600_000,
player: {
@ -70,6 +71,9 @@ export function fullSave(): SaveDocument {
fromDeal: true,
createdAt: T,
lastArrivalAt: T + 200_000,
fuelFu: 52.4,
fuelAvgPrice: 15.2,
refuelPolicy: "auto",
},
{
id: "s2",
@ -84,6 +88,9 @@ export function fullSave(): SaveDocument {
fromDeal: true,
createdAt: T,
lastArrivalAt: T,
fuelFu: 1170,
fuelAvgPrice: 16,
refuelPolicy: "manual",
},
],
pilots: [
@ -157,6 +164,13 @@ export function fullSave(): SaveDocument {
status: "resolved",
resolution: {
credits: 1000,
topUp: {
locationId: "moon",
boughtFu: 7.6,
price: 11,
cost: 84,
tank: { fuelFu: 40, avgPrice: 11 },
},
pilotPay: 0,
loanRepayment: 340,
pilotXp: 10,

View File

@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
describe("save document (Step 14.2)", () => {
it("validates a fully populated save", () => {
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
expect(CURRENT_SAVE_VERSION).toBe(1);
expect(CURRENT_SAVE_VERSION).toBe(2);
});
it("rejects an invalid save with a readable error naming the field", () => {
@ -36,7 +36,7 @@ describe("save document (Step 14.2)", () => {
if (!future.ok) expect(future.error).toContain("newer version");
});
it("migrates a version-1 save to a valid current save", () => {
it("reads a current save as it is", () => {
const result = migrateSave(JSON.parse(JSON.stringify(fullSave())));
expect(result).toEqual({ ok: true, save: fullSave() });
});
@ -56,3 +56,49 @@ describe("save document (Step 14.2)", () => {
});
});
});
/** The fixture as a version-1 save: before fuel tanks (CR-015). */
type Loose = Record<string, unknown> & {
ships: Record<string, unknown>[];
dispatches: { record: Record<string, unknown>; resolution: Record<string, unknown> | null }[];
};
function versionOne(): Record<string, unknown> {
const save = JSON.parse(JSON.stringify(fullSave())) as Loose;
save.saveVersion = 1;
for (const ship of save.ships) {
delete ship.fuelFu;
delete ship.fuelAvgPrice;
delete ship.refuelPolicy;
}
for (const dispatch of save.dispatches) {
delete dispatch.record.fuelBurnedFu;
if (dispatch.resolution) delete dispatch.resolution.topUp;
}
return save;
}
describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => {
it("fills every ship's tank, on Auto top-up, and changes nothing else", () => {
const result = migrateSave(versionOne());
if (!result.ok) throw new Error(result.error);
const { save } = result;
const before = fullSave();
expect(save.saveVersion).toBe(2);
// The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base price.
expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 10, refuelPolicy: "auto" });
// The Clipper is at Earth: a full 1,170 fu at Earth's 16 cr/fu.
expect(save.ships[1]).toMatchObject({ fuelFu: 1170, fuelAvgPrice: 16, refuelPolicy: "auto" });
// Earlier trips paid fuel in credits: nothing came out of a tank, nothing topped up.
for (const dispatch of save.dispatches) {
expect(dispatch.record.fuelBurnedFu).toBe(0);
if (dispatch.resolution) expect(dispatch.resolution.topUp).toBeNull();
}
// In-flight dispatches keep their arrival times; everything else is as it was.
expect(save.dispatches.map((d) => d.arrivalAt)).toEqual(
before.dispatches.map((d) => d.arrivalAt),
);
expect(save.company).toEqual(before.company);
expect(save.pilots).toEqual(before.pilots);
});
});

View File

@ -69,6 +69,9 @@ const ship = {
},
inShopUntil: null,
shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 },
fuelFu: 32.3,
fuelAvgPrice: 15.2,
refuelPolicy: "auto",
};
const pilot = {
id: "p1",
@ -118,6 +121,7 @@ const dispatch = {
departureAt: 4_000_000,
arrivalAt: 4_130_000,
distanceMillionKm: 0.39,
fuelBurnedFu: 7.8,
fuelCost: 234,
expectedPayout: 480,
seed: 123456,
@ -134,6 +138,7 @@ const summary = {
credits: 240,
deadhead: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",

View File

@ -10,7 +10,10 @@
*/
import { content as defaultContent, type ShipModel } from "@deadhead/content";
import {
affordableRefuel,
boardPeriodIndex,
fuelPriceAt,
type Tank,
fullTankReach,
boardPeriodMs,
checkRoute,
@ -31,7 +34,7 @@ import {
resolveDispatch,
restEndsAt,
skillPointsAvailable,
tripFuelCost,
tripFuel,
validateDispatch,
validateReposition,
validateUpgrade,
@ -105,6 +108,8 @@ export interface SimulationResult {
}
interface SimShip extends ShipState {
/** Every simulated ship has a tank, on Auto top-up (decision 0.5.97). */
tank: Tank;
upgrades: UpgradeLevels;
inShopUntil: RealTime | null;
fromDeal: boolean;
@ -244,25 +249,32 @@ export function simulate(options: SimulationOptions): SimulationResult {
/** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
const fuel = tripFuelCost(stats, loc(contract.originId), loc(contract.destinationId), config);
const origin = loc(contract.originId);
const fuel =
tripFuel(stats, origin, loc(contract.destinationId), config) * fuelPriceAt(origin, config);
const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
const tripMs =
(contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression;
return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE);
}
/** Contracts this crew may take now; `budget` defaults to the credits on hand. */
function eligibleContracts(ship: SimShip, pilot: SimPilot, budget = credits): Contract[] {
/** A ship as it would be with a full tank (for "is there any way out" checks). */
function filled(ship: SimShip): SimShip {
const capacity = effectiveStats(model(ship.modelId), ship.upgrades, content).tankCapacityFu;
return { ...ship, tank: { ...ship.tank, fuelFu: capacity } };
}
/** Contracts this crew may take now, with the fuel on board (or a full tank). */
function eligibleContracts(ship: SimShip, pilot: SimPilot, fullTank = false): Contract[] {
if (!discovered.has(ship.locationId)) return [];
return board(ship.locationId).filter(
(contract) =>
validateDispatch(
{
ship,
ship: fullTank ? filled(ship) : ship,
pilot: pilotState(pilot),
contract,
tutorial: false,
credits: budget,
now,
discoveredIds: discovered,
},
@ -288,7 +300,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
);
if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id);
accepted.add(contract.id);
credits -= record.fuelCost;
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
log(
`${ship.modelId} ${contract.originId}→${contract.destinationId} ${contract.goodId} ${contract.massTonnes}t pays ${contract.payout}${contract.firstContact ? " FIRST CONTACT" : ""} fuel ${record.fuelCost} arrives +${Math.round((record.arrivalAt - now) / MS_PER_MINUTE)}m credits ${credits}`,
);
@ -304,6 +316,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
while (flights.length > 0 && flights[0]!.record.arrivalAt <= now) {
const flight = flights.shift()!;
const pilot = pilots.find((p) => p.id === flight.pilotId)!;
const flightShip = flight.shipId ? ships.find((s) => s.id === flight.shipId)! : undefined;
const result = resolveDispatch(
{
record: flight.record,
@ -313,6 +326,15 @@ export function simulate(options: SimulationOptions): SimulationResult {
pilotDutyHours: pilot.dutyHours,
pilotSkills: pilot.skills,
now,
fuel: flightShip
? {
tank: flightShip.tank,
capacityFu: effectiveStats(model(flightShip.modelId), flightShip.upgrades, content)
.tankCapacityFu,
refuelPolicy: "auto",
credits,
}
: undefined,
},
content,
);
@ -320,6 +342,10 @@ export function simulate(options: SimulationOptions): SimulationResult {
const r = result.resolution;
credits += r.credits;
loanBalance -= r.loanRepayment;
if (r.topUp && flightShip) {
credits -= r.topUp.cost;
flightShip.tank = r.topUp.tank;
}
pilot.xp += r.pilotXp;
pilot.inFlight = false;
pilot.dutyHours = r.pilotDutyHours;
@ -372,12 +398,12 @@ export function simulate(options: SimulationOptions): SimulationResult {
inFlight: false,
};
const avatar = pilots[0]!;
// Company pays fuel; the player keeps the tutorial share: best share per minute.
// Tutorial runs use no fuel; the player keeps the tutorial share: best share per minute.
const best = contracts
.filter(
(c) =>
validateDispatch(
{ ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, credits, now },
{ ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, now },
content,
).ok,
)
@ -435,6 +461,11 @@ export function simulate(options: SimulationOptions): SimulationResult {
modelId,
locationId,
inFlight: false,
tank: {
// Market ships come full; the deal's ships with a quarter tank (CR-015).
fuelFu: model(modelId).tankCapacityFu * (fromDeal ? config.deal.tankShare : 1),
avgPrice: fuelPriceAt(loc(locationId), config),
},
upgrades: {},
inShopUntil: null,
fromDeal,
@ -553,6 +584,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue;
if (ship.inShopUntil !== null && ship.inShopUntil > now) continue;
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
topUpHere(ship, stats.tankCapacityFu);
const choices = eligibleContracts(ship, pilot)
.map((c) => ({ c, value: rate(c, stats, pilot) }))
.filter((o) => o.value > 0)
@ -562,25 +594,24 @@ export function simulate(options: SimulationOptions): SimulationResult {
acted = true;
continue;
}
const canDeadhead = (to: string, budget: number) =>
const canDeadhead = (to: string, fullTank: boolean) =>
to !== ship.locationId &&
validateReposition(
{
ship,
ship: fullTank ? filled(ship) : ship,
pilot: pilotState(pilot),
destinationId: to,
discoveredIds: discovered,
credits: budget,
now,
},
content,
).ok;
const deadheads = [...discovered].filter((to) => canDeadhead(to, credits));
// A dead end is no work and no way out whatever the balance (CR-002);
// running short of cash is the soft-lock check's business.
const deadheads = [...discovered].filter((to) => canDeadhead(to, false));
// A dead end is no work and no way out even on a full tank (CR-002);
// running short of cash for fuel is the soft-lock check's business.
if (
eligibleContracts(ship, pilot, Infinity).length === 0 &&
![...discovered].some((to) => canDeadhead(to, Infinity))
eligibleContracts(ship, pilot, true).length === 0 &&
![...discovered].some((to) => canDeadhead(to, true))
) {
violate("idle-ship-with-nowhere-to-go", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
}
@ -607,7 +638,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
},
content,
);
credits -= record.fuelCost;
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
ship.inFlight = true;
pilot.inFlight = true;
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
@ -626,6 +657,16 @@ export function simulate(options: SimulationOptions): SimulationResult {
return acted;
}
/** Tops the tank up where the ship is, as far as credits go (the player's "Refuel here first"). */
function topUpHere(ship: SimShip, capacityFu: number) {
const location = loc(ship.locationId);
if (!location.sellsFuel || ship.tank.fuelFu >= capacityFu) return;
const bought = affordableRefuel(ship.tank, capacityFu, fuelPriceAt(location, config), credits);
if (bought.boughtFu <= 0) return;
credits -= bought.cost;
ship.tank = bought.tank;
}
function checkCashflow() {
if (tutorialActive || flights.length > 0) return;
const affordable = ships.some((ship) => {
@ -635,8 +676,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
discovered.has(ship.locationId) &&
board(ship.locationId).some(
(c) =>
tripFuelCost(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
credits,
tripFuel(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
ship.tank.fuelFu + credits / fuelPriceAt(loc(ship.locationId), config),
)
);
});

View File

@ -5,7 +5,7 @@ import type { Contract } from "./contract";
import { routeDistance } from "./distance";
import { isResting } from "./duty";
import { checkRoute } from "./eligibility";
import { fuelCost } from "./fuel";
import { legFuel, legFuelCost, type Tank } from "./fuel";
import { checkPilotEligibility, type SkillLevels } from "./pilot";
import { deriveSeed, type Seed } from "./rng";
import { travelTimeGameHours } from "./travel";
@ -20,6 +20,8 @@ export interface ShipState {
upgrades?: UpgradeLevels;
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
inShopUntil?: RealTime | null;
/** The ship's fuel (decision 0.5.94); absent for the tutorial loaner, which uses none. */
tank?: Tank;
}
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
@ -43,9 +45,8 @@ export interface DispatchProposal {
ship: ShipState;
pilot: PilotState;
contract: Contract;
/** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */
/** Tutorial runs are flown by the avatar in the loaner, and use no fuel (decision 0.5.94). */
tutorial: boolean;
credits: number;
now: RealTime;
/**
* Locations the player has discovered, for the no-strand rule (decision 0.5.79).
@ -70,7 +71,7 @@ export const DISPATCH_FAILURES = [
"cargo-skill-too-low",
"long-haul-too-low",
"contract-expired",
"insufficient-credits",
"not-enough-fuel",
] as const;
export type DispatchFailure = (typeof DISPATCH_FAILURES)[number];
@ -88,14 +89,25 @@ export function findLocation(id: string, content: SimContent): Location {
return location;
}
/** Fuel charged for a trip, in whole credits. */
export function tripFuelCost(
/** Fuel a trip burns from the tank, in fu (decision 0.5.95). */
export function tripFuel(
model: ShipModel,
origin: Location,
destination: Location,
config: GameConfig,
): number {
return Math.round(fuelCost(model, origin, routeDistance(origin, destination), config));
return legFuel(model, origin, routeDistance(origin, destination), config);
}
/** Whether the ship has the fuel on board for this leg (decision 0.5.95). */
function hasFuelFor(
ship: ShipState,
model: ShipModel,
origin: Location,
destination: Location,
content: SimContent,
): boolean {
return (ship.tank?.fuelFu ?? 0) >= tripFuel(model, origin, destination, content.config);
}
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
@ -134,7 +146,7 @@ export function wouldStrand(
/** Validates a proposed contract dispatch, returning the first failing reason. */
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
const { ship, pilot, contract, tutorial, credits, now } = proposal;
const { ship, pilot, contract, tutorial, now } = proposal;
const model = shipStats(ship, content);
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
@ -171,9 +183,8 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent
if (now >= contract.expiresAt) return fail("contract-expired");
if (!tutorial) {
if (credits < tripFuelCost(model, origin, destination, content.config))
return fail("insufficient-credits");
if (!tutorial && !hasFuelFor(ship, model, origin, destination, content)) {
return fail("not-enough-fuel");
}
return { ok: true };
}
@ -189,9 +200,11 @@ export interface DispatchRecord {
tutorial: boolean;
departureAt: RealTime;
arrivalAt: RealTime;
/** Route distance at departure, in million km. */
/** Route distance, in million km. */
distanceMillionKm: number;
/** Credits charged to the player; 0 for tutorial runs (the company pays). */
/** Fuel taken from the tank at departure, in fu; 0 for tutorial runs (decision 0.5.94). */
fuelBurnedFu: number;
/** That fuel at the tank's average price, in credits (decision 0.5.98); 0 for tutorial runs. */
fuelCost: number;
expectedPayout: number;
/** Seed for anything rolled about this trip. */
@ -230,6 +243,7 @@ function buildRecord(
const distance = routeDistance(origin, destination);
const mass = contract?.massTonnes ?? 0;
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
const burned = tutorial ? 0 : legFuel(model, origin, distance, config);
return {
id,
@ -242,7 +256,8 @@ function buildRecord(
departureAt: now,
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
distanceMillionKm: distance,
fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, content.config),
fuelBurnedFu: burned,
fuelCost: ship.tank ? legFuelCost(burned, ship.tank) : 0,
expectedPayout: contract?.payout ?? 0,
seed: deriveSeed(worldSeed, `dispatch:${id}`),
};
@ -263,7 +278,6 @@ export interface RepositionProposal {
destinationId: string;
/** Locations the player has discovered; only these can be deadheaded to (CR-002). */
discoveredIds: ReadonlySet<string>;
credits: number;
now: RealTime;
}
@ -279,7 +293,7 @@ export const REPOSITION_FAILURES = [
"cannot-land",
"out-of-range",
"would-strand",
"insufficient-credits",
"not-enough-fuel",
] as const;
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
@ -294,7 +308,7 @@ export function validateReposition(
proposal: RepositionProposal,
content: SimContent,
): RepositionCheck {
const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal;
const { ship, pilot, destinationId, discoveredIds, now } = proposal;
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle");
@ -314,8 +328,8 @@ export function validateReposition(
return failWith("would-strand");
}
if (credits < tripFuelCost(model, origin, destination, content.config)) {
return failWith("insufficient-credits");
if (!hasFuelFor(ship, model, origin, destination, content)) {
return failWith("not-enough-fuel");
}
return { ok: true };
}

View File

@ -48,15 +48,50 @@ export function fuelPriceAt(location: Location, config: GameConfig): number {
return config.baseFuelPricePerFu * location.fuelPrice;
}
/**
* Fuel cost in credits for a leg: its fuel at the origin's price. (Step 15.3;
* Step 15.4 replaces the charge with fuel burned from the ship's tank.)
*/
export function fuelCost(
ship: ShipModel,
origin: Location,
distanceMillionKm: number,
config: GameConfig,
): number {
return legFuel(ship, origin, distanceMillionKm, config) * fuelPriceAt(origin, config);
/** What a ship's tank holds (decision 0.5.94): fuel, and its average purchase price. */
export interface Tank {
fuelFu: number;
/** Credits per fu, weighted by how much was bought at each price (decision 0.5.98). */
avgPrice: number;
}
export interface Refuel {
tank: Tank;
boughtFu: number;
/** Whole credits. */
cost: number;
}
/**
* Adds up to `amountFu` at `price`, never past `capacityFu` (decision 0.5.97),
* and keeps the tank's weighted average price.
*/
export function refuel(tank: Tank, capacityFu: number, amountFu: number, price: number): Refuel {
const boughtFu = Math.max(0, Math.min(amountFu, capacityFu - tank.fuelFu));
const fuelFu = tank.fuelFu + boughtFu;
const avgPrice =
fuelFu > 0 ? (tank.fuelFu * tank.avgPrice + boughtFu * price) / fuelFu : tank.avgPrice;
return {
tank: { fuelFu, avgPrice },
boughtFu,
cost: Math.min(Math.round(boughtFu * price), Math.ceil(boughtFu * price)),
};
}
/** The most fuel up to a full tank that `credits` can pay for (Auto top-up, decision 0.5.97). */
export function affordableRefuel(
tank: Tank,
capacityFu: number,
price: number,
credits: number,
): Refuel {
const affordable = Math.max(0, credits) / price;
const result = refuel(tank, capacityFu, affordable, price);
// Rounding never charges more than the player has.
return { ...result, cost: Math.min(result.cost, Math.max(0, Math.floor(credits))) };
}
/** What a leg's fuel cost the player (decision 0.5.98): fu burned × the tank's average price. */
export function legFuelCost(burnedFu: number, tank: Tank): number {
return Math.round(burnedFu * tank.avgPrice);
}

View File

@ -13,12 +13,16 @@ export type { Position } from "./positions";
export { routeDistance } from "./distance";
export { travelTimeGameHours } from "./travel";
export {
affordableRefuel,
economicDistance,
fuelCost,
fuelPriceAt,
fullTankReach,
legFuel,
legFuelCost,
plainDistance,
refuel,
type Refuel,
type Tank,
} from "./fuel";
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
@ -49,7 +53,7 @@ export {
createDispatchRecord,
createRepositionRecord,
shipStats,
tripFuelCost,
tripFuel,
validateDispatch,
wouldStrand,
validateReposition,
@ -79,7 +83,7 @@ export { splitLoanRepayment } from "./loan";
export type { LoanSplit } from "./loan";
export { resolveDispatch } from "./resolution";
export type { Resolution, ResolveInput, ResolveResult } from "./resolution";
export type { RefuelPolicy, Resolution, ResolveInput, ResolveResult, TopUp } from "./resolution";
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";

View File

@ -1,18 +1,37 @@
import type { SimContent } from "./board";
import type { RealTime } from "./clock";
import type { DispatchRecord } from "./dispatch";
import { findLocation } from "./dispatch";
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
import { affordableRefuel, fuelPriceAt, type Tank } from "./fuel";
import { splitLoanRepayment } from "./loan";
import { pilotShare } from "./pay";
import type { SkillLevels } from "./pilot";
import { deliveryXp } from "./xp";
/** Auto top-up (default) fills the tank on landing; Manual never does (decision 0.5.97). */
export type RefuelPolicy = "auto" | "manual";
/** Fuel an Auto top-up bought on landing (decision 0.5.97). */
export interface TopUp {
locationId: string;
boughtFu: number;
/** Credits per fu. */
price: number;
/** Whole credits, paid on top of the delivery's `credits`. */
cost: number;
/** The tank afterwards. */
tank: Tank;
}
export interface Resolution {
/**
* Credits to add: payout minus pilot pay, minus the loan repayment.
* Fuel was already charged at departure.
* Credits to add: payout minus pilot pay, minus the loan repayment. Fuel
* came out of the tank at departure; any top-up is `topUp.cost`.
*/
credits: number;
/** Auto top-up on landing, or null (Manual, nothing bought, or no tank). */
topUp: TopUp | null;
/** A hired pilot's share of this delivery's profit; 0 for the avatar, tutorial runs and deadheads (CR-010). */
pilotPay: number;
/** Credits that went to the lender out of this payout (decision 0.5.32). */
@ -42,6 +61,30 @@ export interface ResolveInput {
pilotDutyHours: number;
pilotSkills: SkillLevels;
now: RealTime;
/**
* The ship's tank on landing, its policy, and the company's credits before this
* resolution (Auto top-up buys what they cover). Absent: no top-up (the
* tutorial loaner uses no fuel).
*/
fuel?: { tank: Tank; capacityFu: number; refuelPolicy: RefuelPolicy; credits: number };
}
/** Auto top-up on landing (decision 0.5.97): as much as the credits after this trip cover. */
function topUpOnLanding(input: ResolveInput, net: number, content: SimContent): TopUp | null {
const { fuel, record } = input;
if (!fuel || record.tutorial || fuel.refuelPolicy !== "auto") return null;
const location = findLocation(record.destinationId, content);
if (!location.sellsFuel) return null;
const price = fuelPriceAt(location, content.config);
const bought = affordableRefuel(fuel.tank, fuel.capacityFu, price, fuel.credits + net);
if (bought.boughtFu <= 0) return null;
return {
locationId: location.id,
boughtFu: bought.boughtFu,
price,
cost: bought.cost,
tank: bought.tank,
};
}
/**
@ -73,6 +116,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
resolution: {
...base,
credits: 0,
topUp: topUpOnLanding(input, 0, content),
pilotPay: 0,
loanRepayment: 0,
pilotXp: 0,
@ -97,6 +141,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
resolution: {
...base,
credits: net,
topUp: topUpOnLanding(input, net, content),
pilotPay,
loanRepayment: repayment,
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),

View File

@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it } from "vitest";
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
import { content } from "@deadhead/content";
import { MS_PER_HOUR, tripFuelCost } from "../src/index";
import { fuelPriceAt, MS_PER_HOUR, tripFuel } from "../src/index";
/**
* Balance targets (plan Step 12.1 as amended by CR-001 to CR-013), checked on
@ -72,17 +72,20 @@ describe("balance simulation (Step 12.1)", () => {
});
it("leaves fuel for at least three Courier runs from Earth after the deal (Step 2.5, measured)", () => {
// Step 12.2: the tutorial share and starting credits as actually earned.
// Step 12.2: the tutorial share and starting credits as actually earned. Since
// CR-015 the deal Courier's quarter tank counts, with credits to buy the rest at Earth.
const courier = content.shipModel("courier")!;
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
const fuel = tripFuelCost(courier, earth, moon, content.config);
const runFu = tripFuel(courier, earth, moon, content.config);
const inTank = courier.tankCapacityFu * content.config.deal.tankShare;
const earthPrice = fuelPriceAt(earth, content.config);
for (const result of lighterFirst) {
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
expect(result.tutorialShareEarned).toBeGreaterThan(0);
expect(result.creditsAtDeal).toBe(
content.config.startingCredits + result.tutorialShareEarned,
);
expect(result.creditsAtDeal!).toBeGreaterThanOrEqual(3 * fuel);
expect(inTank + result.creditsAtDeal! / earthPrice).toBeGreaterThanOrEqual(3 * runFu);
}
});

View File

@ -7,8 +7,12 @@ import {
type PilotState,
type ShipState,
type SimContent,
tripFuel,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
/** Default content plus a test-only ship that cannot land on atmospheric worlds. */
const vacuumModel: ShipModel = {
...defaultContent.shipModel("courier")!,
@ -40,7 +44,13 @@ const contract: Contract = {
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const ship: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,
@ -49,7 +59,7 @@ const pilot: PilotState = {
skills: {},
restEndsAt: null,
};
const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, now };
const reason = (proposal: DispatchProposal) => {
const result = validateDispatch(proposal, content);
@ -123,8 +133,20 @@ describe("dispatch validation", () => {
expect(reason({ ...valid, now: contract.expiresAt })).toBe("contract-expired");
});
it("rejects a dispatch the player cannot afford fuel for", () => {
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
it("rejects a dispatch without the fuel on board, and passes once refueled (Step 15.4)", () => {
const needed = tripFuel(
defaultContent.shipModel("courier")!,
defaultContent.location("earth")!,
defaultContent.location("moon")!,
content.config,
);
const withFuel = (fuelFu: number) => ({
...valid,
ship: { ...ship, tank: { fuelFu, avgPrice: 16 } },
});
expect(reason(withFuel(needed * 0.99))).toBe("not-enough-fuel");
expect(reason({ ...valid, ship: { ...ship, tank: undefined } })).toBe("not-enough-fuel");
expect(reason(withFuel(needed))).toBe("valid");
});
describe("tutorial runs", () => {
@ -137,8 +159,9 @@ describe("dispatch validation", () => {
const avatar: PilotState = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null };
const tutorial: DispatchProposal = { ...valid, ship: loaner, pilot: avatar, tutorial: true };
it("accepts the avatar in the loaner without credits for fuel", () => {
expect(reason({ ...tutorial, credits: 0 })).toBe("valid");
it("accepts the avatar in the loaner, which carries no fuel (decision 0.5.94)", () => {
expect(tutorial.ship.tank).toBeUndefined();
expect(reason(tutorial)).toBe("valid");
});
it("rejects a hired pilot in the loaner", () => {

View File

@ -18,6 +18,9 @@ import {
type ShipState,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
@ -37,7 +40,13 @@ const contract: Contract = {
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const ship: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,
@ -46,7 +55,7 @@ const pilot: PilotState = {
skills: {},
restEndsAt: null,
};
const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, now };
const limit = dutyLimit({}, content.config);
const resolveAt = (record: DispatchRecord, dutyBefore: number, at = record.arrivalAt) => {
@ -78,7 +87,6 @@ describe("duty in validation (Step 10D.3)", () => {
pilot: { ...pilot, restEndsAt: now + 1 },
destinationId: "moon",
discoveredIds: new Set(["earth", "moon"]),
credits: 10_000,
now,
},
content,

View File

@ -12,6 +12,9 @@ import {
type ShipState,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const courier = content.shipModel("courier")!;
const freighter = content.shipModel("freighter")!;
@ -34,7 +37,13 @@ const contract: Contract = {
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const ship: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,
@ -43,7 +52,7 @@ const pilot: PilotState = {
skills: {},
restEndsAt: null,
};
const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, now };
const reason = (p: DispatchProposal) => {
const result = validateDispatch(p, content);
return result.ok ? "valid" : result.reason;

View File

@ -7,6 +7,9 @@ import {
type FleetMember,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const earth = content.location("earth")!;
const start = Date.parse(content.config.epoch.realStart);
const firstPeriod = boardPeriodIndex(start, content.config);
@ -26,7 +29,7 @@ const board = (periodIndex: number, discovered: string[], fleet: FleetMember[])
const clipperCanFly = (contract: ReturnType<typeof board>[number]) =>
validateDispatch(
{
ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false },
ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false, tank: PLENTY },
pilot: {
id: "p",
isAvatar: false,
@ -37,7 +40,6 @@ const clipperCanFly = (contract: ReturnType<typeof board>[number]) =>
},
contract,
tutorial: false,
credits: 1e9,
now: contract.offeredAt,
discoveredIds: new Set(["earth", "moon"]),
},

View File

@ -7,10 +7,13 @@ import {
locationPosition,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const record = createRepositionRecord(
{
id: "r1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY },
pilot: {
id: "p1",
isAvatar: false,

View File

@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
economicDistance,
fuelCost,
fullTankReach,
legFuel,
plainDistance,
@ -33,24 +32,23 @@ describe("economic distance (decision 0.5.84)", () => {
});
});
describe("fuel cost", () => {
it("costs more Earth→Moon than Moon→Earth for the same ship", () => {
describe("leg fuel", () => {
it("burns more Earth→Moon than Moon→Earth for the same ship", () => {
const distance = routeDistance(earth, moon);
expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan(
fuelCost(courier, moon, distance, config),
expect(legFuel(courier, earth, distance, config)).toBeGreaterThan(
legFuel(courier, moon, distance, config),
);
});
it("scales with economic distance", () => {
expect(fuelCost(courier, earth, 10, config) / fuelCost(courier, earth, 5, config)).toBeCloseTo(
expect(legFuel(courier, earth, 10, config) / legFuel(courier, earth, 5, config)).toBeCloseTo(
2 ** exponent,
);
});
it("matches a hand calculation (Step 15.3: the leg's fuel at the origin's price)", () => {
// 20 fu per M km × economic distance of 0.5M km, at 10 cr/fu × Earth's 1.6.
const expected = 20 * ref * (0.5 / ref) ** exponent * 10 * 1.6;
expect(fuelCost(courier, earth, 0.5, config)).toBeCloseTo(expected);
it("matches a hand calculation", () => {
// 20 fu per M km × economic distance of 0.5M km.
expect(legFuel(courier, earth, 0.5, config)).toBeCloseTo(20 * ref * (0.5 / ref) ** exponent);
});
});

View File

@ -8,6 +8,9 @@ import {
pilotShare,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
describe("loan repayment split", () => {
it("takes min(balance, credits × share), rounded down to whole credits", () => {
expect(splitLoanRepayment(1000, 5000, 0.3)).toEqual({ repayment: 300, net: 700 });
@ -61,7 +64,7 @@ describe("resolution with a loan", () => {
const record = createDispatchRecord(
{
id: "d1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY },
pilot: {
id: "p1",
isAvatar: false,

View File

@ -14,6 +14,9 @@ import {
type SimContent,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const loc = (id: string) => content.location(id)!;
const now = Date.parse("2100-01-01T00:30:00Z");
const pilot: PilotState = {
@ -46,13 +49,18 @@ const fixture: SimContent = {
},
],
};
const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false };
const hopper: ShipState = {
id: "s1",
modelId: "hopper",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const proposal: RepositionProposal = {
ship: hopper,
pilot,
destinationId: "moon",
discoveredIds: new Set(["earth", "moon"]),
credits: 100_000,
now,
};
@ -86,7 +94,7 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => {
});
it("applies to contract dispatches too", () => {
const base = { ship: hopper, pilot, contract, tutorial: false, credits: 100_000, now };
const base = { ship: hopper, pilot, contract, tutorial: false, now };
expect(
validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture),
).toEqual({ ok: false, reason: "would-strand" });

View File

@ -9,6 +9,9 @@ import {
type Contract,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const earth = content.location("earth")!;
const moon = content.location("moon")!;
const periodIndex = 4_000;
@ -37,7 +40,7 @@ function mailContract(priority: boolean): Contract {
function check(modelId: string, contract: Contract) {
return validateDispatch(
{
ship: { id: "s", modelId, locationId: "earth", inFlight: false },
ship: { id: "s", modelId, locationId: "earth", inFlight: false, tank: PLENTY },
pilot: {
id: "p",
isAvatar: true,
@ -48,7 +51,6 @@ function check(modelId: string, contract: Contract) {
},
contract,
tutorial: false,
credits: 1_000_000,
now: offeredAt,
},
content,

View File

@ -13,6 +13,9 @@ import {
type ShipState,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
@ -32,7 +35,13 @@ const contract: Contract = {
expiresAt: now + 3_600_000,
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const ship: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,

View File

@ -8,6 +8,9 @@ import {
type RecordInput,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
@ -29,7 +32,7 @@ const contract: Contract = {
};
const input: RecordInput = {
id: "d1",
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY },
pilot: {
id: "p1",
isAvatar: false,

View File

@ -8,8 +8,17 @@ import {
type ShipState,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const atEarth: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const atEarth: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,
@ -24,7 +33,6 @@ const valid: RepositionProposal = {
pilot,
destinationId: "moon",
discoveredIds: new Set(["earth", "moon"]),
credits: 10_000,
now,
};
@ -58,8 +66,10 @@ describe("deadhead (fly empty)", () => {
);
});
it("still needs fuel credits, an idle crew and an assigned pilot", () => {
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
it("still needs fuel on board, an idle crew and an assigned pilot", () => {
expect(reason({ ...valid, ship: { ...atEarth, tank: { fuelFu: 0, avgPrice: 16 } } })).toBe(
"not-enough-fuel",
);
expect(reason({ ...valid, ship: { ...atEarth, inFlight: true } })).toBe("ship-not-idle");
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(

View File

@ -11,6 +11,9 @@ import {
pilotShare,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const contract: Contract = {
id: "c1",
@ -30,7 +33,13 @@ const contract: Contract = {
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const ship: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,

View File

@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest";
import { buildContract } from "../src/board";
import { createDispatchRecord, type Contract } from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const earth = content.location("earth")!;
const ceres = content.location("ceres-station")!;
const good = content.goods.find(
@ -31,7 +34,13 @@ function offered(periodIndex: number): Contract {
}
function flown(contract: Contract, now: number) {
const ship = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false };
const ship = {
id: "s1",
modelId: "freighter",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const pilot = {
id: "p1",
isAvatar: false,

View File

@ -0,0 +1,168 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
affordableRefuel,
createDispatchRecord,
fuelPriceAt,
legFuel,
legFuelCost,
pilotShare,
refuel,
resolveDispatch,
routeDistance,
type Contract,
type PilotState,
type ResolveInput,
type ShipState,
} from "../src/index";
const { config } = content;
const courier = content.shipModel("courier")!;
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
const now = Date.parse("2100-01-01T00:30:00Z");
const MOON_PRICE = fuelPriceAt(moon, config);
const contract: Contract = {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 10,
payout: 2000,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: Date.parse("2100-01-01T00:00:00Z"),
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
firstContact: false,
};
const pilot: PilotState = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
restEndsAt: null,
};
/** A Courier at Earth with a full tank bought at Earth's price. */
const ship: ShipState = {
id: "s1",
modelId: "courier",
locationId: "earth",
inFlight: false,
tank: { fuelFu: courier.tankCapacityFu, avgPrice: fuelPriceAt(earth, config) },
};
const record = createDispatchRecord(
{ id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 },
content,
);
/** The tank on landing: what was left after the leg's fuel came out at departure. */
const landedTank = { ...ship.tank!, fuelFu: ship.tank!.fuelFu - record.fuelBurnedFu };
function landing(policy: "auto" | "manual", credits: number): ResolveInput {
return {
record,
pilotIsAvatar: false,
discoveredIds: new Set(["earth", "moon"]),
loanBalance: 0,
pilotDutyHours: 0,
pilotSkills: {},
now: record.arrivalAt,
fuel: { tank: landedTank, capacityFu: courier.tankCapacityFu, refuelPolicy: policy, credits },
};
}
function resolve(input: ResolveInput) {
const result = resolveDispatch(input, content);
if (result.status !== "resolved") throw new Error("expected a resolution");
return result.resolution;
}
describe("fuel tanks (Step 15.4)", () => {
it("burns less from the Moon: the leg is shortened by 1.3 before the pay curve (0.5.95)", () => {
const d = routeDistance(earth, moon);
// The CR's ÷ 1.3, applied before the economic-distance curve (user decision).
expect(legFuel(courier, moon, d, config)).toBeCloseTo(legFuel(courier, earth, d / 1.3, config));
expect(legFuel(courier, moon, d, config)).toBeCloseTo(
legFuel(courier, earth, d, config) / 1.3 ** config.economicDistance.exponent,
);
});
it("averages the price of what is in the tank", () => {
const filled = refuel({ fuelFu: 50, avgPrice: 2 }, 200, 50, 1);
expect(filled.tank).toEqual({ fuelFu: 100, avgPrice: 1.5 });
expect(filled.boughtFu).toBe(50);
expect(filled.cost).toBe(50);
});
it("never fills past capacity", () => {
const filled = refuel({ fuelFu: 90, avgPrice: 2 }, 100, 50, 1);
expect(filled.boughtFu).toBe(10);
expect(filled.tank.fuelFu).toBe(100);
});
it("buys exactly what the credits cover, up to a full tank", () => {
// Half a tank's worth of credits at 10 cr/fu.
const half = affordableRefuel({ fuelFu: 0, avgPrice: 0 }, 100, 10, 500);
expect(half.boughtFu).toBe(50);
expect(half.cost).toBe(500);
expect(affordableRefuel({ fuelFu: 0, avgPrice: 0 }, 100, 10, 5_000).boughtFu).toBe(100);
expect(affordableRefuel({ fuelFu: 0, avgPrice: 0 }, 100, 10, 0).boughtFu).toBe(0);
});
it("takes the leg's fuel from the tank and costs it at the tank's average price", () => {
const burned = legFuel(courier, earth, routeDistance(earth, moon), config);
expect(record.fuelBurnedFu).toBeCloseTo(burned);
expect(record.fuelCost).toBe(legFuelCost(burned, ship.tank!));
expect(record.fuelCost).toBe(Math.round(burned * 16));
});
it("pays a hired pilot from fuel burned × average price (decision 0.5.98)", () => {
expect(resolve(landing("manual", 0)).pilotPay).toBe(
pilotShare(contract.payout, record.fuelCost, config),
);
});
it("tops up to a full tank on landing with Auto, paying the local price", () => {
const { topUp } = resolve(landing("auto", 10_000));
expect(topUp).not.toBeNull();
expect(topUp!.locationId).toBe("moon");
expect(topUp!.tank.fuelFu).toBeCloseTo(courier.tankCapacityFu);
expect(topUp!.boughtFu).toBeCloseTo(record.fuelBurnedFu);
expect(topUp!.price).toBe(MOON_PRICE);
expect(topUp!.cost).toBe(Math.round(record.fuelBurnedFu * MOON_PRICE));
});
it("buys nothing with Manual", () => {
expect(resolve(landing("manual", 10_000)).topUp).toBeNull();
});
it("buys only what the credits after this delivery cover", () => {
const delivery = resolve(landing("manual", 0)).credits;
// Credits that, with this delivery's pay, cover half the fuel burned.
const halfCost = (record.fuelBurnedFu / 2) * MOON_PRICE;
const { topUp } = resolve(landing("auto", halfCost - delivery));
expect(topUp!.boughtFu).toBeCloseTo(record.fuelBurnedFu / 2);
expect(topUp!.cost).toBeLessThanOrEqual(Math.floor(halfCost));
});
it("leaves the tutorial loaner's fuel alone", () => {
const loaner: ShipState = {
id: "loaner",
modelId: "company-courier",
locationId: "earth",
inFlight: false,
};
const avatar = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null };
const tutorialRun = createDispatchRecord(
{ id: "t1", ship: loaner, pilot: avatar, contract, tutorial: true, now, worldSeed: 7 },
content,
);
expect(tutorialRun.fuelBurnedFu).toBe(0);
expect(tutorialRun.fuelCost).toBe(0);
expect(resolve({ ...landing("auto", 10_000), record: tutorialRun }).topUp).toBeNull();
});
});

View File

@ -11,10 +11,19 @@ import {
type UpgradeProposal,
} from "../src/index";
/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */
const PLENTY = { fuelFu: 10_000, avgPrice: 10 };
const now = Date.parse("2100-01-01T00:30:00Z");
const freighter = content.shipModel("freighter")!;
const engines = content.upgrade("engines")!;
const ship: ShipState = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false };
const ship: ShipState = {
id: "s1",
modelId: "freighter",
locationId: "earth",
inFlight: false,
tank: PLENTY,
};
const valid: UpgradeProposal = {
ship,
upgrade: engines,
@ -104,7 +113,6 @@ describe("ships in the shop can't fly (Step 10I.3)", () => {
pilot,
contract,
tutorial: false,
credits: 1e6,
now: time,
},
content,

View File

@ -163,6 +163,9 @@ importers:
packages/schema:
dependencies:
'@deadhead/content':
specifier: workspace:*
version: link:../content
'@deadhead/sim':
specifier: workspace:*
version: link:../sim