boardgame.io
Version:
library for turn-based games
315 lines (286 loc) • 8.51 kB
text/typescript
/*
* Copyright 2018 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
import PluginImmer from './plugin-immer';
import PluginRandom from './plugin-random';
import PluginEvents from './plugin-events';
import PluginLog from './plugin-log';
import PluginSerializable from './plugin-serializable';
import type {
AnyFn,
DefaultPluginAPIs,
PartialGameState,
State,
Game,
Plugin,
ActionShape,
PlayerID,
} from '../types';
import { error } from '../core/logger';
import type { GameMethod } from '../core/game-methods';
interface PluginOpts {
game: Game;
isClient?: boolean;
}
/**
* List of plugins that are always added.
*/
const CORE_PLUGINS = [PluginImmer, PluginRandom, PluginLog, PluginSerializable];
const DEFAULT_PLUGINS = [...CORE_PLUGINS, PluginEvents];
/**
* Allow plugins to intercept actions and process them.
*/
export const ProcessAction = (
state: State,
action: ActionShape.Plugin,
opts: PluginOpts
): State => {
// TODO(#723): Extend error handling to plugins.
opts.game.plugins
.filter((plugin) => plugin.action !== undefined)
.filter((plugin) => plugin.name === action.payload.type)
.forEach((plugin) => {
const name = plugin.name;
const pluginState = state.plugins[name] || { data: {} };
const data = plugin.action(pluginState.data, action.payload);
state = {
...state,
plugins: {
...state.plugins,
[name]: { ...pluginState, data },
},
};
});
return state;
};
/**
* The APIs created by various plugins are stored in the plugins
* section of the state object:
*
* {
* G: {},
* ctx: {},
* plugins: {
* plugin-a: {
* data: {}, // this is generated by the plugin at Setup / Flush.
* api: {}, // this is ephemeral and generated by Enhance.
* }
* }
* }
*
* This function retrieves plugin APIs and returns them as an object
* for consumption as used by move contexts.
*/
export const GetAPIs = ({ plugins }: PartialGameState) =>
Object.entries(plugins || {}).reduce((apis, [name, { api }]) => {
apis[name] = api;
return apis;
}, {} as DefaultPluginAPIs);
/**
* Applies the provided plugins to the given move / flow function.
*
* @param methodToWrap - The move function or hook to apply the plugins to.
* @param methodType - The type of the move or hook being wrapped.
* @param plugins - The list of plugins.
*/
export const FnWrap = (
methodToWrap: AnyFn,
methodType: GameMethod,
plugins: Plugin[]
) => {
return [...CORE_PLUGINS, ...plugins, PluginEvents]
.filter((plugin) => plugin.fnWrap !== undefined)
.reduce(
(method: AnyFn, { fnWrap }: Plugin) => fnWrap(method, methodType),
methodToWrap
);
};
/**
* Allows the plugin to generate its initial state.
*/
export const Setup = (
state: PartialGameState,
opts: PluginOpts
): PartialGameState => {
[...DEFAULT_PLUGINS, ...opts.game.plugins]
.filter((plugin) => plugin.setup !== undefined)
.forEach((plugin) => {
const name = plugin.name;
const data = plugin.setup({
G: state.G,
ctx: state.ctx,
game: opts.game,
});
state = {
...state,
plugins: {
...state.plugins,
[name]: { data },
},
};
});
return state;
};
/**
* Invokes the plugin before a move or event.
* The API that the plugin generates is stored inside
* the `plugins` section of the state (which is subsequently
* merged into ctx).
*/
export const Enhance = <S extends State | PartialGameState>(
state: S,
opts: PluginOpts & { playerID: PlayerID }
): S => {
[...DEFAULT_PLUGINS, ...opts.game.plugins]
.filter((plugin) => plugin.api !== undefined)
.forEach((plugin) => {
const name = plugin.name;
const pluginState = state.plugins[name] || { data: {} };
const api = plugin.api({
G: state.G,
ctx: state.ctx,
data: pluginState.data,
game: opts.game,
playerID: opts.playerID,
});
state = {
...state,
plugins: {
...state.plugins,
[name]: { ...pluginState, api },
},
};
});
return state;
};
/**
* Allows plugins to update their state after a move / event.
*/
const Flush = (state: State, opts: PluginOpts): State => {
// We flush the events plugin first, then custom plugins and the core plugins.
// This means custom plugins cannot use the events API but will be available in event hooks.
// Note that plugins are flushed in reverse, to allow custom plugins calling each other.
[...CORE_PLUGINS, ...opts.game.plugins, PluginEvents]
.reverse()
.forEach((plugin) => {
const name = plugin.name;
const pluginState = state.plugins[name] || { data: {} };
if (plugin.flush) {
const newData = plugin.flush({
G: state.G,
ctx: state.ctx,
game: opts.game,
api: pluginState.api,
data: pluginState.data,
});
state = {
...state,
plugins: {
...state.plugins,
[plugin.name]: { data: newData },
},
};
} else if (plugin.dangerouslyFlushRawState) {
state = plugin.dangerouslyFlushRawState({
state,
game: opts.game,
api: pluginState.api,
data: pluginState.data,
});
// Remove everything other than data.
const data = state.plugins[name].data;
state = {
...state,
plugins: {
...state.plugins,
[plugin.name]: { data },
},
};
}
});
return state;
};
/**
* Allows plugins to indicate if they should not be materialized on the client.
* This will cause the client to discard the state update and wait for the
* master instead.
*/
export const NoClient = (state: State, opts: PluginOpts): boolean => {
return [...DEFAULT_PLUGINS, ...opts.game.plugins]
.filter((plugin) => plugin.noClient !== undefined)
.map((plugin) => {
const name = plugin.name;
const pluginState = state.plugins[name];
if (pluginState) {
return plugin.noClient({
G: state.G,
ctx: state.ctx,
game: opts.game,
api: pluginState.api,
data: pluginState.data,
});
}
return false;
})
.includes(true);
};
/**
* Allows plugins to indicate if the entire action should be thrown out
* as invalid. This will cancel the entire state update.
*/
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,
data: pluginState && pluginState.data,
});
return message ? { plugin: name, message } : false;
})
.find((value) => value);
return firstInvalidReturn || false;
};
/**
* Update plugin state after move/event & check if plugins consider the update to be valid.
* @returns Tuple of `[updatedState]` or `[originalState, invalidError]`.
*/
export const FlushAndValidate = (state: State, opts: PluginOpts) => {
const updatedState = Flush(state, opts);
const isInvalid = IsInvalid(updatedState, opts);
if (!isInvalid) return [updatedState] as const;
const { plugin, message } = isInvalid;
error(`${plugin} plugin declared action invalid:\n${message}`);
return [state, isInvalid] as const;
};
/**
* 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;
};