boardgame.io
Version:
library for turn-based games
313 lines (309 loc) • 10.8 kB
JavaScript
'use strict';
var redux = require('redux');
var turnOrder = require('./turn-order-d6c2e620.js');
var reducer = require('./reducer-76d3a4df.js');
var initialize = require('./initialize-18a8be06.js');
var base = require('./base-bdd9c13b.js');
/*
* 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 getPlayerMetadata = (gameMetadata, playerID) => {
if (gameMetadata && gameMetadata.players) {
return gameMetadata.players[playerID];
}
};
function IsSynchronous(storageAPI) {
return storageAPI.type() === base.Type.SYNC;
}
/**
* Redact the log.
*
* @param {Array} log - The game log (or deltalog).
* @param {String} playerID - The playerID that this log is
* to be sent to.
*/
function redactLog(log, playerID) {
if (log === undefined) {
return log;
}
return log.map(logEvent => {
// filter for all other players and spectators.
if (playerID !== null && +playerID === +logEvent.action.payload.playerID) {
return logEvent;
}
if (logEvent.redact !== true) {
return logEvent;
}
const payload = {
...logEvent.action.payload,
args: null,
};
const filteredEvent = {
...logEvent,
action: { ...logEvent.action, payload },
};
/* eslint-disable-next-line no-unused-vars */
const { redact, ...remaining } = filteredEvent;
return remaining;
});
}
/**
* Verifies that the game has metadata and is using credentials.
*/
const doesGameRequireAuthentication = (gameMetadata) => {
if (!gameMetadata)
return false;
const { players } = gameMetadata;
const hasCredentials = Object.keys(players).some(key => {
return !!(players[key] && players[key].credentials);
});
return hasCredentials;
};
/**
* Verifies that the move came from a player with the correct credentials.
*/
const isActionFromAuthenticPlayer = (actionCredentials, playerMetadata) => {
if (!actionCredentials)
return false;
if (!playerMetadata)
return false;
return actionCredentials === playerMetadata.credentials;
};
/**
* Remove player credentials from action payload
*/
const stripCredentialsFromAction = (action) => {
// eslint-disable-next-line no-unused-vars
const { credentials, ...payload } = action.payload;
return { ...action, payload };
};
/**
* Master
*
* Class that runs the game and maintains the authoritative state.
* It uses the transportAPI to communicate with clients and the
* storageAPI to communicate with the database.
*/
class Master {
constructor(game, storageAPI, transportAPI, auth) {
this.game = reducer.ProcessGameConfig(game);
this.storageAPI = storageAPI;
this.transportAPI = transportAPI;
this.auth = null;
this.subscribeCallback = () => { };
this.shouldAuth = () => false;
if (auth === true) {
this.auth = isActionFromAuthenticPlayer;
this.shouldAuth = doesGameRequireAuthentication;
}
else if (typeof auth === 'function') {
this.auth = auth;
this.shouldAuth = () => true;
}
}
subscribe(fn) {
this.subscribeCallback = fn;
}
/**
* Called on each move / event made by the client.
* Computes the new value of the game state and returns it
* along with a deltalog.
*/
async onUpdate(credAction, stateID, gameID, playerID) {
let isActionAuthentic;
let metadata;
const credentials = credAction.payload.credentials;
if (IsSynchronous(this.storageAPI)) {
({ metadata } = this.storageAPI.fetch(gameID, { metadata: true }));
const playerMetadata = getPlayerMetadata(metadata, playerID);
isActionAuthentic = this.shouldAuth(metadata)
? this.auth(credentials, playerMetadata)
: true;
}
else {
({ metadata } = await this.storageAPI.fetch(gameID, {
metadata: true,
}));
const playerMetadata = getPlayerMetadata(metadata, playerID);
isActionAuthentic = this.shouldAuth(metadata)
? await this.auth(credentials, playerMetadata)
: true;
}
if (!isActionAuthentic) {
return { error: 'unauthorized action' };
}
let action = stripCredentialsFromAction(credAction);
const key = gameID;
let state;
let result;
if (IsSynchronous(this.storageAPI)) {
result = this.storageAPI.fetch(key, { state: true });
}
else {
result = await this.storageAPI.fetch(key, { state: true });
}
state = result.state;
if (state === undefined) {
turnOrder.error(`game not found, gameID=[${key}]`);
return { error: 'game not found' };
}
if (state.ctx.gameover !== undefined) {
turnOrder.error(`game over - gameID=[${key}]`);
return;
}
const reducer$1 = reducer.CreateGameReducer({
game: this.game,
});
const store = redux.createStore(reducer$1, state);
// Only allow UNDO / REDO if there is exactly one player
// that can make moves right now and the person doing the
// action is that player.
if (action.type == turnOrder.UNDO || action.type == turnOrder.REDO) {
if (state.ctx.currentPlayer !== playerID ||
state.ctx.activePlayers !== null) {
turnOrder.error(`playerID=[${playerID}] cannot undo / redo right now`);
return;
}
}
// Check whether the player is active.
if (!this.game.flow.isPlayerActive(state.G, state.ctx, playerID)) {
turnOrder.error(`player not active - playerID=[${playerID}]`);
return;
}
// Check whether the player is allowed to make the move.
if (action.type == turnOrder.MAKE_MOVE &&
!this.game.flow.getMove(state.ctx, action.payload.type, playerID)) {
turnOrder.error(`move not processed - canPlayerMakeMove=false, playerID=[${playerID}]`);
return;
}
if (state._stateID !== stateID) {
turnOrder.error(`invalid stateID, was=[${stateID}], expected=[${state._stateID}]`);
return;
}
// Update server's version of the store.
store.dispatch(action);
state = store.getState();
this.subscribeCallback({
state,
action,
gameID,
});
this.transportAPI.sendAll((playerID) => {
const filteredState = {
...state,
G: this.game.playerView(state.G, state.ctx, playerID),
deltalog: undefined,
_undo: [],
_redo: [],
};
const log = redactLog(state.deltalog, playerID);
return {
type: 'update',
args: [gameID, filteredState, log],
};
});
const { deltalog, ...stateWithoutDeltalog } = state;
let newMetadata;
if (metadata &&
!('gameover' in metadata) &&
state.ctx.gameover !== undefined) {
newMetadata = {
...metadata,
gameover: state.ctx.gameover,
};
}
if (IsSynchronous(this.storageAPI)) {
this.storageAPI.setState(key, stateWithoutDeltalog, deltalog);
if (newMetadata)
this.storageAPI.setMetadata(key, newMetadata);
}
else {
const writes = [
this.storageAPI.setState(key, stateWithoutDeltalog, deltalog),
];
if (newMetadata) {
writes.push(this.storageAPI.setMetadata(key, newMetadata));
}
await Promise.all(writes);
}
}
/**
* Called when the client connects / reconnects.
* Returns the latest game state and the entire log.
*/
async onSync(gameID, playerID, numPlayers) {
const key = gameID;
let state;
let initialState;
let log;
let gameMetadata;
let filteredMetadata;
let result;
if (IsSynchronous(this.storageAPI)) {
result = this.storageAPI.fetch(key, {
state: true,
metadata: true,
log: true,
initialState: true,
});
}
else {
result = await this.storageAPI.fetch(key, {
state: true,
metadata: true,
log: true,
initialState: true,
});
}
state = result.state;
initialState = result.initialState;
log = result.log;
gameMetadata = result.metadata;
if (gameMetadata) {
filteredMetadata = Object.values(gameMetadata.players).map(player => {
const { credentials, ...filteredData } = player;
return filteredData;
});
}
// If the game doesn't exist, then create one on demand.
// TODO: Move this out of the sync call.
if (state === undefined) {
initialState = state = initialize.InitializeGame({ game: this.game, numPlayers });
this.subscribeCallback({
state,
gameID,
});
if (IsSynchronous(this.storageAPI)) {
this.storageAPI.setState(key, state);
}
else {
await this.storageAPI.setState(key, state);
}
}
const filteredState = {
...state,
G: this.game.playerView(state.G, state.ctx, playerID),
deltalog: undefined,
_undo: [],
_redo: [],
};
log = redactLog(log, playerID);
const syncInfo = {
state: filteredState,
log,
filteredMetadata,
initialState,
};
this.transportAPI.send({
playerID,
type: 'sync',
args: [gameID, syncInfo],
});
return;
}
}
exports.Master = Master;