diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index 530f9c859..cc66ad6e0 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -64,8 +64,11 @@ // Called at the end of each move. onMove: (G, ctx) => G, + // Prevents ending the turn before a minimum number of moves. + minMoves: 1, + // Ends the turn automatically after a number of moves. - moveLimit: 1, + maxMoves: 1, // Calls setActivePlayers with this as argument at the // beginning of the turn. diff --git a/docs/documentation/phases.md b/docs/documentation/phases.md index d76fc43a1..7cfa80151 100644 --- a/docs/documentation/phases.md +++ b/docs/documentation/phases.md @@ -33,7 +33,7 @@ function PlayCard(G, ctx) { const game = { setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), moves: { DrawCard, PlayCard }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, }; ``` @@ -59,7 +59,7 @@ list of moves, which come into effect during that phase: ```js const game = { setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, phases: { draw: { diff --git a/docs/documentation/stages.md b/docs/documentation/stages.md index 2dab6931e..ca49df916 100644 --- a/docs/documentation/stages.md +++ b/docs/documentation/stages.md @@ -106,19 +106,23 @@ setActivePlayers({ ... }, + // Prevents manual endStage before the player + // has made the specified number of moves. + minMoves: 1, + // Calls endStage automatically after the player // has made the specified number of moves. - moveLimit: 5, + maxMoves: 5, // This takes the stage configuration to the // value prior to this setActivePlayers call // once the set of active players becomes empty // (due to players either calling endStage or - // a moveLimit ending the stage for them). + // maxMoves ending the stage for them). revert: true, // A next option will be used once the set of active players - // becomes empty (either by using moveLimit or manually removing + // becomes empty (either by using maxMoves or manually removing // players). // All options available inside setActivePlayers are available // inside next. @@ -131,7 +135,7 @@ require every other player to discard a card when we play one: ```js function PlayCard(G, ctx) { - ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 }); + ctx.events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 }); } const game = { @@ -152,21 +156,41 @@ const game = { #### Advanced Move Limits -Passing a `moveLimit` argument to `setActivePlayers` limits all the +Passing a `minMoves` argument to `setActivePlayers` forces all the +active players to make at least that number of moves before being able to +end the stage, but sometimes you might want to set different move limits +for different players. For cases like this, `setStage` and `setActivePlayers` +support long-form arguments: + +```js +setStage({ stage: 'stage-name', minMoves: 3 }); +``` + +```js +setActivePlayers({ + currentPlayer: { stage: 'stage-name', minMoves: 2 }, + others: { stage: 'stage-name', minMoves: 1 }, + value: { + '0': { stage: 'stage-name', minMoves: 4 }, + }, +}); +``` + +Passing a `maxMoves` argument to `setActivePlayers` limits all the active players to making that number of moves, but sometimes you might want to set different move limits for different players. For cases like this, `setStage` and `setActivePlayers` support long-form arguments: ```js -setStage({ stage: 'stage-name', moveLimit: 3 }); +setStage({ stage: 'stage-name', maxMoves: 3 }); ``` ```js setActivePlayers({ - currentPlayer: { stage: 'stage-name', moveLimit: 2 }, - others: { stage: 'stage-name', moveLimit: 1 }, + currentPlayer: { stage: 'stage-name', maxMoves: 2 }, + others: { stage: 'stage-name', maxMoves: 1 }, value: { - '0': { stage: 'stage-name', moveLimit: 4 }, + '0': { stage: 'stage-name', maxMoves: 4 }, }, }); ``` @@ -230,7 +254,7 @@ aren't restricted to any particular stage. #### ALL_ONCE -Equivalent to `{ all: Stage.NULL, moveLimit: 1 }`. Any player can make +Equivalent to `{ all: Stage.NULL, minMoves: 1, maxMoves: 1 }`. Any player can make exactly one move before they are removed from the set of active players. #### OTHERS diff --git a/docs/documentation/tutorial.md b/docs/documentation/tutorial.md index a9e0551df..3cb93d011 100644 --- a/docs/documentation/tutorial.md +++ b/docs/documentation/tutorial.md @@ -287,16 +287,18 @@ because a player could choose when to end their turn, but in Tic-Tac-Toe we know that the turn should always end when a move is made. There are several different ways to manage turns in boardgame.io. -We’ll use the `moveLimit` option in our game definition to tell +We’ll use the `maxMoves` option in our game definition to tell the framework to automatically end a player’s turn after a single -move has been made. +move has been made, as well as the `minMoves` option, so players +*have* to make a move and can't just `endTurn`. ```js export const TicTacToe = { setup: () => { /* ... */ }, turn: { - moveLimit: 1, + minMoves: 1, + maxMoves: 1, }, moves: { /* ... */ }, diff --git a/examples/react-native/game.js b/examples/react-native/game.js index 5dc0a4fd2..787d995ae 100644 --- a/examples/react-native/game.js +++ b/examples/react-native/game.js @@ -52,7 +52,7 @@ const TicTacToe = { }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, endIf: (G, ctx) => { if (IsVictory(G.cells)) { diff --git a/examples/react-web/src/chess/game.js b/examples/react-web/src/chess/game.js index 0862e447e..d2c5a2b39 100644 --- a/examples/react-web/src/chess/game.js +++ b/examples/react-web/src/chess/game.js @@ -40,7 +40,7 @@ const ChessGame = { }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, endIf: (G) => { const chess = Load(G.pgn); diff --git a/examples/react-web/src/simulator/example-others-once.js b/examples/react-web/src/simulator/example-others-once.js index 5207256bb..a442e95c8 100644 --- a/examples/react-web/src/simulator/example-others-once.js +++ b/examples/react-web/src/simulator/example-others-once.js @@ -13,7 +13,8 @@ const code = `{ play: (G, ctx) => { ctx.events.setActivePlayers({ others: 'discard', - moveLimit: 1 + minMoves: 1, + maxMoves: 1, }); return G; }, @@ -47,7 +48,11 @@ export default { moves: { play: (G, ctx) => { - ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 }); + ctx.events.setActivePlayers({ + others: 'discard', + minMoves: 1, + maxMoves: 1, + }); return G; }, }, diff --git a/examples/react-web/src/tic-tac-toe/game.js b/examples/react-web/src/tic-tac-toe/game.js index 4f7d12129..84603b2dd 100644 --- a/examples/react-web/src/tic-tac-toe/game.js +++ b/examples/react-web/src/tic-tac-toe/game.js @@ -45,7 +45,8 @@ const TicTacToe = { }, turn: { - moveLimit: 1, + minMoves: 1, + maxMoves: 1, }, endIf: (G, ctx) => { diff --git a/examples/snippets/src/example-2/index.js b/examples/snippets/src/example-2/index.js index b3d283746..cc3dbdf7e 100644 --- a/examples/snippets/src/example-2/index.js +++ b/examples/snippets/src/example-2/index.js @@ -36,7 +36,7 @@ const TicTacToe = { }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, endIf: (G, ctx) => { if (IsVictory(G.cells)) { diff --git a/examples/snippets/src/example-3/index.js b/examples/snippets/src/example-3/index.js index bc9d40d57..34f4b7a44 100644 --- a/examples/snippets/src/example-3/index.js +++ b/examples/snippets/src/example-3/index.js @@ -36,7 +36,7 @@ const TicTacToe = { }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, endIf: (G, ctx) => { if (IsVictory(G.cells)) { diff --git a/examples/snippets/src/multiplayer/index.js b/examples/snippets/src/multiplayer/index.js index 14a896297..4fdfc2171 100644 --- a/examples/snippets/src/multiplayer/index.js +++ b/examples/snippets/src/multiplayer/index.js @@ -45,7 +45,7 @@ const TicTacToe = { }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, endIf: (G, ctx) => { if (IsVictory(G.cells)) { diff --git a/examples/snippets/src/phases-1/game.js b/examples/snippets/src/phases-1/game.js index 448bbade5..f5255ee50 100644 --- a/examples/snippets/src/phases-1/game.js +++ b/examples/snippets/src/phases-1/game.js @@ -11,7 +11,7 @@ function PlayCard(G, ctx) { const game = { setup: (ctx) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), moves: { DrawCard, PlayCard }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, }; export default game; diff --git a/examples/snippets/src/phases-2/game.js b/examples/snippets/src/phases-2/game.js index 6caecb7ff..631cc1497 100644 --- a/examples/snippets/src/phases-2/game.js +++ b/examples/snippets/src/phases-2/game.js @@ -23,7 +23,7 @@ const game = { endIf: (G) => G.deck >= 6, }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, }; export default game; diff --git a/examples/snippets/src/stages-1/game.js b/examples/snippets/src/stages-1/game.js index 23d94a112..328bc6d77 100644 --- a/examples/snippets/src/stages-1/game.js +++ b/examples/snippets/src/stages-1/game.js @@ -1,5 +1,5 @@ function militia(G, ctx) { - ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 }); + ctx.events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 }); } function discard(G, ctx) {} diff --git a/integration/src/game.js b/integration/src/game.js index f23cdf9e8..eae0bcaa1 100644 --- a/integration/src/game.js +++ b/integration/src/game.js @@ -52,14 +52,15 @@ const TicTacToe = { }, turn: { - moveLimit: 1, + minMoves: 1, + maxMoves: 1, }, endIf: (G, ctx) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } - if (G.cells.filter(c => c === null).length === 0) { + if (G.cells.filter((c) => c === null).length === 0) { return { draw: true }; } }, diff --git a/src/ai/ai.test.ts b/src/ai/ai.test.ts index 89e303b2d..ae21c12a5 100644 --- a/src/ai/ai.test.ts +++ b/src/ai/ai.test.ts @@ -53,7 +53,7 @@ const TicTacToe = ProcessGameConfig({ }, }, - turn: { moveLimit: 1 }, + turn: { minMoves: 1, maxMoves: 1 }, endIf: (G, ctx) => { if (IsVictory(G.cells)) { diff --git a/src/core/backwards-compatibility.ts b/src/core/backwards-compatibility.ts new file mode 100644 index 000000000..77ac17b3b --- /dev/null +++ b/src/core/backwards-compatibility.ts @@ -0,0 +1,23 @@ +type MoveLimitOptions = { + minMoves?: number; + maxMoves?: number; + moveLimit?: number; +}; + +/** + * Adjust the given options to use the new minMoves/maxMoves if a legacy moveLimit was given + * @param options The options object to apply backwards compatibility to + * @param enforceMinMoves Use moveLimit to set both minMoves and maxMoves + */ +export function supportDeprecatedMoveLimit( + options: MoveLimitOptions, + enforceMinMoves = false +) { + if (options.moveLimit) { + if (enforceMinMoves) { + options.minMoves = options.moveLimit; + } + options.maxMoves = options.moveLimit; + delete options.moveLimit; + } +} diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index b36ed6252..e82be5746 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -241,69 +241,222 @@ describe('turn', () => { } }); - describe('moveLimit', () => { - test('without phases', () => { + describe('minMoves', () => { + describe('without phases', () => { const flow = Flow({ turn: { - moveLimit: 2, + minMoves: 2, }, }); - let state = flow.init({ ctx: flow.ctx(2) } as State); - expect(state.ctx.turn).toBe(1); - state = flow.processMove(state, makeMove('move', null, '0').payload); - expect(state.ctx.turn).toBe(1); - state = flow.processEvent(state, gameEvent('endTurn')); - expect(state.ctx.turn).toBe(1); - state = flow.processMove(state, makeMove('move', null, '0').payload); - expect(state.ctx.turn).toBe(2); + + test('player cannot endTurn if not enough moves were made', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + }); + + test('player can endTurn after enough moves were made', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + }); }); - test('with phases', () => { + describe('with phases', () => { const flow = Flow({ - turn: { moveLimit: 2 }, + turn: { minMoves: 2 }, phases: { B: { turn: { - moveLimit: 1, + minMoves: 1, }, }, }, }); - let state = flow.init({ ctx: flow.ctx(2) } as State); - expect(state.ctx.turn).toBe(1); - expect(state.ctx.currentPlayer).toBe('0'); + test('player cannot endTurn if not enough moves were made in default phase', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); - state = flow.processMove(state, makeMove('move', null, '0').payload); + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); - expect(state.ctx.turn).toBe(1); - expect(state.ctx.currentPlayer).toBe('0'); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); - state = flow.processEvent(state, gameEvent('endTurn')); + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + }); - expect(state.ctx.turn).toBe(1); - expect(state.ctx.currentPlayer).toBe('0'); + test('player can endTurn after enough moves were made in default phase', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); - state = flow.processMove(state, makeMove('move', null, '0').payload); + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); - expect(state.ctx.turn).toBe(2); - expect(state.ctx.currentPlayer).toBe('1'); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); - state = flow.processEvent(state, gameEvent('setPhase', 'B')); + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + }); - expect(state.ctx.phase).toBe('B'); - expect(state.ctx.turn).toBe(3); - expect(state.ctx.currentPlayer).toBe('0'); + test('player cannot endTurn if no move was made in explicit phase', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); - state = flow.processMove(state, makeMove('move', null, '0').payload); - expect(state.ctx.turn).toBe(4); - expect(state.ctx.currentPlayer).toBe('1'); + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + state = flow.processMove(state, makeMove('move', null, '1').payload); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + + state = flow.processEvent(state, gameEvent('setPhase', 'B')); + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(3); + expect(state.ctx.currentPlayer).toBe('0'); + }); + + test('player can endTurn after having made a move, fewer moves needed in explicit phase', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + state = flow.processMove(state, makeMove('move', null, '1').payload); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + + state = flow.processEvent(state, gameEvent('setPhase', 'B')); + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(4); + expect(state.ctx.currentPlayer).toBe('1'); + }); + }); + }); + + describe('maxMoves', () => { + describe('without phases', () => { + const flow = Flow({ + turn: { + maxMoves: 2, + }, + }); + + test('manual endTurn works, even if not enough moves were made', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + }); + + test('turn automatically ends after making enough moves', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processMove(state, makeMove('move', null, '0').payload); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + }); + }); + + describe('with phases', () => { + const flow = Flow({ + turn: { maxMoves: 2 }, + phases: { + B: { + turn: { maxMoves: 1 }, + }, + }, + }); + + test('manual endTurn works in all phases, even if fewer than maxMoves have been made', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + + state = flow.processEvent(state, gameEvent('setPhase', 'B')); + + expect(state.ctx.turn).toBe(3); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processEvent(state, gameEvent('endTurn')); + + expect(state.ctx.turn).toBe(4); + expect(state.ctx.currentPlayer).toBe('1'); + }); + + test('automatic endTurn triggers after fewer moves in different phase', () => { + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processMove(state, makeMove('move', null, '0').payload); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + + state = flow.processEvent(state, gameEvent('setPhase', 'B')); + + expect(state.ctx.turn).toBe(3); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + + expect(state.ctx.turn).toBe(4); + expect(state.ctx.currentPlayer).toBe('1'); + }); }); test('with noLimit moves', () => { const flow = Flow({ turn: { - moveLimit: 2, + maxMoves: 2, }, moves: { A: () => {}, @@ -522,7 +675,7 @@ describe('stages', () => { turn: { activePlayers: { currentPlayer: 'A', - moveLimit: 1, + maxMoves: 1, }, endIf: (G, ctx) => ctx.activePlayers === null, stages: { @@ -569,7 +722,7 @@ describe('stage events', () => { test('with multiple active players', () => { const flow = Flow({ turn: { - activePlayers: { all: 'A', moveLimit: 5 }, + activePlayers: { all: 'A', minMoves: 2, maxMoves: 5 }, }, }); let state = { G: {}, ctx: flow.ctx(3) } as State; @@ -578,13 +731,13 @@ describe('stage events', () => { expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A', '2': 'A' }); state = flow.processEvent( state, - gameEvent('setStage', { stage: 'B', moveLimit: 1 }) + gameEvent('setStage', { stage: 'B', minMoves: 1 }) ); expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'A', '2': 'A' }); state = flow.processEvent( state, - gameEvent('setStage', { stage: 'B', moveLimit: 1 }, '1') + gameEvent('setStage', { stage: 'B', maxMoves: 1 }, '1') ); expect(state.ctx.activePlayers).toEqual({ '0': 'B', '1': 'B', '2': 'A' }); }); @@ -606,17 +759,34 @@ describe('stage events', () => { expect(state.ctx._activePlayersNumMoves).toMatchObject({ '0': 0 }); }); - test('with move limit', () => { + test('with min moves', () => { + const flow = Flow({}); + let state = { G: {}, ctx: flow.ctx(2) } as State; + state = flow.init(state); + + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toBeNull(); + state = flow.processEvent( + state, + gameEvent('setStage', { stage: 'A', minMoves: 1 }) + ); + expect(state.ctx._activePlayersMinMoves).toEqual({ '0': 1 }); + expect(state.ctx._activePlayersMaxMoves).toBeNull(); + }); + + test('with max moves', () => { const flow = Flow({}); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.init(state); - expect(state.ctx._activePlayersMoveLimit).toBeNull(); + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toBeNull(); state = flow.processEvent( state, - gameEvent('setStage', { stage: 'A', moveLimit: 2 }) + gameEvent('setStage', { stage: 'A', maxMoves: 1 }) ); - expect(state.ctx._activePlayersMoveLimit).toEqual({ '0': 2 }); + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1 }); }); test('empty argument ends stage', () => { @@ -716,7 +886,7 @@ describe('stage events', () => { test('with multiple active players', () => { const flow = Flow({ turn: { - activePlayers: { all: 'A', moveLimit: 5 }, + activePlayers: { all: 'A', maxMoves: 5 }, }, }); let state = { G: {}, ctx: flow.ctx(3) } as State; @@ -727,6 +897,35 @@ describe('stage events', () => { expect(state.ctx.activePlayers).toEqual({ '1': 'A', '2': 'A' }); }); + test('with min moves', () => { + const flow = Flow({ + turn: { + activePlayers: { all: 'A', minMoves: 2 }, + }, + }); + let state = { G: {}, ctx: flow.ctx(2) } as State; + state = flow.init(state); + + expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); + + state = flow.processEvent(state, gameEvent('endStage')); + + // player 0 is not allowed to end the stage, they haven't made any move yet + expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endStage')); + + // player 0 is still not allowed to end the stage, they haven't made the minimum number of moves + expect(state.ctx.activePlayers).toEqual({ '0': 'A', '1': 'A' }); + + state = flow.processMove(state, makeMove('move', null, '0').payload); + state = flow.processEvent(state, gameEvent('endStage')); + + // having made 2 moves, player 0 was allowed to end the stage + expect(state.ctx.activePlayers).toEqual({ '1': 'A' }); + }); + test('maintains move count', () => { const flow = Flow({ moves: { A: () => {} }, @@ -1783,6 +1982,68 @@ test('stage events should not be processed out of turn', () => { }); }); +describe('backwards compatibility for moveLimit', () => { + test('turn config maps moveLimit to minMoves/maxMoves', () => { + const flow = Flow({ + moves: { + pass: () => {}, + }, + turn: { + moveLimit: 2, + }, + }); + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx.turn).toBe(1); + expect(state.ctx.currentPlayer).toBe('0'); + + state = flow.processMove(state, makeMove('pass', null, '0').payload); + state = flow.processMove(state, makeMove('pass', null, '0').payload); + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + + state = flow.processMove(state, makeMove('pass', null, '1').payload); + + state = flow.processEvent(state, gameEvent('endTurn', null, '1')); + + // player should not be able to endTurn because they haven't made minMoves yet + + expect(state.ctx.turn).toBe(2); + expect(state.ctx.currentPlayer).toBe('1'); + }); + + test('setActivePlayers maps moveLimit to maxMoves only', () => { + const flow = Flow({}); + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toBeNull(); + + state = flow.processEvent( + state, + gameEvent('setActivePlayers', { all: 'A', moveLimit: 1 }) + ); + + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1, '1': 1 }); + }); + + test('setStage maps moveLimit to maxMoves only', () => { + const flow = Flow({}); + let state = flow.init({ ctx: flow.ctx(2) } as State); + + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toBeNull(); + state = flow.processEvent( + state, + gameEvent('setStage', { stage: 'A', moveLimit: 2 }) + ); + expect(state.ctx._activePlayersMinMoves).toBeNull(); + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2 }); + }); +}); + // These tests serve to document the order in which the various game hooks // are executed and also to catch any potential breaking changes. describe('hook execution order', () => { @@ -1805,7 +2066,7 @@ describe('hook execution order', () => { calls.push('moves.endStage'); }, setActivePlayers: (G, ctx) => { - ctx.events.setActivePlayers({ all: 'A', moveLimit: 1 }); + ctx.events.setActivePlayers({ all: 'A', minMoves: 1, maxMoves: 1 }); calls.push('moves.setActivePlayers'); }, }, @@ -1942,7 +2203,7 @@ describe('hook execution order', () => { ]); }); - test('hooks called on stage end triggered by moveLimit', () => { + test('hooks called on stage end triggered by maxMoves', () => { client.updatePlayerID('1'); client.moves.move(); client.updatePlayerID('0'); diff --git a/src/core/flow.ts b/src/core/flow.ts index 18c8ca5fd..fe6ad8f70 100644 --- a/src/core/flow.ts +++ b/src/core/flow.ts @@ -30,6 +30,7 @@ import type { Move, } from '../types'; import { GameMethod } from './game-methods'; +import { supportDeprecatedMoveLimit } from './backwards-compatibility'; /** * Flow @@ -145,6 +146,9 @@ export function Flow({ phaseConfig.turn.stages = {}; } + // turns previously treated moveLimit as both minMoves and maxMoves, this behaviour is kept intentionally + supportDeprecatedMoveLimit(phaseConfig.turn, true); + for (const stage in phaseConfig.turn.stages) { const stageConfig = phaseConfig.turn.stages[stage]; const moves = stageConfig.moves || {}; @@ -366,9 +370,17 @@ export function Flow({ } if (typeof arg !== 'object') return state; + // `arg` should be of type `StageArg`, loose typing as `any` here for historic reasons + // stages previously did not enforce minMoves, this behaviour is kept intentionally + supportDeprecatedMoveLimit(arg); + let { ctx } = state; - let { activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves } = - ctx; + let { + activePlayers, + _activePlayersMinMoves, + _activePlayersMaxMoves, + _activePlayersNumMoves, + } = ctx; // Checking if stage is valid, even Stage.NULL if (arg.stage !== undefined) { @@ -378,18 +390,26 @@ export function Flow({ activePlayers[playerID] = arg.stage; _activePlayersNumMoves[playerID] = 0; - if (arg.moveLimit) { - if (_activePlayersMoveLimit === null) { - _activePlayersMoveLimit = {}; + if (arg.minMoves) { + if (_activePlayersMinMoves === null) { + _activePlayersMinMoves = {}; } - _activePlayersMoveLimit[playerID] = arg.moveLimit; + _activePlayersMinMoves[playerID] = arg.minMoves; + } + + if (arg.maxMoves) { + if (_activePlayersMaxMoves === null) { + _activePlayersMaxMoves = {}; + } + _activePlayersMaxMoves[playerID] = arg.maxMoves; } } ctx = { ...ctx, activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, _activePlayersNumMoves, }; @@ -419,8 +439,8 @@ export function Flow({ // End the turn if the required number of moves has been made. const currentPlayerMoves = state.ctx.numMoves || 0; if ( - phaseConfig.turn.moveLimit && - currentPlayerMoves >= phaseConfig.turn.moveLimit + phaseConfig.turn.maxMoves && + currentPlayerMoves >= phaseConfig.turn.maxMoves ) { return true; } @@ -496,15 +516,15 @@ export function Flow({ const { currentPlayer, numMoves, phase, turn } = state.ctx; const phaseConfig = GetPhase(state.ctx); - // Prevent ending the turn if moveLimit hasn't been reached. + // Prevent ending the turn if minMoves haven't been reached. const currentPlayerMoves = numMoves || 0; if ( !force && - phaseConfig.turn.moveLimit && - currentPlayerMoves < phaseConfig.turn.moveLimit + phaseConfig.turn.minMoves && + currentPlayerMoves < phaseConfig.turn.minMoves ) { logging.info( - `cannot end turn before making ${phaseConfig.turn.moveLimit} moves` + `cannot end turn before making ${phaseConfig.turn.minMoves} moves` ); return state; } @@ -554,14 +574,24 @@ export function Flow({ playerID = playerID || state.ctx.currentPlayer; let { ctx, _stateID } = state; - let { activePlayers, _activePlayersMoveLimit, phase, turn } = ctx; + let { + activePlayers, + _activePlayersNumMoves, + _activePlayersMinMoves, + _activePlayersMaxMoves, + phase, + turn, + } = ctx; const playerInStage = activePlayers !== null && playerID in activePlayers; + const phaseConfig = GetPhase(ctx); + if (!arg && playerInStage) { - const phaseConfig = GetPhase(ctx); const stage = phaseConfig.turn.stages[activePlayers[playerID]]; - if (stage && stage.next) arg = stage.next; + if (stage && stage.next) { + arg = stage.next; + } } // Checking if arg is a valid stage, even Stage.NULL @@ -572,20 +602,40 @@ export function Flow({ // If player isn’t in a stage, there is nothing else to do. if (!playerInStage) return state; + // Prevent ending the stage if minMoves haven't been reached. + const currentPlayerMoves = _activePlayersNumMoves[playerID] || 0; + if ( + _activePlayersMinMoves && + _activePlayersMinMoves[playerID] && + currentPlayerMoves < _activePlayersMinMoves[playerID] + ) { + logging.info( + `cannot end stage before making ${_activePlayersMinMoves[playerID]} moves` + ); + return state; + } + // Remove player from activePlayers. activePlayers = { ...activePlayers }; delete activePlayers[playerID]; - if (_activePlayersMoveLimit) { - // Remove player from _activePlayersMoveLimit. - _activePlayersMoveLimit = { ..._activePlayersMoveLimit }; - delete _activePlayersMoveLimit[playerID]; + if (_activePlayersMinMoves) { + // Remove player from _activePlayersMinMoves. + _activePlayersMinMoves = { ..._activePlayersMinMoves }; + delete _activePlayersMinMoves[playerID]; + } + + if (_activePlayersMaxMoves) { + // Remove player from _activePlayersMaxMoves. + _activePlayersMaxMoves = { ..._activePlayersMaxMoves }; + delete _activePlayersMaxMoves[playerID]; } ctx = UpdateActivePlayersOnceEmpty({ ...ctx, activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, }); // Create log entry. @@ -649,7 +699,7 @@ export function Flow({ function ProcessMove(state: State, action: ActionPayload.MakeMove): State { const { playerID, type } = action; const { ctx } = state; - const { currentPlayer, activePlayers, _activePlayersMoveLimit } = ctx; + const { currentPlayer, activePlayers, _activePlayersMaxMoves } = ctx; const move = GetMove(ctx, type, playerID); const shouldCount = !move || typeof move === 'function' || move.noLimit !== true; @@ -670,8 +720,8 @@ export function Flow({ }; if ( - _activePlayersMoveLimit && - _activePlayersNumMoves[playerID] >= _activePlayersMoveLimit[playerID] + _activePlayersMaxMoves && + _activePlayersNumMoves[playerID] >= _activePlayersMaxMoves[playerID] ) { state = EndStage(state, { playerID, automatic: true }); } diff --git a/src/core/turn-order.test.ts b/src/core/turn-order.test.ts index adb63bf89..d27b37f5e 100644 --- a/src/core/turn-order.test.ts +++ b/src/core/turn-order.test.ts @@ -388,7 +388,16 @@ describe('setActivePlayers', () => { test('undefined stage leaves player inactive', () => { const newState = flow.processEvent( state, - gameEvent('setActivePlayers', [{ value: { '1': { moveLimit: 2 } } }]) + gameEvent('setActivePlayers', [ + { + value: { + '1': { + minMoves: 2, + maxMoves: 2, + }, + }, + }, + ]) ); expect(newState.ctx.activePlayers).toBeNull(); }); @@ -410,7 +419,8 @@ describe('setActivePlayers', () => { B: (G, ctx) => { ctx.events.setActivePlayers({ value: { '0': Stage.NULL, '1': Stage.NULL }, - moveLimit: 1, + minMoves: 1, + maxMoves: 1, }); return G; }, @@ -434,7 +444,8 @@ describe('setActivePlayers', () => { moves: { B: (G, ctx) => { ctx.events.setActivePlayers({ - moveLimit: 1, + minMoves: 1, + maxMoves: 1, others: Stage.NULL, }); return G; @@ -466,7 +477,8 @@ describe('setActivePlayers', () => { A: (G) => G, B: (G, ctx) => { ctx.events.setActivePlayers({ - moveLimit: 1, + minMoves: 1, + maxMoves: 1, currentPlayer: 'start', }); return G; @@ -517,7 +529,7 @@ describe('setActivePlayers', () => { }, turn: { - activePlayers: { currentPlayer: 'stage', moveLimit: 1 }, + activePlayers: { currentPlayer: 'stage', minMoves: 1, maxMoves: 1 }, }, }; @@ -544,7 +556,8 @@ describe('setActivePlayers', () => { A: (G, ctx) => { ctx.events.setActivePlayers({ currentPlayer: 'stage2', - moveLimit: 1, + minMoves: 1, + maxMoves: 1, revert: true, }); }, @@ -571,7 +584,8 @@ describe('setActivePlayers', () => { _prevActivePlayers: [ { activePlayers: { '0': 'stage1' }, - _activePlayersMoveLimit: null, + _activePlayersMinMoves: null, + _activePlayersMaxMoves: null, _activePlayersNumMoves: { '0': 1 }, }, ], @@ -591,7 +605,8 @@ describe('setActivePlayers', () => { A: (G, ctx) => { ctx.events.setActivePlayers({ currentPlayer: 'stage2', - moveLimit: 1, + minMoves: 1, + maxMoves: 1, revert: true, }); }, @@ -601,7 +616,8 @@ describe('setActivePlayers', () => { turn: { activePlayers: { currentPlayer: 'stage1', - moveLimit: 3, + minMoves: 2, + maxMoves: 3, }, }, }; @@ -612,7 +628,8 @@ describe('setActivePlayers', () => { expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], - _activePlayersMoveLimit: { '0': 3 }, + _activePlayersMinMoves: { '0': 2 }, + _activePlayersMaxMoves: { '0': 3 }, _activePlayersNumMoves: { '0': 0, }, @@ -623,7 +640,8 @@ describe('setActivePlayers', () => { expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], - _activePlayersMoveLimit: { '0': 3 }, + _activePlayersMinMoves: { '0': 2 }, + _activePlayersMaxMoves: { '0': 3 }, _activePlayersNumMoves: { '0': 1, }, @@ -637,10 +655,12 @@ describe('setActivePlayers', () => { { activePlayers: { '0': 'stage1' }, _activePlayersNumMoves: { '0': 2 }, - _activePlayersMoveLimit: { '0': 3 }, + _activePlayersMinMoves: { '0': 2 }, + _activePlayersMaxMoves: { '0': 3 }, }, ], - _activePlayersMoveLimit: { '0': 1 }, + _activePlayersMinMoves: { '0': 1 }, + _activePlayersMaxMoves: { '0': 1 }, _activePlayersNumMoves: { '0': 0, }, @@ -651,7 +671,8 @@ describe('setActivePlayers', () => { expect(state.ctx).toMatchObject({ activePlayers: { '0': 'stage1' }, _prevActivePlayers: [], - _activePlayersMoveLimit: { '0': 3 }, + _activePlayersMinMoves: { '0': 2 }, + _activePlayersMaxMoves: { '0': 3 }, _activePlayersNumMoves: { '0': 2, }, @@ -668,10 +689,12 @@ describe('setActivePlayers', () => { turn: { activePlayers: { currentPlayer: 'stage1', - moveLimit: 1, + minMoves: 1, + maxMoves: 1, next: { currentPlayer: 'stage2', - moveLimit: 1, + minMoves: 1, + maxMoves: 1, next: { currentPlayer: 'stage3', }, @@ -688,7 +711,8 @@ describe('setActivePlayers', () => { _prevActivePlayers: [], _nextActivePlayers: { currentPlayer: 'stage2', - moveLimit: 1, + minMoves: 1, + maxMoves: 1, next: { currentPlayer: 'stage3', }, @@ -721,7 +745,8 @@ describe('setActivePlayers', () => { turn: { activePlayers: { all: 'play', - moveLimit: 3, + minMoves: 1, + maxMoves: 3, }, stages: { play: { moves: { A: () => {} } }, @@ -732,7 +757,13 @@ describe('setActivePlayers', () => { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); - expect(state.ctx._activePlayersMoveLimit).toEqual({ + expect(state.ctx._activePlayersMinMoves).toEqual({ + '0': 1, + '1': 1, + '2': 1, + }); + + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 3, '1': 3, '2': 3, @@ -767,8 +798,8 @@ describe('setActivePlayers', () => { const game = { turn: { activePlayers: { - currentPlayer: { stage: 'play', moveLimit: 2 }, - others: { stage: 'play', moveLimit: 1 }, + currentPlayer: { stage: 'play', minMoves: 1, maxMoves: 2 }, + others: { stage: 'play', maxMoves: 1 }, }, stages: { play: { moves: { A: () => {} } }, @@ -779,7 +810,9 @@ describe('setActivePlayers', () => { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); - expect(state.ctx._activePlayersMoveLimit).toEqual({ + expect(state.ctx._activePlayersMinMoves).toStrictEqual({ '0': 1 }); + + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2, '1': 1, '2': 1, @@ -808,19 +841,25 @@ describe('setActivePlayers', () => { expect(state.ctx.activePlayers).toBeNull(); }); - test('player-specific limit overrides moveLimit arg', () => { + test('player-specific limit overrides move limit args', () => { const game = { turn: { activePlayers: { - all: { stage: 'play', moveLimit: 2 }, - moveLimit: 1, + all: { stage: 'play', minMoves: 2, maxMoves: 2 }, + minMoves: 1, + maxMoves: 1, }, }, }; const state = InitializeGame({ game, numPlayers: 2 }); - expect(state.ctx._activePlayersMoveLimit).toEqual({ + expect(state.ctx._activePlayersMinMoves).toEqual({ + '0': 2, + '1': 2, + }); + + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 2, '1': 2, }); @@ -831,9 +870,9 @@ describe('setActivePlayers', () => { turn: { activePlayers: { value: { - '0': { stage: 'play', moveLimit: 1 }, - '1': { stage: 'play', moveLimit: 2 }, - '2': { stage: 'play', moveLimit: 3 }, + '0': { stage: 'play', maxMoves: 1 }, + '1': { stage: 'play', minMoves: 1, maxMoves: 2 }, + '2': { stage: 'play', minMoves: 2, maxMoves: 3 }, }, }, stages: { @@ -845,7 +884,12 @@ describe('setActivePlayers', () => { const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); - expect(state.ctx._activePlayersMoveLimit).toEqual({ + expect(state.ctx._activePlayersMinMoves).toStrictEqual({ + '1': 1, + '2': 2, + }); + + expect(state.ctx._activePlayersMaxMoves).toEqual({ '0': 1, '1': 2, '2': 3, @@ -909,7 +953,8 @@ describe('setActivePlayers', () => { militia: (G, ctx) => { ctx.events.setActivePlayers({ others: 'discard', - moveLimit: 1, + minMoves: 1, + maxMoves: 1, revert: true, }); }, diff --git a/src/core/turn-order.ts b/src/core/turn-order.ts index 8fb89fec2..acc1a331d 100644 --- a/src/core/turn-order.ts +++ b/src/core/turn-order.ts @@ -16,6 +16,7 @@ import type { State, TurnConfig, } from '../types'; +import { supportDeprecatedMoveLimit } from './backwards-compatibility'; export function SetActivePlayers( ctx: Ctx, @@ -24,7 +25,8 @@ export function SetActivePlayers( let activePlayers: typeof ctx.activePlayers = {}; let _prevActivePlayers: typeof ctx._prevActivePlayers = []; let _nextActivePlayers: ActivePlayersArg | null = null; - let _activePlayersMoveLimit = {}; + let _activePlayersMinMoves = {}; + let _activePlayersMaxMoves = {}; if (Array.isArray(arg)) { // support a simple array of player IDs as active players @@ -33,6 +35,10 @@ export function SetActivePlayers( activePlayers = value; } else { // process active players argument object + + // stages previously did not enforce minMoves, this behaviour is kept intentionally + supportDeprecatedMoveLimit(arg); + if (arg.next) { _nextActivePlayers = arg.next; } @@ -42,7 +48,8 @@ export function SetActivePlayers( ...ctx._prevActivePlayers, { activePlayers: ctx.activePlayers, - _activePlayersMoveLimit: ctx._activePlayersMoveLimit, + _activePlayersMinMoves: ctx._activePlayersMinMoves, + _activePlayersMaxMoves: ctx._activePlayersMaxMoves, _activePlayersNumMoves: ctx._activePlayersNumMoves, }, ]; @@ -51,7 +58,8 @@ export function SetActivePlayers( if (arg.currentPlayer !== undefined) { ApplyActivePlayerArgument( activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, ctx.currentPlayer, arg.currentPlayer ); @@ -63,7 +71,8 @@ export function SetActivePlayers( if (id !== ctx.currentPlayer) { ApplyActivePlayerArgument( activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, id, arg.others ); @@ -76,7 +85,8 @@ export function SetActivePlayers( const id = ctx.playOrder[i]; ApplyActivePlayerArgument( activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, id, arg.all ); @@ -87,17 +97,26 @@ export function SetActivePlayers( for (const id in arg.value) { ApplyActivePlayerArgument( activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, id, arg.value[id] ); } } - if (arg.moveLimit) { + if (arg.minMoves) { + for (const id in activePlayers) { + if (_activePlayersMinMoves[id] === undefined) { + _activePlayersMinMoves[id] = arg.minMoves; + } + } + } + + if (arg.maxMoves) { for (const id in activePlayers) { - if (_activePlayersMoveLimit[id] === undefined) { - _activePlayersMoveLimit[id] = arg.moveLimit; + if (_activePlayersMaxMoves[id] === undefined) { + _activePlayersMaxMoves[id] = arg.maxMoves; } } } @@ -107,8 +126,12 @@ export function SetActivePlayers( activePlayers = null; } - if (Object.keys(_activePlayersMoveLimit).length === 0) { - _activePlayersMoveLimit = null; + if (Object.keys(_activePlayersMinMoves).length === 0) { + _activePlayersMinMoves = null; + } + + if (Object.keys(_activePlayersMaxMoves).length === 0) { + _activePlayersMaxMoves = null; } const _activePlayersNumMoves = {}; @@ -119,7 +142,8 @@ export function SetActivePlayers( return { ...ctx, activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, @@ -134,7 +158,8 @@ export function SetActivePlayers( export function UpdateActivePlayersOnceEmpty(ctx: Ctx) { let { activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, _nextActivePlayers, @@ -145,25 +170,32 @@ export function UpdateActivePlayersOnceEmpty(ctx: Ctx) { ctx = SetActivePlayers(ctx, _nextActivePlayers); ({ activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, } = ctx); } else if (_prevActivePlayers.length > 0) { const lastIndex = _prevActivePlayers.length - 1; - ({ activePlayers, _activePlayersMoveLimit, _activePlayersNumMoves } = - _prevActivePlayers[lastIndex]); + ({ + activePlayers, + _activePlayersMinMoves, + _activePlayersMaxMoves, + _activePlayersNumMoves, + } = _prevActivePlayers[lastIndex]); _prevActivePlayers = _prevActivePlayers.slice(0, lastIndex); } else { activePlayers = null; - _activePlayersMoveLimit = null; + _activePlayersMinMoves = null; + _activePlayersMaxMoves = null; } } return { ...ctx, activePlayers, - _activePlayersMoveLimit, + _activePlayersMinMoves, + _activePlayersMaxMoves, _activePlayersNumMoves, _prevActivePlayers, }; @@ -172,13 +204,15 @@ export function UpdateActivePlayersOnceEmpty(ctx: Ctx) { /** * Apply an active player argument to the given player ID * @param {Object} activePlayers - * @param {Object} _activePlayersMoveLimit + * @param {Object} _activePlayersMinMoves + * @param {Object} _activePlayersMaxMoves * @param {String} playerID The player to apply the parameter to * @param {(String|Object)} arg An active player argument */ function ApplyActivePlayerArgument( activePlayers: Ctx['activePlayers'], - _activePlayersMoveLimit: Ctx['_activePlayersMoveLimit'], + _activePlayersMinMoves: Ctx['_activePlayersMinMoves'], + _activePlayersMaxMoves: Ctx['_activePlayersMaxMoves'], playerID: PlayerID, arg: StageArg ) { @@ -187,8 +221,12 @@ function ApplyActivePlayerArgument( } if (arg.stage !== undefined) { + // stages previously did not enforce minMoves, this behaviour is kept intentionally + supportDeprecatedMoveLimit(arg); + activePlayers[playerID] = arg.stage; - if (arg.moveLimit) _activePlayersMoveLimit[playerID] = arg.moveLimit; + if (arg.minMoves) _activePlayersMinMoves[playerID] = arg.minMoves; + if (arg.maxMoves) _activePlayersMaxMoves[playerID] = arg.maxMoves; } } @@ -414,7 +452,7 @@ export const ActivePlayers = { * This is typically used in a phase where you want to elicit a response * from every player in the game. */ - ALL_ONCE: { all: Stage.NULL, moveLimit: 1 }, + ALL_ONCE: { all: Stage.NULL, minMoves: 1, maxMoves: 1 }, /** * OTHERS @@ -431,5 +469,5 @@ export const ActivePlayers = { * This is typically used in a phase where you want to elicit a response * from every *other* player in the game. */ - OTHERS_ONCE: { others: Stage.NULL, moveLimit: 1 }, + OTHERS_ONCE: { others: Stage.NULL, minMoves: 1, maxMoves: 1 }, }; diff --git a/src/types.ts b/src/types.ts index e9508be16..b9837a868 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,13 +59,24 @@ export type PartialGameState = Pick; export type StageName = string; export type PlayerID = string; -export type StageArg = StageName | { stage?: StageName; moveLimit?: number }; +export type StageArg = + | StageName + | { + stage?: StageName; + /** @deprecated Use `minMoves` and `maxMoves` instead. */ + moveLimit?: number; + minMoves?: number; + maxMoves?: number; + }; export interface ActivePlayersArg { currentPlayer?: StageArg; others?: StageArg; all?: StageArg; value?: Record; + minMoves?: number; + maxMoves?: number; + /** @deprecated Use `minMoves` and `maxMoves` instead. */ moveLimit?: number; revert?: boolean; next?: ActivePlayersArg; @@ -86,11 +97,13 @@ export interface Ctx { gameover?: any; turn: number; phase: string; - _activePlayersMoveLimit?: Record; + _activePlayersMinMoves?: Record; + _activePlayersMaxMoves?: Record; _activePlayersNumMoves?: Record; _prevActivePlayers?: Array<{ activePlayers: null | ActivePlayers; - _activePlayersMoveLimit?: Record; + _activePlayersMinMoves?: Record; + _activePlayersMaxMoves?: Record; _activePlayersNumMoves?: Record; }>; _nextActivePlayers?: ActivePlayersArg; @@ -256,6 +269,9 @@ export interface TurnConfig< CtxWithPlugins extends Ctx = Ctx > { activePlayers?: ActivePlayersArg; + minMoves?: number; + maxMoves?: number; + /** @deprecated Use `minMoves` and `maxMoves` instead. */ moveLimit?: number; onBegin?: (G: G, ctx: CtxWithPlugins) => any; onEnd?: (G: G, ctx: CtxWithPlugins) => any;