Step 3.14: add reposition dispatch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e3967c10e4
commit
039df0ac5b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue