Compare commits

...

2 Commits

Author SHA1 Message Date
fireshaper 439c35ae35 Step 4.2: add API route catalog
CI / check (push) Successful in 40s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:51:45 -04:00
fireshaper f95acd06ba Step 4.1: define API data shapes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:50:48 -04:00
14 changed files with 812 additions and 23 deletions

View File

@ -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.
- **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`.
## 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">`).

View File

@ -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 |

View File

@ -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.
- [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`.

View File

@ -7,5 +7,9 @@
"types": "./src/index.ts",
"scripts": {
"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,4 @@
export {};
export * from "./entities";
export * from "./errors";
export * from "./requests";
export * from "./routes";

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,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"]>;

View File

@ -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"]);
});
});

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;
}
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 };

View File

@ -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,

View File

@ -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: