Step 3.15: add in-flight position and progress

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:49:46 -04:00
parent 039df0ac5b
commit 1472dc6786
5 changed files with 102 additions and 1 deletions

View File

@ -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. - **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`. - **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. - **"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.

View File

@ -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.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.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). - [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.16 — XP and leveling
- [ ] Step 3.17 — Dispatch resolution - [ ] Step 3.17 — Dispatch resolution
- [ ] Step 3.18 — Sim package public API and coverage - [ ] Step 3.18 — Sim package public API and coverage

View File

@ -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 };
}

View File

@ -10,3 +10,4 @@ export * from "./board";
export * from "./tutorial"; export * from "./tutorial";
export * from "./pilot"; export * from "./pilot";
export * from "./dispatch"; export * from "./dispatch";
export * from "./flight";

View File

@ -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);
});
});