boardgame.io
Version:
library for turn-based games
1,421 lines (1,400 loc) • 149 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 nanoid = require('nanoid');
var cors = _interopDefault(require('@koa/cors'));
var produce = _interopDefault(require('immer'));
var isPlainObject = _interopDefault(require('lodash.isplainobject'));
var IO = _interopDefault(require('koa-socket-2'));
var PQueue = _interopDefault(require('p-queue'));
var rfc6902 = require('rfc6902');
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) => (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;
},
};
// Inlined version of Alea from https://github.com/davidbau/seedrandom.
// Converted to Typescript October 2020.
class Alea {
constructor(seed) {
const mash = Mash();
// Apply the seeding algorithm from Baagoe.
this.c = 1;
this.s0 = mash(' ');
this.s1 = mash(' ');
this.s2 = mash(' ');
this.s0 -= mash(seed);
if (this.s0 < 0) {
this.s0 += 1;
}
this.s1 -= mash(seed);
if (this.s1 < 0) {
this.s1 += 1;
}
this.s2 -= mash(seed);
if (this.s2 < 0) {
this.s2 += 1;
}
}
next() {
const t = 2091639 * this.s0 + this.c * 2.3283064365386963e-10; // 2^-32
this.s0 = this.s1;
this.s1 = this.s2;
return (this.s2 = t - (this.c = Math.trunc(t)));
}
}
function Mash() {
let n = 0xefc8249d;
const mash = function (data) {
const str = data.toString();
for (let i = 0; i < str.length; i++) {
n += str.charCodeAt(i);
let 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 copy(f, t) {
t.c = f.c;
t.s0 = f.s0;
t.s1 = f.s1;
t.s2 = f.s2;
return t;
}
function alea(seed, state) {
const xg = new Alea(seed);
const prng = xg.next.bind(xg);
if (state)
copy(state, xg);
prng.state = () => copy(xg, {});
return prng;
}
/*
* 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.
*/
/**
* 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.
*/
class Random {
/**
* constructor
* @param {object} ctx - The ctx object to initialize from.
*/
constructor(state) {
// 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 || { seed: '0' };
this.used = false;
}
/**
* Generates a new seed from the current date / time.
*/
static seed() {
return Date.now().toString(36).slice(-10);
}
isUsed() {
return this.used;
}
getState() {
return this.state;
}
/**
* Generate a random number.
*/
_random() {
this.used = true;
const R = this.state;
const seed = R.prngstate ? '' : R.seed;
const rand = alea(seed, R.prngstate);
const number = rand();
this.state = {
...R,
prngstate: rand.state(),
};
return number;
}
api() {
const random = this._random.bind(this);
const SpotValue = {
D4: 4,
D6: 6,
D8: 8,
D10: 10,
D12: 12,
D20: 20,
};
// Generate functions for predefined dice values D4 - D20.
const predefined = {};
for (const key in SpotValue) {
const spotvalue = SpotValue[key];
predefined[key] = (diceCount) => {
return diceCount === undefined
? Math.floor(random() * spotvalue) + 1
: Array.from({ length: diceCount }).map(() => Math.floor(random() * spotvalue) + 1);
};
}
function Die(spotvalue = 6, diceCount) {
return diceCount === undefined
? Math.floor(random() * spotvalue) + 1
: Array.from({ length: diceCount }).map(() => Math.floor(random() * spotvalue) + 1);
}
return {
/**
* Similar to Die below, but with fixed spot values.
* Supports passing a diceCount
* if not defined, defaults to 1 and returns the value directly.
* if defined, returns an array containing the random dice values.
*
* D4: (diceCount) => value
* D6: (diceCount) => value
* D8: (diceCount) => value
* D10: (diceCount) => value
* D12: (diceCount) => value
* D20: (diceCount) => value
*/
...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,
/**
* Generate a random number between 0 and 1.
*/
Number: () => {
return random();
},
/**
* Shuffle an array.
*
* @param {Array} deck - The array to shuffle. Does not mutate
* the input, but returns the shuffled array.
*/
Shuffle: (deck) => {
const clone = [...deck];
let sourceIndex = deck.length;
let destinationIndex = 0;
const shuffled = Array.from({ length: sourceIndex });
while (sourceIndex) {
const randomIndex = Math.trunc(sourceIndex * random());
shuffled[destinationIndex++] = clone[randomIndex];
clone[randomIndex] = clone[--sourceIndex];
}
return shuffled;
},
_private: this,
};
}
}
/*
* 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._private.isUsed();
},
flush: ({ api }) => {
return api._private.getState();
},
api: ({ data }) => {
const random = new Random(data);
return random.api();
},
setup: ({ game }) => {
let { seed } = game;
if (seed === undefined) {
seed = Random.seed();
}
return { seed };
},
playerView: () => undefined,
};
/*
* Copyright 2017 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
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 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,
});
/**
* Private action used to strip transient metadata (e.g. errors) from the game
* state.
*/
const stripTransients = () => ({
type: STRIP_TRANSIENTS,
});
var GameMethod;
(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";
})(GameMethod || (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 GameMethod.TURN_ON_END:
case GameMethod.PHASE_ON_END:
return stateWithError(event.error, Errors.StageEventInOnEnd);
case GameMethod.PHASE_ON_BEGIN:
return stateWithError(event.error, Errors.StageEventInPhaseBegin);
// Disallow setStage & endStage in turn.onBegin hooks.
case 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 === GameMethod.TURN_ON_END ||
event.calledFrom === 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 === 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, 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,
};
/*
* Copyright 2017 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
/**
* Flow
*
* Creates a reducer that updates ctx (analogous to how moves update G).
*/
function Flow({ moves, phases, endIf, onEnd, turn, events, plugins, }) {
// Attach defaults.
if (moves === undefined) {
moves = {};
}
if (events === undefined) {
events = {};
}
if (plugins === undefined) {
plugins = [];
}
if (phases === undefined) {
phases = {};
}
if (!endIf)
endIf = () => undefined;
if (!onEnd)
onEnd = ({ G }) => G;
if (!turn)
turn = {};
const phaseMap = { ...phases };
if ('' in phaseMap) {
error('cannot specify phase with empty name');
}
phaseMap[''] = {};
const moveMap = {};
const moveNames = new Set();
let startingPhase = null;
Object.keys(moves).forEach((name) => moveNames.add(name));
const HookWrapper = (hook, hookType) => {
const withPlugins = FnWrap(hook, hookType, plugins);
return (state) => {
const pluginAPIs = GetAPIs(state);
return withPlugins({
...pluginAPIs,
G: state.G,
ctx: state.ctx,
playerID: state.playerID,
});
};
};
const TriggerWrapper = (trigger) => {
return (state) => {
const pluginAPIs = GetAPIs(state);
return trigger({
...pluginAPIs,
G: state.G,
ctx: state.ctx,
});
};
};
const wrapped = {
onEnd: HookWrapper(onEnd, GameMethod.GAME_ON_END),
endIf: TriggerWrapper(endIf),
};
for (const phase in phaseMap) {
const phaseConfig = phaseMap[phase];
if (phaseConfig.start === true) {
startingPhase = phase;
}
if (phaseConfig.moves !== undefined) {
for (const move of Object.keys(phaseConfig.moves)) {
moveMap[phase + '.' + move] = phaseConfig.moves[move];
moveNames.add(move);
}
}
if (phaseConfig.endIf === undefined) {
phaseConfig.endIf = () => undefined;
}
if (phaseConfig.onBegin === undefined) {
phaseConfig.onBegin = ({ G }) => G;
}
if (phaseConfig.onEnd === undefined) {
phaseConfig.onEnd = ({ G }) => G;
}
if (phaseConfig.turn === undefined) {
phaseConfig.turn = turn;
}
if (phaseConfig.turn.order === undefined) {
phaseConfig.turn.order = TurnOrder.DEFAULT;
}
if (phaseConfig.turn.onBegin === undefined) {
phaseConfig.turn.onBegin = ({ G }) => G;
}
if (phaseConfig.turn.onEnd === undefined) {
phaseConfig.turn.onEnd = ({ G }) => G;
}
if (phaseConfig.turn.endIf === undefined) {
phaseConfig.turn.endIf = () => false;
}
if (phaseConfig.turn.onMove === undefined) {
phaseConfig.turn.onMove = ({ G }) => G;
}
if (phaseConfig.turn.stages === undefined) {
phaseConfig.turn.stages = {};
}
// turns previously treated moveLimit as both minMoves and maxMoves, this behaviour is kept intentionally
supportDeprecatedMoveLimit(phaseConfig.turn, true);
for (const stage in phaseConfig.turn.stages) {
const stageConfig = phaseConfig.turn.stages[stage];
const moves = stageConfig.moves || {};
for (const move of Object.keys(moves)) {
const key = phase + '.' + stage + '.' + move;
moveMap[key] = moves[move];
moveNames.add(move);
}
}
phaseConfig.wrapped = {
onBegin: HookWrapper(phaseConfig.onBegin, GameMethod.PHASE_ON_BEGIN),
onEnd: HookWrapper(phaseConfig.onEnd, GameMethod.PHASE_ON_END),
endIf: TriggerWrapper(phaseConfig.endIf),
};
phaseConfig.turn.wrapped = {
onMove: HookWrapper(phaseConfig.turn.onMove, GameMethod.TURN_ON_MOVE),
onBegin: HookWrapper(phaseConfig.turn.onBegin, GameMethod.TURN_ON_BEGIN),
onEnd: HookWrapper(phaseConfig.turn.onEnd, GameMethod.TURN_ON_END),
endIf: TriggerWrapper(phaseConfig.turn.endIf),
};
if (typeof phaseConfig.next !== 'function') {
const { next } = phaseConfig;
phaseConfig.next = () => next || null;
}
phaseConfig.wrapped.next = TriggerWrapper(phaseConfig.next);
}
function GetPhase(ctx) {
return ctx.phase ? phaseMap[ctx.phase] : phaseMap[''];
}
function OnMove(state) {
return state;
}
function Process(state, events) {
const phasesEnded = new Set();
const turnsEnded = new Set();
for (let i = 0; i < events.length; i++) {
const { fn, arg, ...rest } = events[i];
// Detect a loop of EndPhase calls.
// This could potentially even be an infinite loop
// if the endIf condition of each phase blindly
// returns true. The moment we detect a single
// loop, we just bail out of all phases.
if (fn === EndPhase) {
turnsEnded.clear();
const phase = state.ctx.phase;
if (phasesEnded.has(phase)) {
const ctx = { ...state.ctx, phase: null };
return { ...state, ctx };
}
phasesEnded.add(phase);
}
// Process event.
const next = [];
state = fn(state, {
...rest,
arg,
next,
});
if (fn === EndGame) {
break;
}
// Check if we should end the game.
const shouldEndGame = ShouldEndGame(state);
if (shouldEndGame) {
events.push({
fn: EndGame,
arg: shouldEndGame,
turn: state.ctx.turn,
phase: state.ctx.phase,
automatic: true,
});
continue;
}
// Check if we should end the phase.
const shouldEndPhase = ShouldEndPhase(state);
if (shouldEndPhase) {
events.push({
fn: EndPhase,
arg: shouldEndPhase,
turn: state.ctx.turn,
phase: state.ctx.phase,
automatic: true,
});
continue;
}
// Check if we should end the turn.
if ([OnMove, UpdateStage, UpdateActivePlayers].includes(fn)) {
const shouldEndTurn = ShouldEndTurn(state);
if (shouldEndTurn) {
events.push({
fn: EndTurn,
arg: shouldEndTurn,
turn: state.ctx.turn,
phase: state.ctx.phase,
automatic: true,
});
continue;
}
}
events.push(...next);
}
return state;
}
///////////
// Start //
///////////
function StartGame(state, { next }) {
next.push({ fn: StartPhase });
return state;