diff --git a/src/client/client.test.js b/src/client/client.test.js index 538db4299..be77352d5 100644 --- a/src/client/client.test.js +++ b/src/client/client.test.js @@ -59,13 +59,19 @@ test('event dispatchers', () => { { const game = Game({ flow: { + undo: true, endPhase: true, }, }); const reducer = createGameReducer({ game, numPlayers: 2 }); const store = createStore(reducer); const api = createEventDispatchers(game.flow.eventNames, store); - expect(Object.getOwnPropertyNames(api)).toEqual(['endTurn', 'endPhase']); + expect(Object.getOwnPropertyNames(api)).toEqual([ + 'undo', + 'redo', + 'endTurn', + 'endPhase', + ]); expect(store.getState().ctx.turn).toBe(0); api.endTurn(); expect(store.getState().ctx.turn).toBe(1); @@ -74,8 +80,12 @@ test('event dispatchers', () => { { const game = Game({ flow: { + endPhase: false, endTurn: false, + undo: false, }, + + phases: [{ name: 'default' }], }); const reducer = createGameReducer({ game, numPlayers: 2 }); const store = createStore(reducer); diff --git a/src/core/flow.js b/src/core/flow.js index 739874af7..bdc82653a 100644 --- a/src/core/flow.js +++ b/src/core/flow.js @@ -130,6 +130,8 @@ export function Flow({ * * @param {...object} endPhase - Set to false to disable the `endPhase` event. * + * @param {...object} undo - Set to true to enable the undo/redo events. + * * @param {...object} optimisticUpdate - (G, ctx, move) => boolean * Control whether a move should * be executed optimistically on @@ -194,6 +196,7 @@ export function FlowWithPhases({ turnOrder, endTurn, endPhase, + undo, optimisticUpdate, }) { // Attach defaults. @@ -203,6 +206,9 @@ export function FlowWithPhases({ if (endTurn === undefined) { endTurn = true; } + if (undo === undefined) { + undo = false; + } if (optimisticUpdate === undefined) { optimisticUpdate = () => true; } @@ -272,7 +278,8 @@ export function FlowWithPhases({ const startTurn = function(state, config) { const ctx = { ...state.ctx }; const G = config.onTurnBegin(state.G, ctx); - return { ...state, G, ctx }; + const _undo = [{ G, ctx }]; + return { ...state, G, ctx, _undo, _redo: [] }; }; const startGame = function(state, config) { @@ -339,8 +346,7 @@ export function FlowWithPhases({ * Passes the turn to the next turn in a round-robin fashion. */ function endTurnEvent(state) { - let G = state.G; - let ctx = state.ctx; + let { G, ctx } = state; const conf = phaseMap[ctx.phase]; @@ -374,6 +380,43 @@ export function FlowWithPhases({ return startTurn({ ...state, G, ctx }, conf); } + function undoEvent(state) { + const { _undo, _redo } = state; + + if (_undo.length < 2) { + return state; + } + + const last = _undo[_undo.length - 1]; + const restore = _undo[_undo.length - 2]; + + return { + ...state, + G: restore.G, + ctx: restore.ctx, + _undo: _undo.slice(0, _undo.length - 1), + _redo: [last, ..._redo], + }; + } + + function redoEvent(state) { + const { _undo, _redo } = state; + + if (_redo.length == 0) { + return state; + } + + const first = _redo[0]; + + return { + ...state, + G: first.G, + ctx: first.ctx, + _undo: [..._undo, first], + _redo: _redo.slice(1), + }; + } + function processMove(state, action, dispatch) { // Update currentPlayerMoves. const currentPlayerMoves = state.ctx.currentPlayerMoves + 1; @@ -387,7 +430,8 @@ export function FlowWithPhases({ const gameover = conf.endGameIf(state.G, state.ctx); // End the turn automatically if endTurnIf is true or if endGameIf returns. - if (endTurnIfWrap(state.G, state.ctx) || gameover !== undefined) { + const endTurn = endTurnIfWrap(state.G, state.ctx); + if (endTurn || gameover !== undefined) { state = dispatch(state, { type: 'endTurn', playerID: action.playerID }); } @@ -406,6 +450,16 @@ export function FlowWithPhases({ }); } + // Update undo / redo state. + if (!endTurn) { + const undo = state._undo || []; + state = { + ...state, + _undo: [...undo, { G: state.G, ctx: state.ctx }], + _redo: [], + }; + } + return state; } @@ -419,6 +473,10 @@ export function FlowWithPhases({ }; let enabledEvents = {}; + if (undo) { + enabledEvents['undo'] = undoEvent; + enabledEvents['redo'] = redoEvent; + } if (endTurn) enabledEvents['endTurn'] = endTurnEvent; if (endPhase) enabledEvents['endPhase'] = endPhaseEvent; diff --git a/src/core/flow.test.js b/src/core/flow.test.js index e1fb4c6d6..705cfe7e3 100644 --- a/src/core/flow.test.js +++ b/src/core/flow.test.js @@ -463,3 +463,61 @@ test('validator', () => { state = reducer(state, makeMove('B')); expect(state.G).not.toMatchObject({ B: true }); }); + +test('undo / redo', () => { + let game = Game({ + moves: { + move: (G, ctx, arg) => ({ ...G, [arg]: true }), + }, + + flow: { + undo: true, + }, + }); + + const reducer = createGameReducer({ game, numPlayers: 2 }); + + let state = reducer(undefined, { type: 'init' }); + + state = reducer(state, makeMove('move', 'A')); + expect(state.G).toEqual({ A: true }); + + state = reducer(state, makeMove('move', 'B')); + expect(state.G).toEqual({ A: true, B: true }); + + state = reducer(state, gameEvent('undo')); + expect(state.G).toEqual({ A: true }); + + state = reducer(state, gameEvent('redo')); + expect(state.G).toEqual({ A: true, B: true }); + + state = reducer(state, gameEvent('redo')); + expect(state.G).toEqual({ A: true, B: true }); + + state = reducer(state, gameEvent('undo')); + expect(state.G).toEqual({ A: true }); + + state = reducer(state, gameEvent('undo')); + state = reducer(state, gameEvent('undo')); + state = reducer(state, gameEvent('undo')); + expect(state.G).toEqual({}); + + state = reducer(state, gameEvent('redo')); + state = reducer(state, makeMove('move', 'C')); + expect(state.G).toEqual({ A: true, C: true }); + + state = reducer(state, gameEvent('undo')); + expect(state.G).toEqual({ A: true }); + + state = reducer(state, gameEvent('redo')); + expect(state.G).toEqual({ A: true, C: true }); + + state = reducer(state, gameEvent('undo')); + state = reducer(state, gameEvent('undo')); + state = reducer(state, makeMove('move', 'A')); + expect(state.G).toEqual({ A: true }); + + state = reducer(state, gameEvent('endTurn')); + state = reducer(state, gameEvent('undo')); + expect(state.G).toEqual({ A: true }); +}); diff --git a/src/core/reducer.js b/src/core/reducer.js index 513731547..ffbc726f9 100644 --- a/src/core/reducer.js +++ b/src/core/reducer.js @@ -33,6 +33,12 @@ export function createGameReducer({ game, numPlayers, multiplayer }) { // GameLog to display a journal of moves. log: [], + // List of {G, ctx} pairs that can be undone. + _undo: [], + + // List of {G, ctx} pairs that can be redone. + _redo: [], + // A monotonically non-decreasing ID to ensure that // state updates are only allowed from clients that // are at the same version that the server. @@ -50,6 +56,7 @@ export function createGameReducer({ game, numPlayers, multiplayer }) { initial.G = state.G; initial.ctx = state.ctx; + initial._undo = state._undo; const deepCopy = obj => JSON.parse(JSON.stringify(obj)); initial._initial = deepCopy(initial); @@ -74,17 +81,12 @@ export function createGameReducer({ game, numPlayers, multiplayer }) { // Init PRNG state. PRNGState.set(state.ctx._random); - - let { G, ctx } = game.flow.processGameEvent( - { G: state.G, ctx: state.ctx }, - action.payload - ); - + // Update state. + const newState = game.flow.processGameEvent(state, action.payload); // Update PRNG state. - ctx = { ...ctx, _random: PRNGState.get() }; + const ctx = { ...newState.ctx, _random: PRNGState.get() }; - const log = [...state.log, action]; - return { ...state, G, ctx, log, _stateID: state._stateID + 1 }; + return { ...newState, ctx, _stateID: state._stateID + 1 }; } case Actions.MAKE_MOVE: { diff --git a/src/core/reducer.test.js b/src/core/reducer.test.js index 3453560e0..7d99c19fb 100644 --- a/src/core/reducer.test.js +++ b/src/core/reducer.test.js @@ -149,5 +149,5 @@ test('log', () => { state = reducer(state, actionB); expect(state.log).toEqual([actionA, actionB]); state = reducer(state, actionC); - expect(state.log).toEqual([actionA, actionB, actionC]); + expect(state.log).toEqual([actionA, actionB, actionC.payload]); }); diff --git a/src/server/index.test.js b/src/server/index.test.js index b73e9258e..e45f7cd96 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -165,35 +165,58 @@ test('action', async () => { await io.socket.receive('action', action, 0, 'gameID', '0'); expect(io.socket.emit).lastCalledWith('sync', 'gameID', { G: {}, - _stateID: 1, _initial: { G: {}, - _stateID: 0, _initial: {}, + _redo: [], + _stateID: 0, + _undo: [ + { + G: {}, + ctx: { + _random: { seed: 0 }, + currentPlayer: '0', + currentPlayerMoves: 0, + numPlayers: 2, + phase: 'default', + turn: 0, + }, + }, + ], ctx: { + _random: { seed: 0 }, currentPlayer: '0', currentPlayerMoves: 0, numPlayers: 2, phase: 'default', turn: 0, - _random: { seed: 0 }, }, log: [], }, + _redo: [], + _stateID: 1, + _undo: [ + { + G: {}, + ctx: { + _random: { seed: 0 }, + currentPlayer: '1', + currentPlayerMoves: 0, + numPlayers: 2, + phase: 'default', + turn: 1, + }, + }, + ], ctx: { + _random: undefined, currentPlayer: '1', currentPlayerMoves: 0, numPlayers: 2, phase: 'default', turn: 1, - _random: undefined, }, - log: [ - { - payload: { args: undefined, playerID: undefined, type: 'endTurn' }, - type: 'GAME_EVENT', - }, - ], + log: [{ args: undefined, playerID: undefined, type: 'endTurn' }], }); io.socket.emit.mockReset();