UNPKG

boardgame.io

Version:
1,596 lines (1,522 loc) 105 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var Koa = _interopDefault(require('koa')); var Router = _interopDefault(require('koa-router')); var koaBody = _interopDefault(require('koa-body')); var shortid = require('shortid'); var cors = _interopDefault(require('@koa/cors')); var produce = _interopDefault(require('immer')); var IO = _interopDefault(require('koa-socket-2')); var redux = require('redux'); /** * 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) => (G, ctx, ...args) => { let isInvalid = false; const newG = produce(G, G => { const result = move(G, ctx, ...args); if (result === INVALID_MOVE) { isInvalid = true; return; } return result; }); if (isInvalid) return INVALID_MOVE; return newG; }, }; function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function (obj) { return typeof obj; }; } else { _typeof = function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread2(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(source, true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(source).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread(); } function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } } function _iterableToArray(iter) { if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter); } function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } // Inlined version of Alea from https://github.com/davidbau/seedrandom. /* * Copyright 2015 David Bau. * * Permission is hereby granted, free of charge, * to any person obtaining a copy of this software * and associated documentation files (the "Software"), * to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, * publish, distribute, sublicense, and/or sell copies of the * Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall * be included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER * DEALINGS IN THE SOFTWARE. */ function Alea(seed) { var me = this, mash = Mash(); me.next = function () { var t = 2091639 * me.s0 + me.c * 2.3283064365386963e-10; // 2^-32 me.s0 = me.s1; me.s1 = me.s2; return me.s2 = t - (me.c = t | 0); }; // Apply the seeding algorithm from Baagoe. me.c = 1; me.s0 = mash(' '); me.s1 = mash(' '); me.s2 = mash(' '); me.s0 -= mash(seed); if (me.s0 < 0) { me.s0 += 1; } me.s1 -= mash(seed); if (me.s1 < 0) { me.s1 += 1; } me.s2 -= mash(seed); if (me.s2 < 0) { me.s2 += 1; } mash = null; } function copy(f, t) { t.c = f.c; t.s0 = f.s0; t.s1 = f.s1; t.s2 = f.s2; return t; } function Mash() { var n = 0xefc8249d; var mash = function mash(data) { data = data.toString(); for (var i = 0; i < data.length; i++) { n += data.charCodeAt(i); var h = 0.02519603282416938 * n; n = h >>> 0; h -= n; h *= n; n = h >>> 0; h -= n; n += h * 0x100000000; // 2^32 } return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 }; return mash; } function alea(seed, opts) { var xg = new Alea(seed), state = opts && opts.state, prng = xg.next; prng.quick = prng; if (state) { if (_typeof(state) == 'object') copy(state, xg); prng.state = function () { return copy(xg, {}); }; } return prng; } /** * Random * * Calls that require a pseudorandom number generator. * Uses a seed from ctx, and also persists the PRNG * state in ctx so that moves can stay pure. */ var Random = /*#__PURE__*/ function () { /** * constructor * @param {object} ctx - The ctx object to initialize from. */ function Random(state) { _classCallCheck(this, Random); // If we are on the client, the seed is not present. // Just use a temporary seed to execute the move without // crashing it. The move state itself is discarded, // so the actual value doesn't matter. this.state = state; this.used = false; } _createClass(Random, [{ key: "isUsed", value: function isUsed() { return this.used; } }, { key: "getState", value: function getState() { return this.state; } /** * Generate a random number. */ }, { key: "_random", value: function _random() { this.used = true; var R = this.state; var fn; if (R.prngstate === undefined) { // No call to a random function has been made. fn = new alea(R.seed, { state: true }); } else { fn = new alea('', { state: R.prngstate }); } var number = fn(); this.state = _objectSpread2({}, R, { prngstate: fn.state() }); return number; } }, { key: "api", value: function api() { var random = this._random.bind(this); var SpotValue = { D4: 4, D6: 6, D8: 8, D10: 10, D12: 12, D20: 20 }; // Generate functions for predefined dice values D4 - D20. var predefined = {}; var _loop = function _loop(key) { var spotvalue = SpotValue[key]; predefined[key] = function (diceCount) { if (diceCount === undefined) { return Math.floor(random() * spotvalue) + 1; } else { return _toConsumableArray(new Array(diceCount).keys()).map(function () { return Math.floor(random() * spotvalue) + 1; }); } }; }; for (var key in SpotValue) { _loop(key); } return _objectSpread2({}, predefined, { /** * Roll a die of specified spot value. * * @param {number} spotvalue - The die dimension (default: 6). * @param {number} diceCount - number of dice to throw. * if not defined, defaults to 1 and returns the value directly. * if defined, returns an array containing the random dice values. */ Die: function Die(spotvalue, diceCount) { if (spotvalue === undefined) { spotvalue = 6; } if (diceCount === undefined) { return Math.floor(random() * spotvalue) + 1; } else { return _toConsumableArray(new Array(diceCount).keys()).map(function () { return Math.floor(random() * spotvalue) + 1; }); } }, /** * Generate a random number between 0 and 1. */ Number: function Number() { return random(); }, /** * Shuffle an array. * * @param {Array} deck - The array to shuffle. Does not mutate * the input, but returns the shuffled array. */ Shuffle: function Shuffle(deck) { var clone = deck.slice(0); var srcIndex = deck.length; var dstIndex = 0; var shuffled = new Array(srcIndex); while (srcIndex) { var randIndex = srcIndex * random() | 0; shuffled[dstIndex++] = clone[randIndex]; clone[randIndex] = clone[--srcIndex]; } return shuffled; }, _obj: this }); } }]); return Random; }(); /** * Generates a new seed from the current date / time. */ Random.seed = function () { return (+new Date()).toString(36).slice(-10); }; /* * 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 RandomPlugin = { name: 'random', noClient: ({ api }) => { return api._obj.isUsed(); }, flush: ({ api }) => { return api._obj.getState(); }, api: ({ data }) => { const random = new Random(data); return random.api(); }, setup: ({ game }) => { let seed = game.seed; if (seed === undefined) { seed = Random.seed(); } return { seed }; }, }; /* * 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 PLUGIN = 'PLUGIN'; /* * 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 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, }); /* * 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. */ /** * Events */ class Events { constructor(flow, playerID) { this.flow = flow; this.playerID = playerID; this.dispatch = []; } /** * Attaches the Events API to ctx. * @param {object} ctx - The ctx object to attach to. */ api(ctx) { const events = { _obj: this, }; const { phase, turn } = ctx; for (const key of this.flow.eventNames) { events[key] = (...args) => { this.dispatch.push({ key, args, phase, turn }); }; } return events; } isUsed() { return this.dispatch.length > 0; } /** * Updates ctx with the triggered events. * @param {object} state - The state object { G, ctx }. */ update(state) { for (let i = 0; i < this.dispatch.length; i++) { const item = this.dispatch[i]; // If the turn already ended some other way, // don't try to end the turn again. if (item.key === 'endTurn' && item.turn !== state.ctx.turn) { continue; } // If the phase already ended some other way, // don't try to end the phase again. if ((item.key === 'endPhase' || item.key === 'setPhase') && item.phase !== state.ctx.phase) { continue; } const action = automaticGameEvent(item.key, item.args, this.playerID); state = { ...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 }) => { return api._obj.isUsed(); }, dangerouslyFlushRawState: ({ state, api }) => { return api._obj.update(state); }, api: ({ game, playerID, ctx }) => { return new Events(game.flow, playerID).api(ctx); }, }; /* * 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 DEFAULT_PLUGINS = [ImmerPlugin, RandomPlugin, EventsPlugin]; /** * Allow plugins to intercept actions and process them. */ const ProcessAction = (state, action, opts) => { 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 API's 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 takes these API's and stuffs them back into * ctx for consumption inside a move function or hook. */ const EnhanceCtx = (state) => { let ctx = { ...state.ctx }; const plugins = state.plugins || {}; Object.entries(plugins).forEach(([name, { api }]) => { ctx[name] = api; }); return ctx; }; /** * Applies the provided plugins to the given move / flow function. * * @param {function} fn - The move function or trigger to apply the plugins to. * @param {object} plugins - The list of plugins. */ const FnWrap = (fn, plugins) => { const reducer = (acc, { fnWrap }) => fnWrap(acc); return [...DEFAULT_PLUGINS, ...plugins] .filter(plugin => plugin.fnWrap !== undefined) .reduce(reducer, fn); }; /** * 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) => { // Note that we flush plugins in reverse order, to make sure that plugins // that come before in the chain are still available. [...DEFAULT_PLUGINS, ...opts.game.plugins].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; }) .some(value => value === true); }; /* * 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 ? () => { } : console.log; const errorfn = console.error; function info(msg) { logfn(`INFO: ${msg}`); } function error(error) { errorfn('ERROR:', error); } /* * 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. */ /** * Event to change the active players (and their stages) in the current turn. */ function SetActivePlayersEvent(state, _playerID, arg) { return { ...state, ctx: SetActivePlayers(state.ctx, arg) }; } function SetActivePlayers(ctx, arg) { let { _prevActivePlayers } = ctx; let activePlayers = {}; let _nextActivePlayers = null; let _activePlayersMoveLimit = {}; if (Array.isArray(arg)) { // support a simple array of player IDs as active players let value = {}; arg.forEach(v => (value[v] = Stage.NULL)); activePlayers = value; } else { // process active players argument object if (arg.next) { _nextActivePlayers = arg.next; } if (arg.revert) { _prevActivePlayers = _prevActivePlayers.concat({ activePlayers: ctx.activePlayers, _activePlayersMoveLimit: ctx._activePlayersMoveLimit, _activePlayersNumMoves: ctx._activePlayersNumMoves, }); } else { _prevActivePlayers = []; } if (arg.currentPlayer !== undefined) { ApplyActivePlayerArgument(activePlayers, _activePlayersMoveLimit, 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, _activePlayersMoveLimit, id, arg.others); } } } if (arg.all !== undefined) { for (let i = 0; i < ctx.playOrder.length; i++) { const id = ctx.playOrder[i]; ApplyActivePlayerArgument(activePlayers, _activePlayersMoveLimit, id, arg.all); } } if (arg.value) { for (const id in arg.value) { ApplyActivePlayerArgument(activePlayers, _activePlayersMoveLimit, id, arg.value[id]); } } if (arg.moveLimit) { for (const id in activePlayers) { if (_activePlayersMoveLimit[id] === undefined) { _activePlayersMoveLimit[id] = arg.moveLimit; } } } } if (Object.keys(activePlayers).length == 0) { activePlayers = null; } if (Object.keys(_activePlayersMoveLimit).length == 0) { _activePlayersMoveLimit = null; } let _activePlayersNumMoves = {}; for (const id in activePlayers) { _activePlayersNumMoves[id] = 0; } return { ...ctx, activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, }; } /** * Update activePlayers, setting it to previous, next or null values * when it becomes empty. * @param ctx */ function UpdateActivePlayersOnceEmpty(ctx) { let { activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, _prevActivePlayers, } = ctx; if (activePlayers && Object.keys(activePlayers).length == 0) { if (ctx._nextActivePlayers) { ctx = SetActivePlayers(ctx, ctx._nextActivePlayers); ({ activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, _prevActivePlayers, } = ctx); } else if (_prevActivePlayers.length > 0) { const lastIndex = _prevActivePlayers.length - 1; ({ activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, } = _prevActivePlayers[lastIndex]); _prevActivePlayers = _prevActivePlayers.slice(0, lastIndex); } else { activePlayers = null; _activePlayersMoveLimit = null; } } return { ...ctx, activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, _prevActivePlayers, }; } /** * Apply an active player argument to the given player ID * @param {Object} activePlayers * @param {Object} _activePlayersMoveLimit * @param {String} playerID The player to apply the parameter to * @param {(String|Object)} arg An active player argument */ function ApplyActivePlayerArgument(activePlayers, _activePlayersMoveLimit, playerID, arg) { if (typeof arg !== 'object' || arg === Stage.NULL) { arg = { stage: arg }; } if (arg.stage !== undefined) { activePlayers[playerID] = arg.stage; if (arg.moveLimit) _activePlayersMoveLimit[playerID] = arg.moveLimit; } } /** * 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 ctxWithAPI = EnhanceCtx(state); const order = turn.order; let playOrder = [...new Array(ctx.numPlayers)].map((_, i) => i + ''); if (order.playOrder !== undefined) { playOrder = order.playOrder(G, ctxWithAPI); } const playOrderPos = order.first(G, ctxWithAPI); 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 ctxWithAPI = EnhanceCtx(state); const t = order.next(G, ctxWithAPI); 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: (G, ctx) => ctx.turn === 0 ? ctx.playOrderPos : (ctx.playOrderPos + 1) % ctx.playOrder.length, next: (G, ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * RESET * * Similar to DEFAULT, but starts from 0 each time. */ RESET: { first: () => 0, next: (G, ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** * CONTINUE * * Similar to DEFAULT, but starts with the player who ended the last phase. */ CONTINUE: { first: (G, ctx) => ctx.playOrderPos, next: (G, 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: (G, 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: (G, 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: (G, ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), }; const Stage = { NULL: null, }; /* * 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, disableUndo, }) { // 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[''] = {}; let moveMap = {}; let moveNames = new Set(); let startingPhase = null; Object.keys(moves).forEach(name => moveNames.add(name)); const HookWrapper = (fn) => { const withPlugins = FnWrap(fn, plugins); return (state) => { const ctxWithAPI = EnhanceCtx(state); return withPlugins(state.G, ctxWithAPI); }; }; const TriggerWrapper = (endIf) => { return (state) => { let ctxWithAPI = EnhanceCtx(state); return endIf(state.G, ctxWithAPI); }; }; const wrapped = { onEnd: HookWrapper(onEnd), endIf: TriggerWrapper(endIf), }; for (let phase in phaseMap) { const conf = phaseMap[phase]; if (conf.start === true) { startingPhase = phase; } if (conf.moves !== undefined) { for (let move of Object.keys(conf.moves)) { moveMap[phase + '.' + move] = conf.moves[move]; moveNames.add(move); } } if (conf.endIf === undefined) { conf.endIf = () => undefined; } if (conf.onBegin === undefined) { conf.onBegin = G => G; } if (conf.onEnd === undefined) { conf.onEnd = G => G; } if (conf.turn === undefined) { conf.turn = turn; } if (conf.turn.order === undefined) { conf.turn.order = TurnOrder.DEFAULT; } if (conf.turn.onBegin === undefined) { conf.turn.onBegin = G => G; } if (conf.turn.onEnd === undefined) { conf.turn.onEnd = G => G; } if (conf.turn.endIf === undefined) { conf.turn.endIf = () => false; } if (conf.turn.onMove === undefined) { conf.turn.onMove = G => G; } if (conf.turn.stages === undefined) { conf.turn.stages = {}; } for (const stage in conf.turn.stages) { const stageConfig = conf.turn.stages[stage]; const moves = stageConfig.moves || {}; for (let move of Object.keys(moves)) { let key = phase + '.' + stage + '.' + move; moveMap[key] = moves[move]; moveNames.add(move); } } conf.wrapped = { onBegin: HookWrapper(conf.onBegin), onEnd: HookWrapper(conf.onEnd), endIf: TriggerWrapper(conf.endIf), }; conf.turn.wrapped = { onMove: HookWrapper(conf.turn.onMove), onBegin: HookWrapper(conf.turn.onBegin), onEnd: HookWrapper(conf.turn.onEnd), endIf: TriggerWrapper(conf.turn.endIf), }; } function GetPhase(ctx) { return ctx.phase ? phaseMap[ctx.phase] : phaseMap['']; } function OnMove(s) { return s; } 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. let 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 (fn === OnMove) { 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 conf = GetPhase(ctx); // Run any phase setup code provided by the user. G = conf.wrapped.onBegin(state); next.push({ fn: StartTurn }); return { ...state, G, ctx }; } function StartTurn(state, { currentPlayer }) { let { G, ctx } = state; const conf = GetPhase(ctx); // Initialize the turn order state. if (currentPlayer) { ctx = { ...ctx, currentPlayer }; if (conf.turn.activePlayers) { ctx = SetActivePlayers(ctx, conf.turn.activePlayers); } } else { // This is only called at the beginning of the phase // when there is no currentPlayer yet. ctx = InitTurnOrderState(state, conf.turn); } const turn = ctx.turn + 1; ctx = { ...ctx, turn, numMoves: 0, _prevActivePlayers: [] }; G = conf.turn.wrapped.onBegin({ ...state, G, ctx }); const _undo = disableUndo ? [] : [{ G, ctx }]; return { ...state, G, ctx, _undo, _redo: [] }; } //////////// // Update // //////////// function UpdatePhase(state, { arg, next, phase }) { const conf = 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 if (conf.next !== undefined) { ctx = { ...ctx, phase: conf.next }; } else { ctx = { ...ctx, phase: 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 conf = GetPhase(ctx); // Update turn order state. const { endPhase, ctx: newCtx } = UpdateTurnOrderState(state, currentPlayer, conf.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: arg }; } let { ctx } = state; let { activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, } = ctx; if (arg.stage) { if (activePlayers === null) { activePlayers = {}; } activePlayers[playerID] = arg.stage; _activePlayersNumMoves[playerID] = 0; if (arg.moveLimit) { if (_activePlayersMoveLimit === null) { _activePlayersMoveLimit = {}; } _activePlayersMoveLimit[playerID] = arg.moveLimit; } } ctx = { ...ctx, activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves, }; return { ...state, ctx }; } /////////////// // ShouldEnd // /////////////// function ShouldEndGame(state) { return wrapped.endIf(state); } function ShouldEndPhase(state) { const conf = GetPhase(state.ctx); return conf.wrapped.endIf(state); } function ShouldEndTurn(state) { const conf = GetPhase(state.ctx); // End the turn if the required number of moves has been made. const currentPlayerMoves = state.ctx.numMoves || 0; if (conf.turn.moveLimit && currentPlayerMoves >= conf.turn.moveLimit) { return true; } return conf.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, automatic }) { // End the turn first. state = EndTurn(state, { turn, force: true }); let G = state.G; let ctx = state.ctx; if (next) { next.push({ fn: UpdatePhase, arg, phase: ctx.phase }); } // If we aren't in a phase, there is nothing else to do. if (ctx.phase === null) { return state; } // Run any cleanup code for the phase that is about to end. const conf = GetPhase(ctx); G = conf.wrapped.onEnd(state); // Reset the phase. ctx = { ...ctx, phase: null }; // Add log entry. const action = gameEvent('endPhase', arg); const logEntry = { action, _stateID: state._stateID, turn: state.ctx.turn, phase: state.ctx.phase, }; if (automatic) { logEntry.automatic = true; } const deltalog = [...state.deltalog, logEntry]; return { ...state, G, ctx, deltalog }; } function EndTurn(state, { arg, next, turn, force, automatic, playerID }) { // This is not the turn that EndTurn was originally // called for. The turn was probably ended some other way. if (turn !== state.ctx.turn) { return state; } let { G, ctx } = state; const conf = GetPhase(ctx); // Prevent ending the turn if moveLimit hasn't been reached. const currentPlayerMoves = ctx.numMoves || 0; if (!force && conf.turn.moveLimit && currentPlayerMoves < conf.turn.moveLimit) { info(`cannot end turn before making ${conf.turn.moveLimit} moves`); return state; } // Run turn-end triggers. G = conf.turn.wrapped.onEnd(state); if (next) { next.push({ fn: UpdateTurn, arg, currentPlayer: ctx.currentPlayer }); } // Reset activePlayers. ctx = { ...ctx, activePlayers: null }; // Remove player from playerOrder if (arg && arg.remove) { playerID = playerID || ctx.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: ctx.turn, phase: ctx.phase }); return state; } } // Add log entry. const action = gameEvent('endTurn', arg); const logEntry = { action, _stateID: state._stateID, turn: state.ctx.turn, phase: state.ctx.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 } = state; let { activePlayers, _activePlayersMoveLimit } = ctx; const playerInStage = activePlayers !== null && playerID in activePlayers; if (!arg && playerInStage) { const conf = GetPhase(ctx); const stage = conf.turn.stages[activePlayers[playerID]]; if (stage && stage.next) arg = stage.next; } if (next && arg) { next.push({ fn: UpdateStage, arg, playerID }); } // If player isn’t in a stage, there is nothing else to do. if (!playerInStage) return state; // Remove player from activePlayers. activePlayers = Object.keys(activePlayers) .filter(id => id !== playerID) .reduce((obj, key) => { obj[key] = activePlayers[key]; return obj; }, {}); if (_activePlayersMoveLimit) { // Remove player from _activePlayersMoveLimit. _activePlayersMoveLimit = Object.keys(_activePlayersMoveLimit) .filter(id => id !== playerID) .reduce((obj, key) => { obj[key] = _activePlayersMoveLimit[key]; return obj; }, {}); } ctx = UpdateActivePlayersOnceEmpty({ ...ctx, activePlayers, _activePlayersMoveLimit, }); // Add log entry. const action = gameEvent('endStage', arg); const logEntry = { action, _stateID: state._stateID, turn: state.ctx.turn, phase: state.ctx.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. * * @pa