boardgame.io
Version:
library for turn-based games
424 lines (373 loc) • 11.1 kB
text/typescript
/*
* 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.
*/
import { InitializeGame } from '../core/initialize';
import { CreateGameReducer } from '../core/reducer';
import { ProcessGameConfig } from '../core/game';
import { UNDO, REDO, MAKE_MOVE } from '../core/action-types';
import { createStore } from 'redux';
import * as logging from '../core/logger';
import {
SyncInfo,
FilteredMetadata,
Game,
Server,
State,
ActionShape,
CredentialedActionShape,
LogEntry,
PlayerID,
} from '../types';
import * as StorageAPI from '../server/db/base';
export const getPlayerMetadata = (
gameMetadata: Server.GameMetadata,
playerID: PlayerID
) => {
if (gameMetadata && gameMetadata.players) {
return gameMetadata.players[playerID];
}
};
function IsSynchronous(
storageAPI: StorageAPI.Sync | StorageAPI.Async
): storageAPI is StorageAPI.Sync {
return storageAPI.type() === StorageAPI.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.
*/
export function redactLog(log: LogEntry[], playerID: 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.
*/
export const doesGameRequireAuthentication = (
gameMetadata?: Server.GameMetadata
) => {
if (!gameMetadata) return false;
const { players } = gameMetadata as Server.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.
*/
export const isActionFromAuthenticPlayer = (
actionCredentials: string,
playerMetadata?: Server.PlayerMetadata
) => {
if (!actionCredentials) return false;
if (!playerMetadata) return false;
return actionCredentials === playerMetadata.credentials;
};
/**
* Remove player credentials from action payload
*/
const stripCredentialsFromAction = (action: CredentialedActionShape.Any) => {
// eslint-disable-next-line no-unused-vars
const { credentials, ...payload } = action.payload;
return { ...action, payload };
};
export type AuthFn = (
actionCredentials: string,
playerMetadata: Server.PlayerMetadata
) => boolean | Promise<boolean>;
type CallbackFn = (arg: {
state: State;
gameID: string;
action?: ActionShape.Any | CredentialedActionShape.Any;
}) => void;
type TransportData =
| {
type: 'update';
args: [string, State, LogEntry[]];
}
| {
type: 'sync';
args: [string, SyncInfo];
};
export interface TransportAPI {
send: (playerData: { playerID: PlayerID } & TransportData) => void;
sendAll: (makePlayerData: (playerID: PlayerID) => TransportData) => void;
}
/**
* 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.
*/
export class Master {
game: ReturnType<typeof ProcessGameConfig>;
storageAPI: StorageAPI.Sync | StorageAPI.Async;
transportAPI: TransportAPI;
subscribeCallback: CallbackFn;
auth: null | AuthFn;
shouldAuth: typeof doesGameRequireAuthentication;
constructor(
game: Game,
storageAPI: StorageAPI.Sync | StorageAPI.Async,
transportAPI: TransportAPI,
auth?: AuthFn | boolean
) {
this.game = 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: CallbackFn) {
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: CredentialedActionShape.Any,
stateID: number,
gameID: string,
playerID: string
) {
let isActionAuthentic;
let metadata: Server.GameMetadata | undefined;
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: State;
let result: StorageAPI.FetchResult<{ state: true }>;
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) {
logging.error(`game not found, gameID=[${key}]`);
return { error: 'game not found' };
}
if (state.ctx.gameover !== undefined) {
logging.error(`game over - gameID=[${key}]`);
return;
}
const reducer = CreateGameReducer({
game: this.game,
});
const store = createStore(reducer, 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 == UNDO || action.type == REDO) {
if (
state.ctx.currentPlayer !== playerID ||
state.ctx.activePlayers !== null
) {
logging.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)) {
logging.error(`player not active - playerID=[${playerID}]`);
return;
}
// Check whether the player is allowed to make the move.
if (
action.type == MAKE_MOVE &&
!this.game.flow.getMove(state.ctx, action.payload.type, playerID)
) {
logging.error(
`move not processed - canPlayerMakeMove=false, playerID=[${playerID}]`
);
return;
}
if (state._stateID !== stateID) {
logging.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: string) => {
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: Server.GameMetadata | undefined;
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: string, playerID: string, numPlayers: number) {
const key = gameID;
let state: State;
let initialState: State;
let log: LogEntry[];
let gameMetadata: Server.GameMetadata;
let filteredMetadata: FilteredMetadata;
let result: StorageAPI.FetchResult<{
state: true;
metadata: true;
log: true;
initialState: true;
}>;
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 = 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: SyncInfo = {
state: filteredState,
log,
filteredMetadata,
initialState,
};
this.transportAPI.send({
playerID,
type: 'sync',
args: [gameID, syncInfo],
});
return;
}
}