Skip to content

Commit afee0b7

Browse files
authored
feat: Allow plugins to declare an action invalid (#963)
1 parent b753094 commit afee0b7

7 files changed

Lines changed: 230 additions & 4 deletions

File tree

docs/documentation/plugins.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ A plugin is an object that contains the following fields.
4747
// for the master instead.
4848
noClient: ({ G, ctx, game, data, api }) => boolean,
4949

50+
// Function that allows the plugin to indicate that the
51+
// current action should be declared invalid and cancelled.
52+
// If `isInvalid` returns an error message, the whole update
53+
// will be abandoned and an error returned to the client.
54+
isInvalid: ({ G, ctx, game, data, api }) => false | string,
55+
5056
// Function that can filter `data` to hide secret state
5157
// before sending it to a specific client.
5258
// `playerID` could also be null or undefined for spectators.

src/core/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,6 @@ export enum ActionErrorType {
3030
ActionDisabled = 'action/action_disabled',
3131
// The requested action is not currently possible
3232
ActionInvalid = 'action/action_invalid',
33+
// The requested action was declared invalid by a plugin
34+
PluginActionInvalid = 'action/plugin_invalid',
3335
}

src/core/reducer.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,96 @@ describe('Events API', () => {
299299
});
300300
});
301301

302+
describe('Plugin Invalid Action API', () => {
303+
const pluginName = 'validator';
304+
const message = 'G.value must divide by 5';
305+
const game: Game<{ value: number }> = {
306+
setup: () => ({ value: 5 }),
307+
plugins: [
308+
{
309+
name: pluginName,
310+
isInvalid: ({ G }) => {
311+
if (G.value % 5 !== 0) return message;
312+
return false;
313+
},
314+
},
315+
],
316+
moves: {
317+
setValue: (G, _ctx, arg) => {
318+
G.value = arg;
319+
},
320+
},
321+
phases: {
322+
unenterable: {
323+
onBegin: () => ({ value: 13 }),
324+
},
325+
enterable: {
326+
onBegin: () => ({ value: 25 }),
327+
},
328+
},
329+
};
330+
331+
let state: State;
332+
beforeEach(() => {
333+
state = InitializeGame({ game });
334+
});
335+
336+
describe('multiplayer client', () => {
337+
const reducer = CreateGameReducer({ game });
338+
339+
test('move is cancelled if plugin declares it invalid', () => {
340+
state = reducer(state, makeMove('setValue', [6], '0'));
341+
expect(state.G).toMatchObject({ value: 5 });
342+
expect(state['transients'].error).toEqual({
343+
type: 'action/plugin_invalid',
344+
payload: { plugin: pluginName, message },
345+
});
346+
});
347+
348+
test('move is processed if no plugin declares it invalid', () => {
349+
state = reducer(state, makeMove('setValue', [15], '0'));
350+
expect(state.G).toMatchObject({ value: 15 });
351+
expect(state['transients']).toBeUndefined();
352+
});
353+
354+
test('event is cancelled if plugin declares it invalid', () => {
355+
state = reducer(state, gameEvent('setPhase', 'unenterable', '0'));
356+
expect(state.G).toMatchObject({ value: 5 });
357+
expect(state.ctx.phase).toBe(null);
358+
expect(state['transients'].error).toEqual({
359+
type: 'action/plugin_invalid',
360+
payload: { plugin: pluginName, message },
361+
});
362+
});
363+
364+
test('event is processed if no plugin declares it invalid', () => {
365+
state = reducer(state, gameEvent('setPhase', 'enterable', '0'));
366+
expect(state.G).toMatchObject({ value: 25 });
367+
expect(state.ctx.phase).toBe('enterable');
368+
expect(state['transients']).toBeUndefined();
369+
});
370+
});
371+
372+
describe('local client', () => {
373+
const reducer = CreateGameReducer({ game, isClient: true });
374+
375+
test('move is cancelled if plugin declares it invalid', () => {
376+
state = reducer(state, makeMove('setValue', [6], '0'));
377+
expect(state.G).toMatchObject({ value: 5 });
378+
expect(state['transients'].error).toEqual({
379+
type: 'action/plugin_invalid',
380+
payload: { plugin: pluginName, message },
381+
});
382+
});
383+
384+
test('move is processed if no plugin declares it invalid', () => {
385+
state = reducer(state, makeMove('setValue', [15], '0'));
386+
expect(state.G).toMatchObject({ value: 15 });
387+
expect(state['transients']).toBeUndefined();
388+
});
389+
});
390+
});
391+
302392
describe('Random inside setup()', () => {
303393
const game1: Game = {
304394
seed: 'seed1',

src/core/reducer.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,30 @@ function initializeDeltalog(
128128
};
129129
}
130130

131+
/**
132+
* Update plugin state after move/event & check if plugins consider the action to be valid.
133+
* @param newState Latest version of state in the reducer.
134+
* @param oldState Initial value of state when reducer started its work.
135+
* @param pluginOpts Plugin configuration options.
136+
* @returns Tuple of the new state updated after flushing plugins and the old
137+
* state augmented with an error if a plugin declared the action invalid.
138+
*/
139+
function flushAndValidatePlugins(
140+
newState: State,
141+
oldState: State,
142+
pluginOpts: { game: Game; isClient?: boolean }
143+
): [State, TransientState?] {
144+
newState = plugins.Flush(newState, pluginOpts);
145+
const isInvalid = plugins.IsInvalid(newState, pluginOpts);
146+
if (!isInvalid) return [newState];
147+
const { plugin, message } = isInvalid;
148+
error(`plugin declared action invalid: ${plugin} - ${message}`);
149+
return [
150+
newState,
151+
WithError(oldState, ActionErrorType.PluginActionInvalid, isInvalid),
152+
];
153+
}
154+
131155
/**
132156
* ExtractTransientsFromState
133157
*
@@ -271,7 +295,12 @@ export function CreateGameReducer({
271295
let newState = game.flow.processEvent(state, action);
272296

273297
// Execute plugins.
274-
newState = plugins.Flush(newState, { game, isClient: false });
298+
let stateWithError: TransientState | undefined;
299+
[newState, stateWithError] = flushAndValidatePlugins(newState, state, {
300+
game,
301+
isClient: false,
302+
});
303+
if (stateWithError) return stateWithError;
275304

276305
// Update undo / redo state.
277306
newState = updateUndoRedoState(newState, { game, action });
@@ -280,7 +309,7 @@ export function CreateGameReducer({
280309
}
281310

282311
case Actions.MAKE_MOVE: {
283-
state = { ...state, deltalog: [] };
312+
const oldState = (state = { ...state, deltalog: [] });
284313

285314
// Check whether the move is allowed at this time.
286315
const move: Move = game.flow.getMove(
@@ -348,10 +377,12 @@ export function CreateGameReducer({
348377
// These will be processed on the server, which
349378
// will send back a state update.
350379
if (isClient) {
351-
state = plugins.Flush(state, {
380+
let stateWithError: TransientState | undefined;
381+
[state, stateWithError] = flushAndValidatePlugins(state, oldState, {
352382
game,
353383
isClient: true,
354384
});
385+
if (stateWithError) return stateWithError;
355386
return {
356387
...state,
357388
_stateID: state._stateID + 1,
@@ -363,7 +394,11 @@ export function CreateGameReducer({
363394

364395
// Allow the flow reducer to process any triggers that happen after moves.
365396
state = game.flow.processMove(state, action.payload);
366-
state = plugins.Flush(state, { game });
397+
let stateWithError: TransientState | undefined;
398+
[state, stateWithError] = flushAndValidatePlugins(state, oldState, {
399+
game,
400+
});
401+
if (stateWithError) return stateWithError;
367402

368403
// Update undo / redo state.
369404
state = updateUndoRedoState(state, { game, action });

src/plugins/main.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { Client } from '../client/client';
1010
import { Local } from '../client/transport/local';
11+
import type { Game } from '../types';
1112

1213
describe('basic', () => {
1314
let client: ReturnType<typeof Client>;
@@ -134,6 +135,69 @@ describe('default values', () => {
134135
});
135136
});
136137

138+
describe('isInvalid method', () => {
139+
// Silence expected error logging and restore when finished.
140+
const stderr = console.error;
141+
beforeAll(() => (console.error = () => {}));
142+
afterAll(() => (console.error = stderr));
143+
144+
test('basic plugin', () => {
145+
const goodG = { good: 'nice' };
146+
const game: Game = {
147+
plugins: [
148+
{
149+
name: 'test',
150+
isInvalid: ({ G }) => 'bad' in G && 'not ok',
151+
},
152+
],
153+
moves: {
154+
good: () => goodG,
155+
bad: () => ({ bad: 'not ok' }),
156+
},
157+
};
158+
159+
const client = Client({ game, playerID: '0' });
160+
client.start();
161+
client.moves.good();
162+
expect(client.getState().G).toEqual(goodG);
163+
client.moves.bad();
164+
expect(client.getState().G).toEqual(goodG);
165+
});
166+
167+
test('plugin with API and data', () => {
168+
const game: Game<any, any> = {
169+
plugins: [
170+
{
171+
name: 'test',
172+
setup: () => ({}),
173+
api: ({ data }) => ({
174+
set: (key, val) => {
175+
data[key] = val;
176+
},
177+
}),
178+
isInvalid: ({ data }) => 'bad' in data && 'not ok',
179+
},
180+
],
181+
moves: {
182+
good: (_, ctx) => {
183+
ctx.test.set('good', 'nice');
184+
},
185+
bad: (_, ctx) => {
186+
ctx.test.set('bad', 'not ok');
187+
},
188+
},
189+
};
190+
191+
const client = Client({ game, playerID: '0' });
192+
client.start();
193+
expect(client.getState().ctx.numMoves).toBe(0);
194+
client.moves.good();
195+
expect(client.getState().ctx.numMoves).toBe(1);
196+
client.moves.bad();
197+
expect(client.getState().ctx.numMoves).toBe(1);
198+
});
199+
});
200+
137201
describe('actions', () => {
138202
let client;
139203

src/plugins/main.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,34 @@ export const NoClient = (state: State, opts: PluginOpts): boolean => {
243243
.some((value) => value === true);
244244
};
245245

246+
/**
247+
* Allows plugins to indicate if the entire action should be thrown out
248+
* as invalid. This will cancel the entire state update.
249+
*/
250+
export const IsInvalid = (
251+
state: State,
252+
opts: PluginOpts
253+
): false | { plugin: string; message: string } => {
254+
const firstInvalidReturn = [...DEFAULT_PLUGINS, ...opts.game.plugins]
255+
.filter((plugin) => plugin.isInvalid !== undefined)
256+
.map((plugin) => {
257+
const { name } = plugin;
258+
const pluginState = state.plugins[name];
259+
260+
const message = plugin.isInvalid({
261+
G: state.G,
262+
ctx: state.ctx,
263+
game: opts.game,
264+
api: pluginState && pluginState.api,
265+
data: pluginState && pluginState.data,
266+
});
267+
268+
return message ? { plugin: name, message } : false;
269+
})
270+
.find((value) => value);
271+
return firstInvalidReturn || false;
272+
};
273+
246274
/**
247275
* Allows plugins to customize their data for specific players.
248276
* For example, a plugin may want to share no data with the client, or

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export interface Plugin<
144144
> {
145145
name: string;
146146
noClient?: (context: PluginContext<API, Data, G>) => boolean;
147+
isInvalid?: (context: PluginContext<API, Data, G>) => false | string;
147148
setup?: (setupCtx: { G: G; ctx: Ctx; game: Game<G, Ctx> }) => Data;
148149
action?: (data: Data, payload: ActionShape.Plugin['payload']) => Data;
149150
api?: (context: {

0 commit comments

Comments
 (0)