From 50b038047ffe072572301310b6b0631e20775dca Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:50:58 -0400 Subject: [PATCH] Step 3.17: add dispatch resolution Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ memory-bank/progress.md | 2 +- packages/sim/src/index.ts | 1 + packages/sim/src/resolution.ts | 53 ++++++++++++ packages/sim/test/resolution.test.ts | 118 +++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/resolution.ts create mode 100644 packages/sim/test/resolution.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 6526fcd..288a315 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -169,3 +169,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Delivery XP** = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record. - **Pilot level** is the number of `pilotLevelXpThresholds` the total XP has reached (0 below the first threshold), so levels cap at the list length (10). - **Skill points available** = level × points per level − points spent, floored at 0. + +## Step 3.17 + +- **Resolution credits = contract payout − pilot pay.** Fuel is not included, because it is charged when the ship departs (the record's `fuelCost`). Credits can be negative if a payout is below pilot pay; the server applies the number as given. +- **Tutorial runs grant XP to the avatar** like any delivery; the plan does not exclude it. They credit the stored tutorial payout with no pilot pay. +- **Repositions resolve to 0 credits and 0 XP** and never discover anything, since they only fly to bays. +- **The caller passes `pilotIsAvatar` and the current discovered set**, so the function stays pure. "Exactly once" is the server's job: it must store the resolution and the acknowledged-at state transactionally. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 93b5726..ec3bc31 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -35,5 +35,5 @@ - [x] **Step 3.14 — Reposition dispatch.** `validateReposition` and `createRepositionRecord` in `src/dispatch.ts`: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17). - [x] **Step 3.15 — In-flight position and progress.** `src/flight.ts`: `dispatchProgress` (clamped 0–1) and `dispatchPosition` (straight line from origin at departure to destination at arrival). - [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`. -- [ ] Step 3.17 — Dispatch resolution +- [x] **Step 3.17 — Dispatch resolution.** `src/resolution.ts`: `resolveDispatch` returns `in-flight` or a `Resolution` (credits after pilot pay, XP, new ship location, discovery). - [ ] Step 3.18 — Sim package public API and coverage diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 368a248..bfc120c 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -12,3 +12,4 @@ export * from "./pilot"; export * from "./dispatch"; export * from "./flight"; export * from "./xp"; +export * from "./resolution"; diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts new file mode 100644 index 0000000..bf4c050 --- /dev/null +++ b/packages/sim/src/resolution.ts @@ -0,0 +1,53 @@ +import type { SimContent } from "./board"; +import type { RealTime } from "./clock"; +import type { DispatchRecord } from "./dispatch"; +import { deliveryXp } from "./xp"; + +export interface Resolution { + /** Credits to add: payout minus pilot pay. Fuel was already charged at departure. */ + credits: number; + pilotXp: number; + /** Where the ship (or tutorial loaner) is now. */ + shipLocationId: string; + /** True when this delivery discovers the destination. */ + discoversDestination: boolean; +} + +export type ResolveResult = + { status: "in-flight" } | { status: "resolved"; resolution: Resolution }; + +export interface ResolveInput { + record: DispatchRecord; + /** The avatar is exempt from pilot pay (decision 0.5.12). */ + pilotIsAvatar: boolean; + discoveredIds: ReadonlySet; + now: RealTime; +} + +/** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ +export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { + const { record, pilotIsAvatar, discoveredIds, now } = input; + if (now < record.arrivalAt) return { status: "in-flight" }; + + const { contract } = record; + const base = { shipLocationId: record.destinationId }; + if (contract === null) { + // Repositions earn nothing and only fly to bays, which are already discovered. + return { + status: "resolved", + resolution: { ...base, credits: 0, pilotXp: 0, discoversDestination: false }, + }; + } + + const pilotPay = record.tutorial || pilotIsAvatar ? 0 : content.config.pilotPayPerDelivery; + return { + status: "resolved", + resolution: { + ...base, + // Tutorial contracts already store exactly the tutorial share as their payout. + credits: contract.payout - pilotPay, + pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), + discoversDestination: !discoveredIds.has(record.destinationId), + }, + }; +} diff --git a/packages/sim/test/resolution.test.ts b/packages/sim/test/resolution.test.ts new file mode 100644 index 0000000..5918731 --- /dev/null +++ b/packages/sim/test/resolution.test.ts @@ -0,0 +1,118 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + createDispatchRecord, + createRepositionRecord, + resolveDispatch, + type Contract, + type PilotState, + type ResolveInput, + type ShipState, +} 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 ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, +}; +const record = createDispatchRecord( + { id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 }, + content, +); +const arrived: ResolveInput = { + record, + pilotIsAvatar: false, + discoveredIds: new Set(["earth", "moon"]), + now: record.arrivalAt, +}; + +const resolve = (input: ResolveInput) => { + const result = resolveDispatch(input, content); + if (result.status !== "resolved") throw new Error("expected a resolution"); + return result.resolution; +}; + +describe("dispatch resolution", () => { + it("reports in flight before arrival", () => { + expect(resolveDispatch({ ...arrived, now: record.arrivalAt - 1 }, content)).toEqual({ + status: "in-flight", + }); + }); + + it("resolves at and after arrival, moving the ship to the destination", () => { + expect(resolve(arrived).shipLocationId).toBe("moon"); + expect(resolveDispatch({ ...arrived, now: record.arrivalAt + 60_000 }, content).status).toBe( + "resolved", + ); + }); + + it("deducts pilot pay for a hired pilot and grants XP", () => { + const resolution = resolve(arrived); + expect(resolution.credits).toBe(2000 - content.config.pilotPayPerDelivery); + expect(resolution.pilotXp).toBeGreaterThan(0); + }); + + it("deducts no pilot pay when the avatar flies", () => { + expect(resolve({ ...arrived, pilotIsAvatar: true }).credits).toBe(2000); + }); + + it("pays exactly the stored tutorial share on a tutorial run", () => { + const tutorialRecord = createDispatchRecord( + { + id: "t1", + ship: { ...ship, id: "loaner" }, + pilot: { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null }, + contract: { ...contract, payout: 1000 }, + tutorial: true, + now, + worldSeed: 7, + }, + content, + ); + const resolution = resolve({ ...arrived, record: tutorialRecord, pilotIsAvatar: true }); + expect(resolution.credits).toBe(1000); + expect(resolution.shipLocationId).toBe("moon"); + }); + + it("marks an undiscovered destination as discovered", () => { + expect(resolve(arrived).discoversDestination).toBe(false); + expect(resolve({ ...arrived, discoveredIds: new Set(["earth"]) }).discoversDestination).toBe( + true, + ); + }); + + it("gives no credits or XP for a reposition", () => { + const reposition = createRepositionRecord( + { id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 }, + content, + ); + expect(resolve({ ...arrived, record: reposition, now: reposition.arrivalAt })).toEqual({ + credits: 0, + pilotXp: 0, + shipLocationId: "moon", + discoversDestination: false, + }); + }); + + it("returns identical results when resolving the same dispatch twice", () => { + expect(resolveDispatch(arrived, content)).toEqual(resolveDispatch(arrived, content)); + }); +});