Compare commits

...

9 Commits

Author SHA1 Message Date
Kevin 6749777b45 Extend tests for the real board and add tileEffects unit tests
game.test.js: rewritten for the new {type, rolls} action shape and 5-stat
player model. The full-playthrough test now walks the real 212-space board
turn by turn (deterministic dummy rolls — always the low end of each die's
range) until finish, asserting every stat stays numeric and every one of
the 9 real tile types actually gets resolved along the way. board-shape
assertions updated to match (212 spaces, no `choices` populated yet).

tileEffects.test.js (new): unit tests per tile type in isolation, including
exact deterministic assertions against real placeholder-table content (roll
a known value, assert the exact banded effect) and a multi-table
roll_table_ref cross-check computed independently from the raw table data —
no seeded-rng machinery anywhere, same explicit-action-value pattern the
reducer tests already used. Also a sweep resolving every real tile across
the full range of its die to catch any type/die combination that throws.

package.json: test script now runs shared/*.test.js instead of naming one
file.
2026-07-28 10:08:37 -07:00
Kevin db82b531e0 Update client rendering for the real board and 5 stats
boardRender.js: TYPE_STYLE now covers the 9 real tile types instead of the
old 5 (start/finish/choice/money/event) — payday/cash_bonus/action_space/
dice_space/roll_table_ref/inline_table each get their own icon, color-coded
by mechanic (green=income, blue=rolls-against-a-real-table, dashed
light-blue=rolls-against-a-synthesized-placeholder, red=decision point).
The old per-space cash badge is gone (tiles no longer carry a static cash
amount — effects resolve dynamically via tables) and replaced with a die
badge ("D20") wherever a tile involves a roll. Also fixes a real bug the
headless verification run caught: computeLayout assumed every 'choice'-type
space has a populated `choices` array, which none do yet post-rebuild
(`for (const t of space.choices)` on undefined) — now falls back to `next`
like everything else, and the lane fan-out is keyed off "does this space
have more than one outgoing edge" generically rather than off `type`, so it
keeps working whenever choices/stop tiles do get real branches later.

index.html: player list shows all five stats (cash/love/education/wealth/
age) instead of just cash; the log feed shows each tile's actual resolution
description (already human-readable from tileEffects.js) with a small ⚠️
marker on placeholder/invented resolutions, instead of a bare cash delta.
computeLayout's colsPerRow bumped to 20 for the much larger (212-space)
board.
2026-07-28 10:07:32 -07:00
Kevin 4d78937dca Rebuild the board and reducer around the real content
shared/board.js: rebuilt from shared/tileInventory.js instead of the
sketch-inspired placeholder — one chain() per segment (same helper as
before), TEMP_SEGMENT_ORDER concatenating all 12 segments in the design
doc's own order since board-graph.json (the real segment connections/STOP
forks) doesn't exist yet — loudly commented as a placeholder to replace, not
a guess at real topology. A synthetic `finish` space is appended since no
tile in the source data has that type (win condition is itself an open
question). Movement is now one tile per turn (board.spaces[pos].next) —
no tile in the data implies a movement die, every `die` belongs to that
tile's own effect resolution, so walkForward() is gone.

shared/tileEffects.js (new): pure resolveTileEffect(space, rolls) covering
all 9 real tile types (payday, action_space, dice_space, roll_table_ref,
inline_table, cash_bonus, event, choice, stop) plus rollsNeededFor(space) so
the server knows what to pre-roll before constructing an action. Real
roll_table_ref/action_space/dice_space tiles resolve against the actual 48
placeholder tables; the two real content gaps (inline_table's 85 tiles,
payday/cash_bonus amounts) resolve against a synthesized generic banded
table per die size, every result clearly flagged `todo: true` so it's
visible in the game log, not just in docs.

shared/game.js: player state is now flat {cash, love, education, wealth,
age} fields (mirrors a roll-table effect object's shape directly). Landing
logic calls resolveTileEffect() instead of applying a single cash delta, and
only pauses for a decision when a space's `choices` array is actually
populated (true of nothing yet) — keeps the existing pending-choice
machinery intact for once board-graph.json exists, instead of needing
choice/stop tiles to block on options nobody has defined.

server/rooms.js: REQUEST_ROLL now looks up the player's next tile,
determines what it needs via rollsNeededFor(), and pre-rolls each die via
crypto.randomInt before constructing the ROLL action — same
randomness-happens-once-at-the-server-boundary pattern as before, just
covering N table rolls instead of one movement die. server/index.js:
default config now carries startingStats instead of startingCash, and
maxPlayers is 8.
2026-07-28 10:06:59 -07:00
Kevin 220f70d342 Add the real 211-tile / 5-stat game content
shared/tile-inventory.json + shared/roll-tables.json + shared/GAME-REVIEW.md
+ shared/BRANCHING-TEMPLATE.md were parsed from the design doc in a separate
session and were already sitting untracked in this repo. This is the real,
authoritative game content — 211 tiles across 12 segments, 5 player stats
(cash/love/education/wealth/age), 48 placeholder roll tables — far more
complete than the hand-sketch-inspired board built earlier.

Adds shared/tileInventory.js and shared/rollTables.js: mechanical
`export default {...}` wrappers around the same JSON (kept as-is alongside
them as the raw source reference), so the content is importable both by the
server and by the browser fetching shared/*.js with zero bundler — the same
pattern every other file in shared/ already uses. Avoids relying on JSON
module import-attribute syntax, whose support is uncertain on node:20-alpine
(the Dockerfile's base image).

Also includes assets/colors.json and assets/tile_types.json (an apparently
separate, incomplete transcription of the hand sketch) — not wired into
anything yet, just brought into version control.

Appends integration-gap TODOs to GAME-REVIEW.md: 85 inline_table tiles have
a die size but no table content anywhere in the parsed files; payday/
cash_bonus amounts are undefined everywhere; multi-table roll_table_ref
tiles (2-3 tables at once, 22 of them) have no documented combination rule.
2026-07-28 10:05:55 -07:00
Kevin 70eeb78eaf Give the board an illustrated-map feel instead of a flowchart
Same data-driven layout as before (nothing about computeLayout changed) —
only what gets drawn changed:

- Procedural terrain backdrop: a winding river, a few mountain clusters, and
  denser two-tone trees, scattered deterministically like the existing
  tree-blobs were, so none of it needs hand-alignment to specific tiles and
  it keeps working at any board size.
- The road between spaces is now a wide dashed dirt path with a subtle
  hand-drawn "wobble" (SVG feTurbulence/feDisplacementMap) instead of a
  clean vector line.
- Tiles are beveled: a drawn shadow copy, a gloss gradient, and a few
  degrees of per-tile tilt derived from a hash of the space id (deterministic,
  not random-per-render) so they read as hand-placed rather than
  machine-stamped. Choice diamonds get a small signpost stick underneath.

Prototyped and screenshotted headlessly before committing; user confirmed
this direction over the previous flowchart-style rendering.
2026-07-22 09:17:25 -07:00
Kevin 6aa9769ce5 Expand the board to the full sketch (~107 spaces, three forks)
shared/board.js now transcribes every distinct space label from the
hand-drawn sketch (assets/game_board.png) rather than Phase 1's small
subset. The sketch's arrows get genuinely ambiguous in a few places (it
reads as a mockup, not an engineered spec) and reuses several space names
across zones (Start A Business, Family Reunion, Market Crash, ...) — that
repetition is kept as intentional flavor rather than deduplicated, and the
ambiguous bits are resolved into a clean DAG with the same shape Phase 1
proved out: a fork, a chain() per branch, a convergence — repeated three
times (Career/Education/Gap Year, then Relationship/Investment, then High
Risk/Safe), into a long shared retirement tail. Branches are built with a
small chain() helper that auto-wires each entry's `next` to the following
one, since hand-wiring ~107 ids was too error-prone.

The reducer, SQLite schema, and rooms/WS layer needed zero changes — the
whole point of the pure-reducer/graph-data design from Phase 1. Two things
did need generalizing:
- public/boardRender.js's lane offset was hardcoded to a 2-way fork; the
  new "Which Path?" fork is 3-way (Career/Education/Gap Year), so the
  offset formula is now symmetric for any number of branches.
- shared/game.test.js hardcoded Phase 1's specific space ids. Rewritten to
  be board-structure-agnostic: it always resolves the first offered choice
  and otherwise rolls the max die value, which reliably makes progress
  regardless of board shape (walkForward always stops early at the next
  choice/finish), plus a graph-well-formedness check.

Verified: unit tests green; a full two-player game played headlessly
end-to-end through all three forks to Finish in 38 turns with zero
console/page errors; the rendered board visually confirmed at full scale
(5-row snake layout, 3-way fork fans out correctly, all labels legible).
2026-07-21 18:56:27 -07:00
Kevin 8df4859696 Render the board as SVG, laid out from the graph data
Adds public/boardRender.js: a layout algorithm that derives every space's
position purely from shared/board.js (column = longest-path distance from
Start, lane = branch offset that fans out at a choice space and re-centers
wherever branches rejoin), so it keeps working unmodified as the board data
grows — no hand-placed coordinates to maintain.

Renders spaces as styled SVG nodes (color/shape by type), connects them with
curved path lines, and animates player tokens between positions. Pending
choices glow and are clickable directly on the board, in addition to the
existing text buttons. Game view widens the card on desktop for the board
and scrolls horizontally on narrow viewports.

Also repicks the player color palette (server/index.js) to avoid the board's
own semantic colors (green/gold/red), after a token nearly disappeared into
the same-colored Start space during visual testing.

Verified with a headless-browser run (Playwright, off-screen — not a desktop
screenshot): two players through lobby -> live join update -> board -> a
fork choice, no console errors, reducer tests still green.
2026-07-21 18:12:01 -07:00
Kevin d40bc09867 Phase 1: reducer, SQLite, rooms & invite links
Turns the Phase 0 deployment shell into a playable async multiplayer game:

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

Verified: shared/game.test.js (node --test) covers the full rules engine;
a scripted two-client run over real HTTP+WS confirms both clients converge
on identical state through create/join/start/play-to-finish; a server
restart mid-game preserves state and reconnect resumes the same player
without creating a duplicate.
2026-07-21 17:10:56 -07:00
Kevin 1f51c7d541 Remove throwaway board-reading crop images
assets/crops/ was a set of zoomed-in crops generated locally to read the
hand-drawn board sketch's small text — not used by any code, just repo bloat.
2026-07-21 17:09:32 -07:00
35 changed files with 14683 additions and 308 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
# Copy to .env and adjust. Nothing here is required for Phase 0.
# Copy to .env and adjust.
# PORT=3000
# DATA_DIR=/app/data
# Phase 1: used to build shareable invite links (POST /api/games -> inviteUrl).
# If unset, the server falls back to the incoming request's own protocol/host,
# which is fine for local dev but should be set behind a reverse proxy so
# invite links use your public domain rather than an internal address.
# PUBLIC_URL=https://game.example.com
# Arrives in later phases:
# ADMIN_PASSWORD=change-me # Phase 4 — protects the admin page
# PUBLIC_URL=https://game.example.com # Phase 1 — for building invite links
+6
View File
@@ -2,6 +2,11 @@ FROM node:20-alpine
WORKDIR /app
# better-sqlite3's prebuilt binary coverage for musl/Alpine is inconsistent
# across Node versions; these let its node-gyp fallback build from source
# when no prebuilt matches. No sqlite-dev needed — it bundles its own SQLite.
RUN apk add --no-cache python3 make g++
# Install dependencies first (better layer caching).
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
@@ -9,6 +14,7 @@ RUN npm ci --omit=dev
# App source.
COPY server ./server
COPY public ./public
COPY shared ./shared
ENV NODE_ENV=production
ENV PORT=3000
+65 -54
View File
@@ -1,12 +1,15 @@
# Life Journey — Deployment Shell (Phase 0)
# Life Journey — Phase 1
An empty, deployable shell for the async multiplayer board game. It contains no
game logic yet — its only job is to prove the deployment path works: the server
runs in Docker, is reachable through Nginx Proxy Manager over HTTPS, and
WebSockets survive the reverse proxy.
An async multiplayer, Game-of-Life-style board game. Phase 0 proved the
deployment path (Docker → Nginx Proxy Manager → HTTPS → WebSocket). Phase 1
adds the actual game: a pure shared reducer, SQLite persistence, and
rooms with shareable invite links, so a few people can play together across
devices and tab closes.
Once both checks are green, adding the real game (Phase 1 onward) never touches
the deployment story again.
The server is the sole authority over game state. It generates the only
source of randomness (the dice roll) and applies it through the exact same
reducer (`shared/game.js`) the browser imports — client and server can never
disagree about the rules.
## What's here
@@ -15,63 +18,71 @@ lifegame/
├── docker-compose.yml
├── Dockerfile
├── package.json / package-lock.json
├── server/index.js # health endpoint + WebSocket echo
└── public/index.html # live HTTP + WebSocket status page
├── shared/
│ ├── board.js # the Phase 1 board graph + movement helper
│ ├── game.js # pure reducer: reduce(state, action) -> newState
│ └── game.test.js # node --test coverage of the whole rules engine
├── server/
│ ├── index.js # REST + WebSocket, static hosting
│ ├── rooms.js # in-memory room registry, applies/broadcasts actions
│ ├── db.js # SQLite schema + data access (games/players/tokens)
│ └── ids.js # id/token/join-code generation
└── public/
├── index.html # lobby / waiting room / game UI
├── client.js # REST wrappers + NetworkTransport (WebSocket)
└── 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
cd ~/homelab/lifegame
docker compose up -d --build
docker compose logs -f # expect: "Life Journey (Phase 0) listening on :3000"
docker compose logs -f # expect: "Life Journey (Phase 1) listening on :3000"
```
Verify locally on the host first:
```bash
curl http://localhost:3000/api/health
# {"ok":true,"service":"life-journey","phase":0,...}
```
## 2. Point Nginx Proxy Manager at it
Add a **Proxy Host**:
- **Domain**: your chosen name, e.g. `game.example.com`
- **Scheme**: `http`
- **Forward Hostname / IP**:
- *Option A (default compose):* the homelab's LAN IP (e.g. `192.168.x.x`)
- *Option B (shared network):* `lifegame`
- **Forward Port**: `3000`
- **Websockets Support**: **ON** ← the easy-to-forget one
- **SSL tab**: request a new Let's Encrypt certificate, Force SSL on
## 3. Confirm it works
Open `https://game.example.com`. You should see two rows go green:
- **Server (HTTP)** — the health endpoint responded
- **WebSocket** — a live socket connected through the proxy
Click **Send WebSocket ping** and the status should show the echo came back.
If HTTP is green but WebSocket is red, the proxy isn't upgrading the
connection — go back and toggle **Websockets Support** on the proxy host.
## Data & backups
The `lifegame-data` volume (mounted at `/app/data`) is where the SQLite
database and uploaded player tokens will live in later phases. It's empty now,
but include it in the same backup routine as your other self-hosted data.
The `lifegame-data` volume (mounted at `/app/data`) holds `lifegame.db`
every game, player, and token. Back it up like your other self-hosted data;
losing it loses every in-progress and finished game.
## What Phase 1 adds
## What's next
- The shared `game.js` rules engine (the pure reducer from the prototype)
- SQLite data model: games, players, board/rule config, tokens
- Rooms + shareable invite links / join codes
- `NetworkTransport` on the client — the one-line swap that makes the game
run across devices
- Persistence & reconnection, since async means tabs close and servers restart
- Expand `shared/board.js` toward the full sketched board
- Richer board rendering (the current UI is a functional list view, not the
illustrated board)
- Tighter reconnection/presence handling (who's online right now, not just
who's joined)
- Admin page (`ADMIN_PASSWORD`, already stubbed in `.env.example`)
+8
View File
@@ -0,0 +1,8 @@
{
"deep pine green": "#163a30",
"warm paper": "#f6edd8",
"marigold": "#e8a12a",
"tomato": "#d6553b",
"muted green": "#3f8f5f",
"slate blue": "#3f6ea5"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+26
View File
@@ -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"
}
]
+1
View File
@@ -7,6 +7,7 @@ services:
- PORT=3000
- NODE_ENV=production
- DATA_DIR=/app/data
- PUBLIC_URL=${PUBLIC_URL:-}
volumes:
- lifegame-data:/app/data
networks:
+426 -2
View File
@@ -1,13 +1,14 @@
{
"name": "life-journey-server",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "life-journey-server",
"version": "0.0.1",
"version": "0.1.0",
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"ws": "^8.18.0"
}
@@ -31,6 +32,57 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
@@ -55,6 +107,30 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -93,6 +169,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -138,6 +220,30 @@
"ms": "2.0.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -157,6 +263,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -186,6 +301,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -231,6 +355,15 @@
"node": ">= 0.6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -277,6 +410,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -313,6 +452,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -359,6 +504,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -427,12 +578,38 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -511,12 +688,45 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -526,6 +736,18 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -550,6 +772,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -565,6 +796,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -578,6 +836,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -618,6 +886,35 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -644,6 +941,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -767,6 +1076,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -776,6 +1130,52 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -785,6 +1185,18 @@
"node": ">=0.6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -807,6 +1219,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -825,6 +1243,12 @@
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+5 -3
View File
@@ -1,15 +1,17 @@
{
"name": "life-journey-server",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Async multiplayer board game — deployment shell (Phase 0)",
"description": "Async multiplayer board game — Phase 1 (rooms, reducer, persistence)",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js"
"dev": "node --watch server/index.js",
"test": "node --test shared/*.test.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2",
"ws": "^8.18.0"
}
+403
View File
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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, '\\$&');
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Browser-side networking: thin REST wrappers, per-game session storage, and
* a NetworkTransport wrapping the authenticated WebSocket. UI code (index.html)
* never touches fetch()/WebSocket directly — it goes through this module.
*/
// Re-exported purely so the UI can decide what to show (enable the Roll
// button, render choice options, ...). Authoritative state always comes from
// the server's `state` broadcast — the UI never re-derives it locally.
export { getLegalIntents, isPlayersTurn } from '/shared/game.js';
export { board } from '/shared/board.js';
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
export function createGame(hostName) {
return postJson('/api/games', { hostName });
}
export function joinGame(code, name) {
return postJson(`/api/games/${encodeURIComponent(code)}/join`, { name });
}
export async function fetchGame(code) {
const res = await fetch(`/api/games/${encodeURIComponent(code)}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
// --- Per-game session persistence (localStorage), so a reload/reconnect
// resumes as the same player instead of joining again. ---
const STORAGE_PREFIX = 'lifegame:session:';
export function saveSession(code, session) {
localStorage.setItem(STORAGE_PREFIX + code, JSON.stringify(session));
}
export function loadSession(code) {
const raw = localStorage.getItem(STORAGE_PREFIX + code);
return raw ? JSON.parse(raw) : null;
}
// --- WebSocket transport ---
export class NetworkTransport {
constructor() {
this.ws = null;
this._stateHandlers = [];
this._errorHandlers = [];
}
/** Opens the socket, authenticates with the session token, and resolves
* once the first authoritative state has been received. */
connect(sessionToken) {
return new Promise((resolve, reject) => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
this.ws = ws;
let settled = false;
ws.onopen = () => ws.send(JSON.stringify({ type: 'AUTH', token: sessionToken }));
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'state') {
if (!settled) {
settled = true;
resolve();
}
this._stateHandlers.forEach((cb) => cb(msg));
} else if (msg.type === 'error') {
this._errorHandlers.forEach((cb) => cb(msg));
}
};
ws.onerror = () => {
if (!settled) {
settled = true;
reject(new Error('WebSocket connection failed'));
}
};
ws.onclose = (event) => {
if (!settled) {
settled = true;
reject(new Error(`Connection closed (${event.code})`));
}
};
});
}
sendIntent(intent) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(intent));
}
}
onState(cb) {
this._stateHandlers.push(cb);
}
onError(cb) {
this._errorHandlers.push(cb);
}
close() {
this.ws?.close();
}
}
+366 -64
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Life Journey — Deployment Check</title>
<title>Life Journey</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
@@ -18,100 +18,402 @@
margin: 0; min-height: 100vh; padding: 24px 16px;
font-family: "Nunito Sans", system-ui, sans-serif; color: var(--ink);
background: radial-gradient(1200px 600px at 50% -10%, #1d493c 0%, var(--page) 45%, var(--page-2) 100%);
display: flex; align-items: center; justify-content: center;
display: flex; align-items: flex-start; justify-content: center;
}
.card {
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 460px; width: 100%;
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%;
margin-top: 24px; 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; }
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
.check {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
border: 1.5px solid var(--line); border-radius: 12px; background: #fffaf0; margin-bottom: 10px;
label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
input[type="text"] {
width: 100%; padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px;
font-family: inherit; font-size: 14px; background: #fffaf0; color: var(--ink);
}
.dot { width: 14px; height: 14px; border-radius: 50%; background: var(--ink-soft); flex: none; }
.dot.ok { background: var(--good); } .dot.fail { background: var(--bad); }
.check .name { font-weight: 700; font-family: "Baloo 2"; }
.check .state { margin-left: auto; font-size: 13px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
button {
font-family: "Baloo 2"; font-weight: 700; font-size: 14px; color: var(--ink);
background: var(--marigold); border: none; border-radius: 10px; padding: 9px 16px;
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 8px;
cursor: pointer; box-shadow: 0 3px 0 #b97f18; margin-top: 10px; margin-right: 8px;
}
button:active { transform: translateY(3px); box-shadow: 0 0 0 #b97f18; }
.note { font-size: 12px; color: var(--ink-soft); margin-top: 16px; line-height: 1.5; }
code { background: #efe5cc; padding: 1px 5px; border-radius: 5px; font-size: 12px; }
button:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #efe5cc; box-shadow: 0 3px 0 var(--line); }
hr { border: none; border-top: 1.5px dashed var(--line); margin: 20px 0; }
.invite-row { display: flex; gap: 8px; margin-top: 8px; }
.invite-row input { flex: 1; }
ul.player-list { list-style: none; padding: 0; margin: 12px 0; }
ul.player-list li {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px;
}
ul.player-list li.active { border-color: var(--marigold); background: #fff3dc; }
.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>
</head>
<body>
<div class="card">
<div class="card" id="app">
<h1>Life Journey</h1>
<p class="sub">Deployment check · Phase 0 — no game here yet</p>
<p class="sub" id="subtitle">Async multiplayer — create a room or join one via invite link.</p>
<div class="check">
<span class="dot" id="http-dot"></span>
<span class="name">Server (HTTP)</span>
<span class="state" id="http-state">checking…</span>
<section id="panel-entry">
<label for="name-input">Your name</label>
<input type="text" id="name-input" maxlength="40" placeholder="e.g. Alice" />
<div id="create-section">
<button id="create-btn">Create Game</button>
</div>
<div class="check">
<span class="dot" id="ws-dot"></span>
<span class="name">WebSocket</span>
<span class="state" id="ws-state">connecting…</span>
<hr />
<label for="join-code-input">Game code</label>
<input type="text" id="join-code-input" maxlength="6" placeholder="e.g. AB3XQ9" style="text-transform:uppercase" />
<button id="join-btn" class="secondary">Join Game</button>
</section>
<section id="panel-lobby" hidden>
<label>Invite link</label>
<div class="invite-row">
<input type="text" id="invite-link" readonly />
<button id="copy-link-btn" class="secondary">Copy</button>
</div>
<button id="ping">Send WebSocket ping</button>
<hr />
<p class="note">
Both dots green means the shell is deployed correctly and your reverse
proxy is passing WebSockets. If the WebSocket dot is red but HTTP is green,
enable <code>Websockets Support</code> on the proxy host in Nginx Proxy Manager.
</p>
<label>Players</label>
<ul class="player-list" id="player-list"></ul>
<button id="start-btn" hidden>Start Game</button>
<p class="hint" id="lobby-hint"></p>
</section>
<section id="panel-game" hidden>
<div class="turn-banner" id="turn-banner"></div>
<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>
<script>
// ---- HTTP health check ----
const httpDot = document.getElementById('http-dot');
const httpState = document.getElementById('http-state');
fetch('/api/health')
.then(r => r.json())
.then(d => {
httpDot.classList.add('ok');
httpState.textContent = `ok · v${d.version}`;
})
.catch(() => {
httpDot.classList.add('fail');
httpState.textContent = 'unreachable';
});
<script type="module">
import {
createGame, joinGame, saveSession, loadSession,
NetworkTransport, getLegalIntents, board,
} from '/client.js';
import { BoardView } from '/boardRender.js';
// ---- WebSocket check ----
const wsDot = document.getElementById('ws-dot');
const wsState = document.getElementById('ws-state');
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
let ws;
const els = {
app: document.getElementById('app'),
subtitle: document.getElementById('subtitle'),
panelEntry: document.getElementById('panel-entry'),
panelLobby: document.getElementById('panel-lobby'),
panelGame: document.getElementById('panel-game'),
nameInput: document.getElementById('name-input'),
createSection: document.getElementById('create-section'),
createBtn: document.getElementById('create-btn'),
joinCodeInput: document.getElementById('join-code-input'),
joinBtn: document.getElementById('join-btn'),
inviteLink: document.getElementById('invite-link'),
copyLinkBtn: document.getElementById('copy-link-btn'),
playerList: document.getElementById('player-list'),
startBtn: document.getElementById('start-btn'),
lobbyHint: document.getElementById('lobby-hint'),
turnBanner: document.getElementById('turn-banner'),
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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
async function connect() {
transport = new NetworkTransport();
transport.onState((msg) => render(msg.state));
transport.onError((msg) => showError(msg.message));
try {
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { wsDot.classList.add('ok'); wsState.textContent = 'connected'; };
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'echo') wsState.textContent = 'echo received ✓';
else if (data.type === 'welcome') wsState.textContent = 'connected';
};
ws.onerror = () => { wsDot.classList.add('fail'); wsState.textContent = 'failed'; };
ws.onclose = () => { if (!wsDot.classList.contains('ok')) { wsDot.classList.add('fail'); wsState.textContent = 'closed'; } };
} catch {
wsDot.classList.add('fail'); wsState.textContent = 'unsupported';
await transport.connect(self.sessionToken);
} catch (err) {
showError(`Could not connect: ${err.message}`);
}
}
document.getElementById('ping').onclick = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('ping ' + new Date().toISOString());
wsState.textContent = 'ping sent…';
function render(state) {
if (state.status === 'lobby') renderLobby(state);
else renderGame(state);
}
function renderLobby(state) {
showPanel('lobby');
els.inviteLink.value = `${location.origin}/join/${self.code}`;
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.playerList.innerHTML = '';
for (const p of players) {
const li = document.createElement('li');
li.innerHTML = `<span class="dot" style="background:${p.color}"></span> ${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}`;
els.playerList.appendChild(li);
}
const legal = getLegalIntents(state, self.playerId);
els.startBtn.hidden = !legal.includes('REQUEST_START');
els.lobbyHint.textContent = players.length < state.config.minPlayers
? `Waiting for at least ${state.config.minPlayers} players…`
: 'Ready to start!';
}
function renderGame(state) {
showPanel('game');
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 {
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>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
/**
* SQLite persistence. games/players hold identity + metadata; tokens covers
* both unclaimed room invite links (kind='invite', player_id NULL) and
* per-player reconnect secrets (kind='session'). config/state are stored as
* JSON text — Phase 1 never needs to query into them.
*/
import Database from 'better-sqlite3';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
board_id TEXT NOT NULL,
config TEXT NOT NULL,
state TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','active','finished')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seat INTEGER NOT NULL,
color TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_seen_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_players_game_id ON players(game_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_players_game_seat ON players(game_id, seat);
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY,
game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
player_id TEXT REFERENCES players(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('invite','session')),
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_tokens_game_id ON tokens(game_id);
CREATE INDEX IF NOT EXISTS idx_tokens_player_id ON tokens(player_id);
`;
let db;
export function initDb(dbPath) {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
return db;
}
function now() {
return new Date().toISOString();
}
function deserializeGame(row) {
if (!row) return null;
return { ...row, config: JSON.parse(row.config), state: JSON.parse(row.state) };
}
export function createGame({ id, code, boardId, config, state }) {
db.prepare(
`INSERT INTO games (id, code, board_id, config, state) VALUES (?, ?, ?, ?, ?)`
).run(id, code, boardId, JSON.stringify(config), JSON.stringify(state));
return getGameById(id);
}
export function getGameByCode(code) {
return deserializeGame(db.prepare(`SELECT * FROM games WHERE code = ?`).get(code));
}
export function getGameById(id) {
return deserializeGame(db.prepare(`SELECT * FROM games WHERE id = ?`).get(id));
}
export function updateGameState(id, state, status) {
db.prepare(`UPDATE games SET state = ?, status = ?, updated_at = ? WHERE id = ?`).run(
JSON.stringify(state),
status,
now(),
id
);
}
export function createPlayer({ id, gameId, name, seat, color }) {
db.prepare(
`INSERT INTO players (id, game_id, name, seat, color) VALUES (?, ?, ?, ?, ?)`
).run(id, gameId, name, seat, color);
}
export function getPlayersByGame(gameId) {
return db.prepare(`SELECT * FROM players WHERE game_id = ? ORDER BY seat`).all(gameId);
}
export function countPlayers(gameId) {
return db.prepare(`SELECT COUNT(*) AS n FROM players WHERE game_id = ?`).get(gameId).n;
}
export function touchPlayerLastSeen(playerId) {
db.prepare(`UPDATE players SET last_seen_at = ? WHERE id = ?`).run(now(), playerId);
}
export function createToken({ token, gameId, playerId = null, kind, expiresAt = null }) {
db.prepare(
`INSERT INTO tokens (token, game_id, player_id, kind, expires_at) VALUES (?, ?, ?, ?, ?)`
).run(token, gameId, playerId, kind, expiresAt);
}
export function getToken(token) {
return db.prepare(`SELECT * FROM tokens WHERE token = ?`).get(token) ?? null;
}
+20
View File
@@ -0,0 +1,20 @@
import crypto from 'node:crypto';
// Unambiguous alphabet for human-shareable join codes: no 0/O or 1/I.
const CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
export function randomId() {
return crypto.randomUUID();
}
export function randomToken() {
return crypto.randomBytes(24).toString('base64url');
}
export function randomJoinCode(length = 6) {
let code = '';
for (let i = 0; i < length; i++) {
code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)];
}
return code;
}
+195 -24
View File
@@ -1,14 +1,11 @@
/**
* Life Journey — Phase 0 deployment shell.
* Life Journey — Phase 1 server.
*
* This does the minimum needed to prove the plumbing works end to end:
* - serves the static frontend
* - answers GET /api/health (is the server reachable?)
* - accepts a WebSocket on /ws (does WS survive the reverse proxy?)
*
* There is deliberately NO game logic here yet. Phase 1 adds the shared
* game.js reducer, a SQLite-backed data model, rooms, and real actions.
* The data directory is created now so the Docker volume mount is validated.
* The server is the sole authority over game state: it validates client
* intents, generates the only source of randomness (the dice roll), builds
* the real action, and applies it through the exact same shared reducer the
* browser imports. Rooms/tokens/state are persisted to SQLite so a restart
* (or a closed tab reconnecting later) resumes rather than resets.
*/
import http from 'node:http';
@@ -18,46 +15,220 @@ import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import { board } from '../shared/board.js';
import { createInitialState } from '../shared/game.js';
import * as db from './db.js';
import * as rooms from './rooms.js';
import { randomId, randomToken, randomJoinCode } from './ids.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
// Ensure the persisted data directory exists (future: SQLite db + token uploads).
fs.mkdirSync(DATA_DIR, { recursive: true });
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
const DEFAULT_CONFIG = {
minPlayers: 2,
maxPlayers: 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();
app.use(express.json());
app.use((err, _req, res, next) => {
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Invalid JSON' });
next(err);
});
// --- Health check: lets the frontend (and you) confirm the server is up ---
// --- Health check ---
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
service: 'life-journey',
phase: 0,
version: '0.0.1',
time: new Date().toISOString(),
res.json({ ok: true, service: 'life-journey', phase: 1, version: '0.1.0', time: new Date().toISOString() });
});
// --- Helpers ---
function cleanName(raw, label) {
const name = typeof raw === 'string' ? raw.trim() : '';
if (!name) throw Object.assign(new Error(`${label} is required`), { status: 400 });
if (name.length > 40) throw Object.assign(new Error(`${label} is too long`), { status: 400 });
return name;
}
function uniqueJoinCode() {
let code;
do {
code = randomJoinCode();
} while (db.getGameByCode(code));
return code;
}
function buildInviteUrl(req, code) {
const base = (process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
return `${base}/join/${code}`;
}
function colorForSeat(seat) {
return PLAYER_COLORS[(seat - 1) % PLAYER_COLORS.length];
}
// --- REST: games/rooms ---
app.post('/api/games', (req, res) => {
let hostName;
try {
hostName = cleanName(req.body?.hostName, 'hostName');
} catch (err) {
return res.status(err.status ?? 400).json({ error: err.message });
}
const gameId = randomId();
const code = uniqueJoinCode();
const initialState = createInitialState(DEFAULT_CONFIG);
db.createGame({ id: gameId, code, boardId: board.id, config: DEFAULT_CONFIG, state: initialState });
const room = rooms.loadOrCreateRoom(gameId);
const playerId = randomId();
const color = colorForSeat(1);
db.createPlayer({ id: playerId, gameId, name: hostName, seat: 1, color });
let state;
try {
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name: hostName, seat: 1, color });
} catch (err) {
return res.status(500).json({ error: err.message });
}
const sessionToken = randomToken();
db.createToken({ token: sessionToken, gameId, playerId, kind: 'session' });
res.status(201).json({
gameId,
code,
inviteUrl: buildInviteUrl(req, code),
playerId,
sessionToken,
state,
});
});
app.get('/api/games/:code', (req, res) => {
const game = db.getGameByCode(req.params.code);
if (!game) return res.status(404).json({ error: 'Game not found' });
res.json({
gameId: game.id,
code: game.code,
status: game.status,
config: game.config,
players: db.getPlayersByGame(game.id),
state: game.state,
});
});
app.post('/api/games/:code/join', (req, res) => {
const game = db.getGameByCode(req.params.code);
if (!game) return res.status(404).json({ error: 'Game not found' });
if (game.status !== 'lobby') return res.status(409).json({ error: 'Game already started' });
let name;
try {
name = cleanName(req.body?.name, 'name');
} catch (err) {
return res.status(err.status ?? 400).json({ error: err.message });
}
const seat = db.countPlayers(game.id) + 1;
if (seat > game.config.maxPlayers) return res.status(409).json({ error: 'Game is full' });
const room = rooms.loadOrCreateRoom(game.id);
const playerId = randomId();
const color = colorForSeat(seat);
db.createPlayer({ id: playerId, gameId: game.id, name, seat, color });
let state;
try {
state = rooms.applyAndBroadcast(room, { type: 'JOIN', playerId, name, seat, color });
} catch (err) {
return res.status(409).json({ error: err.message });
}
const sessionToken = randomToken();
db.createToken({ token: sessionToken, gameId: game.id, playerId, kind: 'session' });
res.status(201).json({ gameId: game.id, playerId, sessionToken, state });
});
// --- Static frontend ---
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
app.use('/shared', express.static(path.join(__dirname, '..', 'shared')));
// Deep link for invite URLs — express.static won't match this path.
app.get('/join/:code', (_req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
const server = http.createServer(app);
// --- WebSocket smoke test on /ws ---
// If this connects through your domain, Nginx Proxy Manager is passing the
// Upgrade/Connection headers correctly ("Websockets Support" is ON). Phase 1
// reuses this exact endpoint to push game state to each device.
// --- WebSocket: room-aware, authenticated via a first {type:'AUTH'} message ---
// (not a query param, so the session token never lands in access/proxy logs)
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'welcome', msg: 'WebSocket connected' }));
let authenticated = false;
let room = null;
let playerId = null;
const authTimeout = setTimeout(() => {
if (!authenticated) ws.close(4001, 'auth timeout');
}, AUTH_TIMEOUT_MS);
ws.on('message', (raw) => {
ws.send(JSON.stringify({ type: 'echo', received: raw.toString() }));
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
if (!authenticated) {
if (msg?.type !== 'AUTH' || typeof msg.token !== 'string') {
ws.close(4001, 'expected AUTH');
return;
}
const tokenRow = db.getToken(msg.token);
if (!tokenRow || tokenRow.kind !== 'session') {
ws.close(4001, 'invalid token');
return;
}
let targetRoom;
try {
targetRoom = rooms.loadOrCreateRoom(tokenRow.game_id);
} catch {
ws.close(4004, 'game not found');
return;
}
clearTimeout(authTimeout);
authenticated = true;
room = targetRoom;
playerId = tokenRow.player_id;
rooms.attachSocket(room, ws, playerId);
db.touchPlayerLastSeen(playerId);
return;
}
if (typeof msg?.type !== 'string') return;
rooms.handleIntent(room, ws, playerId, msg);
});
ws.on('close', () => clearTimeout(authTimeout));
});
server.listen(PORT, () => {
console.log(`Life Journey (Phase 0) listening on :${PORT}`);
console.log(`Life Journey (Phase 1) listening on :${PORT}`);
console.log(`Data directory: ${DATA_DIR}`);
});
+97
View File
@@ -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));
}
+75
View File
@@ -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.
+324
View File
@@ -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 23 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.
+78 -65
View File
@@ -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):
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into
* any branch array later (repointing one `next`) without touching the reducer.
* TEMPORARY: the 12 segments have no defined connections yet — that's what
* shared/BRANCHING-TEMPLATE.md is an open request for (the STOP forks, and
* which segment's exit feeds which segment's entry). Until board-graph.json
* 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? }
* type: 'start' | 'event' | 'money' | 'choice' | 'finish'
* next: id of the following space (absent on 'choice' and 'finish' spaces)
* choices: [id, id] of the branches offered by a 'choice' space
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces)
* Similarly, no tile in the source data has type 'finish' (win condition is
* itself an open question in GAME-REVIEW.md) — a synthetic `finish` space is
* appended after the last tile so the reducer has something to end on.
*
* 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 = [
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' },
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] },
import tileInventory from './tileInventory.js';
{ id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' },
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' },
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' },
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' },
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' },
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' },
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' },
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' },
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' },
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' },
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' },
{ 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 TEMP_SEGMENT_ORDER = [
'starting_strip',
'career',
'investment',
'investment_bottom',
'high_risk',
'gap_year',
'education',
'relationship_top',
'relationship_bottom',
'retirement_top',
'retirement_middle',
'retirement_bottom',
];
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 = {
id: 'phase1-demo',
startSpaceId: 'start',
id: 'life-journey-full-v1',
startSpaceId: `${TEMP_SEGMENT_ORDER[0]}_0`,
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;
}
+43 -22
View File
@@ -2,17 +2,27 @@
* Life Journey — pure game reducer.
*
* 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
* travels inside the ROLL action payload, so server and client can both apply
* the exact same action through this exact same function and land on identical
* state. Illegal transitions throw rather than no-op — the server is expected
* to gate intents with getLegalIntents()/isPlayersTurn() before ever
* constructing an action, so a throw here means that gate was bypassed.
* generates randomness itself: every roll a tile needs is generated by the
* server and travels inside the action payload (`rolls`), so server and
* client can both apply the exact same action through this exact same
* function and land on identical state. Illegal transitions throw rather
* than no-op — the server is expected to gate intents with
* 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;
export function createInitialState(config = {}) {
@@ -90,7 +100,7 @@ function applyJoin(state, { playerId, name, seat, color }) {
seat,
color,
position: board.startSpaceId,
cash: state.config.startingCash,
...state.config.startingStats,
pendingChoice: null,
finished: false,
};
@@ -107,37 +117,38 @@ function applyStart(state) {
return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] };
}
function applyRoll(state, { playerId, value }) {
function applyRoll(state, { playerId, rolls }) {
assertActive(state);
assertPlayersTurn(state, playerId);
const player = state.players[playerId];
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
const landed = walkForward(player.position, value);
return landOn(state, playerId, landed, { type: 'ROLL', value });
const nextSpaceId = board.spaces[player.position].next;
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);
assertPlayersTurn(state, playerId);
const player = state.players[playerId];
const pending = player.pendingChoice;
if (!pending) throw new Error('No pending 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
* resolved branch: apply the space's effect, then either leave the turn
* open (choice pending), end the game (finish), or advance to the next player. */
function landOn(state, playerId, spaceId, logMeta) {
* resolved branch: resolve the space's tile effect, then either leave the
* turn open (a real choice is pending — only true once board-graph.json
* 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 cashDelta = space.cash ?? 0;
const { statDelta, description, todo } = resolveTileEffect(space, rolls ?? []);
const priorPlayer = state.players[playerId];
const nextPlayer = {
...priorPlayer,
...applyStatDelta(priorPlayer, statDelta),
position: spaceId,
cash: priorPlayer.cash + cashDelta,
pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null,
pendingChoice: space.choices?.length ? { atSpace: spaceId, options: space.choices } : null,
finished: space.type === 'finish',
};
@@ -146,7 +157,9 @@ function landOn(state, playerId, spaceId, logMeta) {
playerId,
landedOn: spaceId,
label: space.label,
cashDelta,
statDelta,
description,
todo,
...logMeta,
});
@@ -162,6 +175,14 @@ function landOn(state, playerId, spaceId, logMeta) {
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) {
const turnIndex = (state.turnIndex + 1) % state.turnOrder.length;
return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] };
+101 -69
View File
@@ -1,11 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
import { board } from './board.js';
import { rollsNeededFor } from './tileEffects.js';
function join(state, playerId, name, seat) {
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', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
@@ -14,7 +34,17 @@ test('lobby: join validation', () => {
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();
state = join(state, 'p1', 'Alice', 1);
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.deepEqual(state.turnOrder, ['p1', 'p2']);
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, 'p2'), []);
assert.equal(isPlayersTurn(state, 'p2'), false);
// Not p2's turn yet.
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
// p1 rolls onto the first choice space; movement stops immediately even
// though only 1 of the roll's pips was needed.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
assert.equal(state.players.p1.position, 'crossroads');
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] });
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose');
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/);
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/);
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' });
assert.equal(state.players.p1.position, 'career_1');
assert.equal(state.players.p1.pendingChoice, null);
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn');
// p2 takes the education branch.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 });
assert.equal(state.players.p2.position, 'crossroads');
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' });
assert.equal(state.players.p2.position, 'edu_1');
assert.equal(state.currentTurn, 'p1');
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed
// space's cash effect applies, not spaces merely passed through.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 });
assert.equal(state.players.p1.position, 'life_crossroads');
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply');
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' });
assert.equal(state.players.p1.position, 'investment_1');
assert.equal(state.players.p1.cash, -150);
assert.equal(state.currentTurn, 'p2');
// p2: edu_1 -> life_crossroads is also exactly 6 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 });
assert.equal(state.players.p2.position, 'life_crossroads');
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' });
assert.equal(state.players.p2.position, 'relationship_1');
assert.equal(state.currentTurn, 'p1');
// p1: investment_1 -> high_risk_choice is exactly 4 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 });
assert.equal(state.players.p1.position, 'high_risk_choice');
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' });
assert.equal(state.players.p1.cash, -50); // -150 + 100
assert.equal(state.currentTurn, 'p2');
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 });
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);
assert.equal(state.currentTurn, 'p1');
// p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game.
assert.equal(state.status, 'active');
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
assert.equal(state.players.p1.position, 'finish');
assert.equal(state.status, 'finished');
assert.equal(state.winnerId, 'p1');
assert.equal(state.currentTurn, null);
assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', rolls: [] }), /Not your turn/);
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
});
test('a single roll advances exactly one tile and applies its effect', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
state = join(state, 'p2', 'Bob', 2);
state = reduce(state, { type: 'START_GAME' });
const expectedNext = board.spaces[board.startSpaceId].next;
state = takeTurn(state, 'p1');
assert.equal(state.players.p1.position, expectedNext);
assert.equal(state.currentTurn, 'p2', 'turn advances since no choice is pending yet');
assert.equal(state.log.at(-1).landedOn, expectedNext);
});
test('full board: walking every tile to the temporary finish never crashes and always terminates', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
state = join(state, 'p2', 'Bob', 2);
state = reduce(state, { type: 'START_GAME' });
const seenTypes = new Set();
let guard = 0;
while (state.status === 'active') {
if (++guard > 1000) throw new Error('game did not finish in a reasonable number of turns');
const turnPlayerId = state.currentTurn;
state = takeTurn(state, turnPlayerId);
const after = state.players[turnPlayerId];
seenTypes.add(board.spaces[after.position].type);
for (const stat of ['cash', 'love', 'education', 'wealth', 'age']) {
assert.equal(typeof after[stat], 'number', `${stat} stays numeric`);
}
assert.ok(board.spaces[after.position], 'player is always on a real space');
}
assert.equal(state.status, 'finished');
assert.ok(state.winnerId);
assert.equal(state.currentTurn, null);
assert.equal(state.players[state.winnerId].position, 'finish');
assert.deepEqual(getLegalIntents(state, 'p1'), []);
// Every distinct tile type in the real board actually got resolved along the way.
for (const type of [
'payday', 'action_space', 'dice_space', 'roll_table_ref',
'inline_table', 'cash_bonus', 'event', 'choice', 'stop',
]) {
assert.ok(seenTypes.has(type), `expected to land on a "${type}" tile during a full playthrough`);
}
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', rolls: [] }), /not active/);
});
test('board graph is well-formed', () => {
const ids = Object.keys(board.spaces);
assert.equal(ids.length, 212, '211 real tiles + 1 synthetic finish');
let nonFinishCount = 0;
for (const id of ids) {
const space = board.spaces[id];
if (space.type === 'finish') {
assert.equal(space.next, undefined);
continue;
}
nonFinishCount++;
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
}
assert.equal(nonFinishCount, 211);
});
File diff suppressed because it is too large Load Diff
+3557
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+189
View File
@@ -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;
}
}
+110
View File
@@ -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}`);
}
}
});
File diff suppressed because it is too large Load Diff