Compare commits

...

7 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
19 changed files with 13410 additions and 179 deletions
+14 -9
View File
@@ -29,7 +29,8 @@ lifegame/
│ └── ids.js # id/token/join-code generation │ └── ids.js # id/token/join-code generation
└── public/ └── public/
├── index.html # lobby / waiting room / game UI ├── index.html # lobby / waiting room / game UI
── client.js # REST wrappers + NetworkTransport (WebSocket) ── client.js # REST wrappers + NetworkTransport (WebSocket)
└── boardRender.js # SVG board, laid out from board.js graph data
``` ```
## Running locally ## Running locally
@@ -44,15 +45,19 @@ 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 copy the invite link, open it in the other tab, join, and start the game once
both players are in the lobby. both players are in the lobby.
## The board (Phase 1 subset) ## The board
The full hand-drawn board (`assets/game_board.png`) has ~150 spaces across two `shared/board.js` (~107 spaces) is transcribed from the hand-drawn sketch at
thematic passes (Career, Education, Gap Year, Relationship/Family, `assets/game_board.png`, organized into three fork points with the same
Investment, High Risk). Phase 1 encodes a small subset with the same shape — shape the sketch uses: **Career / Education / Gap Year** at the start,
a Career-vs-Education fork, a Relationship-vs-Investment fork, a **Relationship / Investment** after a shared "quarter-life crisis" chain,
High-Risk-vs-Safe fork, converging to Finish — enough to prove the reducer, then **High Risk / Safe** before a long shared retirement tail to Finish.
persistence, and rooms all work end to end. More spaces can be inserted into The sketch reuses several space names across its zones (Start A Business,
any branch later without touching the reducer or database schema. 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 ## Deploying on the homelab
+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": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node --watch server/index.js", "dev": "node --watch server/index.js",
"test": "node --test shared/game.test.js" "test": "node --test shared/*.test.js"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.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, '\\$&');
}
+102 -7
View File
@@ -23,8 +23,9 @@
.card { .card {
background: var(--paper); border: 1px solid var(--line); border-radius: 16px; background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%; box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 520px; width: 100%;
margin-top: 24px; margin-top: 24px; transition: max-width .25s ease;
} }
.card.wide { max-width: 1100px; }
h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; } h1 { font-family: "Baloo 2"; color: var(--marigold); margin: 0 0 2px; font-size: 30px; font-weight: 800; }
.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; } .sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; }
label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; } label { display: block; font-weight: 700; font-family: "Baloo 2"; font-size: 13px; margin: 14px 0 4px; }
@@ -49,6 +50,8 @@
border: 1.5px solid var(--line); border-radius: 10px; background: #fffaf0; margin-bottom: 6px; font-size: 14px; 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; } 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; } .dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
.hint { color: var(--ink-soft); font-size: 13px; } .hint { color: var(--ink-soft); font-size: 13px; }
.turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; } .turn-banner { font-family: "Baloo 2"; font-weight: 700; font-size: 18px; margin-bottom: 10px; }
@@ -66,6 +69,82 @@
background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad); background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad);
border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px; border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px;
} }
/* --- Board --- */
.board-container {
overflow: auto; max-height: 620px; border-radius: 14px; margin-bottom: 14px;
background: #163a30; border: 1px solid var(--line);
}
.board-svg { display: block; width: 100%; height: auto; min-width: 680px; }
.field-bg { fill: url(#fieldGradient); }
.mountain-body { fill: #8f9aa8; opacity: .5; }
.mountain-snow { fill: #e8edf2; opacity: .45; }
.river-body { fill: none; stroke: #4a90b8; stroke-width: 34; stroke-linecap: round; opacity: .5; }
.river-shine { fill: none; stroke: #a8d8ea; stroke-width: 8; stroke-linecap: round; opacity: .4; }
.tree-canopy-a { fill: #2f6b52; opacity: .6; }
.tree-canopy-b { fill: #3a7a5e; opacity: .55; }
.tree-trunk { fill: #4a3a2a; opacity: .5; }
.road-shadow {
fill: none; stroke: #0f2a23; stroke-width: 15; stroke-linecap: round;
opacity: .3; transform: translate(0, 3px);
}
.road-base { fill: none; stroke: #caa15a; stroke-width: 13; stroke-linecap: round; filter: url(#roadWobble); }
.road-centerline {
fill: none; stroke: #f0dcae; stroke-width: 2.5; stroke-linecap: round;
stroke-dasharray: 8 10; opacity: .85; filter: url(#roadWobble);
}
.space-shadow { fill: rgba(0,0,0,.32); }
.space-gloss { fill: url(#tileGloss); pointer-events: none; }
.choice-post { fill: #8a6a3c; stroke: #5f462a; stroke-width: 1; }
.space-shape {
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
filter: drop-shadow(0 1px 1px rgba(0,0,0,.25));
}
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
.space-stop .space-shape { fill: #d6553b; stroke: #8f3120; }
.space-payday .space-shape { fill: #d7ecd0; stroke: #3f8f5f; }
.space-cash_bonus .space-shape { fill: #fbe4b8; stroke: #b97f18; }
.space-action_space .space-shape,
.space-dice_space .space-shape,
.space-roll_table_ref .space-shape { fill: #cfe0f0; stroke: #3f6ea5; }
.space-inline_table .space-shape { fill: #dde8f2; stroke: #7a97b5; stroke-dasharray: 3 2; }
.space-event .space-shape { fill: #fdf6e3; stroke: #c9b98a; }
.space-label {
width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; font-family: "Baloo 2"; font-weight: 700;
font-size: 10.5px; line-height: 1.15; color: var(--ink); pointer-events: none; gap: 1px;
}
.space-choice .space-label, .space-stop .space-label { color: #fff; }
.space-icon { font-size: 15px; }
.space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
.die-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 10px; fill: var(--ink-soft); }
.space.highlight .space-shape {
stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite;
}
.space.clickable { cursor: pointer; }
.space.clickable:hover .space-shape { filter: drop-shadow(0 0 10px #fff2c4); }
@keyframes pulseGlow {
0%, 100% { filter: drop-shadow(0 0 2px #fff2c4); }
50% { filter: drop-shadow(0 0 12px #ffdc73); }
}
.token { pointer-events: none; transition: transform .5s cubic-bezier(.34,1.4,.64,1); }
.token-dot { stroke: #fff; stroke-width: 2; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); }
.token-label { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; fill: #fff; }
.token.current-turn .token-dot { animation: tokenBounce 1s ease-in-out infinite; }
@keyframes tokenBounce {
0%, 100% { r: 12; } 50% { r: 14.5; }
}
#dice-icon { display: inline-block; }
#dice-icon.spin { animation: diceSpin .55s ease-out; }
@keyframes diceSpin {
0% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(200deg) scale(1.25); }
100% { transform: rotate(360deg) scale(1); }
}
</style> </style>
</head> </head>
<body> <body>
@@ -105,9 +184,10 @@
<section id="panel-game" hidden> <section id="panel-game" hidden>
<div class="turn-banner" id="turn-banner"></div> <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> <ul class="player-list" id="game-player-list"></ul>
<div> <div>
<button id="roll-btn" hidden>Roll</button> <button id="roll-btn" hidden><span id="dice-icon">🎲</span> Roll</button>
<div class="choice-buttons" id="choice-buttons"></div> <div class="choice-buttons" id="choice-buttons"></div>
</div> </div>
<div class="win-banner" id="win-banner" hidden></div> <div class="win-banner" id="win-banner" hidden></div>
@@ -122,8 +202,10 @@
createGame, joinGame, saveSession, loadSession, createGame, joinGame, saveSession, loadSession,
NetworkTransport, getLegalIntents, board, NetworkTransport, getLegalIntents, board,
} from '/client.js'; } from '/client.js';
import { BoardView } from '/boardRender.js';
const els = { const els = {
app: document.getElementById('app'),
subtitle: document.getElementById('subtitle'), subtitle: document.getElementById('subtitle'),
panelEntry: document.getElementById('panel-entry'), panelEntry: document.getElementById('panel-entry'),
panelLobby: document.getElementById('panel-lobby'), panelLobby: document.getElementById('panel-lobby'),
@@ -139,8 +221,10 @@
startBtn: document.getElementById('start-btn'), startBtn: document.getElementById('start-btn'),
lobbyHint: document.getElementById('lobby-hint'), lobbyHint: document.getElementById('lobby-hint'),
turnBanner: document.getElementById('turn-banner'), turnBanner: document.getElementById('turn-banner'),
boardContainer: document.getElementById('board-container'),
gamePlayerList: document.getElementById('game-player-list'), gamePlayerList: document.getElementById('game-player-list'),
rollBtn: document.getElementById('roll-btn'), rollBtn: document.getElementById('roll-btn'),
diceIcon: document.getElementById('dice-icon'),
choiceButtons: document.getElementById('choice-buttons'), choiceButtons: document.getElementById('choice-buttons'),
winBanner: document.getElementById('win-banner'), winBanner: document.getElementById('win-banner'),
logFeed: document.getElementById('log-feed'), logFeed: document.getElementById('log-feed'),
@@ -150,11 +234,13 @@
let transport = null; let transport = null;
let self = { code: null, gameId: null, playerId: null, sessionToken: null }; let self = { code: null, gameId: null, playerId: null, sessionToken: null };
let errorTimer = null; let errorTimer = null;
const boardView = new BoardView(els.boardContainer, board);
function showPanel(name) { function showPanel(name) {
els.panelEntry.hidden = name !== 'entry'; els.panelEntry.hidden = name !== 'entry';
els.panelLobby.hidden = name !== 'lobby'; els.panelLobby.hidden = name !== 'lobby';
els.panelGame.hidden = name !== 'game'; els.panelGame.hidden = name !== 'game';
els.app.classList.toggle('wide', name === 'game');
} }
function showError(message) { function showError(message) {
@@ -205,6 +291,9 @@
function renderGame(state) { function renderGame(state) {
showPanel('game'); 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); const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.gamePlayerList.innerHTML = ''; els.gamePlayerList.innerHTML = '';
for (const p of players) { for (const p of players) {
@@ -213,7 +302,8 @@
const spaceLabel = board.spaces[p.position]?.label ?? p.position; const spaceLabel = board.spaces[p.position]?.label ?? p.position;
li.innerHTML = `<span class="dot" style="background:${p.color}"></span> li.innerHTML = `<span class="dot" style="background:${p.color}"></span>
<strong>${escapeHtml(p.name)}${p.id === self.playerId ? ' (you)' : ''}</strong> <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); els.gamePlayerList.appendChild(li);
} }
@@ -242,9 +332,9 @@
els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => { els.logFeed.innerHTML = state.log.slice().reverse().map((entry) => {
const p = state.players[entry.playerId]; const p = state.players[entry.playerId];
const sign = entry.cashDelta > 0 ? '+' : ''; const text = entry.description || entry.label;
const cashPart = entry.cashDelta ? ` (${sign}${entry.cashDelta})` : ''; const todoMark = entry.todo ? ' <span class="todo-mark" title="Placeholder content">⚠️</span>' : '';
return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(entry.label)}${cashPart}</div>`; return `<div>${escapeHtml(p?.name ?? '?')}${escapeHtml(text)}${todoMark}</div>`;
}).join(''); }).join('');
if (state.status === 'finished') { if (state.status === 'finished') {
@@ -293,7 +383,12 @@
}; };
els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' }); els.startBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_START' });
els.rollBtn.onclick = () => transport.sendIntent({ type: 'REQUEST_ROLL' }); 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.copyLinkBtn.onclick = () => {
els.inviteLink.select(); els.inviteLink.select();
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {}); navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
+10 -2
View File
@@ -28,8 +28,16 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
db.initDb(path.join(DATA_DIR, 'lifegame.db')); db.initDb(path.join(DATA_DIR, 'lifegame.db'));
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; const DEFAULT_CONFIG = {
const PLAYER_COLORS = ['#e8a12a', '#3f8f5f', '#2f6f9f', '#c1452f', '#7a4fae', '#2f8f8f']; 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 AUTH_TIMEOUT_MS = 10_000;
const app = express(); const app = express();
+23 -4
View File
@@ -6,6 +6,8 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { reduce, getLegalIntents } from '../shared/game.js'; 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'; import * as db from './db.js';
export class RoomError extends Error {} export class RoomError extends Error {}
@@ -59,16 +61,33 @@ function buildAction(state, playerId, intent) {
switch (intent.type) { switch (intent.type) {
case 'REQUEST_START': case 'REQUEST_START':
return { type: 'START_GAME' }; return { type: 'START_GAME' };
case 'REQUEST_ROLL': case 'REQUEST_ROLL': {
return { type: 'ROLL', playerId, value: 1 + crypto.randomInt(state.config.diceSides) }; const player = state.players[playerId];
case 'REQUEST_CHOOSE': 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'); 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: default:
throw new Error(`Unknown intent: ${intent.type}`); 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) { function broadcast(room, payload) {
for (const ws of room.sockets) send(ws, 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.
+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): * TEMPORARY: the 12 segments have no defined connections yet — that's what
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe * shared/BRANCHING-TEMPLATE.md is an open request for (the STOP forks, and
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into * which segment's exit feeds which segment's entry). Until board-graph.json
* any branch array later (repointing one `next`) without touching the reducer. * exists, TEMP_SEGMENT_ORDER below just concatenates all 12 segments in the
* design doc's own listed order into one line, so movement/tests/rendering
* have something real to walk. This is NOT the real board topology — delete
* this placeholder and replace `next` wiring once board-graph.json exists.
* *
* Space shape: { id, type, label, next?, choices?, cash?, flavor? } * Similarly, no tile in the source data has type 'finish' (win condition is
* type: 'start' | 'event' | 'money' | 'choice' | 'finish' * itself an open question in GAME-REVIEW.md) — a synthetic `finish` space is
* next: id of the following space (absent on 'choice' and 'finish' spaces) * appended after the last tile so the reducer has something to end on.
* choices: [id, id] of the branches offered by a 'choice' space *
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces) * Movement is one tile per turn (no tile in the data implies a movement die —
* every `die` value belongs to that tile's own effect resolution), so unlike
* the old board there is no walkForward()/multi-step-with-early-stop concept
* here: a turn is just "go to `next`, then resolve whatever that tile needs."
*
* Space shape: { id, type, label, die, externalTables, statsTouched, next }
* type: one of the 9 tile-inventory types — payday, action_space,
* dice_space, roll_table_ref, inline_table, cash_bonus, event, choice, stop
* die: 'D100'|'D20'|'D10'|'D8'|'D6'|'D2'|null — what to roll to resolve this tile
* externalTables: source_doc_id[] into shared/rollTables.js (0-3 entries)
* statsTouched: string[] hint from the source data, informational only
* next: id of the following space (absent only on the synthetic 'finish')
* choices: NOT set on any tile yet (no real branch destinations are known) —
* shared/game.js only pauses a 'choice'/'stop' space for a decision when
* `choices` is non-empty, so today every one of these is a harmless
* pass-through, not a dead end.
*/ */
const spaceList = [ import tileInventory from './tileInventory.js';
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' },
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] },
{ id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' }, const TEMP_SEGMENT_ORDER = [
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' }, 'starting_strip',
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' }, 'career',
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' }, 'investment',
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' }, 'investment_bottom',
'high_risk',
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' }, 'gap_year',
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' }, 'education',
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' }, 'relationship_top',
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' }, 'relationship_bottom',
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' }, 'retirement_top',
'retirement_middle',
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' }, 'retirement_bottom',
{ id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] },
{ id: 'relationship_1', type: 'event', label: 'New City', next: 'relationship_2' },
{ id: 'relationship_2', type: 'event', label: 'Dinner Party', next: 'relationship_3' },
{ id: 'relationship_3', type: 'money', label: 'Wedding', cash: -200, next: 'join_2' },
{ id: 'investment_1', type: 'money', label: 'Side Investment', cash: -150, next: 'investment_2' },
{ id: 'investment_2', type: 'money', label: 'Market Move', cash: 350, next: 'investment_3' },
{ id: 'investment_3', type: 'money', label: '401K Contribution', cash: -100, flavor: 'Future savings', next: 'join_2' },
{ id: 'join_2', type: 'event', label: 'Settling Down', next: 'high_risk_choice' },
{ id: 'high_risk_choice', type: 'choice', label: 'One Last Fork', choices: ['high_risk_1', 'safe_1'] },
{ id: 'high_risk_1', type: 'money', label: 'Startup Gamble', cash: 500, next: 'high_risk_2' },
{ id: 'high_risk_2', type: 'money', label: 'Bankruptcy', cash: -400, flavor: 'Ouch.', next: 'retirement_party' },
{ id: 'safe_1', type: 'money', label: 'Steady Savings', cash: 100, next: 'safe_2' },
{ id: 'safe_2', type: 'money', label: 'Modest Raise', cash: 150, next: 'retirement_party' },
{ id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
{ id: 'finish', type: 'finish', label: 'Finish' },
]; ];
const segmentsById = Object.fromEntries(tileInventory.segments.map((s) => [s.id, s]));
function buildSegmentSpaces(segmentId, nextAfterSegment) {
const segment = segmentsById[segmentId];
if (!segment) throw new Error(`Unknown segment id in TEMP_SEGMENT_ORDER: ${segmentId}`);
return segment.tiles.map((tile, i) => ({
id: `${segmentId}_${i}`,
type: tile.type,
label: tile.name,
die: tile.die ?? null,
externalTables: tile.external_tables ?? [],
statsTouched: tile.stats_touched ?? [],
next: i < segment.tiles.length - 1 ? `${segmentId}_${i + 1}` : nextAfterSegment,
}));
}
const spaceList = TEMP_SEGMENT_ORDER.flatMap((segmentId, i) => {
const isLastSegment = i === TEMP_SEGMENT_ORDER.length - 1;
const nextAfterSegment = isLastSegment ? 'finish' : `${TEMP_SEGMENT_ORDER[i + 1]}_0`;
return buildSegmentSpaces(segmentId, nextAfterSegment);
});
spaceList.push({
id: 'finish',
type: 'finish',
label: 'Finish',
die: null,
externalTables: [],
statsTouched: [],
// no `next` — this is the temporary end of TEMP_SEGMENT_ORDER, not a real
// win-condition tile from the source data.
});
export const board = { export const board = {
id: 'phase1-demo', id: 'life-journey-full-v1',
startSpaceId: 'start', startSpaceId: `${TEMP_SEGMENT_ORDER[0]}_0`,
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])), spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
}; };
/**
* Advance from `startId` by up to `steps` spaces, stopping immediately on arrival
* at a 'choice' or 'finish' space even if pips remain (they're discarded).
*/
export function walkForward(startId, steps) {
let current = startId;
for (let i = 0; i < steps; i++) {
const space = board.spaces[current];
if (!space.next) break; // sitting on a choice/finish space already — nowhere to advance
current = space.next;
const landed = board.spaces[current];
if (landed.type === 'choice' || landed.type === 'finish') break;
}
return current;
}
+43 -22
View File
@@ -2,17 +2,27 @@
* Life Journey — pure game reducer. * Life Journey — pure game reducer.
* *
* reduce(state, action) -> newState is the ONLY way state changes, and it never * reduce(state, action) -> newState is the ONLY way state changes, and it never
* generates randomness itself: the die value is generated by the server and * generates randomness itself: every roll a tile needs is generated by the
* travels inside the ROLL action payload, so server and client can both apply * server and travels inside the action payload (`rolls`), so server and
* the exact same action through this exact same function and land on identical * client can both apply the exact same action through this exact same
* state. Illegal transitions throw rather than no-op — the server is expected * function and land on identical state. Illegal transitions throw rather
* to gate intents with getLegalIntents()/isPlayersTurn() before ever * than no-op — the server is expected to gate intents with
* constructing an action, so a throw here means that gate was bypassed. * getLegalIntents()/isPlayersTurn() before ever constructing an action, so a
* throw here means that gate was bypassed.
*
* Movement is one tile per turn (board.js's `next` pointer) — there is no
* movement die; every `die` a tile carries is for resolving that tile's own
* effect via shared/tileEffects.js, not how far a player travels.
*/ */
import { board, walkForward } from './board.js'; import { board } from './board.js';
import { resolveTileEffect } from './tileEffects.js';
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; const DEFAULT_CONFIG = {
minPlayers: 2,
maxPlayers: 8,
startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 },
};
const LOG_LIMIT = 50; const LOG_LIMIT = 50;
export function createInitialState(config = {}) { export function createInitialState(config = {}) {
@@ -90,7 +100,7 @@ function applyJoin(state, { playerId, name, seat, color }) {
seat, seat,
color, color,
position: board.startSpaceId, position: board.startSpaceId,
cash: state.config.startingCash, ...state.config.startingStats,
pendingChoice: null, pendingChoice: null,
finished: false, finished: false,
}; };
@@ -107,37 +117,38 @@ function applyStart(state) {
return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] }; return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] };
} }
function applyRoll(state, { playerId, value }) { function applyRoll(state, { playerId, rolls }) {
assertActive(state); assertActive(state);
assertPlayersTurn(state, playerId); assertPlayersTurn(state, playerId);
const player = state.players[playerId]; const player = state.players[playerId];
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling'); if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
const landed = walkForward(player.position, value); const nextSpaceId = board.spaces[player.position].next;
return landOn(state, playerId, landed, { type: 'ROLL', value }); if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board');
return landOn(state, playerId, nextSpaceId, rolls, { type: 'ROLL' });
} }
function applyChoose(state, { playerId, spaceId }) { function applyChoose(state, { playerId, spaceId, rolls }) {
assertActive(state); assertActive(state);
assertPlayersTurn(state, playerId); assertPlayersTurn(state, playerId);
const player = state.players[playerId]; const player = state.players[playerId];
const pending = player.pendingChoice; const pending = player.pendingChoice;
if (!pending) throw new Error('No pending choice'); if (!pending) throw new Error('No pending choice');
if (!pending.options.includes(spaceId)) throw new Error('Illegal choice'); if (!pending.options.includes(spaceId)) throw new Error('Illegal choice');
return landOn(state, playerId, spaceId, { type: 'CHOOSE' }); return landOn(state, playerId, spaceId, rolls, { type: 'CHOOSE' });
} }
/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's /** Shared landing logic for both a ROLL's terminal space and a CHOOSE's
* resolved branch: apply the space's effect, then either leave the turn * resolved branch: resolve the space's tile effect, then either leave the
* open (choice pending), end the game (finish), or advance to the next player. */ * turn open (a real choice is pending — only true once board-graph.json
function landOn(state, playerId, spaceId, logMeta) { * populates `choices`), end the game (finish), or advance to the next player. */
function landOn(state, playerId, spaceId, rolls, logMeta) {
const space = board.spaces[spaceId]; const space = board.spaces[spaceId];
const cashDelta = space.cash ?? 0; const { statDelta, description, todo } = resolveTileEffect(space, rolls ?? []);
const priorPlayer = state.players[playerId]; const priorPlayer = state.players[playerId];
const nextPlayer = { const nextPlayer = {
...priorPlayer, ...applyStatDelta(priorPlayer, statDelta),
position: spaceId, position: spaceId,
cash: priorPlayer.cash + cashDelta, pendingChoice: space.choices?.length ? { atSpace: spaceId, options: space.choices } : null,
pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null,
finished: space.type === 'finish', finished: space.type === 'finish',
}; };
@@ -146,7 +157,9 @@ function landOn(state, playerId, spaceId, logMeta) {
playerId, playerId,
landedOn: spaceId, landedOn: spaceId,
label: space.label, label: space.label,
cashDelta, statDelta,
description,
todo,
...logMeta, ...logMeta,
}); });
@@ -162,6 +175,14 @@ function landOn(state, playerId, spaceId, logMeta) {
return advanceTurn(newState); return advanceTurn(newState);
} }
function applyStatDelta(player, statDelta) {
const next = { ...player };
for (const [stat, delta] of Object.entries(statDelta ?? {})) {
next[stat] = (next[stat] ?? 0) + delta;
}
return next;
}
function advanceTurn(state) { function advanceTurn(state) {
const turnIndex = (state.turnIndex + 1) % state.turnOrder.length; const turnIndex = (state.turnIndex + 1) % state.turnOrder.length;
return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] }; return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] };
+101 -69
View File
@@ -1,11 +1,31 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js'; import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
import { board } from './board.js';
import { rollsNeededFor } from './tileEffects.js';
function join(state, playerId, name, seat) { function join(state, playerId, name, seat) {
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' }); return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
} }
// Deterministic dummy rolls — always the low end of each die's range, so
// these tests exercise the reducer's mechanics without depending on real
// (or synthesized-placeholder) table content. shared/tileEffects.test.js
// covers the actual banding/effect math.
function rollsFor(space) {
return rollsNeededFor(space).map(() => 1);
}
function takeTurn(state, playerId) {
const player = state.players[playerId];
if (player.pendingChoice) {
const spaceId = player.pendingChoice.options[0];
return reduce(state, { type: 'CHOOSE', playerId, spaceId, rolls: rollsFor(board.spaces[spaceId]) });
}
const nextSpaceId = board.spaces[player.position].next;
return reduce(state, { type: 'ROLL', playerId, rolls: rollsFor(board.spaces[nextSpaceId]) });
}
test('lobby: join validation', () => { test('lobby: join validation', () => {
let state = createInitialState(); let state = createInitialState();
state = join(state, 'p1', 'Alice', 1); state = join(state, 'p1', 'Alice', 1);
@@ -14,7 +34,17 @@ test('lobby: join validation', () => {
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/); assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
}); });
test('full game: both choice points, race to finish', () => { test('join gives every player the five starting stats', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
const p1 = state.players.p1;
assert.deepEqual(
{ cash: p1.cash, love: p1.love, education: p1.education, wealth: p1.wealth, age: p1.age },
state.config.startingStats
);
});
test('turn order and illegal actions', () => {
let state = createInitialState(); let state = createInitialState();
state = join(state, 'p1', 'Alice', 1); state = join(state, 'p1', 'Alice', 1);
state = join(state, 'p2', 'Bob', 2); state = join(state, 'p2', 'Bob', 2);
@@ -23,75 +53,77 @@ test('full game: both choice points, race to finish', () => {
assert.equal(state.status, 'active'); assert.equal(state.status, 'active');
assert.deepEqual(state.turnOrder, ['p1', 'p2']); assert.deepEqual(state.turnOrder, ['p1', 'p2']);
assert.equal(state.currentTurn, 'p1'); assert.equal(state.currentTurn, 'p1');
assert.equal(isPlayersTurn(state, 'p1'), true);
assert.equal(isPlayersTurn(state, 'p2'), false);
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']); assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
assert.deepEqual(getLegalIntents(state, 'p2'), []); assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.equal(isPlayersTurn(state, 'p2'), false);
// Not p2's turn yet. assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', rolls: [] }), /Not your turn/);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/); assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
});
// p1 rolls onto the first choice space; movement stops immediately even
// though only 1 of the roll's pips was needed. test('a single roll advances exactly one tile and applies its effect', () => {
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 }); let state = createInitialState();
assert.equal(state.players.p1.position, 'crossroads'); state = join(state, 'p1', 'Alice', 1);
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] }); state = join(state, 'p2', 'Bob', 2);
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose'); state = reduce(state, { type: 'START_GAME' });
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']);
const expectedNext = board.spaces[board.startSpaceId].next;
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/); state = takeTurn(state, 'p1');
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/); assert.equal(state.players.p1.position, expectedNext);
assert.equal(state.currentTurn, 'p2', 'turn advances since no choice is pending yet');
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' }); assert.equal(state.log.at(-1).landedOn, expectedNext);
assert.equal(state.players.p1.position, 'career_1'); });
assert.equal(state.players.p1.pendingChoice, null);
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn'); test('full board: walking every tile to the temporary finish never crashes and always terminates', () => {
let state = createInitialState();
// p2 takes the education branch. state = join(state, 'p1', 'Alice', 1);
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }); state = join(state, 'p2', 'Bob', 2);
assert.equal(state.players.p2.position, 'crossroads'); state = reduce(state, { type: 'START_GAME' });
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' });
assert.equal(state.players.p2.position, 'edu_1'); const seenTypes = new Set();
assert.equal(state.currentTurn, 'p1'); let guard = 0;
while (state.status === 'active') {
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed if (++guard > 1000) throw new Error('game did not finish in a reasonable number of turns');
// space's cash effect applies, not spaces merely passed through. const turnPlayerId = state.currentTurn;
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 }); state = takeTurn(state, turnPlayerId);
assert.equal(state.players.p1.position, 'life_crossroads'); const after = state.players[turnPlayerId];
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply'); seenTypes.add(board.spaces[after.position].type);
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' }); for (const stat of ['cash', 'love', 'education', 'wealth', 'age']) {
assert.equal(state.players.p1.position, 'investment_1'); assert.equal(typeof after[stat], 'number', `${stat} stays numeric`);
assert.equal(state.players.p1.cash, -150); }
assert.equal(state.currentTurn, 'p2'); assert.ok(board.spaces[after.position], 'player is always on a real space');
}
// p2: edu_1 -> life_crossroads is also exactly 6 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 }); assert.equal(state.status, 'finished');
assert.equal(state.players.p2.position, 'life_crossroads'); assert.ok(state.winnerId);
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' }); assert.equal(state.currentTurn, null);
assert.equal(state.players.p2.position, 'relationship_1'); assert.equal(state.players[state.winnerId].position, 'finish');
assert.equal(state.currentTurn, 'p1'); assert.deepEqual(getLegalIntents(state, 'p1'), []);
// p1: investment_1 -> high_risk_choice is exactly 4 steps. // Every distinct tile type in the real board actually got resolved along the way.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 }); for (const type of [
assert.equal(state.players.p1.position, 'high_risk_choice'); 'payday', 'action_space', 'dice_space', 'roll_table_ref',
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' }); 'inline_table', 'cash_bonus', 'event', 'choice', 'stop',
assert.equal(state.players.p1.cash, -50); // -150 + 100 ]) {
assert.equal(state.currentTurn, 'p2'); assert.ok(seenTypes.has(type), `expected to land on a "${type}" tile during a full playthrough`);
}
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 }); assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', rolls: [] }), /not active/);
assert.equal(state.players.p2.position, 'high_risk_choice'); });
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' });
assert.equal(state.players.p2.cash, 500); test('board graph is well-formed', () => {
assert.equal(state.currentTurn, 'p1'); const ids = Object.keys(board.spaces);
assert.equal(ids.length, 212, '211 real tiles + 1 synthetic finish');
// p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game. let nonFinishCount = 0;
assert.equal(state.status, 'active'); for (const id of ids) {
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 }); const space = board.spaces[id];
assert.equal(state.players.p1.position, 'finish'); if (space.type === 'finish') {
assert.equal(state.status, 'finished'); assert.equal(space.next, undefined);
assert.equal(state.winnerId, 'p1'); continue;
assert.equal(state.currentTurn, null); }
assert.deepEqual(getLegalIntents(state, 'p2'), []); nonFinishCount++;
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/); }
assert.equal(nonFinishCount, 211);
}); });
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