From 8f8d30e0e13b343763dc89e9bf1f452e1973bfe2 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Sat, 5 Dec 2020 21:35:51 +0100 Subject: [PATCH] feat(plugins): Add playerView option to plugin API (close #671) (#857) * feat(plugins): Add playerView option to plugin API, mirroring game API Closes #671 * feat(plugins): Hide PRNG state from clients This used to be the case before the new plugin API was implemented. PRNG state should be hidden for security. * feat(plugins): Add playerView option to player plugin * docs(plugins): Document playerView option --- docs/documentation/plugins.md | 15 ++++++++++++++- src/client/client.ts | 12 ++++++++---- src/master/master.ts | 3 +++ src/plugins/main.ts | 24 ++++++++++++++++++++++++ src/plugins/plugin-player.test.ts | 26 ++++++++++++++++++++++++++ src/plugins/plugin-player.ts | 8 ++++++++ src/plugins/plugin-random.ts | 2 ++ src/plugins/random/random.test.ts | 6 ++++++ src/types.ts | 7 +++++++ 9 files changed, 98 insertions(+), 5 deletions(-) diff --git a/docs/documentation/plugins.md b/docs/documentation/plugins.md index df2d08888..a45445277 100644 --- a/docs/documentation/plugins.md +++ b/docs/documentation/plugins.md @@ -46,6 +46,11 @@ A plugin is an object that contains the following fields. // the client will discard the state update and wait // for the master instead. noClient: ({ G, ctx, game, data, api }) => boolean, + + // Function that can filter `data` to hide secret state + // before sending it to a specific client. + // `playerID` could also be null or undefined for spectators. + playerView: ({ G, ctx, game, data, playerID }) => filtered data object, } ``` @@ -97,10 +102,18 @@ import { PluginPlayer } from 'boardgame.io/plugins'; // define a function to initialize each player’s state const playerSetup = (playerID) => ({ ... }); +// filter data returned to each client to hide secret state (OPTIONAL) +const playerView = (players, playerID) => ({ + [playerID]: players[playerID], +}); + const game = { plugins: [ // pass your function to the player plugin - PluginPlayer({ setup: playerSetup }), + PluginPlayer({ + setup: playerSetup, + playerView: playerView, + }), ], }; ``` diff --git a/src/client/client.ts b/src/client/client.ts index 952cfadfa..8dab885d2 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -20,6 +20,7 @@ import { ProcessGameConfig } from '../core/game'; import Debug from './debug/Debug.svelte'; import { CreateGameReducer } from '../core/reducer'; import { InitializeGame } from '../core/initialize'; +import { PlayerView } from '../plugins/main'; import { Transport, TransportOpts } from './transport/transport'; import { ClientManager } from './manager'; import { @@ -432,14 +433,17 @@ export class _ClientImpl { // 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) - const G = this.multiplayer - ? state.G - : this.game.playerView(state.G, state.ctx, this.playerID); + if (!this.multiplayer) { + state = { + ...state, + G: this.game.playerView(state.G, state.ctx, this.playerID), + plugins: PlayerView(state, this), + }; + } // Combine into return value. return { ...state, - G, log: this.log, isActive, isConnected: this.transport.isConnected, diff --git a/src/master/master.ts b/src/master/master.ts index 18bae0c21..4ced0bda6 100644 --- a/src/master/master.ts +++ b/src/master/master.ts @@ -12,6 +12,7 @@ import { ProcessGameConfig, IsLongFormMove } from '../core/game'; import { UNDO, REDO, MAKE_MOVE } from '../core/action-types'; import { createStore } from 'redux'; import * as logging from '../core/logger'; +import { PlayerView } from '../plugins/main'; import { SyncInfo, FilteredMetadata, @@ -325,6 +326,7 @@ export class Master { const filteredState = { ...state, G: this.game.playerView(state.G, state.ctx, playerID), + plugins: PlayerView(state, { playerID, game: this.game }), deltalog: undefined, _undo: [], _redo: [], @@ -413,6 +415,7 @@ export class Master { const filteredState = { ...state, G: this.game.playerView(state.G, state.ctx, playerID), + plugins: PlayerView(state, { playerID, game: this.game }), deltalog: undefined, _undo: [], _redo: [], diff --git a/src/plugins/main.ts b/src/plugins/main.ts index cc467bd8f..048aeab4a 100644 --- a/src/plugins/main.ts +++ b/src/plugins/main.ts @@ -235,3 +235,27 @@ export const NoClient = (state: State, opts: PluginOpts): boolean => { }) .some(value => value === true); }; + +/** + * Allows plugins to customize their data for specific players. + * For example, a plugin may want to share no data with the client, or + * want to keep some player data secret from opponents. + */ +export const PlayerView = ( + { G, ctx, plugins = {} }: State, + { game, playerID }: PluginOpts & { playerID: PlayerID } +) => { + [...DEFAULT_PLUGINS, ...game.plugins].forEach(({ name, playerView }) => { + if (!playerView) return; + + const { data } = plugins[name] || { data: {} }; + const newData = playerView({ G, ctx, game, data, playerID }); + + plugins = { + ...plugins, + [name]: { data: newData }, + }; + }); + + return plugins; +}; diff --git a/src/plugins/plugin-player.test.ts b/src/plugins/plugin-player.test.ts index 2c666eb7d..2a25ff4f0 100644 --- a/src/plugins/plugin-player.test.ts +++ b/src/plugins/plugin-player.test.ts @@ -142,3 +142,29 @@ describe('game with phases', () => { }); }); }); + +describe('with playerView', () => { + const plugin = PluginPlayer({ + setup: id => ({ id }), + playerView: (players, playerID) => ({ + [playerID]: players[playerID], + }), + }); + const game = { + plugins: [plugin], + }; + + test('spectator doesn’t see player state', () => { + const spectator = Client({ game }); + expect(spectator.getState().plugins[plugin.name].data).toEqual({ + players: {}, + }); + }); + + test('player only sees own state', () => { + const client = Client({ game, playerID: '0' }); + expect(client.getState().plugins[plugin.name].data).toEqual({ + players: { '0': { id: '0' } }, + }); + }); +}); diff --git a/src/plugins/plugin-player.ts b/src/plugins/plugin-player.ts index e1bf18ca8..8576897d4 100644 --- a/src/plugins/plugin-player.ts +++ b/src/plugins/plugin-player.ts @@ -24,6 +24,10 @@ export interface PlayerAPI { interface PluginPlayerOpts { setup?: (playerID: string) => PlayerState; + playerView?: ( + players: Record, + playerID?: string | null + ) => any; } export interface PlayerPlugin { @@ -39,6 +43,7 @@ export interface PlayerPlugin { */ const PlayerPlugin = ({ setup, + playerView, }: PluginPlayerOpts = {}): Plugin< PlayerAPI, PlayerData @@ -87,6 +92,9 @@ const PlayerPlugin = ({ } return { players }; }, + + playerView: ({ data, playerID }) => + playerView ? { players: playerView(data.players, playerID) } : data, }); export default PlayerPlugin; diff --git a/src/plugins/plugin-random.ts b/src/plugins/plugin-random.ts index 7888c44f6..cd8ca9084 100644 --- a/src/plugins/plugin-random.ts +++ b/src/plugins/plugin-random.ts @@ -37,6 +37,8 @@ const RandomPlugin: Plugin = { } return { seed }; }, + + playerView: () => undefined, }; export default RandomPlugin; diff --git a/src/plugins/random/random.test.ts b/src/plugins/random/random.test.ts index d555636a8..9acc1348b 100644 --- a/src/plugins/random/random.test.ts +++ b/src/plugins/random/random.test.ts @@ -10,6 +10,7 @@ import { Random } from './random'; import { makeMove } from '../../core/action-creators'; import { CreateGameReducer } from '../../core/reducer'; import { InitializeGame } from '../../core/initialize'; +import { Client } from '../../client/client'; function Init(seed) { return new Random({ seed }); @@ -139,3 +140,8 @@ test('turn.onBegin has ctx APIs at the beginning of the game', () => { expect(random).not.toBe(null); expect(events).not.toBe(null); }); + +test('PRNG state is not sent to the client', () => { + const client = Client({ game: {} }); + expect(client.getState().plugins.random.data).toBeUndefined(); +}); diff --git a/src/types.ts b/src/types.ts index 4501a592c..e8dacf805 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,13 @@ export interface Plugin< data: Data; }) => State; fnWrap?: (fn: AnyFn) => (G: G, ctx: Ctx, ...args: any[]) => any; + playerView?: (context: { + G: G; + ctx: Ctx; + game: Game; + data: Data; + playerID?: PlayerID | null; + }) => any; } type MoveFn = (