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:
+76
-191
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user