Step 15.4: sim: tank, burn, and refuel
CI / check (push) Has been cancelled
Details
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:
parent
c627bb9954
commit
276cdbe611
10
DECISIONS.md
10
DECISIONS.md
|
|
@ -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.
|
- **`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.
|
- **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.
|
- **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".
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,11 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
|
const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!;
|
||||||
await expect(logbook(later)).toContainText(`Delivered ${summary.massTonnes} t`);
|
await expect(logbook(later)).toContainText(`Delivered ${summary.massTonnes} t`);
|
||||||
const after = await state(later);
|
const after = await state(later);
|
||||||
// Credits went up by the delivery's net payout; the ship is at the destination.
|
// Credits went up by the delivery's net payout, less any Auto top-up on landing
|
||||||
expect(after.company.credits).toBe(before.company.credits + summary.credits);
|
// (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);
|
expect(after.ships.find((s) => s.id === flight.shipId)!.locationId).toBe(flight.destinationId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import type { Dispatch } from "@deadhead/schema";
|
||||||
import {
|
import {
|
||||||
|
acknowledgeAll,
|
||||||
addCredits,
|
addCredits,
|
||||||
advanceTo,
|
advanceTo,
|
||||||
board,
|
board,
|
||||||
|
|
@ -7,6 +9,7 @@ import {
|
||||||
logbook,
|
logbook,
|
||||||
nextMarsWindow,
|
nextMarsWindow,
|
||||||
playToDeal,
|
playToDeal,
|
||||||
|
run,
|
||||||
serverNow,
|
serverNow,
|
||||||
state,
|
state,
|
||||||
tryDispatch,
|
tryDispatch,
|
||||||
|
|
@ -29,6 +32,19 @@ test.describe("end to end (Phase 12)", () => {
|
||||||
}
|
}
|
||||||
await addCredits(page, 100_000);
|
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).
|
// The board offers a first contact the Clipper can fly (decision 0.5.85).
|
||||||
const offer = (await board(page, "earth")).contracts.find(
|
const offer = (await board(page, "earth")).contracts.find(
|
||||||
(c) => c.firstContact && c.destinationId === "mars",
|
(c) => c.firstContact && c.destinationId === "mars",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export function simShip(ship: Ship): ShipState {
|
||||||
inFlight: ship.status === "in-flight",
|
inFlight: ship.status === "in-flight",
|
||||||
upgrades: ship.upgrades,
|
upgrades: ship.upgrades,
|
||||||
inShopUntil: ship.inShopUntil,
|
inShopUntil: ship.inShopUntil,
|
||||||
|
tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
LONG_HAUL_SKILL_ID,
|
LONG_HAUL_SKILL_ID,
|
||||||
needsRest,
|
needsRest,
|
||||||
realToGame,
|
realToGame,
|
||||||
tripFuelCost,
|
tripFuel,
|
||||||
tripGameHours,
|
tripGameHours,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
wouldStrand,
|
wouldStrand,
|
||||||
|
|
@ -58,7 +58,7 @@ export const INELIGIBLE_REASONS: Record<DispatchFailure, string> = {
|
||||||
"cargo-skill-too-low": "Cargo skill too low",
|
"cargo-skill-too-low": "Cargo skill too low",
|
||||||
"long-haul-too-low": "Long Haul too low",
|
"long-haul-too-low": "Long Haul too low",
|
||||||
"contract-expired": "Contract expired",
|
"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. */
|
/** The no-strand rule in words (CR-012), for the dispatch sheet and deadhead picker. */
|
||||||
|
|
@ -82,7 +82,8 @@ export function explainProblems(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
model: ShipModel,
|
model: ShipModel,
|
||||||
pilot: Pilot,
|
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,
|
gameNow: number,
|
||||||
now: number,
|
now: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|
@ -137,8 +138,10 @@ export function explainProblems(
|
||||||
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
|
problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`);
|
||||||
}
|
}
|
||||||
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
|
if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]);
|
||||||
if (state.company.credits < fuelCost) {
|
if (fuel.onBoardFu < fuel.neededFu) {
|
||||||
problems.push(`Not enough credits for fuel (${formatCredits(fuelCost)})`);
|
problems.push(
|
||||||
|
`Not enough fuel (needs ${Math.ceil(fuel.neededFu)} fu, has ${Math.floor(fuel.onBoardFu)} fu)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
@ -192,12 +195,14 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
contract.destinationId,
|
contract.destinationId,
|
||||||
contract.massTonnes,
|
contract.massTonnes,
|
||||||
);
|
);
|
||||||
const fuelCost = tripFuelCost(
|
const fuelFu = tripFuel(
|
||||||
model,
|
model,
|
||||||
location(contract.originId),
|
location(contract.originId),
|
||||||
location(contract.destinationId),
|
location(contract.destinationId),
|
||||||
content.config,
|
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).
|
// Profit-share pay for a hired pilot; the avatar is exempt (CR-010).
|
||||||
const pilotPay =
|
const pilotPay =
|
||||||
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
|
pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0;
|
||||||
|
|
@ -217,7 +222,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
},
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: state.company.credits,
|
|
||||||
now,
|
now,
|
||||||
discoveredIds: discoveredIdsOf(state),
|
discoveredIds: discoveredIdsOf(state),
|
||||||
},
|
},
|
||||||
|
|
@ -227,7 +231,15 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
||||||
if (check.ok) {
|
if (check.ok) {
|
||||||
problems = [];
|
problems = [];
|
||||||
} else {
|
} 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.
|
// In the shop (CR-009): first, with the time left.
|
||||||
if (isInShop(ship, now))
|
if (isInShop(ship, now))
|
||||||
all.unshift(
|
all.unshift(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, Ship } from "@deadhead/schema";
|
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||||
import {
|
import {
|
||||||
dispatchProgress,
|
dispatchProgress,
|
||||||
tripFuelCost,
|
tripFuel,
|
||||||
validateReposition,
|
validateReposition,
|
||||||
type RepositionFailure,
|
type RepositionFailure,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
|
|
@ -188,7 +188,7 @@ const DEADHEAD_REASONS: Partial<Record<RepositionFailure, string>> = {
|
||||||
"cannot-land": "Can't land there",
|
"cannot-land": "Can't land there",
|
||||||
"out-of-range": "Out of range",
|
"out-of-range": "Out of range",
|
||||||
"would-strand": "Would be stranded",
|
"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",
|
"ship-in-shop": "Ship is in the shop",
|
||||||
"pilot-resting": "Pilot is resting",
|
"pilot-resting": "Pilot is resting",
|
||||||
"pilot-not-idle": "Pilot is flying",
|
"pilot-not-idle": "Pilot is flying",
|
||||||
|
|
@ -226,7 +226,6 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
},
|
},
|
||||||
destinationId: id,
|
destinationId: id,
|
||||||
discoveredIds,
|
discoveredIds,
|
||||||
credits: state.company.credits,
|
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
|
|
@ -238,7 +237,10 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
||||||
: check.reason === "would-strand"
|
: check.reason === "would-strand"
|
||||||
? strandedText(id)
|
? strandedText(id)
|
||||||
: (DEADHEAD_REASONS[check.reason] ?? check.reason),
|
: (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),
|
tripMs: tripRealMs(model, origin, id, 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => {
|
||||||
expect(text).toContain("Build: test");
|
expect(text).toContain("Build: test");
|
||||||
// CR-014: the save, its format version and where it is stored; no player id.
|
// 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: player-1");
|
||||||
expect(text).toContain("Save version: 1");
|
expect(text).toContain("Save version: 2");
|
||||||
expect(text).toContain("Storage: unknown");
|
expect(text).toContain("Storage: unknown");
|
||||||
expect(text).not.toContain("Player:");
|
expect(text).not.toContain("Player:");
|
||||||
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);
|
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ function returningPlayer(acknowledged: boolean): PlayerState {
|
||||||
credits: 538,
|
credits: 538,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const summaries: ResolutionSummary[] = [
|
||||||
credits: 51_200,
|
credits: 51_200,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
@ -48,6 +49,7 @@ const summaries: ResolutionSummary[] = [
|
||||||
credits: 240,
|
credits: 240,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
@ -90,6 +92,7 @@ describe("while you were away", () => {
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
deadhead: true,
|
deadhead: true,
|
||||||
fuelCost: 230,
|
fuelCost: 230,
|
||||||
|
topUp: null,
|
||||||
goodId: null,
|
goodId: null,
|
||||||
massTonnes: null,
|
massTonnes: null,
|
||||||
shipper: null,
|
shipper: null,
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ describe("the deal", () => {
|
||||||
credits: 96,
|
credits: 96,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ describe("developer tools in Settings (Step 10K.11)", () => {
|
||||||
arrivalAt: now + 5 * 60_000,
|
arrivalAt: now + 5 * 60_000,
|
||||||
distanceMillionKm: 0.4,
|
distanceMillionKm: 0.4,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
realToGame,
|
realToGame,
|
||||||
routeDistance,
|
routeDistance,
|
||||||
travelTimeGameHours,
|
travelTimeGameHours,
|
||||||
tripFuelCost,
|
tripFuel,
|
||||||
pilotShare,
|
pilotShare,
|
||||||
} from "@deadhead/sim";
|
} from "@deadhead/sim";
|
||||||
import { screen, waitFor, within } from "@testing-library/react";
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
@ -120,7 +120,8 @@ describe("dispatch sheet", () => {
|
||||||
(travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) *
|
(travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) *
|
||||||
MS_PER_HOUR) /
|
MS_PER_HOUR) /
|
||||||
content.config.timeCompression;
|
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);
|
const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config);
|
||||||
|
|
||||||
expect(within(freighter).getByRole("radio")).toBeEnabled();
|
expect(within(freighter).getByRole("radio")).toBeEnabled();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import { effectiveStats } from "@deadhead/sim";
|
||||||
export function shipExtras(
|
export function shipExtras(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
upgrades: Record<string, number> = {},
|
upgrades: Record<string, number> = {},
|
||||||
): Pick<Ship, "upgrades" | "stats" | "inShopUntil" | "shop"> {
|
): Pick<
|
||||||
|
Ship,
|
||||||
|
"upgrades" | "stats" | "inShopUntil" | "shop" | "fuelFu" | "fuelAvgPrice" | "refuelPolicy"
|
||||||
|
> {
|
||||||
const model = content.shipModel(modelId);
|
const model = content.shipModel(modelId);
|
||||||
if (!model) throw new Error(`Unknown ship model ${modelId}`);
|
if (!model) throw new Error(`Unknown ship model ${modelId}`);
|
||||||
const s = effectiveStats(model, upgrades, content);
|
const s = effectiveStats(model, upgrades, content);
|
||||||
|
|
@ -27,6 +30,10 @@ export function shipExtras(
|
||||||
},
|
},
|
||||||
inShopUntil: null,
|
inShopUntil: null,
|
||||||
shop: null,
|
shop: null,
|
||||||
|
// A full tank bought at Earth (CR-015).
|
||||||
|
fuelFu: s.tankCapacityFu,
|
||||||
|
fuelAvgPrice: 16,
|
||||||
|
refuelPolicy: "auto",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const flight: Dispatch = {
|
||||||
arrivalAt: NOW + 180_000,
|
arrivalAt: NOW + 180_000,
|
||||||
distanceMillionKm: 0.39,
|
distanceMillionKm: 0.39,
|
||||||
fuelCost: 50,
|
fuelCost: 50,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
@ -149,7 +150,14 @@ describe("fleet screen", () => {
|
||||||
const record = createRepositionRecord(
|
const record = createRepositionRecord(
|
||||||
{
|
{
|
||||||
id: "r",
|
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: {
|
pilot: {
|
||||||
id: "avatar",
|
id: "avatar",
|
||||||
isAvatar: true,
|
isAvatar: true,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
|
||||||
arrivalAt,
|
arrivalAt,
|
||||||
distanceMillionKm: 0.39,
|
distanceMillionKm: 0.39,
|
||||||
fuelCost: 50,
|
fuelCost: 50,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
@ -149,6 +150,7 @@ describe("loan on HQ", () => {
|
||||||
credits: 538,
|
credits: 538,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const flight = (id: string, shipId: string, tutorial = false): Dispatch => ({
|
||||||
arrivalAt: NOW + 60_000,
|
arrivalAt: NOW + 60_000,
|
||||||
distanceMillionKm: 0.4,
|
distanceMillionKm: 0.4,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const flight: Dispatch = {
|
||||||
arrivalAt: NOW + 5 * 60_000,
|
arrivalAt: NOW + 5 * 60_000,
|
||||||
distanceMillionKm: 100,
|
distanceMillionKm: 100,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => {
|
||||||
arrivalAt: now + 60_000,
|
arrivalAt: now + 60_000,
|
||||||
distanceMillionKm: 0.4,
|
distanceMillionKm: 0.4,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const flight: Dispatch = {
|
||||||
arrivalAt: NOW + 3_600_000,
|
arrivalAt: NOW + 3_600_000,
|
||||||
distanceMillionKm: 150,
|
distanceMillionKm: 150,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 0,
|
expectedPayout: 0,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
|
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 { screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
@ -65,6 +65,7 @@ const summary: ResolutionSummary = {
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 150,
|
fuelCost: 150,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 10,
|
massTonnes: 10,
|
||||||
shipper: "Pacific Orbital Logistics",
|
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" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
const fuel = tripFuelCost(
|
// The fixtures' tanks were filled at 16 cr/fu (CR-015).
|
||||||
content.shipModel("courier")!,
|
const fuel = Math.round(
|
||||||
content.location("earth")!,
|
tripFuel(
|
||||||
content.location("moon")!,
|
content.shipModel("courier")!,
|
||||||
content.config,
|
content.location("earth")!,
|
||||||
|
content.location("moon")!,
|
||||||
|
content.config,
|
||||||
|
) * 16,
|
||||||
);
|
);
|
||||||
const share = pilotShare(job.payout, fuel, content.config);
|
const share = pilotShare(job.payout, fuel, content.config);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ function inFlight(arrivalAt: number): Dispatch {
|
||||||
arrivalAt,
|
arrivalAt,
|
||||||
distanceMillionKm: 0.39,
|
distanceMillionKm: 0.39,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
fuelBurnedFu: 0,
|
||||||
expectedPayout: 240,
|
expectedPayout: 240,
|
||||||
seed: 1,
|
seed: 1,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const summary = (extra: Partial<ResolutionSummary>): ResolutionSummary => ({
|
||||||
credits: 500,
|
credits: 500,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ describe("scripted delivery summary", () => {
|
||||||
credits: 461,
|
credits: 461,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format
|
||||||
| Package | Role | Depends on |
|
| 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/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/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 |
|
| `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 |
|
| `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)
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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.5 — Sim: stranding and emergency fuel
|
||||||
- [ ] Step 15.6 — Engine: commands and save migration
|
- [ ] Step 15.6 — Engine: commands and save migration
|
||||||
- [ ] Step 15.7 — Client: fuel in the UI
|
- [ ] Step 15.7 — Client: fuel in the UI
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,14 @@ export const gameConfig: GameConfig = {
|
||||||
loanRepaymentShare: 0.25,
|
loanRepaymentShare: 0.25,
|
||||||
// The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the
|
// 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.
|
// 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,
|
contractBoardPeriodRealMinutes: 10,
|
||||||
// Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips.
|
// Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips.
|
||||||
boardRetentionPeriods: 24,
|
boardRetentionPeriods: 24,
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,8 @@ export const gameConfigSchema = z
|
||||||
avatarShipModelId: z.string().min(1),
|
avatarShipModelId: z.string().min(1),
|
||||||
veteranShipModelId: z.string().min(1),
|
veteranShipModelId: z.string().min(1),
|
||||||
bayLocationId: 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(),
|
contractBoardPeriodRealMinutes: z.number().positive(),
|
||||||
/** Past board periods whose boards and accepted contracts are kept (Step 7.3). */
|
/** Past board periods whose boards and accepted contracts are kept (Step 7.3). */
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ describe("game config", () => {
|
||||||
avatarShipModelId: "courier",
|
avatarShipModelId: "courier",
|
||||||
veteranShipModelId: "clipper",
|
veteranShipModelId: "clipper",
|
||||||
bayLocationId: "earth",
|
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);
|
expect(content.dealLoanAmount).toBe(price("courier") + price("clipper") + earth.bayLeasePrice);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,12 @@ const validGameConfig = {
|
||||||
baseFuelPricePerFu: 10,
|
baseFuelPricePerFu: 10,
|
||||||
emergencyFuelMultiplier: 2.5,
|
emergencyFuelMultiplier: 2.5,
|
||||||
economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 },
|
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 = {
|
const validScripted = {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ export function commandTable(): CommandTable {
|
||||||
break;
|
break;
|
||||||
case "reset-upgrades":
|
case "reset-upgrades":
|
||||||
ship.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;
|
break;
|
||||||
case "finish-shop":
|
case "finish-shop":
|
||||||
// Done now: the "out of the shop" note still shows, as for a finished job.
|
// Done now: the "out of the shop" note still shows, as for a finished job.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { RealTime } from "@deadhead/sim";
|
||||||
import type { RandomSource } from "../core";
|
import type { RandomSource } from "../core";
|
||||||
import { fail } from "./errors";
|
import { fail } from "./errors";
|
||||||
import { companyName, shipName } from "./names";
|
import { companyName, shipName } from "./names";
|
||||||
import { companyView, isDealAvailable } from "./state";
|
import { companyView, deliveredTank, isDealAvailable } from "./state";
|
||||||
|
|
||||||
export const AVATAR_NAME = "Captain";
|
export const AVATAR_NAME = "Captain";
|
||||||
|
|
||||||
|
|
@ -121,6 +121,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastArrivalAt: 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 });
|
save.bays.push({ id: random.id(), locationId: bayLocationId, createdAt: now });
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ function claimableContract(
|
||||||
throw fail("unknown-contract", "No such contract on your boards.");
|
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(
|
function storeDispatch(
|
||||||
save: SaveDocument,
|
save: SaveDocument,
|
||||||
args: {
|
args: {
|
||||||
|
|
@ -75,7 +75,7 @@ function storeDispatch(
|
||||||
acceptedAt: now,
|
acceptedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
save.company.credits -= record.fuelCost;
|
if (shipId !== null) requireShip(save, shipId).fuelFu -= record.fuelBurnedFu;
|
||||||
return toApiDispatch(dispatch);
|
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
|
* The avatar flies the company loaner on a tutorial contract (Step 6.4). It
|
||||||
* company pays fuel, and only one tutorial run may be in flight.
|
* uses no fuel (decision 0.5.94), and only one tutorial run may be in flight.
|
||||||
*/
|
*/
|
||||||
export function dispatchTutorial(
|
export function dispatchTutorial(
|
||||||
save: SaveDocument,
|
save: SaveDocument,
|
||||||
|
|
@ -112,10 +112,7 @@ export function dispatchTutorial(
|
||||||
inFlight: false,
|
inFlight: false,
|
||||||
};
|
};
|
||||||
const pilot = pilotState(avatar);
|
const pilot = pilotState(avatar);
|
||||||
const check = validateDispatch(
|
const check = validateDispatch({ ship: loaner, pilot, contract, tutorial: true, now }, content);
|
||||||
{ ship: loaner, pilot, contract, tutorial: true, credits: save.company.credits, now },
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]);
|
if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]);
|
||||||
|
|
||||||
const record = createDispatchRecord(
|
const record = createDispatchRecord(
|
||||||
|
|
@ -156,7 +153,6 @@ export function dispatchContract(
|
||||||
// The no-strand rule counts only discovered locations (decision 0.5.79).
|
// The no-strand rule counts only discovered locations (decision 0.5.79).
|
||||||
discoveredIds: discoveredIds(save),
|
discoveredIds: discoveredIds(save),
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: save.company.credits,
|
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
const check = validateDispatch(proposal, content);
|
const check = validateDispatch(proposal, content);
|
||||||
|
|
@ -185,7 +181,6 @@ export function dispatchReposition(
|
||||||
pilot: pilotState(pilot),
|
pilot: pilotState(pilot),
|
||||||
destinationId: input.destinationId,
|
destinationId: input.destinationId,
|
||||||
discoveredIds: discoveredIds(save),
|
discoveredIds: discoveredIds(save),
|
||||||
credits: save.company.credits,
|
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
const check = validateReposition(proposal, content);
|
const check = validateReposition(proposal, content);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { fail } from "./errors";
|
||||||
import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages";
|
import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages";
|
||||||
import { pilotName, shipName } from "./names";
|
import { pilotName, shipName } from "./names";
|
||||||
import {
|
import {
|
||||||
|
deliveredTank,
|
||||||
hasBayAt,
|
hasBayAt,
|
||||||
requirePilot,
|
requirePilot,
|
||||||
requireShip,
|
requireShip,
|
||||||
|
|
@ -71,6 +72,8 @@ export function buyShip(
|
||||||
fromDeal: false,
|
fromDeal: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastArrivalAt: 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);
|
save.ships.push(ship);
|
||||||
return toApiShip(save, ship, now);
|
return toApiShip(save, ship, now);
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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.",
|
"long-haul-too-low": "The pilot's Long Haul skill is too low for this distance.",
|
||||||
"contract-expired": "This contract has expired.",
|
"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.",
|
"already-there": "The ship is already there.",
|
||||||
"location-undiscovered": "You can only fly empty to places you have discovered.",
|
"location-undiscovered": "You can only fly empty to places you have discovered.",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { content } from "@deadhead/content";
|
import { content } from "@deadhead/content";
|
||||||
import type { SaveDocument } from "@deadhead/schema";
|
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";
|
import { discoveredIds } from "./state";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves every in-flight dispatch that has arrived by `now`, in arrival
|
* 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.
|
* duty and rest, ship and pilot locations, tutorial progress and discovery.
|
||||||
* A resolved dispatch is never resolved again, so running this twice in a
|
* A resolved dispatch is never resolved again, so running this twice in a
|
||||||
* row changes nothing the second time. Returns how many were resolved.
|
* 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) {
|
for (const dispatch of due) {
|
||||||
const pilot = save.pilots.find((p) => p.id === dispatch.pilotId);
|
const pilot = save.pilots.find((p) => p.id === dispatch.pilotId);
|
||||||
if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`);
|
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(
|
const result = resolveDispatch(
|
||||||
{
|
{
|
||||||
record: dispatch.record,
|
record: dispatch.record,
|
||||||
|
|
@ -31,6 +34,15 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
|
||||||
pilotDutyHours: pilot.dutyHours,
|
pilotDutyHours: pilot.dutyHours,
|
||||||
pilotSkills: pilot.skills,
|
pilotSkills: pilot.skills,
|
||||||
now,
|
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,
|
content,
|
||||||
);
|
);
|
||||||
|
|
@ -41,6 +53,12 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
|
||||||
Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now });
|
Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now });
|
||||||
save.company.credits += r.credits;
|
save.company.credits += r.credits;
|
||||||
save.company.loanBalance -= r.loanRepayment;
|
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.xp += r.pilotXp;
|
||||||
pilot.status = "idle";
|
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.
|
// Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar.
|
||||||
if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId;
|
if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId;
|
||||||
|
|
||||||
const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined;
|
|
||||||
if (ship) {
|
if (ship) {
|
||||||
ship.status = "idle";
|
ship.status = "idle";
|
||||||
ship.locationId = r.shipLocationId;
|
ship.locationId = r.shipLocationId;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
completedGoals,
|
completedGoals,
|
||||||
dutyLimit,
|
dutyLimit,
|
||||||
effectiveStats,
|
effectiveStats,
|
||||||
|
fuelPriceAt,
|
||||||
isInShop,
|
isInShop,
|
||||||
isResting,
|
isResting,
|
||||||
pilotLevel,
|
pilotLevel,
|
||||||
|
|
@ -74,6 +75,23 @@ export function spend(save: SaveDocument, amount: number, what: string): void {
|
||||||
save.company.credits -= amount;
|
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 {
|
export function shipState(ship: SavedShip): ShipState {
|
||||||
return {
|
return {
|
||||||
id: ship.id,
|
id: ship.id,
|
||||||
|
|
@ -83,6 +101,7 @@ export function shipState(ship: SavedShip): ShipState {
|
||||||
inFlight: ship.status === "in-flight",
|
inFlight: ship.status === "in-flight",
|
||||||
upgrades: ship.upgrades,
|
upgrades: ship.upgrades,
|
||||||
inShopUntil: ship.inShopUntil,
|
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
|
ship.shopUpgradeId && ship.shopLevel !== null && ship.inShopUntil !== null
|
||||||
? { upgradeId: ship.shopUpgradeId, level: ship.shopLevel, until: ship.inShopUntil }
|
? { upgradeId: ship.shopUpgradeId, level: ship.shopLevel, until: ship.inShopUntil }
|
||||||
: null,
|
: 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,
|
tutorial: d.tutorial,
|
||||||
deadhead: d.record.contract === null,
|
deadhead: d.record.contract === null,
|
||||||
fuelCost: d.record.fuelCost,
|
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,
|
goodId: d.record.contract?.goodId ?? null,
|
||||||
massTonnes: d.record.contract?.massTonnes ?? null,
|
massTonnes: d.record.contract?.massTonnes ?? null,
|
||||||
shipper: d.record.contract?.shipper ?? null,
|
shipper: d.record.contract?.shipper ?? null,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -67,7 +67,11 @@ describe("concurrency and money safety", () => {
|
||||||
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]);
|
||||||
const state = await getState(app, guest);
|
const state = await getState(app, guest);
|
||||||
expect(state.activeDispatches).toHaveLength(1);
|
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 () => {
|
it("leases one bay when the same lease is sent twice at once", async () => {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ async function expectRejected(
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("contract dispatch", () => {
|
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 { app, clock } = testApp();
|
||||||
const o = await owner();
|
const o = await owner();
|
||||||
const contract = testContract();
|
const contract = testContract();
|
||||||
|
|
@ -87,10 +87,14 @@ describe("contract dispatch", () => {
|
||||||
expectedPayout: contract.payout,
|
expectedPayout: contract.payout,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
});
|
});
|
||||||
|
expect(record.fuelBurnedFu).toBeGreaterThan(0);
|
||||||
expect(record.fuelCost).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);
|
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.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id });
|
||||||
expect(state.pilots[0]!.status).toBe("in-flight");
|
expect(state.pilots[0]!.status).toBe("in-flight");
|
||||||
const board = contractBoardResponseSchema.parse(
|
const board = contractBoardResponseSchema.parse(
|
||||||
|
|
@ -186,13 +190,15 @@ describe("contract dispatch", () => {
|
||||||
await expectRejected(o, await dispatch(o, contract.id), "contract-expired");
|
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 o = await owner();
|
||||||
const contract = testContract();
|
const contract = testContract();
|
||||||
await putBoard(testApp().app, [contract]);
|
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);
|
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([]);
|
expect((await readSave(testApp().app)).dispatches).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,13 @@ describe("location contract boards", () => {
|
||||||
(contract) =>
|
(contract) =>
|
||||||
validateDispatch(
|
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: {
|
pilot: {
|
||||||
id: owner.avatarId,
|
id: owner.avatarId,
|
||||||
isAvatar: true,
|
isAvatar: true,
|
||||||
|
|
@ -102,7 +108,6 @@ describe("location contract boards", () => {
|
||||||
},
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: 1_000_000,
|
|
||||||
now: clock.now,
|
now: clock.now,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,19 @@ describe("deadhead dispatch", () => {
|
||||||
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
|
expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]);
|
||||||
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
|
expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" });
|
||||||
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 });
|
expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 });
|
||||||
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
|
// Fuel came out of the tank; landing on Auto top-up bought it back at the Moon (CR-015).
|
||||||
// The logbook marks it as a deadhead with its fuel (no payout to show).
|
const [summary] = state.summaries;
|
||||||
expect(state.summaries).toHaveLength(1);
|
expect(state.summaries).toHaveLength(1);
|
||||||
expect(state.summaries[0]).toMatchObject({
|
expect(summary).toMatchObject({
|
||||||
deadhead: true,
|
deadhead: true,
|
||||||
fuelCost: dispatch.fuelCost,
|
fuelCost: dispatch.fuelCost,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
pilotXp: 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 () => {
|
it("refuses an undiscovered destination and changes nothing", async () => {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
SaveStorage,
|
SaveStorage,
|
||||||
type CommandName,
|
type CommandName,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
import { deliveredTank } from "../src/game/state";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helpers for the ported server tests (CR-014 Step 14.5): the same names and
|
* Helpers for the ported server tests (CR-014 Step 14.5): the same names and
|
||||||
|
|
@ -305,10 +306,11 @@ export function makeOwner(
|
||||||
inShopUntil: null,
|
inShopUntil: null,
|
||||||
shopUpgradeId: null,
|
shopUpgradeId: null,
|
||||||
shopLevel: 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,
|
fromDeal: true,
|
||||||
createdAt: TEST_NOW,
|
createdAt: TEST_NOW,
|
||||||
lastArrivalAt: TEST_NOW,
|
lastArrivalAt: TEST_NOW,
|
||||||
|
...deliveredTank("courier", "earth"),
|
||||||
});
|
});
|
||||||
Object.assign(
|
Object.assign(
|
||||||
save.pilots.find((p) => p.id === avatarId)!,
|
save.pilots.find((p) => p.id === avatarId)!,
|
||||||
|
|
@ -370,6 +372,8 @@ export function addShip(
|
||||||
fromDeal: false,
|
fromDeal: false,
|
||||||
createdAt: TEST_NOW,
|
createdAt: TEST_NOW,
|
||||||
lastArrivalAt: TEST_NOW,
|
lastArrivalAt: TEST_NOW,
|
||||||
|
// A full tank, bought where the ship is (decision 0.5.94).
|
||||||
|
...deliveredTank(ship.modelId, ship.locationId ?? "earth"),
|
||||||
...ship,
|
...ship,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@deadhead/content": "workspace:*",
|
||||||
"@deadhead/sim": "workspace:*",
|
"@deadhead/sim": "workspace:*",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ export const shipSchema = z.object({
|
||||||
shop: z
|
shop: z
|
||||||
.object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema })
|
.object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema })
|
||||||
.nullable(),
|
.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>;
|
export type Ship = z.infer<typeof shipSchema>;
|
||||||
|
|
||||||
|
|
@ -143,6 +148,8 @@ export const dispatchSchema = z.object({
|
||||||
departureAt: timeSchema,
|
departureAt: timeSchema,
|
||||||
arrivalAt: timeSchema,
|
arrivalAt: timeSchema,
|
||||||
distanceMillionKm: z.number().nonnegative(),
|
distanceMillionKm: z.number().nonnegative(),
|
||||||
|
/** Fuel taken from the tank at departure, in fu (CR-015). */
|
||||||
|
fuelBurnedFu: z.number().nonnegative(),
|
||||||
fuelCost: credits.nonnegative(),
|
fuelCost: credits.nonnegative(),
|
||||||
expectedPayout: credits,
|
expectedPayout: credits,
|
||||||
seed: z.number().int().nonnegative(),
|
seed: z.number().int().nonnegative(),
|
||||||
|
|
@ -161,8 +168,12 @@ export const resolutionSummarySchema = z.object({
|
||||||
tutorial: z.boolean(),
|
tutorial: z.boolean(),
|
||||||
/** An empty reposition flight: no cargo, no pay (CR-002). */
|
/** An empty reposition flight: no cargo, no pay (CR-002). */
|
||||||
deadhead: z.boolean(),
|
deadhead: z.boolean(),
|
||||||
/** Fuel paid at departure. */
|
/** The fuel used, at the tank's average price (decision 0.5.98). */
|
||||||
fuelCost: credits.nonnegative(),
|
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. */
|
/** What was delivered and for whom (CR-007); null for a deadhead. */
|
||||||
goodId: idSchema.nullable(),
|
goodId: idSchema.nullable(),
|
||||||
massTonnes: z.number().positive().nullable(),
|
massTonnes: z.number().positive().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -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 { z } from "zod";
|
||||||
import { contractSchema, idSchema, timeSchema } from "./entities";
|
import { contractSchema, idSchema, timeSchema } from "./entities";
|
||||||
|
|
||||||
|
|
@ -23,14 +30,32 @@ export const dispatchRecordSchema = z.object({
|
||||||
departureAt: timeSchema,
|
departureAt: timeSchema,
|
||||||
arrivalAt: timeSchema,
|
arrivalAt: timeSchema,
|
||||||
distanceMillionKm: z.number().nonnegative(),
|
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(),
|
fuelCost: credits.nonnegative(),
|
||||||
expectedPayout: credits,
|
expectedPayout: credits,
|
||||||
seed: z.number().int().nonnegative(),
|
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. */
|
/** What resolving a dispatch did, kept for the "while you were away" summary. */
|
||||||
export const resolutionSchema = z.object({
|
export const resolutionSchema = z.object({
|
||||||
credits,
|
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(),
|
pilotPay: credits.nonnegative(),
|
||||||
loanRepayment: credits.nonnegative(),
|
loanRepayment: credits.nonnegative(),
|
||||||
pilotXp: z.number().int().nonnegative(),
|
pilotXp: z.number().int().nonnegative(),
|
||||||
|
|
@ -59,6 +84,12 @@ export const savedShipSchema = z.object({
|
||||||
createdAt: timeSchema,
|
createdAt: timeSchema,
|
||||||
/** Its latest resolved arrival (the delivery time if none): picks the default Contracts location. */
|
/** Its latest resolved arrival (the delivery time if none): picks the default Contracts location. */
|
||||||
lastArrivalAt: timeSchema,
|
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({
|
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. */
|
/** 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({
|
export const saveDocumentSchema = z.object({
|
||||||
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
||||||
|
|
@ -167,12 +198,42 @@ export interface SaveMigration {
|
||||||
migrate(save: Record<string, unknown>): Record<string, unknown>;
|
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
|
* Version 1 → 2 (CR-015 §5, fuel tanks): every ship gets a full tank, bought at
|
||||||
* there is nothing before it yet; a format change adds `{ from: 1, … }` here
|
* the price where it sits (the base price while in flight), on Auto top-up.
|
||||||
* and bumps `CURRENT_SAVE_VERSION`.
|
* 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 };
|
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
|
||||||
type Stepped = { ok: true; save: Record<string, unknown> } | { ok: false; error: string };
|
type Stepped = { ok: true; save: Record<string, unknown> } | { ok: false; error: string };
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,13 @@ export function fullSave(): SaveDocument {
|
||||||
departureAt: T,
|
departureAt: T,
|
||||||
arrivalAt,
|
arrivalAt,
|
||||||
distanceMillionKm: 0.38,
|
distanceMillionKm: 0.38,
|
||||||
|
fuelBurnedFu: 7.6,
|
||||||
fuelCost: 160,
|
fuelCost: 160,
|
||||||
expectedPayout: 1500,
|
expectedPayout: 1500,
|
||||||
seed: 42,
|
seed: 42,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
saveVersion: 1,
|
saveVersion: 2,
|
||||||
saveId: "save-1",
|
saveId: "save-1",
|
||||||
lastTimeSeen: T + 3_600_000,
|
lastTimeSeen: T + 3_600_000,
|
||||||
player: {
|
player: {
|
||||||
|
|
@ -70,6 +71,9 @@ export function fullSave(): SaveDocument {
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: T,
|
createdAt: T,
|
||||||
lastArrivalAt: T + 200_000,
|
lastArrivalAt: T + 200_000,
|
||||||
|
fuelFu: 52.4,
|
||||||
|
fuelAvgPrice: 15.2,
|
||||||
|
refuelPolicy: "auto",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "s2",
|
id: "s2",
|
||||||
|
|
@ -84,6 +88,9 @@ export function fullSave(): SaveDocument {
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: T,
|
createdAt: T,
|
||||||
lastArrivalAt: T,
|
lastArrivalAt: T,
|
||||||
|
fuelFu: 1170,
|
||||||
|
fuelAvgPrice: 16,
|
||||||
|
refuelPolicy: "manual",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pilots: [
|
pilots: [
|
||||||
|
|
@ -157,6 +164,13 @@ export function fullSave(): SaveDocument {
|
||||||
status: "resolved",
|
status: "resolved",
|
||||||
resolution: {
|
resolution: {
|
||||||
credits: 1000,
|
credits: 1000,
|
||||||
|
topUp: {
|
||||||
|
locationId: "moon",
|
||||||
|
boughtFu: 7.6,
|
||||||
|
price: 11,
|
||||||
|
cost: 84,
|
||||||
|
tank: { fuelFu: 40, avgPrice: 11 },
|
||||||
|
},
|
||||||
pilotPay: 0,
|
pilotPay: 0,
|
||||||
loanRepayment: 340,
|
loanRepayment: 340,
|
||||||
pilotXp: 10,
|
pilotXp: 10,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
|
||||||
describe("save document (Step 14.2)", () => {
|
describe("save document (Step 14.2)", () => {
|
||||||
it("validates a fully populated save", () => {
|
it("validates a fully populated save", () => {
|
||||||
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
|
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", () => {
|
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");
|
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())));
|
const result = migrateSave(JSON.parse(JSON.stringify(fullSave())));
|
||||||
expect(result).toEqual({ ok: true, save: 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ const ship = {
|
||||||
},
|
},
|
||||||
inShopUntil: null,
|
inShopUntil: null,
|
||||||
shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 },
|
shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 },
|
||||||
|
fuelFu: 32.3,
|
||||||
|
fuelAvgPrice: 15.2,
|
||||||
|
refuelPolicy: "auto",
|
||||||
};
|
};
|
||||||
const pilot = {
|
const pilot = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
|
@ -118,6 +121,7 @@ const dispatch = {
|
||||||
departureAt: 4_000_000,
|
departureAt: 4_000_000,
|
||||||
arrivalAt: 4_130_000,
|
arrivalAt: 4_130_000,
|
||||||
distanceMillionKm: 0.39,
|
distanceMillionKm: 0.39,
|
||||||
|
fuelBurnedFu: 7.8,
|
||||||
fuelCost: 234,
|
fuelCost: 234,
|
||||||
expectedPayout: 480,
|
expectedPayout: 480,
|
||||||
seed: 123456,
|
seed: 123456,
|
||||||
|
|
@ -134,6 +138,7 @@ const summary = {
|
||||||
credits: 240,
|
credits: 240,
|
||||||
deadhead: false,
|
deadhead: false,
|
||||||
fuelCost: 0,
|
fuelCost: 0,
|
||||||
|
topUp: null,
|
||||||
goodId: "consumer-goods",
|
goodId: "consumer-goods",
|
||||||
massTonnes: 12,
|
massTonnes: 12,
|
||||||
shipper: "Pacific Orbital Logistics",
|
shipper: "Pacific Orbital Logistics",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@
|
||||||
*/
|
*/
|
||||||
import { content as defaultContent, type ShipModel } from "@deadhead/content";
|
import { content as defaultContent, type ShipModel } from "@deadhead/content";
|
||||||
import {
|
import {
|
||||||
|
affordableRefuel,
|
||||||
boardPeriodIndex,
|
boardPeriodIndex,
|
||||||
|
fuelPriceAt,
|
||||||
|
type Tank,
|
||||||
fullTankReach,
|
fullTankReach,
|
||||||
boardPeriodMs,
|
boardPeriodMs,
|
||||||
checkRoute,
|
checkRoute,
|
||||||
|
|
@ -31,7 +34,7 @@ import {
|
||||||
resolveDispatch,
|
resolveDispatch,
|
||||||
restEndsAt,
|
restEndsAt,
|
||||||
skillPointsAvailable,
|
skillPointsAvailable,
|
||||||
tripFuelCost,
|
tripFuel,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
validateReposition,
|
validateReposition,
|
||||||
validateUpgrade,
|
validateUpgrade,
|
||||||
|
|
@ -105,6 +108,8 @@ export interface SimulationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimShip extends ShipState {
|
interface SimShip extends ShipState {
|
||||||
|
/** Every simulated ship has a tank, on Auto top-up (decision 0.5.97). */
|
||||||
|
tank: Tank;
|
||||||
upgrades: UpgradeLevels;
|
upgrades: UpgradeLevels;
|
||||||
inShopUntil: RealTime | null;
|
inShopUntil: RealTime | null;
|
||||||
fromDeal: boolean;
|
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). */
|
/** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */
|
||||||
function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number {
|
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 pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config);
|
||||||
const tripMs =
|
const tripMs =
|
||||||
(contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression;
|
(contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression;
|
||||||
return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE);
|
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. */
|
/** A ship as it would be with a full tank (for "is there any way out" checks). */
|
||||||
function eligibleContracts(ship: SimShip, pilot: SimPilot, budget = credits): Contract[] {
|
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 [];
|
if (!discovered.has(ship.locationId)) return [];
|
||||||
return board(ship.locationId).filter(
|
return board(ship.locationId).filter(
|
||||||
(contract) =>
|
(contract) =>
|
||||||
validateDispatch(
|
validateDispatch(
|
||||||
{
|
{
|
||||||
ship,
|
ship: fullTank ? filled(ship) : ship,
|
||||||
pilot: pilotState(pilot),
|
pilot: pilotState(pilot),
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: budget,
|
|
||||||
now,
|
now,
|
||||||
discoveredIds: discovered,
|
discoveredIds: discovered,
|
||||||
},
|
},
|
||||||
|
|
@ -288,7 +300,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
);
|
);
|
||||||
if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id);
|
if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id);
|
||||||
accepted.add(contract.id);
|
accepted.add(contract.id);
|
||||||
credits -= record.fuelCost;
|
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
|
||||||
log(
|
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}`,
|
`${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) {
|
while (flights.length > 0 && flights[0]!.record.arrivalAt <= now) {
|
||||||
const flight = flights.shift()!;
|
const flight = flights.shift()!;
|
||||||
const pilot = pilots.find((p) => p.id === flight.pilotId)!;
|
const pilot = pilots.find((p) => p.id === flight.pilotId)!;
|
||||||
|
const flightShip = flight.shipId ? ships.find((s) => s.id === flight.shipId)! : undefined;
|
||||||
const result = resolveDispatch(
|
const result = resolveDispatch(
|
||||||
{
|
{
|
||||||
record: flight.record,
|
record: flight.record,
|
||||||
|
|
@ -313,6 +326,15 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
pilotDutyHours: pilot.dutyHours,
|
pilotDutyHours: pilot.dutyHours,
|
||||||
pilotSkills: pilot.skills,
|
pilotSkills: pilot.skills,
|
||||||
now,
|
now,
|
||||||
|
fuel: flightShip
|
||||||
|
? {
|
||||||
|
tank: flightShip.tank,
|
||||||
|
capacityFu: effectiveStats(model(flightShip.modelId), flightShip.upgrades, content)
|
||||||
|
.tankCapacityFu,
|
||||||
|
refuelPolicy: "auto",
|
||||||
|
credits,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
|
|
@ -320,6 +342,10 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
const r = result.resolution;
|
const r = result.resolution;
|
||||||
credits += r.credits;
|
credits += r.credits;
|
||||||
loanBalance -= r.loanRepayment;
|
loanBalance -= r.loanRepayment;
|
||||||
|
if (r.topUp && flightShip) {
|
||||||
|
credits -= r.topUp.cost;
|
||||||
|
flightShip.tank = r.topUp.tank;
|
||||||
|
}
|
||||||
pilot.xp += r.pilotXp;
|
pilot.xp += r.pilotXp;
|
||||||
pilot.inFlight = false;
|
pilot.inFlight = false;
|
||||||
pilot.dutyHours = r.pilotDutyHours;
|
pilot.dutyHours = r.pilotDutyHours;
|
||||||
|
|
@ -372,12 +398,12 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
inFlight: false,
|
inFlight: false,
|
||||||
};
|
};
|
||||||
const avatar = pilots[0]!;
|
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
|
const best = contracts
|
||||||
.filter(
|
.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
validateDispatch(
|
validateDispatch(
|
||||||
{ ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, credits, now },
|
{ ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, now },
|
||||||
content,
|
content,
|
||||||
).ok,
|
).ok,
|
||||||
)
|
)
|
||||||
|
|
@ -435,6 +461,11 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
modelId,
|
modelId,
|
||||||
locationId,
|
locationId,
|
||||||
inFlight: false,
|
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: {},
|
upgrades: {},
|
||||||
inShopUntil: null,
|
inShopUntil: null,
|
||||||
fromDeal,
|
fromDeal,
|
||||||
|
|
@ -553,6 +584,7 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue;
|
if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue;
|
||||||
if (ship.inShopUntil !== null && ship.inShopUntil > now) continue;
|
if (ship.inShopUntil !== null && ship.inShopUntil > now) continue;
|
||||||
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
|
const stats = effectiveStats(model(ship.modelId), ship.upgrades, content);
|
||||||
|
topUpHere(ship, stats.tankCapacityFu);
|
||||||
const choices = eligibleContracts(ship, pilot)
|
const choices = eligibleContracts(ship, pilot)
|
||||||
.map((c) => ({ c, value: rate(c, stats, pilot) }))
|
.map((c) => ({ c, value: rate(c, stats, pilot) }))
|
||||||
.filter((o) => o.value > 0)
|
.filter((o) => o.value > 0)
|
||||||
|
|
@ -562,25 +594,24 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
acted = true;
|
acted = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const canDeadhead = (to: string, budget: number) =>
|
const canDeadhead = (to: string, fullTank: boolean) =>
|
||||||
to !== ship.locationId &&
|
to !== ship.locationId &&
|
||||||
validateReposition(
|
validateReposition(
|
||||||
{
|
{
|
||||||
ship,
|
ship: fullTank ? filled(ship) : ship,
|
||||||
pilot: pilotState(pilot),
|
pilot: pilotState(pilot),
|
||||||
destinationId: to,
|
destinationId: to,
|
||||||
discoveredIds: discovered,
|
discoveredIds: discovered,
|
||||||
credits: budget,
|
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
).ok;
|
).ok;
|
||||||
const deadheads = [...discovered].filter((to) => canDeadhead(to, credits));
|
const deadheads = [...discovered].filter((to) => canDeadhead(to, false));
|
||||||
// A dead end is no work and no way out whatever the balance (CR-002);
|
// A dead end is no work and no way out even on a full tank (CR-002);
|
||||||
// running short of cash is the soft-lock check's business.
|
// running short of cash for fuel is the soft-lock check's business.
|
||||||
if (
|
if (
|
||||||
eligibleContracts(ship, pilot, Infinity).length === 0 &&
|
eligibleContracts(ship, pilot, true).length === 0 &&
|
||||||
![...discovered].some((to) => canDeadhead(to, Infinity))
|
![...discovered].some((to) => canDeadhead(to, true))
|
||||||
) {
|
) {
|
||||||
violate("idle-ship-with-nowhere-to-go", `${ship.id} ${ship.modelId} at ${ship.locationId}`);
|
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,
|
content,
|
||||||
);
|
);
|
||||||
credits -= record.fuelCost;
|
ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu };
|
||||||
ship.inFlight = true;
|
ship.inFlight = true;
|
||||||
pilot.inFlight = true;
|
pilot.inFlight = true;
|
||||||
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
|
flights.push({ record, shipId: ship.id, pilotId: pilot.id });
|
||||||
|
|
@ -626,6 +657,16 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
return acted;
|
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() {
|
function checkCashflow() {
|
||||||
if (tutorialActive || flights.length > 0) return;
|
if (tutorialActive || flights.length > 0) return;
|
||||||
const affordable = ships.some((ship) => {
|
const affordable = ships.some((ship) => {
|
||||||
|
|
@ -635,8 +676,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
|
||||||
discovered.has(ship.locationId) &&
|
discovered.has(ship.locationId) &&
|
||||||
board(ship.locationId).some(
|
board(ship.locationId).some(
|
||||||
(c) =>
|
(c) =>
|
||||||
tripFuelCost(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
|
tripFuel(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <=
|
||||||
credits,
|
ship.tank.fuelFu + credits / fuelPriceAt(loc(ship.locationId), config),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { Contract } from "./contract";
|
||||||
import { routeDistance } from "./distance";
|
import { routeDistance } from "./distance";
|
||||||
import { isResting } from "./duty";
|
import { isResting } from "./duty";
|
||||||
import { checkRoute } from "./eligibility";
|
import { checkRoute } from "./eligibility";
|
||||||
import { fuelCost } from "./fuel";
|
import { legFuel, legFuelCost, type Tank } from "./fuel";
|
||||||
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||||
import { deriveSeed, type Seed } from "./rng";
|
import { deriveSeed, type Seed } from "./rng";
|
||||||
import { travelTimeGameHours } from "./travel";
|
import { travelTimeGameHours } from "./travel";
|
||||||
|
|
@ -20,6 +20,8 @@ export interface ShipState {
|
||||||
upgrades?: UpgradeLevels;
|
upgrades?: UpgradeLevels;
|
||||||
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
|
/** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */
|
||||||
inShopUntil?: RealTime | null;
|
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). */
|
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
||||||
|
|
@ -43,9 +45,8 @@ export interface DispatchProposal {
|
||||||
ship: ShipState;
|
ship: ShipState;
|
||||||
pilot: PilotState;
|
pilot: PilotState;
|
||||||
contract: Contract;
|
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;
|
tutorial: boolean;
|
||||||
credits: number;
|
|
||||||
now: RealTime;
|
now: RealTime;
|
||||||
/**
|
/**
|
||||||
* Locations the player has discovered, for the no-strand rule (decision 0.5.79).
|
* 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",
|
"cargo-skill-too-low",
|
||||||
"long-haul-too-low",
|
"long-haul-too-low",
|
||||||
"contract-expired",
|
"contract-expired",
|
||||||
"insufficient-credits",
|
"not-enough-fuel",
|
||||||
] as const;
|
] as const;
|
||||||
export type DispatchFailure = (typeof DISPATCH_FAILURES)[number];
|
export type DispatchFailure = (typeof DISPATCH_FAILURES)[number];
|
||||||
|
|
||||||
|
|
@ -88,14 +89,25 @@ export function findLocation(id: string, content: SimContent): Location {
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuel charged for a trip, in whole credits. */
|
/** Fuel a trip burns from the tank, in fu (decision 0.5.95). */
|
||||||
export function tripFuelCost(
|
export function tripFuel(
|
||||||
model: ShipModel,
|
model: ShipModel,
|
||||||
origin: Location,
|
origin: Location,
|
||||||
destination: Location,
|
destination: Location,
|
||||||
config: GameConfig,
|
config: GameConfig,
|
||||||
): number {
|
): 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 });
|
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. */
|
/** Validates a proposed contract dispatch, returning the first failing reason. */
|
||||||
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
|
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 model = shipStats(ship, content);
|
||||||
const origin = findLocation(contract.originId, content);
|
const origin = findLocation(contract.originId, content);
|
||||||
const destination = findLocation(contract.destinationId, 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 (now >= contract.expiresAt) return fail("contract-expired");
|
||||||
|
|
||||||
if (!tutorial) {
|
if (!tutorial && !hasFuelFor(ship, model, origin, destination, content)) {
|
||||||
if (credits < tripFuelCost(model, origin, destination, content.config))
|
return fail("not-enough-fuel");
|
||||||
return fail("insufficient-credits");
|
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -189,9 +200,11 @@ export interface DispatchRecord {
|
||||||
tutorial: boolean;
|
tutorial: boolean;
|
||||||
departureAt: RealTime;
|
departureAt: RealTime;
|
||||||
arrivalAt: RealTime;
|
arrivalAt: RealTime;
|
||||||
/** Route distance at departure, in million km. */
|
/** Route distance, in million km. */
|
||||||
distanceMillionKm: number;
|
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;
|
fuelCost: number;
|
||||||
expectedPayout: number;
|
expectedPayout: number;
|
||||||
/** Seed for anything rolled about this trip. */
|
/** Seed for anything rolled about this trip. */
|
||||||
|
|
@ -230,6 +243,7 @@ function buildRecord(
|
||||||
const distance = routeDistance(origin, destination);
|
const distance = routeDistance(origin, destination);
|
||||||
const mass = contract?.massTonnes ?? 0;
|
const mass = contract?.massTonnes ?? 0;
|
||||||
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
|
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
|
||||||
|
const burned = tutorial ? 0 : legFuel(model, origin, distance, config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -242,7 +256,8 @@ function buildRecord(
|
||||||
departureAt: now,
|
departureAt: now,
|
||||||
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
|
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
|
||||||
distanceMillionKm: distance,
|
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,
|
expectedPayout: contract?.payout ?? 0,
|
||||||
seed: deriveSeed(worldSeed, `dispatch:${id}`),
|
seed: deriveSeed(worldSeed, `dispatch:${id}`),
|
||||||
};
|
};
|
||||||
|
|
@ -263,7 +278,6 @@ export interface RepositionProposal {
|
||||||
destinationId: string;
|
destinationId: string;
|
||||||
/** Locations the player has discovered; only these can be deadheaded to (CR-002). */
|
/** Locations the player has discovered; only these can be deadheaded to (CR-002). */
|
||||||
discoveredIds: ReadonlySet<string>;
|
discoveredIds: ReadonlySet<string>;
|
||||||
credits: number;
|
|
||||||
now: RealTime;
|
now: RealTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,7 +293,7 @@ export const REPOSITION_FAILURES = [
|
||||||
"cannot-land",
|
"cannot-land",
|
||||||
"out-of-range",
|
"out-of-range",
|
||||||
"would-strand",
|
"would-strand",
|
||||||
"insufficient-credits",
|
"not-enough-fuel",
|
||||||
] as const;
|
] as const;
|
||||||
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
|
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
|
||||||
|
|
||||||
|
|
@ -294,7 +308,7 @@ export function validateReposition(
|
||||||
proposal: RepositionProposal,
|
proposal: RepositionProposal,
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
): RepositionCheck {
|
): RepositionCheck {
|
||||||
const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal;
|
const { ship, pilot, destinationId, discoveredIds, now } = proposal;
|
||||||
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
||||||
|
|
||||||
if (ship.inFlight) return failWith("ship-not-idle");
|
if (ship.inFlight) return failWith("ship-not-idle");
|
||||||
|
|
@ -314,8 +328,8 @@ export function validateReposition(
|
||||||
return failWith("would-strand");
|
return failWith("would-strand");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credits < tripFuelCost(model, origin, destination, content.config)) {
|
if (!hasFuelFor(ship, model, origin, destination, content)) {
|
||||||
return failWith("insufficient-credits");
|
return failWith("not-enough-fuel");
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,15 +48,50 @@ export function fuelPriceAt(location: Location, config: GameConfig): number {
|
||||||
return config.baseFuelPricePerFu * location.fuelPrice;
|
return config.baseFuelPricePerFu * location.fuelPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** What a ship's tank holds (decision 0.5.94): fuel, and its average purchase price. */
|
||||||
* Fuel cost in credits for a leg: its fuel at the origin's price. (Step 15.3;
|
export interface Tank {
|
||||||
* Step 15.4 replaces the charge with fuel burned from the ship's tank.)
|
fuelFu: number;
|
||||||
*/
|
/** Credits per fu, weighted by how much was bought at each price (decision 0.5.98). */
|
||||||
export function fuelCost(
|
avgPrice: number;
|
||||||
ship: ShipModel,
|
}
|
||||||
origin: Location,
|
|
||||||
distanceMillionKm: number,
|
export interface Refuel {
|
||||||
config: GameConfig,
|
tank: Tank;
|
||||||
): number {
|
boughtFu: number;
|
||||||
return legFuel(ship, origin, distanceMillionKm, config) * fuelPriceAt(origin, config);
|
/** 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,16 @@ export type { Position } from "./positions";
|
||||||
export { routeDistance } from "./distance";
|
export { routeDistance } from "./distance";
|
||||||
export { travelTimeGameHours } from "./travel";
|
export { travelTimeGameHours } from "./travel";
|
||||||
export {
|
export {
|
||||||
|
affordableRefuel,
|
||||||
economicDistance,
|
economicDistance,
|
||||||
fuelCost,
|
|
||||||
fuelPriceAt,
|
fuelPriceAt,
|
||||||
fullTankReach,
|
fullTankReach,
|
||||||
legFuel,
|
legFuel,
|
||||||
|
legFuelCost,
|
||||||
plainDistance,
|
plainDistance,
|
||||||
|
refuel,
|
||||||
|
type Refuel,
|
||||||
|
type Tank,
|
||||||
} from "./fuel";
|
} from "./fuel";
|
||||||
|
|
||||||
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
|
export { canLandAt, checkRoute, rangeFrom } from "./eligibility";
|
||||||
|
|
@ -49,7 +53,7 @@ export {
|
||||||
createDispatchRecord,
|
createDispatchRecord,
|
||||||
createRepositionRecord,
|
createRepositionRecord,
|
||||||
shipStats,
|
shipStats,
|
||||||
tripFuelCost,
|
tripFuel,
|
||||||
validateDispatch,
|
validateDispatch,
|
||||||
wouldStrand,
|
wouldStrand,
|
||||||
validateReposition,
|
validateReposition,
|
||||||
|
|
@ -79,7 +83,7 @@ export { splitLoanRepayment } from "./loan";
|
||||||
export type { LoanSplit } from "./loan";
|
export type { LoanSplit } from "./loan";
|
||||||
|
|
||||||
export { resolveDispatch } from "./resolution";
|
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";
|
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,37 @@
|
||||||
import type { SimContent } from "./board";
|
import type { SimContent } from "./board";
|
||||||
import type { RealTime } from "./clock";
|
import type { RealTime } from "./clock";
|
||||||
import type { DispatchRecord } from "./dispatch";
|
import type { DispatchRecord } from "./dispatch";
|
||||||
|
import { findLocation } from "./dispatch";
|
||||||
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
|
import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty";
|
||||||
|
import { affordableRefuel, fuelPriceAt, type Tank } from "./fuel";
|
||||||
import { splitLoanRepayment } from "./loan";
|
import { splitLoanRepayment } from "./loan";
|
||||||
import { pilotShare } from "./pay";
|
import { pilotShare } from "./pay";
|
||||||
import type { SkillLevels } from "./pilot";
|
import type { SkillLevels } from "./pilot";
|
||||||
import { deliveryXp } from "./xp";
|
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 {
|
export interface Resolution {
|
||||||
/**
|
/**
|
||||||
* Credits to add: payout minus pilot pay, minus the loan repayment.
|
* Credits to add: payout minus pilot pay, minus the loan repayment. Fuel
|
||||||
* Fuel was already charged at departure.
|
* came out of the tank at departure; any top-up is `topUp.cost`.
|
||||||
*/
|
*/
|
||||||
credits: number;
|
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). */
|
/** A hired pilot's share of this delivery's profit; 0 for the avatar, tutorial runs and deadheads (CR-010). */
|
||||||
pilotPay: number;
|
pilotPay: number;
|
||||||
/** Credits that went to the lender out of this payout (decision 0.5.32). */
|
/** Credits that went to the lender out of this payout (decision 0.5.32). */
|
||||||
|
|
@ -42,6 +61,30 @@ export interface ResolveInput {
|
||||||
pilotDutyHours: number;
|
pilotDutyHours: number;
|
||||||
pilotSkills: SkillLevels;
|
pilotSkills: SkillLevels;
|
||||||
now: RealTime;
|
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: {
|
resolution: {
|
||||||
...base,
|
...base,
|
||||||
credits: 0,
|
credits: 0,
|
||||||
|
topUp: topUpOnLanding(input, 0, content),
|
||||||
pilotPay: 0,
|
pilotPay: 0,
|
||||||
loanRepayment: 0,
|
loanRepayment: 0,
|
||||||
pilotXp: 0,
|
pilotXp: 0,
|
||||||
|
|
@ -97,6 +141,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
|
||||||
resolution: {
|
resolution: {
|
||||||
...base,
|
...base,
|
||||||
credits: net,
|
credits: net,
|
||||||
|
topUp: topUpOnLanding(input, net, content),
|
||||||
pilotPay,
|
pilotPay,
|
||||||
loanRepayment: repayment,
|
loanRepayment: repayment,
|
||||||
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
|
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
|
import { simulate, type Milestones, type SimulationResult } from "../balance/simulate";
|
||||||
import { content } from "@deadhead/content";
|
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
|
* 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)", () => {
|
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 courier = content.shipModel("courier")!;
|
||||||
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
|
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) {
|
for (const result of lighterFirst) {
|
||||||
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
|
expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);
|
||||||
expect(result.tutorialShareEarned).toBeGreaterThan(0);
|
expect(result.tutorialShareEarned).toBeGreaterThan(0);
|
||||||
expect(result.creditsAtDeal).toBe(
|
expect(result.creditsAtDeal).toBe(
|
||||||
content.config.startingCredits + result.tutorialShareEarned,
|
content.config.startingCredits + result.tutorialShareEarned,
|
||||||
);
|
);
|
||||||
expect(result.creditsAtDeal!).toBeGreaterThanOrEqual(3 * fuel);
|
expect(inTank + result.creditsAtDeal! / earthPrice).toBeGreaterThanOrEqual(3 * runFu);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,12 @@ import {
|
||||||
type PilotState,
|
type PilotState,
|
||||||
type ShipState,
|
type ShipState,
|
||||||
type SimContent,
|
type SimContent,
|
||||||
|
tripFuel,
|
||||||
} from "../src/index";
|
} 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. */
|
/** Default content plus a test-only ship that cannot land on atmospheric worlds. */
|
||||||
const vacuumModel: ShipModel = {
|
const vacuumModel: ShipModel = {
|
||||||
...defaultContent.shipModel("courier")!,
|
...defaultContent.shipModel("courier")!,
|
||||||
|
|
@ -40,7 +44,13 @@ const contract: Contract = {
|
||||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||||
firstContact: false,
|
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 = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
@ -49,7 +59,7 @@ const pilot: PilotState = {
|
||||||
skills: {},
|
skills: {},
|
||||||
restEndsAt: null,
|
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 reason = (proposal: DispatchProposal) => {
|
||||||
const result = validateDispatch(proposal, content);
|
const result = validateDispatch(proposal, content);
|
||||||
|
|
@ -123,8 +133,20 @@ describe("dispatch validation", () => {
|
||||||
expect(reason({ ...valid, now: contract.expiresAt })).toBe("contract-expired");
|
expect(reason({ ...valid, now: contract.expiresAt })).toBe("contract-expired");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a dispatch the player cannot afford fuel for", () => {
|
it("rejects a dispatch without the fuel on board, and passes once refueled (Step 15.4)", () => {
|
||||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
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", () => {
|
describe("tutorial runs", () => {
|
||||||
|
|
@ -137,8 +159,9 @@ describe("dispatch validation", () => {
|
||||||
const avatar: PilotState = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null };
|
const avatar: PilotState = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null };
|
||||||
const tutorial: DispatchProposal = { ...valid, ship: loaner, pilot: avatar, tutorial: true };
|
const tutorial: DispatchProposal = { ...valid, ship: loaner, pilot: avatar, tutorial: true };
|
||||||
|
|
||||||
it("accepts the avatar in the loaner without credits for fuel", () => {
|
it("accepts the avatar in the loaner, which carries no fuel (decision 0.5.94)", () => {
|
||||||
expect(reason({ ...tutorial, credits: 0 })).toBe("valid");
|
expect(tutorial.ship.tank).toBeUndefined();
|
||||||
|
expect(reason(tutorial)).toBe("valid");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a hired pilot in the loaner", () => {
|
it("rejects a hired pilot in the loaner", () => {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import {
|
||||||
type ShipState,
|
type ShipState,
|
||||||
} from "../src/index";
|
} 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 now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const contract: Contract = {
|
const contract: Contract = {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
|
|
@ -37,7 +40,13 @@ const contract: Contract = {
|
||||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||||
firstContact: false,
|
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 = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
@ -46,7 +55,7 @@ const pilot: PilotState = {
|
||||||
skills: {},
|
skills: {},
|
||||||
restEndsAt: null,
|
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 limit = dutyLimit({}, content.config);
|
||||||
|
|
||||||
const resolveAt = (record: DispatchRecord, dutyBefore: number, at = record.arrivalAt) => {
|
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 },
|
pilot: { ...pilot, restEndsAt: now + 1 },
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
discoveredIds: new Set(["earth", "moon"]),
|
discoveredIds: new Set(["earth", "moon"]),
|
||||||
credits: 10_000,
|
|
||||||
now,
|
now,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import {
|
||||||
type ShipState,
|
type ShipState,
|
||||||
} from "../src/index";
|
} 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 now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const courier = content.shipModel("courier")!;
|
const courier = content.shipModel("courier")!;
|
||||||
const freighter = content.shipModel("freighter")!;
|
const freighter = content.shipModel("freighter")!;
|
||||||
|
|
@ -34,7 +37,13 @@ const contract: Contract = {
|
||||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||||
firstContact: false,
|
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 = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
@ -43,7 +52,7 @@ const pilot: PilotState = {
|
||||||
skills: {},
|
skills: {},
|
||||||
restEndsAt: null,
|
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 reason = (p: DispatchProposal) => {
|
||||||
const result = validateDispatch(p, content);
|
const result = validateDispatch(p, content);
|
||||||
return result.ok ? "valid" : result.reason;
|
return result.ok ? "valid" : result.reason;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import {
|
||||||
type FleetMember,
|
type FleetMember,
|
||||||
} from "../src/index";
|
} 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 earth = content.location("earth")!;
|
||||||
const start = Date.parse(content.config.epoch.realStart);
|
const start = Date.parse(content.config.epoch.realStart);
|
||||||
const firstPeriod = boardPeriodIndex(start, content.config);
|
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]) =>
|
const clipperCanFly = (contract: ReturnType<typeof board>[number]) =>
|
||||||
validateDispatch(
|
validateDispatch(
|
||||||
{
|
{
|
||||||
ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false },
|
ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false, tank: PLENTY },
|
||||||
pilot: {
|
pilot: {
|
||||||
id: "p",
|
id: "p",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
@ -37,7 +40,6 @@ const clipperCanFly = (contract: ReturnType<typeof board>[number]) =>
|
||||||
},
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: 1e9,
|
|
||||||
now: contract.offeredAt,
|
now: contract.offeredAt,
|
||||||
discoveredIds: new Set(["earth", "moon"]),
|
discoveredIds: new Set(["earth", "moon"]),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@ import {
|
||||||
locationPosition,
|
locationPosition,
|
||||||
} from "../src/index";
|
} 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(
|
const record = createRepositionRecord(
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY },
|
||||||
pilot: {
|
pilot: {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { content } from "@deadhead/content";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
economicDistance,
|
economicDistance,
|
||||||
fuelCost,
|
|
||||||
fullTankReach,
|
fullTankReach,
|
||||||
legFuel,
|
legFuel,
|
||||||
plainDistance,
|
plainDistance,
|
||||||
|
|
@ -33,24 +32,23 @@ describe("economic distance (decision 0.5.84)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fuel cost", () => {
|
describe("leg fuel", () => {
|
||||||
it("costs more Earth→Moon than Moon→Earth for the same ship", () => {
|
it("burns more Earth→Moon than Moon→Earth for the same ship", () => {
|
||||||
const distance = routeDistance(earth, moon);
|
const distance = routeDistance(earth, moon);
|
||||||
expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan(
|
expect(legFuel(courier, earth, distance, config)).toBeGreaterThan(
|
||||||
fuelCost(courier, moon, distance, config),
|
legFuel(courier, moon, distance, config),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scales with economic distance", () => {
|
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,
|
2 ** exponent,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches a hand calculation (Step 15.3: the leg's fuel at the origin's price)", () => {
|
it("matches a hand calculation", () => {
|
||||||
// 20 fu per M km × economic distance of 0.5M km, at 10 cr/fu × Earth's 1.6.
|
// 20 fu per M km × economic distance of 0.5M km.
|
||||||
const expected = 20 * ref * (0.5 / ref) ** exponent * 10 * 1.6;
|
expect(legFuel(courier, earth, 0.5, config)).toBeCloseTo(20 * ref * (0.5 / ref) ** exponent);
|
||||||
expect(fuelCost(courier, earth, 0.5, config)).toBeCloseTo(expected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import {
|
||||||
pilotShare,
|
pilotShare,
|
||||||
} from "../src/index";
|
} 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", () => {
|
describe("loan repayment split", () => {
|
||||||
it("takes min(balance, credits × share), rounded down to whole credits", () => {
|
it("takes min(balance, credits × share), rounded down to whole credits", () => {
|
||||||
expect(splitLoanRepayment(1000, 5000, 0.3)).toEqual({ repayment: 300, net: 700 });
|
expect(splitLoanRepayment(1000, 5000, 0.3)).toEqual({ repayment: 300, net: 700 });
|
||||||
|
|
@ -61,7 +64,7 @@ describe("resolution with a loan", () => {
|
||||||
const record = createDispatchRecord(
|
const record = createDispatchRecord(
|
||||||
{
|
{
|
||||||
id: "d1",
|
id: "d1",
|
||||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY },
|
||||||
pilot: {
|
pilot: {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import {
|
||||||
type SimContent,
|
type SimContent,
|
||||||
} from "../src/index";
|
} 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 loc = (id: string) => content.location(id)!;
|
||||||
const now = Date.parse("2100-01-01T00:30:00Z");
|
const now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const pilot: PilotState = {
|
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 = {
|
const proposal: RepositionProposal = {
|
||||||
ship: hopper,
|
ship: hopper,
|
||||||
pilot,
|
pilot,
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
discoveredIds: new Set(["earth", "moon"]),
|
discoveredIds: new Set(["earth", "moon"]),
|
||||||
credits: 100_000,
|
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,7 +94,7 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies to contract dispatches too", () => {
|
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(
|
expect(
|
||||||
validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture),
|
validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture),
|
||||||
).toEqual({ ok: false, reason: "would-strand" });
|
).toEqual({ ok: false, reason: "would-strand" });
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import {
|
||||||
type Contract,
|
type Contract,
|
||||||
} from "../src/index";
|
} 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 earth = content.location("earth")!;
|
||||||
const moon = content.location("moon")!;
|
const moon = content.location("moon")!;
|
||||||
const periodIndex = 4_000;
|
const periodIndex = 4_000;
|
||||||
|
|
@ -37,7 +40,7 @@ function mailContract(priority: boolean): Contract {
|
||||||
function check(modelId: string, contract: Contract) {
|
function check(modelId: string, contract: Contract) {
|
||||||
return validateDispatch(
|
return validateDispatch(
|
||||||
{
|
{
|
||||||
ship: { id: "s", modelId, locationId: "earth", inFlight: false },
|
ship: { id: "s", modelId, locationId: "earth", inFlight: false, tank: PLENTY },
|
||||||
pilot: {
|
pilot: {
|
||||||
id: "p",
|
id: "p",
|
||||||
isAvatar: true,
|
isAvatar: true,
|
||||||
|
|
@ -48,7 +51,6 @@ function check(modelId: string, contract: Contract) {
|
||||||
},
|
},
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: 1_000_000,
|
|
||||||
now: offeredAt,
|
now: offeredAt,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import {
|
||||||
type ShipState,
|
type ShipState,
|
||||||
} from "../src/index";
|
} 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 now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const contract: Contract = {
|
const contract: Contract = {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
|
|
@ -32,7 +35,13 @@ const contract: Contract = {
|
||||||
expiresAt: now + 3_600_000,
|
expiresAt: now + 3_600_000,
|
||||||
firstContact: false,
|
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 = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import {
|
||||||
type RecordInput,
|
type RecordInput,
|
||||||
} from "../src/index";
|
} 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 now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const contract: Contract = {
|
const contract: Contract = {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
|
|
@ -29,7 +32,7 @@ const contract: Contract = {
|
||||||
};
|
};
|
||||||
const input: RecordInput = {
|
const input: RecordInput = {
|
||||||
id: "d1",
|
id: "d1",
|
||||||
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false },
|
ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY },
|
||||||
pilot: {
|
pilot: {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,17 @@ import {
|
||||||
type ShipState,
|
type ShipState,
|
||||||
} from "../src/index";
|
} 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 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 = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
@ -24,7 +33,6 @@ const valid: RepositionProposal = {
|
||||||
pilot,
|
pilot,
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
discoveredIds: new Set(["earth", "moon"]),
|
discoveredIds: new Set(["earth", "moon"]),
|
||||||
credits: 10_000,
|
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -58,8 +66,10 @@ describe("deadhead (fly empty)", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still needs fuel credits, an idle crew and an assigned pilot", () => {
|
it("still needs fuel on board, an idle crew and an assigned pilot", () => {
|
||||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
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, ship: { ...atEarth, inFlight: true } })).toBe("ship-not-idle");
|
||||||
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
||||||
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
|
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import {
|
||||||
pilotShare,
|
pilotShare,
|
||||||
} from "../src/index";
|
} 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 now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const contract: Contract = {
|
const contract: Contract = {
|
||||||
id: "c1",
|
id: "c1",
|
||||||
|
|
@ -30,7 +33,13 @@ const contract: Contract = {
|
||||||
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
expiresAt: Date.parse("2100-01-01T01:00:00Z"),
|
||||||
firstContact: false,
|
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 = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest";
|
||||||
import { buildContract } from "../src/board";
|
import { buildContract } from "../src/board";
|
||||||
import { createDispatchRecord, type Contract } from "../src/index";
|
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 earth = content.location("earth")!;
|
||||||
const ceres = content.location("ceres-station")!;
|
const ceres = content.location("ceres-station")!;
|
||||||
const good = content.goods.find(
|
const good = content.goods.find(
|
||||||
|
|
@ -31,7 +34,13 @@ function offered(periodIndex: number): Contract {
|
||||||
}
|
}
|
||||||
|
|
||||||
function flown(contract: Contract, now: number) {
|
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 = {
|
const pilot = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,10 +11,19 @@ import {
|
||||||
type UpgradeProposal,
|
type UpgradeProposal,
|
||||||
} from "../src/index";
|
} 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 now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const freighter = content.shipModel("freighter")!;
|
const freighter = content.shipModel("freighter")!;
|
||||||
const engines = content.upgrade("engines")!;
|
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 = {
|
const valid: UpgradeProposal = {
|
||||||
ship,
|
ship,
|
||||||
upgrade: engines,
|
upgrade: engines,
|
||||||
|
|
@ -104,7 +113,6 @@ describe("ships in the shop can't fly (Step 10I.3)", () => {
|
||||||
pilot,
|
pilot,
|
||||||
contract,
|
contract,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
credits: 1e6,
|
|
||||||
now: time,
|
now: time,
|
||||||
},
|
},
|
||||||
content,
|
content,
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,9 @@ importers:
|
||||||
|
|
||||||
packages/schema:
|
packages/schema:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@deadhead/content':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../content
|
||||||
'@deadhead/sim':
|
'@deadhead/sim':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../sim
|
version: link:../sim
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue