Files
lifegame/shared/tileEffects.js
T
Kevin 4d78937dca 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.
2026-07-28 10:06:59 -07:00

190 lines
6.6 KiB
JavaScript

/**
* 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;
}
}