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