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:
parent
bfda781601
commit
957c54cc2c
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue