Step 1.1: create pnpm monorepo

pnpm workspace with apps/web, apps/server, packages/sim, packages/schema
and packages/content, each extending a strict tsconfig.base.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-02 17:28:21 -04:00
commit 66308e7cdc
28 changed files with 1100 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
.env
.env.local
.DS_Store

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
24

11
DECISIONS.md Normal file
View File

@ -0,0 +1,11 @@
# 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.

11
apps/server/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "@deadhead/server",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
}
}

1
apps/server/src/index.ts Normal file
View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

11
apps/web/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "@deadhead/web",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
}
}

1
apps/web/src/index.ts Normal file
View File

@ -0,0 +1 @@
export {};

4
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View File

View File

@ -0,0 +1,224 @@
# Game Design Document — Deadhead: Space Freight Tycoon
**Title:** Deadhead: Space Freight Tycoon ("deadhead" — trucker slang for running empty, a nod to the backhaul mechanic)
**Genre:** Real-time logistics / management tycoon
**Platforms:** Web and mobile (installable PWA, optionally wrapped with Capacitor for app stores)
**Tone:** Soft sci-fi, utilitarian, "American/Euro Truck Simulator in space" — with the focus on management, not piloting
---
## 1. Concept
The player builds a space shipping company from nothing. They start by running jobs for private companies using company ships, save up to buy their first ship, rent a docking bay on Earth, hire pilots, and expand across the solar system — and eventually to nearby stars.
The game is about **logistics decisions**: which jobs to take, which ship and pilot to send, where to open bays, and how to grow. There is no manual flying or landing.
## 2. Design Pillars
1. **Management over simulation** — every interaction is a decision, not a dexterity test.
2. **Put the phone down** — real-time with offline progress; ships keep flying while the app is closed.
3. **Every load goes somewhere for a reason** — contracts come from what each destination produces and needs.
4. **Earned expansion** — new locations are discovered by delivering there, not bought from a menu.
5. **Soft sci-fi** — real solar system locations and distances, but fun beats physics.
## 3. Setting
- Our own solar system, then nearby real stars (late game).
- Ships are classic sci-fi freighters: they take off from a pad, cruise, and land directly at the destination. Think utilitarian haulers with landing struts and big cargo doors, not sleek fighters.
- Distances are based on real planetary positions, so routes like Earth–Mars get shorter or longer over time.
## 4. Core Loop
1. Browse contracts at a docking bay.
2. Choose a ship and pilot, set the throttle, and dispatch.
3. Ship travels in real time (continues while offline).
4. Delivery completes: payout, pilot XP, possible new destination discovered.
5. Reinvest: ships, bays, pilots, pilot skills.
## 5. Progression
### Phase 1 — Contract Pilot (tutorial)
- The player is the pilot, flying company-owned ships on Earth ↔ Moon runs.
- Short and fast; the goal is to teach the loop and get the player to their first ship quickly.
### Phase 2 — Owner-Operator
- Buy a first ship, rent a docking bay on Earth, hire a first pilot.
- The player moves into a management role.
### Phase 3 — Fleet Company
- Multiple ships, pilots, and bays across the inner system, belt, and gas giants.
- Automation becomes important (see §12).
### Phase 4 — Interstellar
- A late-game unlock (jump gates or an FTL technology) opens nearby real stars such as Alpha Centauri, Barnard's Star, and TRAPPIST-1.
- Treated as a new map region, similar to a new map expansion in ATS/ETS.
## 6. Time Model
- **Real-time with offline progress.** Closing the app does not pause the game.
- **Compressed game clock:** default **1 real minute = 1 game hour** (configurable for balancing).
| Route (approx.) | Real-time duration at default scale |
|---|---|
| Earth → Moon | A few minutes |
| Earth → Mars | ~1–2 hours (varies with planet positions) |
| Earth → Jupiter | A few hours |
| Outer system | Overnight runs |
## 7. Travel
```
travel time = distance / cruise speed + takeoff/landing overhead
```
- **Overhead** is a fixed cost per trip. Small, nimble ships do best on short hops; big haulers do best on long routes where cruise speed dominates.
- **Throttle setting** per dispatch: *Cruise* (standard) vs. *Push the engines* (faster, more fuel, more wear).
- **Fuel range** limits distance per trip. Longer routes need multi-leg trips with refuel stops, making waypoint bays (e.g., Mars, Ceres) valuable.
- **Launch cost** scales with the departure world's gravity. Earth is the most expensive pad to launch from; the Moon is cheap, which makes a lunar bay a natural hub.
## 8. Ships
Multiple ships are available for purchase, each with different stats.
### Ship Stats
| Stat | Effect |
|---|---|
| Cruise speed | Travel time |
| Cargo capacity | Mass and/or volume per trip |
| Fuel range | Max distance before refueling |
| Hull rating | Event survival, insurance cost, dangerous-cargo eligibility |
| Landing class | Which destinations the ship can land at |
| Upkeep | Maintenance, crew, and docking costs |
### Landing Classes
| Class | Can land at |
|---|---|
| Vacuum | Airless bodies, belt stations |
| Atmospheric | Above + atmospheric worlds and cloud platforms |
| Hardened | Above + hostile worlds (Venus, Io) |
### Example Archetypes
- **Courier** — fast, small hold, short range, low overhead, atmospheric-rated. Great for short or urgent jobs.
- **Freighter** — balanced all-rounder.
- **Bulk hauler** — slow, huge hold, high capacity. Great for long, heavy routes.
Ship tiers should feel like upgrading from a box truck to a top-end rig.
## 9. Destinations
| Type | Examples | Landing need | Main export | Main hazard |
|---|---|---|---|---|
| Atmospheric world | Earth, Mars, Titan | Atmospheric | Manufactured goods, food | High launch cost |
| Airless body | Moon, Mercury, rocky moons | Any | Minerals, research | Low |
| Hostile world | Venus, Io | Hardened | Rare materials | Environmental damage |
| Cloud platform | Jupiter, Saturn (later Uranus, Neptune) | Atmospheric | Fuel, gases (H₂, He-3) | Storms |
| Belt station | Ceres, Vesta, mining outposts | Any | Ore, metals, ice | Debris fields |
### Gas Giant Cloud Platforms
- Floating industrial platforms in the upper atmosphere.
- The system's fuel refineries: fuel is cheapest here and most expensive near Earth.
- Isolated company towns that import parts, food, and supplies (good backhaul opportunities).
- Storm events can delay departures or damage hulls; Jupiter is rougher than Saturn.
### Asteroid Belt Stations
- Mining and ore-processing stations. Export heavy raw materials; import equipment, supplies, and people.
- Routes through the belt carry debris and collision risk; hull rating matters and hazard pay increases.
- **Ceres** acts as the major hub and refuel waypoint, the gateway to the outer system.
- Station variety: large established stations, rough mining outposts, and possibly a shady independent station offering high-risk contracts.
## 10. Docking Bays & Discovery
- **Docking bays** are rented at a location and act as the player's base there: a place to pick up contracts and house ships.
- A location must be **discovered** before a bay can be rented there. Discovery happens by **completing a delivery to that location**.
- Contracts to undiscovered destinations appear occasionally at existing bays as well-paying "first contact" or survey jobs, giving players a path to expand.
## 11. Pilots
Pilots are hired, assigned to ships, and gain **XP** from deliveries. XP is spent on skills that unlock better contracts and improve results.
### Skills
| Skill | Effect |
|---|---|
| Long haul | Access to longer routes; better fuel efficiency on long trips |
| Heavy cargo | Access to heavy loads; reduces speed penalty from heavy cargo |
| Expensive cargo | Access to high-value cargo |
| Dangerous cargo | Access to hazardous cargo, tiered by hazard class |
| Fragile cargo | Access to medical, biological, and instrument cargo |
| Urgent delivery | Access to just-in-time contracts with time bonuses |
| Fuel efficiency | Reduced fuel use and wear |
**Design rule:** skills should change outcomes (speed, failure chance, fuel use), not just unlock job tiers, so every level-up changes the player's math.
### Pilot Location
- A pilot is wherever their ship is. Pilots only travel by flying a ship.
- Pilots are hired at one of the player's bays and can only be unassigned at a bay, so they never get stranded.
### Pilot Tension
- Salaries scale with level.
- High-level pilots may be poached by rivals.
## 12. Contracts & Economy
### Contract Generation
- Every destination **produces** some goods and **needs** others. Contracts are generated from these flows.
- Backhaul matters: the best routes avoid flying home empty.
- Each bay has one contract board per refresh period. Contracts expire when the period ends.
- Payout is fixed when a contract is offered; travel time and fuel depend on the distance at departure.
### Payout Factors
- Distance
- Cargo type (heavy, high-value, dangerous, fragile)
- Urgency and deadline
- Destination hazard
- Client reputation
### Money Sinks
- Fuel (priced by location)
- Maintenance and wear
- Insurance (scales with cargo value and danger)
- Pilot salaries
- Docking bay rent
### Reputation
- Standing with client companies is a second progression axis alongside money.
- Higher reputation unlocks better contracts; sometimes taking a lower-paying job is worth it for standing.
### Automation (Mid/Late Game)
- Standing routes and auto-accept rules (for example, "take any heavy cargo job on Moon–Mars above X credits").
- Prevents micromanagement from becoming a chore at large fleet sizes.
## 13. Events & Risk
Events are rolled at dispatch time (see §14) and can include:
- Solar flares (delays)
- Gas giant storms (delays, hull damage)
- Belt debris (hull damage)
- Piracy (cargo loss, mitigated by hull rating and pilot skill)
- Customs holds (delays)
## 14. Technical Architecture
### Offline Progress
- Every trip is **deterministic from its dispatch record**. Nothing simulates while the app is closed.
- The dispatch record stores: departure timestamp, origin, destination, ship, pilot, cargo, throttle setting, and RNG seed.
- Arrival time, current position, and events are all derived from that record.
- **Events are pre-rolled at dispatch** from the seed, so reopening the app can't reroll outcomes (prevents save-scumming).
- On load, the client gets the current time from the server, resolves completed dispatches (payouts, XP, pilots returning), and shows in-flight ships at their interpolated positions.
### Backend
- **Server-authoritative time**, so changing the device clock has no effect.
- **Cloud saves** so web and mobile share one game state.
### Notifications
- Web Push for arrivals and events (supported on installed PWAs, including iOS 16.4+).
- A Capacitor wrapper is an option for more reliable notifications and app-store distribution.
## 15. Open Questions
- Visual style
- Exact contract generation and payout formulas
- Ship roster: number of ships, prices, and stat values
- Interstellar unlock mechanic: jump gates vs. ship-based FTL
- Single-player only, or leaderboards and other social features?
- Monetization model, if any
- Tech stack: framework and rendering for the map and UI

View File

@ -0,0 +1,508 @@
# Implementation Plan — Deadhead: Space Freight Tycoon (Base Game)
Companion to `game-design-document.md` and `tech-stack.md`.
This plan covers the **base game only**: the core loop of taking contracts, dispatching ships, earning money, buying ships, renting bays, hiring pilots, discovering locations, and leveling pilots — all in real time with offline progress. Everything else is deferred (see §0.3).
---
## 0. Read First
### 0.1 Rules for AI developers
1. Do the steps **in order**. Do not start a step until the previous step's test passes.
2. Each step is **one commit** (or one PR). The commit message names the step number.
3. Every step's test must be **automated** unless the step explicitly says "manual check."
4. `packages/sim` must stay **pure**: no `Date.now()`, no `Math.random()`, no network, no database, no file I/O. Time and seeds are always passed in as arguments.
5. All game numbers (prices, speeds, payouts, XP curves) live in `packages/content`, never hard-coded in sim, server, or client.
6. The server is the authority. The client may *predict* (ETAs, positions, previews) using `sim`, but never decides outcomes.
7. If a step is ambiguous, choose the simplest interpretation that satisfies its test, and leave a short note in `DECISIONS.md` at the repo root.
8. Do not add features not listed in a step.
9. Update `progress.md` after every step. Update `architecture.md` after any database schema or API change; the full Drizzle schema lives in `architecture.md`.
10. Commit after every step without waiting for review. Stop for the user's review at the end of each phase.
### 0.2 Base game scope
| Area | In base game |
|---|---|
| Locations | Earth, Moon, Mars, Ceres Station (belt), Jupiter Cloud Platform |
| Landing classes | Vacuum, Atmospheric (Hardened is deferred) |
| Ships | 3 archetypes: Courier, Freighter, Bulk Hauler |
| Cargo types | Standard, Heavy, High-Value, Dangerous |
| Pilot skills | Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo |
| Phases | Tutorial (contract pilot) → Owner-Operator → small fleet |
| Time | Real time with offline progress, compressed game clock |
| Platform | Web, installable as a PWA |
### 0.3 Explicitly deferred (do NOT build)
Random events, reputation, fuel price variation, throttle setting, multi-leg routes, recurring bay rent, pilot salaries over time, pilot poaching, automation/standing routes, interstellar, push notifications, Capacitor builds, Hardened landing class, Venus/Io, Fragile/Urgent/Fuel-efficiency skills, social features, monetization.
### 0.4 Glossary
- **Location** — a place a ship can land (planet, moon, station, platform).
- **Discovered** — a location the player has delivered to at least once (Earth and Moon start discovered).
- **Bay** — a docking bay the player rents at a discovered location. Contracts are offered only at bays (plus the tutorial board).
- **Contract** — a job: cargo of a type and mass, from origin to destination, with a payout and skill requirements.
- **Dispatch** — a ship + pilot sent on a trip, either carrying a contract or repositioning empty.
- **Game time** — the in-world clock, derived from real time × compression factor.
- **Resolution** — applying the result of a completed dispatch (payout, XP, ship relocation, discovery) exactly once.
- **Avatar** — the player's own pilot, created at the start. Exempt from pilot pay.
- **Loaner** — the company-owned Courier the avatar flies during the tutorial. Removed when the tutorial ends.
### 0.5 Resolved design decisions
These answer the pre-build review. Log each one in `DECISIONS.md` as it is implemented.
1. **All three base ships are atmospheric-rated.** In the base game, ships are gated by range, capacity and speed. The landing-class check stays in the sim (it matters once Hardened ships arrive) and is tested with a test-only vacuum ship.
2. **Pilot locations.** An assigned pilot is wherever their ship is. An unassigned pilot stays where they were unassigned. Pilots travel only by flying a ship. Unassigning is allowed only at a location with a player bay. New hires appear at a player bay the player chooses.
3. **Tutorial movement.** The avatar and loaner move with each tutorial run. The tutorial board starts at the loaner's current location (Earth→Moon or Moon→Earth). The company pays fuel and pilot pay; the player receives only the tutorial share.
4. **Tutorial end.** The tutorial ends when the player owns a ship, has an Earth bay, and has no tutorial run in flight. The loaner is removed and the avatar moves to Earth, unassigned.
5. **Contract boards are stored.** A bay's board is generated on the first request in a period and stored. Later requests in that period return the stored board, so a discovery mid-period does not change it. Every contract expires at the end of its board period; nothing carries over.
6. **Cargo tier.** Standard is tier 0. A non-Standard contract has a tier from 1 to 5, and that tier is the required level in the cargo's skill.
7. **Long Haul.** A config list of distance cut-offs, one per Long Haul level. The required Long Haul level is the number of cut-offs the route distance exceeds.
8. **Config-driven tuning.** First-contact chance, first-contact multiplier, contract mass range, and Long Haul cut-offs all live in GameConfig.
9. **Game epoch.** Two fields: a real start timestamp and the in-game ephemeris date it maps to. The clock is global for all players.
10. **World seed** is per player and is also used for pilot and ship names.
11. **Payout** is fixed when a contract is offered, using the distance at that moment. Travel time and fuel use the distance at departure.
12. **Pilot pay** applies to every hired pilot. The avatar is exempt.
13. **Hull rating** is stored but has no effect in the base game.
14. **Ship names** are generated from the world seed. No renaming.
15. **Bays** have no ship limit.
16. **Tutorial purchases.** The player may buy or lease anything they can afford during the tutorial.
17. **Cargo fallback.** If no cargo type is both produced at the origin and demanded at the destination, use Standard.
18. **Testable clock.** The server reads time through an injectable clock. Tests call job handlers directly; E2E tests use a test-only clock-offset endpoint that does not exist in production.
19. **Summaries.** Dispatches have an acknowledged-at column; resolved dispatches without one form the "while you were away" summary.
20. **Same domain.** In production, the client and API share one domain, with the API under `/api`, so the session cookie is first-party (iOS Safari blocks cross-site cookies).
21. **Tutorial boards per location.** The tutorial board is stored per period *and* per loaner location (one Earth→Moon board, one Moon→Earth board). The tutorial board size is set large enough that a player cannot exhaust it within one period.
22. **Pilot endpoints.** The route catalog includes "unassign pilot", and "hire pilot" takes the bay location to hire at.
23. **Avatar during tutorial.** The avatar is locked to the loaner and cannot be assigned to an owned ship until the tutorial ends.
24. **Repositions need no skills.** A reposition checks range and landing only; no Long Haul or cargo skill is required.
25. **Freighter range** covers Ceres at its farthest from Earth (≈600M km), so it can also reach Jupiter at its closest. A ship left out of range of every bay waits until distances shrink; this is intended.
26. **Loaner in player state.** Player state has a `tutorial` object with the loaner's location and whether a run is in flight. The loaner is not in the ships list.
27. **Pilot location storage.** Only unassigned pilots store a location; an assigned pilot's location is derived from their ship.
28. **Git hosting.** The repo is on a self-hosted Gitea instance: `http://192.168.1.107:3000/rory/deadhead-space-freight-tycoon`. CI uses Gitea Actions.
---
## Phase 1 — Repository Setup
### Step 1.1 — Create the monorepo
- Create a pnpm workspace with folders `apps/web`, `apps/server`, `packages/sim`, `packages/schema`, `packages/content`.
- Each package gets its own `package.json` with a name scoped as `@deadhead/<name>`.
- Add a root TypeScript base config with strict mode on; each package extends it.
- **Test:** Running the root install command succeeds, and running the root type-check command reports zero errors across all packages (each containing one empty placeholder file).
### Step 1.2 — Add linting and formatting
- Add ESLint and Prettier at the root with shared config.
- Add a lint rule that forbids `Date.now`, `new Date()` with no arguments, and `Math.random` inside `packages/sim` and `packages/content`.
- **Test:** The root lint command passes. Temporarily adding `Math.random()` to a file in `packages/sim` makes lint fail; remove it afterward.
### Step 1.3 — Add the test runner
- Add Vitest at the root configured to run tests in every package.
- Add one trivial passing test in `packages/sim`.
- **Test:** The root test command runs and reports 1 passing test.
### Step 1.4 — Add continuous integration
- Initialize git in the project folder (it is not a repo yet) and push to the Gitea remote (decision 0.5.28).
- Add a Gitea Actions workflow (`.gitea/workflows/`) that runs install, type-check, lint, and test on every push.
- **Test:** Push the branch; the Actions run is green. Ask the user to confirm in the Gitea UI.
### Step 1.5 — Add local Postgres via Docker Compose
- Docker Desktop is installed on the development machine. Add a `docker-compose.yml` with a Postgres service for development and a second Postgres database for tests.
- Add an `.env.example` listing every environment variable the server will need (database URLs, auth secret, port, game clock settings).
- **Test:** Starting compose brings Postgres up, and connecting with a Postgres client to both databases succeeds.
---
## Phase 2 — Content Package (static game data)
### Step 2.1 — Define content schemas
- In `packages/content`, define Zod schemas for: Location, ShipModel, CargoType, Skill, and a GameConfig object.
- Location fields: id, display name, type (atmospheric world / airless body / cloud platform / belt station), landing requirement (vacuum or atmospheric), astronomy body it is positioned from (e.g., Earth, Moon, Mars, Ceres, Jupiter), gravity launch-cost multiplier, starts discovered (yes/no), bay lease price, produces (list of cargo type ids), demands (list of cargo type ids).
- ShipModel fields: id, display name, archetype, price, cruise speed (km per game hour), cargo capacity (tonnes), fuel range (million km), hull rating, landing class, takeoff/landing overhead (game hours), fuel cost per million km.
- CargoType fields: id, display name, base pay per tonne per million km, tier pay multiplier, required skill id (none for Standard). Tiers follow decision 0.5.6.
- Skill fields: id, display name, max level, description.
- GameConfig fields: time compression factor; game epoch as two fields (real start timestamp and the in-game ephemeris date it maps to); starting credits; tutorial payout share; contract board size; contract board period (real minutes — contracts expire at the end of their period); first-contact chance; first-contact payout multiplier; contract mass range; Long Haul distance cut-offs (one per level); XP per delivery formula inputs; XP needed per pilot level; skill points per level; hire cost per pilot; pilot pay per delivery (flat fee).
- **Test:** Unit tests confirm each schema accepts a valid example and rejects an object missing a required field.
### Step 2.2 — Add the five locations
- Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD.
- Earth and Moon start discovered. Earth is atmospheric with the highest launch multiplier; Moon is vacuum with a low multiplier; Mars is atmospheric; Ceres is a belt station (vacuum); Jupiter Cloud Platform is a cloud platform requiring atmospheric landing.
- **Test:** A test loads all locations through the schema without errors, asserts exactly five locations, asserts exactly Earth and Moon start discovered, and asserts every cargo id referenced in produces/demands exists.
### Step 2.3 — Add the three ship models
- Courier: fastest, smallest hold, short range, atmospheric, lowest price.
- Freighter: balanced, atmospheric, mid price.
- Bulk Hauler: slowest, largest hold, longest range, atmospheric, highest price.
- Pick numbers so that, at default config: a Courier can reach the Moon but not Mars, Ceres or Jupiter, even at their closest; a Freighter can reach Mars and Ceres; only the Bulk Hauler can reach Jupiter at its farthest distance.
- All three ships are atmospheric (decision 0.5.1).
- **Test:** A test validates all ships through the schema and asserts the ordering rules: Courier speed > Freighter speed > Bulk Hauler speed, Bulk Hauler capacity > Freighter > Courier, price Courier < Freighter < Bulk Hauler. (Range rules are tested in Phase 3 once distances exist.)
### Step 2.4 — Add cargo types and skills
- Cargo types: Standard (no skill), Heavy (Heavy Cargo skill), High-Value (Expensive Cargo skill), Dangerous (Dangerous Cargo skill). Non-standard types pay more per tonne-distance.
- Skills: Long Haul, Heavy Cargo, Expensive Cargo, Dangerous Cargo, each max level 5.
- **Test:** A test validates all entries, asserts every cargo type's required skill exists, and asserts each non-standard cargo pays more than Standard.
### Step 2.5 — Add the default game config
- Set time compression to 60 (1 real minute = 1 game hour).
- Choose starting credits, tutorial payout share, and ship prices so that the cheapest ship plus an Earth bay lease is affordable after roughly 15–25 tutorial runs.
- **Test:** A test validates the config through the schema. A second test computes (Courier price + Earth bay lease) ÷ average tutorial payout (using a fixed placeholder average defined in the test) and asserts it falls between 15 and 25. This test will be revisited in Phase 12 with real sim numbers.
### Step 2.6 — Export a single content bundle
- Export one `content` object containing all locations, ships, cargo types, skills, and config, plus lookup helpers by id.
- **Test:** A test imports the bundle and looks up one item of each kind by id successfully; looking up an unknown id returns "not found" rather than throwing.
---
## Phase 3 — Simulation Package (pure, deterministic core)
### Step 3.1 — Game clock conversion
- Add functions to convert real timestamps to game time and back, using the config's epoch and compression factor.
- **Test:** Converting real → game → real returns the original value. One real minute after the epoch equals exactly one game hour after the game epoch at compression 60.
### Step 3.2 — Seeded random number generator
- Add a small seeded PRNG with functions: next float in [0,1), integer in range, pick from list, and derive a child seed from a parent seed plus a string label.
- **Test:** The same seed produces the same sequence of 1,000 values across two runs. Different seeds produce different sequences. Child seeds derived with different labels differ.
### Step 3.3 — Location positions
- Add a function that returns a location's 2D position (in million km, ecliptic plane) for a given game time, using `astronomy-engine` for the parent body.
- Earth and Mars use their own heliocentric positions. Moon uses Earth's position plus a small fixed offset. Ceres uses its orbital elements (or a fixed-radius circular orbit approximation if the library does not provide Ceres). Jupiter Cloud Platform uses Jupiter's position.
- **Test:** For a fixed known date, Earth is about 150 million km from the Sun, Mars between 206 and 250 million km, Jupiter between 740 and 820 million km. Calling the function twice with the same time returns identical values.
### Step 3.4 — Route distance
- Add a function returning the distance between two locations at a given game time.
- **Test:** Earth→Moon is under 1 million km at any time. Earth→Mars distance varies by at least 200 million km across a 2-game-year span (sample monthly). Distance A→B equals B→A.
### Step 3.5 — Travel time
- Add a function: travel time = distance ÷ ship cruise speed + ship takeoff/landing overhead. Distance is measured at departure time (no mid-flight recalculation).
- **Test:** For fixed inputs, the result matches a hand-calculated value. A faster ship always arrives sooner on the same route. Overhead dominates on Earth→Moon (overhead is more than half of total time for the Courier).
### Step 3.6 — Fuel cost
- Add a function: fuel cost = distance × ship fuel cost per million km × origin location's launch multiplier.
- **Test:** Earth→Moon costs more than Moon→Earth for the same ship. Fuel cost scales linearly with distance.
### Step 3.7 — Range and landing eligibility
- Add a function that decides whether a ship can fly a route at a given time, returning either "allowed" or a specific reason: out of range, or cannot land at destination (landing class).
- **Test:** A Courier can fly Earth→Moon and Moon→Earth. A Courier is out of range for Mars at its closest. A test-only vacuum ship fixture is rejected at Earth with the cannot-land reason. At Jupiter's farthest point from Earth, only the Bulk Hauler is in range. A Freighter can reach Ceres. (These confirm the Step 2.3 number choices; adjust content if they fail.)
### Step 3.8 — Contract model and payout
- Define a Contract shape: id, origin, destination, cargo type, tier, mass, payout, required skill and level, required Long Haul level, offered-at time, expires-at time (end of its board period), first-contact flag.
- Add a payout function: base pay per tonne per million km × tier multiplier × mass × distance, times the first-contact multiplier when flagged. Payout is fixed at offer time using the distance at that moment (decision 0.5.11).
- **Test:** Payout scales linearly with mass and distance. Dangerous cargo pays more than Standard for identical mass and route. A first-contact contract pays more than an otherwise identical normal contract.
### Step 3.9 — Contract board generation
- Add a function that generates a bay's contract board from: the bay's location, the set of discovered locations, the board period index (real time ÷ board period), and the player's world seed.
- Rules: the board has the configured number of contracts; destinations are mostly discovered locations; cargo type is chosen from types the origin produces and the destination demands, falling back to Standard if none match; the configured first-contact chance decides whether a contract targets a random undiscovered location instead (flagged first-contact); mass comes from the configured range; non-Standard contracts get a tier from 1 to 5, which is their required skill level; required Long Haul level comes from the distance cut-offs; every contract expires at the end of the board period.
- **Test:** The same inputs always produce an identical board. A different period index produces a different board. No contract has origin equal to destination. Over 1,000 generated boards, first-contact contracts are between 5% and 15% of all contracts and every one targets an undiscovered location.
### Step 3.10 — Tutorial contract board
- Add a separate generator for the tutorial "company board": Earth↔Moon only, Standard cargo only, payout reduced by the tutorial payout share, always flown with the company loaner Courier. Contracts start at the loaner's current location: Earth→Moon when it is at Earth, Moon→Earth when it is at the Moon.
- **Test:** With the loaner at Earth, every tutorial contract is Earth→Moon; with it at the Moon, every one is Moon→Earth. All use Standard cargo and pay exactly the tutorial share of the equivalent normal payout.
### Step 3.11 — Pilot eligibility
- Add a function that decides if a pilot can take a contract given their skill levels: the pilot's level in the contract's required skill must meet the required level, and the pilot's Long Haul level must meet the contract's required Long Haul level (decision 0.5.7).
- **Test:** A level-0 pilot can take a short Standard contract. A level-0 pilot cannot take a Heavy contract requiring Heavy Cargo 2. A pilot with Heavy Cargo 2 can. A level-0 pilot cannot take a long route requiring Long Haul 1.
### Step 3.12 — Dispatch validation
- Add a function that validates a proposed dispatch: ship is idle at the contract's origin, pilot is idle and assigned to that ship (or the tutorial loaner case), cargo mass fits ship capacity, route passes range and landing checks, pilot is eligible, contract has not expired, player can afford fuel (skipped for tutorial dispatches, where the company pays).
- Return "valid" or the first failing reason.
- **Test:** One test per failure reason, each constructing a scenario that triggers exactly that reason. One test for a fully valid dispatch.
### Step 3.13 — Dispatch record creation
- Add a function that turns a validated dispatch into a Dispatch record: id, ship, pilot, contract (or none for reposition), origin, destination, departure real time, arrival real time (computed from travel time and clock conversion), fuel cost, expected payout, seed.
- **Test:** Arrival time minus departure time equals the travel time converted to real time. Creating two records from identical inputs yields identical records.
### Step 3.14 — Reposition dispatch
- Allow a dispatch with no contract to any discovered location that has a player bay, paying fuel only and earning no payout or XP.
- **Test:** A reposition record has zero payout and zero XP. A reposition to a location without a player bay fails validation.
### Step 3.15 — In-flight position and progress
- Add functions that return, for a dispatch and a real time: progress (0 to 1, clamped) and an interpolated 2D position along a straight line from the origin's position at departure to the destination's position at arrival.
- **Test:** Progress is 0 at departure, 0.5 at the midpoint, 1 at arrival, and stays 1 after arrival. Position at progress 0 equals the origin position at departure time.
### Step 3.16 — XP and leveling
- Add functions for XP gained per delivery (based on distance and cargo tier from config), pilot level from total XP, and skill points available (levels × points per level minus points spent).
- **Test:** A longer delivery grants more XP than a shorter one. Crossing the XP threshold increases level by exactly one. Skill points available never go negative.
### Step 3.17 — Dispatch resolution
- Add a function that, given a dispatch and current real time, returns either "still in flight" or a Resolution: credits to add (payout minus pilot pay, with the avatar exempt from pilot pay; for tutorial runs, exactly the tutorial share), XP to add to the pilot, ship's new location, and whether the destination becomes discovered.
- **Test:** Before arrival → in flight. At or after arrival → Resolution. A delivery to an undiscovered location marks it discovered. A delivery flown by the avatar deducts no pilot pay; one flown by a hired pilot does. A reposition gives no credits or XP. Resolving the same dispatch twice with the same inputs returns identical results.
### Step 3.18 — Sim package public API and coverage
- Export only the public functions from a single entry point. Configure coverage reporting for `packages/sim`.
- **Test:** The test suite passes with at least 90% line coverage on `packages/sim`.
---
## Phase 4 — Shared Schema Package
### Step 4.1 — API data shapes
- In `packages/schema`, define Zod schemas for every API request and response the base game needs: player state, company, ship, pilot, bay, location (with discovered flag), contract, dispatch, resolution summary, server time, and errors.
- Errors have a stable machine code (matching the sim validation reasons) plus a human-readable message.
- **Test:** Each schema accepts a valid example and rejects an invalid one. Every sim validation reason from Step 3.12 has a matching error code.
### Step 4.2 — API route catalog
- Define a single typed catalog listing every endpoint: method, path, request schema, response schema. This is the contract both server and client use.
- Endpoints: get server time, create guest account, get full player state, get contract board for a bay, get tutorial board, create dispatch, buy ship, lease bay, hire pilot (at a chosen bay), assign pilot to ship, unassign pilot, spend skill point, acknowledge "while you were away" summary (decision 0.5.22).
- **Test:** A test asserts every endpoint has request and response schemas and that no two endpoints share a method and path.
---
## Phase 5 — Server Foundation
### Step 5.1 — Fastify skeleton
- Create the Fastify app in `apps/server` with environment config loading, structured logging, and a health endpoint.
- **Test:** An integration test starts the app and the health endpoint returns success.
### Step 5.2 — Server time endpoint
- Implement the server time endpoint returning the current real time and the current game time.
- **Test:** The returned real time is within 2 seconds of the test's own clock, and game time matches the sim conversion of that real time.
### Step 5.3 — Database schema and migrations
- Using Drizzle, define tables: players (with world seed), companies, ships, pilots (with current location and an is-avatar flag), bays, discovered_locations, contract_boards (one stored board per bay per period, plus tutorial boards per period per loaner location), accepted_contracts, dispatches. Dispatches store the full record including a snapshot of the contract, plus a status (in_flight or resolved), a resolved-at time, and an acknowledged-at time.
- Add a migration command.
- **Test:** Running migrations against the empty test database succeeds; running them a second time is a no-op.
### Step 5.4 — Test database harness
- Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.).
- **Test:** Two integration tests that each create a player both pass when run together, proving isolation.
### Step 5.5 — Guest accounts
- Integrate Better Auth with anonymous/guest sign-in. Creating a guest creates a player (with a random world seed) and company with starting credits, Earth and Moon discovered, the avatar pilot (level 0) at Earth, the loaner Courier at Earth, and tutorial mode on.
- **Test:** Calling create-guest returns a session. Fetching player state with that session shows starting credits, two discovered locations, one pilot, zero ships, and tutorial mode on. A request with no session is rejected.
### Step 5.6 — Request validation from the route catalog
- Wire the schema catalog into Fastify so every request and response is validated automatically.
- **Test:** Sending a malformed body to any endpoint returns a validation error with the documented error format. A response that violates its schema fails in test mode.
---
## Phase 6 — Server Game Logic
### Step 6.1 — Lazy resolution on read
- When player state is fetched, resolve every in-flight dispatch whose arrival time has passed, in arrival order, inside one database transaction, using the sim resolution function.
- Resolution is idempotent: a dispatch moves from in_flight to resolved exactly once, guarded by a conditional update on status.
- Resolved dispatches with no acknowledged-at time form the "while you were away" summary.
- **Test:** Seed a dispatch that arrived in the past; fetching state applies credits, XP, ship location, and discovery. Fetching again does not apply them twice. Two concurrent fetches apply them exactly once.
### Step 6.2 — Get full player state
- Return company credits, ships (with location or active dispatch), pilots (XP, level, skills, assignment, status), bays, discovered locations, tutorial flag, and unacknowledged summaries.
- **Test:** For a seeded player with one ship in flight and one idle, the response reflects both correctly and validates against its schema.
### Step 6.3 — Tutorial board endpoint
- Return the tutorial board for the current period and the loaner's current location, stored on first request per (period, location) (decision 0.5.21), excluding contracts this player has already accepted. Only available while tutorial mode is on.
- **Test:** A tutorial player at Earth gets a board of the configured size, all Earth→Moon. A non-tutorial player gets an error. Accepted contracts disappear from the board.
### Step 6.4 — Tutorial dispatch
- Allow a tutorial player to dispatch their avatar pilot with the company loaner Courier on a tutorial contract. The loaner is not owned and cannot be used for anything else. Only one tutorial dispatch may be in flight at a time.
- **Test:** A tutorial dispatch succeeds, charges no fuel, and creates an in-flight dispatch with the correct arrival time. A second tutorial dispatch while one is in flight is rejected. After arrival, state shows credits increased by exactly the tutorial share, pilot XP increased, and the loaner and avatar at the Moon; the next tutorial board is Moon→Earth.
### Step 6.5 — Buy a ship
- Allow buying any ship model if the player has enough credits. A new ship appears idle at Earth with no pilot assigned.
- **Test:** Buying with enough credits deducts the price and adds the ship. Buying without enough credits returns the insufficient-funds error and changes nothing.
### Step 6.6 — Lease a bay
- Allow leasing a bay at any discovered location where the player does not already have one, for that location's one-time lease price.
- **Test:** Leasing at Earth succeeds and deducts the price. Leasing at an undiscovered location fails. Leasing a second bay at the same location fails.
### Step 6.7 — End tutorial
- When the player owns at least one ship, has a bay at Earth, and has no tutorial run in flight, tutorial mode turns off automatically. The loaner is removed and the avatar moves to Earth as a normal, unassigned pilot.
- **Test:** With the avatar at the Moon, buying a ship and leasing an Earth bay turns tutorial mode off, removes the loaner, and leaves the avatar unassigned at Earth. If a tutorial run is in flight when the conditions are met, the tutorial ends when that run resolves. Afterward, the tutorial board endpoint returns an error.
### Step 6.8 — Hire and assign pilots
- Hiring a pilot costs the configured fee and creates a level-0 pilot with a generated name (from the player seed) at a player bay the player chooses. Pilots can be assigned to an idle, uncrewed ship at the same location. Unassigning is allowed only at a location with a player bay. One pilot per ship (decision 0.5.2).
- **Test:** Hiring at the Moon bay deducts credits and places the pilot at the Moon; hiring at a location without a bay fails. Assigning to an idle ship at the same location succeeds; assigning to a ship at another location, to a crewed ship, or while in flight fails. Unassigning at a location without a bay fails.
### Step 6.9 — Bay contract board endpoint
- Return the contract board for one of the player's bays for the current period. On the first request in a period, generate it with the sim using the player's discovered locations at that moment and store it in contract_boards; later requests in the period return the stored board. Exclude already-accepted contracts. Contracts from earlier periods are never shown.
- **Test:** A bay board is returned for an owned bay. Requesting a board for a location without a player bay fails. Two requests in the same period return the same board, even if a location is discovered between them. After the period rolls over, a new board is returned, and dispatching a contract from the old board fails as expired.
### Step 6.10 — Contract dispatch
- Dispatching a contract runs sim validation, deducts fuel cost, marks the contract accepted, and stores the dispatch record. Ship and pilot become in-flight.
- **Test:** One integration test per sim validation failure confirms the matching error code is returned and nothing changes. A valid dispatch deducts fuel, stores the record, and makes the contract disappear from the board.
### Step 6.11 — Reposition dispatch
- Dispatching with no contract to a discovered location with a player bay deducts fuel and moves the ship and pilot with no payout.
- **Test:** A reposition succeeds and, after arrival, the ship is at the new location with credits reduced only by fuel.
### Step 6.12 — Spend skill points
- Allow spending an available skill point on a skill below max level.
- **Test:** Spending increases the skill by one and reduces available points by one. Spending with no points or on a maxed skill fails.
### Step 6.13 — Acknowledge summaries
- Allow the client to mark "while you were away" summaries as seen.
- **Test:** After acknowledging, state returns no unacknowledged summaries; resolved effects remain.
### Step 6.14 — Concurrency and money safety
- All endpoints that change credits or ship/pilot state run in transactions with row locks on the company.
- **Test:** Firing two simultaneous buy-ship requests where the player can afford only one results in exactly one purchase and no negative balance.
---
## Phase 7 — Background Workers
### Step 7.1 — pg-boss setup
- Add pg-boss to the server, running workers in the same process for now, with a setting to disable workers.
- **Test:** An integration test enqueues a no-op job and observes it complete.
### Step 7.2 — Arrival resolution job
- When a dispatch is created, schedule a job at its arrival time that runs the same resolution logic as Step 6.1 for that player.
- The server reads time only through an injectable clock (decision 0.5.18). Add a test-only clock-offset endpoint for E2E tests; it must not be registered in production mode.
- **Test:** With the clock controlled in tests, a dispatch's job resolves it at arrival without any player state fetch. If lazy resolution already happened, the job does nothing. In production mode, the clock-offset endpoint returns not-found.
### Step 7.3 — Expired contract cleanup
- Add a periodic job that removes stored contract boards and accepted-contract records from periods older than a retention window.
- **Test:** Old boards and records are deleted; current-period ones remain.
---
## Phase 8 — Client Foundation
### Step 8.1 — Vite + React app
- Create the React app in `apps/web` with Tailwind, a basic layout (header with credits and game clock, main area, bottom navigation for mobile), and routing for: HQ, Contracts, Fleet, Pilots, Market, Locations.
- **Test:** A component test renders the layout and each route shows its placeholder title. Manual check: the layout fits a 375px-wide viewport with no horizontal scroll.
### Step 8.2 — Typed API client
- Build an API client from the route catalog so every call is typed and responses are validated.
- **Test:** Unit tests with a mocked fetch confirm a valid response is returned typed and an invalid response raises a validation error.
### Step 8.3 — Server-synced clock
- On startup, fetch server time and store the offset. Provide a "now" helper that returns device time plus offset, and a ticking store that updates every second.
- **Test:** With a mocked server time 10 minutes ahead of the device, the "now" helper returns a time 10 minutes ahead.
### Step 8.4 — Guest onboarding
- On first launch with no session, show a start screen with a "Start your company" button that creates a guest account. Persist the session.
- **Test:** Component test: clicking the button calls create-guest and navigates to HQ. Reloading with a stored session skips the start screen.
### Step 8.5 — Player state store
- Use TanStack Query to load player state, refetching when the app regains focus and when any in-flight dispatch's arrival time passes.
- **Test:** With mocked API, when the clock passes an arrival time, a refetch is triggered automatically.
---
## Phase 9 — Client Screens
### Step 9.1 — HQ screen
- Show credits, game date/time, counts of ships idle/in flight, and the next arrival with a countdown. During tutorial, show a clear goal: "Save up for your first ship and an Earth bay," with progress toward the combined cost.
- **Test:** Component test with seeded state shows correct counts and countdown. Tutorial goal progress shows the correct percentage.
### Step 9.2 — "While you were away" summary
- When state contains unacknowledged summaries, show a modal listing each completed delivery (ship, route, credits, XP, discoveries). Dismissing acknowledges them.
- **Test:** Component test shows the modal with two seeded summaries; dismissing calls acknowledge and hides it.
### Step 9.3 — Tutorial contract board
- During tutorial, the Contracts screen shows the tutorial board: route, cargo, payout, estimated trip time (computed with sim), and a "Fly it" button. The button is disabled while a tutorial run is in flight.
- **Test:** Component test shows the board with trip times matching the sim. "Fly it" calls dispatch; it is disabled while a run is in flight.
### Step 9.4 — Bay contract boards
- After tutorial, the Contracts screen has a selector for the player's bays and shows each bay's board. Each contract shows route, cargo type, mass, distance, payout, required skill, expiry countdown, and a first-contact badge where applicable.
- **Test:** Component test switches bays and shows the right board. A first-contact contract displays its badge.
### Step 9.5 — Dispatch flow
- Selecting a contract opens a dispatch sheet: choose an eligible ship+pilot pair at the origin. Show trip time, fuel cost, net profit, and ETA in both game time and real time. Ineligible pairs are listed but disabled with the reason shown (out of range, can't land, skill too low, too heavy).
- Confirm sends the dispatch; on error, show the server's message.
- **Test:** Component test: an eligible pair shows correct numbers from the sim; an ineligible pair shows the correct reason; confirming calls dispatch; a server error message is displayed.
### Step 9.6 — Fleet screen
- List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location.
- **Test:** Component test: an in-flight ship's progress bar matches sim progress for the mocked time. Reposition opens a destination picker listing only bay locations.
### Step 9.7 — Pilots screen
- List pilots: name, level, XP progress to next level, skill levels, available skill points, assignment, status. Actions: hire pilot (shows cost), assign to ship, spend skill point.
- **Test:** Component test: spending a point calls the endpoint and the UI updates; hire is disabled with insufficient credits; assign lists only idle ships at the pilot's location.
### Step 9.8 — Market screen
- List the three ship models with stats (speed, capacity, range, landing class, price). "Buy" is disabled with insufficient credits.
- **Test:** Component test: buy is disabled when credits are below price; buying calls the endpoint and the new ship appears in Fleet.
### Step 9.9 — Locations screen
- List all locations. Discovered ones show type, landing requirement, current distance from Earth, produces/demands, and a "Lease bay" action with price (or "Bay owned"). Undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
- **Test:** Component test: undiscovered locations hide details; leasing is available only for discovered locations without a bay.
### Step 9.10 — Empty and error states
- Every screen has a sensible empty state (e.g., "No ships yet — visit the Market") and a retry option on network failure.
- **Test:** Component tests for each screen's empty state and network-error state.
---
## Phase 10 — Simple System Map
### Step 10.1 — PixiJS map component
- Add a Map route with a PixiJS canvas showing the Sun and the five locations at their current positions. Use a compressed (non-linear) radial scale so inner and outer locations are both visible. Support pan and pinch-zoom.
- **Test:** Unit test confirms the scale function is monotonic (farther in reality → farther on screen). Manual check: all five locations are visible on a phone-sized screen at default zoom; pinch-zoom works on a touch device.
### Step 10.2 — Ships on the map
- Draw in-flight ships as markers along their route lines, positioned with sim interpolation every frame, and idle ships as badges at their location.
- **Test:** Unit test: marker screen position for a mocked dispatch at 50% progress lies on the route line between the two endpoints. Manual check: ships move smoothly.
### Step 10.3 — Map interactions
- Tapping a location opens a panel with its details and a link to its contract board (if a bay exists). Tapping a ship shows its route, pilot, and ETA.
- **Test:** Component test with simulated taps opens the correct panels.
---
## Phase 11 — PWA
### Step 11.1 — Installable PWA
- Add `vite-plugin-pwa` with a manifest (name "Deadhead: Space Freight Tycoon", icons, theme color, standalone display) and a service worker that caches the app shell.
- **Test:** A Lighthouse PWA audit in CI reports the app as installable. Manual check: the app installs to a phone home screen and launches standalone.
### Step 11.2 — Offline shell behavior
- When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online.
- **Test:** Playwright test with network disabled after first load: the app renders, the offline banner shows, progress bars advance, and action buttons are disabled.
---
## Phase 12 — Balance Harness and End-to-End Tests
### Step 12.1 — Balance simulation script
- Add a script in `packages/sim` that simulates a player making reasonable choices (always take the highest-paying eligible contract, buy the cheapest ship when affordable, lease Earth bay, then hire pilots) over a configurable span of real time, using only sim functions.
- Output: real time to finish the tutorial, real time to own 3 ships, credits over time, and pilot levels over time.
- **Test:** Running the script with a fixed seed produces identical output twice. With default content, tutorial completion falls between 45 and 120 real minutes of active play. (Adjust content values, not the test thresholds, if it fails; record changes in `DECISIONS.md`.)
### Step 12.2 — Revisit the tutorial economy test
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.
- **Test:** The updated Step 2.5 test passes with real sim numbers.
### Step 12.3 — End-to-end: tutorial to first ship
- Playwright test against a real server and test database, with the server clock controllable via a test-only setting: start as guest → fly tutorial runs (advancing the clock) until affordable → buy a Courier → lease Earth bay → tutorial ends.
- **Test:** The E2E test passes and final state shows one owned ship, one Earth bay, and tutorial mode off.
### Step 12.4 — End-to-end: offline progress
- Playwright test: dispatch a contract, close the page, advance the server clock past arrival, reopen.
- **Test:** The "while you were away" modal shows the delivery, credits reflect the payout, and the ship is at the destination.
### Step 12.5 — End-to-end: discovery
- Playwright test: take a first-contact contract to Mars with an eligible ship and pilot, advance the clock past arrival.
- **Test:** Mars shows as discovered on the Locations screen and can now be leased.
### Step 12.6 — End-to-end: pilot progression
- Playwright test: complete enough deliveries to level up a pilot, spend a point in Heavy Cargo, then confirm a previously ineligible Heavy contract becomes eligible in the dispatch flow.
- **Test:** The contract's ship/pilot pair changes from disabled ("skill too low") to enabled.
---
## Phase 13 — Deployment (Base Game)
### Step 13.1 — Containerize the server
- Add a Dockerfile for the server and a production compose file with the server and Postgres. Migrations run on startup.
- **Test:** Building and starting the production compose locally serves the health endpoint and the server time endpoint.
### Step 13.2 — Deploy the client
- Serve the client and API from one domain, with the API under `/api` behind a reverse proxy (decision 0.5.20). Build the web app as static files and deploy them. Hosting providers and the backup storage location are chosen at the start of this phase.
- **Test:** Manual check: the deployed site loads on desktop and mobile, a guest account can be created, and a tutorial run can be dispatched.
### Step 13.3 — Database backups
- Add a scheduled Postgres dump to off-server storage with a retention policy.
- **Test:** Restore the latest dump into a fresh database and confirm a known test player's state is intact.
---
## Definition of Done (Base Game)
- All step tests pass in CI.
- A new player can go from guest start to owning multiple ships, bays at several locations, and leveled pilots.
- Closing the app and returning later correctly shows completed deliveries.
- Discovery works for all four non-starting locations.
- The app installs as a PWA on iOS and Android and is usable at phone width.

9
memory-bank/progress.md Normal file
View File

@ -0,0 +1,9 @@
# Progress
## Phase 1 — Repository Setup
- [x] **Step 1.1 — Create the monorepo.** pnpm workspace with `apps/web`, `apps/server`, `packages/sim`, `packages/schema`, `packages/content` (all `@deadhead/*`), strict `tsconfig.base.json` extended by each package. `pnpm install` + `pnpm typecheck` pass. Repo moved to `C:\Projects\Deadhead Space Freight Tycoon` (G: is exFAT, no symlinks).
- [ ] Step 1.2 — Linting and formatting
- [ ] Step 1.3 — Test runner
- [ ] Step 1.4 — Continuous integration (Gitea Actions; runner `homelab-runner` is registered and idle)
- [ ] Step 1.5 — Local Postgres via Docker Compose

172
memory-bank/tech-stack.md Normal file
View File

@ -0,0 +1,172 @@
# Tech Stack — Deadhead: Space Freight Tycoon
Companion to `game-design-document.md`.
## TL;DR
**TypeScript end to end, in one monorepo.**
| Layer | Choice |
|---|---|
| Language | TypeScript (client, server, shared sim) |
| Monorepo | pnpm workspaces |
| Client UI | React + Vite |
| Client state | Zustand (game state) + TanStack Query (server sync) |
| System map rendering | PixiJS (via `@pixi/react`) |
| Styling | Tailwind CSS |
| PWA | `vite-plugin-pwa` (Workbox) |
| Native wrapper | Capacitor (iOS/Android) |
| API server | Node + Fastify |
| Database | PostgreSQL |
| ORM / migrations | Drizzle ORM |
| Validation | Zod (shared schemas, client + server) |
| Auth | Better Auth (self-hosted) — or Supabase Auth if going managed |
| Job queue / scheduler | pg-boss (Postgres-backed, no Redis needed) |
| Push notifications | `web-push` (VAPID) for PWA; FCM/APNs via Capacitor for native |
| Planetary positions | `astronomy-engine` |
| Seeded RNG | Small pure-TS PRNG (e.g., sfc32 / mulberry32) |
| Testing | Vitest (unit/sim), Playwright (E2E) |
| Deployment | Docker containers (API + Postgres), static client on a CDN |
---
## 1. Why TypeScript Everywhere
The single most important architectural decision is the **deterministic dispatch model** (GDD §14): a trip's arrival time, position, and events are all derived from its dispatch record and RNG seed.
That logic has to run in **two places**:
- **Client** — to show ships moving along their routes, display ETAs, and preview a contract before dispatch.
- **Server** — as the authority that resolves payouts, XP, and events.
If both sides run the *same code*, they can never disagree. A shared TypeScript package gives you that for free. With a C# or Python backend you'd be maintaining two implementations of the sim and chasing drift bugs forever.
## 2. Repo Layout
```
/apps
/web React + Vite client (also the Capacitor app)
/server Fastify API, workers
/packages
/sim Pure, deterministic game logic (no I/O)
/schema Zod schemas + shared types (API contracts)
/content Static game data: ships, destinations, goods, skills
```
### `packages/sim` rules
- **Pure functions only.** No `Date.now()`, no `Math.random()`, no network, no DB.
- Time is always passed in as an argument.
- Randomness comes only from a seeded PRNG derived from the dispatch seed.
- Fully unit-testable: given a dispatch record + timestamp, the output is always identical.
Core functions (illustrative):
```ts
travelTime(route, ship, cargo, throttle): Duration
positionAt(dispatch, now): Vec2
rollEvents(dispatch): GameEvent[] // pre-rolled at dispatch time
resolveDispatch(dispatch, now): Resolution // payout, XP, damage, discovery
generateContracts(bay, worldState, seed): Contract[]
```
### `packages/content`
Ships, destinations, goods, and skills live as typed data files (TS or JSON validated by Zod). This makes balancing a data edit, not a code change, and makes the content easy to hot-reload during tuning.
## 3. Client
### React + Vite
The game is overwhelmingly **UI**: contract boards, fleet lists, pilot sheets, bay management. That's what React is good at. A game engine would be fighting you on every menu.
### PixiJS for the system map
The one truly "game-like" view is the solar system map with ships moving along routes. PixiJS gives GPU-accelerated 2D rendering that stays smooth on phones, embedded inside the React app as a single component.
- Planets positioned from `astronomy-engine` (scaled/compressed for readability — soft sci-fi).
- Ships interpolated along routes with `sim.positionAt()` every frame.
- Pan/zoom with touch support.
### State
- **TanStack Query** owns server data (fleet, contracts, company) — caching, refetch on focus, optimistic updates on dispatch.
- **Zustand** for local UI and derived real-time state (current clock, selected ship, map camera).
### Server time on the client
On connect, the client fetches server time and stores the offset. All "now" values used for rendering come from `Date.now() + serverOffset`, so a wrong device clock only affects visuals and is never trusted for outcomes.
### PWA + Capacitor
- `vite-plugin-pwa` makes the web build installable with offline asset caching.
- Capacitor wraps the **same build** for the App Store and Play Store when you want that. It gives more reliable push notifications and native polish without a second codebase.
- Web Push works on installed PWAs on iOS 16.4+, so Capacitor can wait until after launch.
## 4. Server
### Fastify
Fast, TypeScript-friendly, schema-based validation (pairs well with Zod via `fastify-type-provider-zod`). Small surface area.
### API shape
Plain REST/JSON is enough. This isn't a twitch-multiplayer game; nothing needs WebSockets at launch. The client polls/refetches on focus, which is exactly the "pick up the phone, see what happened" pattern.
### Server-authoritative resolution
- **Dispatch:** server validates (ship available, pilot qualified, landing class OK, fuel range OK), stamps departure time, generates the seed, pre-rolls events via `sim`, and stores the record.
- **Resolution:** happens lazily when the player loads state *and* via a scheduled job at arrival time (so notifications fire and the economy stays consistent even if the player never opens the app).
- Resolution must be **idempotent**: a dispatch resolves exactly once, guarded by a status column + transaction.
### PostgreSQL + Drizzle
- Relational data fits this game well: companies, ships, pilots, bays, contracts, dispatches.
- Drizzle is TypeScript-first, light, and its schema can live in `packages/schema` alongside Zod types.
- JSONB columns for flexible bits (pre-rolled event lists, ship loadouts).
### pg-boss for scheduled work
Postgres-backed job queue, so you don't need to run Redis.
- Schedule an "arrival" job per dispatch → resolve + send push notification.
- Periodic jobs: regenerate contract boards, recompute fuel prices, run automation rules (standing routes).
### Auth
- **Better Auth**: self-hosted, TypeScript-native, supports email/password, OAuth (Google/Apple/Discord), and anonymous accounts.
- Strongly recommend **guest accounts upgradable to full accounts** — players can start instantly on mobile and link an account later to sync across devices.
- If you'd rather not run auth yourself, Supabase (Auth + Postgres) is the managed alternative and plays fine with Drizzle.
## 5. Notifications
| Platform | Mechanism |
|---|---|
| Web / installed PWA | Web Push with VAPID keys (`web-push` library) |
| Capacitor iOS | APNs via `@capacitor/push-notifications` |
| Capacitor Android | FCM via `@capacitor/push-notifications` |
Fired from the pg-boss arrival job. Batch notifications when many ships arrive at once ("4 ships arrived — +12,400 cr").
## 6. Planetary Positions
`astronomy-engine` computes real planetary positions for any date, in pure JS, no API calls.
- Run it in `packages/sim` so client and server agree on distances.
- Use a fixed **game epoch** mapped to real ephemeris dates, advanced by the compressed game clock (1 real min = 1 game hour by default).
- Moons and stations inherit their parent body's position plus a small fixed offset — soft sci-fi doesn't need lunar orbits.
- For interstellar (late game), real star positions from a static dataset in `packages/content`.
## 7. Deployment
- **Client:** static build on any CDN (Cloudflare Pages, Netlify, Vercel).
- **Server + Postgres + workers:** Docker containers. Easy to run on a home Docker host during development, then move to a VPS or a managed platform (Fly.io, Railway, Render) for production.
- **Backups:** automated Postgres dumps from day one — player saves *are* the database.
## 8. Testing
- **Vitest on `packages/sim`** is where most of the value is. Determinism tests: same dispatch + time → same result, every time.
- **Balance simulations:** scripts that run thousands of simulated dispatches through `sim` to check payout curves and progression pacing without playing the game.
- **Playwright** for a handful of critical E2E flows (sign up → first job → first ship).
## 9. Alternatives Considered
| Option | Why not (for this game) |
|---|---|
| Unity | Heavy web builds, slow mobile-web load times, painful for UI-heavy screens. |
| Godot | Better than Unity for web, but still a game engine for a game that's mostly menus; larger downloads than a React app. |
| Phaser | Great for action games; the map is the only part that needs it, and PixiJS covers that more lightly. |
| .NET backend | Solid, but loses shared sim code with the client (unless you go all-in on Blazor, which is a weaker mobile web story). |
| Firebase | Fast to start, but deterministic, transactional server-side resolution is awkward in Firestore + Cloud Functions. |
| Redis + BullMQ | Excellent, but an extra service to run when Postgres + pg-boss covers the need at this scale. |
## 10. Build Order
1. `packages/sim` + `packages/content`: travel time, positions, seeded events, resolution. Fully tested, no UI.
2. Server: auth (guest accounts), dispatch + resolve endpoints, Postgres schema.
3. Client: contract board, fleet list, dispatch flow. Plain UI, no map yet.
4. pg-boss arrival jobs + Web Push.
5. PixiJS system map.
6. PWA install, then Capacitor builds.

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "deadhead",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.1",
"engines": {
"node": ">=24"
},
"scripts": {
"typecheck": "pnpm -r --parallel typecheck"
},
"devDependencies": {
"@types/node": "^24",
"typescript": "~6.0.3"
}
}

View File

@ -0,0 +1,11 @@
{
"name": "@deadhead/content",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
}
}

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View File

@ -0,0 +1,11 @@
{
"name": "@deadhead/schema",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
}
}

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

11
packages/sim/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "@deadhead/sim",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
}
}

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

49
pnpm-lock.yaml Normal file
View File

@ -0,0 +1,49 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^24
version: 24.19.1
typescript:
specifier: ~6.0.3
version: 6.0.3
apps/server: {}
apps/web: {}
packages/content: {}
packages/schema: {}
packages/sim: {}
packages:
'@types/node@24.19.1':
resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
snapshots:
'@types/node@24.19.1':
dependencies:
undici-types: 7.24.6
typescript@6.0.3: {}
undici-types@7.24.6: {}

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

19
tsconfig.base.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true
}
}