boardgame.io
Version:
library for turn-based games
1,727 lines (1,471 loc) • 68.2 kB
text/typescript
/*
* Copyright 2017 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 { makeMove, gameEvent } from './action-creators';
import { Client } from '../client/client';
import { Flow } from './flow';
import { TurnOrder } from './turn-order';
import { error } from '../core/logger';
import type { Ctx, State, Game, PlayerID, MoveFn } from '../types';
jest.mock('../core/logger', () => ({
info: jest.fn(),
error: jest.fn(),
}));
afterEach(jest.clearAllMocks);
describe('phases', () => {
test('invalid phase name', () => {
const flow = Flow({
phases: { '': {} },
});
flow.init({ ctx: flow.ctx(2) } as State);
expect(error).toHaveBeenCalledWith('cannot specify phase with empty name');
});
test('onBegin / onEnd', () => {
const flow = Flow({
phases: {
A: {
start: true,
onBegin: ({ G }) => ({ ...G, setupA: true }),
onEnd: ({ G }) => ({ ...G, cleanupA: true }),
next: 'B',
},
B: {
onBegin: ({ G }) => ({ ...G, setupB: true }),
onEnd: ({ G }) => ({ ...G, cleanupB: true }),
next: 'A',
},
},
turn: {
order: {
first: ({ G }) => {
if (G.setupB && !G.cleanupB) return 1;
return 0;
},
next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
},
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.G).toMatchObject({ setupA: true });
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processEvent(state, gameEvent('endPhase'));
expect(state.G).toMatchObject({
setupA: true,
cleanupA: true,
setupB: true,
});
expect(state.ctx.currentPlayer).toBe('1');
state = flow.processEvent(state, gameEvent('endPhase'));
expect(state.G).toMatchObject({
setupA: true,
cleanupA: true,
setupB: true,
cleanupB: true,
});
expect(state.ctx.currentPlayer).toBe('0');
});
test('endIf', () => {
const flow = Flow({
phases: { A: { start: true, endIf: () => true, next: 'B' }, B: {} },
});
const state = { ctx: flow.ctx(2) } as State;
{
const t = flow.processEvent(state, gameEvent('endPhase'));
expect(t.ctx.phase).toBe('B');
}
{
const t = flow.processEvent(state, gameEvent('endTurn'));
expect(t.ctx.phase).toBe('B');
}
{
const t = flow.processMove(state, makeMove('').payload);
expect(t.ctx.phase).toBe('B');
}
});
describe('onEnd', () => {
let client: ReturnType<typeof Client>;
beforeAll(() => {
const game: Game = {
endIf: () => true,
onEnd: ({ G }) => {
G.onEnd = true;
},
};
client = Client({ game });
});
test('works', () => {
expect(client.getState().G).toEqual({
onEnd: true,
});
});
});
test('end phase on move', () => {
let endPhaseACount = 0;
let endPhaseBCount = 0;
const flow = Flow({
phases: {
A: {
start: true,
endIf: () => true,
onEnd: () => ++endPhaseACount,
next: 'B',
},
B: {
endIf: () => false,
onEnd: () => ++endPhaseBCount,
},
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
expect(state.ctx.phase).toBe('A');
state = flow.processMove(state, makeMove('').payload);
expect(state.ctx.phase).toBe('B');
expect(endPhaseACount).toEqual(1);
expect(endPhaseBCount).toEqual(0);
});
test('endPhase returns to null phase', () => {
const flow = Flow({
phases: { A: { start: true }, B: {}, C: {} },
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.phase).toBe('A');
state = flow.processEvent(state, gameEvent('endPhase'));
expect(state.ctx.phase).toBe(null);
});
test('increment playOrderPos on phase end', () => {
const flow = Flow({
phases: { A: { start: true, next: 'B' }, B: { next: 'A' } },
});
let state = { G: {}, ctx: flow.ctx(3) } as State;
state = flow.init(state);
expect(state.ctx.playOrderPos).toBe(0);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.playOrderPos).toBe(1);
state = flow.processEvent(state, gameEvent('endPhase'));
expect(state.ctx.playOrderPos).toBe(2);
});
describe('setPhase', () => {
let flow: ReturnType<typeof Flow>;
beforeEach(() => {
flow = Flow({
phases: { A: { start: true }, B: {} },
});
});
test('basic', () => {
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.phase).toBe('A');
state = flow.processEvent(state, gameEvent('setPhase', 'B'));
expect(state.ctx.phase).toBe('B');
});
test('invalid arg', () => {
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.phase).toBe('A');
state = flow.processEvent(state, gameEvent('setPhase', 'C'));
expect(error).toBeCalledWith('invalid phase: C');
expect(state.ctx.phase).toBe(null);
});
});
});
describe('turn', () => {
test('onEnd', () => {
const onEnd = jest.fn(({ G }) => G);
const flow = Flow({
turn: { onEnd },
});
const state = { ctx: flow.ctx(2) } as State;
flow.init(state);
expect(onEnd).not.toHaveBeenCalled();
flow.processEvent(state, gameEvent('endTurn'));
expect(onEnd).toHaveBeenCalled();
});
describe('onMove', () => {
const onMove = () => ({ A: true });
test('top level callback', () => {
const flow = Flow({ turn: { onMove } });
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.processMove(state, makeMove('').payload);
expect(state.G).toEqual({ A: true });
});
test('phase specific callback', () => {
const flow = Flow({
turn: { onMove },
phases: { B: { turn: { onMove: () => ({ B: true }) } } },
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.processMove(state, makeMove('').payload);
expect(state.G).toEqual({ A: true });
state = flow.processEvent(state, gameEvent('setPhase', 'B'));
state = flow.processMove(state, makeMove('').payload);
expect(state.G).toEqual({ B: true });
});
test('ctx with playerID', () => {
const playerID = 'playerID';
const flow = Flow({
turn: { onMove: ({ playerID }) => ({ playerID }) },
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.processMove(
state,
makeMove('', undefined, 'playerID').payload
);
expect(state.G.playerID).toEqual(playerID);
});
});
describe('minMoves', () => {
describe('without phases', () => {
const flow = Flow({
turn: {
minMoves: 2,
},
});
test('player cannot endTurn if not enough moves were made', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
});
test('player can endTurn after enough moves were made', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
});
});
describe('with phases', () => {
const flow = Flow({
turn: { minMoves: 2 },
phases: {
B: {
turn: {
minMoves: 1,
},
},
},
});
test('player cannot endTurn if not enough moves were made in default phase', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
});
test('player can endTurn after enough moves were made in default phase', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
});
test('player cannot endTurn if no move was made in explicit phase', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
state = flow.processMove(state, makeMove('move', null, '1').payload);
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
state = flow.processEvent(state, gameEvent('setPhase', 'B'));
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(3);
expect(state.ctx.currentPlayer).toBe('0');
});
test('player can endTurn after having made a move, fewer moves needed in explicit phase', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
state = flow.processMove(state, makeMove('move', null, '1').payload);
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
state = flow.processEvent(state, gameEvent('setPhase', 'B'));
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(4);
expect(state.ctx.currentPlayer).toBe('1');
});
});
});
describe('maxMoves', () => {
describe('without phases', () => {
const flow = Flow({
turn: {
maxMoves: 2,
},
});
test('manual endTurn works, even if not enough moves were made', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
});
test('turn automatically ends after making enough moves', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processMove(state, makeMove('move', null, '0').payload);
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
});
});
describe('with phases', () => {
const flow = Flow({
turn: { maxMoves: 2 },
phases: {
B: {
turn: { maxMoves: 1 },
},
},
});
test('manual endTurn works in all phases, even if fewer than maxMoves have been made', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
state = flow.processEvent(state, gameEvent('setPhase', 'B'));
expect(state.ctx.turn).toBe(3);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.turn).toBe(4);
expect(state.ctx.currentPlayer).toBe('1');
});
test('automatic endTurn triggers after fewer moves in different phase', () => {
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processMove(state, makeMove('move', null, '0').payload);
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
state = flow.processEvent(state, gameEvent('setPhase', 'B'));
expect(state.ctx.turn).toBe(3);
expect(state.ctx.currentPlayer).toBe('0');
state = flow.processMove(state, makeMove('move', null, '0').payload);
expect(state.ctx.turn).toBe(4);
expect(state.ctx.currentPlayer).toBe('1');
});
});
test('with noLimit moves', () => {
const flow = Flow({
turn: {
maxMoves: 2,
},
moves: {
A: () => {},
B: {
move: () => {},
noLimit: true,
},
},
});
let state = flow.init({ ctx: flow.ctx(2) } as State);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.numMoves).toBe(0);
state = flow.processMove(state, makeMove('A', null, '0').payload);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.numMoves).toBe(1);
state = flow.processMove(state, makeMove('B', null, '0').payload);
expect(state.ctx.turn).toBe(1);
expect(state.ctx.numMoves).toBe(1);
state = flow.processMove(state, makeMove('A', null, '0').payload);
expect(state.ctx.turn).toBe(2);
expect(state.ctx.numMoves).toBe(0);
});
});
describe('endIf', () => {
test('global', () => {
const game: Game = {
moves: {
A: () => ({ endTurn: true }),
B: ({ G }) => G,
},
turn: { endIf: ({ G }) => G.endTurn },
};
const client = Client({ game });
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.B();
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.A();
expect(client.getState().ctx.currentPlayer).toBe('1');
});
test('phase specific', () => {
const game: Game = {
moves: {
A: () => ({ endTurn: true }),
B: ({ G }) => G,
},
phases: {
A: { start: true, turn: { endIf: ({ G }) => G.endTurn } },
},
};
const client = Client({ game });
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.B();
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.A();
expect(client.getState().ctx.currentPlayer).toBe('1');
});
test('return value', () => {
const game: Game = {
moves: {
A: ({ G }) => G,
},
turn: { endIf: () => ({ next: '2' }) },
};
const client = Client({ game, numPlayers: 3 });
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.A();
expect(client.getState().ctx.currentPlayer).toBe('2');
});
});
test('endTurn is not called twice in one move', () => {
const flow = Flow({
turn: { endIf: () => true },
phases: {
A: { start: true, endIf: ({ G }) => G.endPhase, next: 'B' },
B: {},
},
});
let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State);
expect(state.ctx.phase).toBe('A');
expect(state.ctx.currentPlayer).toBe('0');
expect(state.ctx.turn).toBe(1);
state = flow.processMove(state, makeMove('').payload);
expect(state.ctx.phase).toBe('A');
expect(state.ctx.currentPlayer).toBe('1');
expect(state.ctx.turn).toBe(2);
state.G = { endPhase: true };
state = flow.processMove(state, makeMove('').payload);
expect(state.ctx.phase).toBe('B');
expect(state.ctx.currentPlayer).toBe('0');
expect(state.ctx.turn).toBe(3);
});
});
describe('stages', () => {
let client: ReturnType<typeof Client>;
beforeAll(() => {
const A = () => {};
const B = () => {};
const game: Game = {
moves: { A },
turn: {
stages: {
B: { moves: { B } },
C: {},
},
},
};
client = Client({ game });
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('no stage', () => {
test('A is allowed', () => {
client.moves.A();
expect(error).not.toBeCalled();
});
test('B is not allowed', () => {
client.moves.B();
expect(error).toBeCalledWith('disallowed move: B');
});
});
describe('stage B', () => {
beforeAll(() => {
client.events.setStage('B');
});
test('A is not allowed', () => {
client.moves.A();
expect(error).toBeCalledWith('disallowed move: A');
});
test('B is allowed', () => {
client.moves.B();
expect(error).not.toBeCalled();
});
});
describe('stage C', () => {
beforeAll(() => {
client.events.setStage('C');
});
test('A is allowed', () => {
client.moves.A();
expect(error).not.toBeCalled();
});
test('B is not allowed', () => {
client.moves.B();
expect(error).toBeCalledWith('disallowed move: B');
});
});
test('stage updates can be reacted to in turn.endIf', () => {
const client = Client({
game: {
turn: {
activePlayers: {
all: 'A',
},
stages: {
A: {
moves: {
leaveStage: ({ events }) => void events.endStage(),
},
},
},
endIf: ({ ctx }) => ctx.activePlayers === null,
},
},
});
let state = client.getState();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
client.updatePlayerID('0');
client.moves.leaveStage();
state = client.getState();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
client.updatePlayerID('1');
client.moves.leaveStage();
state = client.getState();
expect(state.ctx.turn).toBe(2);
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
});
test('stage changes due to move limits are seen by turn.endIf', () => {
const client = Client({
game: {
turn: {
activePlayers: {
currentPlayer: 'A',
maxMoves: 1,
},
endIf: ({ ctx }) => ctx.activePlayers === null,
stages: {
A: {
moves: {
A: () => ({ moved: true }),
},
},
},
},
},
});
let state = client.getState();
expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
client.moves.A();
state = client.getState();
expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
});
});
describe('stage events', () => {
describe('setStage', () => {
test('basic', () => {
const flow = Flow({});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toBeNull();
state = flow.processEvent(state, gameEvent('setStage', 'A'));
expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
});
test('object syntax', () => {
const flow = Flow({});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toBeNull();
state = flow.processEvent(state, gameEvent('setStage', { stage: 'A' }));
expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
});
test('with multiple active players', () => {
const flow = Flow({
turn: {
activePlayers: { all: 'A', minMoves: 2, maxMoves: 5 },
},
});
let state = { G: {}, ctx: flow.ctx(3) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' });
state = flow.processEvent(
state,
gameEvent('setStage', { stage: 'B', minMoves: 1 })
);
expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'A', '2': 'A' });
state = flow.processEvent(
state,
gameEvent('setStage', { stage: 'B', maxMoves: 1 }, '1')
);
expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'B', '2': 'A' });
});
test('resets move count', () => {
const flow = Flow({
moves: { A: () => {} },
turn: {
activePlayers: { currentPlayer: 'A' },
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
state = flow.processMove(state, makeMove('A', null, '0').payload);
expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 });
state = flow.processEvent(state, gameEvent('setStage', 'B'));
expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
});
test('with min moves', () => {
const flow = Flow({});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx._activePlayersMinMoves).toBeNull();
expect(state.ctx._activePlayersMaxMoves).toBeNull();
state = flow.processEvent(
state,
gameEvent('setStage', { stage: 'A', minMoves: 1 })
);
expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 1 });
expect(state.ctx._activePlayersMaxMoves).toBeNull();
});
test('with max moves', () => {
const flow = Flow({});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx._activePlayersMinMoves).toBeNull();
expect(state.ctx._activePlayersMaxMoves).toBeNull();
state = flow.processEvent(
state,
gameEvent('setStage', { stage: 'A', maxMoves: 1 })
);
expect(state.ctx._activePlayersMinMoves).toBeNull();
expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1 });
});
test('empty argument ends stage', () => {
const flow = Flow({ turn: { activePlayers: { currentPlayer: 'A' } } });
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
state = flow.processEvent(state, gameEvent('setStage', {}));
expect(state.ctx.activePlayers).toBeNull();
});
describe('disallowed in hooks', () => {
const setStage: MoveFn = ({ events }) => {
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', () => {
test('basic', () => {
const flow = Flow({
turn: {
activePlayers: { currentPlayer: 'A' },
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toEqual({ '0': 'A' });
state = flow.processEvent(state, gameEvent('endStage'));
expect(state.ctx.activePlayers).toBeNull();
});
test('with multiple active players', () => {
const flow = Flow({
turn: {
activePlayers: { all: 'A', maxMoves: 5 },
},
});
let state = { G: {}, ctx: flow.ctx(3) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' });
state = flow.processEvent(state, gameEvent('endStage'));
expect(state.ctx.activePlayers).toEqual({ '1': 'A', '2': 'A' });
});
test('with min moves', () => {
const flow = Flow({
turn: {
activePlayers: { all: 'A', minMoves: 2 },
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
state = flow.processEvent(state, gameEvent('endStage'));
// player 0 is not allowed to end the stage, they haven't made any move yet
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endStage'));
// player 0 is still not allowed to end the stage, they haven't made the minimum number of moves
expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' });
state = flow.processMove(state, makeMove('move', null, '0').payload);
state = flow.processEvent(state, gameEvent('endStage'));
// having made 2 moves, player 0 was allowed to end the stage
expect(state.ctx.activePlayers).toEqual({ '1': 'A' });
});
test('maintains move count', () => {
const flow = Flow({
moves: { A: () => {} },
turn: {
activePlayers: { currentPlayer: 'A' },
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 });
state = flow.processMove(state, makeMove('A', null, '0').payload);
expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 });
state = flow.processEvent(state, gameEvent('endStage'));
expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 1 });
});
test('sets to next', () => {
const flow = Flow({
turn: {
activePlayers: { currentPlayer: 'A1', others: 'B1' },
stages: {
A1: { next: 'A2' },
B1: { next: 'B2' },
},
},
});
let state = { G: {}, ctx: flow.ctx(2) } as State;
state = flow.init(state);
expect(state.ctx.activePlayers).toMatchObject({
'0': 'A1',
'1': 'B1',
});
state = flow.processEvent(state, gameEvent('endStage', null, '0'));
expect(state.ctx.activePlayers).toMatchObject({
'0': 'A2',
'1': 'B1',
});
state = flow.processEvent(state, gameEvent('endStage', null, '1'));
expect(state.ctx.activePlayers).toMatchObject({
'0': 'A2',
'1': 'B2',
});
});
describe('disallowed in hooks', () => {
const endStage: MoveFn = ({ events }) => {
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: ({ events }) => {
events.setActivePlayers({ currentPlayer: 'A' });
},
},
moves: {
updateActivePlayers: ({ events }) => {
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 = ({ events }) => {
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/);
});
});
});
});
test('init', () => {
let flow = Flow({
phases: { A: { start: true, onEnd: () => ({ done: true }) } },
});
const orig = flow.ctx(2);
let state = { G: {}, ctx: orig } as State;
state = flow.processEvent(state, gameEvent('init'));
expect(state).toEqual({ G: {}, ctx: orig });
flow = Flow({
phases: { A: { start: true, onBegin: () => ({ done: true }) } },
});
state = { ctx: orig } as State;
state = flow.init(state);
expect(state.G).toMatchObject({ done: true });
});
test('next', () => {
const flow = Flow({
phases: {
A: { start: true, next: () => 'C' },
B: {},
C: {},
},
});
let state = { ctx: flow.ctx(3) } as State;
state = flow.processEvent(state, gameEvent('endPhase'));
expect(state.ctx.phase).toEqual('C');
});
describe('endIf', () => {
test('basic', () => {
const flow = Flow({ endIf: ({ G }) => G.win });
let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State);
state = flow.processEvent(state, gameEvent('endTurn'));
expect(state.ctx.gameover).toBe(undefined);
state.G = { win: 'A' };
{
const t = flow.processEvent(state, gameEvent('endTurn'));
expect(t.ctx.gameover).toBe('A');
}
{
const t = flow.processMove(state, makeMove('move').payload);
expect(t.ctx.gameover).toBe('A');
}
});
test('phase automatically ends', () => {
const game: Game = {
phases: {
A: {
start: true,
moves: {
A: () => ({ win: 'A' }),
B: ({ G }) => G,
},
},
},
endIf: ({ G }) => G.win,
};
const client = Client({ game });
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.B();
expect(client.getState().ctx.gameover).toBe(undefined);
expect(client.getState().ctx.currentPlayer).toBe('0');
client.moves.A();
expect(client.getState().ctx.gameover).toBe('A');
expect(
client.getState().deltalog[client.getState().deltalog.length - 1].action
.payload.type
).toBe('endPhase');
});
test('during game initialization with phases', () => {
const flow = Flow({
phases: {
A: {
start: true,
},
},
endIf: () => 'gameover',
});
const state = flow.init({ G: {}, ctx: flow.ctx(2) } as State);
expect(state.ctx.gameover).toBe('gameover');
});
});
test('isPlayerActive', () => {
const playerID = '0';
const flow = Flow({});
expect(flow.isPlayerActive({}, {} as Ctx, playerID)).toBe(false);
expect(
flow.isPlayerActive(
{},
{ currentPlayer: '0', activePlayers: { '1': '' } } as unknown as Ctx,
playerID
)
).toBe(false);
expect(flow.isPlayerActive({}, { currentPlayer: '0' } as Ctx, playerID)).toBe(
true
);
});
describe('endGame', () => {
let client: ReturnType<typeof Client>;
beforeEach(() => {
const game: Game = {
events: { endGame: true },
};
client = Client({ game });
});
test('without arguments', () => {
client.events.endGame();
expect(client.getState().ctx.gameover).toBe(true);
});
test('with arguments', () => {
client.events.endGame(42);
expect(client.getState().ctx.gameover).toBe(42);
});
});
describe('endTurn args', () => {
const flow = Flow({
phases: { A: { start: true, next: 'B' }, B: {}, C: {} },
});
const state = { ctx: flow.ctx(3) } as State;
beforeEach(() => {
jest.resetAllMocks();
});
test('no args', () => {
let t = state;
t = flow.processEvent(t, gameEvent('endPhase'));
t = flow.processEvent(t, gameEvent('endTurn'));
expect(t.ctx.playOrderPos).toBe(1);
expect(t.ctx.currentPlayer).toBe('1');
expect(t.ctx.phase).toBe('B');
});
test('invalid arg to endTurn', () => {
let t = state;
t = flow.processEvent(t, gameEvent('endTurn', '2'));
expect(error).toBeCalledWith(`invalid argument to endTurn: 2`);
expect(t.ctx.currentPlayer).toBe('0');
});
test('valid args', () => {
let t = state;
t = flow.processEvent(t, gameEvent('endTurn', { next: '2' }));
expect(t.ctx.playOrderPos).toBe(2);
expect(t.ctx.currentPlayer).toBe('2');
});
});
describe('pass args', () => {
const flow = Flow({
phases: { A: { start: true, next: 'B' }, B: {}, C: {} },
});
const state = { ctx: flow.ctx(3) } as State;
beforeEach(() => {
jest.resetAllMocks();
});
test('no args', () => {
let t = state;
t = flow.processEvent(t, gameEvent('pass'));
expect(t.ctx.turn).toBe(1);
expect(t.ctx.playOrderPos).toBe(1);
expect(t.ctx.currentPlayer).toBe('1');
});
test('invalid arg to pass', () => {
let t = state;
t = flow.processEvent(t, gameEvent('pass', '2'));
expect(error).toBeCalledWith(`invalid argument to endTurn: 2`);
expect(t.ctx.currentPlayer).toBe('0');
});
test('valid args', () => {
let t = state;
t = flow.processEvent(t, gameEvent('pass', { remove: true }));
expect(t.ctx.turn).toBe(1);
expect(t.ctx.playOrderPos).toBe(0);
expect(t.ctx.currentPlayer).toBe('1');
});
test('removing all players ends phase', () => {
let t = state;
t = flow.processEvent(t, gameEvent('pass', { remove: true }));
t = flow.processEvent(t, gameEvent('pass', { remove: true }));
t = flow.processEvent(t, gameEvent('pass', { remove: true }));
expect(t.ctx.playOrderPos).toBe(0);
expect(t.ctx.currentPlayer).toBe('0');
expect(t.ctx.phase).toBe('B');
});
test('playOrderPos does not go out of bounds when passing at the end of the list', () => {
let t = state;
t = flow.processEvent(t, gameEvent('pass'));
t = flow.processEvent(t, gameEvent('pass'));
t = flow.processEvent(t, gameEvent('pass', { remove: true }));
expect(t.ctx.currentPlayer).toBe('0');
});
test('removing a player deeper into play order returns correct updated playOrder', () => {
let t = state;
t = flow.processEvent(t, gameEvent('pass'));
t = flow.processEvent(t, gameEvent('pass', { remove: true }));
expect(t.ctx.playOrderPos).toBe(1);
expect(t.ctx.currentPlayer).toBe('2');
});
});
test('undoable moves', () => {
const game: Game = {
moves: {
A: {
move: () => ({ A: true }),
undoable: ({ ctx }) => {
return ctx.phase == 'A';
},
},
B: {
move: () => ({ B: true }),
undoable: false,
},
C: () => ({ C: true }),
},
phases: {
A: { start: true },
B: {},
},
};
const client = Client({ game });
client.moves.A();
expect(client.getState().G).toEqual({ A: true });
client.undo();
expect(client.getState().G).toEqual({});
client.moves.B();
expect(client.getState().G).toEqual({ B: true });
client.undo();
expect(client.getState().G).toEqual({ B: true });
client.moves.C();
expect(client.getState().G).toEqual({ C: true });
client.undo();
expect(client.getState().G).toEqual({ B: true });
client.reset();
client.events.setPhase('B');
expect(client.getState().ctx.phase).toBe('B');
client.moves.A();
expect(client.getState().G).toEqual({ A: true });
client.undo();
expect(client.getState().G).toEqual({ A: true });
client.moves.B();
expect(client.getState().G).toEqual({ B: true });
client.undo();
expect(client.getState().G).toEqual({ B: true });
client.moves.C();
expect(client.getState().G).toEqual({ C: true });
client.undo();
expect(client.getState().G).toEqual({ B: true });
});
describe('moveMap', () => {
const game: Game = {
moves: { A: () => {} },
turn: {
stages: {
SA: {
moves: {
A: () => {},
},
},
},
},
phases: {
PA: {
moves: {
A: () => {},
},
turn: {
stages: {
SB: {
moves: {
A: () => {},
},
},
},
},
},
},
};
test('basic', () => {
const { moveMap } = Flow(game);
expect(Object.keys(moveMap)).toEqual(['PA.A', 'PA.SB.A', '.SA.A']);
});
});
describe('infinite loops', () => {
test('infinite loop of self-ending phases via endIf', () => {
const endIf = () => true;
const game: Game = {
phases: {
A: { endIf, next: 'B', start: true },
B: { endIf, next: 'A' },
},
};
const client = Client({ game });
expect(client.getState().ctx.phase).toBe(null);
});
test('infinite endPhase loop from phase.onBegin', () => {
const onBegin = ({ events }) => void events.endPhase();
const game: Game = {
phases: {
A: {
onBegin,
next: 'B',
start: true,
moves: {
a: ({ events }) => void events.endPhase(),
},
},
B: { onBegin, next: 'C' },
C: { onBegin, next: 'A' },
},
};
// The onBegin fails to end the phase during initialisation.
const client = Client({ game, numPlayers: 3 });
let state = client.getState();
expect(state.ctx.phase).toBe('A');
expect(state.ctx.turn).toBe(1);
expect(error).toHaveBeenCalled();
{
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();
{
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);
});
test('double phase ending from client event and turn.onEnd', () => {
const game: Game = {
turn: {
onEnd: ({ events }) => void events.endPhase(),
},
phases: {
A: { next: 'B', start: true },
B: { next: 'C' },
C: { next: 'A' },
},
};
const client = Client({ game });
let state = client.getState();
expect(state.ctx.phase).toBe('A');
expect(state.ctx.turn).toBe(1);
client.events.endPhase();
state = client.getState();
expect(state.ctx.phase).toBe('B');
expect(state.ctx.turn).toBe(2);
});
test('infinite turn endings from turn.onBegin', () => {
const game: Game = {
moves: {
endTurn: ({ events }) => {
events.endTurn();
},
},
turn: {
onBegin: ({ events }) => void events.endTurn(),
},
};
const client = Client({ game });
const initialState = client.getState();
expect(client.getState().ctx.currentPlayer).toBe('0');
// Trigger infinite loop
client.moves.endTurn();
// Expect state to be unchanged and error to be logged.
expect(error).toHaveBeenCalled();
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: [] });
});
test('double turn ending from event and endIf', () => {
const game: Game = {
moves: {
endTurn: ({ events }) => {
events.endTurn();
},
},
turn: {
endIf: () => true,
},
};
const client = Client({ game });
// turn.endIf is ignored during game setup.
let state = client.getState();
expect(state.ctx.currentPlayer).toBe('0');
expect(state.ctx.turn).toBe(1);
// turn.endIf is ignored when the turn was just ended.
client.moves.endTurn();
state = client.getState();
expect(state.ctx.currentPlayer).toBe('1');
expect(state.ctx.turn).toBe(2);
});
test('endIf that triggers endIf', () => {
const game: Game = {
phases: {
A: {
endIf: ({ events }) => {
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', () => {
const moves = {
setAutoEnd: () => ({ shouldEnd: true }),
};
describe('endTurn', () => {
const conditionalEndTurn = ({ G, events }) => {
if (!G.shouldEnd) return;
G.shouldEnd = false;
events.endTurn();
};
test('can end turn from turn.onBegin', () => {
const client = Client({
game: { moves, turn: { onBegin: conditionalEndTurn } },
});
client.moves.setAutoEnd();
let state = client.getState();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
client.events.endTurn();
state = client.getState();
expect(state.ctx.turn).toBe(3);
expect(state.ctx.currentPlayer).toBe('0');
});
test('cannot end turn from phase.onBegin', () => {
const client = Client({
game: {
moves,
phases: {
A: { onBegin: conditionalEndTurn },
},
},
});
client.moves.setAutoEnd();
let state = client.getState();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
expect(state.ctx.phase).toBeNull();
client.events.setPhase('A');
state = client.getState();
expect(state.ctx.turn).toBe(2);
expect(state.ctx.currentPlayer).toBe('1');
expect(state.ctx.phase).toBe('A');
});
test('can end turn from turn.onBegin at start of phase', () => {
const client = Client({
game: {
moves,
phases: {
A: {
turn: { onBegin: conditionalEndTurn },
},
},
},
});
client.moves.setAutoEnd();
let state = client.getState();
expect(state.ctx.phase).toBeNull();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
client.events.setPhase('A');
state = client.getState();
expect(state.ctx.phase).toBe('A');
expect(state.ctx.turn).toBe(3);
expect(state.ctx.currentPlayer).toBe('0');
});
test('cannot end turn from turn.onEnd', () => {
const client = Client({
game: {
moves,
turn: { onEnd: conditionalEndTurn },
},
});
let state = client.getState();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
client.moves.setAutoEnd();
client.events.endTurn();
state = client.getState();
expect(state.ctx.turn).toBe(1);
expect(state.ctx.currentPlayer).toBe('0');
expect(error).toHaveBeenCalled