Repository: boardgameio/boardgame.io (ts)

Issue:
Add playerView option to plugin API
Currently games can define a `playerView` to exclude secret state from the `G` that is returned to clients.

As mentioned in #668, plugins should also be able to hide secret state on the client, for example using a `playerView` option:

```js
const plugin = {
  playerView: ({ G, ctx, game, data, playerID }) => data without secrets,
}
```

We would then have to call the plugins’ `playerView` for each update sent to clients.

Pull request description:
feat(plugins): Add playerView option to plugin API (close #671)
Closes #671

This PR adds a `playerView` option to the plugin API allowing plugins to filter their data before it is returned to clients in the same way a game’s `playerView` option can filter `G`.

It also adds a `playerView` that hides PRNG state from clients to the random plugin and a way for users to set a custom `playerView` for the player plugin.

Patch:
diff --git a/docs/documentation/plugins.md b/docs/documentation/plugins.md
index df2d088..a454452 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 952cfad..8dab885 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<G extends any = any> {
     // 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 18bae0c..4ced0bd 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 cc467bd..048aeab 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.ts b/src/plugins/plugin-player.ts
index e1bf18c..8576897 100644
--- a/src/plugins/plugin-player.ts
+++ b/src/plugins/plugin-player.ts
@@ -24,6 +24,10 @@ export interface PlayerAPI<PlayerState extends any = any> {
 
 interface PluginPlayerOpts<PlayerState extends any = any> {
   setup?: (playerID: string) => PlayerState;
+  playerView?: (
+    players: Record<PlayerID, PlayerState>,
+    playerID?: string | null
+  ) => any;
 }
 
 export interface PlayerPlugin<PlayerState extends any = any> {
@@ -39,6 +43,7 @@ export interface PlayerPlugin<PlayerState extends any = any> {
  */
 const PlayerPlugin = <PlayerState extends any = any>({
   setup,
+  playerView,
 }: PluginPlayerOpts<PlayerState> = {}): Plugin<
   PlayerAPI<PlayerState>,
   PlayerData<PlayerState>
@@ -87,6 +92,9 @@ const PlayerPlugin = <PlayerState extends any = any>({
     }
     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 7888c44..cd8ca90 100644
--- a/src/plugins/plugin-random.ts
+++ b/src/plugins/plugin-random.ts
@@ -37,6 +37,8 @@ const RandomPlugin: Plugin<RandomAPI & PrivateRandomAPI, RandomState> = {
     }
     return { seed };
   },
+
+  playerView: () => undefined,
 };
 
 export default RandomPlugin;
diff --git a/src/types.ts b/src/types.ts
index 4501a59..e8dacf8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -130,6 +130,13 @@ export interface Plugin<
     data: Data;
   }) => State<G, Ctx>;
   fnWrap?: (fn: AnyFn) => (G: G, ctx: Ctx, ...args: any[]) => any;
+  playerView?: (context: {
+    G: G;
+    ctx: Ctx;
+    game: Game<G, Ctx>;
+    data: Data;
+    playerID?: PlayerID | null;
+  }) => any;
 }
 
 type MoveFn<G extends any = any, CtxWithPlugins extends Ctx = Ctx> = (


Test patch:
diff --git a/src/plugins/plugin-player.test.ts b/src/plugins/plugin-player.test.ts
index 2c666eb..2a25ff4 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/random/random.test.ts b/src/plugins/random/random.test.ts
index d555636..9acc134 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();
+});