Compare commits

...

2 Commits

Author SHA1 Message Date
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
6 changed files with 600 additions and 116 deletions
+14 -9
View File
@@ -29,7 +29,8 @@ lifegame/
│ └── ids.js # id/token/join-code generation
└── public/
├── 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
@@ -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
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
thematic passes (Career, Education, Gap Year, Relationship/Family,
Investment, High Risk). Phase 1 encodes a small subset with the same shape —
a Career-vs-Education fork, a Relationship-vs-Investment fork, a
High-Risk-vs-Safe fork, converging to Finish — enough to prove the reducer,
persistence, and rooms all work end to end. More spaces can be inserted into
any branch later without touching the reducer or database schema.
`shared/board.js` (~107 spaces) is transcribed from the hand-drawn sketch at
`assets/game_board.png`, organized into three fork points with the same
shape the sketch uses: **Career / Education / Gap Year** at the start,
**Relationship / Investment** after a shared "quarter-life crisis" chain,
then **High Risk / Safe** before a long shared retirement tail to Finish.
The sketch reuses several space names across its zones (Start A Business,
Family Reunion, Market Crash, ...) — that's kept as intentional recurring
flavor rather than deduplicated. More spaces can be inserted into any
branch's `chain([...])` array in `board.js` without touching the reducer,
the SVG renderer, or the database schema — none of them know or care how
many spaces exist.
## Deploying on the homelab
+279
View File
@@ -0,0 +1,279 @@
/**
* 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.
*/
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 = {
start: { icon: '🚩', shape: 'circle' },
finish: { icon: '🏁', shape: 'circle' },
choice: { icon: '🔀', shape: 'diamond' },
money: { icon: '💰', shape: 'rect' },
event: { icon: '✨', shape: 'rect' },
};
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
export function computeLayout(board, colsPerRow = 14) {
const ids = Object.keys(board.spaces);
const outEdges = new Map(ids.map((id) => [id, []]));
const predecessors = new Map(ids.map((id) => [id, []]));
for (const id of ids) {
const space = board.spaces[id];
const targets = space.type === 'choice' ? space.choices : (space.next ? [space.next] : []);
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));
// Choice spaces fan their N branches out symmetrically around the
// parent's lane (works for 2-way, 3-way, ... forks alike); anything
// else just inherits the parent's lane unchanged. A join (>1
// predecessor) accumulates every incoming contribution and averages
// once all of its predecessors have been processed (guaranteed by the
// time `remaining` hits 0, since that only happens after every
// in-edge is visited).
const offset = space.type === 'choice' ? i - (children.length - 1) / 2 : 0;
const 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]
));
}
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._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);
}
_buildBackdrop(width, height) {
const g = svgEl('g', { class: 'backdrop' });
let seed = 7; // deterministic — stable across re-renders, not flickering
const rand = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
const count = Math.max(6, Math.floor((width * height) / 11000));
for (let i = 0; i < count; i++) {
g.appendChild(svgEl('circle', {
cx: rand() * width, cy: rand() * height, r: 16 + rand() * 26, class: 'tree-blob',
}));
}
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: 'edge-path' }));
}
}
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;
}
_buildNode(space, pos) {
const style = TYPE_STYLE[space.type] ?? TYPE_STYLE.event;
const node = svgEl('g', {
class: `space space-${space.type}`,
transform: `translate(${pos.x}, ${pos.y})`,
'data-space-id': space.id,
});
if (style.shape === 'circle') {
node.appendChild(svgEl('circle', { r: NODE_H / 2, class: 'space-shape' }));
} else if (style.shape === 'diamond') {
const r = NODE_H / 2 + 6;
node.appendChild(svgEl('rect', {
x: -r, y: -r, width: r * 2, height: r * 2, rx: 8,
transform: 'rotate(45)', class: 'space-shape',
}));
} else {
node.appendChild(svgEl('rect', {
x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H, rx: 14, class: 'space-shape',
}));
}
const fo = svgEl('foreignObject', { x: -NODE_W / 2, y: -NODE_H / 2, width: NODE_W, height: NODE_H });
const div = document.createElement('div');
div.className = 'space-label';
div.innerHTML = `<span class="space-icon">${style.icon}</span><span class="space-text">${escapeHtml(space.label)}</span>`;
fo.appendChild(div);
node.appendChild(fo);
if (space.cash) {
const badge = svgEl('text', {
class: `cash-badge ${space.cash > 0 ? 'pos' : 'neg'}`, y: NODE_H / 2 + 17, 'text-anchor': 'middle',
});
badge.textContent = `${space.cash > 0 ? '+' : ''}${space.cash}`;
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, '\\$&');
}
+71 -3
View File
@@ -23,8 +23,9 @@
.card {
background: var(--paper); border: 1px solid var(--line); border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,.28); padding: 28px; max-width: 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; }
.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; }
@@ -66,6 +67,58 @@
background: #fbe3dc; border: 1.5px solid var(--bad); color: var(--bad);
border-radius: 10px; padding: 8px 12px; font-size: 13px; margin-top: 14px;
}
/* --- Board --- */
.board-container {
overflow: auto; max-height: 620px; border-radius: 14px; margin-bottom: 14px;
background: radial-gradient(900px 500px at 30% 0%, #2a5f4c 0%, #1d493c 55%, #163a30 100%);
border: 1px solid var(--line);
}
.board-svg { display: block; width: 100%; height: auto; min-width: 680px; }
.tree-blob { fill: #2f6b52; opacity: .55; }
.edge-path { fill: none; stroke: #caa15a; stroke-width: 7; stroke-linecap: round; opacity: .9; }
.space-shape {
fill: #fffaf0; stroke: var(--line); stroke-width: 2.5;
filter: drop-shadow(0 3px 3px rgba(0,0,0,.35));
}
.space-start .space-shape { fill: var(--good); stroke: #2c6b44; }
.space-finish .space-shape { fill: var(--marigold); stroke: #b97f18; }
.space-choice .space-shape { fill: var(--bad); stroke: #8f3120; }
.space-event .space-shape { fill: #e9d9f7; stroke: #b79bd6; }
.space-label {
width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center;
justify-content: center; text-align: center; font-family: "Baloo 2"; font-weight: 700;
font-size: 10.5px; line-height: 1.15; color: var(--ink); pointer-events: none; gap: 1px;
}
.space-start .space-label, .space-choice .space-label { color: #fff; }
.space-icon { font-size: 15px; }
.space-text { max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
.cash-badge { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; }
.cash-badge.pos { fill: var(--good); }
.cash-badge.neg { fill: var(--bad); }
.space.highlight .space-shape {
stroke: #fff2c4; stroke-width: 4; animation: pulseGlow 1.1s ease-in-out infinite;
}
.space.clickable { cursor: pointer; }
.space.clickable:hover .space-shape { filter: drop-shadow(0 0 10px #fff2c4); }
@keyframes pulseGlow {
0%, 100% { filter: drop-shadow(0 0 2px #fff2c4); }
50% { filter: drop-shadow(0 0 12px #ffdc73); }
}
.token { pointer-events: none; transition: transform .5s cubic-bezier(.34,1.4,.64,1); }
.token-dot { stroke: #fff; stroke-width: 2; filter: drop-shadow(0 2px 2px rgba(0,0,0,.4)); }
.token-label { font-family: "Baloo 2"; font-weight: 800; font-size: 11px; fill: #fff; }
.token.current-turn .token-dot { animation: tokenBounce 1s ease-in-out infinite; }
@keyframes tokenBounce {
0%, 100% { r: 12; } 50% { r: 14.5; }
}
#dice-icon { display: inline-block; }
#dice-icon.spin { animation: diceSpin .55s ease-out; }
@keyframes diceSpin {
0% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(200deg) scale(1.25); }
100% { transform: rotate(360deg) scale(1); }
}
</style>
</head>
<body>
@@ -105,9 +158,10 @@
<section id="panel-game" hidden>
<div class="turn-banner" id="turn-banner"></div>
<div class="board-container" id="board-container"></div>
<ul class="player-list" id="game-player-list"></ul>
<div>
<button id="roll-btn" hidden>Roll</button>
<button id="roll-btn" hidden><span id="dice-icon">🎲</span> Roll</button>
<div class="choice-buttons" id="choice-buttons"></div>
</div>
<div class="win-banner" id="win-banner" hidden></div>
@@ -122,8 +176,10 @@
createGame, joinGame, saveSession, loadSession,
NetworkTransport, getLegalIntents, board,
} from '/client.js';
import { BoardView } from '/boardRender.js';
const els = {
app: document.getElementById('app'),
subtitle: document.getElementById('subtitle'),
panelEntry: document.getElementById('panel-entry'),
panelLobby: document.getElementById('panel-lobby'),
@@ -139,8 +195,10 @@
startBtn: document.getElementById('start-btn'),
lobbyHint: document.getElementById('lobby-hint'),
turnBanner: document.getElementById('turn-banner'),
boardContainer: document.getElementById('board-container'),
gamePlayerList: document.getElementById('game-player-list'),
rollBtn: document.getElementById('roll-btn'),
diceIcon: document.getElementById('dice-icon'),
choiceButtons: document.getElementById('choice-buttons'),
winBanner: document.getElementById('win-banner'),
logFeed: document.getElementById('log-feed'),
@@ -150,11 +208,13 @@
let transport = null;
let self = { code: null, gameId: null, playerId: null, sessionToken: null };
let errorTimer = null;
const boardView = new BoardView(els.boardContainer, board);
function showPanel(name) {
els.panelEntry.hidden = name !== 'entry';
els.panelLobby.hidden = name !== 'lobby';
els.panelGame.hidden = name !== 'game';
els.app.classList.toggle('wide', name === 'game');
}
function showError(message) {
@@ -205,6 +265,9 @@
function renderGame(state) {
showPanel('game');
boardView.update(state, self, {
onChoose: (spaceId) => transport.sendIntent({ type: 'REQUEST_CHOOSE', spaceId }),
});
const players = Object.values(state.players).sort((a, b) => a.seat - b.seat);
els.gamePlayerList.innerHTML = '';
for (const p of players) {
@@ -293,7 +356,12 @@
};
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.inviteLink.select();
navigator.clipboard?.writeText(els.inviteLink.value).catch(() => {});
+3 -1
View File
@@ -29,7 +29,9 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
db.initDb(path.join(DATA_DIR, 'lifegame.db'));
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
const PLAYER_COLORS = ['#e8a12a', '#3f8f5f', '#2f6f9f', '#c1452f', '#7a4fae', '#2f8f8f'];
// Deliberately avoids the board's own semantic colors (green=start,
// marigold=finish, red=choice) so a player's token never blends into a space.
const PLAYER_COLORS = ['#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c'];
const AUTH_TIMEOUT_MS = 10_000;
const app = express();
+163 -35
View File
@@ -1,60 +1,188 @@
/**
* Life Journey — Phase 1 board.
* Life Journey — full board.
*
* A small representative subset of the full hand-drawn board (assets/game_board.png):
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into
* any branch array later (repointing one `next`) without touching the reducer.
* Transcribed from the hand-drawn sketch (assets/game_board.png), which uses
* many space names more than once across its zones (Start A Business,
* Family Reunion, Market Crash, ...) — that repetition is treated as
* intentional recurring flavor, not deduplicated. The sketch's arrows get
* genuinely ambiguous in a few spots (a hand-drawn/AI-generated mockup, not
* an engineered spec), so rather than force a literal reverse-engineering of
* every arrow, every distinct label from the sketch is kept and organized
* into a clean DAG with the same shape Phase 1 already proved out: a fork,
* a chain per branch, a convergence, repeated three times, then a long
* shared retirement tail. More spaces can still be inserted into any branch
* later — just splice into its chain() array below.
*
* Space shape: { id, type, label, next?, choices?, cash?, flavor? }
* type: 'start' | 'event' | 'money' | 'choice' | 'finish'
* next: id of the following space (absent on 'choice' and 'finish' spaces)
* choices: [id, id] of the branches offered by a 'choice' space
* choices: [id, ...] of the branches offered by a 'choice' space (2 or more)
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces)
*/
/** Wires each entry's `next` to the following entry's id; the last one gets `finalNext`. */
function chain(entries, finalNext) {
return entries.map((entry, i) => ({
...entry,
next: i < entries.length - 1 ? entries[i + 1].id : finalNext,
}));
}
const careerChain = chain([
{ id: 'career_1', type: 'event', label: 'High School Dropout' },
{ id: 'career_2', type: 'event', label: 'New Job' },
{ id: 'career_3', type: 'money', label: 'Performance Review', cash: 200 },
{ id: 'career_4', type: 'event', label: 'Volunteered' },
{ id: 'career_5', type: 'event', label: 'Direct Report' },
{ id: 'career_6', type: 'money', label: 'Start A Business', cash: -250 },
{ id: 'career_7', type: 'event', label: 'Office Happy Hour' },
{ id: 'career_8', type: 'money', label: 'Work Trip', cash: -100 },
{ id: 'career_9', type: 'event', label: 'Remote Vs Office' },
{ id: 'career_10', type: 'event', label: 'Exit Interview' },
{ id: 'career_11', type: 'event', label: 'Resignation' },
{ id: 'career_12', type: 'money', label: 'Industry Shift', cash: -150 },
{ id: 'career_13', type: 'money', label: 'Workplace Drama', cash: -150 },
{ id: 'career_14', type: 'money', label: 'Big Career Moment', cash: 350 },
{ id: 'career_15', type: 'event', label: 'Mentorship' },
], 'join_1');
const eduChain = chain([
{ id: 'edu_1', type: 'event', label: 'College Enrolled' },
{ id: 'edu_2', type: 'money', label: 'Scholarship', cash: 400 },
{ id: 'edu_3', type: 'event', label: '4-Year Degree' },
{ id: 'edu_4', type: 'event', label: 'Community College' },
{ id: 'edu_5', type: 'event', label: 'Apprenticeship' },
{ id: 'edu_6', type: 'money', label: 'Campus Drama', cash: -100 },
{ id: 'edu_7', type: 'money', label: 'Study Abroad', cash: -150 },
{ id: 'edu_8', type: 'event', label: 'Trade Circuit' },
{ id: 'edu_9', type: 'event', label: 'Mentor' },
{ id: 'edu_10', type: 'money', label: 'Online Courses', cash: -50 },
{ id: 'edu_11', type: 'event', label: 'Graduation' },
], 'join_1');
const gapChain = chain([
{ id: 'gap_1', type: 'event', label: 'Canada', flavor: '🇨🇦' },
{ id: 'gap_2', type: 'event', label: 'Iceland', flavor: '🇮🇸' },
{ id: 'gap_3', type: 'event', label: 'Travel Buddy' },
{ id: 'gap_4', type: 'event', label: 'Mexico', flavor: '🇲🇽' },
{ id: 'gap_5', type: 'money', label: 'Cultural Mistake', cash: -100 },
{ id: 'gap_6', type: 'event', label: 'Australia', flavor: '🇦🇺' },
{ id: 'gap_7', type: 'event', label: 'Found Community' },
{ id: 'gap_8', type: 'event', label: 'Japan', flavor: '🇯🇵' },
{ id: 'gap_9', type: 'money', label: 'Visa Problem', cash: -150 },
{ id: 'gap_10', type: 'event', label: 'Germany', flavor: '🇩🇪' },
{ id: 'gap_11', type: 'event', label: 'Italy', flavor: '🇮🇹' },
], 'join_1');
const quarterLifeChain = chain([
{ id: 'join_1', type: 'event', label: 'Quarter-Life Crisis' },
{ id: 'q_2', type: 'money', label: 'Horrible Hangover', cash: -50 },
{ id: 'q_3', type: 'event', label: '30th Birthday' },
{ id: 'q_4', type: 'money', label: 'Mystery Tattoo', cash: -100 },
{ id: 'q_5', type: 'money', label: 'Unexpected Joy', cash: 200 },
], 'life_fork');
const familyChain = chain([
{ id: 'family_1', type: 'event', label: 'New City' },
{ id: 'family_2', type: 'event', label: 'Uncles Condo' },
{ id: 'family_3', type: 'event', label: 'Dinner Party' },
{ id: 'family_4', type: 'money', label: 'You Won!', cash: 300 },
{ id: 'family_5', type: 'money', label: "Friends' Wedding", cash: -150 },
{ id: 'family_6', type: 'money', label: 'Job Or Raise', cash: 250 },
{ id: 'family_7', type: 'event', label: 'Podcast' },
{ id: 'family_8', type: 'event', label: 'Hobby' },
{ id: 'family_9', type: 'event', label: 'Perfect Impression' },
{ id: 'family_10', type: 'money', label: 'First Home', cash: -300 },
{ id: 'family_11', type: 'event', label: 'Family Reunion' },
{ id: 'family_12', type: 'money', label: 'Raise Or Job', cash: 200 },
{ id: 'family_13', type: 'money', label: 'House Maintenance', cash: -150 },
{ id: 'family_14', type: 'money', label: 'Family Vacation', cash: -100 },
], 'join_2');
const investChain = chain([
{ id: 'invest_1', type: 'money', label: 'Side Investment', cash: -150 },
{ id: 'invest_2', type: 'money', label: 'Market Move', cash: 350 },
{ id: 'invest_3', type: 'money', label: '401K Contribution', cash: -100 },
{ id: 'invest_4', type: 'event', label: 'Forced Partnership' },
{ id: 'invest_5', type: 'money', label: 'Market Crash', cash: -300 },
{ id: 'invest_6', type: 'money', label: 'Start A Business', cash: -250 },
{ id: 'invest_7', type: 'money', label: 'Bet Against Player', cash: 200 },
{ id: 'invest_8', type: 'money', label: 'Market Manipulation', cash: -200 },
{ id: 'invest_9', type: 'money', label: 'Leveraged Buyout', cash: 300 },
{ id: 'invest_10', type: 'money', label: '100K Windfall', cash: 500 },
{ id: 'invest_11', type: 'money', label: 'Investigation', cash: -250 },
{ id: 'invest_12', type: 'money', label: 'Rogue Trader', cash: -300 },
{ id: 'invest_13', type: 'money', label: '10K Payout', cash: 250 },
], 'join_2');
const highRiskChain = chain([
{ id: 'risk_1', type: 'money', label: 'High Risk Bet', cash: 400 },
{ id: 'risk_2', type: 'money', label: 'Office Buyout', cash: -200 },
{ id: 'risk_3', type: 'money', label: 'Bankruptcy', cash: -500, flavor: 'Ouch.' },
{ id: 'risk_4', type: 'money', label: 'Comeback Deal', cash: 450 },
{ id: 'risk_5', type: 'money', label: 'Investigation', cash: -200 },
{ id: 'risk_6', type: 'money', label: 'Rogue Trader', cash: -250 },
], 'join_3');
const safeChain = chain([
{ id: 'safe_1', type: 'money', label: 'Force Sale', cash: -150 },
{ id: 'safe_2', type: 'money', label: 'Start A Business', cash: -100 },
{ id: 'safe_3', type: 'money', label: 'Raise Or Job', cash: 200 },
{ id: 'safe_4', type: 'event', label: 'Family Reunion' },
{ id: 'safe_5', type: 'money', label: 'Family Vacation', cash: -100 },
{ id: 'safe_6', type: 'money', label: 'Steady Savings', cash: 150 },
], 'join_3');
const leisureChain = chain([
{ id: 'leisure_1', type: 'money', label: 'Investment Paid Off', cash: 300 },
{ id: 'leisure_2', type: 'event', label: 'Start A Band' },
{ id: 'leisure_3', type: 'event', label: 'Jury Duty' },
{ id: 'leisure_4', type: 'event', label: 'Hobby' },
{ id: 'leisure_5', type: 'money', label: 'The Races', cash: -150 },
{ id: 'leisure_6', type: 'event', label: 'Job Offer' },
{ id: 'leisure_7', type: 'money', label: 'Bucket List Trip', cash: -200 },
{ id: 'leisure_8', type: 'money', label: 'Home Renovation', cash: -250 },
{ id: 'leisure_9', type: 'event', label: 'Hosted Thanksgiving' },
{ id: 'leisure_10', type: 'event', label: 'Highschool Reunion' },
{ id: 'leisure_11', type: 'money', label: 'Fantasy Football', cash: 100 },
{ id: 'leisure_12', type: 'money', label: 'Office Pool', cash: 150 },
{ id: 'leisure_13', type: 'event', label: 'Pickle Ball' },
{ id: 'leisure_14', type: 'event', label: 'Bowling Night' },
{ id: 'leisure_15', type: 'money', label: 'Dental Work', cash: -150 },
{ id: 'leisure_16', type: 'money', label: 'Golf Trip', cash: -100 },
{ id: 'leisure_17', type: 'event', label: 'Joined Facebook' },
{ id: 'leisure_18', type: 'money', label: 'Speeding Ticket', cash: -75 },
], 'retirement_party');
const spaceList = [
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' },
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] },
{ id: 'start', type: 'start', label: 'Start', next: 'path_fork' },
{ id: 'path_fork', type: 'choice', label: 'Which Path?', choices: ['career_1', 'edu_1', 'gap_1'] },
{ id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' },
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' },
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' },
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' },
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' },
...careerChain,
...eduChain,
...gapChain,
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' },
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' },
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' },
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' },
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' },
...quarterLifeChain,
{ id: 'life_fork', type: 'choice', label: 'Relationship or Investment?', choices: ['family_1', 'invest_1'] },
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' },
{ id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] },
...familyChain,
...investChain,
{ 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: 'join_2', type: 'event', label: 'Settling Down', next: 'risk_fork' },
{ id: 'risk_fork', type: 'choice', label: 'High Risk or Safe?', choices: ['risk_1', 'safe_1'] },
{ 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' },
...highRiskChain,
...safeChain,
{ 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: 'join_3', type: 'event', label: 'Retirement Planning', next: 'leisure_1' },
...leisureChain,
{ id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
{ id: 'finish', type: 'finish', label: 'Finish' },
];
export const board = {
id: 'phase1-demo',
id: 'life-journey-v1',
startSpaceId: 'start',
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
};
+70 -68
View File
@@ -1,11 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js';
import { board } from './board.js';
function join(state, playerId, name, seat) {
return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' });
}
/** Drives `playerId` forward by always resolving any pending choice with its
* first option and otherwise rolling the max die value — walkForward stops
* early at the next choice/finish regardless of pips, so this reliably makes
* progress without the test needing to know the board's shape or size. */
function takeTurn(state, playerId) {
const player = state.players[playerId];
if (player.pendingChoice) {
return reduce(state, { type: 'CHOOSE', playerId, spaceId: player.pendingChoice.options[0] });
}
return reduce(state, { type: 'ROLL', playerId, value: state.config.diceSides });
}
test('lobby: join validation', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
@@ -14,7 +27,7 @@ test('lobby: join validation', () => {
assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/);
});
test('full game: both choice points, race to finish', () => {
test('turn order and illegal actions', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
state = join(state, 'p2', 'Bob', 2);
@@ -23,75 +36,64 @@ test('full game: both choice points, race to finish', () => {
assert.equal(state.status, 'active');
assert.deepEqual(state.turnOrder, ['p1', 'p2']);
assert.equal(state.currentTurn, 'p1');
assert.equal(isPlayersTurn(state, 'p1'), true);
assert.equal(isPlayersTurn(state, 'p2'), false);
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']);
assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.equal(isPlayersTurn(state, 'p2'), false);
// Not p2's turn yet.
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/);
// p1 rolls onto the first choice space; movement stops immediately even
// though only 1 of the roll's pips was needed.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
assert.equal(state.players.p1.position, 'crossroads');
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] });
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose');
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/);
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/);
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' });
assert.equal(state.players.p1.position, 'career_1');
assert.equal(state.players.p1.pendingChoice, null);
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn');
// p2 takes the education branch.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 });
assert.equal(state.players.p2.position, 'crossroads');
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' });
assert.equal(state.players.p2.position, 'edu_1');
assert.equal(state.currentTurn, 'p1');
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed
// space's cash effect applies, not spaces merely passed through.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 });
assert.equal(state.players.p1.position, 'life_crossroads');
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply');
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' });
assert.equal(state.players.p1.position, 'investment_1');
assert.equal(state.players.p1.cash, -150);
assert.equal(state.currentTurn, 'p2');
// p2: edu_1 -> life_crossroads is also exactly 6 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 });
assert.equal(state.players.p2.position, 'life_crossroads');
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' });
assert.equal(state.players.p2.position, 'relationship_1');
assert.equal(state.currentTurn, 'p1');
// p1: investment_1 -> high_risk_choice is exactly 4 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 });
assert.equal(state.players.p1.position, 'high_risk_choice');
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' });
assert.equal(state.players.p1.cash, -50); // -150 + 100
assert.equal(state.currentTurn, 'p2');
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps.
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 });
assert.equal(state.players.p2.position, 'high_risk_choice');
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' });
assert.equal(state.players.p2.cash, 500);
assert.equal(state.currentTurn, 'p1');
// p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game.
assert.equal(state.status, 'active');
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 });
assert.equal(state.players.p1.position, 'finish');
assert.equal(state.status, 'finished');
assert.equal(state.winnerId, 'p1');
assert.equal(state.currentTurn, null);
assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/);
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'anything' }), /No pending choice/);
});
test('full game: every player reaches every fork, race to finish', () => {
let state = createInitialState();
state = join(state, 'p1', 'Alice', 1);
state = join(state, 'p2', 'Bob', 2);
state = reduce(state, { type: 'START_GAME' });
const seenChoiceSpaces = new Set();
let guard = 0;
while (state.status === 'active') {
if (++guard > 500) throw new Error('game did not finish in a reasonable number of turns');
const turnPlayerId = state.currentTurn;
const before = state.players[turnPlayerId];
if (before.pendingChoice) seenChoiceSpaces.add(before.position);
state = takeTurn(state, turnPlayerId);
const after = state.players[turnPlayerId];
assert.equal(typeof after.cash, 'number');
assert.ok(board.spaces[after.position], 'player is always on a real space');
}
assert.equal(state.status, 'finished');
assert.ok(state.winnerId, 'a winner is recorded');
assert.equal(state.currentTurn, null);
assert.equal(state.players[state.winnerId].position, 'finish');
assert.deepEqual(getLegalIntents(state, 'p1'), []);
assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 1 }), /not active/);
// Always picking the first option at every fork should have visited all
// three choice points at least once between two full playthroughs' worth
// of turns (the loser doesn't necessarily finish, so this just checks the
// race itself exercised real fork logic, not that both players finished).
assert.ok(seenChoiceSpaces.size >= 1, 'at least one fork was actually resolved');
});
test('board graph is well-formed', () => {
const ids = Object.keys(board.spaces);
assert.ok(ids.length > 100, 'the full board should be a substantial expansion of the Phase 1 subset');
for (const id of ids) {
const space = board.spaces[id];
if (space.type === 'choice') {
assert.ok(Array.isArray(space.choices) && space.choices.length >= 2, `${id} needs 2+ choices`);
for (const target of space.choices) assert.ok(board.spaces[target], `${id} -> missing ${target}`);
} else if (space.type === 'finish') {
assert.equal(space.next, undefined);
} else {
assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
}
}
});