boardgame.io
Version:
library for turn-based games
474 lines (426 loc) • 12.6 kB
text/typescript
/*
* 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.
*/
import * as logging from './logger';
import * as plugin from '../plugins/main';
import type {
Ctx,
StageArg,
ActivePlayersArg,
PlayerID,
State,
TurnConfig,
FnContext,
} from '../types';
import { supportDeprecatedMoveLimit } from './backwards-compatibility';
export function SetActivePlayers(ctx: Ctx, arg: ActivePlayersArg): Ctx {
let activePlayers: typeof ctx.activePlayers = {};
let _prevActivePlayers: typeof ctx._prevActivePlayers = [];
let _nextActivePlayers: ActivePlayersArg | null = 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
*/
export function UpdateActivePlayersOnceEmpty(ctx: 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: Ctx['activePlayers'],
_activePlayersMinMoves: Ctx['_activePlayersMinMoves'],
_activePlayersMaxMoves: Ctx['_activePlayersMaxMoves'],
playerID: PlayerID,
arg: StageArg
) {
if (typeof arg !== 'object' || arg === Stage.NULL) {
arg = { stage: arg as string | null };
}
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: Ctx['playOrder'],
playOrderPos: Ctx['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.
*/
export function InitTurnOrderState(state: State, turn: TurnConfig) {
let { G, ctx } = state;
const { numPlayers } = ctx;
const pluginAPIs = plugin.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') {
logging.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.
*/
export function UpdateTurnOrderState(
state: State,
currentPlayer: PlayerID,
turn: TurnConfig,
endTurnArg?: true | { remove?: any; next?: string }
) {
const order = turn.order;
let { G, ctx } = state;
let playOrderPos = ctx.playOrderPos;
let endPhase = false;
if (endTurnArg && endTurnArg !== true) {
if (typeof endTurnArg !== 'object') {
logging.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:
logging.error(`invalid argument to endTurn: ${arg}`);
}
});
} else {
const pluginAPIs = plugin.GetAPIs(state);
const context = { ...pluginAPIs, G, ctx };
const t = order.next(context);
const type = typeof t;
if (t !== undefined && type !== 'number') {
logging.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.
*/
export const TurnOrder = {
/**
* DEFAULT
*
* The default round-robin turn order.
*/
DEFAULT: {
first: ({ ctx }: FnContext) =>
ctx.turn === 0
? ctx.playOrderPos
: (ctx.playOrderPos + 1) % ctx.playOrder.length,
next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
},
/**
* RESET
*
* Similar to DEFAULT, but starts from 0 each time.
*/
RESET: {
first: () => 0,
next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
},
/**
* CONTINUE
*
* Similar to DEFAULT, but starts with the player who ended the last phase.
*/
CONTINUE: {
first: ({ ctx }: FnContext) => ctx.playOrderPos,
next: ({ ctx }: FnContext) => (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 }: FnContext) => {
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: string[]) => ({
playOrder: () => playOrder,
first: () => 0,
next: ({ ctx }: FnContext) => (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: string) => ({
playOrder: ({ G }: FnContext) => G[playOrderField],
first: () => 0,
next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
}),
};
export const Stage = {
NULL: null,
};
export 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 },
};