diff --git a/DECISIONS.md b/DECISIONS.md index 31aeec4..5bb1bfc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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. + +## 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. diff --git a/apps/server/test/tutorial-dispatch.test.ts b/apps/server/test/tutorial-dispatch.test.ts index 6d46389..4490f70 100644 --- a/apps/server/test/tutorial-dispatch.test.ts +++ b/apps/server/test/tutorial-dispatch.test.ts @@ -42,7 +42,12 @@ describe("tutorial dispatch", () => { expectedPayout: contract!.payout, }); const travelMs = - (travelTimeGameHours(content.shipModel("courier")!, dispatch.distanceMillionKm) * + (travelTimeGameHours( + content.shipModel("courier")!, + dispatch.distanceMillionKm, + contract!.massTonnes, + content.config, + ) * MS_PER_HOUR) / content.config.timeCompression; expect(dispatch.arrivalAt - dispatch.departureAt).toBe(Math.round(travelMs)); diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index 549ef34..d210773 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -24,8 +24,19 @@ export function distanceAt(originId: string, destinationId: string, now: number) ); } -/** Trip duration in real milliseconds for a ship departing at `now`. */ -export function tripRealMs(model: ShipModel, originId: string, destinationId: string, now: number) { - const hours = travelTimeGameHours(model, distanceAt(originId, destinationId, now)); +/** Trip duration in real milliseconds for a ship carrying `massTonnes`, departing at `now`. */ +export function tripRealMs( + 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; } diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index ff574d9..3abec26 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -49,7 +49,13 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) .map((ship) => { const model = shipModel(ship.modelId); 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( model, location(contract.originId), diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index 05d2515..933873d 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -68,7 +68,13 @@ export function TutorialBoard() { {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "} {formatDuration( - tripRealMs(loaner, contract.originId, contract.destinationId, now), + tripRealMs( + loaner, + contract.originId, + contract.destinationId, + contract.massTonnes, + now, + ), )} diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index fddbf05..dbe4f90 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -106,7 +106,13 @@ describe("dispatch sheet", () => { const mars = content.location("mars")!; const gameNow = realToGame(NOW, content.config); 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; const fuel = tripFuelCost(model, earth, mars, gameNow); const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery; diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 1d435cb..0c61091 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -32,13 +32,18 @@ const board = { }; /** 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( content.location("earth")!, content.location("moon")!, 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); } @@ -58,7 +63,7 @@ describe("tutorial contract board", () => { expect(items[0]).toHaveTextContent("Standard · 12 t"); expect(items[0]).toHaveTextContent("230 cr"); expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent( - expectedTripTime(useClock.getState().now), + expectedTripTime(useClock.getState().now, 12), ); }); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 54d9b01..4a10545 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -107,7 +107,7 @@ 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. -- [ ] 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.4 — Sim: loan repayment - [ ] Step 10A.5 — Sim: onboarding goal evaluation diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 7a231ba..7bafde0 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -161,7 +161,8 @@ function buildRecord( const destination = findLocation(args.destinationId, content); const departure = realToGame(now, config); 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 { id, diff --git a/packages/sim/src/travel.ts b/packages/sim/src/travel.ts index 10d3faa..d41dba0 100644 --- a/packages/sim/src/travel.ts +++ b/packages/sim/src/travel.ts @@ -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 - * overhead. Pass the distance at departure; it is not recalculated mid-flight. + * Trip duration in game hours: cruise time, the ship's fixed takeoff/landing + * 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; - return cruiseHours + ship.overheadGameHours; + const handlingHours = massTonnes * config.handlingGameHoursPerTonne; + return cruiseHours + ship.overheadGameHours + handlingHours; } diff --git a/packages/sim/test/record.test.ts b/packages/sim/test/record.test.ts index af74d83..7c105d9 100644 --- a/packages/sim/test/record.test.ts +++ b/packages/sim/test/record.test.ts @@ -37,7 +37,9 @@ describe("dispatch record", () => { it("arrives after exactly the travel time converted to real time", () => { const record = createDispatchRecord(input, content); 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( Math.round(travelGameMs / content.config.timeCompression), ); diff --git a/packages/sim/test/travel.test.ts b/packages/sim/test/travel.test.ts index edfa157..ae50dbe 100644 --- a/packages/sim/test/travel.test.ts +++ b/packages/sim/test/travel.test.ts @@ -2,27 +2,55 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { routeDistance, travelTimeGameHours } from "../src/index"; +const config = content.config; const courier = content.shipModel("courier")!; 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", () => { - it("matches a hand calculation", () => { - // 150M km at 2M km/h = 75 h cruise, plus 4 h overhead. - expect(travelTimeGameHours(freighter, 150)).toBe(79); + it("matches a hand calculation, including handling time", () => { + // 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, 50, config)).toBe(80); }); it("gets a faster ship there sooner on the same route", () => { for (const distance of [0.384, 50, 400]) { - expect(travelTimeGameHours(courier, distance)).toBeLessThan( - travelTimeGameHours(freighter, distance), + expect(travelTimeGameHours(courier, distance, 10, config)).toBeLessThan( + travelTimeGameHours(freighter, distance, 10, config), ); } }); it("is mostly overhead for a Courier on Earth→Moon", () => { - const t = Date.parse("2100-01-01T00:00:00Z"); - const distance = routeDistance(content.location("earth")!, content.location("moon")!, t); - const total = travelTimeGameHours(courier, distance); + const total = travelTimeGameHours(courier, earthMoon(), 10, config); 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); + }); +});