Step 2.1: define content schemas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 17:41:05 -04:00
parent 87c17699c2
commit 8ff2df976e
7 changed files with 265 additions and 2 deletions

View File

@ -31,3 +31,15 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **One Postgres container, two databases.** `docker-compose.yml` runs a single `postgres:18` server on port 5432 (user/password `deadhead`). The default database `deadhead` is for development; `docker/postgres/init/01-create-test-db.sql` creates `deadhead_test` the first time the data volume is created. To recreate both from scratch, run `docker compose down -v`.
- **Env var names** in `.env.example`: `DATABASE_URL`, `TEST_DATABASE_URL`, `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` (Better Auth's own names), `PORT`, and `GAME_EPOCH_REAL_START` plus `GAME_EPOCH_EPHEMERIS_DATE` (decision 0.5.9). The game-clock speed is not an env var; it belongs in GameConfig (rule 0.1.5).
- **Epoch values in `.env.example` are placeholders.** The design docs do not fix an ephemeris date yet.
## Step 2.1
- **Ids are lowercase kebab-case strings** (`earth`, `bulk-hauler`, `long-haul`).
- **One landing-class enum** (`vacuum`, `atmospheric`, `hardened`) serves both a location's landing requirement and a ship's landing class. `hardened` is not used in the base game.
- **Location `body`** is an enum of the five astronomy bodies (`earth`, `moon`, `mars`, `ceres`, `jupiter`) so the sim can map each to a position source in Step 3.3.
- **Epoch fields are ISO 8601 UTC strings** (`epoch.realStart`, `epoch.ephemerisDate`), not `Date` objects, so content stays plain data.
- **`tutorialBoardSize`** is a GameConfig field in addition to `contractBoardSize`, because decision 0.5.21 sets the tutorial board's size separately.
- **XP per delivery inputs** are `base`, `perMillionKm` and `perCargoTier`. Step 3.16 decides how they combine.
- **`pilotLevelXpThresholds`** lists the *total* XP needed for level 1, 2, 3, ...; it and the Long Haul cut-offs must be strictly increasing.
- **`tierPayMultiplier`** is one number per cargo type. Step 3.8 decides how it scales with tier.
- **Imports omit the `.ts` extension** (`moduleResolution: "Bundler"`).

View File

@ -7,3 +7,12 @@
- [x] **Step 1.3 — Test runner.** Vitest 5 with one project per workspace package (`pnpm test`). Trivial smoke test in `packages/sim/test/smoke.test.ts`; 1 passing.
- [x] **Step 1.4 — Continuous integration.** Repo pushed to Gitea; `.gitea/workflows/ci.yml` runs install, typecheck, lint and test on every push (runner `homelab-runner`). Run #1 failed because Prettier scanned the runner's in-workspace `.pnpm-store/`; ignored it, and run #2 is green.
- [x] **Step 1.5 — Local Postgres.** `docker compose up -d` starts Postgres 18 with `deadhead` (dev) and `deadhead_test` databases; connected to both with psql. `.env.example` lists the server's env vars.
## Phase 2 — Content Package
- [x] **Step 2.1 — Content schemas.** Zod 4 schemas in `packages/content/src/schemas.ts` for Location, ShipModel, CargoType, Skill and GameConfig, with inferred types. Tests check that each schema accepts a valid example and rejects it with any one field missing.
- [ ] Step 2.2 — The five locations
- [ ] Step 2.3 — The three ship models
- [ ] Step 2.4 — Cargo types and skills
- [ ] Step 2.5 — Default game config
- [ ] Step 2.6 — Single content bundle

View File

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

View File

@ -1 +1 @@
export {};
export * from "./schemas";

View File

@ -0,0 +1,128 @@
import { z } from "zod";
/** Lowercase kebab-case identifier, e.g. `earth`, `bulk-hauler`. */
export const idSchema = z.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/);
const positiveInt = z.number().int().positive();
const nonNegativeInt = z.number().int().nonnegative();
const fraction = z.number().min(0).max(1);
/** Strictly increasing list of positive numbers. */
const increasingList = z
.array(z.number().positive())
.refine((list) => list.every((value, i) => i === 0 || value > list[i - 1]!), {
message: "Values must be strictly increasing",
});
/** Ordered from least to most capable: a ship can land wherever its class or a lower one is required. */
export const landingClassSchema = z.enum(["vacuum", "atmospheric", "hardened"]);
export type LandingClass = z.infer<typeof landingClassSchema>;
export const locationTypeSchema = z.enum([
"atmospheric-world",
"airless-body",
"cloud-platform",
"belt-station",
]);
export type LocationType = z.infer<typeof locationTypeSchema>;
/** Astronomical body a location's position is derived from. */
export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]);
export type AstronomyBody = z.infer<typeof astronomyBodySchema>;
export const locationSchema = z.object({
id: idSchema,
name: z.string().min(1),
type: locationTypeSchema,
landingRequirement: landingClassSchema,
body: astronomyBodySchema,
/** Multiplies fuel cost for launches from this location. */
launchCostMultiplier: z.number().positive(),
startsDiscovered: z.boolean(),
/** Credits. */
bayLeasePrice: nonNegativeInt,
/** Cargo type ids. */
produces: z.array(idSchema),
/** Cargo type ids. */
demands: z.array(idSchema),
});
export type Location = z.infer<typeof locationSchema>;
export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]);
export type ShipArchetype = z.infer<typeof shipArchetypeSchema>;
export const shipModelSchema = z.object({
id: idSchema,
name: z.string().min(1),
archetype: shipArchetypeSchema,
/** Credits. */
price: positiveInt,
cruiseSpeedKmPerGameHour: z.number().positive(),
cargoCapacityTonnes: z.number().positive(),
fuelRangeMillionKm: z.number().positive(),
/** Stored only; no effect in the base game (decision 0.5.13). */
hullRating: nonNegativeInt,
landingClass: landingClassSchema,
/** Fixed takeoff + landing time added to every trip. */
overheadGameHours: z.number().nonnegative(),
/** Credits per million km, before the origin's launch multiplier. */
fuelCostPerMillionKm: z.number().nonnegative(),
});
export type ShipModel = z.infer<typeof shipModelSchema>;
export const cargoTypeSchema = z.object({
id: idSchema,
name: z.string().min(1),
/** Credits per tonne per million km. */
basePayPerTonnePerMillionKm: z.number().positive(),
/** Pay multiplier applied according to the contract's tier (Standard is tier 0, others 1–5). */
tierPayMultiplier: z.number().positive(),
/** Skill whose level must meet the contract's tier; null for Standard. */
requiredSkillId: idSchema.nullable(),
});
export type CargoType = z.infer<typeof cargoTypeSchema>;
export const skillSchema = z.object({
id: idSchema,
name: z.string().min(1),
maxLevel: positiveInt,
description: z.string().min(1),
});
export type Skill = z.infer<typeof skillSchema>;
export const gameConfigSchema = z.object({
/** Game hours per real hour (60 means 1 real minute = 1 game hour). */
timeCompression: z.number().positive(),
/** Decision 0.5.9: the real instant the game clock starts and the in-game date it maps to. */
epoch: z.object({
realStart: z.iso.datetime(),
ephemerisDate: z.iso.datetime(),
}),
startingCredits: nonNegativeInt,
/** Fraction of a normal payout the player receives for a tutorial run. */
tutorialPayoutShare: fraction,
contractBoardSize: positiveInt,
/** Large enough that a player cannot exhaust it within one period (decision 0.5.21). */
tutorialBoardSize: positiveInt,
contractBoardPeriodRealMinutes: z.number().positive(),
firstContactChance: fraction,
firstContactPayoutMultiplier: z.number().positive(),
contractMassTonnes: z
.object({ min: z.number().positive(), max: z.number().positive() })
.refine((range) => range.min <= range.max, { message: "min must not exceed max" }),
/** One cut-off per Long Haul level (decision 0.5.7). */
longHaulCutoffsMillionKm: increasingList,
xpPerDelivery: z.object({
base: z.number().nonnegative(),
perMillionKm: z.number().nonnegative(),
perCargoTier: z.number().nonnegative(),
}),
/** Total XP needed to reach level 1, 2, 3, ... */
pilotLevelXpThresholds: increasingList,
skillPointsPerLevel: positiveInt,
/** Credits. */
pilotHireCost: nonNegativeInt,
/** Credits per delivery; the avatar is exempt (decision 0.5.12). */
pilotPayPerDelivery: nonNegativeInt,
});
export type GameConfig = z.infer<typeof gameConfigSchema>;

View File

@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import {
cargoTypeSchema,
gameConfigSchema,
locationSchema,
shipModelSchema,
skillSchema,
} from "../src/index";
const validLocation = {
id: "earth",
name: "Earth",
type: "atmospheric-world",
landingRequirement: "atmospheric",
body: "earth",
launchCostMultiplier: 3,
startsDiscovered: true,
bayLeasePrice: 5000,
produces: ["standard"],
demands: ["heavy"],
};
const validShipModel = {
id: "courier",
name: "Courier",
archetype: "courier",
price: 20000,
cruiseSpeedKmPerGameHour: 300000,
cargoCapacityTonnes: 10,
fuelRangeMillionKm: 5,
hullRating: 1,
landingClass: "atmospheric",
overheadGameHours: 2,
fuelCostPerMillionKm: 100,
};
const validCargoType = {
id: "standard",
name: "Standard",
basePayPerTonnePerMillionKm: 10,
tierPayMultiplier: 1,
requiredSkillId: null,
};
const validSkill = {
id: "long-haul",
name: "Long Haul",
maxLevel: 5,
description: "Access to longer routes.",
};
const validGameConfig = {
timeCompression: 60,
epoch: { realStart: "2026-01-01T00:00:00Z", ephemerisDate: "2100-01-01T00:00:00Z" },
startingCredits: 0,
tutorialPayoutShare: 0.2,
contractBoardSize: 8,
tutorialBoardSize: 50,
contractBoardPeriodRealMinutes: 60,
firstContactChance: 0.1,
firstContactPayoutMultiplier: 2,
contractMassTonnes: { min: 1, max: 100 },
longHaulCutoffsMillionKm: [100, 200, 300, 400, 500],
xpPerDelivery: { base: 10, perMillionKm: 0.1, perCargoTier: 5 },
pilotLevelXpThresholds: [100, 250, 500],
skillPointsPerLevel: 1,
pilotHireCost: 1000,
pilotPayPerDelivery: 100,
};
const cases: [string, z.ZodType, Record<string, unknown>][] = [
["Location", locationSchema, validLocation],
["ShipModel", shipModelSchema, validShipModel],
["CargoType", cargoTypeSchema, validCargoType],
["Skill", skillSchema, validSkill],
["GameConfig", gameConfigSchema, validGameConfig],
];
describe.each(cases)("%s schema", (_name, schema, valid) => {
it("accepts a valid example", () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it.each(Object.keys(valid))("rejects an object missing %s", (field) => {
const missing = { ...valid };
delete missing[field];
expect(schema.safeParse(missing).success).toBe(false);
});
});
describe("GameConfig refinements", () => {
it("rejects a mass range with min above max", () => {
const config = { ...validGameConfig, contractMassTonnes: { min: 50, max: 10 } };
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
it("rejects Long Haul cut-offs that are not increasing", () => {
const config = { ...validGameConfig, longHaulCutoffsMillionKm: [100, 100, 300] };
expect(gameConfigSchema.safeParse(config).success).toBe(false);
});
});

View File

@ -40,7 +40,11 @@ importers:
apps/web: {}
packages/content: {}
packages/content:
dependencies:
zod:
specifier: ^4.6.5
version: 4.6.5
packages/schema: {}
@ -844,6 +848,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@4.6.5:
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
snapshots:
'@cacheable/memory@2.2.0':
@ -1507,3 +1514,5 @@ snapshots:
word-wrap@1.2.5: {}
yocto-queue@0.1.0: {}
zod@4.6.5: {}