UNPKG

boardgame.io

Version:
525 lines (519 loc) 19.3 kB
'use strict'; var nonSecure = require('nanoid/non-secure'); var Debug = require('./Debug-710a6cb3.js'); var redux = require('redux'); var turnOrder = require('./turn-order-4ab12333.js'); var reducer = require('./reducer-6f7cf6b0.js'); var initialize = require('./initialize-648ccd94.js'); var transport = require('./transport-b1874dfa.js'); /** * This class doesn’t do anything, but simplifies the client class by providing * dummy functions to call, so we don’t need to mock them in the client. */ class DummyImpl extends transport.Transport { connect() { } disconnect() { } sendAction() { } sendChatMessage() { } requestSync() { } updateCredentials() { } updateMatchID() { } updatePlayerID() { } } const DummyTransport = (opts) => new DummyImpl(opts); /** * Class to manage boardgame.io clients and limit debug panel rendering. */ class ClientManager { constructor() { this.debugPanel = null; this.currentClient = null; this.clients = new Map(); this.subscribers = new Map(); } /** * Register a client with the client manager. */ register(client) { // Add client to clients map. this.clients.set(client, client); // Mount debug for this client (no-op if another debug is already mounted). this.mountDebug(client); this.notifySubscribers(); } /** * Unregister a client from the client manager. */ unregister(client) { // Remove client from clients map. this.clients.delete(client); if (this.currentClient === client) { // If the removed client owned the debug panel, unmount it. this.unmountDebug(); // Mount debug panel for next available client. for (const [client] of this.clients) { if (this.debugPanel) break; this.mountDebug(client); } } this.notifySubscribers(); } /** * Subscribe to the client manager state. * Calls the passed callback each time the current client changes or a client * registers/unregisters. * Returns a function to unsubscribe from the state updates. */ subscribe(callback) { const id = Symbol(); this.subscribers.set(id, callback); callback(this.getState()); return () => { this.subscribers.delete(id); }; } /** * Switch to a client with a matching playerID. */ switchPlayerID(playerID) { // For multiplayer clients, try switching control to a different client // that is using the same transport layer. if (this.currentClient.multiplayer) { for (const [client] of this.clients) { if (client.playerID === playerID && client.debugOpt !== false && client.multiplayer === this.currentClient.multiplayer) { this.switchToClient(client); return; } } } // If no client matches, update the playerID for the current client. this.currentClient.updatePlayerID(playerID); this.notifySubscribers(); } /** * Set the passed client as the active client for debugging. */ switchToClient(client) { if (client === this.currentClient) return; this.unmountDebug(); this.mountDebug(client); this.notifySubscribers(); } /** * Notify all subscribers of changes to the client manager state. */ notifySubscribers() { const arg = this.getState(); this.subscribers.forEach((cb) => { cb(arg); }); } /** * Get the client manager state. */ getState() { return { client: this.currentClient, debuggableClients: this.getDebuggableClients(), }; } /** * Get an array of the registered clients that haven’t disabled the debug panel. */ getDebuggableClients() { return [...this.clients.values()].filter((client) => client.debugOpt !== false); } /** * Mount the debug panel using the passed client. */ mountDebug(client) { if (client.debugOpt === false || this.debugPanel !== null || typeof document === 'undefined') { return; } let DebugImpl; let target = document.body; if (process.env.NODE_ENV !== 'production') { DebugImpl = Debug.Debug; } if (client.debugOpt && client.debugOpt !== true) { DebugImpl = client.debugOpt.impl || DebugImpl; target = client.debugOpt.target || target; } if (DebugImpl) { this.currentClient = client; this.debugPanel = new DebugImpl({ target, props: { clientManager: this }, }); } } /** * Unmount the debug panel. */ unmountDebug() { this.debugPanel.$destroy(); this.debugPanel = null; this.currentClient = 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. */ /** * Global client manager instance that all clients register with. */ const GlobalClientManager = new ClientManager(); /** * Standardise the passed playerID, using currentPlayer if appropriate. */ function assumedPlayerID(playerID, store, multiplayer) { // In singleplayer mode, if the client does not have a playerID // associated with it, we attach the currentPlayer as playerID. if (!multiplayer && (playerID === null || playerID === undefined)) { const state = store.getState(); playerID = state.ctx.currentPlayer; } return playerID; } /** * createDispatchers * * Create action dispatcher wrappers with bound playerID and credentials */ function createDispatchers(storeActionType, innerActionNames, store, playerID, credentials, multiplayer) { const dispatchers = {}; for (const name of innerActionNames) { dispatchers[name] = (...args) => { const action = turnOrder.ActionCreators[storeActionType](name, args, assumedPlayerID(playerID, store, multiplayer), credentials); store.dispatch(action); }; } return dispatchers; } // Creates a set of dispatchers to make moves. const createMoveDispatchers = createDispatchers.bind(null, 'makeMove'); // Creates a set of dispatchers to dispatch game flow events. const createEventDispatchers = createDispatchers.bind(null, 'gameEvent'); // Creates a set of dispatchers to dispatch actions to plugins. const createPluginDispatchers = createDispatchers.bind(null, 'plugin'); /** * Implementation of Client (see below). */ class _ClientImpl { constructor({ game, debug, numPlayers, multiplayer, matchID: matchID, playerID, credentials, enhancer, }) { this.game = reducer.ProcessGameConfig(game); this.playerID = playerID; this.matchID = matchID || 'default'; this.credentials = credentials; this.multiplayer = multiplayer; this.debugOpt = debug; this.manager = GlobalClientManager; this.gameStateOverride = null; this.subscribers = {}; this._running = false; this.reducer = reducer.CreateGameReducer({ game: this.game, isClient: multiplayer !== undefined, }); this.initialState = null; if (!multiplayer) { this.initialState = initialize.InitializeGame({ game: this.game, numPlayers }); } this.reset = () => { this.store.dispatch(turnOrder.reset(this.initialState)); }; this.undo = () => { const undo = turnOrder.undo(assumedPlayerID(this.playerID, this.store, this.multiplayer), this.credentials); this.store.dispatch(undo); }; this.redo = () => { const redo = turnOrder.redo(assumedPlayerID(this.playerID, this.store, this.multiplayer), this.credentials); this.store.dispatch(redo); }; this.log = []; /** * Middleware that manages the log object. * Reducers generate deltalogs, which are log events * that are the result of application of a single action. * The master may also send back a deltalog or the entire * log depending on the type of request. * The middleware below takes care of all these cases while * managing the log object. */ const LogMiddleware = (store) => (next) => (action) => { const result = next(action); const state = store.getState(); switch (action.type) { case turnOrder.MAKE_MOVE: case turnOrder.GAME_EVENT: case turnOrder.UNDO: case turnOrder.REDO: { const deltalog = state.deltalog; this.log = [...this.log, ...deltalog]; break; } case turnOrder.RESET: { this.log = []; break; } case turnOrder.PATCH: case turnOrder.UPDATE: { let id = -1; if (this.log.length > 0) { id = this.log[this.log.length - 1]._stateID; } let deltalog = action.deltalog || []; // Filter out actions that are already present // in the current log. This may occur when the // client adds an entry to the log followed by // the update from the master here. deltalog = deltalog.filter((l) => l._stateID > id); this.log = [...this.log, ...deltalog]; break; } case turnOrder.SYNC: { this.initialState = action.initialState; this.log = action.log || []; break; } } return result; }; /** * Middleware that intercepts actions and sends them to the master, * which keeps the authoritative version of the state. */ const TransportMiddleware = (store) => (next) => (action) => { const baseState = store.getState(); const result = next(action); if (!('clientOnly' in action) && action.type !== turnOrder.STRIP_TRANSIENTS) { this.transport.sendAction(baseState, action); } return result; }; /** * Middleware that intercepts actions and invokes the subscription callback. */ const SubscriptionMiddleware = () => (next) => (action) => { const result = next(action); this.notifySubscribers(); return result; }; const middleware = redux.applyMiddleware(reducer.TransientHandlingMiddleware, SubscriptionMiddleware, TransportMiddleware, LogMiddleware); enhancer = enhancer !== undefined ? redux.compose(middleware, enhancer) : middleware; this.store = redux.createStore(this.reducer, this.initialState, enhancer); if (!multiplayer) multiplayer = DummyTransport; this.transport = multiplayer({ transportDataCallback: (data) => this.receiveTransportData(data), gameKey: game, game: this.game, matchID, playerID, credentials, gameName: this.game.name, numPlayers, }); this.createDispatchers(); this.chatMessages = []; this.sendChatMessage = (payload) => { this.transport.sendChatMessage(this.matchID, { id: nonSecure.nanoid(7), sender: this.playerID, payload: payload, }); }; } /** Handle incoming match data from a multiplayer transport. */ receiveMatchData(matchData) { this.matchData = matchData; this.notifySubscribers(); } /** Handle an incoming chat message from a multiplayer transport. */ receiveChatMessage(message) { this.chatMessages = [...this.chatMessages, message]; this.notifySubscribers(); } /** Handle all incoming updates from a multiplayer transport. */ receiveTransportData(data) { const [matchID] = data.args; if (matchID !== this.matchID) return; switch (data.type) { case 'sync': { const [, syncInfo] = data.args; const action = turnOrder.sync(syncInfo); this.receiveMatchData(syncInfo.filteredMetadata); this.store.dispatch(action); break; } case 'update': { const [, state, deltalog] = data.args; const currentState = this.store.getState(); if (state._stateID >= currentState._stateID) { const action = turnOrder.update(state, deltalog); this.store.dispatch(action); } break; } case 'patch': { const [, prevStateID, stateID, patch, deltalog] = data.args; const currentStateID = this.store.getState()._stateID; if (prevStateID !== currentStateID) break; const action = turnOrder.patch(prevStateID, stateID, patch, deltalog); this.store.dispatch(action); // Emit sync if patch apply failed. if (this.store.getState()._stateID === currentStateID) { this.transport.requestSync(); } break; } case 'matchData': { const [, matchData] = data.args; this.receiveMatchData(matchData); break; } case 'chat': { const [, chatMessage] = data.args; this.receiveChatMessage(chatMessage); break; } } } notifySubscribers() { Object.values(this.subscribers).forEach((fn) => fn(this.getState())); } overrideGameState(state) { this.gameStateOverride = state; this.notifySubscribers(); } start() { this.transport.connect(); this._running = true; this.manager.register(this); } stop() { this.transport.disconnect(); this._running = false; this.manager.unregister(this); } subscribe(fn) { const id = Object.keys(this.subscribers).length; this.subscribers[id] = fn; this.transport.subscribeToConnectionStatus(() => this.notifySubscribers()); if (this._running || !this.multiplayer) { fn(this.getState()); } // Return a handle that allows the caller to unsubscribe. return () => { delete this.subscribers[id]; }; } getInitialState() { return this.initialState; } getState() { let state = this.store.getState(); if (this.gameStateOverride !== null) { state = this.gameStateOverride; } // This is the state before a sync with the game master. if (state === null) { return state; } // isActive. let isActive = true; const isPlayerActive = this.game.flow.isPlayerActive(state.G, state.ctx, this.playerID); if (this.multiplayer && !isPlayerActive) { isActive = false; } if (!this.multiplayer && this.playerID !== null && this.playerID !== undefined && !isPlayerActive) { isActive = false; } if (state.ctx.gameover !== undefined) { isActive = false; } // Secrets are normally stripped on the server, // but we also strip them here so that game developers // can see their effects while prototyping. // Do not strip again if this is a multiplayer game // since the server has already stripped secret info. (issue #818) if (!this.multiplayer) { state = { ...state, G: this.game.playerView({ G: state.G, ctx: state.ctx, playerID: this.playerID, }), plugins: turnOrder.PlayerView(state, this), }; } // Combine into return value. return { ...state, log: this.log, isActive, isConnected: this.transport.isConnected, }; } createDispatchers() { this.moves = createMoveDispatchers(this.game.moveNames, this.store, this.playerID, this.credentials, this.multiplayer); this.events = createEventDispatchers(this.game.flow.enabledEventNames, this.store, this.playerID, this.credentials, this.multiplayer); this.plugins = createPluginDispatchers(this.game.pluginNames, this.store, this.playerID, this.credentials, this.multiplayer); } updatePlayerID(playerID) { this.playerID = playerID; this.createDispatchers(); this.transport.updatePlayerID(playerID); this.notifySubscribers(); } updateMatchID(matchID) { this.matchID = matchID; this.createDispatchers(); this.transport.updateMatchID(matchID); this.notifySubscribers(); } updateCredentials(credentials) { this.credentials = credentials; this.createDispatchers(); this.transport.updateCredentials(credentials); this.notifySubscribers(); } } /** * Client * * boardgame.io JS client. * * @param {...object} game - The return value of `Game`. * @param {...object} numPlayers - The number of players. * @param {...object} multiplayer - Set to a falsy value or a transportFactory, e.g., SocketIO() * @param {...object} matchID - The matchID that you want to connect to. * @param {...object} playerID - The playerID associated with this client. * @param {...string} credentials - The authentication credentials associated with this client. * * Returns: * A JS object that provides an API to interact with the * game by dispatching moves and events. */ function Client(opts) { return new _ClientImpl(opts); } exports.Client = Client;