deadhead-space-freight-tycoon/DECISIONS.md

255 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Decisions
Implementation decisions not spelled out in `memory-bank/implementation-plan.md`. Design decisions from plan §0.5 are logged here as they are implemented.
## Step 1.1
- **Repo lives on an NTFS drive (`C:\Projects\...`).** The original location (G:) is exFAT, which cannot hold symlinks, so pnpm could not link workspace packages. Do not move the repo back to an exFAT drive.
- **TypeScript is pinned to `~6.0`.** TypeScript 7 is the latest release, but `typescript-eslint` (needed in Step 1.2) only supports `<6.1`. Upgrade once typescript-eslint supports 7.
- **Packages export TypeScript source directly** (`main`/`types` point at `src/index.ts`). There is no build step for internal packages; Vite, Vitest and the server's TS runner consume the source. The base config uses `moduleResolution: "Bundler"` and `noEmit`.
- **Node 24** is the required runtime (`engines` + `.nvmrc`).
- **Line endings are LF** everywhere (`.gitattributes`), overriding the machine's global `core.autocrlf`, to match Prettier and the Linux CI runner.
## Step 1.2
- **ESLint 10 flat config** with `typescript-eslint` recommended (not type-aware, to keep lint fast) and `eslint-config-prettier`. `pnpm lint` runs ESLint then `prettier --check`.
- **Purity rule** for `packages/sim` and `packages/content`: `no-restricted-properties` bans `Math.random` and `Date.now`; `no-restricted-syntax` bans `new Date()` with no arguments (`new Date(x)` is allowed).
- **Prettier skips `memory-bank/`** so tooling never rewrites the design docs.
## Step 1.3
- **Vitest projects**: the root `vitest.config.ts` treats each `apps/*` and `packages/*` folder as a project, so `pnpm test` runs every package's tests. Packages can add their own `vitest.config.ts` when they need a different environment.
- **Tests live in each package's `test/` folder** and are included in that package's tsconfig, so `pnpm typecheck` covers them.
## Step 1.4
- **CI runs on Gitea Actions** (`.gitea/workflows/ci.yml`): install with a frozen lockfile, then typecheck, lint and test.
- **`.pnpm-store/` is ignored** by Prettier, ESLint and git. On the Gitea runner, pnpm puts its store inside the workspace, and `prettier --check` failed on the store's files (CI run #1).
## Step 1.5
- **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"`).
## 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.
## Step 3.1
- **Times are Unix-millisecond numbers.** A real time is a wall-clock instant; a game time is the instant of the in-game date, so `new Date(gameTime)` gives the in-game date and can be passed straight to `astronomy-engine`.
- **Every sim function takes the content it needs as arguments** (here the `GameConfig`), rather than importing the default bundle, so tests can pass custom fixtures.
## Step 3.2
- **Seeds are 32-bit unsigned integers.** The PRNG is mulberry32; child seeds are an FNV-1a hash of the label, started from the parent seed, plus a final mixing step.
- **`createRng` returns an object with internal state.** It is still deterministic: the same seed always gives the same sequence. Each use (a board, a name) creates its own generator from a derived seed, so draws never depend on call order elsewhere.
## Step 3.3
- **Positions are heliocentric, in the J2000 mean ecliptic plane** (`HelioVector` rotated by `Rotation_EQJ_ECL`), keeping x and y and dropping the small out-of-plane z.
- **The Moon is Earth plus 0.384M km on the +x axis**, as the plan specifies; its real orbit is ignored.
- **Ceres uses a circular orbit** (astronomy-engine has no Ceres): radius 2.7675 AU (~414M km), period 1,680.5 days, mean longitude ~160° at J2000. The angle is approximate, which is fine for gameplay.
- **Test date is 2100-03-01**, close to the placeholder game epoch.
## Step 3.4
- **Route distance is the straight line between the two positions at one game time.** Orbital transfer paths are not modelled.
## Step 3.5
- **Travel time takes the distance as a number**, not two locations and a time. The caller measures the distance at departure, so the function cannot accidentally use a mid-flight distance.
## Step 3.7
- **Landing is checked before range**, so a ship that can neither land nor reach reports `cannot-land`.
- **The route check looks only at the destination's landing requirement.** A ship is always already sitting at its origin, so it can take off from there.
- **Range tests find each planet's closest and farthest point from Earth by sampling** every 4 days over 12 years. All Step 2.3 range choices hold, including decision 0.5.25 (a Freighter reaches Jupiter at its closest).
## Step 3.8
- **Tier scaling is compounding:** pay is multiplied by `tierPayMultiplier ** tier`. Standard (tier 0) is ×1; a tier-5 Dangerous contract pays 1.3⁵ ≈ 3.7× its base rate.
- **Payouts are rounded to whole credits** when the contract is offered.
- **Contracts refer to content by id** (`originId`, `destinationId`, `cargoTypeId`), so they can be stored and sent over the API as plain data. `requiredSkill` is `{ skillId, level }`, or `null` for Standard.
## Step 3.9
- **Board seed:** `deriveSeed(worldSeed, "board:<bayLocationId>:<periodIndex>")`, with one generator per board.
- **Board periods count from the Unix epoch:** period index = floor(real ms ÷ period length). Contracts are offered at the start of their period, and their payout and Long Haul level use the distance at that moment, so a board generated later in the period is identical (decision 0.5.5).
- **Contract ids** are `<origin>:<periodIndex>:<slot>`, unique within a player's world. The server may add its own database ids.
- **First contact** is rolled only when an undiscovered location exists. If no other location is discovered, every contract is first contact. Destinations are picked uniformly from the eligible list.
- **Mass** is a whole number of tonnes drawn uniformly from the configured range. **Tier** is drawn uniformly from 1 to the required skill's max level.
- **Sim functions take a `SimContent` object** (`locations`, `cargoTypes`, `skills`, `config`); the `content` bundle satisfies it, and tests can pass fixtures.
## Step 3.10
- **Tutorial contract mass is capped at the loaner's capacity:** a whole number from the contract minimum (5 t) up to the smaller of the contract maximum and the Courier's 20 t. Otherwise most tutorial contracts would be too heavy to fly.
- **The loaner is the content's Courier,** found by archetype `courier`. `SimContent` now includes `shipModels`.
- **Tutorial payout** = round(normal payout × tutorial share), where the normal payout is the regular rounded payout for the same contract. The contract stores this reduced amount, so resolution credits `contract.payout` directly.
- **Tutorial board seed** is `tutorial:<loanerLocation>:<periodIndex>`, giving one Earth→Moon and one Moon→Earth board per period (decision 0.5.21). Contract ids use the same prefix.
## Step 3.11
- **Pilot skills are a `{ skillId: level }` map**; a missing skill counts as level 0.
- **The Long Haul skill id is the constant `long-haul`** in sim, because the rule (decision 0.5.7) is specifically about that skill.
- **The cargo skill is checked before Long Haul** when reporting a failure reason.
## Step 3.12
- **Minimal state shapes:** `ShipState` { id, modelId, locationId, inFlight } and `PilotState` { id, isAvatar, assignedShipId, inFlight, skills }. The server maps its database rows to these.
- **Tutorial dispatch:** the caller passes the loaner as the ship (a `ShipState` built from the player's `tutorial` object, decision 0.5.26) and sets `tutorial: true`. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that.
- **Checks run in the plan's order**, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits.
- **Fuel is charged in whole credits** (`tripFuelCost` rounds), using the distance at departure (`now`).
## Step 3.13
- **Extra record fields:** `tutorial` (resolution pays the tutorial share and skips pilot pay) and `distanceMillionKm` (the distance at departure, used for XP), on top of the plan's fields.
- **The caller supplies the dispatch id**; the seed is `deriveSeed(worldSeed, "dispatch:<id>")`.
- **Arrival** = departure + travel time in game ms ÷ compression, rounded to whole real milliseconds.
- **`fuelCost` is what the player pays:** 0 for tutorial runs.
- **`expectedPayout` is the contract payout** before pilot pay; resolution deducts pilot pay.
## Step 3.14
- **A reposition needs a player bay at the destination**, checked against `bayLocationIds`. Bays exist only at discovered locations, so this also guarantees the destination is discovered.
- **Reposition check order:** ship idle → pilot idle → pilot assigned → not already there → bay at destination → landing → range → fuel credits. There are no skill checks (decision 0.5.24). Tutorial loaners cannot reposition.
- **Contract and reposition records share one builder**; a reposition has `contract: null` and `expectedPayout: 0`.
- **"Zero XP" is enforced at resolution** (Step 3.17), because the record has no XP field. That step's tests cover it.
## Step 3.15
- **Progress is linear in real time.** Takeoff and landing overhead are not modelled separately, so the ship moves at a constant rate along its line.
- **A zero-length flight reports progress 1** instead of dividing by zero.
## Step 3.16
- **Delivery XP** = round(base + perMillionKm × distance + perCargoTier × tier), using the distance at departure stored on the dispatch record.
- **Pilot level** is the number of `pilotLevelXpThresholds` the total XP has reached (0 below the first threshold), so levels cap at the list length (10).
- **Skill points available** = level × points per level − points spent, floored at 0.
## Step 3.17
- **Resolution credits = contract payout − pilot pay.** Fuel is not included, because it is charged when the ship departs (the record's `fuelCost`). Credits can be negative if a payout is below pilot pay; the server applies the number as given.
- **Tutorial runs grant XP to the avatar** like any delivery; the plan does not exclude it. They credit the stored tutorial payout with no pilot pay.
- **Repositions resolve to 0 credits and 0 XP** and never discover anything, since they only fly to bays.
- **The caller passes `pilotIsAvatar` and the current discovered set**, so the function stays pure. "Exactly once" is the server's job: it must store the resolution and the acknowledged-at state transactionally.
## Step 3.18
- **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">`).
## Step 5.1
- **The server runs with `tsx`** (`pnpm --filter @deadhead/server dev` / `start`). Workspace packages ship TypeScript source with extensionless imports, which Node's built-in type stripping cannot resolve. There is no build step yet.
- **Config** comes from env vars validated with Zod in `loadConfig`, which reports every problem at once. `NODE_ENV`, `PORT` and `LOG_LEVEL` have defaults (development, 3000, info). The entry point loads the repo-root `.env` with `process.loadEnvFile` when the file exists; deployments set real env vars.
- **The health endpoint is `GET /api/health`**, under `/api` so it is reachable through the same-domain proxy (decision 0.5.20). It is infrastructure, so it is not in the route catalog.
- **`buildApp(config)` does not listen,** so tests start it on port 0. Logs are pino JSON; tests use `LOG_LEVEL=silent`.
## Step 5.2
- **The injectable clock arrives now, not in Step 7.2.** `buildApp(config, { clock })` takes a `Clock` (`() => RealTime`) and defaults to `Date.now`. Every endpoint will read "now" through it (decision 0.5.18). Step 7.2 still adds the test-only clock-offset endpoint.
- **Game time uses the default `content.config`**, via sim `realToGame`.
## Step 5.3
- **Every game table references `players.id`.** Players and companies are one-to-one; `companies` holds credits and is the row that money-changing endpoints lock (Step 6.14).
- **Times are `bigint` Unix ms** (Drizzle `mode: "number"`), like sim and the API. World seeds are `bigint` because unsigned 32-bit values overflow `integer`.
- **Status enums use `in_flight`** in the database, as the plan words it; the API uses `in-flight`, so the server maps between them.
- **Dispatches store the full sim `DispatchRecord` as JSONB**, plus the columns queries need (`status`, `arrival_at`, `resolved_at`, `acknowledged_at`, `tutorial`, `ship_id`, `pilot_id`). The applied `Resolution` is kept as JSONB for the summary. A tutorial dispatch has `ship_id` null, because the loaner is not a ship row.
- **Pilot skills are a JSONB `{ skillId: level }` map**, plus a `skill_points_spent` counter. Level is derived from XP, not stored.
- **Contract boards store their contracts as JSONB**, keyed by player, kind (`bay` / `tutorial`), location (the bay or the loaner's location) and period.
- **Auth tables are not here yet;** Better Auth's tables and the link from players to users arrive in Step 5.5.
- **Migrations are generated SQL** (`drizzle-kit generate`) committed in `apps/server/drizzle/`, which Prettier ignores. They are applied with the drizzle-orm migrator (`db:migrate`, or `runMigrations(url)` in tests).
- **Server test files run one at a time** (`apps/server/vitest.config.ts`) because integration tests share one test database.
- **CI runs a `postgres:18` service container** and points `TEST_DATABASE_URL` at it; locally the default is the compose database `deadhead_test`.
## Step 5.4
- **The harness truncates every table in the `public` schema before each test**, rather than wrapping tests in rolled-back transactions. It is simpler, and it works for code that opens its own transactions. Because test files run one at a time, this is safe.
- **`createPlayer` is real game code** (`src/game/new-player.ts`), used both by the harness and, from Step 5.5, by guest sign-up. A new player gets starting credits, the starting-discovered locations, the avatar, and tutorial mode with the loaner at Earth.
- **The avatar is named "Captain"** and the company "Deadhead Freight" for now; there is no naming step in the base game. Hired pilots get generated names (Step 6.8).
- **During the tutorial the avatar is unassigned and stores its location** (decision 0.5.27). The tutorial dispatch code keeps it equal to the loaner's location.
- **Seed states:** `tutorial` (a brand-new player) and `owner` (tutorial over, one Courier at Earth crewed by the avatar, an Earth bay). Later steps add states as their tests need them.
## Step 5.5
- **Better Auth's tables are generated by its CLI** (`@better-auth/cli generate` with the anonymous plugin) into `src/db/auth-schema.ts` and migrated by drizzle-kit (`0001_auth`). They keep Better Auth's own conventions (Postgres `timestamp` columns, singular table names) rather than ours.
- **`players.user_id` links each player to one Better Auth user** (unique, cascade on delete).
- **Guest creation uses Better Auth's server API, not its HTTP routes.** `POST /api/guest` calls `auth.api.signInAnonymous`, forwards the `Set-Cookie` header, and creates the player in the same request. `/api/auth/*` is not mounted, since nothing in the base game needs it yet (upgrading guests to full accounts is not in scope).
- **World seeds come from `crypto.randomInt(0, 2³²)`.**
- **Better Auth uses real time for session expiry**, not the injectable game clock. Auth is not game logic, so this does not affect deterministic tests.
- **Errors:** an `ApiError(status, code, message)` thrown anywhere becomes `{ error: { code, message } }`. Unknown routes return `404 not-found`, and unexpected exceptions are logged and return `500 internal-error`.
- **`GET /api/state` is implemented now without lazy resolution.** It reads ships, pilots (level and skill points derived with sim), bays, all locations with their discovered flag, tutorial state, in-flight dispatches and unacknowledged summaries. Step 6.1 adds resolution, and Step 6.2 its fuller tests.
- **`buildApp` takes an optional `database`.** Tests pass the harness's connection; otherwise the app opens its own and closes it on shutdown.
## Step 5.6
- **The catalog is wired in with a small `registerRoute` helper**, not `fastify-type-provider-zod`. The catalog merges path parameters with the body into one request schema, which the type provider's separate params/body/query slots do not model.
- **Request validation runs before the session check,** so a malformed request always gets `400 validation-error`, even without a session.
- **Requests with no fields reject unexpected ones** (`emptyRequestSchema` is strict), including query parameters.
- **Responses are validated everywhere except production.** A violation is logged and returned as `500 internal-error`, which makes tests fail loudly. Production skips the check to save work.
- **Fastify's own 4xx errors** (malformed JSON, wrong content type) are reported as `400 validation-error` in the documented format.