Step 2.1: define content schemas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
87c17699c2
commit
8ff2df976e
12
DECISIONS.md
12
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`.
|
- **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).
|
- **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.
|
- **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"`).
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.
|
- [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
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,8 @@
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.6.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export {};
|
export * from "./schemas";
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,7 +40,11 @@ importers:
|
||||||
|
|
||||||
apps/web: {}
|
apps/web: {}
|
||||||
|
|
||||||
packages/content: {}
|
packages/content:
|
||||||
|
dependencies:
|
||||||
|
zod:
|
||||||
|
specifier: ^4.6.5
|
||||||
|
version: 4.6.5
|
||||||
|
|
||||||
packages/schema: {}
|
packages/schema: {}
|
||||||
|
|
||||||
|
|
@ -844,6 +848,9 @@ packages:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zod@4.6.5:
|
||||||
|
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@cacheable/memory@2.2.0':
|
'@cacheable/memory@2.2.0':
|
||||||
|
|
@ -1507,3 +1514,5 @@ snapshots:
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zod@4.6.5: {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue