From 8ff2df976ebeb56306f0c75c8f4bc6949fdd07f5 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 17:41:05 -0400 Subject: [PATCH] Step 2.1: define content schemas Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 12 +++ memory-bank/progress.md | 9 ++ packages/content/package.json | 3 + packages/content/src/index.ts | 2 +- packages/content/src/schemas.ts | 128 ++++++++++++++++++++++++++ packages/content/test/schemas.test.ts | 102 ++++++++++++++++++++ pnpm-lock.yaml | 11 ++- 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 packages/content/src/schemas.ts create mode 100644 packages/content/test/schemas.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index c13aef0..013b26d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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"`). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index adfd7d2..9e6327c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/content/package.json b/packages/content/package.json index 52a7f38..5a378f6 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -7,5 +7,8 @@ "types": "./src/index.ts", "scripts": { "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^4.6.5" } } diff --git a/packages/content/src/index.ts b/packages/content/src/index.ts index cb0ff5c..292f605 100644 --- a/packages/content/src/index.ts +++ b/packages/content/src/index.ts @@ -1 +1 @@ -export {}; +export * from "./schemas"; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts new file mode 100644 index 0000000..f307e3b --- /dev/null +++ b/packages/content/src/schemas.ts @@ -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; + +export const locationTypeSchema = z.enum([ + "atmospheric-world", + "airless-body", + "cloud-platform", + "belt-station", +]); +export type LocationType = z.infer; + +/** Astronomical body a location's position is derived from. */ +export const astronomyBodySchema = z.enum(["earth", "moon", "mars", "ceres", "jupiter"]); +export type AstronomyBody = z.infer; + +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; + +export const shipArchetypeSchema = z.enum(["courier", "freighter", "bulk-hauler"]); +export type ShipArchetype = z.infer; + +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; + +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; + +export const skillSchema = z.object({ + id: idSchema, + name: z.string().min(1), + maxLevel: positiveInt, + description: z.string().min(1), +}); +export type Skill = z.infer; + +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; diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts new file mode 100644 index 0000000..f65ae49 --- /dev/null +++ b/packages/content/test/schemas.test.ts @@ -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][] = [ + ["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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b1d0c8..57bf141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}