4d78937dca
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.
195 lines
6.4 KiB
JavaScript
195 lines
6.4 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|