174 lines
5.7 KiB
JavaScript
174 lines
5.7 KiB
JavaScript
/**
|
|
* Life Journey — pure game reducer.
|
|
*
|
|
* reduce(state, action) -> newState is the ONLY way state changes, and it never
|
|
* generates randomness itself: the die value is generated by the server and
|
|
* travels inside the ROLL action payload, 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.
|
|
*/
|
|
|
|
import { board, walkForward } from './board.js';
|
|
|
|
const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 };
|
|
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,
|
|
cash: state.config.startingCash,
|
|
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, value }) {
|
|
assertActive(state);
|
|
assertPlayersTurn(state, playerId);
|
|
const player = state.players[playerId];
|
|
if (player.pendingChoice) throw new Error('Resolve pending choice before rolling');
|
|
const landed = walkForward(player.position, value);
|
|
return landOn(state, playerId, landed, { type: 'ROLL', value });
|
|
}
|
|
|
|
function applyChoose(state, { playerId, spaceId }) {
|
|
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, { type: 'CHOOSE' });
|
|
}
|
|
|
|
/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's
|
|
* resolved branch: apply the space's effect, then either leave the turn
|
|
* open (choice pending), end the game (finish), or advance to the next player. */
|
|
function landOn(state, playerId, spaceId, logMeta) {
|
|
const space = board.spaces[spaceId];
|
|
const cashDelta = space.cash ?? 0;
|
|
const priorPlayer = state.players[playerId];
|
|
const nextPlayer = {
|
|
...priorPlayer,
|
|
position: spaceId,
|
|
cash: priorPlayer.cash + cashDelta,
|
|
pendingChoice: space.type === 'choice' ? { 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,
|
|
cashDelta,
|
|
...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 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;
|
|
}
|