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