diff --git a/DECISIONS.md b/DECISIONS.md index c2ddf02..0d4ef06 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -143,3 +143,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Tutorial dispatch:** the caller passes the loaner as the ship (a `ShipState` built from the player's `tutorial` object, decision 0.5.26) and sets `tutorial: true`. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that. - **Checks run in the plan's order**, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits. - **Fuel is charged in whole credits** (`tripFuelCost` rounds), using the distance at departure (`now`). + +## Step 3.13 + +- **Extra record fields:** `tutorial` (resolution pays the tutorial share and skips pilot pay) and `distanceMillionKm` (the distance at departure, used for XP), on top of the plan's fields. +- **The caller supplies the dispatch id**; the seed is `deriveSeed(worldSeed, "dispatch:")`. +- **Arrival** = departure + travel time in game ms ÷ compression, rounded to whole real milliseconds. +- **`fuelCost` is what the player pays:** 0 for tutorial runs. +- **`expectedPayout` is the contract payout** before pilot pay; resolution deducts pilot pay. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6133e0b..f5d38a3 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -31,7 +31,7 @@ - [x] **Step 3.10 — Tutorial contract board.** `src/tutorial.ts`: `generateTutorialBoard` (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); `loanerModel`. `SimContent` gained `shipModels`. - [x] **Step 3.11 — Pilot eligibility.** `src/pilot.ts`: `checkPilotEligibility(skills, contract)` returns `ok` or `cargo-skill-too-low` / `long-haul-too-low`. - [x] **Step 3.12 — Dispatch validation.** `src/dispatch.ts`: `ShipState`, `PilotState`, `validateDispatch` (first failing reason, plan order), `tripFuelCost` (rounded). One test per failure reason plus tutorial cases. -- [ ] Step 3.13 — Dispatch record creation +- [x] **Step 3.13 — Dispatch record creation.** `createDispatchRecord` in `src/dispatch.ts`: `DispatchRecord` with arrival from travel time, fuel cost, departure distance, a `tutorial` flag and a per-dispatch seed. - [ ] Step 3.14 — Reposition dispatch - [ ] Step 3.15 — In-flight position and progress - [ ] Step 3.16 — XP and leveling diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index ce25e70..070eb86 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -1,11 +1,13 @@ import type { Location, ShipModel } from "@deadhead/content"; import type { SimContent } from "./board"; -import { type GameTime, type RealTime, realToGame } from "./clock"; +import { type GameTime, MS_PER_HOUR, type RealTime, realToGame } from "./clock"; import type { Contract } from "./contract"; import { routeDistance } from "./distance"; import { checkRoute } from "./eligibility"; import { fuelCost } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; +import { deriveSeed, type Seed } from "./rng"; +import { travelTimeGameHours } from "./travel"; export interface ShipState { id: string; @@ -104,3 +106,61 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent } return { ok: true }; } + +export interface DispatchRecord { + id: string; + shipId: string; + pilotId: string; + /** Null for a reposition. */ + contract: Contract | null; + originId: string; + destinationId: string; + tutorial: boolean; + departureAt: RealTime; + arrivalAt: RealTime; + /** Route distance at departure, in million km. */ + distanceMillionKm: number; + /** Credits charged to the player; 0 for tutorial runs (the company pays). */ + fuelCost: number; + expectedPayout: number; + /** Seed for anything rolled about this trip. */ + seed: Seed; +} + +export interface RecordInput { + id: string; + ship: ShipState; + pilot: PilotState; + contract: Contract; + tutorial: boolean; + now: RealTime; + worldSeed: Seed; +} + +/** Turns an already-validated dispatch into its record. Same inputs give the same record. */ +export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord { + const { id, ship, pilot, contract, tutorial, now, worldSeed } = input; + const { config } = content; + const model = findShipModel(ship.modelId, content); + const origin = findLocation(contract.originId, content); + const destination = findLocation(contract.destinationId, content); + const departure = realToGame(now, config); + const distance = routeDistance(origin, destination, departure); + const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR; + + return { + id, + shipId: ship.id, + pilotId: pilot.id, + contract, + originId: origin.id, + destinationId: destination.id, + tutorial, + departureAt: now, + arrivalAt: now + Math.round(travelGameMs / config.timeCompression), + distanceMillionKm: distance, + fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure), + expectedPayout: contract.payout, + seed: deriveSeed(worldSeed, `dispatch:${id}`), + }; +} diff --git a/packages/sim/test/record.test.ts b/packages/sim/test/record.test.ts new file mode 100644 index 0000000..af74d83 --- /dev/null +++ b/packages/sim/test/record.test.ts @@ -0,0 +1,71 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + createDispatchRecord, + MS_PER_HOUR, + travelTimeGameHours, + type Contract, + type RecordInput, +} from "../src/index"; + +const now = Date.parse("2100-01-01T00:30:00Z"); +const contract: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + 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 input: RecordInput = { + id: "d1", + ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, + pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} }, + contract, + tutorial: false, + now, + worldSeed: 7, +}; + +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; + expect(record.arrivalAt - record.departureAt).toBe( + Math.round(travelGameMs / content.config.timeCompression), + ); + }); + + it("copies the route, payout and parties, and charges fuel", () => { + const record = createDispatchRecord(input, content); + expect(record).toMatchObject({ + id: "d1", + shipId: "s1", + pilotId: "p1", + originId: "earth", + destinationId: "moon", + departureAt: now, + expectedPayout: 2000, + tutorial: false, + }); + expect(record.fuelCost).toBeGreaterThan(0); + }); + + it("charges no fuel on a tutorial run", () => { + expect(createDispatchRecord({ ...input, tutorial: true }, content).fuelCost).toBe(0); + }); + + it("is identical for identical inputs and seeded per dispatch", () => { + expect(createDispatchRecord(input, content)).toEqual(createDispatchRecord(input, content)); + expect(createDispatchRecord({ ...input, id: "d2" }, content).seed).not.toBe( + createDispatchRecord(input, content).seed, + ); + }); +});