Skip to content

Commit

Permalink
feat(plugins): Add playerView option to plugin API (close #671) (#857)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
delucis authored Dec 5, 2020
1 parent e18b146 commit 8f8d30e
Show file tree
Hide file tree
Showing 9 changed files with 98 additions and 5 deletions.
15 changes: 14 additions & 1 deletion docs/documentation/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
```

Expand Down Expand Up @@ -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,
}),
],
};
```
Expand Down
12 changes: 8 additions & 4 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/master/master.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand Down
24 changes: 24 additions & 0 deletions src/plugins/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
26 changes: 26 additions & 0 deletions src/plugins/plugin-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
});
});
});
8 changes: 8 additions & 0 deletions src/plugins/plugin-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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>
Expand Down Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/plugins/plugin-random.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const RandomPlugin: Plugin<RandomAPI & PrivateRandomAPI, RandomState> = {
}
return { seed };
},

playerView: () => undefined,
};

export default RandomPlugin;
6 changes: 6 additions & 0 deletions src/plugins/random/random.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
});
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = (
Expand Down

0 comments on commit 8f8d30e

Please sign in to comment.