Skip to content

Commit

Permalink
Custom turn order (#130)
Browse files Browse the repository at this point in the history
* custom order on G

* playerOrder in ctx, aligned TurnOrder, passMap modified to passOrder as array

* renamed to playOrder, added playOrderPos, currentPlayer is calculated from playOrder[playOrderPos]

* add getCurrentPlayer()

* remove onPhaseBegin from test

* reduce test boilerplate

* update docs
  • Loading branch information
rzulian authored and nicolodavis committed Mar 18, 2018
1 parent 748f36f commit 7fcdbfe
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 41 deletions.
60 changes: 50 additions & 10 deletions docs/turn-order.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
# Turn Order

You can customize the order in which the turn gets passed between players
by using the `turnOrder` option. This is passed inside a `flow` section of
the `Game` configuration.
The framework maintains the turn order using the following fields:

The default turn order (round-robin) is called `TurnOrder.DEFAULT`.
```
ctx: {
currentPlayer: '0',
playOrder: ['0', '1', '2', ...],
playOrderPos: 0,
}
```

`playOrderPos` is an index into `playOrder` and the way in which it
is updated is determined by a particular `TurnOrder`. The default
behavior is to just increment it in a round-robin fashion.
`currentPlayer` is just `playerOrder[playOrderPos]`.

If you need something different, you can customize this behavior
by using the `turnOrder` option. This is passed inside a `flow`
section of the `Game` configuration. The framework comes bundled
with a few turn orders in the `TurnOrder` object, and you can
even provide your own implementation.

```js
import { Game, TurnOrder } from 'boardgame.io/core';
Expand All @@ -20,17 +35,42 @@ Game({
}
```
!> Turn orders can also be specified on a per-phase level.
#### Custom Turn Order
A `TurnOrder` object has the following structure:
```js
{
// Get the first player.
first: (G, ctx) => startingPlayer,
// Get the initial value of playOrderPos,
first: (G, ctx) => 0,

// Get the next value of playOrderPos when endTurn is called.
next: (G, ctx) => (ctx.playOrderPos + 1) % ctx.numPlayers,
}
```
// Get the next player when endTurn is called.
next: (G, ctx) => nextPlayer
The implementation above shows the default round-robin order.
If you want to skip over every other player (for example), do
something like this:
```js
import { Game } from 'boardgame.io/core';

Game({
moves: {
...
},

flow: {
turnOrder: {
first: () => 0,
next: (G, ctx) => (ctx.playOrderPos + 2) % ctx.numPlayers,
}
}
}
```
!> `TurnOrder.ANY` implements a turn order where any player can play,
and there isn't really a concept of a current player.
!> If you would like any player to play, then return `undefined` from
these functions. `TurnOrder.ANY` implements this.
24 changes: 21 additions & 3 deletions src/core/flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,19 @@ export function FlowWithPhases({
return conf.endTurnIf(G, ctx);
};

const getCurrentPlayer = (playOrder, playOrderPos) => {
if (playOrderPos === undefined) {
return 'any';
}
return playOrder[playOrderPos] + '';
};

// Helper to perform start-of-phase initialization.
const startPhase = function(state, phaseConfig) {
const ctx = { ...state.ctx };
const G = phaseConfig.onPhaseBegin(state.G, ctx);
ctx.currentPlayer = phaseConfig.turnOrder.first(G, ctx);
ctx.playOrderPos = phaseConfig.turnOrder.first(G, ctx);
ctx.currentPlayer = getCurrentPlayer(ctx.playOrder, ctx.playOrderPos);
ctx.actionPlayers = [ctx.currentPlayer];
return { ...state, G, ctx };
};
Expand Down Expand Up @@ -400,12 +408,20 @@ export function FlowWithPhases({
}

// Update current player.
const currentPlayer = conf.turnOrder.next(G, ctx);
const playOrderPos = conf.turnOrder.next(G, ctx);
const currentPlayer = getCurrentPlayer(ctx.playOrder, playOrderPos);
const actionPlayers = [currentPlayer];
// Update turn.
const turn = ctx.turn + 1;
// Update state.
ctx = { ...ctx, currentPlayer, actionPlayers, turn, currentPlayerMoves: 0 };
ctx = {
...ctx,
playOrderPos,
currentPlayer,
actionPlayers,
turn,
currentPlayerMoves: 0,
};

// End phase if condition is met.
const end = conf.endPhaseIf(G, ctx);
Expand Down Expand Up @@ -532,6 +548,8 @@ export function FlowWithPhases({
turn: 0,
currentPlayer: '0',
currentPlayerMoves: 0,
playOrder: Array.from(Array(numPlayers), (d, i) => i),
playOrderPos: 0,
phase: phases[0].name,
}),
init: state => {
Expand Down
16 changes: 8 additions & 8 deletions src/core/game.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ test('rounds with starting player token', () => {
{
name: 'main',
turnOrder: {
first: G => G.startingPlayerToken + '',
next: (G, ctx) => (+ctx.currentPlayer + 1) % ctx.numPlayers + '',
first: G => G.startingPlayerToken,
next: (G, ctx) => (+ctx.playOrderPos + 1) % ctx.playOrder.length,
},
},
],
Expand Down Expand Up @@ -101,22 +101,22 @@ test('serpentine setup phases', () => {
{
name: 'first setup round',
turnOrder: {
first: () => '0',
next: (G, ctx) => (+ctx.currentPlayer + 1) % ctx.numPlayers + '',
first: () => 0,
next: (G, ctx) => (+ctx.playOrderPos + 1) % ctx.playOrder.length,
},
},
{
name: 'second setup round',
turnOrder: {
first: (G, ctx) => ctx.numPlayers - 1 + '',
next: (G, ctx) => (+ctx.currentPlayer - 1) % ctx.numPlayers + '',
first: (G, ctx) => ctx.playOrder.length - 1,
next: (G, ctx) => (+ctx.playOrderPos - 1) % ctx.playOrder.length,
},
},
{
name: 'main phase',
turnOrder: {
first: () => '0',
next: (G, ctx) => (+ctx.currentPlayer + 1) % ctx.numPlayers + '',
first: () => 0,
next: (G, ctx) => (+ctx.playOrderPos + 1) % ctx.playOrder.length,
},
},
],
Expand Down
34 changes: 17 additions & 17 deletions src/core/turn-order.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@
* Standard move that simulates passing.
*
* Creates two objects in G:
* passMap - A map from playerID -> boolean capturing passes.
* passOrder - An array of playerIDs capturing passes in the pass order.
* allPassed - Set to true when all players have passed.
*/
export const Pass = (G, ctx) => {
let passMap = {};
if (G.passMap !== undefined) {
passMap = { ...G.passMap };
let passOrder = [];
if (G.passOrder !== undefined) {
passOrder = G.passOrder;
}
const playerID =
ctx.currentPlayer === 'any' ? ctx.playerID : ctx.currentPlayer;
passMap[playerID] = true;
G = { ...G, passMap };
if (Object.keys(passMap).length >= ctx.numPlayers) {
passOrder.push(playerID);
G = { ...G, passOrder };
if (passOrder.length >= ctx.numPlayers) {
G.allPassed = true;
}
return G;
Expand All @@ -44,8 +44,8 @@ export const TurnOrder = {
* The default round-robin turn order.
*/
DEFAULT: {
first: (G, ctx) => ctx.currentPlayer,
next: (G, ctx) => (+ctx.currentPlayer + 1) % ctx.numPlayers + '',
first: () => 0,
next: (G, ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length,
},

/**
Expand All @@ -54,8 +54,8 @@ export const TurnOrder = {
* Any player can play and there isn't a currentPlayer really.
*/
ANY: {
first: () => 'any',
next: () => 'any',
first: () => undefined,
next: () => undefined,
},

/**
Expand All @@ -66,14 +66,14 @@ export const TurnOrder = {
*/

SKIP: {
first: (G, ctx) => ctx.currentPlayer,
first: () => 0,
next: (G, ctx) => {
if (G.allPassed) return;
let nextPlayer = ctx.currentPlayer;
for (let i = 0; i < ctx.numPlayers; i++) {
nextPlayer = (+nextPlayer + 1) % ctx.numPlayers + '';
if (!(nextPlayer in G.passMap)) {
return nextPlayer;
let playOrderPos = ctx.playOrderPos;
for (let i = 0; i < ctx.playOrder.length; i++) {
playOrderPos = (playOrderPos + 1) % ctx.playOrder.length;
if (!G.passOrder.includes(ctx.playOrder[playOrderPos] + '')) {
return playOrderPos;
}
}
},
Expand Down
29 changes: 26 additions & 3 deletions src/core/turn-order.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ test('turnOrder', () => {
expect(state.ctx.currentPlayer).toBe('any');

flow = FlowWithPhases({
phases: [{ name: 'A', turnOrder: { first: () => '10', next: () => '3' } }],
phases: [{ name: 'A', turnOrder: { first: () => 9, next: () => 3 } }],
});

state = { ctx: flow.ctx(10) };
state = flow.init(state);
expect(state.ctx.currentPlayer).toBe('10');
expect(state.ctx.currentPlayer).toBe('9');
state = flow.processGameEvent(state, { type: 'endTurn' });
expect(state.ctx.currentPlayer).toBe('3');
});
Expand All @@ -59,10 +59,12 @@ test('passing', () => {
state = reducer(state, makeMove('pass'));
state = reducer(state, gameEvent('endTurn'));
expect(state.G.allPassed).toBe(undefined);
expect(state.G.passOrder).toEqual(['0']);

expect(state.ctx.currentPlayer).toBe('1');
state = reducer(state, gameEvent('endTurn'));
expect(state.G.allPassed).toBe(undefined);
expect(state.G.passOrder).toEqual(['0']);

expect(state.ctx.currentPlayer).toBe('2');
state = reducer(state, gameEvent('endTurn'));
Expand All @@ -72,6 +74,7 @@ test('passing', () => {
state = reducer(state, makeMove('pass'));
state = reducer(state, gameEvent('endTurn'));
expect(state.G.allPassed).toBe(undefined);
expect(state.G.passOrder).toEqual(['0', '1']);

expect(state.ctx.currentPlayer).toBe('2');
state = reducer(state, gameEvent('endTurn'));
Expand All @@ -80,10 +83,10 @@ test('passing', () => {
expect(state.ctx.currentPlayer).toBe('2');
state = reducer(state, makeMove('pass'));
expect(state.G.allPassed).toBe(true);

expect(state.ctx.currentPlayer).toBe('2');
state = reducer(state, gameEvent('endTurn'));
expect(state.G.allPassed).toBe(true);
expect(state.G.passOrder).toEqual(['0', '1', '2']);
});

test('end game after everyone passes', () => {
Expand Down Expand Up @@ -144,3 +147,23 @@ test('override', () => {
state = flow.processGameEvent(state, { type: 'endTurn' });
expect(state.ctx.currentPlayer).toBe('5');
});

test('custom order', () => {
const game = Game({});
const reducer = createGameReducer({ game, numPlayers: 3 });

let state = reducer(undefined, { type: 'init' });

state.ctx = {
...state.ctx,
currentPlayer: '2',
playOrder: [2, 0, 1],
};

state = reducer(state, gameEvent('endTurn'));
expect(state.ctx.currentPlayer).toBe('0');
state = reducer(state, gameEvent('endTurn'));
expect(state.ctx.currentPlayer).toBe('1');
state = reducer(state, gameEvent('endTurn'));
expect(state.ctx.currentPlayer).toBe('2');
});
8 changes: 8 additions & 0 deletions src/server/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ test('action', async () => {
numPlayers: 2,
phase: 'default',
turn: 0,
playOrder: [0, 1],
playOrderPos: 0,
},
},
],
Expand All @@ -192,6 +194,8 @@ test('action', async () => {
numPlayers: 2,
phase: 'default',
turn: 0,
playOrder: [0, 1],
playOrderPos: 0,
},
log: [],
},
Expand All @@ -208,6 +212,8 @@ test('action', async () => {
numPlayers: 2,
phase: 'default',
turn: 1,
playOrder: [0, 1],
playOrderPos: 1,
},
},
],
Expand All @@ -218,6 +224,8 @@ test('action', async () => {
currentPlayerMoves: 0,
numPlayers: 2,
phase: 'default',
playOrder: [0, 1],
playOrderPos: 1,
turn: 1,
},
log: [{ args: undefined, playerID: undefined, type: 'endTurn' }],
Expand Down

0 comments on commit 7fcdbfe

Please sign in to comment.