Skip to content

Commit

Permalink
undo/redo
Browse files Browse the repository at this point in the history
  • Loading branch information
darthfiddler committed Mar 1, 2018
1 parent 6d184a7 commit eec8896
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 25 deletions.
12 changes: 11 additions & 1 deletion src/client/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
66 changes: 62 additions & 4 deletions src/core/flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,6 +196,7 @@ export function FlowWithPhases({
turnOrder,
endTurn,
endPhase,
undo,
optimisticUpdate,
}) {
// Attach defaults.
Expand All @@ -203,6 +206,9 @@ export function FlowWithPhases({
if (endTurn === undefined) {
endTurn = true;
}
if (undo === undefined) {
undo = false;
}
if (optimisticUpdate === undefined) {
optimisticUpdate = () => true;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -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;
Expand All @@ -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 });
}

Expand All @@ -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;
}

Expand All @@ -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;

Expand Down
58 changes: 58 additions & 0 deletions src/core/flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
20 changes: 11 additions & 9 deletions src/core/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/core/reducer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
43 changes: 33 additions & 10 deletions src/server/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down

0 comments on commit eec8896

Please sign in to comment.