Step 3.12: add dispatch validation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dfd76ba9bc
commit
1e3b45b529
|
|
@ -136,3 +136,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Pilot skills are a `{ skillId: level }` map**; a missing skill counts as level 0.
|
- **Pilot skills are a `{ skillId: level }` map**; a missing skill counts as level 0.
|
||||||
- **The Long Haul skill id is the constant `long-haul`** in sim, because the rule (decision 0.5.7) is specifically about that skill.
|
- **The Long Haul skill id is the constant `long-haul`** in sim, because the rule (decision 0.5.7) is specifically about that skill.
|
||||||
- **The cargo skill is checked before Long Haul** when reporting a failure reason.
|
- **The cargo skill is checked before Long Haul** when reporting a failure reason.
|
||||||
|
|
||||||
|
## Step 3.12
|
||||||
|
|
||||||
|
- **Minimal state shapes:** `ShipState` { id, modelId, locationId, inFlight } and `PilotState` { id, isAvatar, assignedShipId, inFlight, skills }. The server maps its database rows to these.
|
||||||
|
- **Tutorial dispatch:** the caller passes the loaner as the ship (a `ShipState` built from the player's `tutorial` object, decision 0.5.26) and sets `tutorial: true`. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that.
|
||||||
|
- **Checks run in the plan's order**, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits.
|
||||||
|
- **Fuel is charged in whole credits** (`tripFuelCost` rounds), using the distance at departure (`now`).
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
- [x] **Step 3.9 — Contract board generation.** `src/board.ts`: `generateContractBoard` (seeded per world, bay and period), plus `boardPeriodIndex`, `requiredLongHaulLevel` and the `SimContent` input type. First contact ≈10% over 1,000 boards.
|
- [x] **Step 3.9 — Contract board generation.** `src/board.ts`: `generateContractBoard` (seeded per world, bay and period), plus `boardPeriodIndex`, `requiredLongHaulLevel` and the `SimContent` input type. First contact ≈10% over 1,000 boards.
|
||||||
- [x] **Step 3.10 — Tutorial contract board.** `src/tutorial.ts`: `generateTutorialBoard` (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); `loanerModel`. `SimContent` gained `shipModels`.
|
- [x] **Step 3.10 — Tutorial contract board.** `src/tutorial.ts`: `generateTutorialBoard` (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); `loanerModel`. `SimContent` gained `shipModels`.
|
||||||
- [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.11 — Pilot eligibility.** `src/pilot.ts`: `checkPilotEligibility(skills, contract)` returns `ok` or `cargo-skill-too-low` / `long-haul-too-low`.
|
||||||
- [ ] Step 3.12 — Dispatch validation
|
- [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.
|
||||||
- [ ] Step 3.13 — Dispatch record creation
|
- [ ] Step 3.13 — Dispatch record creation
|
||||||
- [ ] Step 3.14 — Reposition dispatch
|
- [ ] Step 3.14 — Reposition dispatch
|
||||||
- [ ] Step 3.15 — In-flight position and progress
|
- [ ] Step 3.15 — In-flight position and progress
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import type { Location, ShipModel } from "@deadhead/content";
|
||||||
|
import type { SimContent } from "./board";
|
||||||
|
import { type GameTime, type RealTime, realToGame } from "./clock";
|
||||||
|
import type { Contract } from "./contract";
|
||||||
|
import { routeDistance } from "./distance";
|
||||||
|
import { checkRoute } from "./eligibility";
|
||||||
|
import { fuelCost } from "./fuel";
|
||||||
|
import { checkPilotEligibility, type SkillLevels } from "./pilot";
|
||||||
|
|
||||||
|
export interface ShipState {
|
||||||
|
id: string;
|
||||||
|
modelId: string;
|
||||||
|
locationId: string;
|
||||||
|
inFlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PilotState {
|
||||||
|
id: string;
|
||||||
|
/** The player's own pilot: exempt from pilot pay (decision 0.5.12). */
|
||||||
|
isAvatar: boolean;
|
||||||
|
assignedShipId: string | null;
|
||||||
|
inFlight: boolean;
|
||||||
|
skills: SkillLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchProposal {
|
||||||
|
/** For a tutorial run, the company loaner (decision 0.5.26: it is not in the ships list). */
|
||||||
|
ship: ShipState;
|
||||||
|
pilot: PilotState;
|
||||||
|
contract: Contract;
|
||||||
|
/** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */
|
||||||
|
tutorial: boolean;
|
||||||
|
credits: number;
|
||||||
|
now: RealTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DispatchFailure =
|
||||||
|
| "ship-not-idle"
|
||||||
|
| "ship-not-at-origin"
|
||||||
|
| "pilot-not-idle"
|
||||||
|
| "pilot-not-assigned"
|
||||||
|
| "over-capacity"
|
||||||
|
| "cannot-land"
|
||||||
|
| "out-of-range"
|
||||||
|
| "cargo-skill-too-low"
|
||||||
|
| "long-haul-too-low"
|
||||||
|
| "contract-expired"
|
||||||
|
| "insufficient-credits";
|
||||||
|
|
||||||
|
export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure };
|
||||||
|
|
||||||
|
export function findShipModel(id: string, content: SimContent): ShipModel {
|
||||||
|
const model = content.shipModels.find((s) => s.id === id);
|
||||||
|
if (!model) throw new Error(`Unknown ship model ${id}`);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLocation(id: string, content: SimContent): Location {
|
||||||
|
const location = content.locations.find((l) => l.id === id);
|
||||||
|
if (!location) throw new Error(`Unknown location ${id}`);
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuel charged for a trip, in whole credits, using the distance at departure. */
|
||||||
|
export function tripFuelCost(
|
||||||
|
model: ShipModel,
|
||||||
|
origin: Location,
|
||||||
|
destination: Location,
|
||||||
|
departure: GameTime,
|
||||||
|
): number {
|
||||||
|
return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason });
|
||||||
|
|
||||||
|
/** Validates a proposed contract dispatch, returning the first failing reason. */
|
||||||
|
export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck {
|
||||||
|
const { ship, pilot, contract, tutorial, credits, now } = proposal;
|
||||||
|
const model = findShipModel(ship.modelId, content);
|
||||||
|
const origin = findLocation(contract.originId, content);
|
||||||
|
const destination = findLocation(contract.destinationId, content);
|
||||||
|
const departure = realToGame(now, content.config);
|
||||||
|
|
||||||
|
if (ship.inFlight) return fail("ship-not-idle");
|
||||||
|
if (ship.locationId !== contract.originId) return fail("ship-not-at-origin");
|
||||||
|
if (pilot.inFlight) return fail("pilot-not-idle");
|
||||||
|
// Decision 0.5.23: during the tutorial the avatar flies only the loaner.
|
||||||
|
if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) {
|
||||||
|
return fail("pilot-not-assigned");
|
||||||
|
}
|
||||||
|
if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity");
|
||||||
|
|
||||||
|
const route = checkRoute(model, origin, destination, departure);
|
||||||
|
if (!route.ok) return fail(route.reason);
|
||||||
|
|
||||||
|
const pilotCheck = checkPilotEligibility(pilot.skills, contract);
|
||||||
|
if (!pilotCheck.ok) return fail(pilotCheck.reason);
|
||||||
|
|
||||||
|
if (now >= contract.expiresAt) return fail("contract-expired");
|
||||||
|
|
||||||
|
if (!tutorial) {
|
||||||
|
if (credits < tripFuelCost(model, origin, destination, departure))
|
||||||
|
return fail("insufficient-credits");
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -9,3 +9,4 @@ export * from "./contract";
|
||||||
export * from "./board";
|
export * from "./board";
|
||||||
export * from "./tutorial";
|
export * from "./tutorial";
|
||||||
export * from "./pilot";
|
export * from "./pilot";
|
||||||
|
export * from "./dispatch";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { content as defaultContent, type ShipModel } from "@deadhead/content";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
validateDispatch,
|
||||||
|
type Contract,
|
||||||
|
type DispatchProposal,
|
||||||
|
type PilotState,
|
||||||
|
type ShipState,
|
||||||
|
type SimContent,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
/** Default content plus a test-only ship that cannot land on atmospheric worlds. */
|
||||||
|
const vacuumModel: ShipModel = {
|
||||||
|
...defaultContent.shipModel("courier")!,
|
||||||
|
id: "test-vacuum",
|
||||||
|
landingClass: "vacuum",
|
||||||
|
};
|
||||||
|
const content: SimContent = {
|
||||||
|
...defaultContent,
|
||||||
|
shipModels: [...defaultContent.shipModels, vacuumModel],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now };
|
||||||
|
|
||||||
|
const reason = (proposal: DispatchProposal) => {
|
||||||
|
const result = validateDispatch(proposal, content);
|
||||||
|
return result.ok ? "valid" : result.reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("dispatch validation", () => {
|
||||||
|
it("accepts a fully valid dispatch", () => {
|
||||||
|
expect(reason(valid)).toBe("valid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ship that is in flight", () => {
|
||||||
|
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a ship that is not at the contract origin", () => {
|
||||||
|
expect(reason({ ...valid, ship: { ...ship, locationId: "moon" } })).toBe("ship-not-at-origin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a pilot who is in flight", () => {
|
||||||
|
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a pilot assigned to another ship", () => {
|
||||||
|
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: "s2" } })).toBe(
|
||||||
|
"pilot-not-assigned",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cargo heavier than the ship's capacity", () => {
|
||||||
|
expect(reason({ ...valid, contract: { ...contract, massTonnes: 50 } })).toBe("over-capacity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a destination the ship cannot land at", () => {
|
||||||
|
expect(
|
||||||
|
reason({
|
||||||
|
...valid,
|
||||||
|
ship: { ...ship, modelId: "test-vacuum", locationId: "moon" },
|
||||||
|
contract: { ...contract, originId: "moon", destinationId: "earth" },
|
||||||
|
}),
|
||||||
|
).toBe("cannot-land");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a destination out of the ship's range", () => {
|
||||||
|
expect(reason({ ...valid, contract: { ...contract, destinationId: "mars" } })).toBe(
|
||||||
|
"out-of-range",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a pilot without the required cargo skill", () => {
|
||||||
|
const heavy = {
|
||||||
|
...contract,
|
||||||
|
cargoTypeId: "heavy",
|
||||||
|
tier: 1,
|
||||||
|
requiredSkill: { skillId: "heavy-cargo", level: 1 },
|
||||||
|
};
|
||||||
|
expect(reason({ ...valid, contract: heavy })).toBe("cargo-skill-too-low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a pilot without the required Long Haul level", () => {
|
||||||
|
expect(reason({ ...valid, contract: { ...contract, requiredLongHaulLevel: 1 } })).toBe(
|
||||||
|
"long-haul-too-low",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired contract", () => {
|
||||||
|
expect(reason({ ...valid, now: contract.expiresAt })).toBe("contract-expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a dispatch the player cannot afford fuel for", () => {
|
||||||
|
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tutorial runs", () => {
|
||||||
|
const loaner: ShipState = {
|
||||||
|
id: "loaner",
|
||||||
|
modelId: "courier",
|
||||||
|
locationId: "earth",
|
||||||
|
inFlight: false,
|
||||||
|
};
|
||||||
|
const avatar: PilotState = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null };
|
||||||
|
const tutorial: DispatchProposal = { ...valid, ship: loaner, pilot: avatar, tutorial: true };
|
||||||
|
|
||||||
|
it("accepts the avatar in the loaner without credits for fuel", () => {
|
||||||
|
expect(reason({ ...tutorial, credits: 0 })).toBe("valid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a hired pilot in the loaner", () => {
|
||||||
|
expect(reason({ ...tutorial, pilot })).toBe("pilot-not-assigned");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue