diff --git a/docs/documentation/events.md b/docs/documentation/events.md
index a05ead766..8e85d8ded 100644
--- a/docs/documentation/events.md
+++ b/docs/documentation/events.md
@@ -158,3 +158,21 @@ const game = {
!> This doesn't apply to events in moves or hooks, but just the
ability to call an event directly from a client.
+
+### Calling events from hooks
+
+The events API is available in game hooks like it is inside moves. However,
+because of how hooks and events interact, certain events cannot be called from
+certain hooks. The following table shows which hooks support which events.
+
+| | turn
`onMove` | turn
`onBegin` | turn
`onEnd` | phase
`onBegin` | phase
`onEnd` | game
`onEnd` |
+|-------------------:|:----------------:|:-----------------:|:---------------:|:------------------:|:----------------:|:---------------:|
+| `setStage` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| `endStage` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
+| `setActivePlayers` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
+| `endTurn` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ |
+| `setPhase` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
+| `endPhase` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
+| `endGame` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
+
+✅ = supported ❌ = not supported
diff --git a/packages/core.ts b/packages/core.ts
index 8873638d6..0927b9d1c 100644
--- a/packages/core.ts
+++ b/packages/core.ts
@@ -7,7 +7,15 @@
*/
import { INVALID_MOVE } from '../src/core/constants';
+import { GameMethod } from '../src/core/game-methods';
import { ActivePlayers, TurnOrder, Stage } from '../src/core/turn-order';
import { PlayerView } from '../src/core/player-view';
-export { ActivePlayers, Stage, TurnOrder, PlayerView, INVALID_MOVE };
+export {
+ ActivePlayers,
+ GameMethod,
+ Stage,
+ TurnOrder,
+ PlayerView,
+ INVALID_MOVE,
+};
diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts
index a4834d07b..b36ed6252 100644
--- a/src/core/flow.test.ts
+++ b/src/core/flow.test.ts
@@ -10,7 +10,7 @@ import { makeMove, gameEvent } from './action-creators';
import { Client } from '../client/client';
import { Flow } from './flow';
import { error } from '../core/logger';
-import type { Ctx, State } from '../types';
+import type { Ctx, State, Game, MoveFn } from '../types';
jest.mock('../core/logger', () => ({
info: jest.fn(),
@@ -628,6 +628,74 @@ describe('stage events', () => {
state = flow.processEvent(state, gameEvent('setStage', {}));
expect(state.ctx.activePlayers).toBeNull();
});
+
+ describe('disallowed in hooks', () => {
+ const setStage: MoveFn = (G, ctx) => {
+ ctx.events.setStage('A');
+ };
+
+ test('phase.onBegin', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ start: true,
+ onBegin: setStage,
+ },
+ },
+ };
+ Client({ game });
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/);
+ });
+
+ test('phase.onEnd', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ start: true,
+ onEnd: setStage,
+ },
+ },
+ };
+ const client = Client({ game });
+ expect(error).not.toHaveBeenCalled();
+ client.events.endPhase();
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
+ });
+
+ test('turn.onBegin', () => {
+ const game: Game = {
+ turn: {
+ onBegin: setStage,
+ },
+ };
+ Client({ game });
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `turn.onBegin`/);
+ });
+
+ test('turn.onEnd', () => {
+ const game: Game = {
+ turn: {
+ onEnd: setStage,
+ },
+ };
+ const client = Client({ game });
+ expect(error).not.toHaveBeenCalled();
+ client.events.endTurn();
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
+ });
+ });
});
describe('endStage', () => {
@@ -708,6 +776,165 @@ describe('stage events', () => {
'1': 'B2',
});
});
+
+ describe('disallowed in hooks', () => {
+ const endStage: MoveFn = (G, ctx) => {
+ ctx.events.endStage();
+ };
+
+ test('phase.onBegin', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ start: true,
+ onBegin: endStage,
+ },
+ },
+ };
+ Client({ game });
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/);
+ });
+
+ test('phase.onEnd', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ start: true,
+ onEnd: endStage,
+ },
+ },
+ };
+ const client = Client({ game });
+ expect(error).not.toHaveBeenCalled();
+ client.events.endPhase();
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
+ });
+
+ test('turn.onBegin', () => {
+ const game: Game = {
+ turn: {
+ onBegin: endStage,
+ },
+ };
+ Client({ game });
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `turn.onBegin`/);
+ });
+
+ test('turn.onEnd', () => {
+ const game: Game = {
+ turn: {
+ onEnd: endStage,
+ },
+ };
+ const client = Client({ game });
+ expect(error).not.toHaveBeenCalled();
+ client.events.endTurn();
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
+ });
+ });
+ });
+
+ describe('setActivePlayers', () => {
+ test('basic', () => {
+ const client = Client({
+ numPlayers: 3,
+ game: {
+ turn: {
+ onBegin: (G, ctx) => {
+ ctx.events.setActivePlayers({ currentPlayer: 'A' });
+ },
+ },
+ moves: {
+ updateActivePlayers: (G, ctx) => {
+ ctx.events.setActivePlayers({ others: 'B' });
+ },
+ },
+ },
+ });
+ expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A' });
+ client.moves.updateActivePlayers();
+ expect(client.getState().ctx.activePlayers).toEqual({
+ '1': 'B',
+ '2': 'B',
+ });
+ });
+
+ describe('in hooks', () => {
+ const setActivePlayers: MoveFn = (G, ctx) => {
+ ctx.events.setActivePlayers({ currentPlayer: 'A' });
+ };
+
+ test('disallowed in phase.onBegin', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ start: true,
+ onBegin: setActivePlayers,
+ },
+ },
+ };
+ Client({ game });
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in a phase’s `onBegin` hook/);
+ });
+
+ test('disallowed in phase.onEnd', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ start: true,
+ onEnd: setActivePlayers,
+ },
+ },
+ };
+ const client = Client({ game });
+ expect(error).not.toHaveBeenCalled();
+ client.events.endPhase();
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
+ });
+
+ test('allowed in turn.onBegin', () => {
+ const client = Client({
+ game: {
+ turn: { onBegin: setActivePlayers },
+ },
+ });
+ expect(client.getState().ctx.activePlayers).toEqual({ '0': 'A' });
+ expect(error).not.toHaveBeenCalled();
+ });
+
+ test('disallowed in turn.onEnd', () => {
+ const game: Game = {
+ turn: {
+ onEnd: setActivePlayers,
+ },
+ };
+ const client = Client({ game });
+ expect(error).not.toHaveBeenCalled();
+ client.events.endTurn();
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/disallowed in `onEnd` hooks/);
+ });
+ });
});
});
@@ -1072,18 +1299,22 @@ describe('infinite loops', () => {
expect(state.ctx.phase).toBe('A');
expect(state.ctx.turn).toBe(1);
expect(error).toHaveBeenCalled();
- expect((error as jest.Mock).mock.calls[0][0]).toMatch(
- /events plugin declared action invalid/
- );
+ {
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/);
+ }
jest.clearAllMocks();
// Moves also fail because of the infinite loop (the game is stuck).
client.moves.a();
state = client.getState();
expect(error).toHaveBeenCalled();
- expect((error as jest.Mock).mock.calls[0][0]).toMatch(
- /events plugin declared action invalid/
- );
+ {
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/);
+ }
expect(state.ctx.phase).toBe('A');
expect(state.ctx.turn).toBe(1);
});
@@ -1131,9 +1362,9 @@ describe('infinite loops', () => {
// Expect state to be unchanged and error to be logged.
expect(error).toHaveBeenCalled();
- expect((error as jest.Mock).mock.calls[0][0]).toMatch(
- /events plugin declared action invalid/
- );
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/Maximum number of turn endings exceeded/);
expect(client.getState().ctx.currentPlayer).toBe('0');
expect(client.getState()).toEqual({ ...initialState, deltalog: [] });
});
@@ -1162,6 +1393,26 @@ describe('infinite loops', () => {
expect(state.ctx.currentPlayer).toBe('1');
expect(state.ctx.turn).toBe(2);
});
+
+ test('endIf that triggers endIf', () => {
+ const game: Game = {
+ phases: {
+ A: {
+ endIf: (G, ctx) => {
+ ctx.events.setActivePlayers({ currentPlayer: 'A' });
+ },
+ },
+ },
+ };
+ const client = Client({ game });
+ client.events.setPhase('A');
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(
+ /Events must be called from moves or the `.+` hooks./
+ );
+ });
});
describe('events in hooks', () => {
@@ -1258,8 +1509,12 @@ describe('events in hooks', () => {
client.moves.setAutoEnd();
client.events.endTurn();
state = client.getState();
- expect(state.ctx.turn).toBe(2);
- expect(state.ctx.currentPlayer).toBe('1');
+ expect(state.ctx.turn).toBe(1);
+ expect(state.ctx.currentPlayer).toBe('0');
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/`endTurn` is disallowed in `onEnd` hooks/);
});
test('cannot end turn from phase.onEnd', () => {
@@ -1283,9 +1538,13 @@ describe('events in hooks', () => {
client.moves.setAutoEnd();
client.events.endPhase();
state = client.getState();
- expect(state.ctx.turn).toBe(2);
- expect(state.ctx.currentPlayer).toBe('1');
- expect(state.ctx.phase).toBeNull();
+ expect(state.ctx.turn).toBe(1);
+ expect(state.ctx.currentPlayer).toBe('0');
+ expect(state.ctx.phase).toBe('A');
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(/`endTurn` is disallowed in `onEnd` hooks/);
});
});
@@ -1397,9 +1656,15 @@ describe('events in hooks', () => {
client.moves.setAutoEnd();
client.events.endPhase();
state = client.getState();
- expect(state.ctx.turn).toBe(2);
- expect(state.ctx.currentPlayer).toBe('1');
- expect(state.ctx.phase).toBe('B');
+ expect(state.ctx.turn).toBe(1);
+ expect(state.ctx.currentPlayer).toBe('0');
+ expect(state.ctx.phase).toBe('A');
+ expect(error).toHaveBeenCalled();
+ const errorMessage = (error as jest.Mock).mock.calls[0][0];
+ expect(errorMessage).toMatch(/events plugin declared action invalid/);
+ expect(errorMessage).toMatch(
+ /`setPhase` & `endPhase` are disallowed in a phase’s `onEnd` hook/
+ );
});
});
});
diff --git a/src/core/flow.ts b/src/core/flow.ts
index d1e08e372..18c8ca5fd 100644
--- a/src/core/flow.ts
+++ b/src/core/flow.ts
@@ -29,6 +29,7 @@ import type {
PlayerID,
Move,
} from '../types';
+import { GameMethod } from './game-methods';
/**
* Flow
@@ -76,23 +77,26 @@ export function Flow({
Object.keys(moves).forEach((name) => moveNames.add(name));
- const HookWrapper = (fn: (G: any, ctx: Ctx) => any) => {
- const withPlugins = plugin.FnWrap(fn, plugins);
+ const HookWrapper = (
+ hook: (G: any, ctx: Ctx) => any,
+ hookType: GameMethod
+ ) => {
+ const withPlugins = plugin.FnWrap(hook, hookType, plugins);
return (state: State) => {
const ctxWithAPI = plugin.EnhanceCtx(state);
return withPlugins(state.G, ctxWithAPI);
};
};
- const TriggerWrapper = (endIf: (G: any, ctx: Ctx) => any) => {
+ const TriggerWrapper = (trigger: (G: any, ctx: Ctx) => any) => {
return (state: State) => {
const ctxWithAPI = plugin.EnhanceCtx(state);
- return endIf(state.G, ctxWithAPI);
+ return trigger(state.G, ctxWithAPI);
};
};
const wrapped = {
- onEnd: HookWrapper(onEnd),
+ onEnd: HookWrapper(onEnd, GameMethod.GAME_ON_END),
endIf: TriggerWrapper(endIf),
};
@@ -152,15 +156,15 @@ export function Flow({
}
phaseConfig.wrapped = {
- onBegin: HookWrapper(phaseConfig.onBegin),
- onEnd: HookWrapper(phaseConfig.onEnd),
+ onBegin: HookWrapper(phaseConfig.onBegin, GameMethod.PHASE_ON_BEGIN),
+ onEnd: HookWrapper(phaseConfig.onEnd, GameMethod.PHASE_ON_END),
endIf: TriggerWrapper(phaseConfig.endIf),
};
phaseConfig.turn.wrapped = {
- onMove: HookWrapper(phaseConfig.turn.onMove),
- onBegin: HookWrapper(phaseConfig.turn.onBegin),
- onEnd: HookWrapper(phaseConfig.turn.onEnd),
+ onMove: HookWrapper(phaseConfig.turn.onMove, GameMethod.TURN_ON_MOVE),
+ onBegin: HookWrapper(phaseConfig.turn.onBegin, GameMethod.TURN_ON_BEGIN),
+ onEnd: HookWrapper(phaseConfig.turn.onEnd, GameMethod.TURN_ON_END),
endIf: TriggerWrapper(phaseConfig.turn.endIf),
};
@@ -168,7 +172,7 @@ export function Flow({
const { next } = phaseConfig;
phaseConfig.next = () => next || null;
}
- phaseConfig.wrapped.next = HookWrapper(phaseConfig.next);
+ phaseConfig.wrapped.next = TriggerWrapper(phaseConfig.next);
}
function GetPhase(ctx: { phase: string }): PhaseConfig {
@@ -780,7 +784,7 @@ export function Flow({
enabledEventNames.push('setStage');
}
- function ProcessEvent(state: State, action: ActionShape.GameEvent) {
+ function ProcessEvent(state: State, action: ActionShape.GameEvent): State {
const { type, playerID, args } = action.payload;
if (typeof eventHandlers[type] !== 'function') return state;
return eventHandlers[type](
diff --git a/src/core/game-methods.ts b/src/core/game-methods.ts
new file mode 100644
index 000000000..c7b670068
--- /dev/null
+++ b/src/core/game-methods.ts
@@ -0,0 +1,9 @@
+export enum GameMethod {
+ MOVE = 'MOVE',
+ GAME_ON_END = 'GAME_ON_END',
+ PHASE_ON_BEGIN = 'PHASE_ON_BEGIN',
+ PHASE_ON_END = 'PHASE_ON_END',
+ TURN_ON_BEGIN = 'TURN_ON_BEGIN',
+ TURN_ON_MOVE = 'TURN_ON_MOVE',
+ TURN_ON_END = 'TURN_ON_END',
+}
diff --git a/src/core/game.ts b/src/core/game.ts
index 93a0108f0..c55bd4cbf 100644
--- a/src/core/game.ts
+++ b/src/core/game.ts
@@ -11,6 +11,7 @@ import { Flow } from './flow';
import type { INVALID_MOVE } from './constants';
import type { ActionPayload, Game, Move, LongFormMove, State } from '../types';
import * as logging from './logger';
+import { GameMethod } from './game-methods';
type ProcessedGame = Game & {
flow: ReturnType;
@@ -87,7 +88,7 @@ export function ProcessGameConfig(game: Game | ProcessedGame): ProcessedGame {
}
if (moveFn instanceof Function) {
- const fn = plugins.FnWrap(moveFn, game.plugins);
+ const fn = plugins.FnWrap(moveFn, GameMethod.MOVE, game.plugins);
const ctxWithAPI = {
...plugins.EnhanceCtx(state),
playerID: action.playerID,
diff --git a/src/plugins/events/events.test.ts b/src/plugins/events/events.test.ts
index 3b05177c8..634955603 100644
--- a/src/plugins/events/events.test.ts
+++ b/src/plugins/events/events.test.ts
@@ -8,8 +8,15 @@
import { Events } from './events';
import { Client } from '../../client/client';
+import { error } from '../../core/logger';
import type { Game, Ctx } from '../../types';
+jest.mock('../../core/logger', () => ({
+ info: jest.fn(),
+ error: jest.fn(),
+}));
+afterEach(jest.clearAllMocks);
+
test('constructor', () => {
const flow = {} as Game['flow'];
const playerID = '0';
@@ -62,7 +69,8 @@ test('no duplicate endTurn', () => {
const client = Client({ game });
expect(client.getState().ctx.turn).toBe(1);
client.events.endTurn();
- expect(client.getState().ctx.turn).toBe(2);
+ expect(client.getState().ctx.turn).toBe(1);
+ expect(error).toHaveBeenCalled();
});
test('no duplicate endPhase', () => {
@@ -81,5 +89,6 @@ test('no duplicate endPhase', () => {
const client = Client({ game });
expect(client.getState().ctx.phase).toBe('A');
client.events.setPhase('B');
- expect(client.getState().ctx.phase).toBe('B');
+ expect(client.getState().ctx.phase).toBe('A');
+ expect(error).toHaveBeenCalled();
});
diff --git a/src/plugins/events/events.ts b/src/plugins/events/events.ts
index 341f48ce0..4de9c6cb1 100644
--- a/src/plugins/events/events.ts
+++ b/src/plugins/events/events.ts
@@ -8,6 +8,28 @@
import type { State, Ctx, PlayerID, Game } from '../../types';
import { automaticGameEvent } from '../../core/action-creators';
+import { GameMethod } from '../../core/game-methods';
+
+const enum Errors {
+ CalledOutsideHook = 'Events must be called from moves or the `onBegin`, `onEnd`, and `onMove` hooks.\n' +
+ 'This error probably means you called an event from other game code, like an `endIf` trigger or one of the `turn.order` methods.',
+
+ EndTurnInOnEnd = '`endTurn` is disallowed in `onEnd` hooks — the turn is already ending.',
+
+ MaxTurnEndings = 'Maximum number of turn endings exceeded for this update.\n' +
+ 'This likely means game code is triggering an infinite loop.',
+
+ PhaseEventInOnEnd = '`setPhase` & `endPhase` are disallowed in a phase’s `onEnd` hook — the phase is already ending.\n' +
+ 'If you’re trying to dynamically choose the next phase when a phase ends, use the phase’s `next` trigger.',
+
+ StageEventInOnEnd = '`setStage`, `endStage` & `setActivePlayers` are disallowed in `onEnd` hooks.',
+
+ StageEventInPhaseBegin = '`setStage`, `endStage` & `setActivePlayers` are disallowed in a phase’s `onBegin` hook.\n' +
+ 'Use `setActivePlayers` in a `turn.onBegin` hook or declare stages with `turn.activePlayers` instead.',
+
+ StageEventInTurnBegin = '`setStage` & `endStage` are disallowed in `turn.onBegin`.\n' +
+ 'Use `setActivePlayers` or declare stages with `turn.activePlayers` instead.',
+}
export interface EventsAPI {
endGame?(...args: any[]): void;
@@ -23,7 +45,8 @@ export interface EventsAPI {
export interface PrivateEventsAPI {
_obj: {
isUsed(): boolean;
- updateTurnContext(ctx: Ctx): void;
+ updateTurnContext(ctx: Ctx, methodType: GameMethod | undefined): void;
+ unsetCurrentMethod(): void;
update(state: State): State;
};
}
@@ -39,18 +62,20 @@ export class Events {
args: any[];
phase: string;
turn: number;
+ calledFrom: GameMethod | undefined;
}>;
maxEndedTurnsPerAction: number;
initialTurn: number;
currentPhase: string;
currentTurn: number;
+ currentMethod?: GameMethod;
constructor(flow: Game['flow'], ctx: Ctx, playerID?: PlayerID) {
this.flow = flow;
this.playerID = playerID;
this.dispatch = [];
this.initialTurn = ctx.turn;
- this.updateTurnContext(ctx);
+ this.updateTurnContext(ctx, undefined);
// This is an arbitrarily large upper threshold, which could be made
// configurable via a game option if the need arises.
this.maxEndedTurnsPerAction = ctx.numPlayers * 100;
@@ -67,6 +92,7 @@ export class Events {
args,
phase: this.currentPhase,
turn: this.currentTurn,
+ calledFrom: this.currentMethod,
});
};
}
@@ -78,26 +104,14 @@ export class Events {
return this.dispatch.length > 0;
}
- updateTurnContext(ctx: Ctx) {
+ updateTurnContext(ctx: Ctx, methodType: GameMethod | undefined) {
this.currentPhase = ctx.phase;
this.currentTurn = ctx.turn;
+ this.currentMethod = methodType;
}
- stateWithError(state: State): State {
- return {
- ...state,
- plugins: {
- ...state.plugins,
- events: {
- ...state.plugins.events,
- data: {
- error:
- 'Maximum number of turn endings exceeded for this update.\n' +
- 'This likely means game code is triggering an infinite loop.',
- },
- },
- },
- };
+ unsetCurrentMethod() {
+ this.currentMethod = undefined;
}
/**
@@ -106,47 +120,83 @@ export class Events {
*/
update(state: State): State {
const initialState = state;
- for (let i = 0; i < this.dispatch.length; i++) {
- const endedTurns = this.currentTurn - this.initialTurn;
- // This protects against potential infinite loops if specific events are called on hooks.
- // The moment we exceed the defined threshold, we just bail out of all phases.
- if (endedTurns >= this.maxEndedTurnsPerAction) {
- return this.stateWithError(initialState);
- }
+ const stateWithError = (error: Errors) => ({
+ ...initialState,
+ plugins: {
+ ...initialState.plugins,
+ events: {
+ ...initialState.plugins.events,
+ data: { error },
+ },
+ },
+ });
+ EventQueue: for (let i = 0; i < this.dispatch.length; i++) {
const event = this.dispatch[i];
+ const turnHasEnded = event.turn !== state.ctx.turn;
- // If the turn already ended, don't try to process stage events.
- if (
- (event.type === 'endStage' ||
- event.type === 'setStage' ||
- event.type === 'setActivePlayers') &&
- event.turn !== state.ctx.turn
- ) {
- continue;
+ // This protects against potential infinite loops if specific events are called on hooks.
+ // The moment we exceed the defined threshold, we just bail out of all phases.
+ const endedTurns = this.currentTurn - this.initialTurn;
+ if (endedTurns >= this.maxEndedTurnsPerAction) {
+ return stateWithError(Errors.MaxTurnEndings);
}
- // If the turn already ended some other way,
- // don't try to end the turn again.
- if (event.type === 'endTurn' && event.turn !== state.ctx.turn) {
- continue;
+ if (event.calledFrom === undefined) {
+ return stateWithError(Errors.CalledOutsideHook);
}
- // If the phase already ended some other way,
- // don't try to end the phase again.
- if (
- (event.type === 'endPhase' || event.type === 'setPhase') &&
- event.phase !== state.ctx.phase
- ) {
- continue;
+ switch (event.type) {
+ case 'endStage':
+ case 'setStage':
+ case 'setActivePlayers': {
+ switch (event.calledFrom) {
+ // Disallow all stage events in onEnd and phase.onBegin hooks.
+ case GameMethod.TURN_ON_END:
+ case GameMethod.PHASE_ON_END:
+ return stateWithError(Errors.StageEventInOnEnd);
+ case GameMethod.PHASE_ON_BEGIN:
+ return stateWithError(Errors.StageEventInPhaseBegin);
+ // Disallow setStage & endStage in turn.onBegin hooks.
+ case GameMethod.TURN_ON_BEGIN:
+ if (event.type === 'setActivePlayers') break;
+ return stateWithError(Errors.StageEventInTurnBegin);
+ }
+
+ // If the turn already ended, don't try to process stage events.
+ if (turnHasEnded) continue EventQueue;
+ break;
+ }
+
+ case 'endTurn': {
+ if (
+ event.calledFrom === GameMethod.TURN_ON_END ||
+ event.calledFrom === GameMethod.PHASE_ON_END
+ ) {
+ return stateWithError(Errors.EndTurnInOnEnd);
+ }
+
+ // If the turn already ended some other way,
+ // don't try to end the turn again.
+ if (turnHasEnded) continue EventQueue;
+ break;
+ }
+
+ case 'endPhase':
+ case 'setPhase': {
+ if (event.calledFrom === GameMethod.PHASE_ON_END) {
+ return stateWithError(Errors.PhaseEventInOnEnd);
+ }
+
+ // If the phase already ended some other way,
+ // don't try to end the phase again.
+ if (event.phase !== state.ctx.phase) continue EventQueue;
+ break;
+ }
}
const action = automaticGameEvent(event.type, event.args, this.playerID);
-
- state = {
- ...state,
- ...this.flow.processEvent(state, action),
- };
+ state = this.flow.processEvent(state, action);
}
return state;
}
diff --git a/src/plugins/main.ts b/src/plugins/main.ts
index 2a5ddfac2..524453963 100644
--- a/src/plugins/main.ts
+++ b/src/plugins/main.ts
@@ -22,6 +22,7 @@ import type {
PlayerID,
} from '../types';
import { error } from '../core/logger';
+import type { GameMethod } from '../core/game-methods';
interface PluginOpts {
game: Game;
@@ -92,13 +93,21 @@ export const EnhanceCtx = (state: PartialGameState): Ctx => {
/**
* Applies the provided plugins to the given move / flow function.
*
- * @param {function} functionToWrap - The move function or trigger to apply the plugins to.
- * @param {object} plugins - The list of plugins.
+ * @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 = (functionToWrap: AnyFn, plugins: Plugin[]) => {
+export const FnWrap = (
+ methodToWrap: AnyFn,
+ methodType: GameMethod,
+ plugins: Plugin[]
+) => {
return [...DEFAULT_PLUGINS, ...plugins]
.filter((plugin) => plugin.fnWrap !== undefined)
- .reduce((fn: AnyFn, { fnWrap }: Plugin) => fnWrap(fn), functionToWrap);
+ .reduce(
+ (method: AnyFn, { fnWrap }: Plugin) => fnWrap(method, methodType),
+ methodToWrap
+ );
};
/**
diff --git a/src/plugins/plugin-events.ts b/src/plugins/plugin-events.ts
index a81efea92..cb1304d46 100644
--- a/src/plugins/plugin-events.ts
+++ b/src/plugins/plugin-events.ts
@@ -23,11 +23,12 @@ const EventsPlugin: Plugin = {
// or hook is called. This allows events called after turn or phase
// endings to dispatch the current turn and phase correctly.
fnWrap:
- (fn) =>
+ (method, methodType) =>
(G, ctx, ...args) => {
const api = ctx.events as PrivateEventsAPI;
- if (api) api._obj.updateTurnContext(ctx);
- G = fn(G, ctx, ...args);
+ if (api) api._obj.updateTurnContext(ctx, methodType);
+ G = method(G, ctx, ...args);
+ if (api) api._obj.unsetCurrentMethod();
return G;
},
diff --git a/src/types.ts b/src/types.ts
index fdf4dbcc4..e786ee869 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -6,6 +6,7 @@ import type { ActionErrorType, UpdateErrorType } from './core/errors';
import type { Flow } from './core/flow';
import type { CreateGameReducer } from './core/reducer';
import type { INVALID_MOVE } from './core/constants';
+import type { GameMethod } from './core/game-methods';
import type { Auth } from './server/auth';
import type * as StorageAPI from './server/db/base';
import type { EventsAPI } from './plugins/plugin-events';
@@ -163,7 +164,10 @@ export interface Plugin<
api: API;
data: Data;
}) => State;
- fnWrap?: (fn: AnyFn) => (G: G, ctx: Ctx, ...args: any[]) => any;
+ fnWrap?: (
+ moveOrHook: (G: G, ctx: Ctx, ...args: any[]) => any,
+ methodType: GameMethod
+ ) => (G: G, ctx: Ctx, ...args: any[]) => any;
playerView?: (context: {
G: G;
ctx: Ctx;