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:
+7
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/app/data
|
||||
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||
volumes:
|
||||
- lifegame-data:/app/data
|
||||
networks:
|
||||
|
||||
Generated
+426
-2
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</div>
|
||||
<section id="panel-entry">
|
||||
<label for="name-input">Your name</label>
|
||||
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
|
||||
|
||||
<div class="check">
|
||||
<span class="dot" id="ws-dot"></span>
|
||||
<span class="name">WebSocket</span>
|
||||
<span class="state" id="ws-state">connecting…</span>
|
||||
</div>
|
||||
<div id="create-section">
|
||||
<button id="create-btn">Create Game</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 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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;
|
||||
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';
|
||||
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';
|
||||
}
|
||||
|
||||
document.getElementById('ping').onclick = () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping ' + new Date().toISOString());
|
||||
wsState.textContent = 'ping sent…';
|
||||
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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 {
|
||||
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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
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) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'life-journey',
|
||||
phase: 0,
|
||||
version: '0.0.1',
|
||||
time: new Date().toISOString(),
|
||||
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}`);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user