Compare commits
9 Commits
89159ad5b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6749777b45 | |||
| db82b531e0 | |||
| 4d78937dca | |||
| 220f70d342 | |||
| 70eeb78eaf | |||
| 6aa9769ce5 | |||
| 8df4859696 | |||
| d40bc09867 | |||
| 1f51c7d541 |
@@ -1,7 +1,12 @@
|
|||||||
# Copy to .env and adjust. Nothing here is required for Phase 0.
|
# Copy to .env and adjust.
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# DATA_DIR=/app/data
|
# DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Phase 1: used to build shareable invite links (POST /api/games -> inviteUrl).
|
||||||
|
# If unset, the server falls back to the incoming request's own protocol/host,
|
||||||
|
# which is fine for local dev but should be set behind a reverse proxy so
|
||||||
|
# invite links use your public domain rather than an internal address.
|
||||||
|
# PUBLIC_URL=https://game.example.com
|
||||||
|
|
||||||
# Arrives in later phases:
|
# Arrives in later phases:
|
||||||
# ADMIN_PASSWORD=change-me # Phase 4 — protects the admin page
|
# ADMIN_PASSWORD=change-me # Phase 4 — protects the admin page
|
||||||
# PUBLIC_URL=https://game.example.com # Phase 1 — for building invite links
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# better-sqlite3's prebuilt binary coverage for musl/Alpine is inconsistent
|
||||||
|
# across Node versions; these let its node-gyp fallback build from source
|
||||||
|
# when no prebuilt matches. No sqlite-dev needed — it bundles its own SQLite.
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Install dependencies first (better layer caching).
|
# Install dependencies first (better layer caching).
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
@@ -9,6 +14,7 @@ RUN npm ci --omit=dev
|
|||||||
# App source.
|
# App source.
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY shared ./shared
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# Life Journey — Deployment Shell (Phase 0)
|
# Life Journey — Phase 1
|
||||||
|
|
||||||
An empty, deployable shell for the async multiplayer board game. It contains no
|
An async multiplayer, Game-of-Life-style board game. Phase 0 proved the
|
||||||
game logic yet — its only job is to prove the deployment path works: the server
|
deployment path (Docker → Nginx Proxy Manager → HTTPS → WebSocket). Phase 1
|
||||||
runs in Docker, is reachable through Nginx Proxy Manager over HTTPS, and
|
adds the actual game: a pure shared reducer, SQLite persistence, and
|
||||||
WebSockets survive the reverse proxy.
|
rooms with shareable invite links, so a few people can play together across
|
||||||
|
devices and tab closes.
|
||||||
|
|
||||||
Once both checks are green, adding the real game (Phase 1 onward) never touches
|
The server is the sole authority over game state. It generates the only
|
||||||
the deployment story again.
|
source of randomness (the dice roll) and applies it through the exact same
|
||||||
|
reducer (`shared/game.js`) the browser imports — client and server can never
|
||||||
|
disagree about the rules.
|
||||||
|
|
||||||
## What's here
|
## What's here
|
||||||
|
|
||||||
@@ -15,63 +18,71 @@ lifegame/
|
|||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── package.json / package-lock.json
|
├── package.json / package-lock.json
|
||||||
├── server/index.js # health endpoint + WebSocket echo
|
├── shared/
|
||||||
└── public/index.html # live HTTP + WebSocket status page
|
│ ├── board.js # the Phase 1 board graph + movement helper
|
||||||
|
│ ├── game.js # pure reducer: reduce(state, action) -> newState
|
||||||
|
│ └── game.test.js # node --test coverage of the whole rules engine
|
||||||
|
├── server/
|
||||||
|
│ ├── index.js # REST + WebSocket, static hosting
|
||||||
|
│ ├── rooms.js # in-memory room registry, applies/broadcasts actions
|
||||||
|
│ ├── db.js # SQLite schema + data access (games/players/tokens)
|
||||||
|
│ └── ids.js # id/token/join-code generation
|
||||||
|
└── public/
|
||||||
|
├── index.html # lobby / waiting room / game UI
|
||||||
|
├── client.js # REST wrappers + NetworkTransport (WebSocket)
|
||||||
|
└── boardRender.js # SVG board, laid out from board.js graph data
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
`shared/board.js` (~107 spaces) is transcribed from the hand-drawn sketch at
|
||||||
|
`assets/game_board.png`, organized into three fork points with the same
|
||||||
|
shape the sketch uses: **Career / Education / Gap Year** at the start,
|
||||||
|
**Relationship / Investment** after a shared "quarter-life crisis" chain,
|
||||||
|
then **High Risk / Safe** before a long shared retirement tail to Finish.
|
||||||
|
The sketch reuses several space names across its zones (Start A Business,
|
||||||
|
Family Reunion, Market Crash, ...) — that's kept as intentional recurring
|
||||||
|
flavor rather than deduplicated. More spaces can be inserted into any
|
||||||
|
branch's `chain([...])` array in `board.js` without touching the reducer,
|
||||||
|
the SVG renderer, or the database schema — none of them know or care how
|
||||||
|
many spaces exist.
|
||||||
|
|
||||||
|
## Deploying on the homelab
|
||||||
|
|
||||||
|
Same as Phase 0 — see `homelab-config.md` for the full infrastructure
|
||||||
|
reference. Set `PUBLIC_URL` in `.env` (or the compose environment) to your
|
||||||
|
public domain so invite links generated by the server are shareable rather
|
||||||
|
than pointing at an internal address:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/homelab/lifegame
|
cd ~/homelab/lifegame
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f # expect: "Life Journey (Phase 0) listening on :3000"
|
docker compose logs -f # expect: "Life Journey (Phase 1) listening on :3000"
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify locally on the host first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/api/health
|
|
||||||
# {"ok":true,"service":"life-journey","phase":0,...}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Point Nginx Proxy Manager at it
|
|
||||||
|
|
||||||
Add a **Proxy Host**:
|
|
||||||
|
|
||||||
- **Domain**: your chosen name, e.g. `game.example.com`
|
|
||||||
- **Scheme**: `http`
|
|
||||||
- **Forward Hostname / IP**:
|
|
||||||
- *Option A (default compose):* the homelab's LAN IP (e.g. `192.168.x.x`)
|
|
||||||
- *Option B (shared network):* `lifegame`
|
|
||||||
- **Forward Port**: `3000`
|
|
||||||
- **Websockets Support**: **ON** ← the easy-to-forget one
|
|
||||||
- **SSL tab**: request a new Let's Encrypt certificate, Force SSL on
|
|
||||||
|
|
||||||
## 3. Confirm it works
|
|
||||||
|
|
||||||
Open `https://game.example.com`. You should see two rows go green:
|
|
||||||
|
|
||||||
- **Server (HTTP)** — the health endpoint responded
|
|
||||||
- **WebSocket** — a live socket connected through the proxy
|
|
||||||
|
|
||||||
Click **Send WebSocket ping** and the status should show the echo came back.
|
|
||||||
|
|
||||||
If HTTP is green but WebSocket is red, the proxy isn't upgrading the
|
|
||||||
connection — go back and toggle **Websockets Support** on the proxy host.
|
|
||||||
|
|
||||||
## Data & backups
|
## Data & backups
|
||||||
|
|
||||||
The `lifegame-data` volume (mounted at `/app/data`) is where the SQLite
|
The `lifegame-data` volume (mounted at `/app/data`) holds `lifegame.db` —
|
||||||
database and uploaded player tokens will live in later phases. It's empty now,
|
every game, player, and token. Back it up like your other self-hosted data;
|
||||||
but include it in the same backup routine as your other self-hosted data.
|
losing it loses every in-progress and finished game.
|
||||||
|
|
||||||
## What Phase 1 adds
|
## What's next
|
||||||
|
|
||||||
- The shared `game.js` rules engine (the pure reducer from the prototype)
|
- Expand `shared/board.js` toward the full sketched board
|
||||||
- SQLite data model: games, players, board/rule config, tokens
|
- Richer board rendering (the current UI is a functional list view, not the
|
||||||
- Rooms + shareable invite links / join codes
|
illustrated board)
|
||||||
- `NetworkTransport` on the client — the one-line swap that makes the game
|
- Tighter reconnection/presence handling (who's online right now, not just
|
||||||
run across devices
|
who's joined)
|
||||||
- Persistence & reconnection, since async means tabs close and servers restart
|
- Admin page (`ADMIN_PASSWORD`, already stubbed in `.env.example`)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"deep pine green": "#163a30",
|
||||||
|
"warm paper": "#f6edd8",
|
||||||
|
"marigold": "#e8a12a",
|
||||||
|
"tomato": "#d6553b",
|
||||||
|
"muted green": "#3f8f5f",
|
||||||
|
"slate blue": "#3f6ea5"
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "0",
|
||||||
|
"name": "Start",
|
||||||
|
"color": "#f6edd8",
|
||||||
|
"zone": "start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "High School Dropout",
|
||||||
|
"color": "#d6553b",
|
||||||
|
"zone": "start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "@",
|
||||||
|
"color": "#e8a12a",
|
||||||
|
"zone": "start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"name": "High School Graduate",
|
||||||
|
"color": "#3f8f5f",
|
||||||
|
"zone": "start"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
|
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- lifegame-data:/app/data
|
- lifegame-data:/app/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "life-journey-server",
|
"name": "life-journey-server",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "life-journey-server",
|
"name": "life-journey-server",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,57 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/better-sqlite3": {
|
||||||
|
"version": "11.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
|
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^5.5.0",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.6",
|
"version": "1.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||||
@@ -55,6 +107,30 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -93,6 +169,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -138,6 +220,30 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-extend": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -157,6 +263,15 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -186,6 +301,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -231,6 +355,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expand-template": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||||
|
"license": "(MIT OR WTFPL)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.2",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
@@ -277,6 +410,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@@ -313,6 +452,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-constants": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -359,6 +504,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-from-package": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -427,12 +578,38 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -511,12 +688,45 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mkdirp-classic": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/napi-build-utils": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -526,6 +736,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abi": {
|
||||||
|
"version": "3.94.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
|
||||||
|
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -550,6 +772,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -565,6 +796,33 @@
|
|||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/prebuild-install": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||||
|
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"expand-template": "^2.0.3",
|
||||||
|
"github-from-package": "0.0.0",
|
||||||
|
"minimist": "^1.2.3",
|
||||||
|
"mkdirp-classic": "^0.5.3",
|
||||||
|
"napi-build-utils": "^2.0.0",
|
||||||
|
"node-abi": "^3.3.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"rc": "^1.2.7",
|
||||||
|
"simple-get": "^4.0.0",
|
||||||
|
"tar-fs": "^2.0.0",
|
||||||
|
"tunnel-agent": "^0.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prebuild-install": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -578,6 +836,16 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
@@ -618,6 +886,35 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rc": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||||
|
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-extend": "^0.6.0",
|
||||||
|
"ini": "~1.3.0",
|
||||||
|
"minimist": "^1.2.0",
|
||||||
|
"strip-json-comments": "~2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rc": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -644,6 +941,18 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
@@ -767,6 +1076,51 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-concat": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/simple-get": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"once": "^1.3.1",
|
||||||
|
"simple-concat": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -776,6 +1130,52 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-json-comments": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-fs": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^1.1.1",
|
||||||
|
"mkdirp-classic": "^0.5.2",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"tar-stream": "^2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-stream": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bl": "^4.0.3",
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"fs-constants": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -785,6 +1185,18 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tunnel-agent": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -807,6 +1219,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -825,6 +1243,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.1",
|
"version": "8.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "life-journey-server",
|
"name": "life-journey-server",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Async multiplayer board game — deployment shell (Phase 0)",
|
"description": "Async multiplayer board game — Phase 1 (rooms, reducer, persistence)",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"dev": "node --watch server/index.js"
|
"dev": "node --watch server/index.js",
|
||||||
|
"test": "node --test shared/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* Renders the board graph as SVG, laid out entirely from board.spaces data —
|
||||||
|
* no hand-placed coordinates. Column = longest-path distance from Start;
|
||||||
|
* lane = branch offset that fans out at a 'choice' space and re-centers
|
||||||
|
* (averages) at whichever space its branches rejoin. This means the layout
|
||||||
|
* keeps working unmodified as shared/board.js grows.
|
||||||
|
*
|
||||||
|
* Visual style is illustrated-map-ish rather than flowchart-ish: a
|
||||||
|
* procedurally scattered terrain backdrop (river, mountains, trees) that
|
||||||
|
* doesn't need to align to any specific tile, a wide dashed "road" between
|
||||||
|
* spaces, and tiles drawn as beveled signs with a small deterministic tilt
|
||||||
|
* per space so they read as hand-placed rather than machine-stamped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
const COL_WIDTH = 108;
|
||||||
|
const ROW_HEIGHT = 168;
|
||||||
|
const LANE_HEIGHT = 64;
|
||||||
|
const MARGIN_X = 70;
|
||||||
|
const MARGIN_Y = 80;
|
||||||
|
const NODE_W = 88;
|
||||||
|
const NODE_H = 60;
|
||||||
|
const PLAYER_TOKEN_R = 12;
|
||||||
|
|
||||||
|
const TYPE_STYLE = {
|
||||||
|
finish: { icon: '🏁', shape: 'circle' },
|
||||||
|
choice: { icon: '🔀', shape: 'diamond' },
|
||||||
|
stop: { icon: '🛑', shape: 'diamond' },
|
||||||
|
payday: { icon: '💵', shape: 'rect' },
|
||||||
|
cash_bonus: { icon: '💰', shape: 'rect' },
|
||||||
|
action_space: { icon: '🎯', shape: 'rect' },
|
||||||
|
dice_space: { icon: '🎲', shape: 'rect' },
|
||||||
|
roll_table_ref: { icon: '📋', shape: 'rect' },
|
||||||
|
inline_table: { icon: '🎰', shape: 'rect' },
|
||||||
|
event: { icon: '✨', shape: 'rect' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
|
||||||
|
export function computeLayout(board, colsPerRow = 20) {
|
||||||
|
const ids = Object.keys(board.spaces);
|
||||||
|
const outEdges = new Map(ids.map((id) => [id, []]));
|
||||||
|
const predecessors = new Map(ids.map((id) => [id, []]));
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const space = board.spaces[id];
|
||||||
|
const targets = space.type === 'choice' && space.choices?.length ? space.choices : (space.next ? [space.next] : []);
|
||||||
|
for (const t of targets) {
|
||||||
|
outEdges.get(id).push(t);
|
||||||
|
predecessors.get(t).push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = new Map([[board.startSpaceId, 0]]);
|
||||||
|
const laneSum = new Map([[board.startSpaceId, 0]]);
|
||||||
|
const remaining = new Map(ids.map((id) => [id, predecessors.get(id).length]));
|
||||||
|
const queue = [board.startSpaceId];
|
||||||
|
|
||||||
|
while (queue.length) {
|
||||||
|
const id = queue.shift();
|
||||||
|
const space = board.spaces[id];
|
||||||
|
const children = outEdges.get(id);
|
||||||
|
|
||||||
|
children.forEach((childId, i) => {
|
||||||
|
col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1));
|
||||||
|
|
||||||
|
// Any space with more than one outgoing edge fans its branches out
|
||||||
|
// symmetrically around the parent's lane (works for 2-way, 3-way, ...
|
||||||
|
// forks alike, whatever type the space is — not just 'choice'); a
|
||||||
|
// single child just inherits the parent's lane unchanged. A join (>1
|
||||||
|
// predecessor) accumulates every incoming contribution and averages
|
||||||
|
// once all of its predecessors have been processed (guaranteed by the
|
||||||
|
// time `remaining` hits 0, since that only happens after every
|
||||||
|
// in-edge is visited).
|
||||||
|
const offset = children.length > 1 ? i - (children.length - 1) / 2 : 0;
|
||||||
|
const contribution = laneSum.get(id) + offset;
|
||||||
|
laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution);
|
||||||
|
|
||||||
|
remaining.set(childId, remaining.get(childId) - 1);
|
||||||
|
if (remaining.get(childId) === 0) {
|
||||||
|
const n = predecessors.get(childId).length || 1;
|
||||||
|
laneSum.set(childId, laneSum.get(childId) / n);
|
||||||
|
queue.push(childId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Map();
|
||||||
|
for (const id of ids) {
|
||||||
|
const c = col.get(id) ?? 0;
|
||||||
|
const lane = laneSum.get(id) ?? 0;
|
||||||
|
const row = Math.floor(c / colsPerRow);
|
||||||
|
let colInRow = c % colsPerRow;
|
||||||
|
if (row % 2 === 1) colInRow = colsPerRow - 1 - colInRow; // snake/boustrophedon
|
||||||
|
positions.set(id, {
|
||||||
|
x: MARGIN_X + colInRow * COL_WIDTH,
|
||||||
|
y: MARGIN_Y + row * ROW_HEIGHT + lane * LANE_HEIGHT,
|
||||||
|
col: c,
|
||||||
|
row,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRow = Math.max(0, ...[...positions.values()].map((p) => p.row));
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
edges: outEdges,
|
||||||
|
width: MARGIN_X * 2 + (colsPerRow - 1) * COL_WIDTH,
|
||||||
|
height: MARGIN_Y * 2 + maxRow * ROW_HEIGHT + 3 * LANE_HEIGHT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl(tag, attrs = {}) {
|
||||||
|
const el = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small stable hash so per-tile tilt/detail is deterministic, not random-per-render. */
|
||||||
|
function hashString(str) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRand(seed) {
|
||||||
|
let s = seed;
|
||||||
|
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoardView {
|
||||||
|
constructor(container, board) {
|
||||||
|
this.board = board;
|
||||||
|
this.layout = computeLayout(board);
|
||||||
|
this.container = container;
|
||||||
|
this.tokenEls = new Map(); // playerId -> <g>
|
||||||
|
this._build();
|
||||||
|
}
|
||||||
|
|
||||||
|
_build() {
|
||||||
|
const { width, height } = this.layout;
|
||||||
|
const svg = svgEl('svg', {
|
||||||
|
viewBox: `0 0 ${width} ${height}`,
|
||||||
|
class: 'board-svg',
|
||||||
|
role: 'img',
|
||||||
|
'aria-label': 'Life Journey board',
|
||||||
|
});
|
||||||
|
svg.appendChild(this._buildDefs());
|
||||||
|
svg.appendChild(this._buildBackdrop(width, height));
|
||||||
|
svg.appendChild(this._buildEdges());
|
||||||
|
svg.appendChild(this._buildNodes());
|
||||||
|
this.tokenLayer = svgEl('g', { class: 'token-layer' });
|
||||||
|
svg.appendChild(this.tokenLayer);
|
||||||
|
|
||||||
|
this.svg = svg;
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
this.container.appendChild(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildDefs() {
|
||||||
|
const defs = svgEl('defs');
|
||||||
|
|
||||||
|
const field = svgEl('radialGradient', { id: 'fieldGradient', cx: '30%', cy: '0%', r: '85%' });
|
||||||
|
field.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#2a5f4c' }));
|
||||||
|
field.appendChild(svgEl('stop', { offset: '55%', 'stop-color': '#1d493c' }));
|
||||||
|
field.appendChild(svgEl('stop', { offset: '100%', 'stop-color': '#163a30' }));
|
||||||
|
defs.appendChild(field);
|
||||||
|
|
||||||
|
const gloss = svgEl('linearGradient', { id: 'tileGloss', x1: '0', y1: '0', x2: '0', y2: '1' });
|
||||||
|
gloss.appendChild(svgEl('stop', { offset: '0%', 'stop-color': '#ffffff', 'stop-opacity': '0.38' }));
|
||||||
|
gloss.appendChild(svgEl('stop', { offset: '60%', 'stop-color': '#ffffff', 'stop-opacity': '0' }));
|
||||||
|
defs.appendChild(gloss);
|
||||||
|
|
||||||
|
// Subtle hand-drawn wobble applied to the road paths only.
|
||||||
|
const wobble = svgEl('filter', { id: 'roadWobble', x: '-20%', y: '-20%', width: '140%', height: '140%' });
|
||||||
|
wobble.appendChild(svgEl('feTurbulence', {
|
||||||
|
type: 'fractalNoise', baseFrequency: '0.012 0.02', numOctaves: '2', seed: '3', result: 'noise',
|
||||||
|
}));
|
||||||
|
wobble.appendChild(svgEl('feDisplacementMap', {
|
||||||
|
in: 'SourceGraphic', in2: 'noise', scale: '5', xChannelSelector: 'R', yChannelSelector: 'G',
|
||||||
|
}));
|
||||||
|
defs.appendChild(wobble);
|
||||||
|
|
||||||
|
return defs;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBackdrop(width, height) {
|
||||||
|
const g = svgEl('g', { class: 'backdrop' });
|
||||||
|
g.appendChild(svgEl('rect', { x: 0, y: 0, width, height, class: 'field-bg' }));
|
||||||
|
|
||||||
|
const rand = makeRand(7); // deterministic — stable across re-renders, not flickering
|
||||||
|
|
||||||
|
const mountainCount = Math.max(3, Math.floor(width / 460));
|
||||||
|
for (let i = 0; i < mountainCount; i++) {
|
||||||
|
g.appendChild(this._buildMountainCluster(rand() * width, rand() * height, 70 + rand() * 55, rand));
|
||||||
|
}
|
||||||
|
|
||||||
|
g.appendChild(this._buildRiver(width, height, rand));
|
||||||
|
|
||||||
|
const treeClusterCount = Math.max(14, Math.floor((width * height) / 15000));
|
||||||
|
for (let i = 0; i < treeClusterCount; i++) {
|
||||||
|
g.appendChild(this._buildTreeCluster(rand() * width, rand() * height, rand));
|
||||||
|
}
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildMountainCluster(cx, cy, size, rand) {
|
||||||
|
const g = svgEl('g', { class: 'mountain-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` });
|
||||||
|
const peaks = 3;
|
||||||
|
for (let i = 0; i < peaks; i++) {
|
||||||
|
const w = size * (0.55 + rand() * 0.3);
|
||||||
|
const h = size * (0.75 + rand() * 0.25);
|
||||||
|
const x = (i - 1) * size * 0.5;
|
||||||
|
g.appendChild(svgEl('polygon', {
|
||||||
|
points: `${x},0 ${x - w / 2},${h} ${x + w / 2},${h}`, class: 'mountain-body',
|
||||||
|
}));
|
||||||
|
g.appendChild(svgEl('polygon', {
|
||||||
|
points: `${x},0 ${x - w * 0.18},${h * 0.3} ${x + w * 0.18},${h * 0.3}`, class: 'mountain-snow',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildRiver(width, height, rand) {
|
||||||
|
const steps = Math.max(4, Math.round(height / 260));
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
points.push([width * 0.15 + rand() * width * 0.7, (height / steps) * i]);
|
||||||
|
}
|
||||||
|
let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const [px, py] = points[i - 1];
|
||||||
|
const [x, y] = points[i];
|
||||||
|
const cy = (py + y) / 2;
|
||||||
|
d += ` C ${px.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${cy.toFixed(1)}, ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
const g = svgEl('g', { class: 'river' });
|
||||||
|
g.appendChild(svgEl('path', { d, class: 'river-body' }));
|
||||||
|
g.appendChild(svgEl('path', { d, class: 'river-shine' }));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildTreeCluster(cx, cy, rand) {
|
||||||
|
const g = svgEl('g', { class: 'tree-cluster', transform: `translate(${cx.toFixed(1)},${cy.toFixed(1)})` });
|
||||||
|
const count = 1 + Math.floor(rand() * 2);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ox = (rand() - 0.5) * 20;
|
||||||
|
const oy = (rand() - 0.5) * 12;
|
||||||
|
const r = 10 + rand() * 9;
|
||||||
|
g.appendChild(svgEl('circle', {
|
||||||
|
cx: ox.toFixed(1), cy: (oy - r * 0.6).toFixed(1), r: r.toFixed(1),
|
||||||
|
class: rand() > 0.5 ? 'tree-canopy-a' : 'tree-canopy-b',
|
||||||
|
}));
|
||||||
|
g.appendChild(svgEl('rect', { x: (ox - 2).toFixed(1), y: oy.toFixed(1), width: 4, height: 8, class: 'tree-trunk' }));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildEdges() {
|
||||||
|
const g = svgEl('g', { class: 'edges' });
|
||||||
|
for (const [id, children] of this.layout.edges) {
|
||||||
|
const from = this.layout.positions.get(id);
|
||||||
|
for (const childId of children) {
|
||||||
|
const to = this.layout.positions.get(childId);
|
||||||
|
const cx = from.x + (to.x - from.x) * 0.5;
|
||||||
|
const d = `M ${from.x} ${from.y} C ${cx} ${from.y}, ${cx} ${to.y}, ${to.x} ${to.y}`;
|
||||||
|
g.appendChild(svgEl('path', { d, class: 'road-shadow' }));
|
||||||
|
g.appendChild(svgEl('path', { d, class: 'road-base' }));
|
||||||
|
g.appendChild(svgEl('path', { d, class: 'road-centerline' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildNodes() {
|
||||||
|
const g = svgEl('g', { class: 'nodes' });
|
||||||
|
for (const id of Object.keys(this.board.spaces)) {
|
||||||
|
g.appendChild(this._buildNode(this.board.spaces[id], this.layout.positions.get(id)));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of {circle, diamond, rect}, offset by (dx,dy) — used for the shadow,
|
||||||
|
* main, and gloss copies of a tile so all three share exact geometry. */
|
||||||
|
_shapeEl(shapeType, dx, dy, cls) {
|
||||||
|
const t = shapeType === 'diamond' ? `translate(${dx},${dy}) rotate(45)` : `translate(${dx},${dy})`;
|
||||||
|
if (shapeType === 'circle') return svgEl('circle', { r: NODE_H / 2, class: cls, transform: t });
|
||||||
|
if (shapeType === 'diamond') {
|
||||||
|
const r = NODE_H / 2 + 6;
|
||||||
|
return svgEl('rect', { x: -r, y: -r, width: r * 2, height: r * 2, rx: 8, class: cls, transform: t });
|
||||||
|
}
|
||||||
|
return svgEl('rect', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: cls, transform: t });
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildNode(space, pos) {
|
||||||
|
const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event;
|
||||||
|
const isStart = space.id === this.board.startSpaceId;
|
||||||
|
const node = svgEl('g', {
|
||||||
|
class: `space space-${space.type}${isStart ? ' space-start-marker' : ''}`,
|
||||||
|
transform: `translate(${pos.x}, ${pos.y})`,
|
||||||
|
'data-space-id': space.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A few degrees of deterministic tilt per tile — hand-placed, not
|
||||||
|
// machine-stamped — applied only to the tile's shape, never its label.
|
||||||
|
const tilt = ((hashString(space.id) % 700) / 100 - 3.5).toFixed(2);
|
||||||
|
const shapeGroup = svgEl('g', { class: 'tile-shape-group', transform: `rotate(${tilt})` });
|
||||||
|
shapeGroup.appendChild(this._shapeEl(style.shape, 3, 4, 'space-shadow'));
|
||||||
|
shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-shape'));
|
||||||
|
shapeGroup.appendChild(this._shapeEl(style.shape, 0, 0, 'space-gloss'));
|
||||||
|
if (space.type === 'choice' || space.type === 'stop') {
|
||||||
|
shapeGroup.appendChild(svgEl('rect', { x: -4, y: NODE_H / 2 + 2, width: 8, height: 22, class: 'choice-post' }));
|
||||||
|
}
|
||||||
|
node.appendChild(shapeGroup);
|
||||||
|
|
||||||
|
const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H });
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'space-label';
|
||||||
|
const icon = isStart ? '🚩' : style.icon;
|
||||||
|
div.innerHTML = `<span class="space-icon">${icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
|
||||||
|
fo.appendChild(div);
|
||||||
|
node.appendChild(fo);
|
||||||
|
|
||||||
|
if (space.die) {
|
||||||
|
const badge = svgEl('text', { class: 'die-badge', y: NODE_H / 2 + 17, 'text-anchor': 'middle' });
|
||||||
|
badge.textContent = space.die;
|
||||||
|
node.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move tokens to current positions, highlight+wire up any pending choice. */
|
||||||
|
update(state, self, { onChoose } = {}) {
|
||||||
|
this.svg.querySelectorAll('.space.highlight').forEach((el) => {
|
||||||
|
el.classList.remove('highlight', 'clickable');
|
||||||
|
el.onclick = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = new Map(); // spaceId -> player[]
|
||||||
|
for (const p of Object.values(state.players)) {
|
||||||
|
if (!grouped.has(p.position)) grouped.set(p.position, []);
|
||||||
|
grouped.get(p.position).push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
for (const [position, occupants] of grouped) {
|
||||||
|
const pos = this.layout.positions.get(position);
|
||||||
|
if (!pos) continue;
|
||||||
|
occupants.forEach((p, i) => {
|
||||||
|
seen.add(p.id);
|
||||||
|
const angle = (i / occupants.length) * Math.PI * 2;
|
||||||
|
const spread = occupants.length > 1 ? 16 : 0;
|
||||||
|
const tx = pos.x + Math.cos(angle) * spread;
|
||||||
|
const ty = pos.y + NODE_H / 2 + 16 + Math.sin(angle) * (spread / 2);
|
||||||
|
|
||||||
|
let el = this.tokenEls.get(p.id);
|
||||||
|
if (!el) {
|
||||||
|
el = this._buildToken(p);
|
||||||
|
this.tokenLayer.appendChild(el);
|
||||||
|
this.tokenEls.set(p.id, el);
|
||||||
|
}
|
||||||
|
el.setAttribute('transform', `translate(${tx}, ${ty})`);
|
||||||
|
el.classList.toggle('current-turn', state.currentTurn === p.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [id, el] of this.tokenEls) {
|
||||||
|
if (!seen.has(id)) { el.remove(); this.tokenEls.delete(id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const decidingPlayer = Object.values(state.players).find((p) => p.pendingChoice);
|
||||||
|
if (decidingPlayer) {
|
||||||
|
for (const optionId of decidingPlayer.pendingChoice.options) {
|
||||||
|
const el = this.svg.querySelector(`.space[data-space-id="${cssEscape(optionId)}"]`);
|
||||||
|
if (!el) continue;
|
||||||
|
el.classList.add('highlight');
|
||||||
|
if (decidingPlayer.id === self?.playerId) {
|
||||||
|
el.classList.add('clickable');
|
||||||
|
el.onclick = () => onChoose?.(optionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildToken(player) {
|
||||||
|
const g = svgEl('g', { class: 'token' });
|
||||||
|
g.appendChild(svgEl('circle', { r: PLAYER_TOKEN_R, class: 'token-dot', fill: player.color }));
|
||||||
|
const text = svgEl('text', { class: 'token-label', 'text-anchor': 'middle', dy: '0.35em' });
|
||||||
|
text.textContent = (player.name || '?').trim().charAt(0).toUpperCase();
|
||||||
|
g.appendChild(text);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(str) {
|
||||||
|
return String(str).replace(/["\\]/g, '\\$&');
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Life Journey — Deployment Check</title>
|
<title>Life Journey</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
@@ -18,100 +18,402 @@
|
|||||||
margin: 0; min-height: 100vh; padding: 24px 16px;
|
margin: 0; min-height: 100vh; padding: 24px 16px;
|
||||||
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink);
|
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink);
|
||||||
background: radial-gradient(1200px 600px at 50% -10%, #1d493c 0%, var(--page) 45%, var(--page-2) 100%);
|
background: radial-gradient(1200px 600px at 50% -10%, #1d493c 0%, var(--page) 45%, var(--page-2) 100%);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
|
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 460px; width: 100%;
|
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%;
|
||||||
|
margin-top: 24px; transition: max-width .25s ease;
|
||||||
}
|
}
|
||||||
|
.card.wide { max-width: 1100px; }
|
||||||
h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; }
|
h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; }
|
||||||
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
|
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
|
||||||
.check {
|
label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
|
||||||
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
|
input[type="text"] {
|
||||||
border: 1.5px solid var(--line); border-radius: 12px; background: #fffaf0; margin-bottom: 10px;
|
width: 100%; padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px;
|
||||||
|
font-family: inherit; font-size: 14px; background: #fffaf0; color: var(--ink);
|
||||||
}
|
}
|
||||||
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--ink-soft); flex: none; }
|
|
||||||
.dot.ok { background: var(--good); } .dot.fail { background: var(--bad); }
|
|
||||||
.check .name { font-weight: 700; font-family: "Baloo 2"; }
|
|
||||||
.check .state { margin-left: auto; font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
|
|
||||||
button {
|
button {
|
||||||
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
|
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
|
||||||
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px;
|
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px;
|
||||||
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 8px;
|
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 10px; margin-right: 8px;
|
||||||
}
|
}
|
||||||
button:active { transform: translateY(3px); box-shadow: 0 0 0 #b97f18; }
|
button:active { transform: translateY(3px); box-shadow: 0 0 0 #b97f18; }
|
||||||
.note { font-size: 12px; color: var(--ink-soft); margin-top: 16px; line-height: 1.5; }
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
code { background: #efe5cc; padding: 1px 5px; border-radius: 5px; font-size: 12px; }
|
button.secondary { background: #efe5cc; box-shadow: 0 3px 0 var(--line); }
|
||||||
|
hr { border: none; border-top: 1.5px dashed var(--line); margin: 20px 0; }
|
||||||
|
.invite-row { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
.invite-row input { flex: 1; }
|
||||||
|
ul.player-list { list-style: none; padding: 0; margin: 12px 0; }
|
||||||
|
ul.player-list li {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||||
|
border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px;
|
||||||
|
}
|
||||||
|
ul.player-list li.active { border-color: var(--marigold); background: #fff3dc; }
|
||||||
|
.stat-line { display: block; color: var(--ink-soft); font-size: 12px; margin-top: 2px; }
|
||||||
|
.todo-mark { font-size: 11px; opacity: .8; }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Board --- */
|
||||||
|
.board-container {
|
||||||
|
overflow: auto; max-height: 620px; border-radius: 14px; margin-bottom: 14px;
|
||||||
|
background: #163a30; border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.board-svg { display: block; width: 100%; height: auto; min-width: 680px; }
|
||||||
|
|
||||||
|
.field-bg { fill: url(#fieldGradient); }
|
||||||
|
.mountain-body { fill: #8f9aa8; opacity: .5; }
|
||||||
|
.mountain-snow { fill: #e8edf2; opacity: .45; }
|
||||||
|
.river-body { fill: none; stroke: #4a90b8; stroke-width: 34; stroke-linecap: round; opacity: .5; }
|
||||||
|
.river-shine { fill: none; stroke: #a8d8ea; stroke-width: 8; stroke-linecap: round; opacity: .4; }
|
||||||
|
.tree-canopy-a { fill: #2f6b52; opacity: .6; }
|
||||||
|
.tree-canopy-b { fill: #3a7a5e; opacity: .55; }
|
||||||
|
.tree-trunk { fill: #4a3a2a; opacity: .5; }
|
||||||
|
|
||||||
|
.road-shadow {
|
||||||
|
fill: none; stroke: #0f2a23; stroke-width: 15; stroke-linecap: round;
|
||||||
|
opacity: .3; transform: translate(0, 3px);
|
||||||
|
}
|
||||||
|
.road-base { fill: none; stroke: #caa15a; stroke-width: 13; stroke-linecap: round; filter: url(#roadWobble); }
|
||||||
|
.road-centerline {
|
||||||
|
fill: none; stroke: #f0dcae; stroke-width: 2.5; stroke-linecap: round;
|
||||||
|
stroke-dasharray: 8 10; opacity: .85; filter: url(#roadWobble);
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-shadow { fill: rgba(0,0,0,.32); }
|
||||||
|
.space-gloss { fill: url(#tileGloss); pointer-events: none; }
|
||||||
|
.choice-post { fill: #8a6a3c; stroke: #5f462a; stroke-width: 1; }
|
||||||
|
.space-shape {
|
||||||
|
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0,0,0,.25));
|
||||||
|
}
|
||||||
|
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
|
||||||
|
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
|
||||||
|
.space-stop .space-shape { fill: #d6553b; stroke: #8f3120; }
|
||||||
|
.space-payday .space-shape { fill: #d7ecd0; stroke: #3f8f5f; }
|
||||||
|
.space-cash_bonus .space-shape { fill: #fbe4b8; stroke: #b97f18; }
|
||||||
|
.space-action_space .space-shape,
|
||||||
|
.space-dice_space .space-shape,
|
||||||
|
.space-roll_table_ref .space-shape { fill: #cfe0f0; stroke: #3f6ea5; }
|
||||||
|
.space-inline_table .space-shape { fill: #dde8f2; stroke: #7a97b5; stroke-dasharray: 3 2; }
|
||||||
|
.space-event .space-shape { fill: #fdf6e3; stroke: #c9b98a; }
|
||||||
|
.space-label {
|
||||||
|
width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; text-align: center; font-family: "Baloo 2"; font-weight: 700;
|
||||||
|
font-size: 10.5px; line-height: 1.15; color: var(--ink); pointer-events: none; gap: 1px;
|
||||||
|
}
|
||||||
|
.space-choice .space-label, .space-stop .space-label { color: #fff; }
|
||||||
|
.space-icon { font-size: 15px; }
|
||||||
|
.space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.die-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 10px; fill: var(--ink-soft); }
|
||||||
|
.space.highlight .space-shape {
|
||||||
|
stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.space.clickable { cursor: pointer; }
|
||||||
|
.space.clickable:hover .space-shape { filter: drop-shadow(0 0 10px #fff2c4); }
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
0%, 100% { filter: drop-shadow(0 0 2px #fff2c4); }
|
||||||
|
50% { filter: drop-shadow(0 0 12px #ffdc73); }
|
||||||
|
}
|
||||||
|
.token { pointer-events: none; transition: transform .5s cubic-bezier(.34,1.4,.64,1); }
|
||||||
|
.token-dot { stroke: #fff; stroke-width: 2; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); }
|
||||||
|
.token-label { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; fill: #fff; }
|
||||||
|
.token.current-turn .token-dot { animation: tokenBounce 1s ease-in-out infinite; }
|
||||||
|
@keyframes tokenBounce {
|
||||||
|
0%, 100% { r: 12; } 50% { r: 14.5; }
|
||||||
|
}
|
||||||
|
#dice-icon { display: inline-block; }
|
||||||
|
#dice-icon.spin { animation: diceSpin .55s ease-out; }
|
||||||
|
@keyframes diceSpin {
|
||||||
|
0% { transform: rotate(0deg) scale(1); }
|
||||||
|
50% { transform: rotate(200deg) scale(1.25); }
|
||||||
|
100% { transform: rotate(360deg) scale(1); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card" id="app">
|
||||||
<h1>Life Journey</h1>
|
<h1>Life Journey</h1>
|
||||||
<p class="sub">Deployment check · Phase 0 — no game here yet</p>
|
<p class="sub" id="subtitle">Async multiplayer — create a room or join one via invite link.</p>
|
||||||
|
|
||||||
<div class="check">
|
<section id="panel-entry">
|
||||||
<span class="dot" id="http-dot"></span>
|
<label for="name-input">Your name</label>
|
||||||
<span class="name">Server (HTTP)</span>
|
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
|
||||||
<span class="state" id="http-state">checking…</span>
|
|
||||||
|
<div id="create-section">
|
||||||
|
<button id="create-btn">Create Game</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check">
|
<hr />
|
||||||
<span class="dot" id="ws-dot"></span>
|
|
||||||
<span class="name">WebSocket</span>
|
<label for="join-code-input">Game code</label>
|
||||||
<span class="state" id="ws-state">connecting…</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button id="ping">Send WebSocket ping</button>
|
<hr />
|
||||||
|
|
||||||
<p class="note">
|
<label>Players</label>
|
||||||
Both dots green means the shell is deployed correctly and your reverse
|
<ul class="player-list" id="player-list"></ul>
|
||||||
proxy is passing WebSockets. If the WebSocket dot is red but HTTP is green,
|
<button id="start-btn" hidden>Start Game</button>
|
||||||
enable <code>Websockets Support</code> on the proxy host in Nginx Proxy Manager.
|
<p class="hint" id="lobby-hint"></p>
|
||||||
</p>
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-game" hidden>
|
||||||
|
<div class="turn-banner" id="turn-banner"></div>
|
||||||
|
<div class="board-container" id="board-container"></div>
|
||||||
|
<ul class="player-list" id="game-player-list"></ul>
|
||||||
|
<div>
|
||||||
|
<button id="roll-btn" hidden><span id="dice-icon">🎲</span> Roll</button>
|
||||||
|
<div class="choice-buttons" id="choice-buttons"></div>
|
||||||
|
</div>
|
||||||
|
<div class="win-banner" id="win-banner" hidden></div>
|
||||||
|
<div class="log" id="log-feed"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="error-note" id="error-note" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module">
|
||||||
// ---- HTTP health check ----
|
import {
|
||||||
const httpDot = document.getElementById('http-dot');
|
createGame, joinGame, saveSession, loadSession,
|
||||||
const httpState = document.getElementById('http-state');
|
NetworkTransport, getLegalIntents, board,
|
||||||
fetch('/api/health')
|
} from '/client.js';
|
||||||
.then(r => r.json())
|
import { BoardView } from '/boardRender.js';
|
||||||
.then(d => {
|
|
||||||
httpDot.classList.add('ok');
|
|
||||||
httpState.textContent = `ok · v${d.version}`;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
httpDot.classList.add('fail');
|
|
||||||
httpState.textContent = 'unreachable';
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- WebSocket check ----
|
const els = {
|
||||||
const wsDot = document.getElementById('ws-dot');
|
app: document.getElementById('app'),
|
||||||
const wsState = document.getElementById('ws-state');
|
subtitle: document.getElementById('subtitle'),
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
panelEntry: document.getElementById('panel-entry'),
|
||||||
let ws;
|
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'),
|
||||||
|
boardContainer: document.getElementById('board-container'),
|
||||||
|
gamePlayerList: document.getElementById('game-player-list'),
|
||||||
|
rollBtn: document.getElementById('roll-btn'),
|
||||||
|
diceIcon: document.getElementById('dice-icon'),
|
||||||
|
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;
|
||||||
|
const boardView = new BoardView(els.boardContainer, board);
|
||||||
|
|
||||||
|
function showPanel(name) {
|
||||||
|
els.panelEntry.hidden = name !== 'entry';
|
||||||
|
els.panelLobby.hidden = name !== 'lobby';
|
||||||
|
els.panelGame.hidden = name !== 'game';
|
||||||
|
els.app.classList.toggle('wide', name === 'game');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
els.errorNote.textContent = message;
|
||||||
|
els.errorNote.hidden = false;
|
||||||
|
clearTimeout(errorTimer);
|
||||||
|
errorTimer = setTimeout(() => { els.errorNote.hidden = true; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
transport = new NetworkTransport();
|
||||||
|
transport.onState((msg) => render(msg.state));
|
||||||
|
transport.onError((msg) => showError(msg.message));
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
await transport.connect(self.sessionToken);
|
||||||
ws.onopen = () => { wsDot.classList.add('ok'); wsState.textContent = 'connected'; };
|
} catch (err) {
|
||||||
ws.onmessage = (e) => {
|
showError(`Could not connect: ${err.message}`);
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('ping').onclick = () => {
|
function render(state) {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (state.status === 'lobby') renderLobby(state);
|
||||||
ws.send('ping ' + new Date().toISOString());
|
else renderGame(state);
|
||||||
wsState.textContent = 'ping sent…';
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
boardView.update(state, self, {
|
||||||
|
onChoose: (spaceId) => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId }),
|
||||||
|
});
|
||||||
|
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)}
|
||||||
|
<span class="stat-line">💵$${p.cash} · ❤️${p.love} · 🎓${p.education} · 💎${p.wealth} · 🎂${p.age}</span>`;
|
||||||
|
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 text = entry.description || entry.label;
|
||||||
|
const todoMark = entry.todo ? ' <span class="todo-mark" title="Placeholder content">⚠️</span>' : '';
|
||||||
|
return `<div>${escapeHtml(p?.name ?? '?')} → ${escapeHtml(text)}${todoMark}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (state.status === 'finished') {
|
||||||
|
els.winBanner.hidden = false;
|
||||||
|
els.winBanner.textContent = state.winnerId === self.playerId
|
||||||
|
? '🎉 You win!'
|
||||||
|
: `🏁 ${state.players[state.winnerId]?.name ?? 'Someone'} wins!`;
|
||||||
|
els.rollBtn.hidden = true;
|
||||||
|
els.choiceButtons.innerHTML = '';
|
||||||
} else {
|
} else {
|
||||||
wsState.textContent = 'not connected';
|
els.winBanner.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterAuth(code, session) {
|
||||||
|
self = { code, ...session };
|
||||||
|
saveSession(code, session);
|
||||||
|
history.replaceState(null, '', `/join/${code}`);
|
||||||
|
return connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
els.createBtn.onclick = async () => {
|
||||||
|
try {
|
||||||
|
const hostName = els.nameInput.value;
|
||||||
|
const result = await createGame(hostName);
|
||||||
|
await afterAuth(result.code, {
|
||||||
|
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
els.joinBtn.onclick = async () => {
|
||||||
|
try {
|
||||||
|
const code = els.joinCodeInput.value.trim().toUpperCase();
|
||||||
|
const name = els.nameInput.value;
|
||||||
|
if (!code) throw new Error('Enter a game code');
|
||||||
|
const result = await joinGame(code, name);
|
||||||
|
await afterAuth(code, {
|
||||||
|
gameId: result.gameId, playerId: result.playerId, sessionToken: result.sessionToken,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
|
||||||
|
els.rollBtn.onclick = () => {
|
||||||
|
els.diceIcon.classList.remove('spin');
|
||||||
|
void els.diceIcon.offsetWidth; // restart the animation even on rapid re-clicks
|
||||||
|
els.diceIcon.classList.add('spin');
|
||||||
|
transport.sendIntent({ type: 'REQUEST_ROLL' });
|
||||||
|
};
|
||||||
|
els.copyLinkBtn.onclick = () => {
|
||||||
|
els.inviteLink.select();
|
||||||
|
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const match = location.pathname.match(/^\/join\/([^/]+)/);
|
||||||
|
const codeFromUrl = match ? decodeURIComponent(match[1]) : null;
|
||||||
|
|
||||||
|
if (codeFromUrl) {
|
||||||
|
const existing = loadSession(codeFromUrl);
|
||||||
|
if (existing) {
|
||||||
|
self = { code: codeFromUrl, ...existing };
|
||||||
|
await connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.joinCodeInput.value = codeFromUrl;
|
||||||
|
els.joinCodeInput.readOnly = true;
|
||||||
|
els.createSection.hidden = true;
|
||||||
|
els.subtitle.textContent = `Joining game ${codeFromUrl} — enter your name below.`;
|
||||||
|
}
|
||||||
|
showPanel('entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Life Journey — Phase 0 deployment shell.
|
* Life Journey — Phase 1 server.
|
||||||
*
|
*
|
||||||
* This does the minimum needed to prove the plumbing works end to end:
|
* The server is the sole authority over game state: it validates client
|
||||||
* - serves the static frontend
|
* intents, generates the only source of randomness (the dice roll), builds
|
||||||
* - answers GET /api/health (is the server reachable?)
|
* the real action, and applies it through the exact same shared reducer the
|
||||||
* - accepts a WebSocket on /ws (does WS survive the reverse proxy?)
|
* browser imports. Rooms/tokens/state are persisted to SQLite so a restart
|
||||||
*
|
* (or a closed tab reconnecting later) resumes rather than resets.
|
||||||
* There is deliberately NO game logic here yet. Phase 1 adds the shared
|
|
||||||
* game.js reducer, a SQLite-backed data model, rooms, and real actions.
|
|
||||||
* The data directory is created now so the Docker volume mount is validated.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
@@ -18,46 +15,220 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
import { board } from '../shared/board.js';
|
||||||
|
import { createInitialState } from '../shared/game.js';
|
||||||
|
import * as db from './db.js';
|
||||||
|
import * as rooms from './rooms.js';
|
||||||
|
import { randomId, randomToken, randomJoinCode } from './ids.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||||
|
|
||||||
// Ensure the persisted data directory exists (future: SQLite db + token uploads).
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 8,
|
||||||
|
startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 },
|
||||||
|
};
|
||||||
|
// Deliberately avoids the board's own semantic colors (green=start,
|
||||||
|
// marigold=finish, red=choice) so a player's token never blends into a space.
|
||||||
|
const PLAYER_COLORS = [
|
||||||
|
'#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c', '#4a4a9f', '#8a8a3f',
|
||||||
|
];
|
||||||
|
const AUTH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use((err, _req, res, next) => {
|
||||||
|
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Invalid JSON' });
|
||||||
|
next(err);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Health check: lets the frontend (and you) confirm the server is up ---
|
// --- Health check ---
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({
|
res.json({ ok: true, service: 'life-journey', phase: 1, version: '0.1.0', time: new Date().toISOString() });
|
||||||
ok: true,
|
});
|
||||||
service: 'life-journey',
|
|
||||||
phase: 0,
|
// --- Helpers ---
|
||||||
version: '0.0.1',
|
function cleanName(raw, label) {
|
||||||
time: new Date().toISOString(),
|
const name = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
if (!name) throw Object.assign(new Error(`${label} is required`), { status: 400 });
|
||||||
|
if (name.length > 40) throw Object.assign(new Error(`${label} is too long`), { status: 400 });
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueJoinCode() {
|
||||||
|
let code;
|
||||||
|
do {
|
||||||
|
code = randomJoinCode();
|
||||||
|
} while (db.getGameByCode(code));
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInviteUrl(req, code) {
|
||||||
|
const base = (process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
||||||
|
return `${base}/join/${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForSeat(seat) {
|
||||||
|
return PLAYER_COLORS[(seat - 1) % PLAYER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- REST: games/rooms ---
|
||||||
|
app.post('/api/games', (req, res) => {
|
||||||
|
let hostName;
|
||||||
|
try {
|
||||||
|
hostName = cleanName(req.body?.hostName, 'hostName');
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status ?? 400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameId = randomId();
|
||||||
|
const code = uniqueJoinCode();
|
||||||
|
const initialState = createInitialState(DEFAULT_CONFIG);
|
||||||
|
db.createGame({ id: gameId, code, boardId: board.id, config: DEFAULT_CONFIG, state: initialState });
|
||||||
|
|
||||||
|
const room = rooms.loadOrCreateRoom(gameId);
|
||||||
|
const playerId = randomId();
|
||||||
|
const color = colorForSeat(1);
|
||||||
|
db.createPlayer({ id: playerId, gameId, name: hostName, seat: 1, color });
|
||||||
|
|
||||||
|
let state;
|
||||||
|
try {
|
||||||
|
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name: hostName, seat: 1, color });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = randomToken();
|
||||||
|
db.createToken({ token: sessionToken, gameId, playerId, kind: 'session' });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
gameId,
|
||||||
|
code,
|
||||||
|
inviteUrl: buildInviteUrl(req, code),
|
||||||
|
playerId,
|
||||||
|
sessionToken,
|
||||||
|
state,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/games/:code', (req, res) => {
|
||||||
|
const game = db.getGameByCode(req.params.code);
|
||||||
|
if (!game) return res.status(404).json({ error: 'Game not found' });
|
||||||
|
res.json({
|
||||||
|
gameId: game.id,
|
||||||
|
code: game.code,
|
||||||
|
status: game.status,
|
||||||
|
config: game.config,
|
||||||
|
players: db.getPlayersByGame(game.id),
|
||||||
|
state: game.state,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/games/:code/join', (req, res) => {
|
||||||
|
const game = db.getGameByCode(req.params.code);
|
||||||
|
if (!game) return res.status(404).json({ error: 'Game not found' });
|
||||||
|
if (game.status !== 'lobby') return res.status(409).json({ error: 'Game already started' });
|
||||||
|
|
||||||
|
let name;
|
||||||
|
try {
|
||||||
|
name = cleanName(req.body?.name, 'name');
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status ?? 400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = db.countPlayers(game.id) + 1;
|
||||||
|
if (seat > game.config.maxPlayers) return res.status(409).json({ error: 'Game is full' });
|
||||||
|
|
||||||
|
const room = rooms.loadOrCreateRoom(game.id);
|
||||||
|
const playerId = randomId();
|
||||||
|
const color = colorForSeat(seat);
|
||||||
|
db.createPlayer({ id: playerId, gameId: game.id, name, seat, color });
|
||||||
|
|
||||||
|
let state;
|
||||||
|
try {
|
||||||
|
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name, seat, color });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(409).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = randomToken();
|
||||||
|
db.createToken({ token: sessionToken, gameId: game.id, playerId, kind: 'session' });
|
||||||
|
|
||||||
|
res.status(201).json({ gameId: game.id, playerId, sessionToken, state });
|
||||||
|
});
|
||||||
|
|
||||||
// --- Static frontend ---
|
// --- Static frontend ---
|
||||||
const publicDir = path.join(__dirname, '..', 'public');
|
const publicDir = path.join(__dirname, '..', 'public');
|
||||||
app.use(express.static(publicDir));
|
app.use(express.static(publicDir));
|
||||||
|
app.use('/shared', express.static(path.join(__dirname, '..', 'shared')));
|
||||||
|
|
||||||
|
// Deep link for invite URLs — express.static won't match this path.
|
||||||
|
app.get('/join/:code', (_req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
// --- WebSocket smoke test on /ws ---
|
// --- WebSocket: room-aware, authenticated via a first {type:'AUTH'} message ---
|
||||||
// If this connects through your domain, Nginx Proxy Manager is passing the
|
// (not a query param, so the session token never lands in access/proxy logs)
|
||||||
// Upgrade/Connection headers correctly ("Websockets Support" is ON). Phase 1
|
|
||||||
// reuses this exact endpoint to push game state to each device.
|
|
||||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
ws.send(JSON.stringify({ type: 'welcome', msg: 'WebSocket connected' }));
|
let authenticated = false;
|
||||||
|
let room = null;
|
||||||
|
let playerId = null;
|
||||||
|
|
||||||
|
const authTimeout = setTimeout(() => {
|
||||||
|
if (!authenticated) ws.close(4001, 'auth timeout');
|
||||||
|
}, AUTH_TIMEOUT_MS);
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
ws.send(JSON.stringify({ type: 'echo', received: raw.toString() }));
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
if (msg?.type !== 'AUTH' || typeof msg.token !== 'string') {
|
||||||
|
ws.close(4001, 'expected AUTH');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tokenRow = db.getToken(msg.token);
|
||||||
|
if (!tokenRow || tokenRow.kind !== 'session') {
|
||||||
|
ws.close(4001, 'invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let targetRoom;
|
||||||
|
try {
|
||||||
|
targetRoom = rooms.loadOrCreateRoom(tokenRow.game_id);
|
||||||
|
} catch {
|
||||||
|
ws.close(4004, 'game not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(authTimeout);
|
||||||
|
authenticated = true;
|
||||||
|
room = targetRoom;
|
||||||
|
playerId = tokenRow.player_id;
|
||||||
|
rooms.attachSocket(room, ws, playerId);
|
||||||
|
db.touchPlayerLastSeen(playerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof msg?.type !== 'string') return;
|
||||||
|
rooms.handleIntent(room, ws, playerId, msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => clearTimeout(authTimeout));
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Life Journey (Phase 0) listening on :${PORT}`);
|
console.log(`Life Journey (Phase 1) listening on :${PORT}`);
|
||||||
console.log(`Data directory: ${DATA_DIR}`);
|
console.log(`Data directory: ${DATA_DIR}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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 { board } from '../shared/board.js';
|
||||||
|
import { rollsNeededFor, DIE_SIZES } from '../shared/tileEffects.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': {
|
||||||
|
const player = state.players[playerId];
|
||||||
|
const nextSpaceId = board.spaces[player.position].next;
|
||||||
|
if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board');
|
||||||
|
return { type: 'ROLL', playerId, rolls: rollForSpace(board.spaces[nextSpaceId]) };
|
||||||
|
}
|
||||||
|
case 'REQUEST_CHOOSE': {
|
||||||
|
if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required');
|
||||||
|
return { type: 'CHOOSE', playerId, spaceId: intent.spaceId, rolls: rollForSpace(board.spaces[intent.spaceId]) };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown intent: ${intent.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-roll whatever dice `space` needs to resolve its own tile effect — the
|
||||||
|
* only place actual randomness happens. rollsNeededFor() never touches
|
||||||
|
* randomness itself, it just says which die sizes are needed and in what
|
||||||
|
* order; reduce() applies the results deterministically. */
|
||||||
|
function rollForSpace(space) {
|
||||||
|
return rollsNeededFor(space).map((die) => {
|
||||||
|
const max = DIE_SIZES[die];
|
||||||
|
if (!max) throw new Error(`Unknown die size: ${die}`);
|
||||||
|
return 1 + crypto.randomInt(max);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Life Journey — Branching / Adjacency Template
|
||||||
|
|
||||||
|
The design doc lists tiles **in order within each segment**, but it does not say
|
||||||
|
how the twelve segments connect, or where the forks and merges are. That wiring
|
||||||
|
is what turns a pile of tile-lists into a playable board graph. Fill this in and
|
||||||
|
I'll turn it into a connection map the game can walk.
|
||||||
|
|
||||||
|
## The twelve segments (as parsed, in doc order)
|
||||||
|
|
||||||
|
| # | Segment id | Tiles | First tile → Last tile |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `starting_strip` | 7 | Graduation party → STOP |
|
||||||
|
| 2 | `career` | 22 | Pay Day → STOP |
|
||||||
|
| 3 | `investment` | 20 | Pay Day → Dice Space |
|
||||||
|
| 4 | `investment_bottom` | 23 | Pay Day → Dice Space |
|
||||||
|
| 5 | `high_risk` | 11 | Dice Space → Dice Space |
|
||||||
|
| 6 | `gap_year` | 17 | Pay Day → STOP |
|
||||||
|
| 7 | `education` | 12 | Pay Day → Graduation Gift |
|
||||||
|
| 8 | `relationship_top` | 20 | Pay Day → Aging Parents |
|
||||||
|
| 9 | `relationship_bottom` | 26 | Pay Day → … |
|
||||||
|
| 10 | `retirement_top` | 11 | Pay Day → Hobby |
|
||||||
|
| 11 | `retirement_middle` | 22 | ½ Life Crisis → … |
|
||||||
|
| 12 | `retirement_bottom` | 20 | Pay Day → … |
|
||||||
|
|
||||||
|
The `(top)/(bottom)/(middle)` names strongly suggest parallel lanes on the
|
||||||
|
physical board — that's exactly the geometry only your layout knows.
|
||||||
|
|
||||||
|
## What I need — two things
|
||||||
|
|
||||||
|
### 1. The STOP forks
|
||||||
|
|
||||||
|
There are **3 STOP tiles** (end of `starting_strip`, `career`, `gap_year`). Each
|
||||||
|
says "roll D8 to age, then pick your new path." Tell me the choices at each:
|
||||||
|
|
||||||
|
```
|
||||||
|
STOP after starting_strip → player may choose: [ career | education | gap_year ] ← confirm/edit
|
||||||
|
STOP after career → player may choose: [ ? | ? | ? ]
|
||||||
|
STOP after gap_year → player may choose: [ ? | ? | ? ]
|
||||||
|
```
|
||||||
|
|
||||||
|
(Do investment / high_risk / relationship / retirement also branch off a STOP,
|
||||||
|
or are they entered some other way? Note it.)
|
||||||
|
|
||||||
|
### 2. Segment connections (the graph)
|
||||||
|
|
||||||
|
For **each segment**, tell me what its **entry** connects from and what its
|
||||||
|
**exit** connects to. Easiest format — just fill the arrows, referencing segment
|
||||||
|
ids and tile numbers (0-indexed within the segment):
|
||||||
|
|
||||||
|
```
|
||||||
|
career: enters from STOP#1 → exits to STOP#2
|
||||||
|
investment: enters from ?? → exits to ??
|
||||||
|
investment_bottom: enters from ?? → exits to ??
|
||||||
|
high_risk: enters from ?? → exits to ??
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If a segment has a **mid-path fork or merge** (e.g. tile 8 of `career` splits to
|
||||||
|
`investment` tile 0 AND continues to tile 9), write it like:
|
||||||
|
|
||||||
|
```
|
||||||
|
career tile 8 → forks to: career tile 9 OR investment tile 0
|
||||||
|
relationship_top tile 19 → merges into retirement_top tile 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't worry about being formal — bullet points in plain English are fine. Photos
|
||||||
|
or a rough hand-drawn arrow diagram of the board work too; I'll translate.
|
||||||
|
|
||||||
|
## What happens after you send this
|
||||||
|
|
||||||
|
I convert it into a `board-graph.json` — every tile gets a `next` (or list of
|
||||||
|
`next` for forks) so the game can move tokens along real paths. **Only then** do
|
||||||
|
coordinates get authored (against the real artwork), because a token's screen
|
||||||
|
position and its graph position are two different things and both need the final
|
||||||
|
layout to exist first.
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
# Life Journey — Game Content Review
|
||||||
|
|
||||||
|
A readable summary of everything parsed from the design doc, for checking and filling in. The game tracks five things per player: **cash ($), Love, Education, Wealth, Age.**
|
||||||
|
|
||||||
|
**211 tiles** across **12 segments**, referencing **48 roll tables** that still need real content.
|
||||||
|
|
||||||
|
## Board segments and tiles
|
||||||
|
|
||||||
|
### Starting Strip (7 tiles)
|
||||||
|
|
||||||
|
0. **Graduation party** — _event_
|
||||||
|
1. **Action space** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
2. **First Paycheck** — _choice_
|
||||||
|
3. **Dice roll** — _dice_space_ · ⤷ roll table
|
||||||
|
4. **Underground poker** — _choice_ · D20
|
||||||
|
5. **New job** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
6. **STOP** — _stop_ · D8
|
||||||
|
|
||||||
|
### Career Path (22 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **RECRUITER CALLS** — _choice_
|
||||||
|
2. **PERFORMANCE REVIEW** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
3. **NEW HIRE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
4. **START A BUSINESS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
5. **OFFICE BAR TRIVIA** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
6. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
7. **COMPANY PERKS** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
8. **THE NEWS** — _event_ · D20
|
||||||
|
9. **NEW JOB** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
10. **COMPANY RACE DAY** — _inline_table_ · D10
|
||||||
|
11. **OPPORTUNITY KNOCKS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
12. **CLIENT DINNER** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
13. **SHENANIGANS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
14. **OFFICE CHRISTMAS PARTY** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
15. **MENTORSHIP** — _choice_
|
||||||
|
16. **BIG CAREER MOMENT** — _event_
|
||||||
|
17. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
18. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
19. **PERFORMANCE REVIEW** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
20. **START A BUSINESS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
21. **STOP** — _stop_ · D8
|
||||||
|
|
||||||
|
### Investment Path (20 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
2. **SIDE INVESTMENT** — _inline_table_ · D20
|
||||||
|
3. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
4. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
5. **ESTATE AUCTION** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
6. **MARKET CRASH** — _inline_table_ · D20
|
||||||
|
7. **FORCED PARTNERSHIP** — _choice_
|
||||||
|
8. **401k** — _cash_bonus_ · D20
|
||||||
|
9. **SEMINAR** — _inline_table_ · D20
|
||||||
|
10. **ANGEL INVESTOR** — _inline_table_ · D20
|
||||||
|
11. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
12. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
13. **BLIND INVESTMENT** — _inline_table_ · D6
|
||||||
|
14. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
15. **BANKRUPTCY** — _inline_table_ · D20
|
||||||
|
16. **MARKET MAYHEM** — _choice_
|
||||||
|
17. **FINANCIAL ADVISOR** — _inline_table_ · D20
|
||||||
|
18. **PAY DAY** — _payday_
|
||||||
|
19. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
|
||||||
|
### Investment Path (Bottom) (23 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
2. **SIDE INVESTMENT** — _inline_table_ · D20
|
||||||
|
3. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
4. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
5. **START A BUSINESS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
6. **MARKET CRASH** — _inline_table_ · D20
|
||||||
|
7. **ESTATE AUCTION** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
8. **401k** — _cash_bonus_ · D20
|
||||||
|
9. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
10. **ANGEL INVESTOR** — _inline_table_ · D20
|
||||||
|
11. **FORCED PARTNERSHIP** — _choice_
|
||||||
|
12. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
13. **BANKRUPTCY** — _inline_table_ · D20
|
||||||
|
14. **BLIND INVESTMENT** — _inline_table_ · D6
|
||||||
|
15. **PAY DAY** — _payday_
|
||||||
|
16. **BET AGAINST ANOTHER PLAYER** — _choice_
|
||||||
|
17. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
18. **MARKET MAYHEM** — _event_
|
||||||
|
19. **FORCE SALE** — _inline_table_ · D20
|
||||||
|
20. **START A BUSINESS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
21. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
22. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
|
||||||
|
### High Risk Path (11 tiles)
|
||||||
|
|
||||||
|
0. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
1. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
2. **BET AGAINST ANOTHER PLAYER** — _choice_
|
||||||
|
3. **PAY DAY** — _payday_
|
||||||
|
4. **LEVERAGED BUYOUT** — _inline_table_ · D20
|
||||||
|
5. **100K** — _cash_bonus_
|
||||||
|
6. **INVESTEGATION** — _inline_table_ · D20
|
||||||
|
7. **BLIND INVESTMENT** — _inline_table_ · D6
|
||||||
|
8. **ROUGE TRADER** — _inline_table_ · D20
|
||||||
|
9. **10K** — _cash_bonus_
|
||||||
|
10. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
|
||||||
|
### Gap Year Path (17 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **SHENANIGANS** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
2. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
3. **TREASURE HUNT (NEED TO MAKE TREASURE MAP)** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
4. **LOST IN TRANSLATION** — _event_
|
||||||
|
5. **MEXICO** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
6. **AIRPORT SECURITY** — _inline_table_ · D20
|
||||||
|
7. **AUSTRALIA** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
8. **PAY DAY** — _payday_
|
||||||
|
9. **JAPAN** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
10. **ACCENT ROULETTE** — _event_
|
||||||
|
11. **IRELAND** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
12. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
13. **GERMANY** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
14. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
15. **ITALY** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
16. **STOP** — _stop_ · D8
|
||||||
|
|
||||||
|
### Education Path (12 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **SCHOLARSHIP** — _inline_table_ · D20
|
||||||
|
2. **4 YEAR DAGREE** — _event_
|
||||||
|
3. **COMMUNITY COLLAGE** — _event_
|
||||||
|
4. **APPRENTICESHIP** — _inline_table_ · D20
|
||||||
|
5. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
6. **STUDY ABROD** — _inline_table_ · D20
|
||||||
|
7. **POP QUIZ** — _event_
|
||||||
|
8. **EXTRACURRICULAR** — _roll_table_ref_ · D20 · ⤷ roll table
|
||||||
|
9. **ONLINE COURSE** — _inline_table_ · D20
|
||||||
|
10. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
11. **GRADUATION GIFT** — _inline_table_ · D20
|
||||||
|
|
||||||
|
### Relationship/Family (Top) (20 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
2. **ADOPT A PET** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
3. **UNCLES CONDO** — _inline_table_ · D20
|
||||||
|
4. **NEW CITY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
5. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
6. **HAVE A BABY** — _inline_table_ · D2
|
||||||
|
7. **DINNER PARTY** — _inline_table_ · D20
|
||||||
|
8. **MARRIGE** — _choice_
|
||||||
|
9. **WHITE ELEPHANT** — _inline_table_ · D6
|
||||||
|
10. **FRIENDS WEDDING** — _inline_table_ · D20
|
||||||
|
11. **NEW JOB** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
12. **PODCAST** — _inline_table_ · D20
|
||||||
|
13. **HOBBY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
14. **PERFECT IMPRESSION** — _inline_table_ · D20
|
||||||
|
15. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
16. **FAMILY REUNION** — _inline_table_ · D20
|
||||||
|
17. **HAVE A BABY** — _inline_table_ · D2
|
||||||
|
18. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
19. **AGING PARENTS** — _inline_table_ · D20
|
||||||
|
|
||||||
|
### Relationship/Family (Bottom) (26 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **UNCLES CONDO** — _inline_table_ · D20
|
||||||
|
2. **ADOPT A PET** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
3. **RISKY MOVE** — _inline_table_ · D20
|
||||||
|
4. **NEW CITY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
5. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
6. **HAVE A BABY** — _inline_table_ · D2
|
||||||
|
7. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
8. **SIBLINGS WEDDING** — _inline_table_ · D20
|
||||||
|
9. **NEIGHBORS PET** — _inline_table_ · D20
|
||||||
|
10. **SIDE HUSTLE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
11. **BUY A HOUSE** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
12. **HOBBY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
13. **HAVE A BABY** — _inline_table_ · D2
|
||||||
|
14. **PODCAST** — _inline_table_ · D20
|
||||||
|
15. **MARRIGE** — _choice_
|
||||||
|
16. **PAY DAY** — _payday_
|
||||||
|
17. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
18. **FAMILY VACATION** — _inline_table_ · D20
|
||||||
|
19. **ADOPT A PET** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
20. **FAMILY REUNION** — _inline_table_ · D20
|
||||||
|
21. **NEW JOB** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
22. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
23. **HOUSE MAINTENANCE** — _inline_table_ · D20
|
||||||
|
24. **HAVE A BABY** — _inline_table_ · D2
|
||||||
|
25. **AGING PARENTS** — _inline_table_ · D20
|
||||||
|
|
||||||
|
### Retirement Path (Top) (11 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **INVESTMENT PAID OFF** — _inline_table_ · D20
|
||||||
|
2. **START A BAND** — _inline_table_ · D20
|
||||||
|
3. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
4. **JURY DUTY** — _inline_table_ · D20
|
||||||
|
5. **HOBBY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
6. **THE RACES** — _inline_table_ · D10
|
||||||
|
7. **NEW JOB** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
8. **BUCKET LIST TRIP** — _inline_table_ · D20
|
||||||
|
9. **HOME RENOVATIONS** — _inline_table_ · D20
|
||||||
|
10. **HOSTED THANKSGIVING** — _inline_table_ · D20
|
||||||
|
|
||||||
|
### Retirement Path (Middle) (22 tiles)
|
||||||
|
|
||||||
|
0. **½ LIFE CRISIS** — _inline_table_ · D20
|
||||||
|
1. **HORRIBLE HANGOVER** — _inline_table_ · D20
|
||||||
|
2. **40TH BIRTHDAY** — _inline_table_ · D20
|
||||||
|
3. **MYSTERY TATTOO** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
4. **UNEXPECTED JOY** — _inline_table_ · D20
|
||||||
|
5. **PAY DAY** — _payday_
|
||||||
|
6. **HOBBY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
7. **HOST CHRISTMAS** — _inline_table_ · D20
|
||||||
|
8. **BUCKET LIST** — _inline_table_ · D20
|
||||||
|
9. **RETIREMENT PARTY** — _inline_table_ · D20
|
||||||
|
10. **SELL HOUSE** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
11. **DID YOU HEAR?** — _inline_table_ · D20
|
||||||
|
12. **LOTTERY** — _inline_table_ · D20
|
||||||
|
13. **FANTASY FOOTBALL** — _inline_table_ · D20
|
||||||
|
14. **HIGH SCHOOL REUNION** — _inline_table_ · D20
|
||||||
|
15. **POKER NIGHT** — _inline_table_ · D20
|
||||||
|
16. **PICKEL BALL** — _inline_table_ · D20
|
||||||
|
17. **BOWLING NIGHT** — _inline_table_ · D20
|
||||||
|
18. **INVESTMENT PAID OFF** — _inline_table_ · D20
|
||||||
|
19. **DICE SPACE** — _dice_space_ · D100 · ⤷ roll table
|
||||||
|
20. **PAY DAY** — _payday_
|
||||||
|
21. **RETIREMENT PARTY** — _inline_table_ · D20
|
||||||
|
|
||||||
|
### Retirement Path (Bottom) (20 tiles)
|
||||||
|
|
||||||
|
0. **PAY DAY** — _payday_
|
||||||
|
1. **HOBBY** — _roll_table_ref_ · D100 · ⤷ roll table
|
||||||
|
2. **FANTASY FOOTBALL** — _inline_table_ · D20
|
||||||
|
3. **HOSTED THANKSGIVING** — _inline_table_ · D20
|
||||||
|
4. **HIGH SCHOOL REUNION** — _inline_table_ · D20
|
||||||
|
5. **SPEEDING TICKET** — _inline_table_ · D20
|
||||||
|
6. **BOWLING NIGHT** — _inline_table_ · D20
|
||||||
|
7. **JURY DUTY** — _inline_table_ · D20
|
||||||
|
8. **PICKEL BALL** — _inline_table_ · D20
|
||||||
|
9. **THE RACES** — _inline_table_ · D10
|
||||||
|
10. **NEW JOB** — _roll_table_ref_ · ⤷ roll table
|
||||||
|
11. **POKER NIGHT** — _inline_table_ · D20
|
||||||
|
12. **HOME RENOVATIONS** — _inline_table_ · D20
|
||||||
|
13. **BUCKET LIST TRIP** — _inline_table_ · D20
|
||||||
|
14. **JOINED FACEBOOK** — _inline_table_ · D20
|
||||||
|
15. **GOLF TRIP** — _inline_table_ · D20
|
||||||
|
16. **DENTAL WORK** — _inline_table_ · D20
|
||||||
|
17. **ACTION SPACE** — _action_space_ · D100 · ⤷ roll table
|
||||||
|
18. **START A BAND** — _inline_table_ · D20
|
||||||
|
19. **INVESTMENT PAID OFF** — _inline_table_ · D20
|
||||||
|
|
||||||
|
## Roll tables needed (all PLACEHOLDER)
|
||||||
|
|
||||||
|
Sorted by how many tiles use them. Each has banded stand-in entries so the game is playable now.
|
||||||
|
|
||||||
|
| Table | Die | Used by # tiles |
|
||||||
|
|---|---|---|
|
||||||
|
| DICE SPACE TABLE | D100 | 18 |
|
||||||
|
| ACTION SPACE TABLE | D100 | 15 |
|
||||||
|
| your current property | D100 | 8 |
|
||||||
|
| property table | D100 | 7 |
|
||||||
|
| JOBS TABLE | D100 | 6 |
|
||||||
|
| hobby | D100 | 5 |
|
||||||
|
| D100 BUSINESS TABLE | D100 | 4 |
|
||||||
|
| Profited Or lost | D100 | 4 |
|
||||||
|
| sell your | | | business | D100 | 4 |
|
||||||
|
| Pet Table | D100 | 3 |
|
||||||
|
| Bring the data | D100 | 2 |
|
||||||
|
| Charm the boss | D100 | 2 |
|
||||||
|
| item being auctioned | D100 | 2 |
|
||||||
|
| new city | D100 | 2 |
|
||||||
|
| shenanigan | D20 | 2 |
|
||||||
|
| Throw a someone under the bus | D100 | 2 |
|
||||||
|
| Accept a local recommendation | D100 | 1 |
|
||||||
|
| Accept a local recommendation | D100 | 1 |
|
||||||
|
| Accept a local recommendation | D100 | 1 |
|
||||||
|
| Accept a local recommendation | D100 | 1 |
|
||||||
|
| Accept a local recommendation | D100 | 1 |
|
||||||
|
| Accept a local recommendation | D100 | 1 |
|
||||||
|
| applicant\'s | D100 | 1 |
|
||||||
|
| company perk | D100 | 1 |
|
||||||
|
| Explore the city | D100 | 1 |
|
||||||
|
| Explore the city | D100 | 1 |
|
||||||
|
| Explore the city | D100 | 1 |
|
||||||
|
| Explore the city | D100 | 1 |
|
||||||
|
| Explore the city | D100 | 1 |
|
||||||
|
| Explore the city | D100 | 1 |
|
||||||
|
| gift exchange! | D100 | 1 |
|
||||||
|
| Go into nature | D100 | 1 |
|
||||||
|
| Go into nature | D100 | 1 |
|
||||||
|
| Go into nature | D100 | 1 |
|
||||||
|
| Go into nature | D100 | 1 |
|
||||||
|
| Go into nature | D100 | 1 |
|
||||||
|
| Go into nature | D100 | 1 |
|
||||||
|
| Karaoke bar | D100 | 1 |
|
||||||
|
| Mystery Tattoo Table | D100 | 1 |
|
||||||
|
| Play it safe | D100 | 1 |
|
||||||
|
| Roll D20. | D100 | 1 |
|
||||||
|
| Roll D20. | D100 | 1 |
|
||||||
|
| Side Hustle Table | D100 | 1 |
|
||||||
|
| Sporting event | D100 | 1 |
|
||||||
|
| Take the risk | D100 | 1 |
|
||||||
|
| treasure | D100 | 1 |
|
||||||
|
| trivia question | D100 | 1 |
|
||||||
|
| Upscale steakhouse | D100 | 1 |
|
||||||
|
|
||||||
|
## Open questions for you / your sister
|
||||||
|
|
||||||
|
- **Win condition** isn't stated in the doc — how does the game end and who wins? (Most Wealth? A blend of cash + Love + Wealth at retirement?)
|
||||||
|
- **Age** — the D8 at each STOP adds years; is there a maximum age that ends the game?
|
||||||
|
- **Stat meaning** — is *Wealth* a separate point score from *cash ($)*? Treated that way here.
|
||||||
|
- **Country sub-tables** (Explore city / Nature / Local rec) are **separate per country** (Mexico, Australia, Japan, Ireland, Germany, Italy) — 18 small tables in total.
|
||||||
|
- Several tables are all-players mini-games (trivia, White Elephant, races) — these need special handling in an async game; worth flagging for Phase 3.
|
||||||
|
|
||||||
|
## Additional gaps found while integrating this into the game engine
|
||||||
|
|
||||||
|
- **`inline_table` tiles have no table content anywhere.** 85 of the 211 tiles (40% of the board) are type `inline_table` — they carry a die size (e.g. `COMPANY RACE DAY`, D10) but reference zero external tables, and none of the 48 tables in roll-tables.json are inline (all 48 have a `source_doc_id`). Whatever content was inline in the original design doc next to these tiles wasn't captured during parsing. Currently these resolve against a generic synthesized placeholder table (same banded shape as the real 48, one per die size, obviously fake numbers) — real content for all 85 is still needed.
|
||||||
|
- **`payday`/`cash_bonus` amounts are undefined.** PAY DAY (16 tiles), 100K, 10K, and 401k have no amount specified anywhere in tile-inventory.json or roll-tables.json. Currently using invented placeholder amounts ($2,000 payday; $100,000/$10,000 for the named bonuses; 401k falls into the synthesized-table bucket above) — these are 100% guesses, not derived from anything in the doc.
|
||||||
|
- **Multi-table `roll_table_ref` tiles have no documented combination rule.** 22 tiles reference 2–3 tables at once (e.g. `PERFORMANCE REVIEW` references 3), and nothing in the parsed content says how they combine. Currently implemented as "roll and apply every referenced table's effect, summed" — a reasonable guess, but unconfirmed against the actual design doc.
|
||||||
|
- The tile-type vocabulary actually has **9 types**, not 7 — `inline_table` and `cash_bonus` exist alongside payday/action_space/dice_space/roll_table_ref/choice/event/stop.
|
||||||
@@ -1,76 +1,89 @@
|
|||||||
/**
|
/**
|
||||||
* Life Journey — Phase 1 board.
|
* Life Journey — the real board: 211 tiles across 12 segments, built from
|
||||||
|
* shared/tileInventory.js (parsed from the design doc).
|
||||||
*
|
*
|
||||||
* A small representative subset of the full hand-drawn board (assets/game_board.png):
|
* TEMPORARY: the 12 segments have no defined connections yet — that's what
|
||||||
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe
|
* shared/BRANCHING-TEMPLATE.md is an open request for (the STOP forks, and
|
||||||
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into
|
* which segment's exit feeds which segment's entry). Until board-graph.json
|
||||||
* any branch array later (repointing one `next`) without touching the reducer.
|
* exists, TEMP_SEGMENT_ORDER below just concatenates all 12 segments in the
|
||||||
|
* design doc's own listed order into one line, so movement/tests/rendering
|
||||||
|
* have something real to walk. This is NOT the real board topology — delete
|
||||||
|
* this placeholder and replace `next` wiring once board-graph.json exists.
|
||||||
*
|
*
|
||||||
* Space shape: { id, type, label, next?, choices?, cash?, flavor? }
|
* Similarly, no tile in the source data has type 'finish' (win condition is
|
||||||
* type: 'start' | 'event' | 'money' | 'choice' | 'finish'
|
* itself an open question in GAME-REVIEW.md) — a synthetic `finish` space is
|
||||||
* next: id of the following space (absent on 'choice' and 'finish' spaces)
|
* appended after the last tile so the reducer has something to end on.
|
||||||
* choices: [id, id] of the branches offered by a 'choice' space
|
*
|
||||||
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces)
|
* Movement is one tile per turn (no tile in the data implies a movement die —
|
||||||
|
* every `die` value belongs to that tile's own effect resolution), so unlike
|
||||||
|
* the old board there is no walkForward()/multi-step-with-early-stop concept
|
||||||
|
* here: a turn is just "go to `next`, then resolve whatever that tile needs."
|
||||||
|
*
|
||||||
|
* Space shape: { id, type, label, die, externalTables, statsTouched, next }
|
||||||
|
* type: one of the 9 tile-inventory types — payday, action_space,
|
||||||
|
* dice_space, roll_table_ref, inline_table, cash_bonus, event, choice, stop
|
||||||
|
* die: 'D100'|'D20'|'D10'|'D8'|'D6'|'D2'|null — what to roll to resolve this tile
|
||||||
|
* externalTables: source_doc_id[] into shared/rollTables.js (0-3 entries)
|
||||||
|
* statsTouched: string[] hint from the source data, informational only
|
||||||
|
* next: id of the following space (absent only on the synthetic 'finish')
|
||||||
|
* choices: NOT set on any tile yet (no real branch destinations are known) —
|
||||||
|
* shared/game.js only pauses a 'choice'/'stop' space for a decision when
|
||||||
|
* `choices` is non-empty, so today every one of these is a harmless
|
||||||
|
* pass-through, not a dead end.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const spaceList = [
|
import tileInventory from './tileInventory.js';
|
||||||
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' },
|
|
||||||
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] },
|
|
||||||
|
|
||||||
{ id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' },
|
const TEMP_SEGMENT_ORDER = [
|
||||||
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' },
|
'starting_strip',
|
||||||
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' },
|
'career',
|
||||||
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' },
|
'investment',
|
||||||
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' },
|
'investment_bottom',
|
||||||
|
'high_risk',
|
||||||
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' },
|
'gap_year',
|
||||||
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' },
|
'education',
|
||||||
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' },
|
'relationship_top',
|
||||||
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' },
|
'relationship_bottom',
|
||||||
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' },
|
'retirement_top',
|
||||||
|
'retirement_middle',
|
||||||
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' },
|
'retirement_bottom',
|
||||||
{ id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] },
|
|
||||||
|
|
||||||
{ id: 'relationship_1', type: 'event', label: 'New City', next: 'relationship_2' },
|
|
||||||
{ id: 'relationship_2', type: 'event', label: 'Dinner Party', next: 'relationship_3' },
|
|
||||||
{ id: 'relationship_3', type: 'money', label: 'Wedding', cash: -200, next: 'join_2' },
|
|
||||||
|
|
||||||
{ id: 'investment_1', type: 'money', label: 'Side Investment', cash: -150, next: 'investment_2' },
|
|
||||||
{ id: 'investment_2', type: 'money', label: 'Market Move', cash: 350, next: 'investment_3' },
|
|
||||||
{ id: 'investment_3', type: 'money', label: '401K Contribution', cash: -100, flavor: 'Future savings', next: 'join_2' },
|
|
||||||
|
|
||||||
{ id: 'join_2', type: 'event', label: 'Settling Down', next: 'high_risk_choice' },
|
|
||||||
{ id: 'high_risk_choice', type: 'choice', label: 'One Last Fork', choices: ['high_risk_1', 'safe_1'] },
|
|
||||||
|
|
||||||
{ id: 'high_risk_1', type: 'money', label: 'Startup Gamble', cash: 500, next: 'high_risk_2' },
|
|
||||||
{ id: 'high_risk_2', type: 'money', label: 'Bankruptcy', cash: -400, flavor: 'Ouch.', next: 'retirement_party' },
|
|
||||||
|
|
||||||
{ id: 'safe_1', type: 'money', label: 'Steady Savings', cash: 100, next: 'safe_2' },
|
|
||||||
{ id: 'safe_2', type: 'money', label: 'Modest Raise', cash: 150, next: 'retirement_party' },
|
|
||||||
|
|
||||||
{ id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
|
|
||||||
{ id: 'finish', type: 'finish', label: 'Finish' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const segmentsById = Object.fromEntries(tileInventory.segments.map((s) => [s.id, s]));
|
||||||
|
|
||||||
|
function buildSegmentSpaces(segmentId, nextAfterSegment) {
|
||||||
|
const segment = segmentsById[segmentId];
|
||||||
|
if (!segment) throw new Error(`Unknown segment id in TEMP_SEGMENT_ORDER: ${segmentId}`);
|
||||||
|
return segment.tiles.map((tile, i) => ({
|
||||||
|
id: `${segmentId}_${i}`,
|
||||||
|
type: tile.type,
|
||||||
|
label: tile.name,
|
||||||
|
die: tile.die ?? null,
|
||||||
|
externalTables: tile.external_tables ?? [],
|
||||||
|
statsTouched: tile.stats_touched ?? [],
|
||||||
|
next: i < segment.tiles.length - 1 ? `${segmentId}_${i + 1}` : nextAfterSegment,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceList = TEMP_SEGMENT_ORDER.flatMap((segmentId, i) => {
|
||||||
|
const isLastSegment = i === TEMP_SEGMENT_ORDER.length - 1;
|
||||||
|
const nextAfterSegment = isLastSegment ? 'finish' : `${TEMP_SEGMENT_ORDER[i + 1]}_0`;
|
||||||
|
return buildSegmentSpaces(segmentId, nextAfterSegment);
|
||||||
|
});
|
||||||
|
|
||||||
|
spaceList.push({
|
||||||
|
id: 'finish',
|
||||||
|
type: 'finish',
|
||||||
|
label: 'Finish',
|
||||||
|
die: null,
|
||||||
|
externalTables: [],
|
||||||
|
statsTouched: [],
|
||||||
|
// no `next` — this is the temporary end of TEMP_SEGMENT_ORDER, not a real
|
||||||
|
// win-condition tile from the source data.
|
||||||
|
});
|
||||||
|
|
||||||
export const board = {
|
export const board = {
|
||||||
id: 'phase1-demo',
|
id: 'life-journey-full-v1',
|
||||||
startSpaceId: 'start',
|
startSpaceId: `${TEMP_SEGMENT_ORDER[0]}_0`,
|
||||||
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
|
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Advance from `startId` by up to `steps` spaces, stopping immediately on arrival
|
|
||||||
* at a 'choice' or 'finish' space even if pips remain (they're discarded).
|
|
||||||
*/
|
|
||||||
export function walkForward(startId, steps) {
|
|
||||||
let current = startId;
|
|
||||||
for (let i = 0; i < steps; i++) {
|
|
||||||
const space = board.spaces[current];
|
|
||||||
if (!space.next) break; // sitting on a choice/finish space already — nowhere to advance
|
|
||||||
current = space.next;
|
|
||||||
const landed = board.spaces[current];
|
|
||||||
if (landed.type === 'choice' || landed.type === 'finish') break;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,17 +2,27 @@
|
|||||||
* Life Journey — pure game reducer.
|
* Life Journey — pure game reducer.
|
||||||
*
|
*
|
||||||
* reduce(state, action) -> newState is the ONLY way state changes, and it never
|
* reduce(state, action) -> newState is the ONLY way state changes, and it never
|
||||||
* generates randomness itself: the die value is generated by the server and
|
* generates randomness itself: every roll a tile needs is generated by the
|
||||||
* travels inside the ROLL action payload, so server and client can both apply
|
* server and travels inside the action payload (`rolls`), so server and
|
||||||
* the exact same action through this exact same function and land on identical
|
* client can both apply the exact same action through this exact same
|
||||||
* state. Illegal transitions throw rather than no-op — the server is expected
|
* function and land on identical state. Illegal transitions throw rather
|
||||||
* to gate intents with getLegalIntents()/isPlayersTurn() before ever
|
* than no-op — the server is expected to gate intents with
|
||||||
* constructing an action, so a throw here means that gate was bypassed.
|
* getLegalIntents()/isPlayersTurn() before ever constructing an action, so a
|
||||||
|
* throw here means that gate was bypassed.
|
||||||
|
*
|
||||||
|
* Movement is one tile per turn (board.js's `next` pointer) — there is no
|
||||||
|
* movement die; every `die` a tile carries is for resolving that tile's own
|
||||||
|
* effect via shared/tileEffects.js, not how far a player travels.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { board, walkForward } from './board.js';
|
import { board } from './board.js';
|
||||||
|
import { resolveTileEffect } from './tileEffects.js';
|
||||||
|
|
||||||
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
|
const DEFAULT_CONFIG = {
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 8,
|
||||||
|
startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 },
|
||||||
|
};
|
||||||
const LOG_LIMIT = 50;
|
const LOG_LIMIT = 50;
|
||||||
|
|
||||||
export function createInitialState(config = {}) {
|
export function createInitialState(config = {}) {
|
||||||
@@ -90,7 +100,7 @@ function applyJoin(state, { playerId, name, seat, color }) {
|
|||||||
seat,
|
seat,
|
||||||
color,
|
color,
|
||||||
position: board.startSpaceId,
|
position: board.startSpaceId,
|
||||||
cash: state.config.startingCash,
|
...state.config.startingStats,
|
||||||
pendingChoice: null,
|
pendingChoice: null,
|
||||||
finished: false,
|
finished: false,
|
||||||
};
|
};
|
||||||
@@ -107,37 +117,38 @@ function applyStart(state) {
|
|||||||
return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] };
|
return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRoll(state, { playerId, value }) {
|
function applyRoll(state, { playerId, rolls }) {
|
||||||
assertActive(state);
|
assertActive(state);
|
||||||
assertPlayersTurn(state, playerId);
|
assertPlayersTurn(state, playerId);
|
||||||
const player = state.players[playerId];
|
const player = state.players[playerId];
|
||||||
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
|
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
|
||||||
const landed = walkForward(player.position, value);
|
const nextSpaceId = board.spaces[player.position].next;
|
||||||
return landOn(state, playerId, landed, { type: 'ROLL', value });
|
if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board');
|
||||||
|
return landOn(state, playerId, nextSpaceId, rolls, { type: 'ROLL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyChoose(state, { playerId, spaceId }) {
|
function applyChoose(state, { playerId, spaceId, rolls }) {
|
||||||
assertActive(state);
|
assertActive(state);
|
||||||
assertPlayersTurn(state, playerId);
|
assertPlayersTurn(state, playerId);
|
||||||
const player = state.players[playerId];
|
const player = state.players[playerId];
|
||||||
const pending = player.pendingChoice;
|
const pending = player.pendingChoice;
|
||||||
if (!pending) throw new Error('No pending choice');
|
if (!pending) throw new Error('No pending choice');
|
||||||
if (!pending.options.includes(spaceId)) throw new Error('Illegal choice');
|
if (!pending.options.includes(spaceId)) throw new Error('Illegal choice');
|
||||||
return landOn(state, playerId, spaceId, { type: 'CHOOSE' });
|
return landOn(state, playerId, spaceId, rolls, { type: 'CHOOSE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's
|
/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's
|
||||||
* resolved branch: apply the space's effect, then either leave the turn
|
* resolved branch: resolve the space's tile effect, then either leave the
|
||||||
* open (choice pending), end the game (finish), or advance to the next player. */
|
* turn open (a real choice is pending — only true once board-graph.json
|
||||||
function landOn(state, playerId, spaceId, logMeta) {
|
* populates `choices`), end the game (finish), or advance to the next player. */
|
||||||
|
function landOn(state, playerId, spaceId, rolls, logMeta) {
|
||||||
const space = board.spaces[spaceId];
|
const space = board.spaces[spaceId];
|
||||||
const cashDelta = space.cash ?? 0;
|
const { statDelta, description, todo } = resolveTileEffect(space, rolls ?? []);
|
||||||
const priorPlayer = state.players[playerId];
|
const priorPlayer = state.players[playerId];
|
||||||
const nextPlayer = {
|
const nextPlayer = {
|
||||||
...priorPlayer,
|
...applyStatDelta(priorPlayer, statDelta),
|
||||||
position: spaceId,
|
position: spaceId,
|
||||||
cash: priorPlayer.cash + cashDelta,
|
pendingChoice: space.choices?.length ? { atSpace: spaceId, options: space.choices } : null,
|
||||||
pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null,
|
|
||||||
finished: space.type === 'finish',
|
finished: space.type === 'finish',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,7 +157,9 @@ function landOn(state, playerId, spaceId, logMeta) {
|
|||||||
playerId,
|
playerId,
|
||||||
landedOn: spaceId,
|
landedOn: spaceId,
|
||||||
label: space.label,
|
label: space.label,
|
||||||
cashDelta,
|
statDelta,
|
||||||
|
description,
|
||||||
|
todo,
|
||||||
...logMeta,
|
...logMeta,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,6 +175,14 @@ function landOn(state, playerId, spaceId, logMeta) {
|
|||||||
return advanceTurn(newState);
|
return advanceTurn(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyStatDelta(player, statDelta) {
|
||||||
|
const next = { ...player };
|
||||||
|
for (const [stat, delta] of Object.entries(statDelta ?? {})) {
|
||||||
|
next[stat] = (next[stat] ?? 0) + delta;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function advanceTurn(state) {
|
function advanceTurn(state) {
|
||||||
const turnIndex = (state.turnIndex + 1) % state.turnOrder.length;
|
const turnIndex = (state.turnIndex + 1) % state.turnOrder.length;
|
||||||
return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] };
|
return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] };
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
|
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
|
||||||
|
import { board } from './board.js';
|
||||||
|
import { rollsNeededFor } from './tileEffects.js';
|
||||||
|
|
||||||
function join(state, playerId, name, seat) {
|
function join(state, playerId, name, seat) {
|
||||||
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
|
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic dummy rolls — always the low end of each die's range, so
|
||||||
|
// these tests exercise the reducer's mechanics without depending on real
|
||||||
|
// (or synthesized-placeholder) table content. shared/tileEffects.test.js
|
||||||
|
// covers the actual banding/effect math.
|
||||||
|
function rollsFor(space) {
|
||||||
|
return rollsNeededFor(space).map(() => 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeTurn(state, playerId) {
|
||||||
|
const player = state.players[playerId];
|
||||||
|
if (player.pendingChoice) {
|
||||||
|
const spaceId = player.pendingChoice.options[0];
|
||||||
|
return reduce(state, { type: 'CHOOSE', playerId, spaceId, rolls: rollsFor(board.spaces[spaceId]) });
|
||||||
|
}
|
||||||
|
const nextSpaceId = board.spaces[player.position].next;
|
||||||
|
return reduce(state, { type: 'ROLL', playerId, rolls: rollsFor(board.spaces[nextSpaceId]) });
|
||||||
|
}
|
||||||
|
|
||||||
test('lobby: join validation', () => {
|
test('lobby: join validation', () => {
|
||||||
let state = createInitialState();
|
let state = createInitialState();
|
||||||
state = join(state, 'p1', 'Alice', 1);
|
state = join(state, 'p1', 'Alice', 1);
|
||||||
@@ -14,7 +34,17 @@ test('lobby: join validation', () => {
|
|||||||
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
|
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('full game: both choice points, race to finish', () => {
|
test('join gives every player the five starting stats', () => {
|
||||||
|
let state = createInitialState();
|
||||||
|
state = join(state, 'p1', 'Alice', 1);
|
||||||
|
const p1 = state.players.p1;
|
||||||
|
assert.deepEqual(
|
||||||
|
{ cash: p1.cash, love: p1.love, education: p1.education, wealth: p1.wealth, age: p1.age },
|
||||||
|
state.config.startingStats
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turn order and illegal actions', () => {
|
||||||
let state = createInitialState();
|
let state = createInitialState();
|
||||||
state = join(state, 'p1', 'Alice', 1);
|
state = join(state, 'p1', 'Alice', 1);
|
||||||
state = join(state, 'p2', 'Bob', 2);
|
state = join(state, 'p2', 'Bob', 2);
|
||||||
@@ -23,75 +53,77 @@ test('full game: both choice points, race to finish', () => {
|
|||||||
assert.equal(state.status, 'active');
|
assert.equal(state.status, 'active');
|
||||||
assert.deepEqual(state.turnOrder, ['p1', 'p2']);
|
assert.deepEqual(state.turnOrder, ['p1', 'p2']);
|
||||||
assert.equal(state.currentTurn, 'p1');
|
assert.equal(state.currentTurn, 'p1');
|
||||||
|
assert.equal(isPlayersTurn(state, 'p1'), true);
|
||||||
|
assert.equal(isPlayersTurn(state, 'p2'), false);
|
||||||
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
|
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
|
||||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
||||||
assert.equal(isPlayersTurn(state, 'p2'), false);
|
|
||||||
|
|
||||||
// Not p2's turn yet.
|
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', rolls: [] }), /Not your turn/);
|
||||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
|
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
|
||||||
|
});
|
||||||
// p1 rolls onto the first choice space; movement stops immediately even
|
|
||||||
// though only 1 of the roll's pips was needed.
|
test('a single roll advances exactly one tile and applies its effect', () => {
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
|
let state = createInitialState();
|
||||||
assert.equal(state.players.p1.position, 'crossroads');
|
state = join(state, 'p1', 'Alice', 1);
|
||||||
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] });
|
state = join(state, 'p2', 'Bob', 2);
|
||||||
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose');
|
state = reduce(state, { type: 'START_GAME' });
|
||||||
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']);
|
|
||||||
|
const expectedNext = board.spaces[board.startSpaceId].next;
|
||||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/);
|
state = takeTurn(state, 'p1');
|
||||||
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/);
|
assert.equal(state.players.p1.position, expectedNext);
|
||||||
|
assert.equal(state.currentTurn, 'p2', 'turn advances since no choice is pending yet');
|
||||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' });
|
assert.equal(state.log.at(-1).landedOn, expectedNext);
|
||||||
assert.equal(state.players.p1.position, 'career_1');
|
});
|
||||||
assert.equal(state.players.p1.pendingChoice, null);
|
|
||||||
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn');
|
test('full board: walking every tile to the temporary finish never crashes and always terminates', () => {
|
||||||
|
let state = createInitialState();
|
||||||
// p2 takes the education branch.
|
state = join(state, 'p1', 'Alice', 1);
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 });
|
state = join(state, 'p2', 'Bob', 2);
|
||||||
assert.equal(state.players.p2.position, 'crossroads');
|
state = reduce(state, { type: 'START_GAME' });
|
||||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' });
|
|
||||||
assert.equal(state.players.p2.position, 'edu_1');
|
const seenTypes = new Set();
|
||||||
assert.equal(state.currentTurn, 'p1');
|
let guard = 0;
|
||||||
|
while (state.status === 'active') {
|
||||||
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed
|
if (++guard > 1000) throw new Error('game did not finish in a reasonable number of turns');
|
||||||
// space's cash effect applies, not spaces merely passed through.
|
const turnPlayerId = state.currentTurn;
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 });
|
state = takeTurn(state, turnPlayerId);
|
||||||
assert.equal(state.players.p1.position, 'life_crossroads');
|
const after = state.players[turnPlayerId];
|
||||||
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply');
|
seenTypes.add(board.spaces[after.position].type);
|
||||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' });
|
for (const stat of ['cash', 'love', 'education', 'wealth', 'age']) {
|
||||||
assert.equal(state.players.p1.position, 'investment_1');
|
assert.equal(typeof after[stat], 'number', `${stat} stays numeric`);
|
||||||
assert.equal(state.players.p1.cash, -150);
|
}
|
||||||
assert.equal(state.currentTurn, 'p2');
|
assert.ok(board.spaces[after.position], 'player is always on a real space');
|
||||||
|
}
|
||||||
// p2: edu_1 -> life_crossroads is also exactly 6 steps.
|
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 });
|
assert.equal(state.status, 'finished');
|
||||||
assert.equal(state.players.p2.position, 'life_crossroads');
|
assert.ok(state.winnerId);
|
||||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' });
|
assert.equal(state.currentTurn, null);
|
||||||
assert.equal(state.players.p2.position, 'relationship_1');
|
assert.equal(state.players[state.winnerId].position, 'finish');
|
||||||
assert.equal(state.currentTurn, 'p1');
|
assert.deepEqual(getLegalIntents(state, 'p1'), []);
|
||||||
|
|
||||||
// p1: investment_1 -> high_risk_choice is exactly 4 steps.
|
// Every distinct tile type in the real board actually got resolved along the way.
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 });
|
for (const type of [
|
||||||
assert.equal(state.players.p1.position, 'high_risk_choice');
|
'payday', 'action_space', 'dice_space', 'roll_table_ref',
|
||||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' });
|
'inline_table', 'cash_bonus', 'event', 'choice', 'stop',
|
||||||
assert.equal(state.players.p1.cash, -50); // -150 + 100
|
]) {
|
||||||
assert.equal(state.currentTurn, 'p2');
|
assert.ok(seenTypes.has(type), `expected to land on a "${type}" tile during a full playthrough`);
|
||||||
|
}
|
||||||
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps.
|
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 });
|
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', rolls: [] }), /not active/);
|
||||||
assert.equal(state.players.p2.position, 'high_risk_choice');
|
});
|
||||||
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' });
|
|
||||||
assert.equal(state.players.p2.cash, 500);
|
test('board graph is well-formed', () => {
|
||||||
assert.equal(state.currentTurn, 'p1');
|
const ids = Object.keys(board.spaces);
|
||||||
|
assert.equal(ids.length, 212, '211 real tiles + 1 synthetic finish');
|
||||||
// p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game.
|
let nonFinishCount = 0;
|
||||||
assert.equal(state.status, 'active');
|
for (const id of ids) {
|
||||||
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
|
const space = board.spaces[id];
|
||||||
assert.equal(state.players.p1.position, 'finish');
|
if (space.type === 'finish') {
|
||||||
assert.equal(state.status, 'finished');
|
assert.equal(space.next, undefined);
|
||||||
assert.equal(state.winnerId, 'p1');
|
continue;
|
||||||
assert.equal(state.currentTurn, null);
|
}
|
||||||
assert.deepEqual(getLegalIntents(state, 'p2'), []);
|
nonFinishCount++;
|
||||||
|
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
|
||||||
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/);
|
}
|
||||||
|
assert.equal(nonFinishCount, 211);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Tile-effect resolution: given a board space and the roll value(s) it
|
||||||
|
* needs, compute the stat delta to apply. Pure — no randomness happens here;
|
||||||
|
* the caller (server/rooms.js) pre-rolls via rollsNeededFor() and passes the
|
||||||
|
* results in, the exact same pattern shared/game.js already uses for the
|
||||||
|
* movement die. This is what keeps reduce() itself 100% deterministic.
|
||||||
|
*
|
||||||
|
* Two real gaps in the parsed content, both flagged `todo: true` on every
|
||||||
|
* resolution they touch so it's visible in the game log, not just docs:
|
||||||
|
* - 85 inline_table tiles have a die size but zero table content anywhere
|
||||||
|
* in tile-inventory.js/roll-tables.js.
|
||||||
|
* - payday/cash_bonus tiles (PAY DAY, 100K, 10K, 401k) have no amount
|
||||||
|
* defined anywhere either.
|
||||||
|
* Both resolve against SYNTHESIZED_TABLES — one generic banded placeholder
|
||||||
|
* table per die size (not per tile), built the same way the real 48 tables
|
||||||
|
* are shaped (banded ranges -> a cash effect), so swapping in real content
|
||||||
|
* later is a matter of replacing one table, not touching this file's logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import rollTablesData from './rollTables.js';
|
||||||
|
|
||||||
|
const tablesById = Object.fromEntries(rollTablesData.tables.map((t) => [t.source_doc_id, t]));
|
||||||
|
|
||||||
|
export const DIE_SIZES = { D2: 2, D6: 6, D8: 8, D10: 10, D20: 20, D100: 100 };
|
||||||
|
|
||||||
|
const PLACEHOLDER_PAYDAY_CASH = 2000;
|
||||||
|
const PLACEHOLDER_CASH_BONUS = { '100K': 100000, '10K': 10000 };
|
||||||
|
|
||||||
|
const SYNTHESIZED_TABLES = Object.fromEntries(
|
||||||
|
Object.entries(DIE_SIZES).map(([die, max]) => [die, { die, entries: bandedPlaceholderEntries(max) }])
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Same worst→jackpot banded shape as the 48 real placeholder tables, scaled
|
||||||
|
* to the die's range. Cash-only so it composes safely no matter what the
|
||||||
|
* tile actually wants to touch — a real replacement table can touch anything. */
|
||||||
|
function bandedPlaceholderEntries(max) {
|
||||||
|
const bands = [
|
||||||
|
{ frac: 0.10, cash: -500, label: 'worst outcome' },
|
||||||
|
{ frac: 0.30, cash: -200, label: 'bad outcome' },
|
||||||
|
{ frac: 0.55, cash: -50, label: 'mediocre outcome' },
|
||||||
|
{ frac: 0.75, cash: 100, label: 'decent outcome' },
|
||||||
|
{ frac: 0.90, cash: 300, label: 'good outcome' },
|
||||||
|
{ frac: 0.99, cash: 600, label: 'great outcome' },
|
||||||
|
{ frac: 1.00, cash: 1200, label: 'jackpot' },
|
||||||
|
];
|
||||||
|
const entries = [];
|
||||||
|
let lo = 1;
|
||||||
|
for (const band of bands) {
|
||||||
|
if (lo > max) break; // die too small to hold this many distinct bands
|
||||||
|
const hi = Math.min(max, Math.max(lo, Math.round(max * band.frac)));
|
||||||
|
entries.push({
|
||||||
|
range: lo === hi ? `${lo}` : `${lo}-${hi}`,
|
||||||
|
result: `PLACEHOLDER: ${band.label}`,
|
||||||
|
effect: { cash: band.cash },
|
||||||
|
});
|
||||||
|
lo = hi + 1;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupBand(entries, roll) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const [loStr, hiStr] = entry.range.split('-');
|
||||||
|
const lo = Number(loStr);
|
||||||
|
const hi = hiStr !== undefined ? Number(hiStr) : lo;
|
||||||
|
if (roll >= lo && roll <= hi) return entry;
|
||||||
|
}
|
||||||
|
return entries[entries.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableDieFor(tableId, fallbackDie) {
|
||||||
|
return tablesById[tableId]?.die ?? fallbackDie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What die(s) landing on `space` needs rolled, in the order resolveTileEffect
|
||||||
|
* expects them back. The server calls this BEFORE constructing the action. */
|
||||||
|
export function rollsNeededFor(space) {
|
||||||
|
switch (space.type) {
|
||||||
|
case 'action_space':
|
||||||
|
case 'dice_space':
|
||||||
|
return [tableDieFor(space.externalTables[0], space.die)];
|
||||||
|
case 'roll_table_ref':
|
||||||
|
return space.externalTables.map((tableId) => tableDieFor(tableId, space.die));
|
||||||
|
case 'inline_table':
|
||||||
|
case 'stop':
|
||||||
|
return [space.die];
|
||||||
|
case 'cash_bonus':
|
||||||
|
case 'event':
|
||||||
|
return space.die ? [space.die] : [];
|
||||||
|
case 'payday':
|
||||||
|
case 'choice':
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure: given `space` and the roll values rollsNeededFor(space) asked for,
|
||||||
|
* return { statDelta, description, todo }. */
|
||||||
|
export function resolveTileEffect(space, rolls = []) {
|
||||||
|
switch (space.type) {
|
||||||
|
case 'payday':
|
||||||
|
return {
|
||||||
|
statDelta: { cash: PLACEHOLDER_PAYDAY_CASH },
|
||||||
|
description: `${space.label}: +$${PLACEHOLDER_PAYDAY_CASH} (PLACEHOLDER amount)`,
|
||||||
|
todo: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'cash_bonus': {
|
||||||
|
if (space.label in PLACEHOLDER_CASH_BONUS) {
|
||||||
|
const amount = PLACEHOLDER_CASH_BONUS[space.label];
|
||||||
|
return {
|
||||||
|
statDelta: { cash: amount },
|
||||||
|
description: `${space.label}: +$${amount} (PLACEHOLDER amount)`,
|
||||||
|
todo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return resolveSynthesized(space, rolls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'action_space':
|
||||||
|
case 'dice_space':
|
||||||
|
case 'roll_table_ref':
|
||||||
|
return resolveExternalTables(space, rolls);
|
||||||
|
|
||||||
|
case 'inline_table':
|
||||||
|
return resolveSynthesized(space, rolls[0]);
|
||||||
|
|
||||||
|
case 'event':
|
||||||
|
return space.die ? resolveSynthesized(space, rolls[0]) : resolveFixedEvent(space);
|
||||||
|
|
||||||
|
case 'choice':
|
||||||
|
return { statDelta: {}, description: `${space.label}: choice options not yet defined (TODO)`, todo: true };
|
||||||
|
|
||||||
|
case 'stop': {
|
||||||
|
const roll = rolls[0] ?? 0;
|
||||||
|
return { statDelta: { age: roll }, description: `${space.label}: age +${roll}`, todo: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { statDelta: {}, description: `${space.label}: unhandled tile type "${space.type}" (TODO)`, todo: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExternalTables(space, rolls) {
|
||||||
|
const statDelta = {};
|
||||||
|
const parts = [];
|
||||||
|
space.externalTables.forEach((tableId, i) => {
|
||||||
|
const table = tablesById[tableId];
|
||||||
|
const roll = rolls[i];
|
||||||
|
if (!table) {
|
||||||
|
parts.push(`${space.label}: missing table ${tableId} (TODO)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = lookupBand(table.entries, roll);
|
||||||
|
mergeStatDelta(statDelta, entry.effect);
|
||||||
|
parts.push(`${space.label} → ${table.display_name} (${roll}): ${entry.result}`);
|
||||||
|
});
|
||||||
|
return { statDelta, description: parts.join(' | '), todo: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSynthesized(space, roll) {
|
||||||
|
const table = SYNTHESIZED_TABLES[space.die];
|
||||||
|
if (!table) {
|
||||||
|
return { statDelta: {}, description: `${space.label}: no die to roll against (TODO)`, todo: true };
|
||||||
|
}
|
||||||
|
const entry = lookupBand(table.entries, roll);
|
||||||
|
return {
|
||||||
|
statDelta: { ...entry.effect },
|
||||||
|
description: `${space.label} (${roll}): ${entry.result} (PLACEHOLDER table)`,
|
||||||
|
todo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFixedEvent(space) {
|
||||||
|
const statDelta = {};
|
||||||
|
for (const stat of space.statsTouched) statDelta[stat] = stat === 'cash' ? 100 : 1;
|
||||||
|
const hasEffect = Object.keys(statDelta).length > 0;
|
||||||
|
return {
|
||||||
|
statDelta,
|
||||||
|
description: `${space.label}${hasEffect ? ' (PLACEHOLDER amount)' : ''}`,
|
||||||
|
todo: hasEffect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStatDelta(target, effect) {
|
||||||
|
for (const [stat, delta] of Object.entries(effect ?? {})) {
|
||||||
|
target[stat] = (target[stat] ?? 0) + delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { resolveTileEffect, rollsNeededFor, DIE_SIZES } from './tileEffects.js';
|
||||||
|
import { board } from './board.js';
|
||||||
|
import rollTables from './rollTables.js';
|
||||||
|
|
||||||
|
const spaces = Object.values(board.spaces);
|
||||||
|
const byType = (type) => spaces.filter((s) => s.type === type);
|
||||||
|
|
||||||
|
test('DIE_SIZES covers every die actually referenced in the real board', () => {
|
||||||
|
const usedDice = new Set(spaces.map((s) => s.die).filter(Boolean));
|
||||||
|
for (const die of usedDice) assert.ok(DIE_SIZES[die], `unknown die size: ${die}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollsNeededFor asks for exactly one roll per referenced table', () => {
|
||||||
|
const multi = byType('roll_table_ref').find((s) => s.externalTables.length === 3);
|
||||||
|
assert.ok(multi, 'expected a 3-table roll_table_ref tile in the real board');
|
||||||
|
assert.equal(rollsNeededFor(multi).length, 3);
|
||||||
|
|
||||||
|
const single = byType('dice_space')[0];
|
||||||
|
assert.equal(rollsNeededFor(single).length, 1);
|
||||||
|
|
||||||
|
const payday = byType('payday')[0];
|
||||||
|
assert.deepEqual(rollsNeededFor(payday), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dice_space resolves against its real referenced table with an exact banded effect', () => {
|
||||||
|
const space = byType('dice_space')[0];
|
||||||
|
const table = rollTables.tables.find((t) => t.source_doc_id === space.externalTables[0]);
|
||||||
|
assert.ok(table, 'dice_space tile should reference a real table');
|
||||||
|
|
||||||
|
const [loStr] = table.entries[0].range.split('-');
|
||||||
|
const roll = Number(loStr);
|
||||||
|
const result = resolveTileEffect(space, [roll]);
|
||||||
|
assert.deepEqual(result.statDelta, table.entries[0].effect);
|
||||||
|
assert.equal(result.todo, false, 'a real referenced table is not an invented placeholder');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-table roll_table_ref sums every referenced table\'s effect', () => {
|
||||||
|
const multi = byType('roll_table_ref').find((s) => s.externalTables.length >= 2);
|
||||||
|
const rolls = rollsNeededFor(multi).map(() => 1); // roll the minimum on every table
|
||||||
|
|
||||||
|
const expected = {};
|
||||||
|
for (const tableId of multi.externalTables) {
|
||||||
|
const table = rollTables.tables.find((t) => t.source_doc_id === tableId);
|
||||||
|
for (const [stat, delta] of Object.entries(table.entries[0].effect)) {
|
||||||
|
expected[stat] = (expected[stat] ?? 0) + delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(resolveTileEffect(multi, rolls).statDelta, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop applies the rolled D8 value directly to age, no table lookup', () => {
|
||||||
|
const stop = byType('stop')[0];
|
||||||
|
assert.equal(stop.die, 'D8');
|
||||||
|
const result = resolveTileEffect(stop, [5]);
|
||||||
|
assert.deepEqual(result.statDelta, { age: 5 });
|
||||||
|
assert.equal(result.todo, false, 'age += roll is a specified mechanic, not invented content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choice tiles are a harmless no-op while no board-graph.json choices exist yet', () => {
|
||||||
|
const choice = byType('choice')[0];
|
||||||
|
assert.equal(choice.choices, undefined);
|
||||||
|
const result = resolveTileEffect(choice, []);
|
||||||
|
assert.deepEqual(result.statDelta, {});
|
||||||
|
assert.equal(result.todo, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inline_table tiles resolve against a synthesized placeholder, clearly marked todo', () => {
|
||||||
|
const inline = byType('inline_table')[0];
|
||||||
|
assert.deepEqual(rollsNeededFor(inline), [inline.die]);
|
||||||
|
const max = DIE_SIZES[inline.die];
|
||||||
|
const result = resolveTileEffect(inline, [max]); // top of the range
|
||||||
|
assert.equal(result.todo, true);
|
||||||
|
assert.equal(typeof result.statDelta.cash, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payday and named cash_bonus tiles use fixed placeholder amounts, no roll needed', () => {
|
||||||
|
const payday = byType('payday')[0];
|
||||||
|
assert.deepEqual(rollsNeededFor(payday), []);
|
||||||
|
const paydayResult = resolveTileEffect(payday, []);
|
||||||
|
assert.ok(paydayResult.statDelta.cash > 0);
|
||||||
|
assert.equal(paydayResult.todo, true);
|
||||||
|
|
||||||
|
const hundredK = byType('cash_bonus').find((s) => s.label === '100K');
|
||||||
|
assert.deepEqual(resolveTileEffect(hundredK, []).statDelta, { cash: 100000 });
|
||||||
|
|
||||||
|
const tenK = byType('cash_bonus').find((s) => s.label === '10K');
|
||||||
|
assert.deepEqual(resolveTileEffect(tenK, []).statDelta, { cash: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('401k (a cash_bonus with a die but no table) falls into the synthesized placeholder path', () => {
|
||||||
|
const four01k = byType('cash_bonus').find((s) => s.label === '401k');
|
||||||
|
assert.ok(four01k, 'expected a 401k cash_bonus tile');
|
||||||
|
assert.equal(four01k.die, 'D20');
|
||||||
|
assert.deepEqual(rollsNeededFor(four01k), ['D20']);
|
||||||
|
const result = resolveTileEffect(four01k, [20]);
|
||||||
|
assert.equal(result.todo, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every real tile resolves without throwing across the full range of its die', () => {
|
||||||
|
for (const space of spaces) {
|
||||||
|
const dice = rollsNeededFor(space);
|
||||||
|
for (const roll of [1, ...dice.map((d) => DIE_SIZES[d])]) {
|
||||||
|
const rolls = dice.map(() => roll);
|
||||||
|
assert.doesNotThrow(() => resolveTileEffect(space, rolls), `${space.id} (${space.type}) threw on roll=${roll}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||