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;