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).
This commit is contained in:
2026-07-21 18:56:27 -07:00
parent 8df4859696
commit 6aa9769ce5
5 changed files with 256 additions and 120 deletions
+12 -8
View File
@@ -45,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
+10 -8
View File
@@ -10,7 +10,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
const COL_WIDTH = 108; const COL_WIDTH = 108;
const ROW_HEIGHT = 168; const ROW_HEIGHT = 168;
const LANE_HEIGHT = 52; const LANE_HEIGHT = 64;
const MARGIN_X = 70; const MARGIN_X = 70;
const MARGIN_Y = 80; const MARGIN_Y = 80;
const NODE_W = 88; const NODE_W = 88;
@@ -26,7 +26,7 @@ const TYPE_STYLE = {
}; };
/** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */ /** Pure layout computation — no DOM. Returns { positions, edges, width, height }. */
export function computeLayout(board, colsPerRow = 9) { export function computeLayout(board, colsPerRow = 14) {
const ids = Object.keys(board.spaces); const ids = Object.keys(board.spaces);
const outEdges = new Map(ids.map((id) => [id, []])); const outEdges = new Map(ids.map((id) => [id, []]));
const predecessors = new Map(ids.map((id) => [id, []])); const predecessors = new Map(ids.map((id) => [id, []]));
@@ -53,12 +53,14 @@ export function computeLayout(board, colsPerRow = 9) {
children.forEach((childId, i) => { children.forEach((childId, i) => {
col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1)); col.set(childId, Math.max(col.get(childId) ?? -Infinity, col.get(id) + 1));
// Choice spaces fan their two branches out (-1/+1 lanes); anything else // Choice spaces fan their N branches out symmetrically around the
// just inherits the parent's lane unchanged. A join (>1 predecessor) // parent's lane (works for 2-way, 3-way, ... forks alike); anything
// accumulates every incoming contribution and averages once all of its // else just inherits the parent's lane unchanged. A join (>1
// predecessors have been processed (guaranteed by the time `remaining` // predecessor) accumulates every incoming contribution and averages
// hits 0, since that only happens after every in-edge is visited). // once all of its predecessors have been processed (guaranteed by the
const offset = space.type === 'choice' ? (i === 0 ? -1 : 1) : 0; // 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; const contribution = laneSum.get(id) + offset;
laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution); laneSum.set(childId, (laneSum.get(childId) ?? 0) + contribution);
+1 -1
View File
@@ -70,7 +70,7 @@
/* --- Board --- */ /* --- Board --- */
.board-container { .board-container {
overflow-x: auto; border-radius: 14px; margin-bottom: 14px; 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%); background: radial-gradient(900px 500px at 30% 0%, #2a5f4c 0%, #1d493c 55%, #163a30 100%);
border: 1px solid var(--line); border: 1px solid var(--line);
} }
+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): * Transcribed from the hand-drawn sketch (assets/game_board.png), which uses
* a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe * many space names more than once across its zones (Start A Business,
* fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into * Family Reunion, Market Crash, ...) — that repetition is treated as
* any branch array later (repointing one `next`) without touching the reducer. * 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? } * Space shape: { id, type, label, next?, choices?, cash?, flavor? }
* type: 'start' | 'event' | 'money' | 'choice' | 'finish' * type: 'start' | 'event' | 'money' | 'choice' | 'finish'
* next: id of the following space (absent on 'choice' and 'finish' spaces) * 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) * 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 = [ const spaceList = [
{ id: 'start', type: 'start', label: 'Start', next: 'crossroads' }, { id: 'start', type: 'start', label: 'Start', next: 'path_fork' },
{ id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] }, { 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' }, ...careerChain,
{ id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' }, ...eduChain,
{ id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' }, ...gapChain,
{ id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' },
{ id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' },
{ id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' }, ...quarterLifeChain,
{ id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' }, { id: 'life_fork', type: 'choice', label: 'Relationship or Investment?', choices: ['family_1', 'invest_1'] },
{ id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' },
{ id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' },
{ id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' },
{ id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' }, ...familyChain,
{ id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] }, ...investChain,
{ id: 'relationship_1', type: 'event', label: 'New City', next: 'relationship_2' }, { id: 'join_2', type: 'event', label: 'Settling Down', next: 'risk_fork' },
{ id: 'relationship_2', type: 'event', label: 'Dinner Party', next: 'relationship_3' }, { id: 'risk_fork', type: 'choice', label: 'High Risk or Safe?', choices: ['risk_1', 'safe_1'] },
{ 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' }, ...highRiskChain,
{ id: 'investment_2', type: 'money', label: 'Market Move', cash: 350, next: 'investment_3' }, ...safeChain,
{ 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: 'join_3', type: 'event', label: 'Retirement Planning', next: 'leisure_1' },
{ id: 'high_risk_choice', type: 'choice', label: 'One Last Fork', choices: ['high_risk_1', 'safe_1'] }, ...leisureChain,
{ 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: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' },
{ id: 'finish', type: 'finish', label: 'Finish' }, { id: 'finish', type: 'finish', label: 'Finish' },
]; ];
export const board = { export const board = {
id: 'phase1-demo', id: 'life-journey-v1',
startSpaceId: 'start', startSpaceId: 'start',
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])), spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
}; };
+70 -68
View File
@@ -1,11 +1,24 @@
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';
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' });
} }
/** 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', () => { test('lobby: join validation', () => {
let state = createInitialState(); let state = createInitialState();
state = join(state, 'p1', 'Alice', 1); state = join(state, 'p1', 'Alice', 1);
@@ -14,7 +27,7 @@ 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('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 +36,64 @@ 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', value: 3 }), /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.
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 }); test('full game: every player reaches every fork, race to finish', () => {
assert.equal(state.players.p1.position, 'crossroads'); let state = createInitialState();
assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] }); state = join(state, 'p1', 'Alice', 1);
assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose'); state = join(state, 'p2', 'Bob', 2);
assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']); state = reduce(state, { type: 'START_GAME' });
assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/); const seenChoiceSpaces = new Set();
assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/); let guard = 0;
while (state.status === 'active') {
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' }); if (++guard > 500) throw new Error('game did not finish in a reasonable number of turns');
assert.equal(state.players.p1.position, 'career_1'); const turnPlayerId = state.currentTurn;
assert.equal(state.players.p1.pendingChoice, null); const before = state.players[turnPlayerId];
assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn'); if (before.pendingChoice) seenChoiceSpaces.add(before.position);
// p2 takes the education branch. state = takeTurn(state, turnPlayerId);
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 });
assert.equal(state.players.p2.position, 'crossroads'); const after = state.players[turnPlayerId];
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' }); assert.equal(typeof after.cash, 'number');
assert.equal(state.players.p2.position, 'edu_1'); assert.ok(board.spaces[after.position], 'player is always on a real space');
assert.equal(state.currentTurn, 'p1'); }
// p1: career_1 -> life_crossroads is exactly 6 steps; only the landed assert.equal(state.status, 'finished');
// space's cash effect applies, not spaces merely passed through. assert.ok(state.winnerId, 'a winner is recorded');
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 }); assert.equal(state.currentTurn, null);
assert.equal(state.players.p1.position, 'life_crossroads'); assert.equal(state.players[state.winnerId].position, 'finish');
assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply'); assert.deepEqual(getLegalIntents(state, 'p1'), []);
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' }); assert.deepEqual(getLegalIntents(state, 'p2'), []);
assert.equal(state.players.p1.position, 'investment_1'); assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 1 }), /not active/);
assert.equal(state.players.p1.cash, -150);
assert.equal(state.currentTurn, 'p2'); // Always picking the first option at every fork should have visited all
// three choice points at least once between two full playthroughs' worth
// p2: edu_1 -> life_crossroads is also exactly 6 steps. // of turns (the loser doesn't necessarily finish, so this just checks the
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 }); // race itself exercised real fork logic, not that both players finished).
assert.equal(state.players.p2.position, 'life_crossroads'); assert.ok(seenChoiceSpaces.size >= 1, 'at least one fork was actually resolved');
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' }); });
assert.equal(state.players.p2.position, 'relationship_1');
assert.equal(state.currentTurn, 'p1'); test('board graph is well-formed', () => {
const ids = Object.keys(board.spaces);
// p1: investment_1 -> high_risk_choice is exactly 4 steps. assert.ok(ids.length > 100, 'the full board should be a substantial expansion of the Phase 1 subset');
state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 });
assert.equal(state.players.p1.position, 'high_risk_choice'); for (const id of ids) {
state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' }); const space = board.spaces[id];
assert.equal(state.players.p1.cash, -50); // -150 + 100 if (space.type === 'choice') {
assert.equal(state.currentTurn, 'p2'); 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}`);
// p2: relationship_1 -> high_risk_choice is also exactly 4 steps. } else if (space.type === 'finish') {
state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 }); assert.equal(space.next, undefined);
assert.equal(state.players.p2.position, 'high_risk_choice'); } else {
state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' }); assert.ok(board.spaces[space.next], `${id} -> missing ${space.next}`);
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/);
}); });