diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index 2a80173de..c6454487c 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -1022,6 +1022,21 @@ describe('infinite loops', () => { client.moves.endTurn(); expect(client.getState().ctx.currentPlayer).toBe('1'); }); + + test('loop 5', () => { + const onBegin = (G, ctx) => ctx.events.endPhase(); + const game = { + phases: { + A: { onBegin, next: 'B', start: true }, + B: { onBegin, next: 'C' }, + C: { onBegin, next: 'A' }, + }, + }; + + const client = Client({ game, numPlayers: 3 }); + + expect(client.getState().ctx.phase).toBe(null); + }); }); describe('activePlayers', () => { @@ -1084,3 +1099,56 @@ test('events in hooks triggered by moves should be processed', () => { '1': 'A', }); }); + +test('stage events should not be processed out of turn', () => { + const game = { + phases: { + A: { + start: true, + turn: { + activePlayers: { + all: 'A1', + }, + stages: { + A1: { + moves: { + endStage: (G, ctx) => { + G.endStage = true; + ctx.events.endStage(); + }, + }, + }, + }, + }, + endIf: (G) => G.endStage, + next: 'B', + }, + B: { + turn: { + activePlayers: { + all: 'B1', + }, + stages: { + B1: {}, + }, + }, + }, + }, + }; + + const client = Client({ game, numPlayers: 3 }); + + expect(client.getState().ctx.activePlayers).toEqual({ + '0': 'A1', + '1': 'A1', + '2': 'A1', + }); + + client.moves.endStage(); + + expect(client.getState().ctx.activePlayers).toEqual({ + '0': 'B1', + '1': 'B1', + '2': 'B1', + }); +}); diff --git a/src/plugins/events/events.test.ts b/src/plugins/events/events.test.ts index a89f96354..c82532588 100644 --- a/src/plugins/events/events.test.ts +++ b/src/plugins/events/events.test.ts @@ -13,16 +13,19 @@ import type { Game, Ctx } from '../../types'; test('constructor', () => { const flow = {} as Game['flow']; const playerID = '0'; - const e = new Events(flow, playerID); + const e = new Events(flow, { phase: '', turn: 0 } as Ctx, playerID); expect(e.flow).toBe(flow); expect(e.playerID).toBe(playerID); expect(e.dispatch).toEqual([]); + expect(e.initialTurn).toEqual(0); + expect(e.currentPhase).toEqual(''); + expect(e.currentTurn).toEqual(0); }); test('dispatch', () => { const flow = { eventNames: ['A', 'B'] } as Game['flow']; - const e = new Events(flow); - const events = e.api({ phase: '', turn: 0 } as Ctx); + const e = new Events(flow, { phase: '', turn: 0 } as Ctx); + const events = e.api(); expect(e.dispatch).toEqual([]); ((events as unknown) as { A(): void }).A(); diff --git a/src/plugins/events/events.ts b/src/plugins/events/events.ts index 83a420c2c..6c8462c97 100644 --- a/src/plugins/events/events.ts +++ b/src/plugins/events/events.ts @@ -23,6 +23,7 @@ export interface EventsAPI { export interface PrivateEventsAPI { _obj: { isUsed(): boolean; + updateTurnContext(ctx: Ctx): void; update(state: State): State; }; } @@ -39,26 +40,30 @@ export class Events { phase: string; turn: number; }>; + initialTurn: number; + currentPhase: string; + currentTurn: number; - constructor(flow: Game['flow'], playerID?: PlayerID) { + constructor(flow: Game['flow'], ctx: Ctx, playerID?: PlayerID) { this.flow = flow; this.playerID = playerID; this.dispatch = []; + this.initialTurn = ctx.turn; + this.updateTurnContext(ctx); } - /** - * Attaches the Events API to ctx. - * @param {object} ctx - The ctx object to attach to. - */ - api(ctx: Ctx) { + api() { const events: EventsAPI & PrivateEventsAPI = { _obj: this, }; - const { phase, turn } = ctx; - for (const key of this.flow.eventNames) { events[key] = (...args: any[]) => { - this.dispatch.push({ key, args, phase, turn }); + this.dispatch.push({ + key, + args, + phase: this.currentPhase, + turn: this.currentTurn, + }); }; } @@ -69,14 +74,41 @@ export class Events { return this.dispatch.length > 0; } + updateTurnContext(ctx: Ctx) { + this.currentPhase = ctx.phase; + this.currentTurn = ctx.turn; + } + /** * Updates ctx with the triggered events. * @param {object} state - The state object { G, ctx }. */ update(state: State) { for (let i = 0; i < this.dispatch.length; i++) { + const endedTurns = this.currentTurn - this.initialTurn; + // This is an arbitrarily large number. + const threshold = state.ctx.numPlayers * 10; + + // 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 > threshold) { + state = { ...state, ctx: { ...state.ctx, phase: null } }; + break; + } + const item = this.dispatch[i]; + // If the turn already ended, + // don't try to process stage events. + if ( + (item.key === 'endStage' || + item.key === 'setStage' || + item.key === 'setActivePlayers') && + item.turn !== state.ctx.turn + ) { + continue; + } + // If the turn already ended some other way, // don't try to end the turn again. if (item.key === 'endTurn' && item.turn !== state.ctx.turn) { diff --git a/src/plugins/plugin-events.ts b/src/plugins/plugin-events.ts index 380e3a113..b7e7c7003 100644 --- a/src/plugins/plugin-events.ts +++ b/src/plugins/plugin-events.ts @@ -19,12 +19,24 @@ const EventsPlugin: Plugin = { return api._obj.isUsed(); }, + fnWrap: (fn) => (G, ctx, ...args) => { + const api = ctx.events as PrivateEventsAPI; + + if (api) { + api._obj.updateTurnContext(ctx); + } + + G = fn(G, ctx, ...args); + + return G; + }, + dangerouslyFlushRawState: ({ state, api }) => { return api._obj.update(state); }, - api: ({ game, playerID, ctx }) => { - return new Events(game.flow, playerID).api(ctx); + api: ({ game, ctx, playerID }) => { + return new Events(game.flow, ctx, playerID).api(); }, };