/** * Life Journey — pure game reducer. * * reduce(state, action) -> newState is the ONLY way state changes, and it never * 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 } from './board.js'; import { resolveTileEffect } from './tileEffects.js'; 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 = {}) { return { boardId: board.id, config: { ...DEFAULT_CONFIG, ...config }, status: 'lobby', turnOrder: [], turnIndex: 0, currentTurn: null, winnerId: null, players: {}, log: [], }; } export function reduce(state, action) { switch (action.type) { case 'JOIN': return applyJoin(state, action); case 'START_GAME': return applyStart(state, action); case 'ROLL': return applyRoll(state, action); case 'CHOOSE': return applyChoose(state, action); default: throw new Error(`Unknown action type: ${action.type}`); } } /** What intents (if any) `playerId` may legally send right now — the single * source of truth used by both server-side validation and client-side UI. */ export function getLegalIntents(state, playerId) { const player = state.players[playerId]; if (!player) return []; if (state.status === 'lobby') { const count = Object.keys(state.players).length; return count >= state.config.minPlayers ? ['REQUEST_START'] : []; } if (state.status !== 'active' || state.currentTurn !== playerId) return []; return player.pendingChoice ? ['REQUEST_CHOOSE'] : ['REQUEST_ROLL']; } export function isPlayersTurn(state, playerId) { return state.status === 'active' && state.currentTurn === playerId; } // --- internals ------------------------------------------------------------- function assertLobby(state) { if (state.status !== 'lobby') throw new Error('Game is not in lobby'); } function assertActive(state) { if (state.status !== 'active') throw new Error('Game is not active'); } function assertPlayersTurn(state, playerId) { if (state.currentTurn !== playerId) throw new Error('Not your turn'); } function applyJoin(state, { playerId, name, seat, color }) { assertLobby(state); if (state.players[playerId]) throw new Error('Player already joined'); if (Object.keys(state.players).length >= state.config.maxPlayers) { throw new Error('Game is full'); } if (Object.values(state.players).some((p) => p.seat === seat)) { throw new Error('Seat already taken'); } const player = { id: playerId, name, seat, color, position: board.startSpaceId, ...state.config.startingStats, pendingChoice: null, finished: false, }; return { ...state, players: { ...state.players, [playerId]: player } }; } function applyStart(state) { assertLobby(state); const players = Object.values(state.players).sort((a, b) => a.seat - b.seat); if (players.length < state.config.minPlayers) { throw new Error('Not enough players to start'); } const turnOrder = players.map((p) => p.id); return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] }; } 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 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, 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, rolls, { type: 'CHOOSE' }); } /** Shared landing logic for both a ROLL's terminal space and a CHOOSE's * 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 { statDelta, description, todo } = resolveTileEffect(space, rolls ?? []); const priorPlayer = state.players[playerId]; const nextPlayer = { ...applyStatDelta(priorPlayer, statDelta), position: spaceId, pendingChoice: space.choices?.length ? { atSpace: spaceId, options: space.choices } : null, finished: space.type === 'finish', }; let newState = { ...state, players: { ...state.players, [playerId]: nextPlayer } }; newState.log = appendLog(state.log, { playerId, landedOn: spaceId, label: space.label, statDelta, description, todo, ...logMeta, }); if (space.type === 'finish') { newState.status = 'finished'; newState.winnerId = playerId; newState.currentTurn = null; return newState; } if (nextPlayer.pendingChoice) return newState; // turn stays with this player 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] }; } function appendLog(log, entry) { const next = [...log, entry]; return next.length > LOG_LIMIT ? next.slice(next.length - LOG_LIMIT) : next; }