523 lines
24 KiB
Markdown
523 lines
24 KiB
Markdown
# Star Wars – Wild Space
|
||
|
||
A persistent, real-time team strategy game set in the Star Wars universe. Two teams (`blue` / `red`) compete to explore and capture planets in the Wild Space ring. Planet positions are **deterministic from the server world seed** — the same coordinates always yield the same planet, on every epoch. Cell stats are hidden until a team reveals them; visibility is **per-team** and **private**. All game state is persisted in PostgreSQL.
|
||
|
||
> **Desktop only** — the galaxy map is not playable on phones or narrow screens (< 768 px wide).
|
||
|
||
---
|
||
|
||
## Table of contents
|
||
|
||
1. [Architecture](#architecture)
|
||
2. [Prerequisites](#prerequisites)
|
||
3. [Deploy on a new server](#deploy-on-a-new-server)
|
||
4. [Environment variables](#environment-variables)
|
||
5. [Runtime config (`game.settings.json`)](#runtime-config-gamesettingsjson)
|
||
6. [Local development (without Docker)](#local-development-without-docker)
|
||
7. [UI layout](#ui-layout)
|
||
8. [Game mechanics](#game-mechanics)
|
||
9. [WebSocket real-time layer](#websocket-real-time-layer)
|
||
10. [Deterministic planet generation](#deterministic-planet-generation)
|
||
11. [Economy engine](#economy-engine)
|
||
12. [Military system](#military-system)
|
||
13. [API reference](#api-reference)
|
||
14. [Database schema](#database-schema)
|
||
15. [Docker image notes](#docker-image-notes)
|
||
16. [Database persistence](#database-persistence)
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
| Component | Technology |
|
||
|-----------|-----------|
|
||
| Web server / API | Node.js 20 + Express |
|
||
| Real-time push | WebSocket (`ws` library) — upgrade on the same HTTP port |
|
||
| Game database | PostgreSQL 16 (`star_wars_grid`) |
|
||
| Users database | PostgreSQL 16 (`star_wars_users`) |
|
||
| Auth | JWT (7-day tokens, signed with `JWT_SECRET`, bcrypt password hashing with cost 12) |
|
||
| Config hot-reload | File `mtime` polling (`game.settings.json`) — no restart needed |
|
||
| Economy engine | Server-side tick loop (every 5 s), runs headlessly |
|
||
| Container runtime | Docker / Docker Compose |
|
||
|
||
The app runs as a **single Node.js process** that serves the static frontend (`public/`), the REST API (`/api/*`), and the WebSocket endpoint (`/ws`), all on the same port. Two separate PostgreSQL instances isolate game state from user accounts.
|
||
|
||
```
|
||
Browser ──HTTP──► Express (/api/*, static)
|
||
──WS───► WebSocket hub (/ws)
|
||
│
|
||
┌───────────┼───────────────┐
|
||
▼ ▼ ▼
|
||
econTick configPoll route handlers
|
||
(5 s) (variable) (reveal/capture/attack)
|
||
│ │ │
|
||
└───────────┴───────────────┘
|
||
│ broadcast()
|
||
▼
|
||
Connected clients
|
||
```
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
| Requirement | Minimum version |
|
||
|-------------|----------------|
|
||
| Docker Engine | 24+ |
|
||
| Docker Compose plugin | v2 (`docker compose`) |
|
||
| Git | any recent |
|
||
|
||
No Node.js or PostgreSQL installation is needed on the host — everything runs inside Docker.
|
||
|
||
---
|
||
|
||
## Deploy on a new server
|
||
|
||
### 1 — Clone the repository
|
||
|
||
```bash
|
||
git clone <repo-url> star-wars-wild-space
|
||
cd star-wars-wild-space
|
||
```
|
||
|
||
### 2 — Create the environment file
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Open `.env` and set every value — see [Environment variables](#environment-variables) for details. At a minimum you **must** change:
|
||
|
||
- `JWT_SECRET` — generate a strong random string:
|
||
```bash
|
||
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||
```
|
||
|
||
Alternatives for Linux-based distros:
|
||
```bash
|
||
openssl rand -hex 48
|
||
# or from the entropy pool:
|
||
head -c 48 /dev/urandom | xxd -p -c 48
|
||
```
|
||
- `POSTGRES_PASSWORD`
|
||
- `POSTGRES_USERS_PASSWORD`
|
||
|
||
### 3 — Review the game config
|
||
|
||
`config/game.settings.json` is mounted into the container and hot-reloaded at runtime. Adjust values before first boot if needed — see [Runtime config](#runtime-config-gamesettingsjson).
|
||
|
||
### 4 — Create the data directories
|
||
|
||
Docker bind-mounts the PostgreSQL data directories from the host. Create them before the first run so that Docker does not create them as root:
|
||
|
||
```bash
|
||
mkdir -p data/postgres data/postgres_users
|
||
```
|
||
|
||
### 5 — Build and start
|
||
|
||
```bash
|
||
docker compose up --build -d
|
||
```
|
||
|
||
The `app` service waits for both databases to pass their health checks before starting (`pg_isready` polling every 3 s, up to 15 retries). On first boot the server automatically creates all required tables via idempotent `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` migrations.
|
||
|
||
### 6 — Verify the deployment
|
||
|
||
```bash
|
||
# Check all three containers are running
|
||
docker compose ps
|
||
|
||
# Tail logs
|
||
docker compose logs -f app
|
||
|
||
# Health check (should return JSON)
|
||
curl http://localhost:8080/api/config
|
||
```
|
||
|
||
Open `http://<server-ip>:8080` in a browser.
|
||
|
||
### 7 — (Optional) Reverse proxy
|
||
|
||
To expose the app on port 80/443, put Nginx or Caddy in front and proxy to `http://localhost:8080`. WebSocket (`/ws`) proxying requires setting appropriate `Upgrade` and `Connection` headers. Set `CORS_ORIGIN` in `.env` to your public domain.
|
||
|
||
### Stopping / restarting
|
||
|
||
```bash
|
||
docker compose down # stop (data preserved)
|
||
docker compose down -v # stop and remove named volumes (data lost)
|
||
docker compose restart app # restart only the app container
|
||
```
|
||
|
||
---
|
||
|
||
## Environment variables
|
||
|
||
**Never commit secrets.** All credentials live in **`.env`** (git-ignored). Copy from `.env.example` before the first run.
|
||
|
||
| Variable | Default | Description |
|
||
|----------|---------|-------------|
|
||
| `PORT` | `8080` | HTTP port exposed by the app |
|
||
| `JWT_SECRET` | *(required)* | Secret used to sign JWT tokens — use a long random string in production |
|
||
| `POSTGRES_USER` | `game` | Game-DB username |
|
||
| `POSTGRES_PASSWORD` | *(required)* | Game-DB password |
|
||
| `POSTGRES_DB` | `star_wars_grid` | Game-DB name |
|
||
| `POSTGRES_USERS_USER` | `users` | Users-DB username |
|
||
| `POSTGRES_USERS_PASSWORD` | *(required)* | Users-DB password |
|
||
| `POSTGRES_USERS_DB` | `star_wars_users` | Users-DB name |
|
||
| `CORS_ORIGIN` | `*` | `Access-Control-Allow-Origin` header — set to your public domain in production |
|
||
| `CONFIG_FILE_PATH` | `config/game.settings.json` | Override path to the config file (set by Docker Compose) |
|
||
|
||
---
|
||
|
||
## Runtime config (`game.settings.json`)
|
||
|
||
Edit **`config/game.settings.json`** on the host (bind-mounted into the container at `/app/config/game.settings.json`). The server checks the file's **mtime** on a schedule and applies new values without a restart; the change propagates to all connected clients via a `config-updated` WebSocket broadcast within at most `configReloadIntervalSeconds` seconds.
|
||
|
||
### Core fields
|
||
|
||
| Field | Default | Meaning |
|
||
|-------|---------|---------|
|
||
| `dailyActionQuota` | `100` | Per-user action budget, refilled on the schedule below. Each new cell reveal costs 1 action. |
|
||
| `teamActionQuota` | `100` | Shared team action pool, used for planet captures. Refilled on the same schedule. |
|
||
| `actionsResetIntervalHours` | `24` | How often both quotas reset; aligned to UTC boundaries. Must be a divisor of 24: `1, 2, 3, 4, 6, 8, 12, 24`. |
|
||
| `databaseWipeoutIntervalSeconds` | `604800` | World period length in seconds (default 7 d). The world seed is `swg-<slot>` where `slot = floor(UTC unix seconds / this value)`. When the slot advances, `grid_cells` is truncated and a new era begins. Minimum `60`. |
|
||
| `configReloadIntervalSeconds` | `30` | How often the server polls the config file. Minimum `5`. |
|
||
|
||
### Economy fields
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `elementWorth` | Per-element bonus multipliers (`common`, `food`, `petrol`, `goods`, `industry`, `medic`, `money`, `science`). The sum of `(productionShare% / 100) × worth` across all owned planets gives the team's element bonus (%). This bonus scales the effective `dailyActionQuota`. |
|
||
| `resourceWorth.common` / `resourceWorth.rare` | Credit-per-second weight per resource type for common and rare planet tiers. Drives income in the server-side economy tick. |
|
||
|
||
### Military fields
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `militaryPower.humans` / `.near` / `.aliens` | Percentage of a planet's population (in billions) that contributes to the team's military power. E.g. a planet with 5 billion humans and `humans: 10` contributes 0.5 billion military. |
|
||
|
||
`GET /api/config` returns the current values of all fields above plus **`worldSeed`**, **`seedPeriodStartsAtUtc`**, **`seedPeriodEndsAtUtc`**, and (if called with a valid Bearer token + `?team=`) **`actionsRemaining`** and **`teamActionsRemaining`**.
|
||
|
||
---
|
||
|
||
## Local development (without Docker)
|
||
|
||
Requires two running PostgreSQL 16 instances, then:
|
||
|
||
```bash
|
||
cp .env.example .env # fill in all values
|
||
|
||
npm install
|
||
|
||
# Set connection strings (PowerShell example)
|
||
$env:DATABASE_URL = "postgres://game:game@localhost:5432/star_wars_grid"
|
||
$env:USERS_DATABASE_URL = "postgres://users:users@localhost:5433/star_wars_users"
|
||
$env:JWT_SECRET = "dev_secret_change_me"
|
||
|
||
npm start
|
||
```
|
||
|
||
The server creates all tables automatically on first run. Ensure `config/game.settings.json` exists (included in the repository).
|
||
|
||
### Ports (local)
|
||
|
||
| Service | Port |
|
||
|---------|------|
|
||
| App (HTTP + WS) | `8080` (or `PORT` env var) |
|
||
| Game Postgres | `5432` |
|
||
| Users Postgres | `5433` |
|
||
|
||
---
|
||
|
||
## UI layout
|
||
|
||
| Pane | Content |
|
||
|------|---------|
|
||
| **Left column** | Title, team tile & econ scores, player info, action quotas, world seed & period countdown, active players, military power, victory points, selection details |
|
||
| **Right side** | Galaxy map — a perfect square anchored to the right edge of the viewport |
|
||
|
||
---
|
||
|
||
## Game mechanics
|
||
|
||
### Teams
|
||
|
||
Users register as either **`blue`** or **`red`**. The team is locked to the account and embedded in the JWT so the server always trusts the token, never the client.
|
||
|
||
### Per-team cell visibility
|
||
|
||
Each team has its own **private** visibility layer (`team_cell_visibility`). Revealing a cell marks it as visible only for your team: you see the planet data, the other team does not. Both teams can independently scout the same tile. Ownership (`discovered_by`) is entirely separate from visibility.
|
||
|
||
### Action quotas
|
||
|
||
Two quota systems gate how fast teams can act:
|
||
|
||
| Quota | Scope | Refills |
|
||
|-------|-------|---------|
|
||
| `dailyActionQuota` | Per user | Every `actionsResetIntervalHours` UTC |
|
||
| `teamActionQuota` | Shared per team | Same schedule |
|
||
|
||
- Revealing a **new** (previously unseen by your team) cell costs **1 user action**.
|
||
- Re-clicking a cell your team has already revealed is free (no action consumed).
|
||
- The team's **element bonus** boosts the effective user quota: `floor(dailyActionQuota × (1 + elementBonus / 100))`.
|
||
- Military power can increase the team quota: `teamActionQuota + floor(max(0, netMilitaryPower) / 10_000)` extra actions.
|
||
|
||
### Reveal flow
|
||
|
||
`POST /api/cell/reveal`:
|
||
1. Checks the seed is current (`410` if stale).
|
||
2. If already visible for this team → free re-view, quota untouched, returns current cell data.
|
||
3. If new → deduct 1 user action (`429` if exhausted), mark `team_cell_visibility`, insert into `grid_cells` (deterministically computed from seed+coords), broadcast `cell-updated` to your team over WebSocket.
|
||
|
||
### Planet capture
|
||
|
||
`POST /api/cell/capture` transfers ownership (`discovered_by`) of a planet to your team:
|
||
|
||
- Cost in **team actions**: `max(1, ceil(population_billions / 10))`.
|
||
- If the tile is currently **enemy-controlled**, cost is **doubled**.
|
||
- A successful capture broadcasts `cell-updated` to your team, and to the opposing team **only if they had already revealed that cell**.
|
||
- A `team-quota-updated` broadcast is sent to your own team after deduction.
|
||
|
||
### World seed & wipe
|
||
|
||
The world seed is `swg-<slot>` where `slot = floor(utcUnixSeconds / databaseWipeoutIntervalSeconds)`. When the slot advances:
|
||
|
||
1. The team with the highest economic score for the expiring epoch receives a **victory point**.
|
||
2. `grid_cells` and all per-epoch tables (`team_cell_visibility`, cooldowns, econ scores, element bonus, military deductions, attack log) are truncated or cleaned.
|
||
3. Both team and user quotas reset to server defaults.
|
||
4. A `seed-changed` WebSocket broadcast triggers all clients to resync.
|
||
|
||
User accounts, victory points table, and the `db_metadata` row survive all wipes.
|
||
|
||
---
|
||
|
||
## WebSocket real-time layer
|
||
|
||
The WebSocket server is co-hosted with Express on the same HTTP port, upgraded at path `/ws` (`ws://host/ws` or `wss://host/ws` over TLS). There is no separate port or process.
|
||
|
||
### Connection lifecycle
|
||
|
||
```
|
||
Client connects
|
||
→ Server sends: { type: "welcome", authenticated: false, team: null, timestamp: … }
|
||
|
||
Client sends: { type: "auth", token: "<JWT>" }
|
||
→ Server sends: { type: "auth-state", authenticated: true|false, team: "blue"|"red"|null, timestamp: … }
|
||
|
||
(ongoing)
|
||
← Server sends periodic snapshots, cell updates, etc.
|
||
|
||
Heartbeat: server pings every 30 s; clients that don't pong are terminated and removed.
|
||
```
|
||
|
||
### Server → client message types
|
||
|
||
| Type | Trigger | Payload highlights |
|
||
|------|---------|-------------------|
|
||
| `welcome` | On connect | `{ authenticated: false, team: null }` |
|
||
| `auth-state` | After client sends `auth` | `{ authenticated, team }` |
|
||
| `snapshot` | Every economy tick (5 s) | `{ worldSeed, scores, elementBonus, militaryDeductions, activePlayers, playerNames, victoryPoints, incomePerSecond, militaryPowerGross }` |
|
||
| `cell-updated` | Cell revealed / captured / attacked | `{ worldSeed, cell: { x, y, exploitable, hasPlanet, planet, discoveredBy, capturedBy } }` — **targeted to team(s)** |
|
||
| `team-quota-updated` | After a capture | `{ team, actionsRemaining }` — **targeted to the acting team** |
|
||
| `military-deductions-updated` | After a military attack | `{ worldSeed, deductions: { blue, red } }` — **broadcast to all** |
|
||
| `config-updated` | Config file changed | `{ worldSeed, config: { … } }` — broadcast to all |
|
||
| `seed-changed` | Epoch rolls over | `{ worldSeed }` — broadcast to all; clients resync from REST API |
|
||
|
||
### Client reconnection & fallback
|
||
|
||
The frontend (`public/src/realtime.js`) implements **exponential back-off reconnection** (1 s → 2 s → 4 s … capped at 10 s). While disconnected, it automatically falls back to HTTP polling every `configReloadIntervalSeconds` for config/scores and every 5 s for economy data. Polling stops the moment a WebSocket connection is re-established (`onOpen` callback).
|
||
|
||
The `snapshot` message carries the same data as calling several REST endpoints in parallel, making it the primary update path when connected.
|
||
|
||
---
|
||
|
||
## Deterministic planet generation
|
||
|
||
Planet positions and stats are computed **purely from the world seed and coordinates** — they are never stored until a team reveals a cell. The same (seed, x, y) triple always produces the same planet.
|
||
|
||
### RNG pipeline (`public/src/rng.js`, shared server-side)
|
||
|
||
```
|
||
seedString ──FNV-1a 32──► seedU32
|
||
(x, y, seedU32) ──hash2u32──► presenceU32 → planet present if presenceU32 / 2³² < 0.10 (10% chance)
|
||
(x, y, seedU32 ^ 0xa5a5a5a5) ──hash2u32──► statsU32 ──mulberry32──► per-planet PRNG stream
|
||
```
|
||
|
||
| Function | Algorithm | Purpose |
|
||
|----------|-----------|---------|
|
||
| `fnv1a32(str)` | FNV-1a 32-bit | Converts the seed string to a stable uint32 |
|
||
| `hash2u32(a, b, seed)` | Integer avalanche mix | Maps (x, y) to a deterministic uint32 for planet presence and stats seeding |
|
||
| `mulberry32(seedU32)` | Mulberry32 PRNG | Fast, high-quality stateless PRNG stream for planet attribute generation |
|
||
|
||
The `hash2u32` / `mulberry32` functions are in `public/src/rng.js` and are imported directly by `server/helpers/cell.js`, ensuring client and server always agree on cell contents without any network round-trip.
|
||
|
||
### Galaxy ring geometry
|
||
|
||
The exploitable zone is an annular ring centered on the 100×100 grid:
|
||
|
||
- **Outer radius**: 50 cells (Ø100)
|
||
- **Inner radius**: 30 cells (Ø60)
|
||
- Planet density: ~10% of exploitable ring cells
|
||
|
||
---
|
||
|
||
## Economy engine
|
||
|
||
`server/econTick.js` runs a `setInterval` every **5 seconds**, independent of connected clients:
|
||
|
||
1. Calls `ensureSeedEpoch()` — detects and handles epoch rollovers.
|
||
2. Queries all `grid_cells` rows for the current seed.
|
||
3. Computes **income per second** for each team: iterates owned planets, sums `(resourceShare% / 100) × resourceWorth × populationFactor`.
|
||
4. **Population factor** applies a small-population efficiency boost: `(billions / 10) × boost` where boost is `10× (< 10 B)`, `5× (< 100 B)`, `2.5× (< 1 000 B)`, `1× (≥ 1 000 B)`.
|
||
5. Adds `income × 5` credits to each team's `team_econ_scores`.
|
||
6. **Overwrites** `team_element_bonus` with the current bonus computed from production percentages across all owned planets.
|
||
7. Builds a **realtime snapshot** (via `buildRealtimeSnapshot`) containing econ scores, element bonus, military state, active player counts & names, victory points, and income/s — then broadcasts it as a `snapshot` message to all connected WebSocket clients.
|
||
|
||
---
|
||
|
||
## Military system
|
||
|
||
Military power represents a team's capacity for aggressive tile takeovers.
|
||
|
||
### Power computation
|
||
|
||
For each team-owned planet:
|
||
`militaryContribution = population.billions × militaryPower[species] / 100`
|
||
|
||
Species → config key mapping: `Humains` → `humans`, `Presque'humains` → `near`, `Aliens` → `aliens`.
|
||
|
||
The gross military power is shown in the UI. The **net** military power accounts for deductions from attacks spent: `netPower = grossPower − teamMilitaryDeductions`.
|
||
|
||
### Military attack (`POST /api/military/attack`)
|
||
|
||
- Target must be an enemy-owned tile with a planet.
|
||
- Costs exactly **1.0 billion** military power (added to `team_military_deductions`).
|
||
- Transfers tile ownership to the attacker immediately.
|
||
- Records the event in `cell_attack_log`.
|
||
- Broadcasts `military-deductions-updated` to **all** clients and `cell-updated` to the relevant team(s).
|
||
|
||
### Team action quota bonus
|
||
|
||
Net military power also feeds into capture capacity: every 10 000 units of net military above 0 grants 1 extra team action on quota refresh.
|
||
|
||
---
|
||
|
||
## API reference
|
||
|
||
All endpoints are relative to the app root. All responses are JSON. Cache-Control is `no-store` on every route.
|
||
|
||
### Public (no auth required)
|
||
|
||
| Method | Path | Description |
|
||
|--------|------|-------------|
|
||
| `GET` | `/api/config` | Game config + world seed + period timestamps. Accepts `?team=blue\|red` and a Bearer token to also return per-user and team `actionsRemaining`. |
|
||
| `GET` | `/api/grid/:seed` | All cells visible to the requesting team for `seed` (auth-aware). Returns `410` if `seed` ≠ current world seed. Requires a valid Bearer token; returns empty grid if token is absent or invalid. |
|
||
| `GET` | `/api/scores` | `{ blue: N, red: N }` — captured tile counts for the current period. |
|
||
| `GET` | `/api/econ-scores` | `{ blue: N, red: N }` — cumulative economic scores for the current period. |
|
||
| `GET` | `/api/element-bonus` | `{ blue: N, red: N }` — element bonus percentages. |
|
||
| `GET` | `/api/victory-points` | Array of all awarded victory points across all eras. |
|
||
| `GET` | `/api/db-info` | `{ createdAt }` — database creation timestamp. |
|
||
| `GET` | `/api/active-players` | `{ blue: N, red: N }` — players active in the current epoch. |
|
||
| `GET` | `/api/active-players/names` | `{ blue: [...], red: [...] }` — usernames of active players. |
|
||
| `GET` | `/api/team-quota?team=blue\|red` | `{ team, actionsRemaining }` — current shared team action pool. |
|
||
| `GET` | `/api/military-deductions` | `{ blue: N, red: N }` — cumulative military power spent this epoch. |
|
||
| `GET` | `/api/cell/attacks?x=&y=` | `{ x, y, attackCount }` — number of attacks on a specific cell this epoch. |
|
||
|
||
### Auth (no token required)
|
||
|
||
| Method | Path | Body | Response |
|
||
|--------|------|------|----------|
|
||
| `POST` | `/api/auth/register` | `{ username, password, team }` | `{ token, user }` |
|
||
| `POST` | `/api/auth/login` | `{ username, password }` | `{ token, user }` |
|
||
| `GET` | `/api/auth/player-counts` | — | `{ blue: N, red: N }` — total registered players per team |
|
||
|
||
### Auth (Bearer token required)
|
||
|
||
| Method | Path | Description |
|
||
|--------|------|-------------|
|
||
| `GET` | `/api/auth/me` | Returns refreshed `{ token, user }`. |
|
||
| `POST` | `/api/cell/reveal` | Body `{ seed, x, y }`. Marks cell as visible for your team. Costs 1 user action if not yet seen. Returns `{ x, y, exploitable, hasPlanet, planet, discoveredBy, capturedBy }`. `429` if quota exhausted; `410` if seed stale. |
|
||
| `POST` | `/api/cell/capture` | Body `{ seed, x, y }`. Transfers planet ownership. Costs team actions based on population. `409` if already owned; `429` if quota exhausted; `410` if seed stale. |
|
||
| `POST` | `/api/military/attack` | Body `{ seed, x, y }`. Attacks an enemy tile. Costs 1 billion military power. `409` if targeting own/neutral tile. |
|
||
|
||
### Registration field rules
|
||
|
||
| Field | Rules |
|
||
|-------|-------|
|
||
| `username` | 2–32 characters |
|
||
| `password` | Minimum 6 characters |
|
||
| `team` | `"blue"` or `"red"` |
|
||
|
||
Tokens are valid for **7 days**. Call `GET /api/auth/me` to silently renew.
|
||
|
||
### Error codes
|
||
|
||
| HTTP | `error` value | Meaning |
|
||
|------|---------------|---------|
|
||
| 400 | `invalid_body` / `missing_fields` | Malformed request |
|
||
| 401 | `unauthorized` / `invalid_token` | Missing or invalid JWT |
|
||
| 404 | `cell_not_found` | Cell does not exist in the DB yet |
|
||
| 409 | `already_owned` / `no_planet` / `cannot_attack_own_or_neutral_tile` | Business logic conflict |
|
||
| 410 | `seed_expired` | Client is using a stale world seed — resync from `/api/config` |
|
||
| 429 | `quota_exhausted` / `team_quota_exhausted` | Action budget depleted |
|
||
| 500 | `database_error` / `config_error` | Server-side failure |
|
||
|
||
---
|
||
|
||
## Database schema
|
||
|
||
### `star_wars_grid` (game DB)
|
||
|
||
| Table | Key columns | Notes |
|
||
|-------|-------------|-------|
|
||
| `grid_cells` | `(world_seed, x, y)` UNIQUE | Cells inserted on first reveal. `discovered_by` is `NULL` (neutral), `'blue'`, or `'red'`. `captured_by` stores the capturer's username. |
|
||
| `team_cell_visibility` | `(world_seed, team, x, y)` PK | Per-team visibility — independent of ownership. Truncated on epoch change. |
|
||
| `team_econ_scores` | `(world_seed, team)` PK | Cumulative credits per epoch. |
|
||
| `team_element_bonus` | `(world_seed, team)` PK | Overwritten every 5 s tick. |
|
||
| `team_military_deductions` | `(world_seed, team)` PK | Cumulative military power spent. |
|
||
| `team_action_quota` | `team` PK | Shared team action pool, persists across epochs. |
|
||
| `cell_attack_log` | `id` PK, index on `(world_seed, x, y)` | One row per military attack event. |
|
||
| `victory_points` | `id` PK, index on `team` | One row per awarded VP; survives all wipes. |
|
||
| `db_metadata` | `id` PK | Single row with DB creation timestamp. |
|
||
|
||
### `star_wars_users` (users DB)
|
||
|
||
| Table | Key columns | Notes |
|
||
|-------|-------------|-------|
|
||
| `users` | `id` PK, `username` UNIQUE | `team` constrained to `'blue'` or `'red'`; `password_hash` is bcrypt cost-12. |
|
||
| `user_action_quota` | `user_id` PK → `users.id` | Per-user daily action budget with `quota_reset_at` timestamp. |
|
||
|
||
---
|
||
|
||
## Docker image notes
|
||
|
||
- Build uses a **two-stage** Dockerfile:
|
||
- Stage 1 (`node:20-alpine`): installs production dependencies via `npm ci --omit=dev`.
|
||
- Stage 2 (`gcr.io/distroless/nodejs20-debian12:nonroot`): minimal runtime with no shell or package manager; runs as UID 65532 (non-root).
|
||
- `NODE_ENV=production` is baked into the image.
|
||
- Built-in health check: calls `server/healthcheck.js` via the Node binary every 15 s (`GET /api/config`, exits 0 on HTTP 200). No `wget`/`curl` needed — distroless has none.
|
||
- Secrets are **never baked into the image** — always injected at runtime via `.env` / Compose environment.
|
||
- `config/` is a **volume mount**, not baked in, enabling hot-reload without rebuilding the image.
|
||
|
||
---
|
||
|
||
## Database persistence
|
||
|
||
PostgreSQL data lives in bind-mount directories on the host:
|
||
|
||
| Directory | Database |
|
||
|-----------|----------|
|
||
| `./data/postgres` | Game DB (`star_wars_grid`) |
|
||
| `./data/postgres_users` | Users DB (`star_wars_users`) |
|
||
|
||
A **world wipe** only cleans epoch-scoped tables when the UTC period slot changes; it does not touch the host data directories. Only user accounts (`users`), `victory_points`, and `db_metadata` survive all wipes. Both `user_action_quota` and `team_action_quota` are truncated and reset to config defaults on wipe.
|
||
|
||
To fully reset the game state (including all user accounts), stop the stack and delete both data directories, then start again:
|
||
|
||
```bash
|
||
docker compose down
|
||
rm -rf data/postgres data/postgres_users
|
||
mkdir -p data/postgres data/postgres_users
|
||
docker compose up -d
|
||
``` |