UNPKG

boardgame.io

Version:
1,133 lines (1,115 loc) 40 kB
'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var produce = _interopDefault(require('immer')); var pluginRandom = require('./plugin-random-7425844d.js'); var isPlainObject = _interopDefault(require('lodash.isplainobject')); /* * 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. */ const MAKE_MOVE = 'MAKE_MOVE'; const GAME_EVENT = 'GAME_EVENT'; const REDO = 'REDO'; const RESET = 'RESET'; const SYNC = 'SYNC'; const UNDO = 'UNDO'; const UPDATE = 'UPDATE'; const PATCH = 'PATCH'; const PLUGIN = 'PLUGIN'; const STRIP_TRANSIENTS = 'STRIP_TRANSIENTS'; /* * 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. */ /** * Generate a move to be dispatched to the game move reducer. * * @param {string} type - The move type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ const makeMove = (type, args, playerID, credentials) => ({ type: MAKE_MOVE, payload: { type, args, playerID, credentials }, }); /** * Generate a game event to be dispatched to the flow reducer. * * @param {string} type - The event type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ const gameEvent = (type, args, playerID, credentials) => ({ type: GAME_EVENT, payload: { type, args, playerID, credentials }, }); /** * Generate an automatic game event that is a side-effect of a move. * @param {string} type - The event type. * @param {Array} args - Additional arguments. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ const automaticGameEvent = (type, args, playerID, credentials) => ({ type: GAME_EVENT, payload: { type, args, playerID, credentials }, automatic: true, }); const sync = (info) => ({ type: SYNC, state: info.state, log: info.log, initialState: info.initialState, clientOnly: true, }); /** * Used to update the Redux store's state with patch in response to * an action coming from another player. * @param prevStateID previous stateID * @param stateID stateID after this patch * @param {Operation[]} patch - The patch to apply. * @param {LogEntry[]} deltalog - A log delta. */ const patch = (prevStateID, stateID, patch, deltalog) => ({ type: PATCH, prevStateID, stateID, patch, deltalog, clientOnly: true, }); /** * Used to update the Redux store's state in response to * an action coming from another player. * @param {object} state - The state to restore. * @param {Array} deltalog - A log delta. */ const update = (state, deltalog) => ({ type: UPDATE, state, deltalog, clientOnly: true, }); /** * Used to reset the game state. * @param {object} state - The initial state. */ const reset = (state) => ({ type: RESET, state, clientOnly: true, }); /** * Used to undo the last move. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ const undo = (playerID, credentials) => ({ type: UNDO, payload: { type: null, args: null, playerID, credentials }, }); /** * Used to redo the last undone move. * @param {string} playerID - The ID of the player making this action. * @param {string} credentials - (optional) The credentials for the player making this action. */ const redo = (playerID, credentials) => ({ type: REDO, payload: { type: null, args: null, playerID, credentials }, }); /** * Allows plugins to define their own actions and intercept them. */ const plugin = (type, args, playerID, credentials) => ({ type: PLUGIN, payload: { type, args, playerID, credentials }, }); /** * Private action used to strip transient metadata (e.g. errors) from the game * state. */ const stripTransients = () => ({ type: STRIP_TRANSIENTS, }); var ActionCreators = /*#__PURE__*/Object.freeze({ __proto__: null, makeMove: makeMove, gameEvent: gameEvent, automaticGameEvent: automaticGameEvent, sync: sync, patch: patch, update: update, reset: reset, undo: undo, redo: redo, plugin: plugin, stripTransients: stripTransients }); /** * Moves can return this when they want to indicate * that the combination of arguments is illegal and * the move ought to be discarded. */ const INVALID_MOVE = 'INVALID_MOVE'; /* * Copyright 2018 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. */ /** * Plugin that allows using Immer to make immutable changes * to G by just mutating it. */ const ImmerPlugin = { name: 'plugin-immer', fnWrap: (move) => (context, ...args) => { let isInvalid = false; const newG = produce(context.G, (G) => { const result = move({ ...context, G }, ...args); if (result === INVALID_MOVE) { isInvalid = true; return; } return result; }); if (isInvalid) return INVALID_MOVE; return newG; }, }; (function (GameMethod) { GameMethod["MOVE"] = "MOVE"; GameMethod["GAME_ON_END"] = "GAME_ON_END"; GameMethod["PHASE_ON_BEGIN"] = "PHASE_ON_BEGIN"; GameMethod["PHASE_ON_END"] = "PHASE_ON_END"; GameMethod["TURN_ON_BEGIN"] = "TURN_ON_BEGIN"; GameMethod["TURN_ON_MOVE"] = "TURN_ON_MOVE"; GameMethod["TURN_ON_END"] = "TURN_ON_END"; })(exports.GameMethod || (exports.GameMethod = {})); /* * Copyright 2018 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 Errors; (function (Errors) { Errors["CalledOutsideHook"] = "Events must be called from moves or the `onBegin`, `onEnd`, and `onMove` hooks.\nThis error probably means you called an event from other game code, like an `endIf` trigger or one of the `turn.order` methods."; Errors["EndTurnInOnEnd"] = "`endTurn` is disallowed in `onEnd` hooks \u2014 the turn is already ending."; Errors["MaxTurnEndings"] = "Maximum number of turn endings exceeded for this update.\nThis likely means game code is triggering an infinite loop."; Errors["PhaseEventInOnEnd"] = "`setPhase` & `endPhase` are disallowed in a phase\u2019s `onEnd` hook \u2014 the phase is already ending.\nIf you\u2019re trying to dynamically choose the next phase when a phase ends, use the phase\u2019s `next` trigger."; Errors["StageEventInOnEnd"] = "`setStage`, `endStage` & `setActivePlayers` are disallowed in `onEnd` hooks."; Errors["StageEventInPhaseBegin"] = "`setStage`, `endStage` & `setActivePlayers` are disallowed in a phase\u2019s `onBegin` hook.\nUse `setActivePlayers` in a `turn.onBegin` hook or declare stages with `turn.activePlayers` instead."; Errors["StageEventInTurnBegin"] = "`setStage` & `endStage` are disallowed in `turn.onBegin`.\nUse `setActivePlayers` or declare stages with `turn.activePlayers` instead."; })(Errors || (Errors = {})); /** * Events */ class Events { constructor(flow, ctx, playerID) { this.flow = flow; this.playerID = playerID; this.dispatch = []; this.initialTurn = ctx.turn; this.updateTurnContext(ctx, undefined); // This is an arbitrarily large upper threshold, which could be made // configurable via a game option if the need arises. this.maxEndedTurnsPerAction = ctx.numPlayers * 100; } api() { const events = { _private: this, }; for (const type of this.flow.eventNames) { events[type] = (...args) => { this.dispatch.push({ type, args, phase: this.currentPhase, turn: this.currentTurn, calledFrom: this.currentMethod, // Used to capture a stack trace in case it is needed later. error: new Error('Events Plugin Error'), }); }; } return events; } isUsed() { return this.dispatch.length > 0; } updateTurnContext(ctx, methodType) { this.currentPhase = ctx.phase; this.currentTurn = ctx.turn; this.currentMethod = methodType; } unsetCurrentMethod() { this.currentMethod = undefined; } /** * Updates ctx with the triggered events. * @param {object} state - The state object { G, ctx }. */ update(state) { const initialState = state; const stateWithError = ({ stack }, message) => ({ ...initialState, plugins: { ...initialState.plugins, events: { ...initialState.plugins.events, data: { error: message + '\n' + stack }, }, }, }); EventQueue: for (let i = 0; i < this.dispatch.length; i++) { const event = this.dispatch[i]; const turnHasEnded = event.turn !== state.ctx.turn; // This protects against potential infinite loops if specific events are called on hooks. // The moment we exceed the defined threshold, we just bail out of all phases. const endedTurns = this.currentTurn - this.initialTurn; if (endedTurns >= this.maxEndedTurnsPerAction) { return stateWithError(event.error, Errors.MaxTurnEndings); } if (event.calledFrom === undefined) { return stateWithError(event.error, Errors.CalledOutsideHook); } // Stop processing events once the game has finished. if (state.ctx.gameover) break EventQueue; switch (event.type) { case 'endStage': case 'setStage': case 'setActivePlayers': { switch (event.calledFrom) { // Disallow all stage events in onEnd and phase.onBegin hooks. case exports.GameMethod.TURN_ON_END: case exports.GameMethod.PHASE_ON_END: return stateWithError(event.error, Errors.StageEventInOnEnd); case exports.GameMethod.PHASE_ON_BEGIN: return stateWithError(event.error, Errors.StageEventInPhaseBegin); // Disallow setStage & endStage in turn.onBegin hooks. case exports.GameMethod.TURN_ON_BEGIN: if (event.type === 'setActivePlayers') break; return stateWithError(event.error, Errors.StageEventInTurnBegin); } // If the turn already ended, don't try to process stage events. if (turnHasEnded) continue EventQueue; break; } case 'endTurn': { if (event.calledFrom === exports.GameMethod.TURN_ON_END || event.calledFrom === exports.GameMethod.PHASE_ON_END) { return stateWithError(event.error, Errors.EndTurnInOnEnd); } // If the turn already ended some other way, // don't try to end the turn again. if (turnHasEnded) continue EventQueue; break; } case 'endPhase': case 'setPhase': { if (event.calledFrom === exports.GameMethod.PHASE_ON_END) { return stateWithError(event.error, Errors.PhaseEventInOnEnd); } // If the phase already ended some other way, // don't try to end the phase again. if (event.phase !== state.ctx.phase) continue EventQueue; break; } } const action = automaticGameEvent(event.type, event.args, this.playerID); state = this.flow.processEvent(state, action); } return state; } } /* * Copyright 2020 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. */ const EventsPlugin = { name: 'events', noClient: ({ api }) => api._private.isUsed(), isInvalid: ({ data }) => data.error || false, // Update the events plugin’s internal turn context each time a move // or hook is called. This allows events called after turn or phase // endings to dispatch the current turn and phase correctly. fnWrap: (method, methodType) => (context, ...args) => { const api = context.events; if (api) api._private.updateTurnContext(context.ctx, methodType); const G = method(context, ...args); if (api) api._private.unsetCurrentMethod(); return G; }, dangerouslyFlushRawState: ({ state, api }) => api._private.update(state), api: ({ game, ctx, playerID }) => new Events(game.flow, ctx, playerID).api(), }; /* * Copyright 2018 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. */ /** * Plugin that makes it possible to add metadata to log entries. * During a move, you can set metadata using ctx.log.setMetadata and it will be * available on the log entry for that move. */ const LogPlugin = { name: 'log', flush: () => ({}), api: ({ data }) => { return { setMetadata: (metadata) => { data.metadata = metadata; }, }; }, setup: () => ({}), }; /** * Check if a value can be serialized (e.g. using `JSON.stringify`). * Adapted from: https://stackoverflow.com/a/30712764/3829557 */ function isSerializable(value) { // Primitives are OK. if (value === undefined || value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { return true; } // A non-primitive value that is neither a POJO or an array cannot be serialized. if (!isPlainObject(value) && !Array.isArray(value)) { return false; } // Recurse entries if the value is an object or array. for (const key in value) { if (!isSerializable(value[key])) return false; } return true; } /** * Plugin that checks whether state is serializable, in order to avoid * network serialization bugs. */ const SerializablePlugin = { name: 'plugin-serializable', fnWrap: (move) => (context, ...args) => { const result = move(context, ...args); // Check state in non-production environments. if (process.env.NODE_ENV !== 'production' && !isSerializable(result)) { throw new Error('Move state is not JSON-serialiazable.\n' + 'See https://boardgame.io/documentation/#/?id=state for more information.'); } return result; }, }; /* * Copyright 2018 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. */ const production = process.env.NODE_ENV === 'production'; const logfn = production ? () => { } : (...msg) => console.log(...msg); const errorfn = (...msg) => console.error(...msg); function info(msg) { logfn(`INFO: ${msg}`); } function error(error) { errorfn('ERROR:', error); } /* * Copyright 2018 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. */ /** * List of plugins that are always added. */ const CORE_PLUGINS = [ImmerPlugin, pluginRandom.RandomPlugin, LogPlugin, SerializablePlugin]; const DEFAULT_PLUGINS = [...CORE_PLUGINS, EventsPlugin]; /** * Allow plugins to intercept actions and process them. */ const ProcessAction = (state, action, opts) => { // TODO(#723): Extend error handling to plugins. opts.game.plugins .filter((plugin) => plugin.action !== undefined) .filter((plugin) => plugin.name === action.payload.type) .forEach((plugin) => { const name = plugin.name; const pluginState = state.plugins[name] || { data: {} }; const data = plugin.action(pluginState.data, action.payload); state = { ...state, plugins: { ...state.plugins, [name]: { ...pluginState, data }, }, }; }); return state; }; /** * The APIs created by various plugins are stored in the plugins * section of the state object: * * { * G: {}, * ctx: {}, * plugins: { * plugin-a: { * data: {}, // this is generated by the plugin at Setup / Flush. * api: {}, // this is ephemeral and generated by Enhance. * } * } * } * * This function retrieves plugin APIs and returns them as an object * for consumption as used by move contexts. */ const GetAPIs = ({ plugins }) => Object.entries(plugins || {}).reduce((apis, [name, { api }]) => { apis[name] = api; return apis; }, {}); /** * Applies the provided plugins to the given move / flow function. * * @param methodToWrap - The move function or hook to apply the plugins to. * @param methodType - The type of the move or hook being wrapped. * @param plugins - The list of plugins. */ const FnWrap = (methodToWrap, methodType, plugins) => { return [...CORE_PLUGINS, ...plugins, EventsPlugin] .filter((plugin) => plugin.fnWrap !== undefined) .reduce((method, { fnWrap }) => fnWrap(method, methodType), methodToWrap); }; /** * Allows the plugin to generate its initial state. */ const Setup = (state, opts) => { [...DEFAULT_PLUGINS, ...opts.game.plugins] .filter((plugin) => plugin.setup !== undefined) .forEach((plugin) => { const name = plugin.name; const data = plugin.setup({ G: state.G, ctx: state.ctx, game: opts.game, }); state = { ...state, plugins: { ...state.plugins, [name]: { data }, }, }; }); return state; }; /** * Invokes the plugin before a move or event. * The API that the plugin generates is stored inside * the `plugins` section of the state (which is subsequently * merged into ctx). */ const Enhance = (state, opts) => { [...DEFAULT_PLUGINS, ...opts.game.plugins] .filter((plugin) => plugin.api !== undefined) .forEach((plugin) => { const name = plugin.name; const pluginState = state.plugins[name] || { data: {} }; const api = plugin.api({ G: state.G, ctx: state.ctx, data: pluginState.data, game: opts.game, playerID: opts.playerID, }); state = { ...state, plugins: { ...state.plugins, [name]: { ...pluginState, api }, }, }; }); return state; }; /** * Allows plugins to update their state after a move / event. */ const Flush = (state, opts) => { // We flush the events plugin first, then custom plugins and the core plugins. // This means custom plugins cannot use the events API but will be available in event hooks. // Note that plugins are flushed in reverse, to allow custom plugins calling each other. [...CORE_PLUGINS, ...opts.game.plugins, EventsPlugin] .reverse() .forEach((plugin) => { const name = plugin.name; const pluginState = state.plugins[name] || { data: {} }; if (plugin.flush) { const newData = plugin.flush({ G: state.G, ctx: state.ctx, game: opts.game, api: pluginState.api, data: pluginState.data, }); state = { ...state, plugins: { ...state.plugins, [plugin.name]: { data: newData }, }, }; } else if (plugin.dangerouslyFlushRawState) { state = plugin.dangerouslyFlushRawState({ state, game: opts.game, api: pluginState.api, data: pluginState.data, }); // Remove everything other than data. const data = state.plugins[name].data; state = { ...state, plugins: { ...state.plugins, [plugin.name]: { data }, }, }; } }); return state; }; /** * Allows plugins to indicate if they should not be materialized on the client. * This will cause the client to discard the state update and wait for the * master instead. */ const NoClient = (state, opts) => { return [...DEFAULT_PLUGINS, ...opts.game.plugins] .filter((plugin) => plugin.noClient !== undefined) .map((plugin) => { const name = plugin.name; const pluginState = state.plugins[name]; if (pluginState) { return plugin.noClient({ G: state.G, ctx: state.ctx, game: opts.game, api: pluginState.api, data: pluginState.data, }); } return false; }) .includes(true); }; /** * Allows plugins to indicate if the entire action should be thrown out * as invalid. This will cancel the entire state update. */ const IsInvalid = (state, opts) => { const firstInvalidReturn = [...DEFAULT_PLUGINS, ...opts.game.plugins] .filter((plugin) => plugin.isInvalid !== undefined) .map((plugin) => { const { name } = plugin; const pluginState = state.plugins[name]; const message = plugin.isInvalid({ G: state.G, ctx: state.ctx, game: opts.game, data: pluginState && pluginState.data, }); return message ? { plugin: name, message } : false; }) .find((value) => value); return firstInvalidReturn || false; }; /** * Update plugin state after move/event & check if plugins consider the update to be valid. * @returns Tuple of `[updatedState]` or `[originalState, invalidError]`. */ const FlushAndValidate = (state, opts) => { const updatedState = Flush(state, opts); const isInvalid = IsInvalid(updatedState, opts); if (!isInvalid) return [updatedState]; const { plugin, message } = isInvalid; error(`${plugin} plugin declared action invalid:\n${message}`); return [state, isInvalid]; }; /** * Allows plugins to customize their data for specific players. * For example, a plugin may want to share no data with the client, or * want to keep some player data secret from opponents. */ const PlayerView = ({ G, ctx, plugins = {} }, { game, playerID }) => { [...DEFAULT_PLUGINS, ...game.plugins].forEach(({ name, playerView }) => { if (!playerView) return; const { data } = plugins[name] || { data: {} }; const newData = playerView({ G, ctx, game, data, playerID }); plugins = { ...plugins, [name]: { data: newData }, }; }); return plugins; }; /** * Adjust the given options to use the new minMoves/maxMoves if a legacy moveLimit was given * @param options The options object to apply backwards compatibility to * @param enforceMinMoves Use moveLimit to set both minMoves and maxMoves */ function supportDeprecatedMoveLimit(options, enforceMinMoves = false) { if (options.moveLimit) { if (enforceMinMoves) { options.minMoves = options.moveLimit; } options.maxMoves = options.moveLimit; delete options.moveLimit; } } /* * 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 SetActivePlayers(ctx, arg) { let activePlayers = {}; let _prevActivePlayers = []; let _nextActivePlayers = null; let _activePlayersMinMoves = {}; let _activePlayersMaxMoves = {}; if (Array.isArray(arg)) { // support a simple array of player IDs as active players const value = {}; arg.forEach((v) => (value[v] = Stage.NULL)); activePlayers = value; } else { // process active players argument object // stages previously did not enforce minMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(arg); if (arg.next) { _nextActivePlayers = arg.next; } if (arg.revert) { _prevActivePlayers = [ ...ctx._prevActivePlayers, { activePlayers: ctx.activePlayers, _activePlayersMinMoves: ctx._activePlayersMinMoves, _activePlayersMaxMoves: ctx._activePlayersMaxMoves, _activePlayersNumMoves: ctx._activePlayersNumMoves, }, ]; } if (arg.currentPlayer !== undefined) { ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, ctx.currentPlayer, arg.currentPlayer); } if (arg.others !== undefined) { for (let i = 0; i < ctx.playOrder.length; i++) { const id = ctx.playOrder[i]; if (id !== ctx.currentPlayer) { ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.others); } } } if (arg.all !== undefined) { for (let i = 0; i < ctx.playOrder.length; i++) { const id = ctx.playOrder[i]; ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.all); } } if (arg.value) { for (const id in arg.value) { ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, id, arg.value[id]); } } if (arg.minMoves) { for (const id in activePlayers) { if (_activePlayersMinMoves[id] === undefined) { _activePlayersMinMoves[id] = arg.minMoves; } } } if (arg.maxMoves) { for (const id in activePlayers) { if (_activePlayersMaxMoves[id] === undefined) { _activePlayersMaxMoves[id] = arg.maxMoves; } } } } if (Object.keys(activePlayers).length === 0) { activePlayers = null; } if (Object.keys(_activePlayersMinMoves).length === 0) { _activePlayersMinMoves = null; } if (Object.keys(_activePlayersMaxMoves).length === 0) { _activePlayersMaxMoves = null; } const _activePlayersNumMoves = {}; for (const id in activePlayers) { _activePlayersNumMoves[id] = 0; } return { ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, }; } /** * Update activePlayers, setting it to previous, next or null values * when it becomes empty. * @param ctx */ function UpdateActivePlayersOnceEmpty(ctx) { let { activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, } = ctx; if (activePlayers && Object.keys(activePlayers).length === 0) { if (_nextActivePlayers) { ctx = SetActivePlayers(ctx, _nextActivePlayers); ({ activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, } = ctx); } else if (_prevActivePlayers.length > 0) { const lastIndex = _prevActivePlayers.length - 1; ({ activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, } = _prevActivePlayers[lastIndex]); _prevActivePlayers = _prevActivePlayers.slice(0, lastIndex); } else { activePlayers = null; _activePlayersMinMoves = null; _activePlayersMaxMoves = null; } } return { ...ctx, activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, }; } /** * Apply an active player argument to the given player ID * @param {Object} activePlayers * @param {Object} _activePlayersMinMoves * @param {Object} _activePlayersMaxMoves * @param {String} playerID The player to apply the parameter to * @param {(String|Object)} arg An active player argument */ function ApplyActivePlayerArgument(activePlayers, _activePlayersMinMoves, _activePlayersMaxMoves, playerID, arg) { if (typeof arg !== 'object' || arg === Stage.NULL) { arg = { stage: arg }; } if (arg.stage !== undefined) { // stages previously did not enforce minMoves, this behaviour is kept intentionally supportDeprecatedMoveLimit(arg); activePlayers[playerID] = arg.stage; if (arg.minMoves) _activePlayersMinMoves[playerID] = arg.minMoves; if (arg.maxMoves) _activePlayersMaxMoves[playerID] = arg.maxMoves; } } /** * Converts a playOrderPos index into its value in playOrder. * @param {Array} playOrder - An array of player ID's. * @param {number} playOrderPos - An index into the above. */ function getCurrentPlayer(playOrder, playOrderPos) { // convert to string in case playOrder is set to number[] return playOrder[playOrderPos] + ''; } /** * Called at the start of a turn to initialize turn order state. * * TODO: This is called inside StartTurn, which is called from * both UpdateTurn and StartPhase (so it's called at the beginning * of a new phase as well as between turns). We should probably * split it into two. */ function InitTurnOrderState(state, turn) { let { G, ctx } = state; const { numPlayers } = ctx; const pluginAPIs = GetAPIs(state); const context = { ...pluginAPIs, G, ctx }; const order = turn.order; let playOrder = [...Array.from({ length: numPlayers })].map((_, i) => i + ''); if (order.playOrder !== undefined) { playOrder = order.playOrder(context); } const playOrderPos = order.first(context); const posType = typeof playOrderPos; if (posType !== 'number') { error(`invalid value returned by turn.order.first — expected number got ${posType}${playOrderPos}”.`); } const currentPlayer = getCurrentPlayer(playOrder, playOrderPos); ctx = { ...ctx, currentPlayer, playOrderPos, playOrder }; ctx = SetActivePlayers(ctx, turn.activePlayers || {}); return ctx; } /** * Called at the end of each turn to update the turn order state. * @param {object} G - The game object G. * @param {object} ctx - The game object ctx. * @param {object} turn - A turn object for this phase. * @param {string} endTurnArg - An optional argument to endTurn that may specify the next player. */ function UpdateTurnOrderState(state, currentPlayer, turn, endTurnArg) { const order = turn.order; let { G, ctx } = state; let playOrderPos = ctx.playOrderPos; let endPhase = false; if (endTurnArg && endTurnArg !== true) { if (typeof endTurnArg !== 'object') { error(`invalid argument to endTurn: ${endTurnArg}`); } Object.keys(endTurnArg).forEach((arg) => { switch (arg) { case 'remove': currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos); break; case 'next': playOrderPos = ctx.playOrder.indexOf(endTurnArg.next); currentPlayer = endTurnArg.next; break; default: error(`invalid argument to endTurn: ${arg}`); } }); } else { const pluginAPIs = GetAPIs(state); const context = { ...pluginAPIs, G, ctx }; const t = order.next(context); const type = typeof t; if (t !== undefined && type !== 'number') { error(`invalid value returned by turn.order.next — expected number or undefined got ${type}${t}”.`); } if (t === undefined) { endPhase = true; } else { playOrderPos = t; currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos); } } ctx = { ...ctx, playOrderPos, currentPlayer, }; return { endPhase, ctx }; } /** * Set of different turn orders possible in a phase. * These are meant to be passed to the `turn` setting * in the flow objects. * * Each object defines the first player when the phase / game * begins, and also a function `next` to determine who the * next player is when the turn ends. * * The phase ends if next() returns undefined. */ const TurnOrder = { /** * DEFAULT * * The default round-robin turn order. */ DEFAULT: { first: ({ ctx }) => ctx.turn === 0 ? ctx.playOrderPos : (ctx.playOrderPos + 1) % ctx.playOrder.length, next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * RESET * * Similar to DEFAULT, but starts from 0 each time. */ RESET: { first: () => 0, next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * CONTINUE * * Similar to DEFAULT, but starts with the player who ended the last phase. */ CONTINUE: { first: ({ ctx }) => ctx.playOrderPos, next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * ONCE * * Another round-robin turn order, but goes around just once. * The phase ends after all players have played. */ ONCE: { first: () => 0, next: ({ ctx }) => { if (ctx.playOrderPos < ctx.playOrder.length - 1) { return ctx.playOrderPos + 1; } }, }, /** * CUSTOM * * Identical to DEFAULT, but also sets playOrder at the * beginning of the phase. * * @param {Array} playOrder - The play order. */ CUSTOM: (playOrder) => ({ playOrder: () => playOrder, first: () => 0, next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), /** * CUSTOM_FROM * * Identical to DEFAULT, but also sets playOrder at the * beginning of the phase to a value specified by a field * in G. * * @param {string} playOrderField - Field in G. */ CUSTOM_FROM: (playOrderField) => ({ playOrder: ({ G }) => G[playOrderField], first: () => 0, next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), }; const Stage = { NULL: null, }; const ActivePlayers = { /** * ALL * * The turn stays with one player, but any player can play (in any order) * until the phase ends. */ ALL: { all: Stage.NULL }, /** * ALL_ONCE * * The turn stays with one player, but any player can play (once, and in any order). * This is typically used in a phase where you want to elicit a response * from every player in the game. */ ALL_ONCE: { all: Stage.NULL, minMoves: 1, maxMoves: 1 }, /** * OTHERS * * The turn stays with one player, and every *other* player can play (in any order) * until the phase ends. */ OTHERS: { others: Stage.NULL }, /** * OTHERS_ONCE * * The turn stays with one player, and every *other* player can play (once, and in any order). * This is typically used in a phase where you want to elicit a response * from every *other* player in the game. */ OTHERS_ONCE: { others: Stage.NULL, minMoves: 1, maxMoves: 1 }, }; exports.ActionCreators = ActionCreators; exports.ActivePlayers = ActivePlayers; exports.Enhance = Enhance; exports.FlushAndValidate = FlushAndValidate; exports.FnWrap = FnWrap; exports.GAME_EVENT = GAME_EVENT; exports.GetAPIs = GetAPIs; exports.INVALID_MOVE = INVALID_MOVE; exports.InitTurnOrderState = InitTurnOrderState; exports.MAKE_MOVE = MAKE_MOVE; exports.NoClient = NoClient; exports.PATCH = PATCH; exports.PLUGIN = PLUGIN; exports.PlayerView = PlayerView; exports.ProcessAction = ProcessAction; exports.REDO = REDO; exports.RESET = RESET; exports.STRIP_TRANSIENTS = STRIP_TRANSIENTS; exports.SYNC = SYNC; exports.SetActivePlayers = SetActivePlayers; exports.Setup = Setup; exports.Stage = Stage; exports.TurnOrder = TurnOrder; exports.UNDO = UNDO; exports.UPDATE = UPDATE; exports.UpdateActivePlayersOnceEmpty = UpdateActivePlayersOnceEmpty; exports.UpdateTurnOrderState = UpdateTurnOrderState; exports.error = error; exports.gameEvent = gameEvent; exports.info = info; exports.makeMove = makeMove; exports.patch = patch; exports.redo = redo; exports.reset = reset; exports.stripTransients = stripTransients; exports.supportDeprecatedMoveLimit = supportDeprecatedMoveLimit; exports.sync = sync; exports.undo = undo; exports.update = update;