Files
star-wars-wild-space/README.md
T
2026-04-03 14:25:19 +02:00

523 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) / 1_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` | 232 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
```