Compare commits
2 Commits
f33a9155cc
...
439c35ae35
| Author | SHA1 | Date |
|---|---|---|
|
|
439c35ae35 | |
|
|
f95acd06ba |
18
DECISIONS.md
18
DECISIONS.md
|
|
@ -182,3 +182,21 @@ 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`.
|
||||||
|
|
||||||
|
## Step 4.2
|
||||||
|
|
||||||
|
- **One `request` schema per route covers path parameters and body together** (e.g. `assignPilot` takes `pilotId` from `/api/pilots/:pilotId/assign` and `shipId` from the body). The server merges the two before validating; a test checks that every `:param` is a field of the request schema.
|
||||||
|
- **Each route has an `auth` flag.** Only `getServerTime` and `createGuest` work without a session.
|
||||||
|
- **REST-style paths:** collections are created with `POST` (`/api/ships`, `/api/bays`, `/api/pilots`, `/api/dispatches`), and pilot actions are sub-resources (`/api/pilots/:pilotId/assign`).
|
||||||
|
- **The catalog is an `as const` object** that satisfies `Record<string, RouteDef>`, so the server and client get exact request and response types per route name (`RouteRequest<"buyShip">`).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# 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`, ...).
|
||||||
|
|
||||||
|
## API routes (`packages/schema/src/routes.ts`)
|
||||||
|
|
||||||
|
The `routes` catalog is the single source for both server and client. Each entry has `method`, `path`, `request` (path parameters merged with the body), `response` (2xx body) and `auth`. Errors always use the error body above.
|
||||||
|
|
||||||
|
| Name | Method & path | Request | Response | Auth |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `getServerTime` | `GET /api/time` | — | `ServerTime` | no |
|
||||||
|
| `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` + session cookie | no |
|
||||||
|
| `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes |
|
||||||
|
| `getContractBoard` | `GET /api/bays/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes |
|
||||||
|
| `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes |
|
||||||
|
| `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes |
|
||||||
|
| `buyShip` | `POST /api/ships` | `modelId` | `{ ship }` | yes |
|
||||||
|
| `leaseBay` | `POST /api/bays` | `locationId` | `{ bay }` | yes |
|
||||||
|
| `hirePilot` | `POST /api/pilots` | `locationId` (a player bay) | `{ pilot }` | yes |
|
||||||
|
| `assignPilot` | `POST /api/pilots/:pilotId/assign` | `pilotId`, `shipId` | `{ pilot }` | yes |
|
||||||
|
| `unassignPilot` | `POST /api/pilots/:pilotId/unassign` | `pilotId` | `{ pilot }` | yes |
|
||||||
|
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
|
||||||
|
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds` | `{ acknowledged }` | yes |
|
||||||
|
|
@ -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.
|
||||||
|
- [x] **Step 4.2 — API route catalog.** `packages/schema/src/routes.ts`: 13 typed endpoints under `/api` with request/response schemas and an `auth` flag, plus `RouteRequest` / `RouteResponse` helper types. Route table added to `architecture.md`.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -1 +1,4 @@
|
||||||
export {};
|
export * from "./entities";
|
||||||
|
export * from "./errors";
|
||||||
|
export * from "./requests";
|
||||||
|
export * from "./routes";
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { playerStateSchema, serverTimeSchema } from "./entities";
|
||||||
|
import {
|
||||||
|
acknowledgeSummariesRequestSchema,
|
||||||
|
acknowledgeSummariesResponseSchema,
|
||||||
|
assignPilotRequestSchema,
|
||||||
|
bayResponseSchema,
|
||||||
|
buyShipRequestSchema,
|
||||||
|
contractBoardRequestSchema,
|
||||||
|
contractBoardResponseSchema,
|
||||||
|
createDispatchRequestSchema,
|
||||||
|
createGuestResponseSchema,
|
||||||
|
dispatchResponseSchema,
|
||||||
|
emptyRequestSchema,
|
||||||
|
hirePilotRequestSchema,
|
||||||
|
leaseBayRequestSchema,
|
||||||
|
pilotResponseSchema,
|
||||||
|
shipResponseSchema,
|
||||||
|
spendSkillPointRequestSchema,
|
||||||
|
unassignPilotRequestSchema,
|
||||||
|
} from "./requests";
|
||||||
|
|
||||||
|
export interface RouteDef {
|
||||||
|
method: "GET" | "POST";
|
||||||
|
/** Under `/api` (decision 0.5.20). `:name` segments are path parameters. */
|
||||||
|
path: string;
|
||||||
|
/** Path parameters plus body: every `:name` in the path is a field here. */
|
||||||
|
request: z.ZodType;
|
||||||
|
/** Body of the 2xx response. Errors always use `errorResponseSchema`. */
|
||||||
|
response: z.ZodType;
|
||||||
|
/** Whether a session is required. */
|
||||||
|
auth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every endpoint of the base game: the contract between server and client. */
|
||||||
|
export const routes = {
|
||||||
|
getServerTime: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/time",
|
||||||
|
request: emptyRequestSchema,
|
||||||
|
response: serverTimeSchema,
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
createGuest: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/guest",
|
||||||
|
request: emptyRequestSchema,
|
||||||
|
response: createGuestResponseSchema,
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
getPlayerState: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/state",
|
||||||
|
request: emptyRequestSchema,
|
||||||
|
response: playerStateSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
getContractBoard: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/bays/:locationId/board",
|
||||||
|
request: contractBoardRequestSchema,
|
||||||
|
response: contractBoardResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
getTutorialBoard: {
|
||||||
|
method: "GET",
|
||||||
|
path: "/api/tutorial/board",
|
||||||
|
request: emptyRequestSchema,
|
||||||
|
response: contractBoardResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
createDispatch: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/dispatches",
|
||||||
|
request: createDispatchRequestSchema,
|
||||||
|
response: dispatchResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
buyShip: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/ships",
|
||||||
|
request: buyShipRequestSchema,
|
||||||
|
response: shipResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
leaseBay: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/bays",
|
||||||
|
request: leaseBayRequestSchema,
|
||||||
|
response: bayResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
hirePilot: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/pilots",
|
||||||
|
request: hirePilotRequestSchema,
|
||||||
|
response: pilotResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
assignPilot: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/pilots/:pilotId/assign",
|
||||||
|
request: assignPilotRequestSchema,
|
||||||
|
response: pilotResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
unassignPilot: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/pilots/:pilotId/unassign",
|
||||||
|
request: unassignPilotRequestSchema,
|
||||||
|
response: pilotResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
spendSkillPoint: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/pilots/:pilotId/skills",
|
||||||
|
request: spendSkillPointRequestSchema,
|
||||||
|
response: pilotResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
acknowledgeSummaries: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/summaries/acknowledge",
|
||||||
|
request: acknowledgeSummariesRequestSchema,
|
||||||
|
response: acknowledgeSummariesResponseSchema,
|
||||||
|
auth: true,
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, RouteDef>;
|
||||||
|
|
||||||
|
export type Routes = typeof routes;
|
||||||
|
export type RouteName = keyof Routes;
|
||||||
|
export type RouteRequest<N extends RouteName> = z.input<Routes[N]["request"]>;
|
||||||
|
export type RouteResponse<N extends RouteName> = z.output<Routes[N]["response"]>;
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { routes, type RouteDef } from "../src/index";
|
||||||
|
|
||||||
|
const entries = Object.entries(routes) as [string, RouteDef][];
|
||||||
|
|
||||||
|
describe("route catalog", () => {
|
||||||
|
it("lists every base-game endpoint", () => {
|
||||||
|
expect(Object.keys(routes).sort()).toEqual(
|
||||||
|
[
|
||||||
|
"getServerTime",
|
||||||
|
"createGuest",
|
||||||
|
"getPlayerState",
|
||||||
|
"getContractBoard",
|
||||||
|
"getTutorialBoard",
|
||||||
|
"createDispatch",
|
||||||
|
"buyShip",
|
||||||
|
"leaseBay",
|
||||||
|
"hirePilot",
|
||||||
|
"assignPilot",
|
||||||
|
"unassignPilot",
|
||||||
|
"spendSkillPoint",
|
||||||
|
"acknowledgeSummaries",
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(entries)("%s has request and response schemas", (_name, route) => {
|
||||||
|
expect(route.request).toBeInstanceOf(z.ZodType);
|
||||||
|
expect(route.response).toBeInstanceOf(z.ZodType);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never gives two endpoints the same method and path", () => {
|
||||||
|
const keys = entries.map(([, r]) => `${r.method} ${r.path}`);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every path under /api", () => {
|
||||||
|
for (const [, route] of entries) {
|
||||||
|
expect(route.path.startsWith("/api/")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(entries)("%s declares each path parameter in its request schema", (_name, route) => {
|
||||||
|
const params = [...route.path.matchAll(/:(\w+)/g)].map((m) => m[1]!);
|
||||||
|
if (params.length === 0) return;
|
||||||
|
expect(route.request).toBeInstanceOf(z.ZodObject);
|
||||||
|
const shape = (route.request as z.ZodObject).shape;
|
||||||
|
for (const param of params) {
|
||||||
|
expect(shape).toHaveProperty(param);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a session everywhere except server time and guest creation", () => {
|
||||||
|
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
|
||||||
|
expect(open.sort()).toEqual(["createGuest", "getServerTime"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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">>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue