From 992416aa42a75e677422e03d3cead14a1a3c74fe Mon Sep 17 00:00:00 2001 From: Nicolo Davis Date: Sat, 20 Oct 2018 17:37:47 +0800 Subject: [PATCH] change format of args to endPhase and endTurn OLD endPhase('new phase') endTurn('new player') NEW endPhase({ next: 'new phase' }) endTurn({ next: 'new player' }) --- docs/CHANGELOG.md | 26 ++++++ docs/phases.md | 55 +++++++++---- docs/turn-order.md | 20 +++-- examples/react/turnorder/example-militia.js | 8 +- examples/react/turnorder/simulator.js | 4 +- src/core/flow.js | 37 ++++----- src/core/flow.test.js | 89 ++++++++++++++------- src/core/game.test.js | 2 +- src/core/turn-order.js | 18 +++-- src/core/turn-order.test.js | 13 +-- 10 files changed, 180 insertions(+), 92 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 937bff824..c028c2431 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -32,6 +32,32 @@ phases: { game reverts to in case it detects an infinite loop of `endPhase` events caused by a cycle. +4. The format of the argument to `endPhase` or the return + value of `endPhaseIf` is now an object of type `{ next: 'phase name' }` + +``` +// old +endPhase('new phase') +endPhaseIf: () => 'new phase' + +// new +endPhase({ next: 'new phase' }) +endPhaseIf: () => ({ next: 'new phase' }) +``` + +5. The format of the argument to `endTurn` or the return + value of `endTurnIf` is now an object of type `{ next: playerID }` + +``` +// old +endTurn(playerID) +endTurnIf: () => playerID + +// new +endTurn({ next: playerID }) +endTurnIf: () => ({ next: playerID }) +``` + ## v0.26.3 #### Features diff --git a/docs/phases.md b/docs/phases.md index 16fcee833..0938cfc7e 100644 --- a/docs/phases.md +++ b/docs/phases.md @@ -88,31 +88,52 @@ has three phases, even though we never use the `default` phase. A phase ends when one of the following happens: -###### 1. `endPhaseIf` triggers: +###### `endPhaseIf` triggers: -This is a simple boolean function that terminates the phase when -it returns `true` (see the example above). +This is a function that terminates the phase when it returns a truthy value (see the example above). -###### 2. The `endPhase` event is dispatched: +###### The `endPhase` event is dispatched: This can happen either in the game logic or from the client directly. See the [Events API](events.md) for more details on how to dispatch events. -###### What happens when a phase terminates? +### What happens when a phase terminates? -The game moves on to the "next" phase. This phase is determined by the -following in increasing order of precedence (i.e. if [2] and [4] are both -relevant, the result of [4] is used): +The game moves on to the "next" phase. This phase is determined by the following (the latter ones overruling the former): -1. The `default` phase is chosen as the next phase if no other option is present. +#### 1 -2. If a phase specifies the `next` option (like our example above does), then that is - chosen as the next phase. +The `default` phase is chosen as the next phase if no other option is present. -3. `endPhaseIf` can return the name of the next phase. +#### 2 -4. The `endPhase` event accepts the name of the next phase as an argument. +If a phase specifies the `next` option (like our example above does), then that is chosen as the next phase. + +```js +phases: { + A: { + next: 'B'; + } +} +``` + +#### 3 + +The `endPhase` event accepts an argument that can specify the +next phase: + +```js +endPhase({ next: 'B' }); +``` + +#### 4 + +`endPhaseIf` can return an object specifying the next phase: + +```js +endPhaseIf: () => ({ next: 'B' }); +``` Watch our game in action now with phases. Notice that you can only draw cards in the first phase, and you can only play cards in the second phase. @@ -149,11 +170,11 @@ Let's take a look at some of these: ```js flow: { - // Ends the turn if this returns true. - endTurnIf: (G, ctx) => boolean + // Ends the turn if this returns a truthy value. + endTurnIf: (G, ctx) => boolean|object // Ends the game if this returns anything other than undefined. - endGameIf: (G, ctx) => boolean + endGameIf: (G, ctx) => {} // Run at the start of a turn. onTurnBegin: (G, ctx) => G @@ -167,7 +188,7 @@ flow: { phases: { A: { // Ends the phase if this returns a truthy value. - endPhaseIf: (G, ctx) => {} + endPhaseIf: (G, ctx) => boolean|object // Run at the beginning of a phase. onPhaseBegin: (G, ctx) => G diff --git a/docs/turn-order.md b/docs/turn-order.md index 60e42ef71..8f495152a 100644 --- a/docs/turn-order.md +++ b/docs/turn-order.md @@ -109,7 +109,7 @@ Game({ phases: { A: { turnOrder: TurnOrder.ANY }, B: { turnOrder: TurnOrder.ONCE }, - ], + }, } } ``` @@ -175,21 +175,25 @@ returning an object of type: ### endTurn / endTurnIf You can also specify the next player during the `endTurn` event. -The `endTurn` event takes an additional argument specifying -the next player. If `endTurnIf` returns a string that is a playerID, -that player is made the next player (instead of following the turn -order). +The `endTurn` event takes an additional argument that may specify +the next player: -Player '3' is made the new player in both the following examples: +```js +endTurn({ next: playerID }); +``` + +This argument can also be the return value of `endTurnIf` and works the same way. + +Player `3` is made the new player in both the following examples: ```js onClickEndTurn() { - this.props.endTurn('3'); + this.props.events.endTurn({ next: '3' }); } ``` ```js flow: { - endTurnIf: (G, ctx) => '3', + endTurnIf: (G, ctx) => ({ next: '3' }), } ``` diff --git a/examples/react/turnorder/example-militia.js b/examples/react/turnorder/example-militia.js index 0ed836344..e73bc9f9b 100644 --- a/examples/react/turnorder/example-militia.js +++ b/examples/react/turnorder/example-militia.js @@ -20,14 +20,14 @@ const code = `{ }, onTurnBegin(G, ctx) { - ctx.events.endPhase('play'); + ctx.events.endPhase({ next: 'play' }); return G; }, }, moves: { play(G, ctx) { - ctx.events.endPhase('discard'); + ctx.events.endPhase({ next: 'discard' }); ctx.events.setActionPlayers({ allOthers: true, once: true }); return G; }, @@ -63,14 +63,14 @@ export default { }, onTurnBegin(G, ctx) { - ctx.events.endPhase('play'); + ctx.events.endPhase({ next: 'play' }); return G; }, }, moves: { play(G, ctx) { - ctx.events.endPhase('discard'); + ctx.events.endPhase({ next: 'discard' }); ctx.events.setActionPlayers({ allOthers: true, once: true }); return G; }, diff --git a/examples/react/turnorder/simulator.js b/examples/react/turnorder/simulator.js index 2dca81b65..2e72a69de 100644 --- a/examples/react/turnorder/simulator.js +++ b/examples/react/turnorder/simulator.js @@ -54,7 +54,7 @@ class Board extends React.Component { this.props.ctx.allowedMoves.includes(e[0]) ) .map(e => ( - )); @@ -63,7 +63,7 @@ class Board extends React.Component { .filter(() => current && active) .filter(e => e[0] != 'setActionPlayers') .map(e => ( - )); diff --git a/src/core/flow.js b/src/core/flow.js index 05faf968a..4cd514bd1 100644 --- a/src/core/flow.js +++ b/src/core/flow.js @@ -137,10 +137,10 @@ export function Flow({ * @param {...object} endTurnIf - The turn automatically ends if this * returns a truthy value * (checked after each move). - * If the return value is a playerID, + * If the return value is { next: playerID }, * that player is the next player * (instead of following the turn order). - * (G, ctx) => boolean|string + * (G, ctx) => boolean|object * * @param {...object} endGameIf - The game automatically ends if this function * returns anything (checked after each move). @@ -199,15 +199,14 @@ export function Flow({ * onPhaseEnd: (G, ctx) => G, * * // The phase ends if this function returns a truthy value. - * // If the return value is the name of another phase, - * // that will be chosen as the next phase (as opposed - * // to the next one in round-robin order). - * endPhaseIf: (G, ctx) => boolean|string, + * // If the return value is of the form { next: 'phase name' } + * // then that will be chosen as the next phase. + * endPhaseIf: (G, ctx) => boolean|object, * * Phase-specific options that override their global equivalents: * * // A phase-specific endTurnIf. - * endTurnIf: (G, ctx) => boolean, + * endTurnIf: (G, ctx) => boolean|object, * * // A phase-specific endGameIf. * endGameIf: (G, ctx) => {}, @@ -292,7 +291,7 @@ export function FlowWithPhases({ const conf = phaseMap[phase]; if (conf.endPhaseIf === undefined) { - conf.endPhaseIf = () => false; + conf.endPhaseIf = () => undefined; } if (conf.onPhaseBegin === undefined) { conf.onPhaseBegin = G => G; @@ -414,7 +413,7 @@ export function FlowWithPhases({ * If this call results in a cycle, the phase is reset to * the default phase. */ - function endPhaseEvent(state, nextPhase, visitedPhases) { + function endPhaseEvent(state, arg, visitedPhases) { let G = state.G; let ctx = state.ctx; @@ -428,14 +427,16 @@ export function FlowWithPhases({ } // Update the phase. - if (nextPhase in phaseMap) { - ctx = { ...ctx, phase: nextPhase }; - } else { - if (conf.next !== undefined) { - ctx = { ...ctx, phase: conf.next }; + if (arg && arg !== true) { + if (arg.next in phaseMap) { + ctx = { ...ctx, phase: arg.next }; } else { - ctx = { ...ctx, phase: 'default' }; + logging.error('invalid argument to endPhase: ' + arg); } + } else if (conf.next !== undefined) { + ctx = { ...ctx, phase: conf.next }; + } else { + ctx = { ...ctx, phase: 'default' }; } // Run any setup code for the new phase. @@ -456,7 +457,7 @@ export function FlowWithPhases({ state, automaticGameEvent( 'endPhase', - ['default', visitedPhases], + [{ next: 'default' }, visitedPhases], this.playerID ) ); @@ -490,7 +491,7 @@ export function FlowWithPhases({ * Ends the current turn. * Passes the turn to the next turn in a round-robin fashion. */ - function endTurnEvent(state, nextPlayer) { + function endTurnEvent(state, arg) { let { G, ctx } = state; const conf = phaseMap[ctx.phase]; @@ -518,7 +519,7 @@ export function FlowWithPhases({ G, ctx, conf.turnOrder, - nextPlayer + arg ); endPhase = a; ctx = b; diff --git a/src/core/flow.test.js b/src/core/flow.test.js index 7204ea6f5..d14845d9d 100644 --- a/src/core/flow.test.js +++ b/src/core/flow.test.js @@ -167,7 +167,7 @@ describe('phases', () => { const flow = FlowWithPhases({ endTurnIf: (G, ctx) => ctx.phase === 'C', startingPhase: 'A', - phases: { A: {}, B: {}, C: {} }, + phases: { A: { next: 'B' }, B: { next: 'C' }, C: {} }, }); let state = { G: {}, ctx: flow.ctx(2) }; @@ -175,12 +175,12 @@ describe('phases', () => { expect(state.ctx.phase).toBe('A'); expect(state.ctx.currentPlayer).toBe('0'); - state = flow.processGameEvent(state, gameEvent('endPhase', 'B')); + state = flow.processGameEvent(state, gameEvent('endPhase')); expect(state.ctx.phase).toBe('B'); expect(state.ctx.currentPlayer).toBe('0'); - state = flow.processGameEvent(state, gameEvent('endPhase', 'C')); + state = flow.processGameEvent(state, gameEvent('endPhase')); expect(state.ctx.phase).toBe('C'); expect(state.ctx.currentPlayer).toBe('1'); @@ -214,7 +214,7 @@ test('movesPerTurn', () => { state = flow.processMove(state, makeMove('move', null, '0').payload); expect(state.ctx.turn).toBe(1); - state = flow.processGameEvent(state, gameEvent('endPhase', 'B')); + state = flow.processGameEvent(state, gameEvent('endPhase', { next: 'B' })); expect(state.ctx.turn).toBe(1); state = flow.processMove(state, makeMove('move', null, '1').payload); @@ -241,7 +241,7 @@ test('onTurnBegin', () => { expect(onTurnBegin).toHaveBeenCalled(); expect(onTurnBeginOverride).not.toHaveBeenCalled(); - state = flow.processGameEvent(state, gameEvent('endPhase', 'B')); + state = flow.processGameEvent(state, gameEvent('endPhase', { next: 'B' })); expect(state.ctx.phase).toBe('B'); expect(onTurnBeginOverride).not.toHaveBeenCalled(); @@ -262,7 +262,7 @@ test('onTurnEnd', () => { const flow = FlowWithPhases({ onTurnEnd, startingPhase: 'A', - phases: { A: {}, B: { onTurnEnd: onTurnEndOverride } }, + phases: { A: { next: 'B' }, B: { onTurnEnd: onTurnEndOverride } }, }); let state = { ctx: flow.ctx(2) }; @@ -280,7 +280,7 @@ test('onTurnEnd', () => { onTurnEnd.mockReset(); onTurnEndOverride.mockReset(); - state = flow.processGameEvent(state, gameEvent('endPhase', 'B')); + state = flow.processGameEvent(state, gameEvent('endPhase')); flow.processGameEvent(state, gameEvent('endTurn')); expect(onTurnEnd).not.toHaveBeenCalled(); @@ -306,7 +306,7 @@ test('onMove', () => { let state = { G: {}, ctx: flow.ctx(2) }; state = flow.processMove(state, makeMove().payload); expect(state.G).toEqual({ A: true }); - state = flow.processGameEvent(state, gameEvent('endPhase', 'B')); + state = flow.processGameEvent(state, gameEvent('endPhase', { next: 'B' })); state = flow.processMove(state, makeMove().payload); expect(state.G).toEqual({ B: true }); } @@ -407,8 +407,8 @@ test('endGameIf', () => { } }); -test('endTurnIf', () => { - { +describe('endTurnIf', () => { + test('global', () => { const flow = FlowWithPhases({ endTurnIf: G => G.endTurn }); const game = Game({ moves: { @@ -425,9 +425,9 @@ test('endTurnIf', () => { expect(state.ctx.currentPlayer).toBe('0'); state = reducer(state, makeMove('A')); expect(state.ctx.currentPlayer).toBe('1'); - } + }); - { + test('phase specific', () => { const flow = FlowWithPhases({ startingPhase: 'A', phases: { @@ -449,7 +449,23 @@ test('endTurnIf', () => { expect(state.ctx.currentPlayer).toBe('0'); state = reducer(state, makeMove('A')); expect(state.ctx.currentPlayer).toBe('1'); - } + }); + + test('return value', () => { + const flow = FlowWithPhases({ endTurnIf: () => ({ next: '2' }) }); + const game = Game({ + moves: { + A: G => G, + }, + flow, + }); + const reducer = CreateGameReducer({ game, numPlayers: 3 }); + + let state = reducer(undefined, { type: 'init' }); + expect(state.ctx.currentPlayer).toBe('0'); + state = reducer(state, makeMove('A')); + expect(state.ctx.currentPlayer).toBe('2'); + }); }); test('canMakeMove', () => { @@ -493,7 +509,7 @@ test('canMakeMove', () => { expect(state.G).not.toMatchObject({ C: true }); // Phase B (B is allowed). - state = reducer(state, gameEvent('endPhase', 'B')); + state = reducer(state, gameEvent('endPhase', { next: 'B' })); state.G = {}; expect(state.ctx.phase).toBe('B'); @@ -505,7 +521,7 @@ test('canMakeMove', () => { expect(state.G).not.toMatchObject({ C: true }); // Phase C (A and B allowed). - state = reducer(state, gameEvent('endPhase', 'C')); + state = reducer(state, gameEvent('endPhase', { next: 'C' })); state.G = {}; expect(state.ctx.phase).toBe('C'); @@ -517,7 +533,7 @@ test('canMakeMove', () => { expect(state.G).not.toMatchObject({ C: true }); // Phase D (A, B and C allowed). - state = reducer(state, gameEvent('endPhase', 'D')); + state = reducer(state, gameEvent('endPhase', { next: 'D' })); state.G = {}; expect(state.ctx.phase).toBe('D'); @@ -596,7 +612,7 @@ test('endGame', () => { } }); -test('endTurn / endPhase args', () => { +describe('endTurn / endPhase args', () => { const flow = FlowWithPhases({ startingPhase: 'A', phases: { A: { next: 'B' }, B: {}, C: {} }, @@ -604,30 +620,41 @@ test('endTurn / endPhase args', () => { const state = { ctx: flow.ctx(3) }; - { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('no args', () => { let t = state; t = flow.processGameEvent(t, gameEvent('endPhase')); t = flow.processGameEvent(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 endPhase', () => { let t = state; t = flow.processGameEvent(t, gameEvent('endPhase', 'C')); + expect(error).toBeCalledWith(`invalid argument to endPhase: C`); + expect(t.ctx.phase).toBe('A'); + }); + + test('invalid arg to endTurn', () => { + let t = state; t = flow.processGameEvent(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.processGameEvent(t, gameEvent('endPhase', { next: 'C' })); + t = flow.processGameEvent(t, gameEvent('endTurn', { next: '2' })); expect(t.ctx.playOrderPos).toBe(2); expect(t.ctx.currentPlayer).toBe('2'); expect(t.ctx.phase).toBe('C'); - } - - { - let t = state; - t = flow.processGameEvent(t, gameEvent('endTurn', '0')); - expect(t.ctx.playOrderPos).toBe(0); - expect(t.ctx.currentPlayer).toBe('0'); - } + }); }); test('resetGame', () => { @@ -706,7 +733,7 @@ test('undo / redo restricted by undoableMoves', () => { expect(state.G).toEqual({ C: true }); state.G = {}; - state = reducer(state, gameEvent('endPhase', 'B')); + state = reducer(state, gameEvent('endPhase', { next: 'B' })); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.phase).toBe('B'); @@ -724,7 +751,7 @@ test('undo / redo restricted by undoableMoves', () => { expect(state.G).toEqual({ C: true }); state.G = {}; - state = reducer(state, gameEvent('endPhase', 'C')); + state = reducer(state, gameEvent('endPhase', { next: 'C' })); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.phase).toBe('C'); @@ -742,7 +769,7 @@ test('undo / redo restricted by undoableMoves', () => { expect(state.G).toEqual({ C: true }); state.G = {}; - state = reducer(state, gameEvent('endPhase', 'D')); + state = reducer(state, gameEvent('endPhase', { next: 'D' })); state = reducer(state, gameEvent('endTurn')); expect(state.ctx.phase).toBe('D'); diff --git a/src/core/game.test.js b/src/core/game.test.js index f214e102f..e2420362e 100644 --- a/src/core/game.test.js +++ b/src/core/game.test.js @@ -84,7 +84,7 @@ test('rounds with starting player token', () => { expect(state.ctx.currentPlayer).toBe('3'); state = reducer(state, gameEvent('endTurn')); - state = reducer(state, gameEvent('endPhase', 'main')); + state = reducer(state, gameEvent('endPhase', { next: 'main' })); expect(state.ctx.currentPlayer).toBe('2'); state = reducer(state, gameEvent('endTurn')); diff --git a/src/core/turn-order.js b/src/core/turn-order.js index 34a5c686a..891797649 100644 --- a/src/core/turn-order.js +++ b/src/core/turn-order.js @@ -6,6 +6,8 @@ * https://opensource.org/licenses/MIT. */ +import * as logging from './logger'; + /** * Standard move that simulates passing. * @@ -119,19 +121,23 @@ export function InitTurnOrderState(G, ctx, turnOrder) { * @param {object} G - The game object G. * @param {object} ctx - The game object ctx. * @param {object} turnOrder - A turn order object for this phase. - * @param {string} nextPlayer - An optional argument to endTurn that + * @param {string} endTurnArg - An optional argument to endTurn that may specify the next player. */ -export function UpdateTurnOrderState(G, ctx, turnOrder, nextPlayer) { +export function UpdateTurnOrderState(G, ctx, turnOrder, endTurnArg) { let playOrderPos = ctx.playOrderPos; let currentPlayer = ctx.currentPlayer; let actionPlayers = ctx.actionPlayers; let endPhase = false; - if (ctx.playOrder.includes(nextPlayer)) { - playOrderPos = ctx.playOrder.indexOf(nextPlayer); - currentPlayer = nextPlayer; - actionPlayers = [currentPlayer]; + if (endTurnArg && endTurnArg !== true) { + if (ctx.playOrder.includes(endTurnArg.next)) { + playOrderPos = ctx.playOrder.indexOf(endTurnArg.next); + currentPlayer = endTurnArg.next; + actionPlayers = [currentPlayer]; + } else { + logging.error(`invalid argument to endTurn: ${endTurnArg}`); + } } else { const t = turnOrder.next(G, ctx); diff --git a/src/core/turn-order.test.js b/src/core/turn-order.test.js index 238a525dd..ed2c5b257 100644 --- a/src/core/turn-order.test.js +++ b/src/core/turn-order.test.js @@ -153,7 +153,8 @@ test('override', () => { let flow = FlowWithPhases({ turnOrder: even, - phases: { A: {}, B: { turnOrder: odd } }, + phases: { A: { next: 'B' }, B: { turnOrder: odd } }, + startingPhase: 'A', }); let state = { ctx: flow.ctx(10) }; @@ -165,7 +166,7 @@ test('override', () => { state = flow.processGameEvent(state, gameEvent('endTurn')); expect(state.ctx.currentPlayer).toBe('4'); - state = flow.processGameEvent(state, gameEvent('endPhase', 'B')); + state = flow.processGameEvent(state, gameEvent('endPhase')); expect(state.ctx.currentPlayer).toBe('1'); state = flow.processGameEvent(state, gameEvent('endTurn')); @@ -336,13 +337,15 @@ describe('UpdateTurnOrderState', () => { actionPlayers: ['0'], }; - test('without nextPlayer', () => { + test('without next player', () => { const { ctx: t } = UpdateTurnOrderState(G, ctx, TurnOrder.DEFAULT); expect(t).toMatchObject({ currentPlayer: '1' }); }); - test('with nextPlayer', () => { - const { ctx: t } = UpdateTurnOrderState(G, ctx, TurnOrder.DEFAULT, '2'); + test('with next player', () => { + const { ctx: t } = UpdateTurnOrderState(G, ctx, TurnOrder.DEFAULT, { + next: '2', + }); expect(t).toMatchObject({ currentPlayer: '2' }); });