From 1472dc6786fa32cd7555a5077bb2a4ff6ee63eda Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:49:46 -0400 Subject: [PATCH] Step 3.15: add in-flight position and progress Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 5 +++ memory-bank/progress.md | 2 +- packages/sim/src/flight.ts | 33 +++++++++++++++++ packages/sim/src/index.ts | 1 + packages/sim/test/flight.test.ts | 62 ++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/flight.ts create mode 100644 packages/sim/test/flight.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 0e2810a..a67530d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -158,3 +158,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Reposition check order:** ship idle → pilot idle → pilot assigned → not already there → bay at destination → landing → range → fuel credits. There are no skill checks (decision 0.5.24). Tutorial loaners cannot reposition. - **Contract and reposition records share one builder**; a reposition has `contract: null` and `expectedPayout: 0`. - **"Zero XP" is enforced at resolution** (Step 3.17), because the record has no XP field. That step's tests cover it. + +## Step 3.15 + +- **Progress is linear in real time.** Takeoff and landing overhead are not modelled separately, so the ship moves at a constant rate along its line. +- **A zero-length flight reports progress 1** instead of dividing by zero. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index e757f1e..673491d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -33,7 +33,7 @@ - [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. - [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. - [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). -- [ ] Step 3.15 — In-flight position and progress +- [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). - [ ] Step 3.16 — XP and leveling - [ ] Step 3.17 — Dispatch resolution - [ ] Step 3.18 — Sim package public API and coverage diff --git a/packages/sim/src/flight.ts b/packages/sim/src/flight.ts new file mode 100644 index 0000000..e35d588 --- /dev/null +++ b/packages/sim/src/flight.ts @@ -0,0 +1,33 @@ +import type { SimContent } from "./board"; +import { type RealTime, realToGame } from "./clock"; +import { type DispatchRecord, findLocation } from "./dispatch"; +import { locationPosition, type Position } from "./positions"; + +/** Fraction of the trip completed at a real time, clamped to [0, 1]. */ +export function dispatchProgress(record: DispatchRecord, now: RealTime): number { + const duration = record.arrivalAt - record.departureAt; + if (duration <= 0) return 1; + return Math.min(1, Math.max(0, (now - record.departureAt) / duration)); +} + +/** + * Ship position at a real time: a straight line from the origin's position at + * departure to the destination's position at arrival. + */ +export function dispatchPosition( + record: DispatchRecord, + now: RealTime, + content: SimContent, +): Position { + const { config } = content; + const start = locationPosition( + findLocation(record.originId, content), + realToGame(record.departureAt, config), + ); + const end = locationPosition( + findLocation(record.destinationId, content), + realToGame(record.arrivalAt, config), + ); + const t = dispatchProgress(record, now); + return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t }; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index a3b367a..ca007fe 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -10,3 +10,4 @@ export * from "./board"; export * from "./tutorial"; export * from "./pilot"; export * from "./dispatch"; +export * from "./flight"; diff --git a/packages/sim/test/flight.test.ts b/packages/sim/test/flight.test.ts new file mode 100644 index 0000000..6319269 --- /dev/null +++ b/packages/sim/test/flight.test.ts @@ -0,0 +1,62 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + createRepositionRecord, + dispatchPosition, + dispatchProgress, + locationPosition, + realToGame, +} from "../src/index"; + +const record = createRepositionRecord( + { + id: "r1", + ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, + pilot: { id: "p1", isAvatar: false, assignedShipId: "s1", inFlight: false, skills: {} }, + destinationId: "moon", + now: Date.parse("2100-01-01T00:30:00Z"), + worldSeed: 7, + }, + content, +); +const midpoint = (record.departureAt + record.arrivalAt) / 2; + +describe("in-flight progress", () => { + it("is 0 at departure, 0.5 at the midpoint and 1 at arrival", () => { + expect(dispatchProgress(record, record.departureAt)).toBe(0); + expect(dispatchProgress(record, midpoint)).toBe(0.5); + expect(dispatchProgress(record, record.arrivalAt)).toBe(1); + }); + + it("stays clamped outside the flight", () => { + expect(dispatchProgress(record, record.departureAt - 60_000)).toBe(0); + expect(dispatchProgress(record, record.arrivalAt + 3_600_000)).toBe(1); + }); +}); + +describe("in-flight position", () => { + const origin = locationPosition( + content.location("earth")!, + realToGame(record.departureAt, content.config), + ); + const destination = locationPosition( + content.location("moon")!, + realToGame(record.arrivalAt, content.config), + ); + + it("starts at the origin's position at departure", () => { + expect(dispatchPosition(record, record.departureAt, content)).toEqual(origin); + }); + + it("ends at the destination's position at arrival", () => { + const end = dispatchPosition(record, record.arrivalAt, content); + expect(end.x).toBeCloseTo(destination.x, 9); + expect(end.y).toBeCloseTo(destination.y, 9); + }); + + it("is halfway along the line at the midpoint", () => { + const mid = dispatchPosition(record, midpoint, content); + expect(mid.x).toBeCloseTo((origin.x + destination.x) / 2, 9); + expect(mid.y).toBeCloseTo((origin.y + destination.y) / 2, 9); + }); +});