Skip to content

Commit

Permalink
feat: Allow plugins to declare an action invalid (#963)
Browse files Browse the repository at this point in the history
  • Loading branch information
delucis authored Jul 21, 2021
1 parent b753094 commit afee0b7
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 4 deletions.
6 changes: 6 additions & 0 deletions docs/documentation/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
90 changes: 90 additions & 0 deletions src/core/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
43 changes: 39 additions & 4 deletions src/core/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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 });
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
64 changes: 64 additions & 0 deletions src/plugins/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Client>;
Expand Down Expand Up @@ -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<any, any> = {
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;

Expand Down
28 changes: 28 additions & 0 deletions src/plugins/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export interface Plugin<
> {
name: string;
noClient?: (context: PluginContext<API, Data, G>) => boolean;
isInvalid?: (context: PluginContext<API, Data, G>) => false | string;
setup?: (setupCtx: { G: G; ctx: Ctx; game: Game<G, Ctx> }) => Data;
action?: (data: Data, payload: ActionShape.Plugin['payload']) => Data;
api?: (context: {
Expand Down

0 comments on commit afee0b7

Please sign in to comment.