Step 10A.2: add cargo handling time to travel time

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 10:50:15 -04:00
parent bfda781601
commit 957c54cc2c
12 changed files with 109 additions and 26 deletions

View File

@ -491,3 +491,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`dealLoanAmount` 5,000** = Courier price + Earth bay lease, and a test keeps it equal to that sum. - **`dealLoanAmount` 5,000** = Courier price + Earth bay lease, and a test keeps it equal to that sum.
- **Goal conditions are data** (a discriminated union: `own-ship-deliveries`, `skill-points-spent`, `ships-owned`, `pilots-hired`, `locations-discovered`, `loan-paid-off`), so Step 10A.5's evaluator needs no per-goal code. Rewards are first guesses (300–2,500). - **Goal conditions are data** (a discriminated union: `own-ship-deliveries`, `skill-points-spent`, `ships-owned`, `pilots-hired`, `locations-discovered`, `loan-paid-off`), so Step 10A.5's evaluator needs no per-goal code. Rewards are first guesses (300–2,500).
- **`content.purchasableShipModels`** is what the Market lists; buying a non-purchasable model returns `404 not-found`, like an unknown model. - **`content.purchasableShipModels`** is what the Market lists; buying a non-purchasable model returns `404 not-found`, like an unknown model.
## Step 10A.2
- **`travelTimeGameHours` takes mass and config as arguments**, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3).
- **Client trip-time predictions pass the contract's mass,** so the dispatch sheet and tutorial board match what the server will record.

View File

@ -42,7 +42,12 @@ describe("tutorial dispatch", () => {
expectedPayout: contract!.payout, expectedPayout: contract!.payout,
}); });
const travelMs = const travelMs =
(travelTimeGameHours(content.shipModel("courier")!, dispatch.distanceMillionKm) * (travelTimeGameHours(
content.shipModel("courier")!,
dispatch.distanceMillionKm,
contract!.massTonnes,
content.config,
) *
MS_PER_HOUR) / MS_PER_HOUR) /
content.config.timeCompression; content.config.timeCompression;
expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs)); expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs));

View File

@ -24,8 +24,19 @@ export function distanceAt(originId: string, destinationId: string, now: number)
); );
} }
/** Trip duration in real milliseconds for a ship departing at `now`. */ /** Trip duration in real milliseconds for a ship carrying `massTonnes`, departing at `now`. */
export function tripRealMs(model: ShipModel, originId: string, destinationId: string, now: number) { export function tripRealMs(
const hours = travelTimeGameHours(model, distanceAt(originId, destinationId, now)); model: ShipModel,
originId: string,
destinationId: string,
massTonnes: number,
now: number,
) {
const hours = travelTimeGameHours(
model,
distanceAt(originId, destinationId, now),
massTonnes,
content.config,
);
return (hours * MS_PER_HOUR) / content.config.timeCompression; return (hours * MS_PER_HOUR) / content.config.timeCompression;
} }

View File

@ -49,7 +49,13 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
.map((ship) => { .map((ship) => {
const model = shipModel(ship.modelId); const model = shipModel(ship.modelId);
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null; const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
const tripMs = tripRealMs(model, contract.originId, contract.destinationId, now); const tripMs = tripRealMs(
model,
contract.originId,
contract.destinationId,
contract.massTonnes,
now,
);
const fuelCost = tripFuelCost( const fuelCost = tripFuelCost(
model, model,
location(contract.originId), location(contract.originId),

View File

@ -68,7 +68,13 @@ export function TutorialBoard() {
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "} {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "}
<span aria-label="Trip time"> <span aria-label="Trip time">
{formatDuration( {formatDuration(
tripRealMs(loaner, contract.originId, contract.destinationId, now), tripRealMs(
loaner,
contract.originId,
contract.destinationId,
contract.massTonnes,
now,
),
)} )}
</span> </span>
</span> </span>

View File

@ -106,7 +106,13 @@ describe("dispatch sheet", () => {
const mars = content.location("mars")!; const mars = content.location("mars")!;
const gameNow = realToGame(NOW, content.config); const gameNow = realToGame(NOW, content.config);
const tripMs = const tripMs =
(travelTimeGameHours(model, routeDistance(earth, mars, gameNow)) * MS_PER_HOUR) / (travelTimeGameHours(
model,
routeDistance(earth, mars, gameNow),
marsJob.massTonnes,
content.config,
) *
MS_PER_HOUR) /
content.config.timeCompression; content.config.timeCompression;
const fuel = tripFuelCost(model, earth, mars, gameNow); const fuel = tripFuelCost(model, earth, mars, gameNow);
const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery; const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery;

View File

@ -32,13 +32,18 @@ const board = {
}; };
/** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */ /** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */
function expectedTripTime(now: number) { function expectedTripTime(now: number, massTonnes: number) {
const distance = routeDistance( const distance = routeDistance(
content.location("earth")!, content.location("earth")!,
content.location("moon")!, content.location("moon")!,
realToGame(now, content.config), realToGame(now, content.config),
); );
const hours = travelTimeGameHours(content.shipModel("courier")!, distance); const hours = travelTimeGameHours(
content.shipModel("courier")!,
distance,
massTonnes,
content.config,
);
return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression); return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression);
} }
@ -58,7 +63,7 @@ describe("tutorial contract board", () => {
expect(items[0]).toHaveTextContent("Standard · 12 t"); expect(items[0]).toHaveTextContent("Standard · 12 t");
expect(items[0]).toHaveTextContent("230 cr"); expect(items[0]).toHaveTextContent("230 cr");
expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent( expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent(
expectedTripTime(useClock.getState().now), expectedTripTime(useClock.getState().now, 12),
); );
}); });

View File

@ -107,7 +107,7 @@
Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded as plan §0.5.29–0.5.34. Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded as plan §0.5.29–0.5.34.
- [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner. - [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner.
- [ ] Step 10A.2 — Sim: handling time - [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated.
- [ ] Step 10A.3 — Sim: tutorial board v2 - [ ] Step 10A.3 — Sim: tutorial board v2
- [ ] Step 10A.4 — Sim: loan repayment - [ ] Step 10A.4 — Sim: loan repayment
- [ ] Step 10A.5 — Sim: onboarding goal evaluation - [ ] Step 10A.5 — Sim: onboarding goal evaluation

View File

@ -161,7 +161,8 @@ function buildRecord(
const destination = findLocation(args.destinationId, content); const destination = findLocation(args.destinationId, content);
const departure = realToGame(now, config); const departure = realToGame(now, config);
const distance = routeDistance(origin, destination, departure); const distance = routeDistance(origin, destination, departure);
const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR; const mass = contract?.massTonnes ?? 0;
const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR;
return { return {
id, id,

View File

@ -1,10 +1,18 @@
import type { ShipModel } from "@deadhead/content"; import type { GameConfig, ShipModel } from "@deadhead/content";
/** /**
* Trip duration in game hours: cruise time plus the ship's fixed takeoff/landing * Trip duration in game hours: cruise time, the ship's fixed takeoff/landing
* overhead. Pass the distance at departure; it is not recalculated mid-flight. * overhead, and loading/unloading time for the cargo (decision 0.5.31). Pass
* the distance at departure; it is not recalculated mid-flight. Repositions
* carry zero mass.
*/ */
export function travelTimeGameHours(ship: ShipModel, distanceMillionKm: number): number { export function travelTimeGameHours(
ship: ShipModel,
distanceMillionKm: number,
massTonnes: number,
config: GameConfig,
): number {
const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour; const cruiseHours = (distanceMillionKm * 1e6) / ship.cruiseSpeedKmPerGameHour;
return cruiseHours + ship.overheadGameHours; const handlingHours = massTonnes * config.handlingGameHoursPerTonne;
return cruiseHours + ship.overheadGameHours + handlingHours;
} }

View File

@ -37,7 +37,9 @@ describe("dispatch record", () => {
it("arrives after exactly the travel time converted to real time", () => { it("arrives after exactly the travel time converted to real time", () => {
const record = createDispatchRecord(input, content); const record = createDispatchRecord(input, content);
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
const travelGameMs = travelTimeGameHours(courier, record.distanceMillionKm) * MS_PER_HOUR; const travelGameMs =
travelTimeGameHours(courier, record.distanceMillionKm, contract.massTonnes, content.config) *
MS_PER_HOUR;
expect(record.arrivalAt - record.departureAt).toBe( expect(record.arrivalAt - record.departureAt).toBe(
Math.round(travelGameMs / content.config.timeCompression), Math.round(travelGameMs / content.config.timeCompression),
); );

View File

@ -2,27 +2,55 @@ import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { routeDistance, travelTimeGameHours } from "../src/index"; import { routeDistance, travelTimeGameHours } from "../src/index";
const config = content.config;
const courier = content.shipModel("courier")!; const courier = content.shipModel("courier")!;
const freighter = content.shipModel("freighter")!; const freighter = content.shipModel("freighter")!;
const loaner = content.shipModel("company-courier")!;
const earthMoon = () =>
routeDistance(
content.location("earth")!,
content.location("moon")!,
Date.parse("2100-01-01T00:00:00Z"),
);
describe("travel time", () => { describe("travel time", () => {
it("matches a hand calculation", () => { it("matches a hand calculation, including handling time", () => {
// 150M km at 2M km/h = 75 h cruise, plus 4 h overhead. // 150M km at 2M km/h = 75 h cruise, plus 4 h overhead, plus 50 t × 0.02 h/t = 1 h handling.
expect(travelTimeGameHours(freighter, 150)).toBe(79); expect(travelTimeGameHours(freighter, 150, 50, config)).toBe(80);
}); });
it("gets a faster ship there sooner on the same route", () => { it("gets a faster ship there sooner on the same route", () => {
for (const distance of [0.384, 50, 400]) { for (const distance of [0.384, 50, 400]) {
expect(travelTimeGameHours(courier, distance)).toBeLessThan( expect(travelTimeGameHours(courier, distance, 10, config)).toBeLessThan(
travelTimeGameHours(freighter, distance), travelTimeGameHours(freighter, distance, 10, config),
); );
} }
}); });
it("is mostly overhead for a Courier on Earth→Moon", () => { it("is mostly overhead for a Courier on Earth→Moon", () => {
const t = Date.parse("2100-01-01T00:00:00Z"); const total = travelTimeGameHours(courier, earthMoon(), 10, config);
const distance = routeDistance(content.location("earth")!, content.location("moon")!, t);
const total = travelTimeGameHours(courier, distance);
expect(courier.overheadGameHours).toBeGreaterThan(total / 2); expect(courier.overheadGameHours).toBeGreaterThan(total / 2);
}); });
}); });
describe("handling time (decision 0.5.31)", () => {
it("makes a heavier contract take longer than a lighter one on the same route and ship", () => {
const distance = earthMoon();
expect(travelTimeGameHours(courier, distance, 20, config)).toBeGreaterThan(
travelTimeGameHours(courier, distance, 5, config),
);
});
it("gives a reposition (zero mass) the old formula's time", () => {
const distance = 150;
const oldFormula =
(distance * 1e6) / freighter.cruiseSpeedKmPerGameHour + freighter.overheadGameHours;
expect(travelTimeGameHours(freighter, distance, 0, config)).toBe(oldFormula);
});
it("keeps a full Company Courier tutorial run Earth→Moon under 1 game hour (1 real minute)", () => {
const hours = travelTimeGameHours(loaner, earthMoon(), config.tutorialMassTonnes.full, config);
expect(hours).toBeLessThan(1);
expect(config.timeCompression).toBe(60);
});
});