Skip to content

Commit 2595399

Browse files
authored
feat: Improve undo/redo (#823)
* fix(reducer): Include plugins’ state in undo/redo stack * feat(reducer): Include playerID in undo/redo stack * feat(reducer): Prevent a player from undoing another player’s action * feat(reducer): Prevent a player from redoing another player’s undo * refactor(reducer): Factor out hasPlayerID check for actions * fix(master): Improve undo/redo checks Fixes #565
1 parent 4f2ffc4 commit 2595399

8 files changed

Lines changed: 166 additions & 73 deletions

File tree

src/core/flow.test.js

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -201,34 +201,6 @@ describe('phases', () => {
201201
});
202202

203203
describe('turn', () => {
204-
test('onBegin with undoable feature', () => {
205-
const onBegin = jest.fn(G => G);
206-
const flow = Flow({
207-
turn: { onBegin },
208-
disableUndo: false,
209-
});
210-
const state = { ctx: flow.ctx(2) };
211-
212-
expect(onBegin).not.toHaveBeenCalled();
213-
const updatedState = flow.init(state);
214-
expect(onBegin).toHaveBeenCalled();
215-
expect(updatedState._undo).toHaveLength(1);
216-
});
217-
218-
test('onBegin without undoable feature', () => {
219-
const onBegin = jest.fn(G => G);
220-
const flow = Flow({
221-
turn: { onBegin },
222-
disableUndo: true,
223-
});
224-
const state = { ctx: flow.ctx(2) };
225-
226-
expect(onBegin).not.toHaveBeenCalled();
227-
const updatedState = flow.init(state);
228-
expect(onBegin).toHaveBeenCalled();
229-
expect(updatedState._undo).toHaveLength(0);
230-
});
231-
232204
test('onEnd', () => {
233205
const onEnd = jest.fn(G => G);
234206
const flow = Flow({

src/core/flow.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export function Flow({
4343
turn,
4444
events,
4545
plugins,
46-
disableUndo,
4746
}: Game) {
4847
// Attach defaults.
4948
if (moves === undefined) {
@@ -297,9 +296,7 @@ export function Flow({
297296

298297
G = conf.turn.wrapped.onBegin({ ...state, G, ctx });
299298

300-
const _undo = disableUndo ? [] : [{ G, ctx }];
301-
302-
return { ...state, G, ctx, _undo, _redo: [] };
299+
return { ...state, G, ctx, _undo: [], _redo: [] } as State;
303300
}
304301

305302
////////////

src/core/initialize.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,16 @@ export function InitializeGame({
6363
initial = game.flow.init(initial);
6464
initial = plugins.Flush(initial, { game });
6565

66+
// Initialize undo stack.
67+
if (!game.disableUndo) {
68+
initial._undo = [
69+
{
70+
G: initial.G,
71+
ctx: initial.ctx,
72+
plugins: initial.plugins,
73+
},
74+
];
75+
}
76+
6677
return initial;
6778
}

src/core/reducer.test.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,10 @@ test('undo / redo', () => {
314314

315315
let state = InitializeGame({ game });
316316

317-
state = reducer(state, makeMove('move', 'A'));
317+
state = reducer(state, makeMove('move', 'A', '0'));
318318
expect(state.G).toMatchObject({ A: true });
319319

320-
state = reducer(state, makeMove('move', 'B'));
320+
state = reducer(state, makeMove('move', 'B', '0'));
321321
expect(state.G).toMatchObject({ A: true, B: true });
322322
expect(state._undo[1].ctx.events).toBeUndefined();
323323
expect(state._undo[1].ctx.random).toBeUndefined();
@@ -340,7 +340,7 @@ test('undo / redo', () => {
340340
expect(state.G).toEqual({});
341341

342342
state = reducer(state, redo());
343-
state = reducer(state, makeMove('move', 'C'));
343+
state = reducer(state, makeMove('move', 'C', '0'));
344344
expect(state.G).toMatchObject({ A: true, C: true });
345345

346346
state = reducer(state, undo());
@@ -351,7 +351,7 @@ test('undo / redo', () => {
351351

352352
state = reducer(state, undo());
353353
state = reducer(state, undo());
354-
state = reducer(state, makeMove('roll'));
354+
state = reducer(state, makeMove('roll', null, '0'));
355355
expect(state.G).toMatchObject({ roll: 4 });
356356

357357
state = reducer(state, undo());
@@ -377,12 +377,12 @@ test('disable undo / redo', () => {
377377

378378
let state = InitializeGame({ game });
379379

380-
state = reducer(state, makeMove('move', 'A'));
380+
state = reducer(state, makeMove('move', 'A', '0'));
381381
expect(state.G).toMatchObject({ A: true });
382382
expect(state._undo).toEqual([]);
383383
expect(state._redo).toEqual([]);
384384

385-
state = reducer(state, makeMove('move', 'B'));
385+
state = reducer(state, makeMove('move', 'B', '0'));
386386
expect(state.G).toMatchObject({ A: true, B: true });
387387
expect(state._undo).toEqual([]);
388388
expect(state._redo).toEqual([]);
@@ -419,33 +419,49 @@ describe('undo stack', () => {
419419
test('contains initial state at start of game', () => {
420420
expect(state._undo).toHaveLength(1);
421421
expect(state._undo[0].ctx).toEqual(state.ctx);
422+
expect(state._undo[0].plugins).toEqual(state.plugins);
422423
});
423424

424425
test('grows when a move is made', () => {
425-
state = reducer(state, makeMove('basic'));
426+
state = reducer(state, makeMove('basic', null, '0'));
426427
expect(state._undo).toHaveLength(2);
427428
expect(state._undo[1].moveType).toBe('basic');
428429
expect(state._undo[1].ctx).toEqual(state.ctx);
430+
expect(state._undo[1].plugins).toEqual(state.plugins);
429431
});
430432

431433
test('shrinks when a move is undone', () => {
432434
state = reducer(state, undo());
433435
expect(state._undo).toHaveLength(1);
434436
expect(state._undo[0].ctx).toEqual(state.ctx);
437+
expect(state._undo[0].plugins).toEqual(state.plugins);
435438
});
436439

437440
test('grows when a move is redone', () => {
438441
state = reducer(state, redo());
439442
expect(state._undo).toHaveLength(2);
440443
expect(state._undo[1].moveType).toBe('basic');
441444
expect(state._undo[1].ctx).toEqual(state.ctx);
445+
expect(state._undo[1].plugins).toEqual(state.plugins);
442446
});
443447

444448
test('is reset when a turn ends', () => {
445449
state = reducer(state, makeMove('endTurn'));
446450
expect(state._undo).toHaveLength(1);
447451
expect(state._undo[0].ctx).toEqual(state.ctx);
448-
expect(state._undo[0].moveType).toBeUndefined();
452+
expect(state._undo[0].plugins).toEqual(state.plugins);
453+
expect(state._undo[0].moveType).toBe('endTurn');
454+
});
455+
456+
test('can’t undo at the start of a turn', () => {
457+
const newState = reducer(state, undo());
458+
expect(state).toEqual(newState);
459+
});
460+
461+
test('can’t undo another player’s move', () => {
462+
state = reducer(state, makeMove('basic', null, '1'));
463+
const newState = reducer(state, undo('0'));
464+
expect(state).toEqual(newState);
449465
});
450466
});
451467

@@ -467,7 +483,7 @@ describe('redo stack', () => {
467483
});
468484

469485
test('grows when a move is undone', () => {
470-
state = reducer(state, makeMove('basic'));
486+
state = reducer(state, makeMove('basic', null, '0'));
471487
state = reducer(state, undo());
472488
expect(state._redo).toHaveLength(1);
473489
expect(state._redo[0].moveType).toBe('basic');
@@ -479,21 +495,30 @@ describe('redo stack', () => {
479495
});
480496

481497
test('is reset when a move is made', () => {
482-
state = reducer(state, makeMove('basic'));
498+
state = reducer(state, makeMove('basic', null, '0'));
483499
state = reducer(state, undo());
484500
state = reducer(state, undo());
485501
expect(state._redo).toHaveLength(2);
486-
state = reducer(state, makeMove('basic'));
502+
state = reducer(state, makeMove('basic', null, '0'));
487503
expect(state._redo).toHaveLength(0);
488504
});
489505

490506
test('is reset when a turn ends', () => {
491-
state = reducer(state, makeMove('basic'));
507+
state = reducer(state, makeMove('basic', null, '0'));
492508
state = reducer(state, undo());
493509
expect(state._redo).toHaveLength(1);
494510
state = reducer(state, makeMove('endTurn'));
495511
expect(state._redo).toHaveLength(0);
496512
});
513+
514+
test('can’t redo another player’s undo', () => {
515+
state = reducer(state, makeMove('basic', null, '1'));
516+
state = reducer(state, undo('1'));
517+
expect(state._redo).toHaveLength(1);
518+
const newState = reducer(state, redo('0'));
519+
expect(state._redo).toHaveLength(1);
520+
expect(newState).toEqual(state);
521+
});
497522
});
498523

499524
describe('undo / redo with stages', () => {

src/core/reducer.ts

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,20 @@ import {
1919
State,
2020
Move,
2121
LongFormMove,
22+
Undo,
2223
} from '../types';
2324

25+
/**
26+
* Check if the payload for the passed action contains a playerID.
27+
*/
28+
const actionHasPlayerID = (
29+
action:
30+
| ActionShape.MakeMove
31+
| ActionShape.GameEvent
32+
| ActionShape.Undo
33+
| ActionShape.Redo
34+
) => action.payload.playerID !== null && action.payload.playerID !== undefined;
35+
2436
/**
2537
* Returns true if a move can be undone.
2638
*/
@@ -46,6 +58,37 @@ const CanUndoMove = (G: any, ctx: Ctx, move: Move): boolean => {
4658
return move.undoable;
4759
};
4860

61+
/**
62+
* Update the undo and redo stacks for a move or event.
63+
*/
64+
function updateUndoRedoState(
65+
state: State,
66+
opts: {
67+
game: Game;
68+
action: ActionShape.GameEvent | ActionShape.MakeMove;
69+
}
70+
): State {
71+
if (opts.game.disableUndo) return state;
72+
73+
const undoEntry: Undo = {
74+
G: state.G,
75+
ctx: state.ctx,
76+
plugins: state.plugins,
77+
playerID: opts.action.payload.playerID || state.ctx.currentPlayer,
78+
};
79+
80+
if (opts.action.type === 'MAKE_MOVE') {
81+
undoEntry.moveType = opts.action.payload.type;
82+
}
83+
84+
return {
85+
...state,
86+
_undo: [...state._undo, undoEntry],
87+
// Always reset redo stack when making a move or event
88+
_redo: [],
89+
};
90+
}
91+
4992
/**
5093
* CreateGameReducer
5194
*
@@ -88,8 +131,7 @@ export function CreateGameReducer({
88131

89132
// Ignore the event if the player isn't active.
90133
if (
91-
action.payload.playerID !== null &&
92-
action.payload.playerID !== undefined &&
134+
actionHasPlayerID(action) &&
93135
!game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID)
94136
) {
95137
error(`disallowed event: ${action.payload.type}`);
@@ -109,6 +151,9 @@ export function CreateGameReducer({
109151
// Execute plugins.
110152
newState = plugins.Flush(newState, { game, isClient: false });
111153

154+
// Update undo / redo state.
155+
newState = updateUndoRedoState(newState, { game, action });
156+
112157
return { ...newState, _stateID: state._stateID + 1 };
113158
}
114159

@@ -139,8 +184,7 @@ export function CreateGameReducer({
139184

140185
// Ignore the move if the player isn't active.
141186
if (
142-
action.payload.playerID !== null &&
143-
action.payload.playerID !== undefined &&
187+
actionHasPlayerID(action) &&
144188
!game.flow.isPlayerActive(state.G, state.ctx, action.payload.playerID)
145189
) {
146190
error(`disallowed move: ${action.payload.type}`);
@@ -207,23 +251,12 @@ export function CreateGameReducer({
207251
// Add the deltalog to state.
208252
state.deltalog = [logEntry];
209253

210-
const prevTurnCount = state.ctx.turn;
211-
212254
// Allow the flow reducer to process any triggers that happen after moves.
213255
state = game.flow.processMove(state, action.payload);
214256
state = plugins.Flush(state, { game });
215257

216258
// Update undo / redo state.
217-
// Only update undo stack if the turn has not been ended
218-
if (state.ctx.turn === prevTurnCount && !game.disableUndo) {
219-
state._undo = state._undo.concat({
220-
G: state.G,
221-
ctx: state.ctx,
222-
moveType: action.payload.type,
223-
});
224-
}
225-
// Always reset redo stack when making a move
226-
state._redo = [];
259+
state = updateUndoRedoState(state, { game, action });
227260

228261
return {
229262
...state,
@@ -252,11 +285,19 @@ export function CreateGameReducer({
252285
const last = _undo[_undo.length - 1];
253286
const restore = _undo[_undo.length - 2];
254287

288+
// Only allow players to undo their own moves.
289+
if (
290+
actionHasPlayerID(action) &&
291+
action.payload.playerID !== last.playerID
292+
) {
293+
return state;
294+
}
295+
255296
// Only allow undoable moves to be undone.
256297
const lastMove: Move = game.flow.getMove(
257298
restore.ctx,
258299
last.moveType,
259-
action.payload.playerID
300+
last.playerID
260301
);
261302
if (!CanUndoMove(state.G, state.ctx, lastMove)) {
262303
return state;
@@ -285,6 +326,14 @@ export function CreateGameReducer({
285326

286327
const first = _redo[0];
287328

329+
// Only allow players to redo their own undos.
330+
if (
331+
actionHasPlayerID(action) &&
332+
action.payload.playerID !== first.playerID
333+
) {
334+
return state;
335+
}
336+
288337
return {
289338
...state,
290339
G: first.G,

0 commit comments

Comments
 (0)