UNPKG

boardgame.io

Version:
992 lines (872 loc) 27.6 kB
/* * 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 { INVALID_MOVE } from './constants'; import { applyMiddleware, createStore } from 'redux'; import { CreateGameReducer, TransientHandlingMiddleware } from './reducer'; import { InitializeGame } from './initialize'; import { makeMove, gameEvent, sync, update, reset, undo, redo, patch, } from './action-creators'; import { error } from '../core/logger'; import type { Game, State, SyncInfo } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), error: jest.fn(), })); const game: Game = { moves: { A: ({ G }) => G, B: () => ({ moved: true }), C: () => ({ victory: true }), Invalid: () => INVALID_MOVE, }, endIf: ({ G, ctx }) => (G.victory ? ctx.currentPlayer : undefined), }; const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); test('_stateID is incremented', () => { let state = initialState; state = reducer(state, makeMove('A')); expect(state._stateID).toBe(1); state = reducer(state, gameEvent('endTurn')); expect(state._stateID).toBe(2); }); test('move returns INVALID_MOVE', () => { const game: Game = { moves: { A: () => INVALID_MOVE, }, }; const reducer = CreateGameReducer({ game }); const state = reducer(initialState, makeMove('A')); expect(error).toBeCalledWith('invalid move: A args: undefined'); expect(state._stateID).toBe(0); }); test('makeMove', () => { let state = initialState; expect(state._stateID).toBe(0); state = reducer(state, makeMove('unknown')); expect(state._stateID).toBe(0); expect(state.G).not.toMatchObject({ moved: true }); expect(error).toBeCalledWith('disallowed move: unknown'); state = reducer(state, makeMove('A')); expect(state._stateID).toBe(1); expect(state.G).not.toMatchObject({ moved: true }); state = reducer(state, makeMove('B')); expect(state._stateID).toBe(2); expect(state.G).toMatchObject({ moved: true }); state.ctx.gameover = true; state = reducer(state, makeMove('B')); expect(state._stateID).toBe(2); expect(error).toBeCalledWith('cannot make move after game end'); state = reducer(state, gameEvent('endTurn')); expect(state._stateID).toBe(2); expect(error).toBeCalledWith('cannot call event after game end'); }); test('disable move by invalid playerIDs', () => { let state = initialState; expect(state._stateID).toBe(0); // playerID="1" cannot move right now. state = reducer(state, makeMove('A', null, '1')); expect(state._stateID).toBe(0); // playerID="1" cannot call events right now. state = reducer(state, gameEvent('endTurn', null, '1')); expect(state._stateID).toBe(0); // playerID="0" can move. state = reducer(state, makeMove('A', null, '0')); expect(state._stateID).toBe(1); // playerID=undefined can always move. state = reducer(state, makeMove('A')); expect(state._stateID).toBe(2); }); test('sync', () => { const state = reducer( undefined, sync({ state: { G: 'restored' } } as SyncInfo) ); expect(state).toEqual({ G: 'restored' }); }); test('update', () => { const state = reducer(undefined, update({ G: 'restored' } as State, [])); expect(state).toEqual({ G: 'restored' }); }); test('valid patch', () => { const originalState = { _stateID: 0, G: 'patch' } as State; const state = reducer( originalState, patch(0, 1, [{ op: 'replace', path: '/_stateID', value: 1 }], []) ); expect(state).toEqual({ _stateID: 1, G: 'patch' }); }); test('invalid patch', () => { const originalState = { _stateID: 0, G: 'patch' } as State; const { transients, ...state } = reducer( originalState, patch(0, 1, [{ op: 'replace', path: '/_stateIDD', value: 1 }], []) ); expect(state).toEqual(originalState); expect(transients.error.type).toEqual('update/patch_failed'); // It's an array. expect(transients.error.payload.length).toEqual(1); // It looks like the standard rfc6902 error language. expect(transients.error.payload[0].toString()).toContain('/_stateIDD'); }); test('reset', () => { let state = reducer(initialState, makeMove('A')); expect(state).not.toEqual(initialState); state = reducer(state, reset(initialState)); expect(state).toEqual(initialState); }); test('victory', () => { let state = reducer(initialState, makeMove('A')); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.gameover).toEqual(undefined); state = reducer(state, makeMove('B')); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.gameover).toEqual(undefined); state = reducer(state, makeMove('C')); expect(state.ctx.gameover).toEqual('0'); }); test('endTurn', () => { { const state = reducer(initialState, gameEvent('endTurn')); expect(state.ctx.turn).toBe(2); } { const reducer = CreateGameReducer({ game, isClient: true }); const state = reducer(initialState, gameEvent('endTurn')); expect(state.ctx.turn).toBe(1); } }); test('light client when multiplayer=true', () => { const game: Game = { moves: { A: () => ({ win: true }) }, endIf: ({ G }) => G.win, }; { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.ctx.gameover).toBe(undefined); state = reducer(state, makeMove('A')); expect(state.ctx.gameover).toBe(true); } { const reducer = CreateGameReducer({ game, isClient: true }); let state = InitializeGame({ game }); expect(state.ctx.gameover).toBe(undefined); state = reducer(state, makeMove('A')); expect(state.ctx.gameover).toBe(undefined); } }); test('disable optimistic updates', () => { const game: Game = { moves: { A: { move: () => ({ A: true }), client: false, }, }, }; { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); expect(state.G).not.toMatchObject({ A: true }); state = reducer(state, makeMove('A')); expect(state.G).toMatchObject({ A: true }); } { const reducer = CreateGameReducer({ game, isClient: true }); let state = InitializeGame({ game }); expect(state.G).not.toMatchObject({ A: true }); state = reducer(state, makeMove('A')); expect(state.G).not.toMatchObject({ A: true }); } }); test('numPlayers', () => { const numPlayers = 4; const state = InitializeGame({ game, numPlayers }); expect(state.ctx.numPlayers).toBe(4); }); test('deltalog', () => { let state = initialState; const actionA = makeMove('A'); const actionB = makeMove('B'); const actionC = gameEvent('endTurn'); state = reducer(state, actionA); expect(state.deltalog).toEqual([ { action: actionA, _stateID: 0, phase: null, turn: 1, }, ]); state = reducer(state, actionB); expect(state.deltalog).toEqual([ { action: actionB, _stateID: 1, phase: null, turn: 1, }, ]); state = reducer(state, actionC); expect(state.deltalog).toEqual([ { action: actionC, _stateID: 2, phase: null, turn: 1, }, ]); }); describe('Events API', () => { const fn = ({ events }) => (events ? {} : { error: true }); const game: Game = { setup: () => ({}), phases: { A: {} }, turn: { onBegin: fn, onEnd: fn, onMove: fn, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('is attached at the beginning', () => { expect(state.G).not.toEqual({ error: true }); }); test('is attached at the end of turns', () => { state = reducer(state, gameEvent('endTurn')); expect(state.G).not.toEqual({ error: true }); }); test('is attached at the end of phases', () => { state = reducer(state, gameEvent('endPhase')); expect(state.G).not.toEqual({ error: true }); }); }); describe('Plugin Invalid Action API', () => { const pluginName = 'validator'; const message = 'G.value must divide by 5'; const game: Game<{ value: number }> = { setup: () => ({ value: 5 }), plugins: [ { name: pluginName, isInvalid: ({ G }) => { if (G.value % 5 !== 0) return message; return false; }, }, ], moves: { setValue: ({ G }, arg: number) => { G.value = arg; }, }, phases: { unenterable: { onBegin: () => ({ value: 13 }), }, enterable: { onBegin: () => ({ value: 25 }), }, }, }; let state: State; beforeEach(() => { state = InitializeGame({ game }); }); describe('multiplayer client', () => { const reducer = CreateGameReducer({ game }); test('move is cancelled if plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [6], '0')); expect(state.G).toMatchObject({ value: 5 }); expect(state['transients'].error).toEqual({ type: 'action/plugin_invalid', payload: { plugin: pluginName, message }, }); }); test('move is processed if no plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [15], '0')); expect(state.G).toMatchObject({ value: 15 }); expect(state['transients']).toBeUndefined(); }); test('event is cancelled if plugin declares it invalid', () => { state = reducer(state, gameEvent('setPhase', 'unenterable', '0')); expect(state.G).toMatchObject({ value: 5 }); expect(state.ctx.phase).toBe(null); expect(state['transients'].error).toEqual({ type: 'action/plugin_invalid', payload: { plugin: pluginName, message }, }); }); test('event is processed if no plugin declares it invalid', () => { state = reducer(state, gameEvent('setPhase', 'enterable', '0')); expect(state.G).toMatchObject({ value: 25 }); expect(state.ctx.phase).toBe('enterable'); expect(state['transients']).toBeUndefined(); }); }); describe('local client', () => { const reducer = CreateGameReducer({ game, isClient: true }); test('move is cancelled if plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [6], '0')); expect(state.G).toMatchObject({ value: 5 }); expect(state['transients'].error).toEqual({ type: 'action/plugin_invalid', payload: { plugin: pluginName, message }, }); }); test('move is processed if no plugin declares it invalid', () => { state = reducer(state, makeMove('setValue', [15], '0')); expect(state.G).toMatchObject({ value: 15 }); expect(state['transients']).toBeUndefined(); }); }); }); describe('Random inside setup()', () => { const game1: Game = { seed: 'seed1', setup: (ctx) => ({ n: ctx.random.D6() }), }; const game2: Game = { seed: 'seed2', setup: (ctx) => ({ n: ctx.random.D6() }), }; const game3: Game = { seed: 'seed2', setup: (ctx) => ({ n: ctx.random.D6() }), }; test('setting seed', () => { const state1 = InitializeGame({ game: game1 }); const state2 = InitializeGame({ game: game2 }); const state3 = InitializeGame({ game: game3 }); expect(state1.G.n).not.toBe(state2.G.n); expect(state2.G.n).toBe(state3.G.n); }); }); describe('redact', () => { const game: Game = { setup: () => ({ isASecret: false, }), moves: { A: { move: ({ G }) => G, redact: ({ G }) => G.isASecret, }, B: ({ G }) => { return { ...G, isASecret: true }; }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('move A is not secret and is not redact', () => { state = reducer(state, makeMove('A', ['not redact'], '0')); expect(state.G).toMatchObject({ isASecret: false, }); const [lastLogEntry] = state.deltalog.slice(-1); expect(lastLogEntry).toMatchObject({ action: { payload: { type: 'A', args: ['not redact'], }, }, redact: false, }); }); test('move A is secret and is redact', () => { state = reducer(state, makeMove('B', ['not redact'], '0')); state = reducer(state, makeMove('A', ['redact'], '0')); expect(state.G).toMatchObject({ isASecret: true, }); const [lastLogEntry] = state.deltalog.slice(-1); expect(lastLogEntry).toMatchObject({ action: { payload: { type: 'A', args: ['redact'], }, }, redact: true, }); }); }); describe('undo / redo', () => { const game: Game = { seed: 0, moves: { move: ({ G }, arg: string) => ({ ...G, [arg]: true }), roll: ({ G, random }) => { G.roll = random.D6(); }, }, turn: { stages: { special: {}, }, }, }; beforeEach(() => { jest.clearAllMocks(); }); const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); // TODO: Check if this test is still actually required after removal of APIs from ctx test('plugin APIs are not included in undo state', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, makeMove('move', 'B', '0')); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo[1].ctx).not.toHaveProperty('events'); expect(state._undo[1].ctx).not.toHaveProperty('random'); }); test('undo restores previous state after move', () => { const initial = reducer(initialState, makeMove('move', 'A', '0')); let newState = reducer(initial, makeMove('roll', null, '0')); newState = reducer(newState, undo()); expect(newState.G).toEqual(initial.G); expect(newState.ctx).toEqual(initial.ctx); expect(newState.plugins).toEqual(initial.plugins); }); test('undo restores previous state after event', () => { const initial = reducer( initialState, gameEvent('setStage', 'special', '0') ); let newState = reducer(initial, gameEvent('endStage', undefined, '0')); expect(error).not.toBeCalled(); // Make sure we actually modified the stage. expect(newState.ctx.activePlayers).not.toEqual(initial.ctx.activePlayers); newState = reducer(newState, undo()); expect(error).not.toBeCalled(); expect(newState.G).toEqual(initial.G); expect(newState.ctx).toEqual(initial.ctx); expect(newState.plugins).toEqual(initial.plugins); }); test('redo restores undone state', () => { let state = initialState; // Make two moves. const state1 = (state = reducer(state, makeMove('move', 'A', '0'))); const state2 = (state = reducer(state, makeMove('roll', null, '0'))); // Undo both of them. state = reducer(state, undo()); state = reducer(state, undo()); // Redo one of them. state = reducer(state, redo()); expect(state.G).toEqual(state1.G); expect(state.ctx).toEqual(state1.ctx); expect(state.plugins).toEqual(state1.plugins); // Redo a second time. state = reducer(state, redo()); expect(state.G).toEqual(state2.G); expect(state.ctx).toEqual(state2.ctx); expect(state.plugins).toEqual(state2.plugins); }); test('can undo redone state', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, undo()); state = reducer(state, redo()); state = reducer(state, undo()); expect(state.G).toMatchObject(initialState.G); expect(state.ctx).toMatchObject(initialState.ctx); expect(state.plugins).toMatchObject(initialState.plugins); }); test('undo has no effect if nothing to undo', () => { let state = reducer(initialState, undo()); state = reducer(state, undo()); state = reducer(state, undo()); expect(state.G).toMatchObject(initialState.G); expect(state.ctx).toMatchObject(initialState.ctx); expect(state.plugins).toMatchObject(initialState.plugins); }); test('redo works after multiple undos', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, undo()); state = reducer(state, undo()); state = reducer(state, undo()); state = reducer(state, redo()); state = reducer(state, makeMove('move', 'C', '0')); expect(state.G).toMatchObject({ A: true, C: true }); state = reducer(state, undo()); expect(state.G).toMatchObject({ A: true }); state = reducer(state, redo()); expect(state.G).toMatchObject({ A: true, C: true }); }); test('redo only resets deltalog if nothing to redo', () => { const state = reducer(initialState, makeMove('move', 'A', '0')); expect(reducer(state, redo())).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); }); test('disable undo / redo', () => { const game: Game = { seed: 0, disableUndo: true, moves: { move: ({ G }, arg: string) => ({ ...G, [arg]: true }), }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); state = reducer(state, makeMove('move', 'A', '0')); expect(state.G).toMatchObject({ A: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, makeMove('move', 'B', '0')); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, undo()); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, undo()); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); state = reducer(state, redo()); expect(state.G).toMatchObject({ A: true, B: true }); expect(state._undo).toEqual([]); expect(state._redo).toEqual([]); }); describe('undo stack', () => { const game: Game = { moves: { basic: () => {}, endTurn: ({ events }) => { events.endTurn(); }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('contains initial state at start of game', () => { expect(state._undo).toHaveLength(1); expect(state._undo[0].ctx).toEqual(state.ctx); expect(state._undo[0].plugins).toEqual(state.plugins); }); test('grows when a move is made', () => { state = reducer(state, makeMove('basic', null, '0')); expect(state._undo).toHaveLength(2); expect(state._undo[1].moveType).toBe('basic'); expect(state._undo[1].ctx).toEqual(state.ctx); expect(state._undo[1].plugins).toEqual(state.plugins); }); test('shrinks when a move is undone', () => { state = reducer(state, undo()); expect(state._undo).toHaveLength(1); expect(state._undo[0].ctx).toEqual(state.ctx); expect(state._undo[0].plugins).toEqual(state.plugins); }); test('grows when a move is redone', () => { state = reducer(state, redo()); expect(state._undo).toHaveLength(2); expect(state._undo[1].moveType).toBe('basic'); expect(state._undo[1].ctx).toEqual(state.ctx); expect(state._undo[1].plugins).toEqual(state.plugins); }); test('is reset when a turn ends', () => { state = reducer(state, makeMove('endTurn')); expect(state._undo).toHaveLength(1); expect(state._undo[0].ctx).toEqual(state.ctx); expect(state._undo[0].plugins).toEqual(state.plugins); expect(state._undo[0].moveType).toBe('endTurn'); }); test('can’t undo at the start of a turn', () => { const newState = reducer(state, undo()); expect(newState).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); test('can’t undo another player’s move', () => { state = reducer(state, makeMove('basic', null, '1')); const newState = reducer(state, undo('0')); expect(newState).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); }); describe('redo stack', () => { const game: Game = { moves: { basic: () => {}, endTurn: ({ events }) => { events.endTurn(); }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('is empty at start of game', () => { expect(state._redo).toHaveLength(0); }); test('grows when a move is undone', () => { state = reducer(state, makeMove('basic', null, '0')); state = reducer(state, undo()); expect(state._redo).toHaveLength(1); expect(state._redo[0].moveType).toBe('basic'); }); test('shrinks when a move is redone', () => { state = reducer(state, redo()); expect(state._redo).toHaveLength(0); }); test('is reset when a move is made', () => { state = reducer(state, makeMove('basic', null, '0')); state = reducer(state, undo()); state = reducer(state, undo()); expect(state._redo).toHaveLength(2); state = reducer(state, makeMove('basic', null, '0')); expect(state._redo).toHaveLength(0); }); test('is reset when a turn ends', () => { state = reducer(state, makeMove('basic', null, '0')); state = reducer(state, undo()); expect(state._redo).toHaveLength(1); state = reducer(state, makeMove('endTurn')); expect(state._redo).toHaveLength(0); }); test('can’t redo another player’s undo', () => { state = reducer(state, makeMove('basic', null, '1')); state = reducer(state, undo('1')); expect(state._redo).toHaveLength(1); const newState = reducer(state, redo('0')); expect(state._redo).toHaveLength(1); expect(newState).toMatchObject({ ...state, deltalog: [], transients: { error: { type: 'action/action_invalid', }, }, }); }); }); describe('undo / redo with stages', () => { const game: Game = { setup: () => ({ A: false, B: false, C: false }), turn: { activePlayers: { currentPlayer: 'start' }, stages: { start: { moves: { moveA: { move: ({ G, events }, moveAisReversible) => { events.setStage('A'); return { ...G, moveAisReversible, A: true }; }, undoable: ({ G }) => G.moveAisReversible > 0, }, }, }, A: { moves: { moveB: { move: ({ G, events }) => { events.setStage('B'); return { ...G, B: true }; }, undoable: false, }, }, }, B: { moves: { moveC: { move: ({ G, events }) => { events.setStage('C'); return { ...G, C: true }; }, undoable: true, }, }, }, C: { moves: {}, }, }, }, }; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game }); test('moveA sets state & moves player to stage A (undoable)', () => { state = reducer(state, makeMove('moveA', true, '0')); expect(state.G).toMatchObject({ moveAisReversible: true, A: true, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('A'); }); test('undo undoes last move (moveA)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ A: false, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('start'); }); test('redo redoes moveA', () => { state = reducer(state, redo('0')); expect(state.G).toMatchObject({ moveAisReversible: true, A: true, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('A'); }); test('undo undoes last move after redo (moveA)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ A: false, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('start'); }); test('moveA sets state & moves player to stage A (not undoable)', () => { state = reducer(state, makeMove('moveA', false, '0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: false, C: false, }); expect(state.ctx.activePlayers['0']).toBe('A'); }); test('moveB sets state & moves player to stage B', () => { state = reducer(state, makeMove('moveB', [], '0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('undo doesn’t undo last move if not undoable (moveB)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('moveC sets state & moves player to stage C', () => { state = reducer(state, makeMove('moveC', [], '0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: true, }); expect(state.ctx.activePlayers['0']).toBe('C'); }); test('undo undoes last move (moveC)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('redo redoes moveC', () => { state = reducer(state, redo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: true, }); expect(state.ctx.activePlayers['0']).toBe('C'); }); test('undo undoes last move after redo (moveC)', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); test('undo doesn’t undo last move if not undoable after undo/redo', () => { state = reducer(state, undo('0')); expect(state.G).toMatchObject({ moveAisReversible: false, A: true, B: true, C: false, }); expect(state.ctx.activePlayers['0']).toBe('B'); }); }); describe('TransientHandlingMiddleware', () => { const middleware = applyMiddleware(TransientHandlingMiddleware); let store = null; beforeEach(() => { store = createStore(reducer, initialState, middleware); }); test('regular dispatch result has no transients', () => { const result = store.dispatch(makeMove('A')); expect(result).toEqual( expect.not.objectContaining({ transients: expect.anything() }) ); expect(result).toEqual( expect.not.objectContaining({ stripTransientsResult: expect.anything() }) ); }); test('failing dispatch result contains transients', () => { const result = store.dispatch(makeMove('Invalid')); expect(result).toMatchObject({ transients: { error: { type: 'action/invalid_move', }, }, }); }); });