UNPKG

boardgame.io

Version:
315 lines (286 loc) 8.51 kB
/* * 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; };