UNPKG

boardgame.io

Version:
1,421 lines (1,400 loc) 149 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var Koa = _interopDefault(require('koa')); var Router = _interopDefault(require('@koa/router')); var koaBody = _interopDefault(require('koa-body')); var 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;