Phase 1: reducer, SQLite, rooms & invite links

Turns the Phase 0 deployment shell into a playable async multiplayer game:

- shared/game.js + shared/board.js: pure reducer (reduce(state, action) ->
  newState) over a small branching board subset mirroring the sketched
  design (Career/Education fork, Relationship/Investment fork, High-Risk/Safe
  fork, race to Finish). Dice randomness is generated server-side and shipped
  inside the ROLL action payload, so the reducer itself stays fully pure and
  is identically importable by both server and browser.
- server/db.js: SQLite (better-sqlite3) schema for games/players/tokens,
  config and state stored as JSON. tokens covers both room invite links and
  per-player reconnect secrets.
- server/rooms.js: in-memory room registry that is the only place the shared
  reducer is invoked server-side — validates intents, applies actions,
  persists, and broadcasts to every socket in the room.
- server/index.js: REST endpoints to create/join/inspect a game, and a
  room-aware /ws that authenticates via a first {type:'AUTH'} message rather
  than a URL query param (keeps session tokens out of access/proxy logs).
- public/client.js + public/index.html: NetworkTransport wrapping the
  WebSocket, localStorage-backed session persistence so a reload resumes as
  the same player, and a lobby/waiting-room/game-view UI.
- Dockerfile: adds python3/make/g++ so better-sqlite3's node-gyp fallback
  builds on Alpine when a prebuilt binary isn't available for the exact
  Node/musl combo.

Verified: shared/game.test.js (node --test) covers the full rules engine;
a scripted two-client run over real HTTP+WS confirms both clients converge
on identical state through create/join/start/play-to-finish; a server
restart mid-game preserves state and reconnect resumes the same player
without creating a duplicate.
This commit is contained in:
2026-07-21 17:10:56 -07:00
parent 1f51c7d541
commit d40bc09867
12 changed files with 1296 additions and 152 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
# Copy to .env and adjust. Nothing here is required for Phase 0.
# Copy to .env and adjust.
# PORT=3000
# DATA_DIR=/app/data
# Phase 1: used to build shareable invite links (POST /api/games -> inviteUrl).
# If unset, the server falls back to the incoming request's own protocol/host,
# which is fine for local dev but should be set behind a reverse proxy so
# invite links use your public domain rather than an internal address.
# PUBLIC_URL=https://game.example.com
# Arrives in later phases:
# ADMIN_PASSWORD=change-me # Phase 4 — protects the admin page
# PUBLIC_URL=https://game.example.com # Phase 1 — for building invite links
+6
View File
@@ -2,6 +2,11 @@ FROM node:20-alpine
WORKDIR /app
# better-sqlite3's prebuilt binary coverage for musl/Alpine is inconsistent
# across Node versions; these let its node-gyp fallback build from source
# when no prebuilt matches. No sqlite-dev needed — it bundles its own SQLite.
RUN apk add --no-cache python3 make g++
# Install dependencies first (better layer caching).
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
@@ -9,6 +14,7 @@ RUN npm ci --omit=dev
# App source.
COPY server ./server
COPY public ./public
COPY shared ./shared
ENV NODE_ENV=production
ENV PORT=3000
+60 -54
View File
@@ -1,12 +1,15 @@
# Life Journey — Deployment Shell (Phase 0)
# Life Journey — Phase 1
An empty, deployable shell for the async multiplayer board game. It contains no
game logic yet — its only job is to prove the deployment path works: the server
runs in Docker, is reachable through Nginx Proxy Manager over HTTPS, and
WebSockets survive the reverse proxy.
An async multiplayer, Game-of-Life-style board game. Phase 0 proved the
deployment path (Docker → Nginx Proxy Manager → HTTPS → WebSocket). Phase 1
adds the actual game: a pure shared reducer, SQLite persistence, and
rooms with shareable invite links, so a few people can play together across
devices and tab closes.
Once both checks are green, adding the real game (Phase 1 onward) never touches
the deployment story again.
The server is the sole authority over game state. It generates the only
source of randomness (the dice roll) and applies it through the exact same
reducer (`shared/game.js`) the browser imports — client and server can never
disagree about the rules.
## What's here
@@ -15,63 +18,66 @@ lifegame/
├── docker-compose.yml
├── Dockerfile
├── package.json / package-lock.json
├── server/index.js # health endpoint + WebSocket echo
└── public/index.html # live HTTP + WebSocket status page
├── shared/
│ ├── board.js # the Phase 1 board graph + movement helper
│ ├── game.js # pure reducer: reduce(state, action) -> newState
│ └── game.test.js # node --test coverage of the whole rules engine
├── server/
│ ├── index.js # REST + WebSocket, static hosting
│ ├── rooms.js # in-memory room registry, applies/broadcasts actions
│ ├── db.js # SQLite schema + data access (games/players/tokens)
│ └── ids.js # id/token/join-code generation
└── public/
├── index.html # lobby / waiting room / game UI
└── client.js # REST wrappers + NetworkTransport (WebSocket)
```
## 1. Put it on the homelab
## Running locally
Drop the folder in your services directory (e.g. `~/homelab/lifegame/`) and build:
```bash
npm install
npm test # reducer unit tests — no server needed
npm run dev # starts on :3000, creates data/lifegame.db on first game
```
Open two browser tabs at `http://localhost:3000`. Create a game in one tab,
copy the invite link, open it in the other tab, join, and start the game once
both players are in the lobby.
## The board (Phase 1 subset)
The full hand-drawn board (`assets/game_board.png`) has ~150 spaces across two
thematic passes (Career, Education, Gap Year, Relationship/Family,
Investment, High Risk). Phase 1 encodes a small subset with the same shape —
a Career-vs-Education fork, a Relationship-vs-Investment fork, a
High-Risk-vs-Safe fork, converging to Finish — enough to prove the reducer,
persistence, and rooms all work end to end. More spaces can be inserted into
any branch later without touching the reducer or database schema.
## Deploying on the homelab
Same as Phase 0 — see `homelab-config.md` for the full infrastructure
reference. Set `PUBLIC_URL` in `.env` (or the compose environment) to your
public domain so invite links generated by the server are shareable rather
than pointing at an internal address:
```bash
cd ~/homelab/lifegame
docker compose up -d --build
docker compose logs -f # expect: "Life Journey (Phase 0) listening on :3000"
docker compose logs -f # expect: "Life Journey (Phase 1) listening on :3000"
```
Verify locally on the host first:
```bash
curl http://localhost:3000/api/health
# {"ok":true,"service":"life-journey","phase":0,...}
```
## 2. Point Nginx Proxy Manager at it
Add a **Proxy Host**:
- **Domain**: your chosen name, e.g. `game.example.com`
- **Scheme**: `http`
- **Forward Hostname / IP**:
- *Option A (default compose):* the homelab's LAN IP (e.g. `192.168.x.x`)
- *Option B (shared network):* `lifegame`
- **Forward Port**: `3000`
- **Websockets Support**: **ON** ← the easy-to-forget one
- **SSL tab**: request a new Let's Encrypt certificate, Force SSL on
## 3. Confirm it works
Open `https://game.example.com`. You should see two rows go green:
- **Server (HTTP)** — the health endpoint responded
- **WebSocket** — a live socket connected through the proxy
Click **Send WebSocket ping** and the status should show the echo came back.
If HTTP is green but WebSocket is red, the proxy isn't upgrading the
connection — go back and toggle **Websockets Support** on the proxy host.
## Data & backups
The `lifegame-data` volume (mounted at `/app/data`) is where the SQLite
database and uploaded player tokens will live in later phases. It's empty now,
but include it in the same backup routine as your other self-hosted data.
The `lifegame-data` volume (mounted at `/app/data`) holds `lifegame.db`
every game, player, and token. Back it up like your other self-hosted data;
losing it loses every in-progress and finished game.
## What Phase 1 adds
## What's next
- The shared `game.js` rules engine (the pure reducer from the prototype)
- SQLite data model: games, players, board/rule config, tokens
- Rooms + shareable invite links / join codes
- `NetworkTransport` on the client — the one-line swap that makes the game
run across devices
- Persistence & reconnection, since async means tabs close and servers restart
- Expand `shared/board.js` toward the full sketched board
- Richer board rendering (the current UI is a functional list view, not the
illustrated board)
- Tighter reconnection/presence handling (who's online right now, not just
who's joined)
- Admin page (`ADMIN_PASSWORD`, already stubbed in `.env.example`)
+1
View File
@@ -7,6 +7,7 @@ services:
- PORT=3000
- NODE_ENV=production
- DATA_DIR=/app/data
- PUBLIC_URL=${PUBLIC_URL:-}
volumes:
- lifegame-data:/app/data
networks:
+426 -2
View File
@@ -1,13 +1,14 @@
{
"name": "life-journey-server",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "life-journey-server",
"version": "0.0.1",
"version": "0.1.0",
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"ws": "^8.18.0"
}
@@ -31,6 +32,57 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -55,6 +107,30 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -93,6 +169,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -138,6 +220,30 @@
"ms": "2.0.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -157,6 +263,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -186,6 +301,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -231,6 +355,15 @@
"node": ">= 0.6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -277,6 +410,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -313,6 +452,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -359,6 +504,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -427,12 +578,38 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -511,12 +688,45 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -526,6 +736,18 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -550,6 +772,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -565,6 +796,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -578,6 +836,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -618,6 +886,35 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -644,6 +941,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -767,6 +1076,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -776,6 +1130,52 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -785,6 +1185,18 @@
"node": ">=0.6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -807,6 +1219,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -825,6 +1243,12 @@
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+5 -3
View File
@@ -1,15 +1,17 @@
{
"name": "life-journey-server",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Async multiplayer board game — deployment shell (Phase 0)",
"description": "Async multiplayer board game — Phase 1 (rooms, reducer, persistence)",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js",
"test": "node --test shared/game.test.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"ws": "^8.18.0"
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Browser-side networking: thin REST wrappers, per-game session storage, and
* a NetworkTransport wrapping the authenticated WebSocket. UI code (index.html)
* never touches fetch()/WebSocket directly — it goes through this module.
*/
// Re-exported purely so the UI can decide what to show (enable the Roll
// button, render choice options, ...). Authoritative state always comes from
// the server's `state` broadcast — the UI never re-derives it locally.
export { getLegalIntents, isPlayersTurn } from '/shared/game.js';
export { board } from '/shared/board.js';
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
export function createGame(hostName) {
return postJson('/api/games', { hostName });
}
export function joinGame(code, name) {
return postJson(`/api/games/${encodeURIComponent(code)}/join`, { name });
}
export async function fetchGame(code) {
const res = await fetch(`/api/games/${encodeURIComponent(code)}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
// --- Per-game session persistence (localStorage), so a reload/reconnect
// resumes as the same player instead of joining again. ---
const STORAGE_PREFIX = 'lifegame:session:';
export function saveSession(code, session) {
localStorage.setItem(STORAGE_PREFIX + code, JSON.stringify(session));
}
export function loadSession(code) {
const raw = localStorage.getItem(STORAGE_PREFIX + code);
return raw ? JSON.parse(raw) : null;
}
// --- WebSocket transport ---
export class NetworkTransport {
constructor() {
this.ws = null;
this._stateHandlers = [];
this._errorHandlers = [];
}
/** Opens the socket, authenticates with the session token, and resolves
* once the first authoritative state has been received. */
connect(sessionToken) {
return new Promise((resolve, reject) => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
this.ws = ws;
let settled = false;
ws.onopen = () => ws.send(JSON.stringify({ type: 'AUTH', token: sessionToken }));
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'state') {
if (!settled) {
settled = true;
resolve();
}
this._stateHandlers.forEach((cb) => cb(msg));
} else if (msg.type === 'error') {
this._errorHandlers.forEach((cb) => cb(msg));
}
};
ws.onerror = () => {
if (!settled) {
settled = true;
reject(new Error('WebSocket connection failed'));
}
};
ws.onclose = (event) => {
if (!settled) {
settled = true;
reject(new Error(`Connection closed (${event.code})`));
}
};
});
}
sendIntent(intent) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(intent));
}
}
onState(cb) {
this._stateHandlers.push(cb);
}
onError(cb) {
this._errorHandlers.push(cb);
}
close() {
this.ws?.close();
}
}
+271 -64
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Life Journey — Deployment Check</title>
<title>Life Journey</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
@@ -18,100 +18,307 @@
margin: 0; min-height: 100vh; padding: 24px 16px;
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink);
background: radial-gradient(1200px 600px at 50% -10%, #1d493c 0%, var(--page) 45%, var(--page-2) 100%);
display: flex; align-items: center; justify-content: center;
display: flex; align-items: flex-start; justify-content: center;
}
.card {
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 460px; width: 100%;
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%;
margin-top: 24px;
}
h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; }
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
.check {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
border: 1.5px solid var(--line); border-radius: 12px; background: #fffaf0; margin-bottom: 10px;
label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
input[type="text"] {
width: 100%; padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px;
font-family: inherit; font-size: 14px; background: #fffaf0; color: var(--ink);
}
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--ink-soft); flex: none; }
.dot.ok { background: var(--good); } .dot.fail { background: var(--bad); }
.check .name { font-weight: 700; font-family: "Baloo 2"; }
.check .state { margin-left: auto; font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
button {
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px;
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 8px;
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 10px; margin-right: 8px;
}
button:active { transform: translateY(3px); box-shadow: 0 0 0 #b97f18; }
.note { font-size: 12px; color: var(--ink-soft); margin-top: 16px; line-height: 1.5; }
code { background: #efe5cc; padding: 1px 5px; border-radius: 5px; font-size: 12px; }
button:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #efe5cc; box-shadow: 0 3px 0 var(--line); }
hr { border: none; border-top: 1.5px dashed var(--line); margin: 20px 0; }
.invite-row { display: flex; gap: 8px; margin-top: 8px; }
.invite-row input { flex: 1; }
ul.player-list { list-style: none; padding: 0; margin: 12px 0; }
ul.player-list li {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px;
}
ul.player-list li.active { border-color: var(--marigold); background: #fff3dc; }
.dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.hint { color: var(--ink-soft); font-size: 13px; }
.turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; }
.choice-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.log {
max-height: 160px; overflow-y: auto; font-size: 12px; color: var(--ink-soft);
border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 10px;
}
.log div { padding: 2px 0; }
.win-banner {
font-family: "Baloo 2"; font-weight: 800; font-size: 20px; color: var(--good);
text-align: center; margin: 14px 0;
}
.error-note {
background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad);
border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px;
}
</style>
</head>
<body>
<div class="card">
<div class="card" id="app">
<h1>Life Journey</h1>
<p class="sub">Deployment check · Phase 0 — no game here yet</p>
<p class="sub" id="subtitle">Async multiplayer — create a room or join one via invite link.</p>
<div class="check">
<span class="dot" id="http-dot"></span>
<span class="name">Server (HTTP)</span>
<span class="state" id="http-state">checking…</span>
<section id="panel-entry">
<label for="name-input">Your name</label>
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
<div id="create-section">
<button id="create-btn">Create Game</button>
</div>
<div class="check">
<span class="dot" id="ws-dot"></span>
<span class="name">WebSocket</span>
<span class="state" id="ws-state">connecting…</span>
<hr />
<label for="join-code-input">Game code</label>
<input type="text" id="join-code-input" maxlength="6" placeholder="e.g. AB3XQ9" style="text-transform:uppercase" />
<button id="join-btn" class="secondary">Join Game</button>
</section>
<section id="panel-lobby" hidden>
<label>Invite link</label>
<div class="invite-row">
<input type="text" id="invite-link" readonly />
<button id="copy-link-btn" class="secondary">Copy</button>
</div>
<button id="ping">Send WebSocket ping</button>
<hr />
<p class="note">
Both dots green means the shell is deployed correctly and your reverse
proxy is passing WebSockets. If the WebSocket dot is red but HTTP is green,
enable <code>Websockets Support</code> on the proxy host in Nginx Proxy Manager.
</p>
<label>Players</label>
<ul class="player-list" id="player-list"></ul>
<button id="start-btn" hidden>Start Game</button>
<p class="hint" id="lobby-hint"></p>
</section>
<section id="panel-game" hidden>
<div class="turn-banner" id="turn-banner"></div>
<ul class="player-list" id="game-player-list"></ul>
<div>
<button id="roll-btn" hidden>Roll</button>
<div class="choice-buttons" id="choice-buttons"></div>
</div>
<div class="win-banner" id="win-banner" hidden></div>
<div class="log" id="log-feed"></div>
</section>
<p class="error-note" id="error-note" hidden></p>
</div>
<script>
// ---- HTTP health check ----
const httpDot = document.getElementById('http-dot');
const httpState = document.getElementById('http-state');
fetch('/api/health')
.then(r => r.json())
.then(d => {
httpDot.classList.add('ok');
httpState.textContent = `ok · v${d.version}`;
})
.catch(() => {
httpDot.classList.add('fail');
httpState.textContent = 'unreachable';
});
<script type="module">
import {
createGame, joinGame, saveSession, loadSession,
NetworkTransport, getLegalIntents, board,
} from '/client.js';
// ---- WebSocket check ----
const wsDot = document.getElementById('ws-dot');
const wsState = document.getElementById('ws-state');
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
let ws;
const els = {
subtitle: document.getElementById('subtitle'),
panelEntry: document.getElementById('panel-entry'),
panelLobby: document.getElementById('panel-lobby'),
panelGame: document.getElementById('panel-game'),
nameInput: document.getElementById('name-input'),
createSection: document.getElementById('create-section'),
createBtn: document.getElementById('create-btn'),
joinCodeInput: document.getElementById('join-code-input'),
joinBtn: document.getElementById('join-btn'),
inviteLink: document.getElementById('invite-link'),
copyLinkBtn: document.getElementById('copy-link-btn'),
playerList: document.getElementById('player-list'),
startBtn: document.getElementById('start-btn'),
lobbyHint: document.getElementById('lobby-hint'),
turnBanner: document.getElementById('turn-banner'),
gamePlayerList: document.getElementById('game-player-list'),
rollBtn: document.getElementById('roll-btn'),
choiceButtons: document.getElementById('choice-buttons'),
winBanner: document.getElementById('win-banner'),
logFeed: document.getElementById('log-feed'),
errorNote: document.getElementById('error-note'),
};
let transport = null;
let self = { code: null, gameId: null, playerId: null, sessionToken: null };
let errorTimer = null;
function showPanel(name) {
els.panelEntry.hidden = name !== 'entry';
els.panelLobby.hidden = name !== 'lobby';
els.panelGame.hidden = name !== 'game';
}
function showError(message) {
els.errorNote.textContent = message;
els.errorNote.hidden = false;
clearTimeout(errorTimer);
errorTimer = setTimeout(() => { els.errorNote.hidden = true; }, 4000);
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
async function connect() {
transport = new NetworkTransport();
transport.onState((msg) => render(msg.state));
transport.onError((msg) => showError(msg.message));
try {
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { wsDot.classList.add('ok'); wsState.textContent = 'connected'; };
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'echo') wsState.textContent = 'echo received ✓';
else if (data.type === 'welcome') wsState.textContent = 'connected';
};
ws.onerror = () => { wsDot.classList.add('fail'); wsState.textContent = 'failed'; };
ws.onclose = () => { if (!wsDot.classList.contains('ok')) { wsDot.classList.add('fail'); wsState.textContent = 'closed'; } };
} catch {
wsDot.classList.add('fail'); wsState.textContent = 'unsupported';
await transport.connect(self.sessionToken);
} catch (err) {
showError(`Could not connect: ${err.message}`);
}
}
document.getElementById('ping').onclick = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('ping ' + new Date().toISOString());
wsState.textContent = 'ping sent…';
function render(state) {
if (state.status === 'lobby') renderLobby(state);
else renderGame(state);
}
function renderLobby(state) {
showPanel('lobby');
els.inviteLink.value = `${location.origin}/join/${self.code}`;
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.playerList.innerHTML = '';
for (const p of players) {
const li = document.createElement('li');
li.innerHTML = `<span class="dot" style="background:${p.color}"></span> ${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}`;
els.playerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
els.startBtn.hidden = !legal.includes('REQUEST_START');
els.lobbyHint.textContent = players.length < state.config.minPlayers
? `Waiting for at least ${state.config.minPlayers} players…`
: 'Ready to start!';
}
function renderGame(state) {
showPanel('game');
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.gamePlayerList.innerHTML = '';
for (const p of players) {
const li = document.createElement('li');
li.className = state.currentTurn === p.id ? 'active' : '';
const spaceLabel = board.spaces[p.position]?.label ?? p.position;
li.innerHTML = `<span class="dot" style="background:${p.color}"></span>
<strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong>
${escapeHtml(spaceLabel)} · $${p.cash}`;
els.gamePlayerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
const me = state.players[self.playerId];
els.turnBanner.textContent = state.status === 'finished'
? ''
: (state.currentTurn === self.playerId
? 'Your turn'
: `Waiting for ${state.players[state.currentTurn]?.name ?? '…'}`);
els.rollBtn.hidden = !legal.includes('REQUEST_ROLL');
els.choiceButtons.innerHTML = '';
if (legal.includes('REQUEST_CHOOSE') && me?.pendingChoice) {
for (const optionId of me.pendingChoice.options) {
const btn = document.createElement('button');
btn.textContent = board.spaces[optionId]?.label ?? optionId;
btn.onclick = () => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId: optionId });
els.choiceButtons.appendChild(btn);
}
} else if (me?.pendingChoice) {
const waitingName = state.players[state.currentTurn]?.name ?? 'them';
els.choiceButtons.innerHTML = `<p class="hint">Waiting for ${escapeHtml(waitingName)} to choose…</p>`;
}
els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => {
const p = state.players[entry.playerId];
const sign = entry.cashDelta > 0 ? '+' : '';
const cashPart = entry.cashDelta ? ` (${sign}${entry.cashDelta})` : '';
return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(entry.label)}${cashPart}</div>`;
}).join('');
if (state.status === 'finished') {
els.winBanner.hidden = false;
els.winBanner.textContent = state.winnerId === self.playerId
? '🎉 You win!'
: `🏁 ${state.players[state.winnerId]?.name ?? 'Someone'} wins!`;
els.rollBtn.hidden = true;
els.choiceButtons.innerHTML = '';
} else {
wsState.textContent = 'not connected';
els.winBanner.hidden = true;
}
}
function afterAuth(code, session) {
self = { code, ...session };
saveSession(code, session);
history.replaceState(null, '', `/join/${code}`);
return connect();
}
els.createBtn.onclick = async () => {
try {
const hostName = els.nameInput.value;
const result = await createGame(hostName);
await afterAuth(result.code, {
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
});
} catch (err) {
showError(err.message);
}
};
els.joinBtn.onclick = async () => {
try {
const code = els.joinCodeInput.value.trim().toUpperCase();
const name = els.nameInput.value;
if (!code) throw new Error('Enter a game code');
const result = await joinGame(code, name);
await afterAuth(code, {
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
});
} catch (err) {
showError(err.message);
}
};
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
els.rollBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_ROLL' });
els.copyLinkBtn.onclick = () => {
els.inviteLink.select();
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
};
async function boot() {
const match = location.pathname.match(/^\/join\/([^/]+)/);
const codeFromUrl = match ? decodeURIComponent(match[1]) : null;
if (codeFromUrl) {
const existing = loadSession(codeFromUrl);
if (existing) {
self = { code: codeFromUrl, ...existing };
await connect();
return;
}
els.joinCodeInput.value = codeFromUrl;
els.joinCodeInput.readOnly = true;
els.createSection.hidden = true;
els.subtitle.textContent = `Joining game ${codeFromUrl} — enter your name below.`;
}
showPanel('entry');
}
boot();
</script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
/**
* SQLite persistence. games/players hold identity + metadata; tokens covers
* both unclaimed room invite links (kind='invite', player_id NULL) and
* per-player reconnect secrets (kind='session'). config/state are stored as
* JSON text — Phase 1 never needs to query into them.
*/
import Database from 'better-sqlite3';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
board_id TEXT NOT NULL,
config TEXT NOT NULL,
state TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','active','finished')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seat INTEGER NOT NULL,
color TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_seen_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_players_game_id ON players(game_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_players_game_seat ON players(game_id, seat);
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
player_id TEXT REFERENCES players(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('invite','session')),
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_tokens_game_id ON tokens(game_id);
CREATE INDEX IF NOT EXISTS idx_tokens_player_id ON tokens(player_id);
`;
let db;
export function initDb(dbPath) {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
function now() {
return new Date().toISOString();
}
function deserializeGame(row) {
if (!row) return null;
return { ...row, config: JSON.parse(row.config), state: JSON.parse(row.state) };
}
export function createGame({ id, code, boardId, config, state }) {
db.prepare(
`INSERT INTO games (id, code, board_id, config, state) VALUES (?, ?, ?, ?, ?)`
).run(id, code, boardId, JSON.stringify(config), JSON.stringify(state));
return getGameById(id);
}
export function getGameByCode(code) {
return deserializeGame(db.prepare(`SELECT * FROM games WHERE code = ?`).get(code));
}
export function getGameById(id) {
return deserializeGame(db.prepare(`SELECT * FROM games WHERE id = ?`).get(id));
}
export function updateGameState(id, state, status) {
db.prepare(`UPDATE games SET state = ?, status = ?, updated_at = ? WHERE id = ?`).run(
JSON.stringify(state),
status,
now(),
id
);
}
export function createPlayer({ id, gameId, name, seat, color }) {
db.prepare(
`INSERT INTO players (id, game_id, name, seat, color) VALUES (?, ?, ?, ?, ?)`
).run(id, gameId, name, seat, color);
}
export function getPlayersByGame(gameId) {
return db.prepare(`SELECT * FROM players WHERE game_id = ? ORDER BY seat`).all(gameId);
}
export function countPlayers(gameId) {
return db.prepare(`SELECT COUNT(*) AS n FROM players WHERE game_id = ?`).get(gameId).n;
}
export function touchPlayerLastSeen(playerId) {
db.prepare(`UPDATE players SET last_seen_at = ? WHERE id = ?`).run(now(), playerId);
}
export function createToken({ token, gameId, playerId = null, kind, expiresAt = null }) {
db.prepare(
`INSERT INTO tokens (token, game_id, player_id, kind, expires_at) VALUES (?, ?, ?, ?, ?)`
).run(token, gameId, playerId, kind, expiresAt);
}
export function getToken(token) {
return db.prepare(`SELECT * FROM tokens WHERE token = ?`).get(token) ?? null;
}
+20
View File
@@ -0,0 +1,20 @@
import crypto from 'node:crypto';
// Unambiguous alphabet for human-shareable join codes: no 0/O or 1/I.
const CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
export function randomId() {
return crypto.randomUUID();
}
export function randomToken() {
return crypto.randomBytes(24).toString('base64url');
}
export function randomJoinCode(length = 6) {
let code = '';
for (let i = 0; i < length; i++) {
code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)];
}
return code;
}
+189 -26
View File
@@ -1,14 +1,11 @@
/**
* Life Journey — Phase 0 deployment shell.
* Life Journey — Phase 1 server.
*
* This does the minimum needed to prove the plumbing works end to end:
* - serves the static frontend
* - answers GET /api/health (is the server reachable?)
* - accepts a WebSocket on /ws (does WS survive the reverse proxy?)
*
* There is deliberately NO game logic here yet. Phase 1 adds the shared
* game.js reducer, a SQLite-backed data model, rooms, and real actions.
* The data directory is created now so the Docker volume mount is validated.
* The server is the sole authority over game state: it validates client
* intents, generates the only source of randomness (the dice roll), builds
* the real action, and applies it through the exact same shared reducer the
* browser imports. Rooms/tokens/state are persisted to SQLite so a restart
* (or a closed tab reconnecting later) resumes rather than resets.
*/
import http from 'node:http';
@@ -18,46 +15,212 @@ import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import { board } from '../shared/board.js';
import { createInitialState } from '../shared/game.js';
import * as db from './db.js';
import * as rooms from './rooms.js';
import { randomId, randomToken, randomJoinCode } from './ids.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
// Ensure the persisted data directory exists (future: SQLite db + token uploads).
fs.mkdirSync(DATA_DIR, { recursive: true });
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
const PLAYER_COLORS = ['#e8a12a', '#3f8f5f', '#2f6f9f', '#c1452f', '#7a4fae', '#2f8f8f'];
const AUTH_TIMEOUT_MS = 10_000;
const app = express();
app.use(express.json());
// --- Health check: lets the frontend (and you) confirm the server is up ---
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
service: 'life-journey',
phase: 0,
version: '0.0.1',
time: new Date().toISOString(),
app.use((err, _req, res, next) => {
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Invalid JSON' });
next(err);
});
// --- Health check ---
app.get('/api/health', (_req, res) => {
res.json({ ok: true, service: 'life-journey', phase: 1, version: '0.1.0', time: new Date().toISOString() });
});
// --- Helpers ---
function cleanName(raw, label) {
const name = typeof raw === 'string' ? raw.trim() : '';
if (!name) throw Object.assign(new Error(`${label} is required`), { status: 400 });
if (name.length > 40) throw Object.assign(new Error(`${label} is too long`), { status: 400 });
return name;
}
function uniqueJoinCode() {
let code;
do {
code = randomJoinCode();
} while (db.getGameByCode(code));
return code;
}
function buildInviteUrl(req, code) {
const base = (process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
return `${base}/join/${code}`;
}
function colorForSeat(seat) {
return PLAYER_COLORS[(seat - 1) % PLAYER_COLORS.length];
}
// --- REST: games/rooms ---
app.post('/api/games', (req, res) => {
let hostName;
try {
hostName = cleanName(req.body?.hostName, 'hostName');
} catch (err) {
return res.status(err.status ?? 400).json({ error: err.message });
}
const gameId = randomId();
const code = uniqueJoinCode();
const initialState = createInitialState(DEFAULT_CONFIG);
db.createGame({ id: gameId, code, boardId: board.id, config: DEFAULT_CONFIG, state: initialState });
const room = rooms.loadOrCreateRoom(gameId);
const playerId = randomId();
const color = colorForSeat(1);
db.createPlayer({ id: playerId, gameId, name: hostName, seat: 1, color });
let state;
try {
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name: hostName, seat: 1, color });
} catch (err) {
return res.status(500).json({ error: err.message });
}
const sessionToken = randomToken();
db.createToken({ token: sessionToken, gameId, playerId, kind: 'session' });
res.status(201).json({
gameId,
code,
inviteUrl: buildInviteUrl(req, code),
playerId,
sessionToken,
state,
});
});
app.get('/api/games/:code', (req, res) => {
const game = db.getGameByCode(req.params.code);
if (!game) return res.status(404).json({ error: 'Game not found' });
res.json({
gameId: game.id,
code: game.code,
status: game.status,
config: game.config,
players: db.getPlayersByGame(game.id),
state: game.state,
});
});
app.post('/api/games/:code/join', (req, res) => {
const game = db.getGameByCode(req.params.code);
if (!game) return res.status(404).json({ error: 'Game not found' });
if (game.status !== 'lobby') return res.status(409).json({ error: 'Game already started' });
let name;
try {
name = cleanName(req.body?.name, 'name');
} catch (err) {
return res.status(err.status ?? 400).json({ error: err.message });
}
const seat = db.countPlayers(game.id) + 1;
if (seat > game.config.maxPlayers) return res.status(409).json({ error: 'Game is full' });
const room = rooms.loadOrCreateRoom(game.id);
const playerId = randomId();
const color = colorForSeat(seat);
db.createPlayer({ id: playerId, gameId: game.id, name, seat, color });
let state;
try {
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name, seat, color });
} catch (err) {
return res.status(409).json({ error: err.message });
}
const sessionToken = randomToken();
db.createToken({ token: sessionToken, gameId: game.id, playerId, kind: 'session' });
res.status(201).json({ gameId: game.id, playerId, sessionToken, state });
});
// --- Static frontend ---
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
app.use('/shared', express.static(path.join(__dirname, '..', 'shared')));
// Deep link for invite URLs — express.static won't match this path.
app.get('/join/:code', (_req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
const server = http.createServer(app);
// --- WebSocket smoke test on /ws ---
// If this connects through your domain, Nginx Proxy Manager is passing the
// Upgrade/Connection headers correctly ("Websockets Support" is ON). Phase 1
// reuses this exact endpoint to push game state to each device.
// --- WebSocket: room-aware, authenticated via a first {type:'AUTH'} message ---
// (not a query param, so the session token never lands in access/proxy logs)
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'welcome', msg: 'WebSocket connected' }));
let authenticated = false;
let room = null;
let playerId = null;
const authTimeout = setTimeout(() => {
if (!authenticated) ws.close(4001, 'auth timeout');
}, AUTH_TIMEOUT_MS);
ws.on('message', (raw) => {
ws.send(JSON.stringify({ type: 'echo', received: raw.toString() }));
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
if (!authenticated) {
if (msg?.type !== 'AUTH' || typeof msg.token !== 'string') {
ws.close(4001, 'expected AUTH');
return;
}
const tokenRow = db.getToken(msg.token);
if (!tokenRow || tokenRow.kind !== 'session') {
ws.close(4001, 'invalid token');
return;
}
let targetRoom;
try {
targetRoom = rooms.loadOrCreateRoom(tokenRow.game_id);
} catch {
ws.close(4004, 'game not found');
return;
}
clearTimeout(authTimeout);
authenticated = true;
room = targetRoom;
playerId = tokenRow.player_id;
rooms.attachSocket(room, ws, playerId);
db.touchPlayerLastSeen(playerId);
return;
}
if (typeof msg?.type !== 'string') return;
rooms.handleIntent(room, ws, playerId, msg);
});
ws.on('close', () => clearTimeout(authTimeout));
});
server.listen(PORT, () => {
console.log(`Life Journey (Phase 0) listening on :${PORT}`);
console.log(`Life Journey (Phase 1) listening on :${PORT}`);
console.log(`Data directory: ${DATA_DIR}`);
});
+78
View File
@@ -0,0 +1,78 @@
/**
* In-memory room registry, SQLite as the durable backing store. Every
* authoritative state change flows through applyAndBroadcast(), which is the
* only place the shared reducer is invoked server-side.
*/
import crypto from 'node:crypto';
import { reduce, getLegalIntents } from '../shared/game.js';
import * as db from './db.js';
export class RoomError extends Error {}
const rooms = new Map(); // gameId -> { gameId, state, sockets: Set<WebSocket> }
export function loadOrCreateRoom(gameId) {
const existing = rooms.get(gameId);
if (existing) return existing;
const row = db.getGameById(gameId);
if (!row) throw new RoomError(`Game not found: ${gameId}`);
const room = { gameId, state: row.state, sockets: new Set() };
rooms.set(gameId, room);
return room;
}
export function attachSocket(room, ws, playerId) {
ws.playerId = playerId;
room.sockets.add(ws);
send(ws, { type: 'state', gameId: room.gameId, state: room.state, lastAction: null });
ws.on('close', () => room.sockets.delete(ws));
}
/** Apply an already-constructed action through the shared reducer, persist,
* and broadcast the result to every socket in the room. Throws if the
* reducer rejects the action. */
export function applyAndBroadcast(room, action) {
const newState = reduce(room.state, action);
room.state = newState;
db.updateGameState(room.gameId, newState, newState.status);
broadcast(room, { type: 'state', gameId: room.gameId, state: newState, lastAction: action });
return newState;
}
/** Validate + resolve a client intent into a real action, then apply it.
* Sends an {type:'error'} back to the originating socket on rejection. */
export function handleIntent(room, ws, playerId, intent) {
try {
const action = buildAction(room.state, playerId, intent);
applyAndBroadcast(room, action);
} catch (err) {
send(ws, { type: 'error', message: err.message });
}
}
function buildAction(state, playerId, intent) {
const legal = getLegalIntents(state, playerId);
if (!legal.includes(intent.type)) {
throw new Error(`Not allowed: ${intent.type}`);
}
switch (intent.type) {
case 'REQUEST_START':
return { type: 'START_GAME' };
case 'REQUEST_ROLL':
return { type: 'ROLL', playerId, value: 1 + crypto.randomInt(state.config.diceSides) };
case 'REQUEST_CHOOSE':
if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required');
return { type: 'CHOOSE', playerId, spaceId: intent.spaceId };
default:
throw new Error(`Unknown intent: ${intent.type}`);
}
}
function broadcast(room, payload) {
for (const ws of room.sockets) send(ws, payload);
}
function send(ws, payload) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload));
}