UNPKG

boardgame.io

Version:
589 lines (525 loc) 16.2 kB
/* * 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 { nanoid } from 'nanoid/non-secure'; import 'svelte'; import type { Dispatch, StoreEnhancer } from 'redux'; import { createStore, compose, applyMiddleware } from 'redux'; import * as Actions from '../core/action-types'; import * as ActionCreators from '../core/action-creators'; import { ProcessGameConfig } from '../core/game'; import type Debug from './debug/Debug.svelte'; import { CreateGameReducer, TransientHandlingMiddleware, } from '../core/reducer'; import { InitializeGame } from '../core/initialize'; import { PlayerView } from '../plugins/main'; import type { Transport, TransportOpts } from './transport/transport'; import { DummyTransport } from './transport/dummy'; import { ClientManager } from './manager'; import type { TransportData } from '../master/master'; import type { ActivePlayersArg, ActionShape, CredentialedActionShape, FilteredMetadata, Game, LogEntry, PlayerID, Reducer, State, Store, ChatMessage, } from '../types'; type ClientAction = | ActionShape.Reset | ActionShape.Sync | ActionShape.Update | ActionShape.Patch; type Action = | CredentialedActionShape.Any | ActionShape.StripTransients | ClientAction; export interface DebugOpt { target?: HTMLElement; impl?: typeof Debug; collapseOnLoad?: boolean; hideToggleButton?: boolean; } /** * Global client manager instance that all clients register with. */ const GlobalClientManager = new ClientManager(); /** * Standardise the passed playerID, using currentPlayer if appropriate. */ function assumedPlayerID( playerID: PlayerID | null | undefined, store: Store, multiplayer?: unknown ): PlayerID { // 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: 'makeMove' | 'gameEvent' | 'plugin', innerActionNames: string[], store: Store, playerID: PlayerID, credentials: string, multiplayer?: unknown ) { const dispatchers: Record<string, (...args: any[]) => void> = {}; for (const name of innerActionNames) { dispatchers[name] = (...args) => { const action = ActionCreators[storeActionType]( name, args, assumedPlayerID(playerID, store, multiplayer), credentials ); store.dispatch(action); }; } return dispatchers; } // Creates a set of dispatchers to make moves. export const createMoveDispatchers = createDispatchers.bind(null, 'makeMove'); // Creates a set of dispatchers to dispatch game flow events. export const createEventDispatchers = createDispatchers.bind(null, 'gameEvent'); // Creates a set of dispatchers to dispatch actions to plugins. export const createPluginDispatchers = createDispatchers.bind(null, 'plugin'); export interface ClientOpts< G extends any = any, PluginAPIs extends Record<string, unknown> = Record<string, unknown> > { game: Game<G, PluginAPIs>; debug?: DebugOpt | boolean; numPlayers?: number; multiplayer?: (opts: TransportOpts) => Transport; matchID?: string; playerID?: PlayerID; credentials?: string; enhancer?: StoreEnhancer; } export type ClientState<G extends any = any> = | null | (State<G> & { isActive: boolean; isConnected: boolean; log: LogEntry[]; }); /** * Implementation of Client (see below). */ export class _ClientImpl< G extends any = any, PluginAPIs extends Record<string, unknown> = Record<string, unknown> > { private gameStateOverride?: any; private initialState: State<G>; readonly multiplayer: (opts: TransportOpts) => Transport; private reducer: Reducer; private _running: boolean; private subscribers: Record<string, (state: State<G> | null) => void>; private transport: Transport; private manager: ClientManager; readonly debugOpt?: DebugOpt | boolean; readonly game: ReturnType<typeof ProcessGameConfig>; readonly store: Store; log: State['deltalog']; matchID: string; playerID: PlayerID | null; credentials: string; matchData?: FilteredMetadata; moves: Record<string, (...args: any[]) => void>; events: { endGame?: (gameover?: any) => void; endPhase?: () => void; endTurn?: (arg?: { next: PlayerID }) => void; setPhase?: (newPhase: string) => void; endStage?: () => void; setStage?: (newStage: string) => void; setActivePlayers?: (arg: ActivePlayersArg) => void; }; plugins: Record<string, (...args: any[]) => void>; reset: () => void; undo: () => void; redo: () => void; sendChatMessage: (message: any) => void; chatMessages: ChatMessage[]; constructor({ game, debug, numPlayers, multiplayer, matchID: matchID, playerID, credentials, enhancer, }: ClientOpts<G, PluginAPIs>) { this.game = 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 = CreateGameReducer({ game: this.game, isClient: multiplayer !== undefined, }); this.initialState = null; if (!multiplayer) { this.initialState = InitializeGame({ game: this.game, numPlayers }); } this.reset = () => { this.store.dispatch(ActionCreators.reset(this.initialState)); }; this.undo = () => { const undo = ActionCreators.undo( assumedPlayerID(this.playerID, this.store, this.multiplayer), this.credentials ); this.store.dispatch(undo); }; this.redo = () => { const redo = ActionCreators.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: Store) => (next: Dispatch<Action>) => (action: Action) => { const result = next(action); const state = store.getState(); switch (action.type) { case Actions.MAKE_MOVE: case Actions.GAME_EVENT: case Actions.UNDO: case Actions.REDO: { const deltalog = state.deltalog; this.log = [...this.log, ...deltalog]; break; } case Actions.RESET: { this.log = []; break; } case Actions.PATCH: case Actions.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 Actions.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: Store) => (next: Dispatch<Action>) => (action: Action) => { const baseState = store.getState(); const result = next(action); if ( !('clientOnly' in action) && action.type !== Actions.STRIP_TRANSIENTS ) { this.transport.sendAction(baseState, action); } return result; }; /** * Middleware that intercepts actions and invokes the subscription callback. */ const SubscriptionMiddleware = () => (next: Dispatch<Action>) => (action: Action) => { const result = next(action); this.notifySubscribers(); return result; }; const middleware = applyMiddleware( TransientHandlingMiddleware, SubscriptionMiddleware, TransportMiddleware, LogMiddleware ); enhancer = enhancer !== undefined ? compose(middleware, enhancer) : middleware; this.store = 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: nanoid(7), sender: this.playerID, payload: payload, }); }; } /** Handle incoming match data from a multiplayer transport. */ private receiveMatchData(matchData: FilteredMetadata): void { this.matchData = matchData; this.notifySubscribers(); } /** Handle an incoming chat message from a multiplayer transport. */ private receiveChatMessage(message: ChatMessage): void { this.chatMessages = [...this.chatMessages, message]; this.notifySubscribers(); } /** Handle all incoming updates from a multiplayer transport. */ private receiveTransportData(data: TransportData): void { const [matchID] = data.args; if (matchID !== this.matchID) return; switch (data.type) { case 'sync': { const [, syncInfo] = data.args; const action = ActionCreators.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 = ActionCreators.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 = ActionCreators.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; } } } private notifySubscribers() { Object.values(this.subscribers).forEach((fn) => fn(this.getState())); } overrideGameState(state: any) { 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: (state: ClientState<G>) => void) { 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(): ClientState<G> { 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 as null; } // 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: PlayerView(state, this), }; } // Combine into return value. return { ...state, log: this.log, isActive, isConnected: this.transport.isConnected, }; } private 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: PlayerID | null) { this.playerID = playerID; this.createDispatchers(); this.transport.updatePlayerID(playerID); this.notifySubscribers(); } updateMatchID(matchID: string) { this.matchID = matchID; this.createDispatchers(); this.transport.updateMatchID(matchID); this.notifySubscribers(); } updateCredentials(credentials: string) { 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. */ export function Client< G extends any = any, PluginAPIs extends Record<string, unknown> = Record<string, unknown> >(opts: ClientOpts<G, PluginAPIs>) { return new _ClientImpl<G, PluginAPIs>(opts); }