Compare commits

...

4 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
18 changed files with 12886 additions and 277 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"deep pine green": "#163a30",
"warm paper": "#f6edd8",
"marigold": "#e8a12a",
"tomato": "#d6553b",
"muted green": "#3f8f5f",
"slate blue": "#3f6ea5"
}
+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 -1
View File
@@ -8,7 +8,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"test": "node --test shared/game.test.js"
"test": "node --test shared/*.test.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
+22 -16
View File
@@ -24,22 +24,27 @@ const NODE_H = 60;
const PLAYER_TOKEN_R = 12;
const TYPE_STYLE = {
start: { icon: '🚩', shape: 'circle' },
finish: { icon: '🏁', shape: 'circle' },
choice: { icon: '🔀', shape: 'diamond' },
money: { icon: '💰', shape: 'rect' },
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 = 14) {
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 : (space.next ? [space.next] : []);
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);
@@ -59,14 +64,15 @@ export function computeLayout(board, colsPerRow = 14) {
children.forEach((childId, i) => {
col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1));
// Choice spaces fan their N branches out symmetrically around the
// parent's lane (works for 2-way, 3-way, ... forks alike); anything
// else just inherits the parent's lane unchanged. A join (>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 = space.type === 'choice' ? i - (children.length - 1) / 2 : 0;
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);
@@ -294,8 +300,9 @@ export class BoardView {
_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}`,
class: `space space-${space.type}${isStart ? ' space-start-marker' : ''}`,
transform: `translate(${pos.x}, ${pos.y})`,
'data-space-id': space.id,
});
@@ -307,7 +314,7 @@ export class BoardView {
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') {
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);
@@ -315,15 +322,14 @@ export class BoardView {
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';
div.innerHTML = `<span class="space-icon">${style.icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
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.cash) {
const badge = svgEl('text', {
class: `cash-badge ${space.cash > 0 ? 'pos' : 'neg'}`, y: NODE_H / 2 + 17, 'text-anchor': 'middle',
});
badge.textContent = `${space.cash > 0 ? '+' : ''}${space.cash}`;
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);
}
+17 -10
View File
@@ -50,6 +50,8 @@
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; }
@@ -101,21 +103,25 @@
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
filter: drop-shadow(0 1px 1px rgba(0,0,0,.25));
}
.space-start .space-shape { fill: var(--good); stroke: #2c6b44; }
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
.space-event .space-shape { fill: #e9d9f7; stroke: #b79bd6; }
.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-start .space-label, .space-choice .space-label { color: #fff; }
.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; }
.cash-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; }
.cash-badge.pos { fill: var(--good); }
.cash-badge.neg { fill: var(--bad); }
.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;
}
@@ -296,7 +302,8 @@
const spaceLabel = board.spaces[p.position]?.label ?? p.position;
li.innerHTML = `<span class="dot" style="background:${p.color}"></span>
<strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong>
${escapeHtml(spaceLabel)} · $${p.cash}`;
${escapeHtml(spaceLabel)}
<span class="stat-line">💵$${p.cash} · ❤️${p.love} · 🎓${p.education} · 💎${p.wealth} · 🎂${p.age}</span>`;
els.gamePlayerList.appendChild(li);
}
@@ -325,9 +332,9 @@
els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => {
const p = state.players[entry.playerId];
const sign = entry.cashDelta > 0 ? '+' : '';
const cashPart = entry.cashDelta ? ` (${sign}${entry.cashDelta})` : '';
return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(entry.label)}${cashPart}</div>`;
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') {
+8 -2
View File
@@ -28,10 +28,16 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(DATA_DIR, { recursive: true });
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
const 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'];
const PLAYER_COLORS = [
'#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c', '#4a4a9f', '#8a8a3f',
];
const AUTH_TIMEOUT_MS = 10_000;
const app = express();
+23 -4
View File
@@ -6,6 +6,8 @@
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 {}
@@ -59,16 +61,33 @@ function buildAction(state, playerId, intent) {
switch (intent.type) {
case 'REQUEST_START':
return { type: 'START_GAME' };
case 'REQUEST_ROLL':
return { type: 'ROLL', playerId, value: 1 + crypto.randomInt(state.config.diceSides) };
case 'REQUEST_CHOOSE':
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 };
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);
}
+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.
+76 -191
View File
@@ -1,204 +1,89 @@
/**
* Life Journey — full board.
* Life Journey — the real board: 211 tiles across 12 segments, built from
* shared/tileInventory.js (parsed from the design doc).
*
* Transcribed from the hand-drawn sketch (assets/game_board.png), which uses
* many space names more than once across its zones (Start A Business,
* Family Reunion, Market Crash, ...) — that repetition is treated as
* intentional recurring flavor, not deduplicated. The sketch's arrows get
* genuinely ambiguous in a few spots (a hand-drawn/AI-generated mockup, not
* an engineered spec), so rather than force a literal reverse-engineering of
* every arrow, every distinct label from the sketch is kept and organized
* into a clean DAG with the same shape Phase 1 already proved out: a fork,
* a chain per branch, a convergence, repeated three times, then a long
* shared retirement tail. More spaces can still be inserted into any branch
* later — just splice into its chain() array below.
* 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, ...] of the branches offered by a 'choice' space (2 or more)
* 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.
*/
/** Wires each entry's `next` to the following entry's id; the last one gets `finalNext`. */
function chain(entries, finalNext) {
return entries.map((entry, i) => ({
...entry,
next: i < entries.length - 1 ? entries[i + 1].id : finalNext,
import tileInventory from './tileInventory.js';
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 careerChain = chain([
{ id: 'career_1', type: 'event', label: 'High School Dropout' },
{ id: 'career_2', type: 'event', label: 'New Job' },
{ id: 'career_3', type: 'money', label: 'Performance Review', cash: 200 },
{ id: 'career_4', type: 'event', label: 'Volunteered' },
{ id: 'career_5', type: 'event', label: 'Direct Report' },
{ id: 'career_6', type: 'money', label: 'Start A Business', cash: -250 },
{ id: 'career_7', type: 'event', label: 'Office Happy Hour' },
{ id: 'career_8', type: 'money', label: 'Work Trip', cash: -100 },
{ id: 'career_9', type: 'event', label: 'Remote Vs Office' },
{ id: 'career_10', type: 'event', label: 'Exit Interview' },
{ id: 'career_11', type: 'event', label: 'Resignation' },
{ id: 'career_12', type: 'money', label: 'Industry Shift', cash: -150 },
{ id: 'career_13', type: 'money', label: 'Workplace Drama', cash: -150 },
{ id: 'career_14', type: 'money', label: 'Big Career Moment', cash: 350 },
{ id: 'career_15', type: 'event', label: 'Mentorship' },
], 'join_1');
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);
});
const eduChain = chain([
{ id: 'edu_1', type: 'event', label: 'College Enrolled' },
{ id: 'edu_2', type: 'money', label: 'Scholarship', cash: 400 },
{ id: 'edu_3', type: 'event', label: '4-Year Degree' },
{ id: 'edu_4', type: 'event', label: 'Community College' },
{ id: 'edu_5', type: 'event', label: 'Apprenticeship' },
{ id: 'edu_6', type: 'money', label: 'Campus Drama', cash: -100 },
{ id: 'edu_7', type: 'money', label: 'Study Abroad', cash: -150 },
{ id: 'edu_8', type: 'event', label: 'Trade Circuit' },
{ id: 'edu_9', type: 'event', label: 'Mentor' },
{ id: 'edu_10', type: 'money', label: 'Online Courses', cash: -50 },
{ id: 'edu_11', type: 'event', label: 'Graduation' },
], 'join_1');
const gapChain = chain([
{ id: 'gap_1', type: 'event', label: 'Canada', flavor: '🇨🇦' },
{ id: 'gap_2', type: 'event', label: 'Iceland', flavor: '🇮🇸' },
{ id: 'gap_3', type: 'event', label: 'Travel Buddy' },
{ id: 'gap_4', type: 'event', label: 'Mexico', flavor: '🇲🇽' },
{ id: 'gap_5', type: 'money', label: 'Cultural Mistake', cash: -100 },
{ id: 'gap_6', type: 'event', label: 'Australia', flavor: '🇦🇺' },
{ id: 'gap_7', type: 'event', label: 'Found Community' },
{ id: 'gap_8', type: 'event', label: 'Japan', flavor: '🇯🇵' },
{ id: 'gap_9', type: 'money', label: 'Visa Problem', cash: -150 },
{ id: 'gap_10', type: 'event', label: 'Germany', flavor: '🇩🇪' },
{ id: 'gap_11', type: 'event', label: 'Italy', flavor: '🇮🇹' },
], 'join_1');
const quarterLifeChain = chain([
{ id: 'join_1', type: 'event', label: 'Quarter-Life Crisis' },
{ id: 'q_2', type: 'money', label: 'Horrible Hangover', cash: -50 },
{ id: 'q_3', type: 'event', label: '30th Birthday' },
{ id: 'q_4', type: 'money', label: 'Mystery Tattoo', cash: -100 },
{ id: 'q_5', type: 'money', label: 'Unexpected Joy', cash: 200 },
], 'life_fork');
const familyChain = chain([
{ id: 'family_1', type: 'event', label: 'New City' },
{ id: 'family_2', type: 'event', label: 'Uncles Condo' },
{ id: 'family_3', type: 'event', label: 'Dinner Party' },
{ id: 'family_4', type: 'money', label: 'You Won!', cash: 300 },
{ id: 'family_5', type: 'money', label: "Friends' Wedding", cash: -150 },
{ id: 'family_6', type: 'money', label: 'Job Or Raise', cash: 250 },
{ id: 'family_7', type: 'event', label: 'Podcast' },
{ id: 'family_8', type: 'event', label: 'Hobby' },
{ id: 'family_9', type: 'event', label: 'Perfect Impression' },
{ id: 'family_10', type: 'money', label: 'First Home', cash: -300 },
{ id: 'family_11', type: 'event', label: 'Family Reunion' },
{ id: 'family_12', type: 'money', label: 'Raise Or Job', cash: 200 },
{ id: 'family_13', type: 'money', label: 'House Maintenance', cash: -150 },
{ id: 'family_14', type: 'money', label: 'Family Vacation', cash: -100 },
], 'join_2');
const investChain = chain([
{ id: 'invest_1', type: 'money', label: 'Side Investment', cash: -150 },
{ id: 'invest_2', type: 'money', label: 'Market Move', cash: 350 },
{ id: 'invest_3', type: 'money', label: '401K Contribution', cash: -100 },
{ id: 'invest_4', type: 'event', label: 'Forced Partnership' },
{ id: 'invest_5', type: 'money', label: 'Market Crash', cash: -300 },
{ id: 'invest_6', type: 'money', label: 'Start A Business', cash: -250 },
{ id: 'invest_7', type: 'money', label: 'Bet Against Player', cash: 200 },
{ id: 'invest_8', type: 'money', label: 'Market Manipulation', cash: -200 },
{ id: 'invest_9', type: 'money', label: 'Leveraged Buyout', cash: 300 },
{ id: 'invest_10', type: 'money', label: '100K Windfall', cash: 500 },
{ id: 'invest_11', type: 'money', label: 'Investigation', cash: -250 },
{ id: 'invest_12', type: 'money', label: 'Rogue Trader', cash: -300 },
{ id: 'invest_13', type: 'money', label: '10K Payout', cash: 250 },
], 'join_2');
const highRiskChain = chain([
{ id: 'risk_1', type: 'money', label: 'High Risk Bet', cash: 400 },
{ id: 'risk_2', type: 'money', label: 'Office Buyout', cash: -200 },
{ id: 'risk_3', type: 'money', label: 'Bankruptcy', cash: -500, flavor: 'Ouch.' },
{ id: 'risk_4', type: 'money', label: 'Comeback Deal', cash: 450 },
{ id: 'risk_5', type: 'money', label: 'Investigation', cash: -200 },
{ id: 'risk_6', type: 'money', label: 'Rogue Trader', cash: -250 },
], 'join_3');
const safeChain = chain([
{ id: 'safe_1', type: 'money', label: 'Force Sale', cash: -150 },
{ id: 'safe_2', type: 'money', label: 'Start A Business', cash: -100 },
{ id: 'safe_3', type: 'money', label: 'Raise Or Job', cash: 200 },
{ id: 'safe_4', type: 'event', label: 'Family Reunion' },
{ id: 'safe_5', type: 'money', label: 'Family Vacation', cash: -100 },
{ id: 'safe_6', type: 'money', label: 'Steady Savings', cash: 150 },
], 'join_3');
const leisureChain = chain([
{ id: 'leisure_1', type: 'money', label: 'Investment Paid Off', cash: 300 },
{ id: 'leisure_2', type: 'event', label: 'Start A Band' },
{ id: 'leisure_3', type: 'event', label: 'Jury Duty' },
{ id: 'leisure_4', type: 'event', label: 'Hobby' },
{ id: 'leisure_5', type: 'money', label: 'The Races', cash: -150 },
{ id: 'leisure_6', type: 'event', label: 'Job Offer' },
{ id: 'leisure_7', type: 'money', label: 'Bucket List Trip', cash: -200 },
{ id: 'leisure_8', type: 'money', label: 'Home Renovation', cash: -250 },
{ id: 'leisure_9', type: 'event', label: 'Hosted Thanksgiving' },
{ id: 'leisure_10', type: 'event', label: 'Highschool Reunion' },
{ id: 'leisure_11', type: 'money', label: 'Fantasy Football', cash: 100 },
{ id: 'leisure_12', type: 'money', label: 'Office Pool', cash: 150 },
{ id: 'leisure_13', type: 'event', label: 'Pickle Ball' },
{ id: 'leisure_14', type: 'event', label: 'Bowling Night' },
{ id: 'leisure_15', type: 'money', label: 'Dental Work', cash: -150 },
{ id: 'leisure_16', type: 'money', label: 'Golf Trip', cash: -100 },
{ id: 'leisure_17', type: 'event', label: 'Joined Facebook' },
{ id: 'leisure_18', type: 'money', label: 'Speeding Ticket', cash: -75 },
], 'retirement_party');
const spaceList = [
{ id: 'start', type: 'start', label: 'Start', next: 'path_fork' },
{ id: 'path_fork', type: 'choice', label: 'Which Path?', choices: ['career_1', 'edu_1', 'gap_1'] },
...careerChain,
...eduChain,
...gapChain,
...quarterLifeChain,
{ id: 'life_fork', type: 'choice', label: 'Relationship or Investment?', choices: ['family_1', 'invest_1'] },
...familyChain,
...investChain,
{ id: 'join_2', type: 'event', label: 'Settling Down', next: 'risk_fork' },
{ id: 'risk_fork', type: 'choice', label: 'High Risk or Safe?', choices: ['risk_1', 'safe_1'] },
...highRiskChain,
...safeChain,
{ id: 'join_3', type: 'event', label: 'Retirement Planning', next: 'leisure_1' },
...leisureChain,
{ id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
{ id: 'finish', type: 'finish', label: 'Finish' },
];
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: 'life-journey-v1',
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] };
+61 -31
View File
@@ -2,21 +2,28 @@ 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' });
}
/** Drives `playerId` forward by always resolving any pending choice with its
* first option and otherwise rolling the max die value — walkForward stops
* early at the next choice/finish regardless of pips, so this reliably makes
* progress without the test needing to know the board's shape or size. */
// 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) {
return reduce(state, { type: 'CHOOSE', playerId, spaceId: player.pendingChoice.options[0] });
const spaceId = player.pendingChoice.options[0];
return reduce(state, { type: 'CHOOSE', playerId, spaceId, rolls: rollsFor(board.spaces[spaceId]) });
}
return reduce(state, { type: 'ROLL', playerId, value: state.config.diceSides });
const nextSpaceId = board.spaces[player.position].next;
return reduce(state, { type: 'ROLL', playerId, rolls: rollsFor(board.spaces[nextSpaceId]) });
}
test('lobby: join validation', () => {
@@ -27,6 +34,16 @@ test('lobby: join validation', () => {
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
});
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);
@@ -41,59 +58,72 @@ test('turn order and illegal actions', () => {
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
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('full game: every player reaches every fork, race to finish', () => {
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 seenChoiceSpaces = new Set();
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 > 500) throw new Error('game did not finish in a reasonable number of turns');
if (++guard > 1000) throw new Error('game did not finish in a reasonable number of turns');
const turnPlayerId = state.currentTurn;
const before = state.players[turnPlayerId];
if (before.pendingChoice) seenChoiceSpaces.add(before.position);
state = takeTurn(state, turnPlayerId);
const after = state.players[turnPlayerId];
assert.equal(typeof after.cash, 'number');
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, 'a winner is recorded');
assert.ok(state.winnerId);
assert.equal(state.currentTurn, null);
assert.equal(state.players[state.winnerId].position, 'finish');
assert.deepEqual(getLegalIntents(state, 'p1'), []);
assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 1 }), /not active/);
// Always picking the first option at every fork should have visited all
// three choice points at least once between two full playthroughs' worth
// of turns (the loser doesn't necessarily finish, so this just checks the
// race itself exercised real fork logic, not that both players finished).
assert.ok(seenChoiceSpaces.size >= 1, 'at least one fork was actually resolved');
// 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.ok(ids.length > 100, 'the full board should be a substantial expansion of the Phase 1 subset');
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 === 'choice') {
assert.ok(Array.isArray(space.choices) && space.choices.length >= 2, `${id} needs 2+ choices`);
for (const target of space.choices) assert.ok(board.spaces[target], `${id} -> missing ${target}`);
} else if (space.type === 'finish') {
if (space.type === 'finish') {
assert.equal(space.next, undefined);
} else {
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
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