Step 4.1: define API data shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 20:50:48 -04:00
parent f33a9155cc
commit f95acd06ba
12 changed files with 593 additions and 23 deletions

View File

@ -182,3 +182,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Explicit public API.** `packages/sim/src/index.ts` lists every export. Internal helpers (`findLocation`, `findShipModel`, `standardCargo`, `buildContract`, `skillLevel`) are not exported; tests that need one import it from its module. - **Explicit public API.** `packages/sim/src/index.ts` lists every export. Internal helpers (`findLocation`, `findShipModel`, `standardCargo`, `buildContract`, `skillLevel`) are not exported; tests that need one import it from its module.
- **Coverage** uses `@vitest/coverage-v8`, configured in the root `vitest.config.ts` to measure only `packages/sim/src` with a 90% line threshold. `pnpm coverage` runs the whole suite with coverage, and CI runs it in place of `pnpm test`, so a coverage drop fails CI. - **Coverage** uses `@vitest/coverage-v8`, configured in the root `vitest.config.ts` to measure only `packages/sim/src` with a 90% line threshold. `pnpm coverage` runs the whole suite with coverage, and CI runs it in place of `pnpm test`, so a coverage drop fails CI.
- **The Step 1.3 smoke test is removed** now that the sim package has real tests. - **The Step 1.3 smoke test is removed** now that the sim package has real tests.
## Step 4.1
- **`@deadhead/schema` depends on `@deadhead/sim`** so error codes are built from sim's `DISPATCH_FAILURES` and `REPOSITION_FAILURES`, which replace the earlier type-only unions. A sim reason cannot exist without a matching API error code.
- **The API carries only per-player data.** Location details, ship models, cargo and config come from `@deadhead/content`; the API sends ids plus per-player flags (`LocationState` is just `{ id, discovered }`).
- **Times are Unix-ms numbers** in the API too, matching sim, so the client can pass them straight to sim functions.
- **Player state:** `tutorial` is `{ loanerLocationId, runInFlight }`, or `null` once the tutorial is over (decision 0.5.26). In-flight dispatches, including the tutorial run, are in `activeDispatches`; ships point to theirs through `dispatchId`. Unacknowledged summaries are in `summaries`.
- **Ship and pilot `locationId` is null while in flight.** The client gets in-flight positions from the dispatch.
- **One dispatch request, three kinds:** `contract` (contract + ship + pilot), `tutorial` (contract only; the avatar flies the loaner), `reposition` (ship + pilot + destination).
- **Create-guest returns the new player's id and full state.** The session itself travels as a cookie (decision 0.5.20).
- **Compile-time tests** check that the schema `Contract` and sim `Contract` are mutually assignable, and that a sim `DispatchRecord` fits the API `Dispatch`.

View File

@ -0,0 +1,40 @@
# Architecture
Living reference for how the code is organised. Update it after any database schema or API change (implementation plan rule 0.1.9). Decisions and their reasons live in `DECISIONS.md`.
## Packages
| Package | Role | Depends on |
|---|---|---|
| `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod |
| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. | content, astronomy-engine |
| `packages/schema` | Zod schemas for every API request, response and error. Shared by server and client. | sim (error codes), zod |
| `apps/server` | Fastify API, Postgres via Drizzle, pg-boss workers. The authority on outcomes. | content, sim, schema |
| `apps/web` | React client. Predicts with sim, never decides outcomes. | content, sim, schema |
## Conventions
- **Times** are Unix milliseconds (numbers). A *real* time is a wall-clock instant; a *game* time is the instant of the in-game date (`realToGame` / `gameToReal` in sim).
- **Ids** are non-empty strings. Content ids are kebab-case (`earth`, `bulk-hauler`).
- **Credits** are whole numbers.
- **Static vs. player data:** the API never sends static data. The client imports `@deadhead/content` and joins by id.
## API data model (`packages/schema`)
| Shape | Fields |
|---|---|
| `ServerTime` | `realTime`, `gameTime` |
| `Company` | `id`, `name`, `credits` |
| `Ship` | `id`, `modelId`, `name`, `status` (`idle` / `in-flight`), `locationId` (null in flight), `dispatchId` (null when idle), `pilotId` |
| `Pilot` | `id`, `name`, `isAvatar`, `xp`, `level`, `skills` (`{ skillId: level }`), `skillPointsAvailable`, `assignedShipId`, `status`, `locationId` (null in flight) |
| `Bay` | `id`, `locationId` |
| `LocationState` | `id`, `discovered` |
| `Contract` | mirrors sim `Contract` |
| `Dispatch` | mirrors sim `DispatchRecord` + `status` (`in-flight` / `resolved`), `resolvedAt` |
| `ResolutionSummary` | `dispatchId`, `shipId`, `pilotId`, `originId`, `destinationId`, `tutorial`, `credits`, `pilotXp`, `discoveredLocationId`, `resolvedAt` |
| `TutorialState` | `loanerLocationId` (`earth` / `moon`), `runInFlight` |
| `PlayerState` | `company`, `ships`, `pilots`, `bays`, `locations`, `tutorial` (null once over), `activeDispatches`, `summaries` |
### Errors
Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged, plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...).

View File

@ -37,3 +37,8 @@
- [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`. - [x] **Step 3.16 — XP and leveling.** `src/xp.ts`: `deliveryXp`, `pilotLevel`, `skillPointsAvailable`.
- [x] **Step 3.17 — Dispatch resolution.** `src/resolution.ts`: `resolveDispatch` returns `in-flight` or a `Resolution` (credits after pilot pay, XP, new ship location, discovery). - [x] **Step 3.17 — Dispatch resolution.** `src/resolution.ts`: `resolveDispatch` returns `in-flight` or a `Resolution` (credits after pilot pay, XP, new ship location, discovery).
- [x] **Step 3.18 — Public API and coverage.** `src/index.ts` exports an explicit list (content lookups and contract building stay internal). `pnpm coverage` (V8, `packages/sim/src`, 90% line threshold) reports 100% lines; CI now runs `pnpm coverage` instead of `pnpm test`. - [x] **Step 3.18 — Public API and coverage.** `src/index.ts` exports an explicit list (content lookups and contract building stay internal). `pnpm coverage` (V8, `packages/sim/src`, 90% line threshold) reports 100% lines; CI now runs `pnpm coverage` instead of `pnpm test`.
## Phase 4 — Shared Schema Package
- [x] **Step 4.1 — API data shapes.** `packages/schema/src`: entity schemas (`entities.ts`), error format and codes (`errors.ts`), request/response bodies (`requests.ts`). Error codes include every sim validation reason; sim now exports `DISPATCH_FAILURES` / `REPOSITION_FAILURES`. `architecture.md` started with the API data model.
- [ ] Step 4.2 — API route catalog

View File

@ -7,5 +7,9 @@
"types": "./src/index.ts", "types": "./src/index.ts",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
},
"dependencies": {
"@deadhead/sim": "workspace:*",
"zod": "^4.6.5"
} }
} }

View File

@ -0,0 +1,141 @@
import { z } from "zod";
/**
* Per-player data sent over the API. Static data (location details, ship
* models, cargo, skills, config) comes from `@deadhead/content`; these shapes
* refer to it by id. Times are Unix milliseconds, as in `@deadhead/sim`.
*/
export const idSchema = z.string().min(1);
export const timeSchema = z.number().int().nonnegative();
const credits = z.number().int();
export const serverTimeSchema = z.object({
realTime: timeSchema,
/** In-game instant (sim `realToGame` of `realTime`). */
gameTime: z.number().int(),
});
export type ServerTime = z.infer<typeof serverTimeSchema>;
export const companySchema = z.object({
id: idSchema,
name: z.string().min(1),
/** May go negative only through pilot pay on a low payout (see DECISIONS Step 3.17). */
credits,
});
export type Company = z.infer<typeof companySchema>;
export const shipSchema = z.object({
id: idSchema,
modelId: idSchema,
name: z.string().min(1),
status: z.enum(["idle", "in-flight"]),
/** Where an idle ship is; null while in flight. */
locationId: idSchema.nullable(),
/** The active dispatch while in flight; null when idle. */
dispatchId: idSchema.nullable(),
pilotId: idSchema.nullable(),
});
export type Ship = z.infer<typeof shipSchema>;
export const pilotSchema = z.object({
id: idSchema,
name: z.string().min(1),
isAvatar: z.boolean(),
xp: z.number().int().nonnegative(),
level: z.number().int().nonnegative(),
/** Skill levels by skill id; a missing skill is level 0. */
skills: z.record(z.string(), z.number().int().nonnegative()),
skillPointsAvailable: z.number().int().nonnegative(),
assignedShipId: idSchema.nullable(),
status: z.enum(["idle", "in-flight"]),
/** Where the pilot is (their ship's location if assigned); null while in flight. */
locationId: idSchema.nullable(),
});
export type Pilot = z.infer<typeof pilotSchema>;
export const baySchema = z.object({
id: idSchema,
locationId: idSchema,
});
export type Bay = z.infer<typeof baySchema>;
export const locationStateSchema = z.object({
id: idSchema,
discovered: z.boolean(),
});
export type LocationState = z.infer<typeof locationStateSchema>;
/** Mirrors the sim `Contract`. */
export const contractSchema = z.object({
id: idSchema,
originId: idSchema,
destinationId: idSchema,
cargoTypeId: idSchema,
tier: z.number().int().min(0),
massTonnes: z.number().positive(),
payout: credits,
requiredSkill: z.object({ skillId: idSchema, level: z.number().int().positive() }).nullable(),
requiredLongHaulLevel: z.number().int().nonnegative(),
offeredAt: timeSchema,
expiresAt: timeSchema,
firstContact: z.boolean(),
});
export type Contract = z.infer<typeof contractSchema>;
/** Mirrors the sim `DispatchRecord`, plus server-side status. */
export const dispatchSchema = z.object({
id: idSchema,
shipId: idSchema,
pilotId: idSchema,
contract: contractSchema.nullable(),
originId: idSchema,
destinationId: idSchema,
tutorial: z.boolean(),
departureAt: timeSchema,
arrivalAt: timeSchema,
distanceMillionKm: z.number().nonnegative(),
fuelCost: credits.nonnegative(),
expectedPayout: credits,
seed: z.number().int().nonnegative(),
status: z.enum(["in-flight", "resolved"]),
resolvedAt: timeSchema.nullable(),
});
export type Dispatch = z.infer<typeof dispatchSchema>;
/** One "while you were away" entry: a resolved dispatch not yet acknowledged. */
export const resolutionSummarySchema = z.object({
dispatchId: idSchema,
shipId: idSchema,
pilotId: idSchema,
originId: idSchema,
destinationId: idSchema,
tutorial: z.boolean(),
credits,
pilotXp: z.number().int().nonnegative(),
/** Set when the delivery discovered its destination. */
discoveredLocationId: idSchema.nullable(),
resolvedAt: timeSchema,
});
export type ResolutionSummary = z.infer<typeof resolutionSummarySchema>;
/** Decision 0.5.26: the loaner is not in the ships list. Null once the tutorial ends. */
export const tutorialStateSchema = z.object({
loanerLocationId: z.enum(["earth", "moon"]),
runInFlight: z.boolean(),
});
export type TutorialState = z.infer<typeof tutorialStateSchema>;
export const playerStateSchema = z.object({
company: companySchema,
ships: z.array(shipSchema),
pilots: z.array(pilotSchema),
bays: z.array(baySchema),
locations: z.array(locationStateSchema),
tutorial: tutorialStateSchema.nullable(),
/** In-flight dispatches, including the tutorial run. */
activeDispatches: z.array(dispatchSchema),
/** Unacknowledged "while you were away" entries. */
summaries: z.array(resolutionSummarySchema),
});
export type PlayerState = z.infer<typeof playerStateSchema>;

View File

@ -0,0 +1,48 @@
import {
DISPATCH_FAILURES,
REPOSITION_FAILURES,
type DispatchFailure,
type RepositionFailure,
} from "@deadhead/sim";
import { z } from "zod";
/** Error codes the server adds on top of the sim's validation reasons. */
export const SERVER_ERROR_CODES = [
"validation-error",
"unauthorized",
"not-found",
"internal-error",
"insufficient-funds",
"not-in-tutorial",
"tutorial-run-in-flight",
"unknown-contract",
"contract-already-accepted",
"location-undiscovered",
"bay-already-leased",
"no-bay-at-location",
"ship-not-idle-here",
"ship-crewed",
"pilot-already-assigned",
"pilot-not-assigned-to-ship",
"no-skill-points",
"skill-maxed",
"unknown-skill",
] as const;
export type ErrorCode = DispatchFailure | RepositionFailure | (typeof SERVER_ERROR_CODES)[number];
/** Every machine-readable error code. Sim validation reasons are used unchanged. */
export const ERROR_CODES = [
...new Set<ErrorCode>([...DISPATCH_FAILURES, ...REPOSITION_FAILURES, ...SERVER_ERROR_CODES]),
] as [ErrorCode, ...ErrorCode[]];
export const errorCodeSchema = z.enum(ERROR_CODES);
/** Body of every non-2xx response. */
export const errorResponseSchema = z.object({
error: z.object({
code: errorCodeSchema,
message: z.string().min(1),
}),
});
export type ErrorResponse = z.infer<typeof errorResponseSchema>;

View File

@ -1 +1,3 @@
export {}; export * from "./entities";
export * from "./errors";
export * from "./requests";

View File

@ -0,0 +1,65 @@
import { z } from "zod";
import {
baySchema,
contractSchema,
dispatchSchema,
idSchema,
pilotSchema,
playerStateSchema,
shipSchema,
timeSchema,
} from "./entities";
/** Request with no body or parameters. */
export const emptyRequestSchema = z.object({}).strict();
export const createGuestResponseSchema = z.object({
playerId: idSchema,
state: playerStateSchema,
});
export const contractBoardRequestSchema = z.object({ locationId: idSchema });
export const contractBoardResponseSchema = z.object({
contracts: z.array(contractSchema),
/** When this board period ends and every contract on it expires. */
periodEndsAt: timeSchema,
});
export const createDispatchRequestSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("contract"),
contractId: idSchema,
shipId: idSchema,
pilotId: idSchema,
}),
/** The avatar flies the loaner; no ship or pilot to choose. */
z.object({ kind: z.literal("tutorial"), contractId: idSchema }),
z.object({
kind: z.literal("reposition"),
shipId: idSchema,
pilotId: idSchema,
destinationId: idSchema,
}),
]);
export type CreateDispatchRequest = z.infer<typeof createDispatchRequestSchema>;
export const dispatchResponseSchema = z.object({ dispatch: dispatchSchema });
export const buyShipRequestSchema = z.object({ modelId: idSchema });
export const shipResponseSchema = z.object({ ship: shipSchema });
export const leaseBayRequestSchema = z.object({ locationId: idSchema });
export const bayResponseSchema = z.object({ bay: baySchema });
/** Decision 0.5.22: hire at one of the player's bays. */
export const hirePilotRequestSchema = z.object({ locationId: idSchema });
export const assignPilotRequestSchema = z.object({ pilotId: idSchema, shipId: idSchema });
export const unassignPilotRequestSchema = z.object({ pilotId: idSchema });
export const spendSkillPointRequestSchema = z.object({ pilotId: idSchema, skillId: idSchema });
export const pilotResponseSchema = z.object({ pilot: pilotSchema });
export const acknowledgeSummariesRequestSchema = z.object({
dispatchIds: z.array(idSchema).min(1),
});
export const acknowledgeSummariesResponseSchema = z.object({
acknowledged: z.number().int().nonnegative(),
});

View File

@ -0,0 +1,239 @@
import {
DISPATCH_FAILURES,
REPOSITION_FAILURES,
type Contract as SimContract,
type DispatchRecord,
} from "@deadhead/sim";
import { describe, expect, expectTypeOf, it } from "vitest";
import type { z } from "zod";
import {
acknowledgeSummariesRequestSchema,
acknowledgeSummariesResponseSchema,
assignPilotRequestSchema,
baySchema,
bayResponseSchema,
buyShipRequestSchema,
companySchema,
contractBoardRequestSchema,
contractBoardResponseSchema,
contractSchema,
createDispatchRequestSchema,
createGuestResponseSchema,
dispatchResponseSchema,
dispatchSchema,
emptyRequestSchema,
ERROR_CODES,
errorResponseSchema,
hirePilotRequestSchema,
leaseBayRequestSchema,
locationStateSchema,
pilotResponseSchema,
pilotSchema,
playerStateSchema,
resolutionSummarySchema,
serverTimeSchema,
shipResponseSchema,
shipSchema,
spendSkillPointRequestSchema,
tutorialStateSchema,
unassignPilotRequestSchema,
type Contract,
type Dispatch,
} from "../src/index";
const company = { id: "co1", name: "Deadhead Freight", credits: 5000 };
const ship = {
id: "s1",
modelId: "courier",
name: "Dusty Mule",
status: "idle",
locationId: "earth",
dispatchId: null,
pilotId: "p1",
};
const pilot = {
id: "p1",
name: "Avery Kade",
isAvatar: true,
xp: 120,
level: 1,
skills: { "long-haul": 1 },
skillPointsAvailable: 1,
assignedShipId: "s1",
status: "idle",
locationId: "earth",
};
const bay = { id: "b1", locationId: "earth" };
const location = { id: "mars", discovered: false };
const contract = {
id: "earth:1:0",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
tier: 0,
massTonnes: 12,
payout: 480,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: 3_600_000,
expiresAt: 7_200_000,
firstContact: false,
};
const dispatch = {
id: "d1",
shipId: "s1",
pilotId: "p1",
contract,
originId: "earth",
destinationId: "moon",
tutorial: false,
departureAt: 4_000_000,
arrivalAt: 4_130_000,
distanceMillionKm: 0.39,
fuelCost: 234,
expectedPayout: 480,
seed: 123456,
status: "in-flight",
resolvedAt: null,
};
const summary = {
dispatchId: "d0",
shipId: "s1",
pilotId: "p1",
originId: "moon",
destinationId: "earth",
tutorial: true,
credits: 240,
pilotXp: 10,
discoveredLocationId: null,
resolvedAt: 3_900_000,
};
const tutorial = { loanerLocationId: "earth", runInFlight: false };
const playerState = {
company,
ships: [ship],
pilots: [pilot],
bays: [bay],
locations: [location],
tutorial,
activeDispatches: [dispatch],
summaries: [summary],
};
const cases: [string, z.ZodType, unknown, unknown][] = [
[
"server time",
serverTimeSchema,
{ realTime: 1, gameTime: 60 },
{ realTime: "now", gameTime: 60 },
],
["company", companySchema, company, { ...company, credits: 1.5 }],
["ship", shipSchema, ship, { ...ship, status: "lost" }],
["pilot", pilotSchema, pilot, { ...pilot, xp: -1 }],
["bay", baySchema, bay, { id: "b1" }],
["location", locationStateSchema, location, { id: "mars", discovered: "no" }],
["contract", contractSchema, contract, { ...contract, tier: -1 }],
["dispatch", dispatchSchema, dispatch, { ...dispatch, status: "lost" }],
["resolution summary", resolutionSummarySchema, summary, { ...summary, pilotXp: -5 }],
["tutorial", tutorialStateSchema, tutorial, { ...tutorial, loanerLocationId: "mars" }],
["player state", playerStateSchema, playerState, { ...playerState, ships: null }],
[
"error",
errorResponseSchema,
{ error: { code: "out-of-range", message: "Destination is out of range." } },
{ error: { code: "made-up", message: "?" } },
],
["empty request", emptyRequestSchema, {}, { extra: 1 }],
[
"create guest response",
createGuestResponseSchema,
{ playerId: "pl1", state: playerState },
{ playerId: "pl1" },
],
["contract board request", contractBoardRequestSchema, { locationId: "earth" }, {}],
[
"contract board response",
contractBoardResponseSchema,
{ contracts: [contract], periodEndsAt: 7_200_000 },
{ contracts: [contract] },
],
[
"create dispatch request",
createDispatchRequestSchema,
{ kind: "contract", contractId: "c1", shipId: "s1", pilotId: "p1" },
{ kind: "contract", contractId: "c1" },
],
["dispatch response", dispatchResponseSchema, { dispatch }, { dispatch: {} }],
["buy ship request", buyShipRequestSchema, { modelId: "courier" }, { modelId: "" }],
["ship response", shipResponseSchema, { ship }, {}],
["lease bay request", leaseBayRequestSchema, { locationId: "moon" }, { locationId: 3 }],
["bay response", bayResponseSchema, { bay }, { bay: null }],
["hire pilot request", hirePilotRequestSchema, { locationId: "earth" }, {}],
[
"assign pilot request",
assignPilotRequestSchema,
{ pilotId: "p1", shipId: "s1" },
{ pilotId: "p1" },
],
["unassign pilot request", unassignPilotRequestSchema, { pilotId: "p1" }, {}],
[
"spend skill point request",
spendSkillPointRequestSchema,
{ pilotId: "p1", skillId: "heavy-cargo" },
{ pilotId: "p1" },
],
["pilot response", pilotResponseSchema, { pilot }, { pilot: {} }],
[
"acknowledge summaries request",
acknowledgeSummariesRequestSchema,
{ dispatchIds: ["d0"] },
{ dispatchIds: [] },
],
["acknowledge summaries response", acknowledgeSummariesResponseSchema, { acknowledged: 1 }, {}],
];
describe.each(cases)("%s schema", (_name, schema, valid, invalid) => {
it("accepts a valid example", () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it("rejects an invalid example", () => {
expect(schema.safeParse(invalid).success).toBe(false);
});
});
describe("create dispatch request", () => {
it("accepts tutorial and reposition variants", () => {
expect(
createDispatchRequestSchema.safeParse({ kind: "tutorial", contractId: "c1" }).success,
).toBe(true);
expect(
createDispatchRequestSchema.safeParse({
kind: "reposition",
shipId: "s1",
pilotId: "p1",
destinationId: "moon",
}).success,
).toBe(true);
});
});
describe("error codes", () => {
it("include every sim dispatch and reposition validation reason", () => {
for (const reason of [...DISPATCH_FAILURES, ...REPOSITION_FAILURES]) {
expect(ERROR_CODES).toContain(reason);
}
});
it("are unique", () => {
expect(new Set(ERROR_CODES).size).toBe(ERROR_CODES.length);
});
});
describe("sim compatibility", () => {
it("keeps contracts and dispatches assignable to and from the sim types", () => {
expectTypeOf<SimContract>().toExtend<Contract>();
expectTypeOf<Contract>().toExtend<SimContract>();
expectTypeOf<DispatchRecord>().toExtend<Omit<Dispatch, "status" | "resolvedAt">>();
});
});

View File

@ -36,18 +36,21 @@ export interface DispatchProposal {
now: RealTime; now: RealTime;
} }
export type DispatchFailure = /** Every reason `validateDispatch` can fail, in check order. */
| "ship-not-idle" export const DISPATCH_FAILURES = [
| "ship-not-at-origin" "ship-not-idle",
| "pilot-not-idle" "ship-not-at-origin",
| "pilot-not-assigned" "pilot-not-idle",
| "over-capacity" "pilot-not-assigned",
| "cannot-land" "over-capacity",
| "out-of-range" "cannot-land",
| "cargo-skill-too-low" "out-of-range",
| "long-haul-too-low" "cargo-skill-too-low",
| "contract-expired" "long-haul-too-low",
| "insufficient-credits"; "contract-expired",
"insufficient-credits",
] as const;
export type DispatchFailure = (typeof DISPATCH_FAILURES)[number];
export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure }; export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure };
@ -196,15 +199,18 @@ export interface RepositionProposal {
now: RealTime; now: RealTime;
} }
export type RepositionFailure = /** Every reason `validateReposition` can fail, in check order. */
| "ship-not-idle" export const REPOSITION_FAILURES = [
| "pilot-not-idle" "ship-not-idle",
| "pilot-not-assigned" "pilot-not-idle",
| "already-there" "pilot-not-assigned",
| "no-bay-at-destination" "already-there",
| "cannot-land" "no-bay-at-destination",
| "out-of-range" "cannot-land",
| "insufficient-credits"; "out-of-range",
"insufficient-credits",
] as const;
export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure }; export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };

View File

@ -35,6 +35,8 @@ export { LONG_HAUL_SKILL_ID, checkPilotEligibility } from "./pilot";
export type { PilotCheck, SkillLevels } from "./pilot"; export type { PilotCheck, SkillLevels } from "./pilot";
export { export {
DISPATCH_FAILURES,
REPOSITION_FAILURES,
createDispatchRecord, createDispatchRecord,
createRepositionRecord, createRepositionRecord,
tripFuelCost, tripFuelCost,

View File

@ -49,7 +49,14 @@ importers:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5 version: 4.6.5
packages/schema: {} packages/schema:
dependencies:
'@deadhead/sim':
specifier: workspace:*
version: link:../sim
zod:
specifier: ^4.6.5
version: 4.6.5
packages/sim: packages/sim:
dependencies: dependencies: