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 # PORT=3000
# DATA_DIR=/app/data # 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: # Arrives in later phases:
# ADMIN_PASSWORD=change-me # Phase 4 — protects the admin page # 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 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). # Install dependencies first (better layer caching).
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
@@ -9,6 +14,7 @@ RUN npm ci --omit=dev
# App source. # App source.
COPY server ./server COPY server ./server
COPY public ./public COPY public ./public
COPY shared ./shared
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 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 An async multiplayer, Game-of-Life-style board game. Phase 0 proved the
game logic yet — its only job is to prove the deployment path works: the server deployment path (Docker → Nginx Proxy Manager → HTTPS → WebSocket). Phase 1
runs in Docker, is reachable through Nginx Proxy Manager over HTTPS, and adds the actual game: a pure shared reducer, SQLite persistence, and
WebSockets survive the reverse proxy. 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 server is the sole authority over game state. It generates the only
the deployment story again. 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 ## What's here
@@ -15,63 +18,66 @@ lifegame/
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
├── package.json / package-lock.json ├── package.json / package-lock.json
├── server/index.js # health endpoint + WebSocket echo ├── shared/
└── public/index.html # live HTTP + WebSocket status page │ ├── 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 ```bash
cd ~/homelab/lifegame cd ~/homelab/lifegame
docker compose up -d --build 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 ## Data & backups
The `lifegame-data` volume (mounted at `/app/data`) is where the SQLite The `lifegame-data` volume (mounted at `/app/data`) holds `lifegame.db`
database and uploaded player tokens will live in later phases. It's empty now, every game, player, and token. Back it up like your other self-hosted data;
but include it in the same backup routine as 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) - Expand `shared/board.js` toward the full sketched board
- SQLite data model: games, players, board/rule config, tokens - Richer board rendering (the current UI is a functional list view, not the
- Rooms + shareable invite links / join codes illustrated board)
- `NetworkTransport` on the client — the one-line swap that makes the game - Tighter reconnection/presence handling (who's online right now, not just
run across devices who's joined)
- Persistence & reconnection, since async means tabs close and servers restart - Admin page (`ADMIN_PASSWORD`, already stubbed in `.env.example`)
+1
View File
@@ -7,6 +7,7 @@ services:
- PORT=3000 - PORT=3000
- NODE_ENV=production - NODE_ENV=production
- DATA_DIR=/app/data - DATA_DIR=/app/data
- PUBLIC_URL=${PUBLIC_URL:-}
volumes: volumes:
- lifegame-data:/app/data - lifegame-data:/app/data
networks: networks:
+426 -2
View File
@@ -1,13 +1,14 @@
{ {
"name": "life-journey-server", "name": "life-journey-server",
"version": "0.0.1", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "life-journey-server", "name": "life-journey-server",
"version": "0.0.1", "version": "0.1.0",
"dependencies": { "dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2", "express": "^4.19.2",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
@@ -31,6 +32,57 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/body-parser": {
"version": "1.20.6", "version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -55,6 +107,30 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -93,6 +169,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -138,6 +220,30 @@
"ms": "2.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -157,6 +263,15 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -186,6 +301,15 @@
"node": ">= 0.8" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -231,6 +355,15 @@
"node": ">= 0.6" "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": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -277,6 +410,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -313,6 +452,12 @@
"node": ">= 0.6" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -359,6 +504,12 @@
"node": ">= 0.4" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -427,12 +578,38 @@
"node": ">=0.10.0" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -511,12 +688,45 @@
"node": ">= 0.6" "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": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -526,6 +736,18 @@
"node": ">= 0.6" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -550,6 +772,15 @@
"node": ">= 0.8" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -565,6 +796,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -578,6 +836,16 @@
"node": ">= 0.10" "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": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -618,6 +886,35 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -644,6 +941,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -767,6 +1076,51 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -776,6 +1130,52 @@
"node": ">= 0.8" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -785,6 +1185,18 @@
"node": ">=0.6" "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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -807,6 +1219,12 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -825,6 +1243,12 @@
"node": ">= 0.8" "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": { "node_modules/ws": {
"version": "8.21.1", "version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+5 -3
View File
@@ -1,15 +1,17 @@
{ {
"name": "life-journey-server", "name": "life-journey-server",
"version": "0.0.1", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "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", "main": "server/index.js",
"scripts": { "scripts": {
"start": "node server/index.js", "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": { "dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2", "express": "^4.19.2",
"ws": "^8.18.0" "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();
}
}
+274 -67
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <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; margin: 0; min-height: 100vh; padding: 24px 16px;
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink); 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%); 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 { .card {
background: var(--paper); border: 1px solid var(--line); border-radius: 16px; 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; } 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; } .sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
.check { label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
display: flex; align-items: center; gap: 12px; padding: 12px 14px; input[type="text"] {
border: 1.5px solid var(--line); border-radius: 12px; background: #fffaf0; margin-bottom: 10px; 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 { button {
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink); font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px; 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; } 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; } button:disabled { opacity: .5; cursor: not-allowed; }
code { background: #efe5cc; padding: 1px 5px; border-radius: 5px; font-size: 12px; } 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> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card" id="app">
<h1>Life Journey</h1> <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"> <section id="panel-entry">
<span class="dot" id="http-dot"></span> <label for="name-input">Your name</label>
<span class="name">Server (HTTP)</span> <input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
<span class="state" id="http-state">checking…</span>
</div>
<div class="check"> <div id="create-section">
<span class="dot" id="ws-dot"></span> <button id="create-btn">Create Game</button>
<span class="name">WebSocket</span> </div>
<span class="state" id="ws-state">connecting…</span>
</div>
<button id="ping">Send WebSocket ping</button> <hr />
<p class="note"> <label for="join-code-input">Game code</label>
Both dots green means the shell is deployed correctly and your reverse <input type="text" id="join-code-input" maxlength="6" placeholder="e.g. AB3XQ9" style="text-transform:uppercase" />
proxy is passing WebSockets. If the WebSocket dot is red but HTTP is green, <button id="join-btn" class="secondary">Join Game</button>
enable <code>Websockets Support</code> on the proxy host in Nginx Proxy Manager. </section>
</p>
<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>
<hr />
<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> </div>
<script> <script type="module">
// ---- HTTP health check ---- import {
const httpDot = document.getElementById('http-dot'); createGame, joinGame, saveSession, loadSession,
const httpState = document.getElementById('http-state'); NetworkTransport, getLegalIntents, board,
fetch('/api/health') } from '/client.js';
.then(r => r.json())
.then(d => {
httpDot.classList.add('ok');
httpState.textContent = `ok · v${d.version}`;
})
.catch(() => {
httpDot.classList.add('fail');
httpState.textContent = 'unreachable';
});
// ---- WebSocket check ---- const els = {
const wsDot = document.getElementById('ws-dot'); subtitle: document.getElementById('subtitle'),
const wsState = document.getElementById('ws-state'); panelEntry: document.getElementById('panel-entry'),
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; panelLobby: document.getElementById('panel-lobby'),
let ws; panelGame: document.getElementById('panel-game'),
try { nameInput: document.getElementById('name-input'),
ws = new WebSocket(`${proto}://${location.host}/ws`); createSection: document.getElementById('create-section'),
ws.onopen = () => { wsDot.classList.add('ok'); wsState.textContent = 'connected'; }; createBtn: document.getElementById('create-btn'),
ws.onmessage = (e) => { joinCodeInput: document.getElementById('join-code-input'),
const data = JSON.parse(e.data); joinBtn: document.getElementById('join-btn'),
if (data.type === 'echo') wsState.textContent = 'echo received ✓'; inviteLink: document.getElementById('invite-link'),
else if (data.type === 'welcome') wsState.textContent = 'connected'; copyLinkBtn: document.getElementById('copy-link-btn'),
}; playerList: document.getElementById('player-list'),
ws.onerror = () => { wsDot.classList.add('fail'); wsState.textContent = 'failed'; }; startBtn: document.getElementById('start-btn'),
ws.onclose = () => { if (!wsDot.classList.contains('ok')) { wsDot.classList.add('fail'); wsState.textContent = 'closed'; } }; lobbyHint: document.getElementById('lobby-hint'),
} catch { turnBanner: document.getElementById('turn-banner'),
wsDot.classList.add('fail'); wsState.textContent = 'unsupported'; 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';
} }
document.getElementById('ping').onclick = () => { function showError(message) {
if (ws && ws.readyState === WebSocket.OPEN) { els.errorNote.textContent = message;
ws.send('ping ' + new Date().toISOString()); els.errorNote.hidden = false;
wsState.textContent = 'ping sent…'; 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 {
await transport.connect(self.sessionToken);
} catch (err) {
showError(`Could not connect: ${err.message}`);
}
}
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 { } 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> </script>
</body> </body>
</html> </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;
}
+187 -24
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: * The server is the sole authority over game state: it validates client
* - serves the static frontend * intents, generates the only source of randomness (the dice roll), builds
* - answers GET /api/health (is the server reachable?) * the real action, and applies it through the exact same shared reducer the
* - accepts a WebSocket on /ws (does WS survive the reverse proxy?) * browser imports. Rooms/tokens/state are persisted to SQLite so a restart
* * (or a closed tab reconnecting later) resumes rather than resets.
* 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.
*/ */
import http from 'node:http'; import http from 'node:http';
@@ -18,46 +15,212 @@ import { fileURLToPath } from 'node:url';
import express from 'express'; import express from 'express';
import { WebSocketServer } from 'ws'; 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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); 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 }); 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(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((err, _req, res, next) => {
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Invalid JSON' });
next(err);
});
// --- Health check: lets the frontend (and you) confirm the server is up --- // --- Health check ---
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ res.json({ ok: true, service: 'life-journey', phase: 1, version: '0.1.0', time: new Date().toISOString() });
ok: true, });
service: 'life-journey',
phase: 0, // --- Helpers ---
version: '0.0.1', function cleanName(raw, label) {
time: new Date().toISOString(), 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 --- // --- Static frontend ---
const publicDir = path.join(__dirname, '..', 'public'); const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir)); 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); const server = http.createServer(app);
// --- WebSocket smoke test on /ws --- // --- WebSocket: room-aware, authenticated via a first {type:'AUTH'} message ---
// If this connects through your domain, Nginx Proxy Manager is passing the // (not a query param, so the session token never lands in access/proxy logs)
// Upgrade/Connection headers correctly ("Websockets Support" is ON). Phase 1
// reuses this exact endpoint to push game state to each device.
const wss = new WebSocketServer({ server, path: '/ws' }); const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (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.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, () => { 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}`); 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));
}