diff --git a/.env.example b/.env.example index d6bff21..066fd3d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index a109f2a..c0af9f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 46c22df..dfce92a 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/docker-compose.yml b/docker-compose.yml index d180899..dc80cf9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - PORT=3000 - NODE_ENV=production - DATA_DIR=/app/data + - PUBLIC_URL=${PUBLIC_URL:-} volumes: - lifegame-data:/app/data networks: diff --git a/package-lock.json b/package-lock.json index 41345e3..3cd1033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index da4f8de..184f604 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/public/client.js b/public/client.js new file mode 100644 index 0000000..44c7e4b --- /dev/null +++ b/public/client.js @@ -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(); + } +} diff --git a/public/index.html b/public/index.html index 2ed60c8..ac7da69 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ -Life Journey — Deployment Check +Life Journey @@ -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; + } -
+

Life Journey

-

Deployment check · Phase 0 — no game here yet

+

Async multiplayer — create a room or join one via invite link.

-
- - Server (HTTP) - checking… -
+
+ + -
- - WebSocket - connecting… -
+
+ +
- +
-

- 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 Websockets Support on the proxy host in Nginx Proxy Manager. -

+ + + +
+ + + + + +
- diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..90a2d3a --- /dev/null +++ b/server/db.js @@ -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; +} diff --git a/server/ids.js b/server/ids.js new file mode 100644 index 0000000..6120d51 --- /dev/null +++ b/server/ids.js @@ -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; +} diff --git a/server/index.js b/server/index.js index da2f7c1..7768ec5 100644 --- a/server/index.js +++ b/server/index.js @@ -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}`); }); diff --git a/server/rooms.js b/server/rooms.js new file mode 100644 index 0000000..2d99732 --- /dev/null +++ b/server/rooms.js @@ -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 } + +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)); +}