Compare commits

..

6 Commits

Author SHA1 Message Date
fireshaper 241e2613f4 Step 2.6: export a single content bundle
CI / check (push) Successful in 24s Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:47:44 -04:00
fireshaper 2c211524d3 Step 2.5: add the default game config
Rescales money values so tutorial runs pay meaningful amounts, and moves
the game epoch from .env.example into GameConfig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:47:15 -04:00
fireshaper 6804a7120c Step 2.4: add cargo types and skills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:43:32 -04:00
fireshaper 10ded07ea6 Step 2.3: add the three ship models
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:42:52 -04:00
fireshaper 24d9942336 Step 2.2: add the five locations
Also fixes Prettier formatting in DECISIONS.md from Step 2.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:42:07 -04:00
fireshaper 8ff2df976e Step 2.1: define content schemas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:41:05 -04:00
18 changed files with 745 additions and 7 deletions

View File

@ -11,8 +11,3 @@ BETTER_AUTH_URL=http://localhost:3000
# HTTP port the API server listens on # HTTP port the API server listens on
PORT=3000 PORT=3000
# Game clock epoch (decision 0.5.9): the real instant the game clock starts,
# and the in-game ephemeris date it maps to. Both ISO 8601, UTC.
GAME_EPOCH_REAL_START=2026-01-01T00:00:00Z
GAME_EPOCH_EPHEMERIS_DATE=2100-01-01T00:00:00Z

View File

@ -31,3 +31,49 @@ 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"`).
## Step 2.2
- **Location ids:** `earth`, `moon`, `mars`, `ceres-station`, `jupiter-cloud-platform`.
- **Cargo flows** follow GDD §9: Earth and Mars produce standard and high-value goods and demand ore (heavy) and fuel (dangerous); the Moon produces standard and heavy; Ceres produces heavy; Jupiter produces dangerous (fuel). The outposts import high-value equipment.
- **Launch multipliers:** Earth 3, Jupiter Cloud Platform 2, Mars 1.5, Moon 0.5, Ceres 0.3.
- **Bay lease prices are first guesses** (Earth 5,000 up to Jupiter 15,000). Step 2.5 tunes the Earth price against the tutorial.
- **The cargo-id check uses a placeholder list** of the four cargo ids planned in Step 2.4 until the cargo data exists.
## Step 2.3
- **Ranges** (million km): Courier 5 (Moon only; Mars is ~55 at its closest), Freighter 650 (covers Ceres at its farthest, ~600, and Jupiter near its closest, ~590, per decision 0.5.25), Bulk Hauler 1,000 (Jupiter at its farthest, ~970). Step 3.7 checks these against real positions.
- **Speeds and overheads** aim at GDD §6 durations at compression 60: Courier 3M km/h with 2 h overhead (Earth→Moon ≈ 2 real minutes, mostly overhead), Freighter 2M km/h with 4 h (Earth→Mars ≈ 1–2 real hours), Bulk Hauler 1.2M km/h with 8 h.
- **Prices** (30,000 / 150,000 / 600,000), **capacities** (20 / 100 / 400 t), **fuel costs** (20 / 40 / 80 per million km) and **hull ratings** (1 / 2 / 3) are first guesses; Step 2.5 tunes the Courier price.
## Step 2.4
- **Ids:** cargo `standard`, `heavy`, `high-value`, `dangerous`; skills `long-haul`, `heavy-cargo`, `expensive-cargo`, `dangerous-cargo`.
- **Base pay** (credits per tonne per million km): Standard 10, Heavy 14, High-Value 18, Dangerous 20. **Tier multipliers:** 1, 1.2, 1.25, 1.3. First guesses, to be tuned in Phase 12.
- **Cargo and skills share one data file** (`src/data/cargo.ts`), since every cargo skill exists to gate a cargo type.
## Step 2.5
- **The game epoch lives in GameConfig, not in env vars.** Step 2.1 puts it in GameConfig, and rule 0.1.5 keeps game numbers in `packages/content`. The `GAME_EPOCH_*` variables added to `.env.example` in Step 1.5 are removed; the placeholder values (real start 2026-01-01, in-game 2100-01-01) are unchanged.
- **Money values scaled up.** With the Step 2.2–2.4 rates a tutorial run paid ~24 credits. Pay rates and fuel costs are ×10, and prices are set so a Courier (4,000) plus an Earth bay (1,000) takes ~21 runs at ~240 credits each. Freighter 100,000, Bulk Hauler 400,000; leases Moon 800, Mars 20,000, Ceres 40,000, Jupiter 80,000.
- **Placeholder tutorial payout in the test:** Standard pay × 0.384M km × average mass (midpoint of the contract minimum and Courier capacity, 12.5 t) × tutorial share (0.5).
- **Starting credits are 0.** The company pays fuel during the tutorial, and the ~21-run target already assumes the player starts with nothing.
- **Other defaults:** board size 8, tutorial board 40 (more than the ~30 runs that fit in a 60-minute period), first contact 10% at ×2 pay, contract mass 5–400 t, Long Haul cut-offs 100/250/400/600/800M km, ten pilot levels at 2 skill points each (enough to max all four skills), hire cost 2,000, pilot pay 100 per delivery.
- **Known balance issue for Phase 12:** pay is linear in distance, but every trip pays a fixed overhead, so short Earth–Moon runs earn far less per real hour than long runs (a Freighter on Mars earns roughly 50× a Courier on the Moon).
## Step 2.6
- **Lookups return `undefined`** for an unknown id ("not found" without throwing). Callers decide whether a missing id is an error.
- **The package also exports each data list and schema individually**, alongside the `content` bundle, so tests and the server can validate data with the schemas.

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.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.
- [x] **Step 2.2 — The five locations.** `src/data/locations.ts`: Earth, Moon, Mars, Ceres Station, Jupiter Cloud Platform. Earth and Moon start discovered; Earth has the highest launch multiplier.
- [x] **Step 2.3 — The three ship models.** `src/data/ships.ts`: Courier, Freighter, Bulk Hauler, all atmospheric. Speed, capacity and price orderings tested; range rules are checked in Step 3.7.
- [x] **Step 2.4 — Cargo types and skills.** `src/data/cargo.ts`: Standard, Heavy, High-Value, Dangerous; skills Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo (max level 5). The location test now checks cargo ids against the real data.
- [x] **Step 2.5 — Default game config.** `src/data/config.ts`. Courier + Earth bay ≈ 21 tutorial runs with the placeholder average payout. Money values rescaled ×10 so tutorial runs pay meaningful amounts; epoch moved from `.env.example` into GameConfig.
- [x] **Step 2.6 — Single content bundle.** `content` in `src/bundle.ts` holds all data plus `location`, `shipModel`, `cargoType` and `skill` lookups that return `undefined` for unknown ids.

View File

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

View File

@ -0,0 +1,24 @@
import { cargoTypes, skills } from "./data/cargo";
import { gameConfig } from "./data/config";
import { locations } from "./data/locations";
import { shipModels } from "./data/ships";
/** Returns a lookup that gives `undefined` for an unknown id instead of throwing. */
function byId<T extends { id: string }>(items: readonly T[]): (id: string) => T | undefined {
const map = new Map(items.map((item) => [item.id, item]));
return (id) => map.get(id);
}
/** All static game data in one object, with lookups by id. */
export const content = {
locations,
shipModels,
cargoTypes,
skills,
config: gameConfig,
location: byId(locations),
shipModel: byId(shipModels),
cargoType: byId(cargoTypes),
skill: byId(skills),
};
export type Content = typeof content;

View File

@ -0,0 +1,60 @@
import type { CargoType, Skill } from "../schemas";
/** Standard is always tier 0; other types carry a tier from 1 to 5 (decision 0.5.6). */
export const cargoTypes: CargoType[] = [
{
id: "standard",
name: "Standard",
basePayPerTonnePerMillionKm: 100,
tierPayMultiplier: 1,
requiredSkillId: null,
},
{
id: "heavy",
name: "Heavy",
basePayPerTonnePerMillionKm: 140,
tierPayMultiplier: 1.2,
requiredSkillId: "heavy-cargo",
},
{
id: "high-value",
name: "High-Value",
basePayPerTonnePerMillionKm: 180,
tierPayMultiplier: 1.25,
requiredSkillId: "expensive-cargo",
},
{
id: "dangerous",
name: "Dangerous",
basePayPerTonnePerMillionKm: 200,
tierPayMultiplier: 1.3,
requiredSkillId: "dangerous-cargo",
},
];
export const skills: Skill[] = [
{
id: "long-haul",
name: "Long Haul",
maxLevel: 5,
description: "Access to longer routes.",
},
{
id: "heavy-cargo",
name: "Heavy Cargo",
maxLevel: 5,
description: "Access to heavier loads, one tier per level.",
},
{
id: "expensive-cargo",
name: "Expensive Cargo",
maxLevel: 5,
description: "Access to high-value cargo, one tier per level.",
},
{
id: "dangerous-cargo",
name: "Dangerous Cargo",
maxLevel: 5,
description: "Access to hazardous cargo, one hazard class per level.",
},
];

View File

@ -0,0 +1,29 @@
import type { GameConfig } from "../schemas";
/** Default tuning. All numbers are first guesses until balancing in Phase 12. */
export const gameConfig: GameConfig = {
// 1 real minute = 1 game hour.
timeCompression: 60,
// Placeholders: the design docs do not fix an in-game start date yet.
epoch: {
realStart: "2026-01-01T00:00:00Z",
ephemerisDate: "2100-01-01T00:00:00Z",
},
startingCredits: 0,
tutorialPayoutShare: 0.5,
contractBoardSize: 8,
// A tutorial run takes about 2 real minutes, so one hour-long period allows ~30.
tutorialBoardSize: 40,
contractBoardPeriodRealMinutes: 60,
firstContactChance: 0.1,
firstContactPayoutMultiplier: 2,
contractMassTonnes: { min: 5, max: 400 },
// Mars at its closest needs no Long Haul; Jupiter at its farthest needs level 5.
longHaulCutoffsMillionKm: [100, 250, 400, 600, 800],
xpPerDelivery: { base: 10, perMillionKm: 0.2, perCargoTier: 5 },
pilotLevelXpThresholds: [100, 250, 450, 700, 1000, 1400, 1900, 2500, 3200, 4000],
// Ten levels × 2 points = enough to max all four skills (4 × 5 levels).
skillPointsPerLevel: 2,
pilotHireCost: 2000,
pilotPayPerDelivery: 100,
};

View File

@ -0,0 +1,69 @@
import type { Location } from "../schemas";
/**
* Base-game destinations (GDD §9). Cargo flows: Earth and Mars make goods, the
* Moon and Ceres mine ore (heavy), Jupiter refines fuel (dangerous); remote
* outposts import high-value equipment.
*/
export const locations: Location[] = [
{
id: "earth",
name: "Earth",
type: "atmospheric-world",
landingRequirement: "atmospheric",
body: "earth",
launchCostMultiplier: 3,
startsDiscovered: true,
bayLeasePrice: 1000,
produces: ["standard", "high-value"],
demands: ["heavy", "dangerous"],
},
{
id: "moon",
name: "Moon",
type: "airless-body",
landingRequirement: "vacuum",
body: "moon",
launchCostMultiplier: 0.5,
startsDiscovered: true,
bayLeasePrice: 800,
produces: ["standard", "heavy"],
demands: ["standard", "high-value", "dangerous"],
},
{
id: "mars",
name: "Mars",
type: "atmospheric-world",
landingRequirement: "atmospheric",
body: "mars",
launchCostMultiplier: 1.5,
startsDiscovered: false,
bayLeasePrice: 20000,
produces: ["standard", "high-value"],
demands: ["heavy", "dangerous"],
},
{
id: "ceres-station",
name: "Ceres Station",
type: "belt-station",
landingRequirement: "vacuum",
body: "ceres",
launchCostMultiplier: 0.3,
startsDiscovered: false,
bayLeasePrice: 40000,
produces: ["heavy"],
demands: ["standard", "high-value", "dangerous"],
},
{
id: "jupiter-cloud-platform",
name: "Jupiter Cloud Platform",
type: "cloud-platform",
landingRequirement: "atmospheric",
body: "jupiter",
launchCostMultiplier: 2,
startsDiscovered: false,
bayLeasePrice: 80000,
produces: ["dangerous"],
demands: ["standard", "high-value", "heavy"],
},
];

View File

@ -0,0 +1,51 @@
import type { ShipModel } from "../schemas";
/**
* Base-game ship models (GDD §8). All are atmospheric-rated (decision 0.5.1).
* Ranges against approximate Earth distances: Moon ~0.4M km, Mars at closest
* ~55M km, Ceres at farthest ~600M km, Jupiter ~590M–970M km.
*/
export const shipModels: ShipModel[] = [
{
id: "courier",
name: "Courier",
archetype: "courier",
price: 4000,
cruiseSpeedKmPerGameHour: 3_000_000,
cargoCapacityTonnes: 20,
// Moon only.
fuelRangeMillionKm: 5,
hullRating: 1,
landingClass: "atmospheric",
overheadGameHours: 2,
fuelCostPerMillionKm: 200,
},
{
id: "freighter",
name: "Freighter",
archetype: "freighter",
price: 100000,
cruiseSpeedKmPerGameHour: 2_000_000,
cargoCapacityTonnes: 100,
// Mars and Ceres at any distance; Jupiter only near its closest (decision 0.5.25).
fuelRangeMillionKm: 650,
hullRating: 2,
landingClass: "atmospheric",
overheadGameHours: 4,
fuelCostPerMillionKm: 400,
},
{
id: "bulk-hauler",
name: "Bulk Hauler",
archetype: "bulk-hauler",
price: 400000,
cruiseSpeedKmPerGameHour: 1_200_000,
cargoCapacityTonnes: 400,
// Jupiter at its farthest.
fuelRangeMillionKm: 1000,
hullRating: 3,
landingClass: "atmospheric",
overheadGameHours: 8,
fuelCostPerMillionKm: 800,
},
];

View File

@ -1 +1,6 @@
export {}; export * from "./schemas";
export * from "./data/locations";
export * from "./data/ships";
export * from "./data/cargo";
export * from "./data/config";
export * from "./bundle";

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,26 @@
import { describe, expect, it } from "vitest";
import { content } from "../src/index";
describe("content bundle", () => {
it("contains every kind of content", () => {
expect(content.locations).toHaveLength(5);
expect(content.shipModels).toHaveLength(3);
expect(content.cargoTypes).toHaveLength(4);
expect(content.skills).toHaveLength(4);
expect(content.config.timeCompression).toBe(60);
});
it("looks up one item of each kind by id", () => {
expect(content.location("mars")?.name).toBe("Mars");
expect(content.shipModel("freighter")?.name).toBe("Freighter");
expect(content.cargoType("dangerous")?.name).toBe("Dangerous");
expect(content.skill("long-haul")?.name).toBe("Long Haul");
});
it("returns undefined for an unknown id without throwing", () => {
expect(content.location("pluto")).toBeUndefined();
expect(content.shipModel("x-wing")).toBeUndefined();
expect(content.cargoType("cheese")).toBeUndefined();
expect(content.skill("juggling")).toBeUndefined();
});
});

View File

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { cargoTypeSchema, cargoTypes, skillSchema, skills } from "../src/index";
describe("cargo types", () => {
it("all pass the schema", () => {
for (const cargo of cargoTypes) {
expect(() => cargoTypeSchema.parse(cargo)).not.toThrow();
}
});
it("has Standard plus three skill-gated types", () => {
expect(cargoTypes.map((c) => c.id)).toEqual(["standard", "heavy", "high-value", "dangerous"]);
expect(cargoTypes.find((c) => c.id === "standard")!.requiredSkillId).toBeNull();
});
it("requires only skills that exist", () => {
const skillIds = skills.map((s) => s.id);
for (const cargo of cargoTypes) {
if (cargo.requiredSkillId !== null) {
expect(skillIds).toContain(cargo.requiredSkillId);
}
}
});
it("pays more than Standard for every non-standard type", () => {
const standard = cargoTypes.find((c) => c.id === "standard")!;
for (const cargo of cargoTypes) {
if (cargo !== standard) {
expect(cargo.basePayPerTonnePerMillionKm).toBeGreaterThan(
standard.basePayPerTonnePerMillionKm,
);
}
}
});
});
describe("skills", () => {
it("all pass the schema", () => {
for (const skill of skills) {
expect(() => skillSchema.parse(skill)).not.toThrow();
}
});
it("has the four base skills, each max level 5", () => {
expect(skills.map((s) => s.id)).toEqual([
"long-haul",
"heavy-cargo",
"expensive-cargo",
"dangerous-cargo",
]);
for (const skill of skills) {
expect(skill.maxLevel).toBe(5);
}
});
});

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
cargoTypes,
gameConfig,
gameConfigSchema,
locations,
shipModels,
skills,
} from "../src/index";
// Placeholder average tutorial payout until Phase 12 uses real sim numbers:
// Standard cargo over ~0.384M km (Earth–Moon), at the average mass a Courier
// can carry from the configured range, times the tutorial share.
const EARTH_MOON_MILLION_KM = 0.384;
describe("game config", () => {
it("passes the schema", () => {
expect(() => gameConfigSchema.parse(gameConfig)).not.toThrow();
});
it("uses 1 real minute = 1 game hour", () => {
expect(gameConfig.timeCompression).toBe(60);
});
it("has one Long Haul cut-off per Long Haul level", () => {
const longHaul = skills.find((s) => s.id === "long-haul")!;
expect(gameConfig.longHaulCutoffsMillionKm).toHaveLength(longHaul.maxLevel);
});
it("makes a Courier plus an Earth bay affordable after 15–25 tutorial runs", () => {
const courier = shipModels.find((s) => s.id === "courier")!;
const earth = locations.find((l) => l.id === "earth")!;
const standard = cargoTypes.find((c) => c.id === "standard")!;
const averageMass = (gameConfig.contractMassTonnes.min + courier.cargoCapacityTonnes) / 2;
const averageTutorialPayout =
standard.basePayPerTonnePerMillionKm *
averageMass *
EARTH_MOON_MILLION_KM *
gameConfig.tutorialPayoutShare;
const runs = (courier.price + earth.bayLeasePrice) / averageTutorialPayout;
expect(runs).toBeGreaterThanOrEqual(15);
expect(runs).toBeLessThanOrEqual(25);
});
});

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { cargoTypes, locationSchema, locations } from "../src/index";
const cargoTypeIds = cargoTypes.map((c) => c.id);
describe("locations", () => {
it("all pass the schema", () => {
for (const location of locations) {
expect(() => locationSchema.parse(location)).not.toThrow();
}
});
it("has exactly five locations with unique ids", () => {
expect(locations).toHaveLength(5);
expect(new Set(locations.map((l) => l.id)).size).toBe(5);
});
it("starts with exactly Earth and Moon discovered", () => {
const discovered = locations.filter((l) => l.startsDiscovered).map((l) => l.id);
expect(discovered.sort()).toEqual(["earth", "moon"]);
});
it("references only existing cargo types", () => {
for (const location of locations) {
for (const cargoId of [...location.produces, ...location.demands]) {
expect(cargoTypeIds).toContain(cargoId);
}
}
});
it("gives Earth the highest launch multiplier", () => {
const earth = locations.find((l) => l.id === "earth")!;
for (const location of locations) {
if (location !== earth) {
expect(earth.launchCostMultiplier).toBeGreaterThan(location.launchCostMultiplier);
}
}
});
});

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

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { shipModelSchema, shipModels } from "../src/index";
const ship = (id: string) => shipModels.find((s) => s.id === id)!;
describe("ship models", () => {
it("all pass the schema", () => {
for (const model of shipModels) {
expect(() => shipModelSchema.parse(model)).not.toThrow();
}
});
it("has the three archetypes, all atmospheric", () => {
expect(shipModels.map((s) => s.id)).toEqual(["courier", "freighter", "bulk-hauler"]);
for (const model of shipModels) {
expect(model.landingClass).toBe("atmospheric");
}
});
it("orders speed Courier > Freighter > Bulk Hauler", () => {
expect(ship("courier").cruiseSpeedKmPerGameHour).toBeGreaterThan(
ship("freighter").cruiseSpeedKmPerGameHour,
);
expect(ship("freighter").cruiseSpeedKmPerGameHour).toBeGreaterThan(
ship("bulk-hauler").cruiseSpeedKmPerGameHour,
);
});
it("orders capacity Bulk Hauler > Freighter > Courier", () => {
expect(ship("bulk-hauler").cargoCapacityTonnes).toBeGreaterThan(
ship("freighter").cargoCapacityTonnes,
);
expect(ship("freighter").cargoCapacityTonnes).toBeGreaterThan(
ship("courier").cargoCapacityTonnes,
);
});
it("orders price Courier < Freighter < Bulk Hauler", () => {
expect(ship("courier").price).toBeLessThan(ship("freighter").price);
expect(ship("freighter").price).toBeLessThan(ship("bulk-hauler").price);
});
});

View File

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