Rebuild the board and reducer around the real content

shared/board.js: rebuilt from shared/tileInventory.js instead of the
sketch-inspired placeholder — one chain() per segment (same helper as
before), TEMP_SEGMENT_ORDER concatenating all 12 segments in the design
doc's own order since board-graph.json (the real segment connections/STOP
forks) doesn't exist yet — loudly commented as a placeholder to replace, not
a guess at real topology. A synthetic `finish` space is appended since no
tile in the source data has that type (win condition is itself an open
question). Movement is now one tile per turn (board.spaces[pos].next) —
no tile in the data implies a movement die, every `die` belongs to that
tile's own effect resolution, so walkForward() is gone.

shared/tileEffects.js (new): pure resolveTileEffect(space, rolls) covering
all 9 real tile types (payday, action_space, dice_space, roll_table_ref,
inline_table, cash_bonus, event, choice, stop) plus rollsNeededFor(space) so
the server knows what to pre-roll before constructing an action. Real
roll_table_ref/action_space/dice_space tiles resolve against the actual 48
placeholder tables; the two real content gaps (inline_table's 85 tiles,
payday/cash_bonus amounts) resolve against a synthesized generic banded
table per die size, every result clearly flagged `todo: true` so it's
visible in the game log, not just in docs.

shared/game.js: player state is now flat {cash, love, education, wealth,
age} fields (mirrors a roll-table effect object's shape directly). Landing
logic calls resolveTileEffect() instead of applying a single cash delta, and
only pauses for a decision when a space's `choices` array is actually
populated (true of nothing yet) — keeps the existing pending-choice
machinery intact for once board-graph.json exists, instead of needing
choice/stop tiles to block on options nobody has defined.

server/rooms.js: REQUEST_ROLL now looks up the player's next tile,
determines what it needs via rollsNeededFor(), and pre-rolls each die via
crypto.randomInt before constructing the ROLL action — same
randomness-happens-once-at-the-server-boundary pattern as before, just
covering N table rolls instead of one movement die. server/index.js:
default config now carries startingStats instead of startingCash, and
maxPlayers is 8.
This commit is contained in:
2026-07-28 10:06:59 -07:00
parent 220f70d342
commit 4d78937dca
5 changed files with 339 additions and 219 deletions
+8 -2
View File
@@ -28,10 +28,16 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
db.initDb(path.join(DATA_DIR, 'lifegame.db')); db.initDb(path.join(DATA_DIR, 'lifegame.db'));
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; const DEFAULT_CONFIG = {
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, // Deliberately avoids the board's own semantic colors (green=start,
// marigold=finish, red=choice) so a player's token never blends into a space. // 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 AUTH_TIMEOUT_MS = 10_000;
const app = express(); const app = express();
+23 -4
View File
@@ -6,6 +6,8 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { reduce, getLegalIntents } from '../shared/game.js'; import { reduce, getLegalIntents } from '../shared/game.js';
import { board } from '../shared/board.js';
import { rollsNeededFor, DIE_SIZES } from '../shared/tileEffects.js';
import * as db from './db.js'; import * as db from './db.js';
export class RoomError extends Error {} export class RoomError extends Error {}
@@ -59,16 +61,33 @@ function buildAction(state, playerId, intent) {
switch (intent.type) { switch (intent.type) {
case 'REQUEST_START': case 'REQUEST_START':
return { type: 'START_GAME' }; return { type: 'START_GAME' };
case 'REQUEST_ROLL': case 'REQUEST_ROLL': {
return { type: 'ROLL', playerId, value: 1 + crypto.randomInt(state.config.diceSides) }; const player = state.players[playerId];
case 'REQUEST_CHOOSE': const nextSpaceId = board.spaces[player.position].next;
if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board');
return { type: 'ROLL', playerId, rolls: rollForSpace(board.spaces[nextSpaceId]) };
}
case 'REQUEST_CHOOSE': {
if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required'); if (typeof intent.spaceId !== 'string') throw new Error('spaceId is required');
return { type: 'CHOOSE', playerId, spaceId: intent.spaceId }; return { type: 'CHOOSE', playerId, spaceId: intent.spaceId, rolls: rollForSpace(board.spaces[intent.spaceId]) };
}
default: default:
throw new Error(`Unknown intent: ${intent.type}`); throw new Error(`Unknown intent: ${intent.type}`);
} }
} }
/** Pre-roll whatever dice `space` needs to resolve its own tile effect — the
* only place actual randomness happens. rollsNeededFor() never touches
* randomness itself, it just says which die sizes are needed and in what
* order; reduce() applies the results deterministically. */
function rollForSpace(space) {
return rollsNeededFor(space).map((die) => {
const max = DIE_SIZES[die];
if (!max) throw new Error(`Unknown die size: ${die}`);
return 1 + crypto.randomInt(max);
});
}
function broadcast(room, payload) { function broadcast(room, payload) {
for (const ws of room.sockets) send(ws, payload); for (const ws of room.sockets) send(ws, payload);
} }
+76 -191
View File
@@ -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 * TEMPORARY: the 12 segments have no defined connections yet — that's what
* many space names more than once across its zones (Start A Business, * shared/BRANCHING-TEMPLATE.md is an open request for (the STOP forks, and
* Family Reunion, Market Crash, ...) — that repetition is treated as * which segment's exit feeds which segment's entry). Until board-graph.json
* intentional recurring flavor, not deduplicated. The sketch's arrows get * exists, TEMP_SEGMENT_ORDER below just concatenates all 12 segments in the
* genuinely ambiguous in a few spots (a hand-drawn/AI-generated mockup, not * design doc's own listed order into one line, so movement/tests/rendering
* an engineered spec), so rather than force a literal reverse-engineering of * have something real to walk. This is NOT the real board topology — delete
* every arrow, every distinct label from the sketch is kept and organized * this placeholder and replace `next` wiring once board-graph.json exists.
* 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? } * Similarly, no tile in the source data has type 'finish' (win condition is
* type: 'start' | 'event' | 'money' | 'choice' | 'finish' * itself an open question in GAME-REVIEW.md) — a synthetic `finish` space is
* next: id of the following space (absent on 'choice' and 'finish' spaces) * appended after the last tile so the reducer has something to end on.
* choices: [id, ...] of the branches offered by a 'choice' space (2 or more) *
* cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces) * Movement is one tile per turn (no tile in the data implies a movement die —
* every `die` value belongs to that tile's own effect resolution), so unlike
* the old board there is no walkForward()/multi-step-with-early-stop concept
* here: a turn is just "go to `next`, then resolve whatever that tile needs."
*
* Space shape: { id, type, label, die, externalTables, statsTouched, next }
* type: one of the 9 tile-inventory types — payday, action_space,
* dice_space, roll_table_ref, inline_table, cash_bonus, event, choice, stop
* die: 'D100'|'D20'|'D10'|'D8'|'D6'|'D2'|null — what to roll to resolve this tile
* externalTables: source_doc_id[] into shared/rollTables.js (0-3 entries)
* statsTouched: string[] hint from the source data, informational only
* next: id of the following space (absent only on the synthetic 'finish')
* choices: NOT set on any tile yet (no real branch destinations are known) —
* shared/game.js only pauses a 'choice'/'stop' space for a decision when
* `choices` is non-empty, so today every one of these is a harmless
* pass-through, not a dead end.
*/ */
/** Wires each entry's `next` to the following entry's id; the last one gets `finalNext`. */ import tileInventory from './tileInventory.js';
function chain(entries, finalNext) {
return entries.map((entry, i) => ({ const TEMP_SEGMENT_ORDER = [
...entry, 'starting_strip',
next: i < entries.length - 1 ? entries[i + 1].id : finalNext, '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([ const spaceList = TEMP_SEGMENT_ORDER.flatMap((segmentId, i) => {
{ id: 'career_1', type: 'event', label: 'High School Dropout' }, const isLastSegment = i === TEMP_SEGMENT_ORDER.length - 1;
{ id: 'career_2', type: 'event', label: 'New Job' }, const nextAfterSegment = isLastSegment ? 'finish' : `${TEMP_SEGMENT_ORDER[i + 1]}_0`;
{ id: 'career_3', type: 'money', label: 'Performance Review', cash: 200 }, return buildSegmentSpaces(segmentId, nextAfterSegment);
{ 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([ spaceList.push({
{ id: 'edu_1', type: 'event', label: 'College Enrolled' }, id: 'finish',
{ id: 'edu_2', type: 'money', label: 'Scholarship', cash: 400 }, type: 'finish',
{ id: 'edu_3', type: 'event', label: '4-Year Degree' }, label: 'Finish',
{ id: 'edu_4', type: 'event', label: 'Community College' }, die: null,
{ id: 'edu_5', type: 'event', label: 'Apprenticeship' }, externalTables: [],
{ id: 'edu_6', type: 'money', label: 'Campus Drama', cash: -100 }, statsTouched: [],
{ id: 'edu_7', type: 'money', label: 'Study Abroad', cash: -150 }, // no `next` — this is the temporary end of TEMP_SEGMENT_ORDER, not a real
{ id: 'edu_8', type: 'event', label: 'Trade Circuit' }, // win-condition tile from the source data.
{ 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' },
];
export const board = { export const board = {
id: 'life-journey-v1', id: 'life-journey-full-v1',
startSpaceId: 'start', startSpaceId: `${TEMP_SEGMENT_ORDER[0]}_0`,
spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])), spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])),
}; };
/**
* Advance from `startId` by up to `steps` spaces, stopping immediately on arrival
* at a 'choice' or 'finish' space even if pips remain (they're discarded).
*/
export function walkForward(startId, steps) {
let current = startId;
for (let i = 0; i < steps; i++) {
const space = board.spaces[current];
if (!space.next) break; // sitting on a choice/finish space already — nowhere to advance
current = space.next;
const landed = board.spaces[current];
if (landed.type === 'choice' || landed.type === 'finish') break;
}
return current;
}
+43 -22
View File
@@ -2,17 +2,27 @@
* Life Journey — pure game reducer. * Life Journey — pure game reducer.
* *
* reduce(state, action) -> newState is the ONLY way state changes, and it never * reduce(state, action) -> newState is the ONLY way state changes, and it never
* generates randomness itself: the die value is generated by the server and * generates randomness itself: every roll a tile needs is generated by the
* travels inside the ROLL action payload, so server and client can both apply * server and travels inside the action payload (`rolls`), so server and
* the exact same action through this exact same function and land on identical * client can both apply the exact same action through this exact same
* state. Illegal transitions throw rather than no-op — the server is expected * function and land on identical state. Illegal transitions throw rather
* to gate intents with getLegalIntents()/isPlayersTurn() before ever * than no-op — the server is expected to gate intents with
* constructing an action, so a throw here means that gate was bypassed. * getLegalIntents()/isPlayersTurn() before ever constructing an action, so a
* throw here means that gate was bypassed.
*
* Movement is one tile per turn (board.js's `next` pointer) — there is no
* movement die; every `die` a tile carries is for resolving that tile's own
* effect via shared/tileEffects.js, not how far a player travels.
*/ */
import { board, walkForward } from './board.js'; import { board } from './board.js';
import { resolveTileEffect } from './tileEffects.js';
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; const DEFAULT_CONFIG = {
minPlayers: 2,
maxPlayers: 8,
startingStats: { cash: 0, love: 0, education: 0, wealth: 0, age: 18 },
};
const LOG_LIMIT = 50; const LOG_LIMIT = 50;
export function createInitialState(config = {}) { export function createInitialState(config = {}) {
@@ -90,7 +100,7 @@ function applyJoin(state, { playerId, name, seat, color }) {
seat, seat,
color, color,
position: board.startSpaceId, position: board.startSpaceId,
cash: state.config.startingCash, ...state.config.startingStats,
pendingChoice: null, pendingChoice: null,
finished: false, finished: false,
}; };
@@ -107,37 +117,38 @@ function applyStart(state) {
return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] }; return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] };
} }
function applyRoll(state, { playerId, value }) { function applyRoll(state, { playerId, rolls }) {
assertActive(state); assertActive(state);
assertPlayersTurn(state, playerId); assertPlayersTurn(state, playerId);
const player = state.players[playerId]; const player = state.players[playerId];
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling'); if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
const landed = walkForward(player.position, value); const nextSpaceId = board.spaces[player.position].next;
return landOn(state, playerId, landed, { type: 'ROLL', value }); if (!nextSpaceId) throw new Error('No next space defined — already at the end of the board');
return landOn(state, playerId, nextSpaceId, rolls, { type: 'ROLL' });
} }
function applyChoose(state, { playerId, spaceId }) { function applyChoose(state, { playerId, spaceId, rolls }) {
assertActive(state); assertActive(state);
assertPlayersTurn(state, playerId); assertPlayersTurn(state, playerId);
const player = state.players[playerId]; const player = state.players[playerId];
const pending = player.pendingChoice; const pending = player.pendingChoice;
if (!pending) throw new Error('No pending choice'); if (!pending) throw new Error('No pending choice');
if (!pending.options.includes(spaceId)) throw new Error('Illegal choice'); if (!pending.options.includes(spaceId)) throw new Error('Illegal choice');
return landOn(state, playerId, spaceId, { type: 'CHOOSE' }); return landOn(state, playerId, spaceId, rolls, { type: 'CHOOSE' });
} }
/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's /** Shared landing logic for both a ROLL's terminal space and a CHOOSE's
* resolved branch: apply the space's effect, then either leave the turn * resolved branch: resolve the space's tile effect, then either leave the
* open (choice pending), end the game (finish), or advance to the next player. */ * turn open (a real choice is pending — only true once board-graph.json
function landOn(state, playerId, spaceId, logMeta) { * populates `choices`), end the game (finish), or advance to the next player. */
function landOn(state, playerId, spaceId, rolls, logMeta) {
const space = board.spaces[spaceId]; const space = board.spaces[spaceId];
const cashDelta = space.cash ?? 0; const { statDelta, description, todo } = resolveTileEffect(space, rolls ?? []);
const priorPlayer = state.players[playerId]; const priorPlayer = state.players[playerId];
const nextPlayer = { const nextPlayer = {
...priorPlayer, ...applyStatDelta(priorPlayer, statDelta),
position: spaceId, position: spaceId,
cash: priorPlayer.cash + cashDelta, pendingChoice: space.choices?.length ? { atSpace: spaceId, options: space.choices } : null,
pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null,
finished: space.type === 'finish', finished: space.type === 'finish',
}; };
@@ -146,7 +157,9 @@ function landOn(state, playerId, spaceId, logMeta) {
playerId, playerId,
landedOn: spaceId, landedOn: spaceId,
label: space.label, label: space.label,
cashDelta, statDelta,
description,
todo,
...logMeta, ...logMeta,
}); });
@@ -162,6 +175,14 @@ function landOn(state, playerId, spaceId, logMeta) {
return advanceTurn(newState); return advanceTurn(newState);
} }
function applyStatDelta(player, statDelta) {
const next = { ...player };
for (const [stat, delta] of Object.entries(statDelta ?? {})) {
next[stat] = (next[stat] ?? 0) + delta;
}
return next;
}
function advanceTurn(state) { function advanceTurn(state) {
const turnIndex = (state.turnIndex + 1) % state.turnOrder.length; const turnIndex = (state.turnIndex + 1) % state.turnOrder.length;
return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] }; return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] };
+189
View File
@@ -0,0 +1,189 @@
/**
* Tile-effect resolution: given a board space and the roll value(s) it
* needs, compute the stat delta to apply. Pure — no randomness happens here;
* the caller (server/rooms.js) pre-rolls via rollsNeededFor() and passes the
* results in, the exact same pattern shared/game.js already uses for the
* movement die. This is what keeps reduce() itself 100% deterministic.
*
* Two real gaps in the parsed content, both flagged `todo: true` on every
* resolution they touch so it's visible in the game log, not just docs:
* - 85 inline_table tiles have a die size but zero table content anywhere
* in tile-inventory.js/roll-tables.js.
* - payday/cash_bonus tiles (PAY DAY, 100K, 10K, 401k) have no amount
* defined anywhere either.
* Both resolve against SYNTHESIZED_TABLES — one generic banded placeholder
* table per die size (not per tile), built the same way the real 48 tables
* are shaped (banded ranges -> a cash effect), so swapping in real content
* later is a matter of replacing one table, not touching this file's logic.
*/
import rollTablesData from './rollTables.js';
const tablesById = Object.fromEntries(rollTablesData.tables.map((t) => [t.source_doc_id, t]));
export const DIE_SIZES = { D2: 2, D6: 6, D8: 8, D10: 10, D20: 20, D100: 100 };
const PLACEHOLDER_PAYDAY_CASH = 2000;
const PLACEHOLDER_CASH_BONUS = { '100K': 100000, '10K': 10000 };
const SYNTHESIZED_TABLES = Object.fromEntries(
Object.entries(DIE_SIZES).map(([die, max]) => [die, { die, entries: bandedPlaceholderEntries(max) }])
);
/** Same worst→jackpot banded shape as the 48 real placeholder tables, scaled
* to the die's range. Cash-only so it composes safely no matter what the
* tile actually wants to touch — a real replacement table can touch anything. */
function bandedPlaceholderEntries(max) {
const bands = [
{ frac: 0.10, cash: -500, label: 'worst outcome' },
{ frac: 0.30, cash: -200, label: 'bad outcome' },
{ frac: 0.55, cash: -50, label: 'mediocre outcome' },
{ frac: 0.75, cash: 100, label: 'decent outcome' },
{ frac: 0.90, cash: 300, label: 'good outcome' },
{ frac: 0.99, cash: 600, label: 'great outcome' },
{ frac: 1.00, cash: 1200, label: 'jackpot' },
];
const entries = [];
let lo = 1;
for (const band of bands) {
if (lo > max) break; // die too small to hold this many distinct bands
const hi = Math.min(max, Math.max(lo, Math.round(max * band.frac)));
entries.push({
range: lo === hi ? `${lo}` : `${lo}-${hi}`,
result: `PLACEHOLDER: ${band.label}`,
effect: { cash: band.cash },
});
lo = hi + 1;
}
return entries;
}
function lookupBand(entries, roll) {
for (const entry of entries) {
const [loStr, hiStr] = entry.range.split('-');
const lo = Number(loStr);
const hi = hiStr !== undefined ? Number(hiStr) : lo;
if (roll >= lo && roll <= hi) return entry;
}
return entries[entries.length - 1];
}
function tableDieFor(tableId, fallbackDie) {
return tablesById[tableId]?.die ?? fallbackDie;
}
/** What die(s) landing on `space` needs rolled, in the order resolveTileEffect
* expects them back. The server calls this BEFORE constructing the action. */
export function rollsNeededFor(space) {
switch (space.type) {
case 'action_space':
case 'dice_space':
return [tableDieFor(space.externalTables[0], space.die)];
case 'roll_table_ref':
return space.externalTables.map((tableId) => tableDieFor(tableId, space.die));
case 'inline_table':
case 'stop':
return [space.die];
case 'cash_bonus':
case 'event':
return space.die ? [space.die] : [];
case 'payday':
case 'choice':
default:
return [];
}
}
/** Pure: given `space` and the roll values rollsNeededFor(space) asked for,
* return { statDelta, description, todo }. */
export function resolveTileEffect(space, rolls = []) {
switch (space.type) {
case 'payday':
return {
statDelta: { cash: PLACEHOLDER_PAYDAY_CASH },
description: `${space.label}: +$${PLACEHOLDER_PAYDAY_CASH} (PLACEHOLDER amount)`,
todo: true,
};
case 'cash_bonus': {
if (space.label in PLACEHOLDER_CASH_BONUS) {
const amount = PLACEHOLDER_CASH_BONUS[space.label];
return {
statDelta: { cash: amount },
description: `${space.label}: +$${amount} (PLACEHOLDER amount)`,
todo: true,
};
}
return resolveSynthesized(space, rolls[0]);
}
case 'action_space':
case 'dice_space':
case 'roll_table_ref':
return resolveExternalTables(space, rolls);
case 'inline_table':
return resolveSynthesized(space, rolls[0]);
case 'event':
return space.die ? resolveSynthesized(space, rolls[0]) : resolveFixedEvent(space);
case 'choice':
return { statDelta: {}, description: `${space.label}: choice options not yet defined (TODO)`, todo: true };
case 'stop': {
const roll = rolls[0] ?? 0;
return { statDelta: { age: roll }, description: `${space.label}: age +${roll}`, todo: false };
}
default:
return { statDelta: {}, description: `${space.label}: unhandled tile type "${space.type}" (TODO)`, todo: true };
}
}
function resolveExternalTables(space, rolls) {
const statDelta = {};
const parts = [];
space.externalTables.forEach((tableId, i) => {
const table = tablesById[tableId];
const roll = rolls[i];
if (!table) {
parts.push(`${space.label}: missing table ${tableId} (TODO)`);
return;
}
const entry = lookupBand(table.entries, roll);
mergeStatDelta(statDelta, entry.effect);
parts.push(`${space.label}${table.display_name} (${roll}): ${entry.result}`);
});
return { statDelta, description: parts.join(' | '), todo: false };
}
function resolveSynthesized(space, roll) {
const table = SYNTHESIZED_TABLES[space.die];
if (!table) {
return { statDelta: {}, description: `${space.label}: no die to roll against (TODO)`, todo: true };
}
const entry = lookupBand(table.entries, roll);
return {
statDelta: { ...entry.effect },
description: `${space.label} (${roll}): ${entry.result} (PLACEHOLDER table)`,
todo: true,
};
}
function resolveFixedEvent(space) {
const statDelta = {};
for (const stat of space.statsTouched) statDelta[stat] = stat === 'cash' ? 100 : 1;
const hasEffect = Object.keys(statDelta).length > 0;
return {
statDelta,
description: `${space.label}${hasEffect ? ' (PLACEHOLDER amount)' : ''}`,
todo: hasEffect,
};
}
function mergeStatDelta(target, effect) {
for (const [stat, delta] of Object.entries(effect ?? {})) {
target[stat] = (target[stat] ?? 0) + delta;
}
}