UNPKG

boardgame.io

Version:
1,199 lines (1,193 loc) 46.8 kB
import { e as error, G as GameMethod, a as GetAPIs, T as TurnOrder, b as supportDeprecatedMoveLimit, S as Stage, c as SetActivePlayers, i as info, F as FnWrap, I as InitTurnOrderState, U as UpdateTurnOrderState, d as UpdateActivePlayersOnceEmpty, g as gameEvent, P as PATCH, f as PLUGIN, h as ProcessAction, R as REDO, j as UNDO, k as SYNC, l as UPDATE, m as RESET, M as MAKE_MOVE, E as Enhance, n as INVALID_MOVE, N as NoClient, o as GAME_EVENT, p as STRIP_TRANSIENTS, q as FlushAndValidate, r as stripTransients } from './turn-order-8cc4909b.js'; import { applyPatch } from 'rfc6902'; /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ /** * Flow * * Creates a reducer that updates ctx (analogous to how moves update G). */ function Flow({ moves, phases, endIf, onEnd, turn, events, plugins, }) { // Attach defaults. if (moves === undefined) { moves = {}; } if (events === undefined) { events = {}; } if (plugins === undefined) { plugins = []; } if (phases === undefined) { phases = {}; } if (!endIf) endIf = () => undefined; if (!onEnd) onEnd = ({ G }) => G; if (!turn) turn = {}; const phaseMap = { ...phases }; if ('' in phaseMap) { error('cannot specify phase with empty name'); } phaseMap[''] = {}; const moveMap = {}; const moveNames = new Set(); let startingPhase = null; Object.keys(moves).forEach((name) => moveNames.add(name)); const HookWrapper = (hook, hookType) => { const withPlugins = FnWrap(hook, hookType, plugins); return (state) => { const pluginAPIs = GetAPIs(state); return withPlugins({ ...pluginAPIs, G: state.G, ctx: state.ctx, playerID: state.playerID, }); }; }; const TriggerWrapper = (trigger) => { return (state) => { const pluginAPIs = GetAPIs(state); return trigger({ ...pluginAPIs, G: state.G, ctx: state.ctx, }); }; }; const wrapped = { onEnd: HookWrapper(onEnd, GameMethod.GAME_ON_END), endIf: TriggerWrapper(endIf), }; for (const phase in phaseMap) { const phaseConfig = phaseMap[phase]; if (phaseConfig.start === true) { startingPhase = phase; } if (phaseConfig.moves !== undefined) { for (const move of Object.keys(phaseConfig.moves)) { moveMap[phase + '.' + move] = phaseConfig.moves[move]; moveNames.add(move); } } if (phaseConfig.endIf === undefined) { phaseConfig.endIf = () => undefined; } if (phaseConfig.onBegin === undefined) { phaseConfig.onBegin = ({ G }) => G; } if (phaseConfig.onEnd === undefined) { phaseConfig.onEnd = ({ G }) => G; } if (phaseConfig.turn === undefined) { phaseConfig.turn = turn; } if (phaseConfig.turn.order === undefined) { phaseConfig.turn.order = TurnOrder.DEFAULT; } if (phaseConfig.turn.onBegin === undefined) { phaseConfig.turn.onBegin = ({ G }) => G; } if (phaseConfig.turn.onEnd === undefined) { phaseConfig.turn.onEnd = ({ G }) => G; } if (phaseConfig.turn.endIf === undefined) { phaseConfig.turn.endIf = () => false; } if (phaseConfig.turn.onMove === undefined) { phaseConfig.turn.onMove = ({ G }) => G; } if (phaseConfig.turn.stages === undefined) { phaseConfig.turn.stages = {}; } // turns previously treated moveLimit as both minMoves and maxMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(phaseConfig.turn, true); for (const stage in phaseConfig.turn.stages) { const stageConfig = phaseConfig.turn.stages[stage]; const moves = stageConfig.moves || {}; for (const move of Object.keys(moves)) { const key = phase + '.' + stage + '.' + move; moveMap[key] = moves[move]; moveNames.add(move); } } phaseConfig.wrapped = { onBegin: HookWrapper(phaseConfig.onBegin, GameMethod.PHASE_ON_BEGIN), onEnd: HookWrapper(phaseConfig.onEnd, GameMethod.PHASE_ON_END), endIf: TriggerWrapper(phaseConfig.endIf), }; phaseConfig.turn.wrapped = { onMove: HookWrapper(phaseConfig.turn.onMove, GameMethod.TURN_ON_MOVE), onBegin: HookWrapper(phaseConfig.turn.onBegin, GameMethod.TURN_ON_BEGIN), onEnd: HookWrapper(phaseConfig.turn.onEnd, GameMethod.TURN_ON_END), endIf: TriggerWrapper(phaseConfig.turn.endIf), }; if (typeof phaseConfig.next !== 'function') { const { next } = phaseConfig; phaseConfig.next = () => next || null; } phaseConfig.wrapped.next = TriggerWrapper(phaseConfig.next); } function GetPhase(ctx) { return ctx.phase ? phaseMap[ctx.phase] : phaseMap['']; } function OnMove(state) { return state; } function Process(state, events) { const phasesEnded = new Set(); const turnsEnded = new Set(); for (let i = 0; i < events.length; i++) { const { fn, arg, ...rest } = events[i]; // Detect a loop of EndPhase calls. // This could potentially even be an infinite loop // if the endIf condition of each phase blindly // returns true. The moment we detect a single // loop, we just bail out of all phases. if (fn === EndPhase) { turnsEnded.clear(); const phase = state.ctx.phase; if (phasesEnded.has(phase)) { const ctx = { ...state.ctx, phase: null }; return { ...state, ctx }; } phasesEnded.add(phase); } // Process event. const next = []; state = fn(state, { ...rest, arg, next, }); if (fn === EndGame) { break; } // Check if we should end the game. const shouldEndGame = ShouldEndGame(state); if (shouldEndGame) { events.push({ fn: EndGame, arg: shouldEndGame, turn: state.ctx.turn, phase: state.ctx.phase, automatic: true, }); continue; } // Check if we should end the phase. const shouldEndPhase = ShouldEndPhase(state); if (shouldEndPhase) { events.push({ fn: EndPhase, arg: shouldEndPhase, turn: state.ctx.turn, phase: state.ctx.phase, automatic: true, }); continue; } // Check if we should end the turn. if ([OnMove, UpdateStage, UpdateActivePlayers].includes(fn)) { const shouldEndTurn = ShouldEndTurn(state); if (shouldEndTurn) { events.push({ fn: EndTurn, arg: shouldEndTurn, turn: state.ctx.turn, phase: state.ctx.phase, automatic: true, }); continue; } } events.push(...next); } return state; } /////////// // Start // /////////// function StartGame(state, { next }) { next.push({ fn: StartPhase }); return state; } function StartPhase(state, { next }) { let { G, ctx } = state; const phaseConfig = GetPhase(ctx); // Run any phase setup code provided by the user. G = phaseConfig.wrapped.onBegin(state); next.push({ fn: StartTurn }); return { ...state, G, ctx }; } function StartTurn(state, { currentPlayer }) { let { ctx } = state; const phaseConfig = GetPhase(ctx); // Initialize the turn order state. if (currentPlayer) { ctx = { ...ctx, currentPlayer }; if (phaseConfig.turn.activePlayers) { ctx = SetActivePlayers(ctx, phaseConfig.turn.activePlayers); } } else { // This is only called at the beginning of the phase // when there is no currentPlayer yet. ctx = InitTurnOrderState(state, phaseConfig.turn); } const turn = ctx.turn + 1; ctx = { ...ctx, turn, numMoves: 0, _prevActivePlayers: [] }; const G = phaseConfig.turn.wrapped.onBegin({ ...state, ctx }); return { ...state, G, ctx, _undo: [], _redo: [] }; } //////////// // Update // //////////// function UpdatePhase(state, { arg, next, phase }) { const phaseConfig = GetPhase({ phase }); let { ctx } = state; if (arg && arg.next) { if (arg.next in phaseMap) { ctx = { ...ctx, phase: arg.next }; } else { error('invalid phase: ' + arg.next); return state; } } else { ctx = { ...ctx, phase: phaseConfig.wrapped.next(state) || null }; } state = { ...state, ctx }; // Start the new phase. next.push({ fn: StartPhase }); return state; } function UpdateTurn(state, { arg, currentPlayer, next }) { let { G, ctx } = state; const phaseConfig = GetPhase(ctx); // Update turn order state. const { endPhase, ctx: newCtx } = UpdateTurnOrderState(state, currentPlayer, phaseConfig.turn, arg); ctx = newCtx; state = { ...state, G, ctx }; if (endPhase) { next.push({ fn: EndPhase, turn: ctx.turn, phase: ctx.phase }); } else { next.push({ fn: StartTurn, currentPlayer: ctx.currentPlayer }); } return state; } function UpdateStage(state, { arg, playerID }) { if (typeof arg === 'string' || arg === Stage.NULL) { arg = { stage: arg }; } if (typeof arg !== 'object') return state; // `arg` should be of type `StageArg`, loose typing as `any` here for historic reasons // stages previously did not enforce minMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(arg); let { ctx } = state; let { activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, } = ctx; // Checking if stage is valid, even Stage.NULL if (arg.stage !== undefined) { if (activePlayers === null) { activePlayers = {}; } activePlayers[playerID] = arg.stage; _activePlayersNumMoves[playerID] = 0; if (arg.minMoves) { if (_activePlayersMinMoves === null) { _activePlayersMinMoves = {}; } _activePlayersMinMoves[playerID] = arg.minMoves; } if (arg.maxMoves) { if (_activePlayersMaxMoves === null) { _activePlayersMaxMoves = {}; } _activePlayersMaxMoves[playerID] = arg.maxMoves; } } ctx = { ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, }; return { ...state, ctx }; } function UpdateActivePlayers(state, { arg }) { return { ...state, ctx: SetActivePlayers(state.ctx, arg) }; } /////////////// // ShouldEnd // /////////////// function ShouldEndGame(state) { return wrapped.endIf(state); } function ShouldEndPhase(state) { const phaseConfig = GetPhase(state.ctx); return phaseConfig.wrapped.endIf(state); } function ShouldEndTurn(state) { const phaseConfig = GetPhase(state.ctx); // End the turn if the required number of moves has been made. const currentPlayerMoves = state.ctx.numMoves || 0; if (phaseConfig.turn.maxMoves && currentPlayerMoves >= phaseConfig.turn.maxMoves) { return true; } return phaseConfig.turn.wrapped.endIf(state); } ///////// // End // ///////// function EndGame(state, { arg, phase }) { state = EndPhase(state, { phase }); if (arg === undefined) { arg = true; } state = { ...state, ctx: { ...state.ctx, gameover: arg } }; // Run game end hook. const G = wrapped.onEnd(state); return { ...state, G }; } function EndPhase(state, { arg, next, turn: initialTurn, automatic }) { // End the turn first. state = EndTurn(state, { turn: initialTurn, force: true, automatic: true }); const { phase, turn } = state.ctx; if (next) { next.push({ fn: UpdatePhase, arg, phase }); } // If we aren't in a phase, there is nothing else to do. if (phase === null) { return state; } // Run any cleanup code for the phase that is about to end. const phaseConfig = GetPhase(state.ctx); const G = phaseConfig.wrapped.onEnd(state); // Reset the phase. const ctx = { ...state.ctx, phase: null }; // Add log entry. const action = gameEvent('endPhase', arg); const { _stateID } = state; const logEntry = { action, _stateID, turn, phase }; if (automatic) logEntry.automatic = true; const deltalog = [...(state.deltalog || []), logEntry]; return { ...state, G, ctx, deltalog }; } function EndTurn(state, { arg, next, turn: initialTurn, force, automatic, playerID }) { // This is not the turn that EndTurn was originally // called for. The turn was probably ended some other way. if (initialTurn !== state.ctx.turn) { return state; } const { currentPlayer, numMoves, phase, turn } = state.ctx; const phaseConfig = GetPhase(state.ctx); // Prevent ending the turn if minMoves haven't been reached. const currentPlayerMoves = numMoves || 0; if (!force && phaseConfig.turn.minMoves && currentPlayerMoves < phaseConfig.turn.minMoves) { info(`cannot end turn before making ${phaseConfig.turn.minMoves} moves`); return state; } // Run turn-end triggers. const G = phaseConfig.turn.wrapped.onEnd(state); if (next) { next.push({ fn: UpdateTurn, arg, currentPlayer }); } // Reset activePlayers. let ctx = { ...state.ctx, activePlayers: null }; // Remove player from playerOrder if (arg && arg.remove) { playerID = playerID || currentPlayer; const playOrder = ctx.playOrder.filter((i) => i != playerID); const playOrderPos = ctx.playOrderPos > playOrder.length - 1 ? 0 : ctx.playOrderPos; ctx = { ...ctx, playOrder, playOrderPos }; if (playOrder.length === 0) { next.push({ fn: EndPhase, turn, phase }); return state; } } // Create log entry. const action = gameEvent('endTurn', arg); const { _stateID } = state; const logEntry = { action, _stateID, turn, phase }; if (automatic) logEntry.automatic = true; const deltalog = [...(state.deltalog || []), logEntry]; return { ...state, G, ctx, deltalog, _undo: [], _redo: [] }; } function EndStage(state, { arg, next, automatic, playerID }) { playerID = playerID || state.ctx.currentPlayer; let { ctx, _stateID } = state; let { activePlayers, _activePlayersNumMoves, _activePlayersMinMoves, _activePlayersMaxMoves, phase, turn, } = ctx; const playerInStage = activePlayers !== null && playerID in activePlayers; const phaseConfig = GetPhase(ctx); if (!arg && playerInStage) { const stage = phaseConfig.turn.stages[activePlayers[playerID]]; if (stage && stage.next) { arg = stage.next; } } // Checking if arg is a valid stage, even Stage.NULL if (next) { next.push({ fn: UpdateStage, arg, playerID }); } // If player isn’t in a stage, there is nothing else to do. if (!playerInStage) return state; // Prevent ending the stage if minMoves haven't been reached. const currentPlayerMoves = _activePlayersNumMoves[playerID] || 0; if (_activePlayersMinMoves && _activePlayersMinMoves[playerID] && currentPlayerMoves < _activePlayersMinMoves[playerID]) { info(`cannot end stage before making ${_activePlayersMinMoves[playerID]} moves`); return state; } // Remove player from activePlayers. activePlayers = { ...activePlayers }; delete activePlayers[playerID]; if (_activePlayersMinMoves) { // Remove player from _activePlayersMinMoves. _activePlayersMinMoves = { ..._activePlayersMinMoves }; delete _activePlayersMinMoves[playerID]; } if (_activePlayersMaxMoves) { // Remove player from _activePlayersMaxMoves. _activePlayersMaxMoves = { ..._activePlayersMaxMoves }; delete _activePlayersMaxMoves[playerID]; } ctx = UpdateActivePlayersOnceEmpty({ ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, }); // Create log entry. const action = gameEvent('endStage', arg); const logEntry = { action, _stateID, turn, phase }; if (automatic) logEntry.automatic = true; const deltalog = [...(state.deltalog || []), logEntry]; return { ...state, ctx, deltalog }; } /** * Retrieves the relevant move that can be played by playerID. * * If ctx.activePlayers is set (i.e. one or more players are in some stage), * then it attempts to find the move inside the stages config for * that turn. If the stage for a player is '', then the player is * allowed to make a move (as determined by the phase config), but * isn't restricted to a particular set as defined in the stage config. * * If not, it then looks for the move inside the phase. * * If it doesn't find the move there, it looks at the global move definition. * * @param {object} ctx * @param {string} name * @param {string} playerID */ function GetMove(ctx, name, playerID) { const phaseConfig = GetPhase(ctx); const stages = phaseConfig.turn.stages; const { activePlayers } = ctx; if (activePlayers && activePlayers[playerID] !== undefined && activePlayers[playerID] !== Stage.NULL && stages[activePlayers[playerID]] !== undefined && stages[activePlayers[playerID]].moves !== undefined) { // Check if moves are defined for the player's stage. const stage = stages[activePlayers[playerID]]; const moves = stage.moves; if (name in moves) { return moves[name]; } } else if (phaseConfig.moves) { // Check if moves are defined for the current phase. if (name in phaseConfig.moves) { return phaseConfig.moves[name]; } } else if (name in moves) { // Check for the move globally. return moves[name]; } return null; } function ProcessMove(state, action) { const { playerID, type } = action; const { currentPlayer, activePlayers, _activePlayersMaxMoves } = state.ctx; const move = GetMove(state.ctx, type, playerID); const shouldCount = !move || typeof move === 'function' || move.noLimit !== true; let { numMoves, _activePlayersNumMoves } = state.ctx; if (shouldCount) { if (playerID === currentPlayer) numMoves++; if (activePlayers) _activePlayersNumMoves[playerID]++; } state = { ...state, ctx: { ...state.ctx, numMoves, _activePlayersNumMoves, }, }; if (_activePlayersMaxMoves && _activePlayersNumMoves[playerID] >= _activePlayersMaxMoves[playerID]) { state = EndStage(state, { playerID, automatic: true }); } const phaseConfig = GetPhase(state.ctx); const G = phaseConfig.turn.wrapped.onMove({ ...state, playerID }); state = { ...state, G }; const events = [{ fn: OnMove }]; return Process(state, events); } function SetStageEvent(state, playerID, arg) { return Process(state, [{ fn: EndStage, arg, playerID }]); } function EndStageEvent(state, playerID) { return Process(state, [{ fn: EndStage, playerID }]); } function SetActivePlayersEvent(state, _playerID, arg) { return Process(state, [{ fn: UpdateActivePlayers, arg }]); } function SetPhaseEvent(state, _playerID, newPhase) { return Process(state, [ { fn: EndPhase, phase: state.ctx.phase, turn: state.ctx.turn, arg: { next: newPhase }, }, ]); } function EndPhaseEvent(state) { return Process(state, [ { fn: EndPhase, phase: state.ctx.phase, turn: state.ctx.turn }, ]); } function EndTurnEvent(state, _playerID, arg) { return Process(state, [ { fn: EndTurn, turn: state.ctx.turn, phase: state.ctx.phase, arg }, ]); } function PassEvent(state, _playerID, arg) { return Process(state, [ { fn: EndTurn, turn: state.ctx.turn, phase: state.ctx.phase, force: true, arg, }, ]); } function EndGameEvent(state, _playerID, arg) { return Process(state, [ { fn: EndGame, turn: state.ctx.turn, phase: state.ctx.phase, arg }, ]); } const eventHandlers = { endStage: EndStageEvent, setStage: SetStageEvent, endTurn: EndTurnEvent, pass: PassEvent, endPhase: EndPhaseEvent, setPhase: SetPhaseEvent, endGame: EndGameEvent, setActivePlayers: SetActivePlayersEvent, }; const enabledEventNames = []; if (events.endTurn !== false) { enabledEventNames.push('endTurn'); } if (events.pass !== false) { enabledEventNames.push('pass'); } if (events.endPhase !== false) { enabledEventNames.push('endPhase'); } if (events.setPhase !== false) { enabledEventNames.push('setPhase'); } if (events.endGame !== false) { enabledEventNames.push('endGame'); } if (events.setActivePlayers !== false) { enabledEventNames.push('setActivePlayers'); } if (events.endStage !== false) { enabledEventNames.push('endStage'); } if (events.setStage !== false) { enabledEventNames.push('setStage'); } function ProcessEvent(state, action) { const { type, playerID, args } = action.payload; if (typeof eventHandlers[type] !== 'function') return state; return eventHandlers[type](state, playerID, ...(Array.isArray(args) ? args : [args])); } function IsPlayerActive(_G, ctx, playerID) { if (ctx.activePlayers) { return playerID in ctx.activePlayers; } return ctx.currentPlayer === playerID; } return { ctx: (numPlayers) => ({ numPlayers, turn: 0, currentPlayer: '0', playOrder: [...Array.from({ length: numPlayers })].map((_, i) => i + ''), playOrderPos: 0, phase: startingPhase, activePlayers: null, }), init: (state) => { return Process(state, [{ fn: StartGame }]); }, isPlayerActive: IsPlayerActive, eventHandlers, eventNames: Object.keys(eventHandlers), enabledEventNames, moveMap, moveNames: [...moveNames.values()], processMove: ProcessMove, processEvent: ProcessEvent, getMove: GetMove, }; } /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ function IsProcessed(game) { return game.processMove !== undefined; } /** * Helper to generate the game move reducer. The returned * reducer has the following signature: * * (G, action, ctx) => {} * * You can roll your own if you like, or use any Redux * addon to generate such a reducer. * * The convention used in this framework is to * have action.type contain the name of the move, and * action.args contain any additional arguments as an * Array. */ function ProcessGameConfig(game) { // The Game() function has already been called on this // config object, so just pass it through. if (IsProcessed(game)) { return game; } if (game.name === undefined) game.name = 'default'; if (game.deltaState === undefined) game.deltaState = false; if (game.disableUndo === undefined) game.disableUndo = false; if (game.setup === undefined) game.setup = () => ({}); if (game.moves === undefined) game.moves = {}; if (game.playerView === undefined) game.playerView = ({ G }) => G; if (game.plugins === undefined) game.plugins = []; game.plugins.forEach((plugin) => { if (plugin.name === undefined) { throw new Error('Plugin missing name attribute'); } if (plugin.name.includes(' ')) { throw new Error(plugin.name + ': Plugin name must not include spaces'); } }); if (game.name.includes(' ')) { throw new Error(game.name + ': Game name must not include spaces'); } const flow = Flow(game); return { ...game, flow, moveNames: flow.moveNames, pluginNames: game.plugins.map((p) => p.name), processMove: (state, action) => { let moveFn = flow.getMove(state.ctx, action.type, action.playerID); if (IsLongFormMove(moveFn)) { moveFn = moveFn.move; } if (moveFn instanceof Function) { const fn = FnWrap(moveFn, GameMethod.MOVE, game.plugins); let args = []; if (action.args !== undefined) { args = Array.isArray(action.args) ? action.args : [action.args]; } const context = { ...GetAPIs(state), G: state.G, ctx: state.ctx, playerID: action.playerID, }; return fn(context, ...args); } error(`invalid move object: ${action.type}`); return state.G; }, }; } function IsLongFormMove(move) { return move instanceof Object && move.move !== undefined; } /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ var UpdateErrorType; (function (UpdateErrorType) { // The action’s credentials were missing or invalid UpdateErrorType["UnauthorizedAction"] = "update/unauthorized_action"; // The action’s matchID was not found UpdateErrorType["MatchNotFound"] = "update/match_not_found"; // Could not apply Patch operation (rfc6902). UpdateErrorType["PatchFailed"] = "update/patch_failed"; })(UpdateErrorType || (UpdateErrorType = {})); var ActionErrorType; (function (ActionErrorType) { // The action contained a stale state ID ActionErrorType["StaleStateId"] = "action/stale_state_id"; // The requested move is unknown or not currently available ActionErrorType["UnavailableMove"] = "action/unavailable_move"; // The move declared it was invalid (INVALID_MOVE constant) ActionErrorType["InvalidMove"] = "action/invalid_move"; // The player making the action is not currently active ActionErrorType["InactivePlayer"] = "action/inactive_player"; // The game has finished ActionErrorType["GameOver"] = "action/gameover"; // The requested action is disabled (e.g. undo/redo, events) ActionErrorType["ActionDisabled"] = "action/action_disabled"; // The requested action is not currently possible ActionErrorType["ActionInvalid"] = "action/action_invalid"; // The requested action was declared invalid by a plugin ActionErrorType["PluginActionInvalid"] = "action/plugin_invalid"; })(ActionErrorType || (ActionErrorType = {})); /* * Copyright 2017 The boardgame.io Authors * * Use of this source code is governed by a MIT-style * license that can be found in the LICENSE file or at * https://opensource.org/licenses/MIT. */ /** * Check if the payload for the passed action contains a playerID. */ const actionHasPlayerID = (action) => action.payload.playerID !== null && action.payload.playerID !== undefined; /** * Returns true if a move can be undone. */ const CanUndoMove = (G, ctx, move) => { function HasUndoable(move) { return move.undoable !== undefined; } function IsFunction(undoable) { return undoable instanceof Function; } if (!HasUndoable(move)) { return true; } if (IsFunction(move.undoable)) { return move.undoable({ G, ctx }); } return move.undoable; }; /** * Update the undo and redo stacks for a move or event. */ function updateUndoRedoState(state, opts) { if (opts.game.disableUndo) return state; const undoEntry = { G: state.G, ctx: state.ctx, plugins: state.plugins, playerID: opts.action.payload.playerID || state.ctx.currentPlayer, }; if (opts.action.type === 'MAKE_MOVE') { undoEntry.moveType = opts.action.payload.type; } return { ...state, _undo: [...state._undo, undoEntry], // Always reset redo stack when making a move or event _redo: [], }; } /** * Process state, adding the initial deltalog for this action. */ function initializeDeltalog(state, action, move) { // Create a log entry for this action. const logEntry = { action, _stateID: state._stateID, turn: state.ctx.turn, phase: state.ctx.phase, }; const pluginLogMetadata = state.plugins.log.data.metadata; if (pluginLogMetadata !== undefined) { logEntry.metadata = pluginLogMetadata; } if (typeof move === 'object' && move.redact === true) { logEntry.redact = true; } else if (typeof move === 'object' && move.redact instanceof Function) { logEntry.redact = move.redact({ G: state.G, ctx: state.ctx }); } return { ...state, deltalog: [logEntry], }; } /** * Update plugin state after move/event & check if plugins consider the action to be valid. * @param state Current version of state in the reducer. * @param oldState State to revert to in case of error. * @param pluginOpts Plugin configuration options. * @returns Tuple of the new state updated after flushing plugins and the old * state augmented with an error if a plugin declared the action invalid. */ function flushAndValidatePlugins(state, oldState, pluginOpts) { const [newState, isInvalid] = FlushAndValidate(state, pluginOpts); if (!isInvalid) return [newState]; return [ newState, WithError(oldState, ActionErrorType.PluginActionInvalid, isInvalid), ]; } /** * ExtractTransientsFromState * * Split out transients from the a TransientState */ function ExtractTransients(transientState) { if (!transientState) { // We preserve null for the state for legacy callers, but the transient // field should be undefined if not present to be consistent with the // code path below. return [null, undefined]; } const { transients, ...state } = transientState; return [state, transients]; } /** * WithError * * Augment a State instance with transient error information. */ function WithError(state, errorType, payload) { const error = { type: errorType, payload, }; return { ...state, transients: { error, }, }; } /** * Middleware for processing TransientState associated with the reducer * returned by CreateGameReducer. * This should pretty much be used everywhere you want realistic state * transitions and error handling. */ const TransientHandlingMiddleware = (store) => (next) => (action) => { const result = next(action); switch (action.type) { case STRIP_TRANSIENTS: { return result; } default: { const [, transients] = ExtractTransients(store.getState()); if (typeof transients !== 'undefined') { store.dispatch(stripTransients()); // Dev Note: If parent middleware needs to correlate the spawned // StripTransients action to the triggering action, instrument here. // // This is a bit tricky; for more details, see: // https://github.com/boardgameio/boardgame.io/pull/940#discussion_r636200648 return { ...result, transients, }; } return result; } } }; /** * CreateGameReducer * * Creates the main game state reducer. */ function CreateGameReducer({ game, isClient, }) { game = ProcessGameConfig(game); /** * GameReducer * * Redux reducer that maintains the overall game state. * @param {object} state - The state before the action. * @param {object} action - A Redux action. */ return (stateWithTransients = null, action) => { let [state /*, transients */] = ExtractTransients(stateWithTransients); switch (action.type) { case STRIP_TRANSIENTS: { // This action indicates that transient metadata in the state has been // consumed and should now be stripped from the state.. return state; } case GAME_EVENT: { state = { ...state, deltalog: [] }; // Process game events only on the server. // These events like `endTurn` typically // contain code that may rely on secret state // and cannot be computed on the client. if (isClient) { return state; } // Disallow events once the game is over. if (state.ctx.gameover !== undefined) { error(`cannot call event after game end`); return WithError(state, ActionErrorType.GameOver); } // Ignore the event if the player isn't active. if (actionHasPlayerID(action) && !game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID)) { error(`disallowed event: ${action.payload.type}`); return WithError(state, ActionErrorType.InactivePlayer); } // Execute plugins. state = Enhance(state, { game, isClient: false, playerID: action.payload.playerID, }); // Process event. let newState = game.flow.processEvent(state, action); // Execute plugins. let stateWithError; [newState, stateWithError] = flushAndValidatePlugins(newState, state, { game, isClient: false, }); if (stateWithError) return stateWithError; // Update undo / redo state. newState = updateUndoRedoState(newState, { game, action }); return { ...newState, _stateID: state._stateID + 1 }; } case MAKE_MOVE: { const oldState = (state = { ...state, deltalog: [] }); // Check whether the move is allowed at this time. const move = game.flow.getMove(state.ctx, action.payload.type, action.payload.playerID || state.ctx.currentPlayer); if (move === null) { error(`disallowed move: ${action.payload.type}`); return WithError(state, ActionErrorType.UnavailableMove); } // Don't run move on client if move says so. if (isClient && move.client === false) { return state; } // Disallow moves once the game is over. if (state.ctx.gameover !== undefined) { error(`cannot make move after game end`); return WithError(state, ActionErrorType.GameOver); } // Ignore the move if the player isn't active. if (actionHasPlayerID(action) && !game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID)) { error(`disallowed move: ${action.payload.type}`); return WithError(state, ActionErrorType.InactivePlayer); } // Execute plugins. state = Enhance(state, { game, isClient, playerID: action.payload.playerID, }); // Process the move. const G = game.processMove(state, action.payload); // The game declared the move as invalid. if (G === INVALID_MOVE) { error(`invalid move: ${action.payload.type} args: ${action.payload.args}`); // TODO(#723): Marshal a nice error payload with the processed move. return WithError(state, ActionErrorType.InvalidMove); } const newState = { ...state, G }; // Some plugin indicated that it is not suitable to be // materialized on the client (and must wait for the server // response instead). if (isClient && NoClient(newState, { game })) { return state; } state = newState; // If we're on the client, just process the move // and no triggers in multiplayer mode. // These will be processed on the server, which // will send back a state update. if (isClient) { let stateWithError; [state, stateWithError] = flushAndValidatePlugins(state, oldState, { game, isClient: true, }); if (stateWithError) return stateWithError; return { ...state, _stateID: state._stateID + 1, }; } // On the server, construct the deltalog. state = initializeDeltalog(state, action, move); // Allow the flow reducer to process any triggers that happen after moves. state = game.flow.processMove(state, action.payload); let stateWithError; [state, stateWithError] = flushAndValidatePlugins(state, oldState, { game, }); if (stateWithError) return stateWithError; // Update undo / redo state. state = updateUndoRedoState(state, { game, action }); return { ...state, _stateID: state._stateID + 1, }; } case RESET: case UPDATE: case SYNC: { return action.state; } case UNDO: { state = { ...state, deltalog: [] }; if (game.disableUndo) { error('Undo is not enabled'); return WithError(state, ActionErrorType.ActionDisabled); } const { G, ctx, _undo, _redo, _stateID } = state; if (_undo.length < 2) { error(`No moves to undo`); return WithError(state, ActionErrorType.ActionInvalid); } const last = _undo[_undo.length - 1]; const restore = _undo[_undo.length - 2]; // Only allow players to undo their own moves. if (actionHasPlayerID(action) && action.payload.playerID !== last.playerID) { error(`Cannot undo other players' moves`); return WithError(state, ActionErrorType.ActionInvalid); } // If undoing a move, check it is undoable. if (last.moveType) { const lastMove = game.flow.getMove(restore.ctx, last.moveType, last.playerID); if (!CanUndoMove(G, ctx, lastMove)) { error(`Move cannot be undone`); return WithError(state, ActionErrorType.ActionInvalid); } } state = initializeDeltalog(state, action); return { ...state, G: restore.G, ctx: restore.ctx, plugins: restore.plugins, _stateID: _stateID + 1, _undo: _undo.slice(0, -1), _redo: [last, ..._redo], }; } case REDO: { state = { ...state, deltalog: [] }; if (game.disableUndo) { error('Redo is not enabled'); return WithError(state, ActionErrorType.ActionDisabled); } const { _undo, _redo, _stateID } = state; if (_redo.length === 0) { error(`No moves to redo`); return WithError(state, ActionErrorType.ActionInvalid); } const first = _redo[0]; // Only allow players to redo their own undos. if (actionHasPlayerID(action) && action.payload.playerID !== first.playerID) { error(`Cannot redo other players' moves`); return WithError(state, ActionErrorType.ActionInvalid); } state = initializeDeltalog(state, action); return { ...state, G: first.G, ctx: first.ctx, plugins: first.plugins, _stateID: _stateID + 1, _undo: [..._undo, first], _redo: _redo.slice(1), }; } case PLUGIN: { // TODO(#723): Expose error semantics to plugin processing. return ProcessAction(state, action, { game }); } case PATCH: { const oldState = state; const newState = JSON.parse(JSON.stringify(oldState)); const patchError = applyPatch(newState, action.patch); const hasError = patchError.some((entry) => entry !== null); if (hasError) { error(`Patch ${JSON.stringify(action.patch)} apply failed`); return WithError(oldState, UpdateErrorType.PatchFailed, patchError); } else { return newState; } } default: { return state; } } }; } export { CreateGameReducer as C, IsLongFormMove as I, ProcessGameConfig as P, TransientHandlingMiddleware as T };