From 4d78937dcab8f3418da1a0a12c62af9bf85cea73 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 28 Jul 2026 10:06:59 -0700 Subject: [PATCH] Rebuild the board and reducer around the real content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared/board.js: rebuilt from shared/tileInventory.js instead of the sketch-inspired placeholder — one chain() per segment (same helper as before), TEMP_SEGMENT_ORDER concatenating all 12 segments in the design doc's own order since board-graph.json (the real segment connections/STOP forks) doesn't exist yet — loudly commented as a placeholder to replace, not a guess at real topology. A synthetic `finish` space is appended since no tile in the source data has that type (win condition is itself an open question). Movement is now one tile per turn (board.spaces[pos].next) — no tile in the data implies a movement die, every `die` belongs to that tile's own effect resolution, so walkForward() is gone. shared/tileEffects.js (new): pure resolveTileEffect(space, rolls) covering all 9 real tile types (payday, action_space, dice_space, roll_table_ref, inline_table, cash_bonus, event, choice, stop) plus rollsNeededFor(space) so the server knows what to pre-roll before constructing an action. Real roll_table_ref/action_space/dice_space tiles resolve against the actual 48 placeholder tables; the two real content gaps (inline_table's 85 tiles, payday/cash_bonus amounts) resolve against a synthesized generic banded table per die size, every result clearly flagged `todo: true` so it's visible in the game log, not just in docs. shared/game.js: player state is now flat {cash, love, education, wealth, age} fields (mirrors a roll-table effect object's shape directly). Landing logic calls resolveTileEffect() instead of applying a single cash delta, and only pauses for a decision when a space's `choices` array is actually populated (true of nothing yet) — keeps the existing pending-choice machinery intact for once board-graph.json exists, instead of needing choice/stop tiles to block on options nobody has defined. server/rooms.js: REQUEST_ROLL now looks up the player's next tile, determines what it needs via rollsNeededFor(), and pre-rolls each die via crypto.randomInt before constructing the ROLL action — same randomness-happens-once-at-the-server-boundary pattern as before, just covering N table rolls instead of one movement die. server/index.js: default config now carries startingStats instead of startingCash, and maxPlayers is 8. --- server/index.js | 10 +- server/rooms.js | 27 ++++- shared/board.js | 267 ++++++++++++------------------------------ shared/game.js | 65 ++++++---- shared/tileEffects.js | 189 ++++++++++++++++++++++++++++++ 5 files changed, 339 insertions(+), 219 deletions(-) create mode 100644 shared/tileEffects.js diff --git a/server/index.js b/server/index.js index 0bc848e..eddfbd5 100644 --- a/server/index.js +++ b/server/index.js @@ -28,10 +28,16 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); fs.mkdirSync(DATA_DIR, { recursive: true }); db.initDb(path.join(DATA_DIR, 'lifegame.db')); -const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; +const DEFAULT_CONFIG = { + minPlayers: 2, + maxPlayers: 8, + startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 }, +}; // Deliberately avoids the board's own semantic colors (green=start, // marigold=finish, red=choice) so a player's token never blends into a space. -const PLAYER_COLORS = ['#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c']; +const PLAYER_COLORS = [ + '#2f6f9f', '#7a4fae', '#2f8f8f', '#c15fa0', '#a6752c', '#5a6b8c', '#4a4a9f', '#8a8a3f', +]; const AUTH_TIMEOUT_MS = 10_000; const app = express(); diff --git a/server/rooms.js b/server/rooms.js index 2d99732..0cc25ff 100644 --- a/server/rooms.js +++ b/server/rooms.js @@ -6,6 +6,8 @@ import crypto from 'node:crypto'; import { reduce, getLegalIntents } from '../shared/game.js'; +import { board } from '../shared/board.js'; +import { rollsNeededFor, DIE_SIZES } from '../shared/tileEffects.js'; import * as db from './db.js'; export class RoomError extends Error {} @@ -59,16 +61,33 @@ function buildAction(state, playerId, intent) { switch (intent.type) { case 'REQUEST_START': return { type: 'START_GAME' }; - case 'REQUEST_ROLL': - return { type: 'ROLL', playerId, value: 1 + crypto.randomInt(state.config.diceSides) }; - case 'REQUEST_CHOOSE': + case 'REQUEST_ROLL': { + const player = state.players[playerId]; + const nextSpaceId = board.spaces[player.position].next; + if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board'); + return { type: 'ROLL', playerId, rolls: rollForSpace(board.spaces[nextSpaceId]) }; + } + case 'REQUEST_CHOOSE': { if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required'); - return { type: 'CHOOSE', playerId, spaceId: intent.spaceId }; + return { type: 'CHOOSE', playerId, spaceId: intent.spaceId, rolls: rollForSpace(board.spaces[intent.spaceId]) }; + } default: throw new Error(`Unknown intent: ${intent.type}`); } } +/** Pre-roll whatever dice `space` needs to resolve its own tile effect — the + * only place actual randomness happens. rollsNeededFor() never touches + * randomness itself, it just says which die sizes are needed and in what + * order; reduce() applies the results deterministically. */ +function rollForSpace(space) { + return rollsNeededFor(space).map((die) => { + const max = DIE_SIZES[die]; + if (!max) throw new Error(`Unknown die size: ${die}`); + return 1 + crypto.randomInt(max); + }); +} + function broadcast(room, payload) { for (const ws of room.sockets) send(ws, payload); } diff --git a/shared/board.js b/shared/board.js index 828349a..913fd89 100644 --- a/shared/board.js +++ b/shared/board.js @@ -1,204 +1,89 @@ /** - * Life Journey — full board. + * Life Journey — the real board: 211 tiles across 12 segments, built from + * shared/tileInventory.js (parsed from the design doc). * - * Transcribed from the hand-drawn sketch (assets/game_board.png), which uses - * many space names more than once across its zones (Start A Business, - * Family Reunion, Market Crash, ...) — that repetition is treated as - * intentional recurring flavor, not deduplicated. The sketch's arrows get - * genuinely ambiguous in a few spots (a hand-drawn/AI-generated mockup, not - * an engineered spec), so rather than force a literal reverse-engineering of - * every arrow, every distinct label from the sketch is kept and organized - * into a clean DAG with the same shape Phase 1 already proved out: a fork, - * a chain per branch, a convergence, repeated three times, then a long - * shared retirement tail. More spaces can still be inserted into any branch - * later — just splice into its chain() array below. + * TEMPORARY: the 12 segments have no defined connections yet — that's what + * shared/BRANCHING-TEMPLATE.md is an open request for (the STOP forks, and + * which segment's exit feeds which segment's entry). Until board-graph.json + * exists, TEMP_SEGMENT_ORDER below just concatenates all 12 segments in the + * design doc's own listed order into one line, so movement/tests/rendering + * have something real to walk. This is NOT the real board topology — delete + * this placeholder and replace `next` wiring once board-graph.json exists. * - * Space shape: { id, type, label, next?, choices?, cash?, flavor? } - * type: 'start' | 'event' | 'money' | 'choice' | 'finish' - * next: id of the following space (absent on 'choice' and 'finish' spaces) - * choices: [id, ...] of the branches offered by a 'choice' space (2 or more) - * cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces) + * Similarly, no tile in the source data has type 'finish' (win condition is + * itself an open question in GAME-REVIEW.md) — a synthetic `finish` space is + * appended after the last tile so the reducer has something to end on. + * + * Movement is one tile per turn (no tile in the data implies a movement die — + * every `die` value belongs to that tile's own effect resolution), so unlike + * the old board there is no walkForward()/multi-step-with-early-stop concept + * here: a turn is just "go to `next`, then resolve whatever that tile needs." + * + * Space shape: { id, type, label, die, externalTables, statsTouched, next } + * type: one of the 9 tile-inventory types — payday, action_space, + * dice_space, roll_table_ref, inline_table, cash_bonus, event, choice, stop + * die: 'D100'|'D20'|'D10'|'D8'|'D6'|'D2'|null — what to roll to resolve this tile + * externalTables: source_doc_id[] into shared/rollTables.js (0-3 entries) + * statsTouched: string[] hint from the source data, informational only + * next: id of the following space (absent only on the synthetic 'finish') + * choices: NOT set on any tile yet (no real branch destinations are known) — + * shared/game.js only pauses a 'choice'/'stop' space for a decision when + * `choices` is non-empty, so today every one of these is a harmless + * pass-through, not a dead end. */ -/** Wires each entry's `next` to the following entry's id; the last one gets `finalNext`. */ -function chain(entries, finalNext) { - return entries.map((entry, i) => ({ - ...entry, - next: i < entries.length - 1 ? entries[i + 1].id : finalNext, +import tileInventory from './tileInventory.js'; + +const TEMP_SEGMENT_ORDER = [ + 'starting_strip', + 'career', + 'investment', + 'investment_bottom', + 'high_risk', + 'gap_year', + 'education', + 'relationship_top', + 'relationship_bottom', + 'retirement_top', + 'retirement_middle', + 'retirement_bottom', +]; + +const segmentsById = Object.fromEntries(tileInventory.segments.map((s) => [s.id, s])); + +function buildSegmentSpaces(segmentId, nextAfterSegment) { + const segment = segmentsById[segmentId]; + if (!segment) throw new Error(`Unknown segment id in TEMP_SEGMENT_ORDER: ${segmentId}`); + return segment.tiles.map((tile, i) => ({ + id: `${segmentId}_${i}`, + type: tile.type, + label: tile.name, + die: tile.die ?? null, + externalTables: tile.external_tables ?? [], + statsTouched: tile.stats_touched ?? [], + next: i < segment.tiles.length - 1 ? `${segmentId}_${i + 1}` : nextAfterSegment, })); } -const careerChain = chain([ - { id: 'career_1', type: 'event', label: 'High School Dropout' }, - { id: 'career_2', type: 'event', label: 'New Job' }, - { id: 'career_3', type: 'money', label: 'Performance Review', cash: 200 }, - { id: 'career_4', type: 'event', label: 'Volunteered' }, - { id: 'career_5', type: 'event', label: 'Direct Report' }, - { id: 'career_6', type: 'money', label: 'Start A Business', cash: -250 }, - { id: 'career_7', type: 'event', label: 'Office Happy Hour' }, - { id: 'career_8', type: 'money', label: 'Work Trip', cash: -100 }, - { id: 'career_9', type: 'event', label: 'Remote Vs Office' }, - { id: 'career_10', type: 'event', label: 'Exit Interview' }, - { id: 'career_11', type: 'event', label: 'Resignation' }, - { id: 'career_12', type: 'money', label: 'Industry Shift', cash: -150 }, - { id: 'career_13', type: 'money', label: 'Workplace Drama', cash: -150 }, - { id: 'career_14', type: 'money', label: 'Big Career Moment', cash: 350 }, - { id: 'career_15', type: 'event', label: 'Mentorship' }, -], 'join_1'); +const spaceList = TEMP_SEGMENT_ORDER.flatMap((segmentId, i) => { + const isLastSegment = i === TEMP_SEGMENT_ORDER.length - 1; + const nextAfterSegment = isLastSegment ? 'finish' : `${TEMP_SEGMENT_ORDER[i + 1]}_0`; + return buildSegmentSpaces(segmentId, nextAfterSegment); +}); -const eduChain = chain([ - { id: 'edu_1', type: 'event', label: 'College Enrolled' }, - { id: 'edu_2', type: 'money', label: 'Scholarship', cash: 400 }, - { id: 'edu_3', type: 'event', label: '4-Year Degree' }, - { id: 'edu_4', type: 'event', label: 'Community College' }, - { id: 'edu_5', type: 'event', label: 'Apprenticeship' }, - { id: 'edu_6', type: 'money', label: 'Campus Drama', cash: -100 }, - { id: 'edu_7', type: 'money', label: 'Study Abroad', cash: -150 }, - { id: 'edu_8', type: 'event', label: 'Trade Circuit' }, - { id: 'edu_9', type: 'event', label: 'Mentor' }, - { id: 'edu_10', type: 'money', label: 'Online Courses', cash: -50 }, - { id: 'edu_11', type: 'event', label: 'Graduation' }, -], 'join_1'); - -const gapChain = chain([ - { id: 'gap_1', type: 'event', label: 'Canada', flavor: '🇨🇦' }, - { id: 'gap_2', type: 'event', label: 'Iceland', flavor: '🇮🇸' }, - { id: 'gap_3', type: 'event', label: 'Travel Buddy' }, - { id: 'gap_4', type: 'event', label: 'Mexico', flavor: '🇲🇽' }, - { id: 'gap_5', type: 'money', label: 'Cultural Mistake', cash: -100 }, - { id: 'gap_6', type: 'event', label: 'Australia', flavor: '🇦🇺' }, - { id: 'gap_7', type: 'event', label: 'Found Community' }, - { id: 'gap_8', type: 'event', label: 'Japan', flavor: '🇯🇵' }, - { id: 'gap_9', type: 'money', label: 'Visa Problem', cash: -150 }, - { id: 'gap_10', type: 'event', label: 'Germany', flavor: '🇩🇪' }, - { id: 'gap_11', type: 'event', label: 'Italy', flavor: '🇮🇹' }, -], 'join_1'); - -const quarterLifeChain = chain([ - { id: 'join_1', type: 'event', label: 'Quarter-Life Crisis' }, - { id: 'q_2', type: 'money', label: 'Horrible Hangover', cash: -50 }, - { id: 'q_3', type: 'event', label: '30th Birthday' }, - { id: 'q_4', type: 'money', label: 'Mystery Tattoo', cash: -100 }, - { id: 'q_5', type: 'money', label: 'Unexpected Joy', cash: 200 }, -], 'life_fork'); - -const familyChain = chain([ - { id: 'family_1', type: 'event', label: 'New City' }, - { id: 'family_2', type: 'event', label: 'Uncles Condo' }, - { id: 'family_3', type: 'event', label: 'Dinner Party' }, - { id: 'family_4', type: 'money', label: 'You Won!', cash: 300 }, - { id: 'family_5', type: 'money', label: "Friends' Wedding", cash: -150 }, - { id: 'family_6', type: 'money', label: 'Job Or Raise', cash: 250 }, - { id: 'family_7', type: 'event', label: 'Podcast' }, - { id: 'family_8', type: 'event', label: 'Hobby' }, - { id: 'family_9', type: 'event', label: 'Perfect Impression' }, - { id: 'family_10', type: 'money', label: 'First Home', cash: -300 }, - { id: 'family_11', type: 'event', label: 'Family Reunion' }, - { id: 'family_12', type: 'money', label: 'Raise Or Job', cash: 200 }, - { id: 'family_13', type: 'money', label: 'House Maintenance', cash: -150 }, - { id: 'family_14', type: 'money', label: 'Family Vacation', cash: -100 }, -], 'join_2'); - -const investChain = chain([ - { id: 'invest_1', type: 'money', label: 'Side Investment', cash: -150 }, - { id: 'invest_2', type: 'money', label: 'Market Move', cash: 350 }, - { id: 'invest_3', type: 'money', label: '401K Contribution', cash: -100 }, - { id: 'invest_4', type: 'event', label: 'Forced Partnership' }, - { id: 'invest_5', type: 'money', label: 'Market Crash', cash: -300 }, - { id: 'invest_6', type: 'money', label: 'Start A Business', cash: -250 }, - { id: 'invest_7', type: 'money', label: 'Bet Against Player', cash: 200 }, - { id: 'invest_8', type: 'money', label: 'Market Manipulation', cash: -200 }, - { id: 'invest_9', type: 'money', label: 'Leveraged Buyout', cash: 300 }, - { id: 'invest_10', type: 'money', label: '100K Windfall', cash: 500 }, - { id: 'invest_11', type: 'money', label: 'Investigation', cash: -250 }, - { id: 'invest_12', type: 'money', label: 'Rogue Trader', cash: -300 }, - { id: 'invest_13', type: 'money', label: '10K Payout', cash: 250 }, -], 'join_2'); - -const highRiskChain = chain([ - { id: 'risk_1', type: 'money', label: 'High Risk Bet', cash: 400 }, - { id: 'risk_2', type: 'money', label: 'Office Buyout', cash: -200 }, - { id: 'risk_3', type: 'money', label: 'Bankruptcy', cash: -500, flavor: 'Ouch.' }, - { id: 'risk_4', type: 'money', label: 'Comeback Deal', cash: 450 }, - { id: 'risk_5', type: 'money', label: 'Investigation', cash: -200 }, - { id: 'risk_6', type: 'money', label: 'Rogue Trader', cash: -250 }, -], 'join_3'); - -const safeChain = chain([ - { id: 'safe_1', type: 'money', label: 'Force Sale', cash: -150 }, - { id: 'safe_2', type: 'money', label: 'Start A Business', cash: -100 }, - { id: 'safe_3', type: 'money', label: 'Raise Or Job', cash: 200 }, - { id: 'safe_4', type: 'event', label: 'Family Reunion' }, - { id: 'safe_5', type: 'money', label: 'Family Vacation', cash: -100 }, - { id: 'safe_6', type: 'money', label: 'Steady Savings', cash: 150 }, -], 'join_3'); - -const leisureChain = chain([ - { id: 'leisure_1', type: 'money', label: 'Investment Paid Off', cash: 300 }, - { id: 'leisure_2', type: 'event', label: 'Start A Band' }, - { id: 'leisure_3', type: 'event', label: 'Jury Duty' }, - { id: 'leisure_4', type: 'event', label: 'Hobby' }, - { id: 'leisure_5', type: 'money', label: 'The Races', cash: -150 }, - { id: 'leisure_6', type: 'event', label: 'Job Offer' }, - { id: 'leisure_7', type: 'money', label: 'Bucket List Trip', cash: -200 }, - { id: 'leisure_8', type: 'money', label: 'Home Renovation', cash: -250 }, - { id: 'leisure_9', type: 'event', label: 'Hosted Thanksgiving' }, - { id: 'leisure_10', type: 'event', label: 'Highschool Reunion' }, - { id: 'leisure_11', type: 'money', label: 'Fantasy Football', cash: 100 }, - { id: 'leisure_12', type: 'money', label: 'Office Pool', cash: 150 }, - { id: 'leisure_13', type: 'event', label: 'Pickle Ball' }, - { id: 'leisure_14', type: 'event', label: 'Bowling Night' }, - { id: 'leisure_15', type: 'money', label: 'Dental Work', cash: -150 }, - { id: 'leisure_16', type: 'money', label: 'Golf Trip', cash: -100 }, - { id: 'leisure_17', type: 'event', label: 'Joined Facebook' }, - { id: 'leisure_18', type: 'money', label: 'Speeding Ticket', cash: -75 }, -], 'retirement_party'); - -const spaceList = [ - { id: 'start', type: 'start', label: 'Start', next: 'path_fork' }, - { id: 'path_fork', type: 'choice', label: 'Which Path?', choices: ['career_1', 'edu_1', 'gap_1'] }, - - ...careerChain, - ...eduChain, - ...gapChain, - - ...quarterLifeChain, - { id: 'life_fork', type: 'choice', label: 'Relationship or Investment?', choices: ['family_1', 'invest_1'] }, - - ...familyChain, - ...investChain, - - { id: 'join_2', type: 'event', label: 'Settling Down', next: 'risk_fork' }, - { id: 'risk_fork', type: 'choice', label: 'High Risk or Safe?', choices: ['risk_1', 'safe_1'] }, - - ...highRiskChain, - ...safeChain, - - { id: 'join_3', type: 'event', label: 'Retirement Planning', next: 'leisure_1' }, - ...leisureChain, - - { id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' }, - { id: 'finish', type: 'finish', label: 'Finish' }, -]; +spaceList.push({ + id: 'finish', + type: 'finish', + label: 'Finish', + die: null, + externalTables: [], + statsTouched: [], + // no `next` — this is the temporary end of TEMP_SEGMENT_ORDER, not a real + // win-condition tile from the source data. +}); export const board = { - id: 'life-journey-v1', - startSpaceId: 'start', + id: 'life-journey-full-v1', + startSpaceId: `${TEMP_SEGMENT_ORDER[0]}_0`, spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])), }; - -/** - * Advance from `startId` by up to `steps` spaces, stopping immediately on arrival - * at a 'choice' or 'finish' space even if pips remain (they're discarded). - */ -export function walkForward(startId, steps) { - let current = startId; - for (let i = 0; i < steps; i++) { - const space = board.spaces[current]; - if (!space.next) break; // sitting on a choice/finish space already — nowhere to advance - current = space.next; - const landed = board.spaces[current]; - if (landed.type === 'choice' || landed.type === 'finish') break; - } - return current; -} diff --git a/shared/game.js b/shared/game.js index 651b5af..ea0fa58 100644 --- a/shared/game.js +++ b/shared/game.js @@ -2,17 +2,27 @@ * Life Journey — pure game reducer. * * reduce(state, action) -> newState is the ONLY way state changes, and it never - * generates randomness itself: the die value is generated by the server and - * travels inside the ROLL action payload, so server and client can both apply - * the exact same action through this exact same function and land on identical - * state. Illegal transitions throw rather than no-op — the server is expected - * to gate intents with getLegalIntents()/isPlayersTurn() before ever - * constructing an action, so a throw here means that gate was bypassed. + * generates randomness itself: every roll a tile needs is generated by the + * server and travels inside the action payload (`rolls`), so server and + * client can both apply the exact same action through this exact same + * function and land on identical state. Illegal transitions throw rather + * than no-op — the server is expected to gate intents with + * getLegalIntents()/isPlayersTurn() before ever constructing an action, so a + * throw here means that gate was bypassed. + * + * Movement is one tile per turn (board.js's `next` pointer) — there is no + * movement die; every `die` a tile carries is for resolving that tile's own + * effect via shared/tileEffects.js, not how far a player travels. */ -import { board, walkForward } from './board.js'; +import { board } from './board.js'; +import { resolveTileEffect } from './tileEffects.js'; -const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; +const DEFAULT_CONFIG = { + minPlayers: 2, + maxPlayers: 8, + startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 }, +}; const LOG_LIMIT = 50; export function createInitialState(config = {}) { @@ -90,7 +100,7 @@ function applyJoin(state, { playerId, name, seat, color }) { seat, color, position: board.startSpaceId, - cash: state.config.startingCash, + ...state.config.startingStats, pendingChoice: null, finished: false, }; @@ -107,37 +117,38 @@ function applyStart(state) { return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] }; } -function applyRoll(state, { playerId, value }) { +function applyRoll(state, { playerId, rolls }) { assertActive(state); assertPlayersTurn(state, playerId); const player = state.players[playerId]; if (player.pendingChoice) throw new Error('Resolve pending choice before rolling'); - const landed = walkForward(player.position, value); - return landOn(state, playerId, landed, { type: 'ROLL', value }); + const nextSpaceId = board.spaces[player.position].next; + if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board'); + return landOn(state, playerId, nextSpaceId, rolls, { type: 'ROLL' }); } -function applyChoose(state, { playerId, spaceId }) { +function applyChoose(state, { playerId, spaceId, rolls }) { assertActive(state); assertPlayersTurn(state, playerId); const player = state.players[playerId]; const pending = player.pendingChoice; if (!pending) throw new Error('No pending choice'); if (!pending.options.includes(spaceId)) throw new Error('Illegal choice'); - return landOn(state, playerId, spaceId, { type: 'CHOOSE' }); + return landOn(state, playerId, spaceId, rolls, { type: 'CHOOSE' }); } /** Shared landing logic for both a ROLL's terminal space and a CHOOSE's - * resolved branch: apply the space's effect, then either leave the turn - * open (choice pending), end the game (finish), or advance to the next player. */ -function landOn(state, playerId, spaceId, logMeta) { + * resolved branch: resolve the space's tile effect, then either leave the + * turn open (a real choice is pending — only true once board-graph.json + * populates `choices`), end the game (finish), or advance to the next player. */ +function landOn(state, playerId, spaceId, rolls, logMeta) { const space = board.spaces[spaceId]; - const cashDelta = space.cash ?? 0; + const { statDelta, description, todo } = resolveTileEffect(space, rolls ?? []); const priorPlayer = state.players[playerId]; const nextPlayer = { - ...priorPlayer, + ...applyStatDelta(priorPlayer, statDelta), position: spaceId, - cash: priorPlayer.cash + cashDelta, - pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null, + pendingChoice: space.choices?.length ? { atSpace: spaceId, options: space.choices } : null, finished: space.type === 'finish', }; @@ -146,7 +157,9 @@ function landOn(state, playerId, spaceId, logMeta) { playerId, landedOn: spaceId, label: space.label, - cashDelta, + statDelta, + description, + todo, ...logMeta, }); @@ -162,6 +175,14 @@ function landOn(state, playerId, spaceId, logMeta) { return advanceTurn(newState); } +function applyStatDelta(player, statDelta) { + const next = { ...player }; + for (const [stat, delta] of Object.entries(statDelta ?? {})) { + next[stat] = (next[stat] ?? 0) + delta; + } + return next; +} + function advanceTurn(state) { const turnIndex = (state.turnIndex + 1) % state.turnOrder.length; return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] }; diff --git a/shared/tileEffects.js b/shared/tileEffects.js new file mode 100644 index 0000000..3eb1c9d --- /dev/null +++ b/shared/tileEffects.js @@ -0,0 +1,189 @@ +/** + * Tile-effect resolution: given a board space and the roll value(s) it + * needs, compute the stat delta to apply. Pure — no randomness happens here; + * the caller (server/rooms.js) pre-rolls via rollsNeededFor() and passes the + * results in, the exact same pattern shared/game.js already uses for the + * movement die. This is what keeps reduce() itself 100% deterministic. + * + * Two real gaps in the parsed content, both flagged `todo: true` on every + * resolution they touch so it's visible in the game log, not just docs: + * - 85 inline_table tiles have a die size but zero table content anywhere + * in tile-inventory.js/roll-tables.js. + * - payday/cash_bonus tiles (PAY DAY, 100K, 10K, 401k) have no amount + * defined anywhere either. + * Both resolve against SYNTHESIZED_TABLES — one generic banded placeholder + * table per die size (not per tile), built the same way the real 48 tables + * are shaped (banded ranges -> a cash effect), so swapping in real content + * later is a matter of replacing one table, not touching this file's logic. + */ + +import rollTablesData from './rollTables.js'; + +const tablesById = Object.fromEntries(rollTablesData.tables.map((t) => [t.source_doc_id, t])); + +export const DIE_SIZES = { D2: 2, D6: 6, D8: 8, D10: 10, D20: 20, D100: 100 }; + +const PLACEHOLDER_PAYDAY_CASH = 2000; +const PLACEHOLDER_CASH_BONUS = { '100K': 100000, '10K': 10000 }; + +const SYNTHESIZED_TABLES = Object.fromEntries( + Object.entries(DIE_SIZES).map(([die, max]) => [die, { die, entries: bandedPlaceholderEntries(max) }]) +); + +/** Same worst→jackpot banded shape as the 48 real placeholder tables, scaled + * to the die's range. Cash-only so it composes safely no matter what the + * tile actually wants to touch — a real replacement table can touch anything. */ +function bandedPlaceholderEntries(max) { + const bands = [ + { frac: 0.10, cash: -500, label: 'worst outcome' }, + { frac: 0.30, cash: -200, label: 'bad outcome' }, + { frac: 0.55, cash: -50, label: 'mediocre outcome' }, + { frac: 0.75, cash: 100, label: 'decent outcome' }, + { frac: 0.90, cash: 300, label: 'good outcome' }, + { frac: 0.99, cash: 600, label: 'great outcome' }, + { frac: 1.00, cash: 1200, label: 'jackpot' }, + ]; + const entries = []; + let lo = 1; + for (const band of bands) { + if (lo > max) break; // die too small to hold this many distinct bands + const hi = Math.min(max, Math.max(lo, Math.round(max * band.frac))); + entries.push({ + range: lo === hi ? `${lo}` : `${lo}-${hi}`, + result: `PLACEHOLDER: ${band.label}`, + effect: { cash: band.cash }, + }); + lo = hi + 1; + } + return entries; +} + +function lookupBand(entries, roll) { + for (const entry of entries) { + const [loStr, hiStr] = entry.range.split('-'); + const lo = Number(loStr); + const hi = hiStr !== undefined ? Number(hiStr) : lo; + if (roll >= lo && roll <= hi) return entry; + } + return entries[entries.length - 1]; +} + +function tableDieFor(tableId, fallbackDie) { + return tablesById[tableId]?.die ?? fallbackDie; +} + +/** What die(s) landing on `space` needs rolled, in the order resolveTileEffect + * expects them back. The server calls this BEFORE constructing the action. */ +export function rollsNeededFor(space) { + switch (space.type) { + case 'action_space': + case 'dice_space': + return [tableDieFor(space.externalTables[0], space.die)]; + case 'roll_table_ref': + return space.externalTables.map((tableId) => tableDieFor(tableId, space.die)); + case 'inline_table': + case 'stop': + return [space.die]; + case 'cash_bonus': + case 'event': + return space.die ? [space.die] : []; + case 'payday': + case 'choice': + default: + return []; + } +} + +/** Pure: given `space` and the roll values rollsNeededFor(space) asked for, + * return { statDelta, description, todo }. */ +export function resolveTileEffect(space, rolls = []) { + switch (space.type) { + case 'payday': + return { + statDelta: { cash: PLACEHOLDER_PAYDAY_CASH }, + description: `${space.label}: +$${PLACEHOLDER_PAYDAY_CASH} (PLACEHOLDER amount)`, + todo: true, + }; + + case 'cash_bonus': { + if (space.label in PLACEHOLDER_CASH_BONUS) { + const amount = PLACEHOLDER_CASH_BONUS[space.label]; + return { + statDelta: { cash: amount }, + description: `${space.label}: +$${amount} (PLACEHOLDER amount)`, + todo: true, + }; + } + return resolveSynthesized(space, rolls[0]); + } + + case 'action_space': + case 'dice_space': + case 'roll_table_ref': + return resolveExternalTables(space, rolls); + + case 'inline_table': + return resolveSynthesized(space, rolls[0]); + + case 'event': + return space.die ? resolveSynthesized(space, rolls[0]) : resolveFixedEvent(space); + + case 'choice': + return { statDelta: {}, description: `${space.label}: choice options not yet defined (TODO)`, todo: true }; + + case 'stop': { + const roll = rolls[0] ?? 0; + return { statDelta: { age: roll }, description: `${space.label}: age +${roll}`, todo: false }; + } + + default: + return { statDelta: {}, description: `${space.label}: unhandled tile type "${space.type}" (TODO)`, todo: true }; + } +} + +function resolveExternalTables(space, rolls) { + const statDelta = {}; + const parts = []; + space.externalTables.forEach((tableId, i) => { + const table = tablesById[tableId]; + const roll = rolls[i]; + if (!table) { + parts.push(`${space.label}: missing table ${tableId} (TODO)`); + return; + } + const entry = lookupBand(table.entries, roll); + mergeStatDelta(statDelta, entry.effect); + parts.push(`${space.label} → ${table.display_name} (${roll}): ${entry.result}`); + }); + return { statDelta, description: parts.join(' | '), todo: false }; +} + +function resolveSynthesized(space, roll) { + const table = SYNTHESIZED_TABLES[space.die]; + if (!table) { + return { statDelta: {}, description: `${space.label}: no die to roll against (TODO)`, todo: true }; + } + const entry = lookupBand(table.entries, roll); + return { + statDelta: { ...entry.effect }, + description: `${space.label} (${roll}): ${entry.result} (PLACEHOLDER table)`, + todo: true, + }; +} + +function resolveFixedEvent(space) { + const statDelta = {}; + for (const stat of space.statsTouched) statDelta[stat] = stat === 'cash' ? 100 : 1; + const hasEffect = Object.keys(statDelta).length > 0; + return { + statDelta, + description: `${space.label}${hasEffect ? ' (PLACEHOLDER amount)' : ''}`, + todo: hasEffect, + }; +} + +function mergeStatDelta(target, effect) { + for (const [stat, delta] of Object.entries(effect ?? {})) { + target[stat] = (target[stat] ?? 0) + delta; + } +}