boardgame.io
Version:
library for turn-based games
1,596 lines (1,522 loc) • 105 kB
JavaScript
'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