diff --git a/DECISIONS.md b/DECISIONS.md index 91d111f..b8ef002 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **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. + +## 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`. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index e69de29..19aca4b 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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`, ...). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4d40d91..76a382d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -37,3 +37,8 @@ - [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.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 diff --git a/packages/schema/package.json b/packages/schema/package.json index 131e103..0730f93 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -7,5 +7,9 @@ "types": "./src/index.ts", "scripts": { "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@deadhead/sim": "workspace:*", + "zod": "^4.6.5" } } diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts new file mode 100644 index 0000000..e1d9a5b --- /dev/null +++ b/packages/schema/src/entities.ts @@ -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; + +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; + +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; + +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; + +export const baySchema = z.object({ + id: idSchema, + locationId: idSchema, +}); +export type Bay = z.infer; + +export const locationStateSchema = z.object({ + id: idSchema, + discovered: z.boolean(), +}); +export type LocationState = z.infer; + +/** 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; + +/** 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; + +/** 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; + +/** 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; + +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; diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts new file mode 100644 index 0000000..c56bed4 --- /dev/null +++ b/packages/schema/src/errors.ts @@ -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([...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; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index cb0ff5c..43b72ce 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1 +1,3 @@ -export {}; +export * from "./entities"; +export * from "./errors"; +export * from "./requests"; diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts new file mode 100644 index 0000000..e816fdc --- /dev/null +++ b/packages/schema/src/requests.ts @@ -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; +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(), +}); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts new file mode 100644 index 0000000..cfba4f9 --- /dev/null +++ b/packages/schema/test/schemas.test.ts @@ -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().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend>(); + }); +}); diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 2d4b1e3..7a231ba 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -36,18 +36,21 @@ export interface DispatchProposal { 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"; +/** Every reason `validateDispatch` can fail, in check order. */ +export const DISPATCH_FAILURES = [ + "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", +] as const; +export type DispatchFailure = (typeof DISPATCH_FAILURES)[number]; export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure }; @@ -196,15 +199,18 @@ export interface RepositionProposal { 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"; +/** Every reason `validateReposition` can fail, in check order. */ +export const REPOSITION_FAILURES = [ + "ship-not-idle", + "pilot-not-idle", + "pilot-not-assigned", + "already-there", + "no-bay-at-destination", + "cannot-land", + "out-of-range", + "insufficient-credits", +] as const; +export type RepositionFailure = (typeof REPOSITION_FAILURES)[number]; export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure }; diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index a771c21..dc85bf2 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -35,6 +35,8 @@ export { LONG_HAUL_SKILL_ID, checkPilotEligibility } from "./pilot"; export type { PilotCheck, SkillLevels } from "./pilot"; export { + DISPATCH_FAILURES, + REPOSITION_FAILURES, createDispatchRecord, createRepositionRecord, tripFuelCost, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89ac8c9..3a338cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,14 @@ importers: specifier: ^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: dependencies: