From afee0b7b1f0d878118c960637fa0e1d90b848ce9 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Wed, 21 Jul 2021 09:39:55 +0200 Subject: [PATCH] feat: Allow plugins to declare an action invalid (#963) --- docs/documentation/plugins.md | 6 +++ src/core/errors.ts | 2 + src/core/reducer.test.ts | 90 +++++++++++++++++++++++++++++++++++ src/core/reducer.ts | 43 +++++++++++++++-- src/plugins/main.test.ts | 64 +++++++++++++++++++++++++ src/plugins/main.ts | 28 +++++++++++ src/types.ts | 1 + 7 files changed, 230 insertions(+), 4 deletions(-) diff --git a/docs/documentation/plugins.md b/docs/documentation/plugins.md index a45445277..3d03acdb2 100644 --- a/docs/documentation/plugins.md +++ b/docs/documentation/plugins.md @@ -47,6 +47,12 @@ A plugin is an object that contains the following fields. // for the master instead. noClient: ({ G, ctx, game, data, api }) => boolean, + // Function that allows the plugin to indicate that the + // current action should be declared invalid and cancelled. + // If `isInvalid` returns an error message, the whole update + // will be abandoned and an error returned to the client. + isInvalid: ({ G, ctx, game, data, api }) => false | string, + // 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. diff --git a/src/core/errors.ts b/src/core/errors.ts index 267c8621d..ad2fbb900 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -30,4 +30,6 @@ export enum ActionErrorType { ActionDisabled = 'action/action_disabled', // The requested action is not currently possible ActionInvalid = 'action/action_invalid', + // The requested action was declared invalid by a plugin + PluginActionInvalid = 'action/plugin_invalid', } diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index 0d2c83917..f6ff82ea4 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -299,6 +299,96 @@ describe('Events API', () => { }); }); +describe('Plugin Invalid Action API', () => { + const pluginName = 'validator'; + const message = 'G.value must divide by 5'; + const game: Game<{ value: number }> = { + setup: () => ({ value: 5 }), + plugins: [ + { + name: pluginName, + isInvalid: ({ G }) => { + if (G.value % 5 !== 0) return message; + return false; + }, + }, + ], + moves: { + setValue: (G, _ctx, arg) => { + G.value = arg; + }, + }, + phases: { + unenterable: { + onBegin: () => ({ value: 13 }), + }, + enterable: { + onBegin: () => ({ value: 25 }), + }, + }, + }; + + let state: State; + beforeEach(() => { + state = InitializeGame({ game }); + }); + + describe('multiplayer client', () => { + const reducer = CreateGameReducer({ game }); + + test('move is cancelled if plugin declares it invalid', () => { + state = reducer(state, makeMove('setValue', [6], '0')); + expect(state.G).toMatchObject({ value: 5 }); + expect(state['transients'].error).toEqual({ + type: 'action/plugin_invalid', + payload: { plugin: pluginName, message }, + }); + }); + + test('move is processed if no plugin declares it invalid', () => { + state = reducer(state, makeMove('setValue', [15], '0')); + expect(state.G).toMatchObject({ value: 15 }); + expect(state['transients']).toBeUndefined(); + }); + + test('event is cancelled if plugin declares it invalid', () => { + state = reducer(state, gameEvent('setPhase', 'unenterable', '0')); + expect(state.G).toMatchObject({ value: 5 }); + expect(state.ctx.phase).toBe(null); + expect(state['transients'].error).toEqual({ + type: 'action/plugin_invalid', + payload: { plugin: pluginName, message }, + }); + }); + + test('event is processed if no plugin declares it invalid', () => { + state = reducer(state, gameEvent('setPhase', 'enterable', '0')); + expect(state.G).toMatchObject({ value: 25 }); + expect(state.ctx.phase).toBe('enterable'); + expect(state['transients']).toBeUndefined(); + }); + }); + + describe('local client', () => { + const reducer = CreateGameReducer({ game, isClient: true }); + + test('move is cancelled if plugin declares it invalid', () => { + state = reducer(state, makeMove('setValue', [6], '0')); + expect(state.G).toMatchObject({ value: 5 }); + expect(state['transients'].error).toEqual({ + type: 'action/plugin_invalid', + payload: { plugin: pluginName, message }, + }); + }); + + test('move is processed if no plugin declares it invalid', () => { + state = reducer(state, makeMove('setValue', [15], '0')); + expect(state.G).toMatchObject({ value: 15 }); + expect(state['transients']).toBeUndefined(); + }); + }); +}); + describe('Random inside setup()', () => { const game1: Game = { seed: 'seed1', diff --git a/src/core/reducer.ts b/src/core/reducer.ts index beba12b13..4f5141703 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -128,6 +128,30 @@ function initializeDeltalog( }; } +/** + * Update plugin state after move/event & check if plugins consider the action to be valid. + * @param newState Latest version of state in the reducer. + * @param oldState Initial value of state when reducer started its work. + * @param pluginOpts Plugin configuration options. + * @returns Tuple of the new state updated after flushing plugins and the old + * state augmented with an error if a plugin declared the action invalid. + */ +function flushAndValidatePlugins( + newState: State, + oldState: State, + pluginOpts: { game: Game; isClient?: boolean } +): [State, TransientState?] { + newState = plugins.Flush(newState, pluginOpts); + const isInvalid = plugins.IsInvalid(newState, pluginOpts); + if (!isInvalid) return [newState]; + const { plugin, message } = isInvalid; + error(`plugin declared action invalid: ${plugin} - ${message}`); + return [ + newState, + WithError(oldState, ActionErrorType.PluginActionInvalid, isInvalid), + ]; +} + /** * ExtractTransientsFromState * @@ -271,7 +295,12 @@ export function CreateGameReducer({ let newState = game.flow.processEvent(state, action); // Execute plugins. - newState = plugins.Flush(newState, { game, isClient: false }); + let stateWithError: TransientState | undefined; + [newState, stateWithError] = flushAndValidatePlugins(newState, state, { + game, + isClient: false, + }); + if (stateWithError) return stateWithError; // Update undo / redo state. newState = updateUndoRedoState(newState, { game, action }); @@ -280,7 +309,7 @@ export function CreateGameReducer({ } case Actions.MAKE_MOVE: { - state = { ...state, deltalog: [] }; + const oldState = (state = { ...state, deltalog: [] }); // Check whether the move is allowed at this time. const move: Move = game.flow.getMove( @@ -348,10 +377,12 @@ export function CreateGameReducer({ // These will be processed on the server, which // will send back a state update. if (isClient) { - state = plugins.Flush(state, { + let stateWithError: TransientState | undefined; + [state, stateWithError] = flushAndValidatePlugins(state, oldState, { game, isClient: true, }); + if (stateWithError) return stateWithError; return { ...state, _stateID: state._stateID + 1, @@ -363,7 +394,11 @@ export function CreateGameReducer({ // Allow the flow reducer to process any triggers that happen after moves. state = game.flow.processMove(state, action.payload); - state = plugins.Flush(state, { game }); + let stateWithError: TransientState | undefined; + [state, stateWithError] = flushAndValidatePlugins(state, oldState, { + game, + }); + if (stateWithError) return stateWithError; // Update undo / redo state. state = updateUndoRedoState(state, { game, action }); diff --git a/src/plugins/main.test.ts b/src/plugins/main.test.ts index 94a706278..bf3ca9fa9 100644 --- a/src/plugins/main.test.ts +++ b/src/plugins/main.test.ts @@ -8,6 +8,7 @@ import { Client } from '../client/client'; import { Local } from '../client/transport/local'; +import type { Game } from '../types'; describe('basic', () => { let client: ReturnType; @@ -134,6 +135,69 @@ describe('default values', () => { }); }); +describe('isInvalid method', () => { + // Silence expected error logging and restore when finished. + const stderr = console.error; + beforeAll(() => (console.error = () => {})); + afterAll(() => (console.error = stderr)); + + test('basic plugin', () => { + const goodG = { good: 'nice' }; + const game: Game = { + plugins: [ + { + name: 'test', + isInvalid: ({ G }) => 'bad' in G && 'not ok', + }, + ], + moves: { + good: () => goodG, + bad: () => ({ bad: 'not ok' }), + }, + }; + + const client = Client({ game, playerID: '0' }); + client.start(); + client.moves.good(); + expect(client.getState().G).toEqual(goodG); + client.moves.bad(); + expect(client.getState().G).toEqual(goodG); + }); + + test('plugin with API and data', () => { + const game: Game = { + plugins: [ + { + name: 'test', + setup: () => ({}), + api: ({ data }) => ({ + set: (key, val) => { + data[key] = val; + }, + }), + isInvalid: ({ data }) => 'bad' in data && 'not ok', + }, + ], + moves: { + good: (_, ctx) => { + ctx.test.set('good', 'nice'); + }, + bad: (_, ctx) => { + ctx.test.set('bad', 'not ok'); + }, + }, + }; + + const client = Client({ game, playerID: '0' }); + client.start(); + expect(client.getState().ctx.numMoves).toBe(0); + client.moves.good(); + expect(client.getState().ctx.numMoves).toBe(1); + client.moves.bad(); + expect(client.getState().ctx.numMoves).toBe(1); + }); +}); + describe('actions', () => { let client; diff --git a/src/plugins/main.ts b/src/plugins/main.ts index 23bcc967f..420eab9bc 100644 --- a/src/plugins/main.ts +++ b/src/plugins/main.ts @@ -243,6 +243,34 @@ export const NoClient = (state: State, opts: PluginOpts): boolean => { .some((value) => value === true); }; +/** + * Allows plugins to indicate if the entire action should be thrown out + * as invalid. This will cancel the entire state update. + */ +export const IsInvalid = ( + state: State, + opts: PluginOpts +): false | { plugin: string; message: string } => { + const firstInvalidReturn = [...DEFAULT_PLUGINS, ...opts.game.plugins] + .filter((plugin) => plugin.isInvalid !== undefined) + .map((plugin) => { + const { name } = plugin; + const pluginState = state.plugins[name]; + + const message = plugin.isInvalid({ + G: state.G, + ctx: state.ctx, + game: opts.game, + api: pluginState && pluginState.api, + data: pluginState && pluginState.data, + }); + + return message ? { plugin: name, message } : false; + }) + .find((value) => value); + return firstInvalidReturn || false; +}; + /** * Allows plugins to customize their data for specific players. * For example, a plugin may want to share no data with the client, or diff --git a/src/types.ts b/src/types.ts index 85257e213..2efb55dd8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,6 +144,7 @@ export interface Plugin< > { name: string; noClient?: (context: PluginContext) => boolean; + isInvalid?: (context: PluginContext) => false | string; setup?: (setupCtx: { G: G; ctx: Ctx; game: Game }) => Data; action?: (data: Data, payload: ActionShape.Plugin['payload']) => Data; api?: (context: {