Step 3.14: add reposition dispatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 18:49:14 -04:00
parent e3967c10e4
commit 039df0ac5b
4 changed files with 177 additions and 7 deletions

View File

@ -151,3 +151,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 3.14
- **A reposition needs a player bay at the destination**, checked against `bayLocationIds`. Bays exist only at discovered locations, so this also guarantees the destination is discovered.
- **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.

View File

@ -32,7 +32,7 @@
- [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.
- [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
- [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
- [ ] Step 3.16 — XP and leveling
- [ ] Step 3.17 — Dispatch resolution

View File

@ -137,13 +137,25 @@ export interface RecordInput {
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;
function buildRecord(
args: {
id: string;
ship: ShipState;
pilot: PilotState;
contract: Contract | null;
originId: string;
destinationId: string;
tutorial: boolean;
now: RealTime;
worldSeed: Seed;
},
content: SimContent,
): DispatchRecord {
const { id, ship, pilot, contract, tutorial, now, worldSeed } = args;
const { config } = content;
const model = findShipModel(ship.modelId, content);
const origin = findLocation(contract.originId, content);
const destination = findLocation(contract.destinationId, content);
const origin = findLocation(args.originId, content);
const destination = findLocation(args.destinationId, content);
const departure = realToGame(now, config);
const distance = routeDistance(origin, destination, departure);
const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR;
@ -160,7 +172,88 @@ export function createDispatchRecord(input: RecordInput, content: SimContent): D
arrivalAt: now + Math.round(travelGameMs / config.timeCompression),
distanceMillionKm: distance,
fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure),
expectedPayout: contract.payout,
expectedPayout: contract?.payout ?? 0,
seed: deriveSeed(worldSeed, `dispatch:${id}`),
};
}
/** Turns an already-validated dispatch into its record. Same inputs give the same record. */
export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord {
const { contract } = input;
return buildRecord(
{ ...input, originId: contract.originId, destinationId: contract.destinationId },
content,
);
}
export interface RepositionProposal {
ship: ShipState;
pilot: PilotState;
destinationId: string;
/** Locations where the player has a bay (bays exist only at discovered locations). */
bayLocationIds: ReadonlySet<string>;
credits: number;
now: RealTime;
}
export type RepositionFailure =
| "ship-not-idle"
| "pilot-not-idle"
| "pilot-not-assigned"
| "already-there"
| "no-bay-at-destination"
| "cannot-land"
| "out-of-range"
| "insufficient-credits";
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
/**
* Validates an empty flight to one of the player's bays. Only range and landing
* apply; no skills are needed (decision 0.5.24).
*/
export function validateReposition(
proposal: RepositionProposal,
content: SimContent,
): RepositionCheck {
const { ship, pilot, destinationId, bayLocationIds, credits, now } = proposal;
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
if (ship.inFlight) return failWith("ship-not-idle");
if (pilot.inFlight) return failWith("pilot-not-idle");
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
if (destinationId === ship.locationId) return failWith("already-there");
if (!bayLocationIds.has(destinationId)) return failWith("no-bay-at-destination");
const model = findShipModel(ship.modelId, content);
const origin = findLocation(ship.locationId, content);
const destination = findLocation(destinationId, content);
const departure = realToGame(now, content.config);
const route = checkRoute(model, origin, destination, departure);
if (!route.ok) return failWith(route.reason);
if (credits < tripFuelCost(model, origin, destination, departure)) {
return failWith("insufficient-credits");
}
return { ok: true };
}
export interface RepositionRecordInput {
id: string;
ship: ShipState;
pilot: PilotState;
destinationId: string;
now: RealTime;
worldSeed: Seed;
}
/** Record for a validated reposition: no contract, fuel only, zero payout. */
export function createRepositionRecord(
input: RepositionRecordInput,
content: SimContent,
): DispatchRecord {
return buildRecord(
{ ...input, contract: null, originId: input.ship.locationId, tutorial: false },
content,
);
}

View File

@ -0,0 +1,70 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import {
createRepositionRecord,
validateReposition,
type PilotState,
type RepositionProposal,
type ShipState,
} from "../src/index";
const now = Date.parse("2100-01-01T00:30:00Z");
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
const pilot: PilotState = {
id: "p1",
isAvatar: false,
assignedShipId: "s1",
inFlight: false,
skills: {},
};
const valid: RepositionProposal = {
ship,
pilot,
destinationId: "moon",
bayLocationIds: new Set(["earth", "moon"]),
credits: 10_000,
now,
};
const reason = (proposal: RepositionProposal) => {
const result = validateReposition(proposal, content);
return result.ok ? "valid" : result.reason;
};
describe("reposition", () => {
it("accepts an empty flight to a bay in range, with no skills needed", () => {
expect(reason(valid)).toBe("valid");
});
it("fails validation when the destination has no player bay", () => {
expect(reason({ ...valid, bayLocationIds: new Set(["earth"]) })).toBe("no-bay-at-destination");
});
it("still applies range and fuel checks", () => {
expect(
reason({ ...valid, destinationId: "mars", bayLocationIds: new Set(["earth", "mars"]) }),
).toBe("out-of-range");
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
});
it("rejects flying to where the ship already is, or with a busy or unassigned crew", () => {
expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there");
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
"pilot-not-assigned",
);
});
it("creates a record with no contract, zero payout and a fuel charge", () => {
const record = createRepositionRecord(
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
content,
);
expect(record.contract).toBeNull();
expect(record.expectedPayout).toBe(0);
expect(record.fuelCost).toBeGreaterThan(0);
expect([record.originId, record.destinationId]).toEqual(["earth", "moon"]);
expect(record.arrivalAt).toBeGreaterThan(record.departureAt);
});
});