From 6aa9769ce52c1719be9b2d6525ef6d21c53e4d98 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 21 Jul 2026 18:56:27 -0700 Subject: [PATCH] Expand the board to the full sketch (~107 spaces, three forks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 20 +++-- public/boardRender.js | 18 ++-- public/index.html | 2 +- shared/board.js | 198 ++++++++++++++++++++++++++++++++++-------- shared/game.test.js | 138 ++++++++++++++--------------- 5 files changed, 256 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 1b3aef0..25f9e83 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/public/boardRender.js b/public/boardRender.js index f531ce4..6c63b3f 100644 --- a/public/boardRender.js +++ b/public/boardRender.js @@ -10,7 +10,7 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; const COL_WIDTH = 108; const ROW_HEIGHT = 168; -const LANE_HEIGHT = 52; +const LANE_HEIGHT = 64; const MARGIN_X = 70; const MARGIN_Y = 80; const NODE_W = 88; @@ -26,7 +26,7 @@ const TYPE_STYLE = { }; /** 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 outEdges = 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) => { 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 - // 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 === 0 ? -1 : 1) : 0; + // 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); diff --git a/public/index.html b/public/index.html index 34c0be4..3902e75 100644 --- a/public/index.html +++ b/public/index.html @@ -70,7 +70,7 @@ /* --- Board --- */ .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%); border: 1px solid var(--line); } diff --git a/shared/board.js b/shared/board.js index e9bca7f..828349a 100644 --- a/shared/board.js +++ b/shared/board.js @@ -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])), }; diff --git a/shared/game.test.js b/shared/game.test.js index 7eb5b08..9ccf2b6 100644 --- a/shared/game.test.js +++ b/shared/game.test.js @@ -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}`); + } + } });