Skip to content

Commit

Permalink
Random API (#103)
Browse files Browse the repository at this point in the history
* add dependency to seedrandom

* initially add module containing random/shuffle fns

* align tests with other test code.

* add seed to ctx (either a provided or a created one)

* secret state: delete "seed" key from ctx where playerView() is used.

* amend test to bring branch coverage to 100%

* use object spread syntax for key deletion.

* store prng state on ctx.

* implement rolldie and allow evaluation.

* drop any modification of and reliance on an existing ctx

* inline newCtx

* pass seed through separate option rather than through G

* prepare code to be split into public/internal parts, commented out shuffle

* split API public/internal

* drop shuffle

* drop TODOs

* fix comments

* embed random into flow, breaks a log test

* fix gamelog rewind test

* use es6 export keyword

* add seedrandom as new global

* drop console.log statements used for debugging

* add random.md after first iteration

* add second iteration on random.md

* add second iteration over client-facing random API

* allow to evaluate random die values.

* add fns for all commonly used dice.

* allow arbitrary die spot values.

* add export random API

* pass seed to flow for proper initialization

* add third pass over random.md

* add license headers for new files

* some tweaks to random.md

* rename RequestRandom to Random

* pass spotvalue as arg to addrandomop and retire regex

* update random.md
  • Loading branch information
Stefan-Hanke authored and nicolodavis committed Feb 16, 2018
1 parent f510b69 commit d296b36
Show file tree
Hide file tree
Showing 18 changed files with 345 additions and 13 deletions.
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* [Turn Order](turn-order.md)
* [Phases](phases.md)
* [Secret State](secret-state.md)
* [Randomness](random.md)

* API Reference

Expand Down
56 changes: 56 additions & 0 deletions docs/random.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Randomness and its Use in Games

Many games allow moves whose outcome depends on shuffled cards or rolled dice.
Take e.g. the game [Yahtzee](https://en.wikipedia.org/wiki/Yahtzee).
A player rolls dice, chooses some, rolls another time, chooses some more, and does a final dice roll.
Depending on the face-up sides he now must choose where he will score.

This poses interesting challenges regarding the implementation.

* **AI**. Randomness makes games interesting since you cannot predict the future, but it
needs to be controlled in order for allowing games that can be replayed exactly (e.g. for AI purposes).

* **PRNG State**. The game runs on both the server and client.
All code and data on the client can be viewed and used to a player's advantage.
If a client could predict the next random numbers that are to be generated, the future flow of a game stops being unpredictable.
The library must not allow such a scenario. The RNG and its state must stay at the server.

* **Pure Functions**. The library is built using Redux. This is important for games since each move is a [reducer](https://redux.js.org/docs/basics/Reducers.html),
and thus must be pure. Calling `Math.random()` and other functions that
maintain external state would make the game logic impure and not idempotent.

## Using Randomness in Games

[boardgame.io]() takes a rather unusual approach to randomness: It disallows getting random variables directly.
Instead, a game can ask the engine to generate random numbers, and the engine will inject those into the game on the next move.

```js
import { Random } from 'boardgame.io/core';

const SomeGame = Game({
moves: {
rollDie(G, ctx) {
// G.diceValue will contain the requested
// die value at the end of this move.
return Random.D6(G, 'diceValue');
},
},

flow: {
onMove: G => {
const dice = G.diceValue;
// do something...
return { ...G };
},
},
// ...
});
```

This will place a request to a D6 dice roll inside `G`.
While processing the move, the request gets evaluated and the result placed into `diceValue`, where it can be used.

## Background

There is an interesting background article by David Bau called [Random Seeds, Coded Hints, and Quintillions](http://davidbau.com/archives/2010/01/30/random_seeds_coded_hints_and_quintillions.html).
Despite its age, this article gives insight on topics about randomness, like differentiating _local_ and _network_ entropy.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"react-json-view": "^1.13.0",
"react-redux": "^5.0.6",
"redux": "^3.7.2",
"seedrandom": "^2.4.3",
"socket.io": "^2.0.3"
},
"peerDependencies": {
Expand Down
3 changes: 2 additions & 1 deletion packages/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ import Game from '../src/core/game.js';
import { Flow, FlowWithPhases } from '../src/core/flow.js';
import { TurnOrder, Pass } from '../src/core/turn-order.js';
import { PlayerView } from '../src/core/player-view.js';
import { Random } from '../src/core/random';

export { Game, Flow, FlowWithPhases, TurnOrder, Pass, PlayerView };
export { Game, Flow, FlowWithPhases, TurnOrder, Pass, PlayerView, Random };
2 changes: 2 additions & 0 deletions packages/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Token from '../src/ui/token.js';
import { Card } from '../src/ui/card.js';
import { Grid } from '../src/ui/grid.js';
import { HexGrid } from '../src/ui/hex.js';
import { Random } from '../src/core/random';

export default {
Client,
Expand All @@ -26,4 +27,5 @@ export default {
Token,
Grid,
HexGrid,
Random,
};
5 changes: 4 additions & 1 deletion rollup.npm.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const globals = {
'react-json-view': 'ReactJson',
mousetrap: 'Mousetrap',
'socket.io-client': 'io',
seedrandom: 'seedrandom',
};

export default [
Expand Down Expand Up @@ -60,6 +61,8 @@ export default [

{
input: 'packages/core.js',
external: ['seedrandom'],
globals: { seedrandom: 'seedrandom' },
output: { file: 'dist/core.js', format: 'umd' },
name: 'Core',
plugins: plugins,
Expand Down Expand Up @@ -91,7 +94,7 @@ export default [
// Browser minified version.
{
input: 'packages/main.js',
globals: { react: 'React' },
globals: { react: 'React', seedrandom: 'seedrandom' },
external: ['react'],
output: [{ file: pkg.unpkg, format: 'umd' }],
name: 'BoardgameIO',
Expand Down
4 changes: 3 additions & 1 deletion src/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,12 @@ function Client({ game, numPlayers, board, multiplayer, debug }) {
isActive = false;
}

let newctx = { ...state.ctx, seed: undefined };
return {
...state,
isActive,
G: game.playerView(state.G, state.ctx, this.props.playerID),
G: game.playerView(state.G, newctx, this.props.playerID),
ctx: newctx,
};
};

Expand Down
16 changes: 9 additions & 7 deletions src/client/log/log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ test('GameLog', () => {
test('GameLog rewind', () => {
const game = Game({
moves: {
A: (G, ctx, arg) => arg,
A: (G, ctx, arg) => {
return { arg };
},
},

flow: {
endTurnIf: G => G == 42,
endTurnIf: G => G && G.arg == 42,
},
});
const reducer = createGameReducer({ game });
Expand All @@ -59,25 +61,25 @@ test('GameLog rewind', () => {
</Provider>
);

expect(store.getState().G).toEqual(2);
expect(store.getState().G).toMatchObject({ arg: 2 });
root
.find('.log-turn')
.at(0)
.simulate('mouseover');
expect(store.getState().G).toEqual(1);
expect(store.getState().G).toEqual({ arg: 1 });
root
.find('.log-turn')
.at(0)
.simulate('mouseout');
expect(store.getState().G).toEqual(2);
expect(store.getState().G).toEqual({ arg: 2 });
root
.find('.log-turn')
.at(1)
.simulate('mouseover');
expect(store.getState().G).toEqual(42);
expect(store.getState().G).toEqual({ arg: 42 });
root
.find('.log-turn')
.at(0)
.simulate('mouseout');
expect(store.getState().G).toEqual(2);
expect(store.getState().G).toEqual({ arg: 2 });
});
7 changes: 7 additions & 0 deletions src/core/flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import * as ActionCreators from './action-creators';
import { TurnOrder } from './turn-order';
import { runrandom } from './randomeval';

/**
* Helper to create a reducer that manages ctx (with the
Expand Down Expand Up @@ -169,6 +170,7 @@ export function FlowWithPhases({
turnOrder,
endTurn,
endPhase,
seed,
}) {
// Attach defaults.
if (endPhase === undefined && phases) {
Expand Down Expand Up @@ -352,6 +354,10 @@ export function FlowWithPhases({

const conf = phaseMap[state.ctx.phase];

// run random operations
let { G: GRandom, ctx: ctxRandom } = runrandom(state.G, state.ctx);
state = { ...state, G: GRandom, ctx: ctxRandom };

const G = conf.onMove(state.G, state.ctx, action);
state = { ...state, G };

Expand Down Expand Up @@ -395,6 +401,7 @@ export function FlowWithPhases({

return Flow({
ctx: numPlayers => ({
seed,
numPlayers,
turn: 0,
currentPlayer: '0',
Expand Down
18 changes: 18 additions & 0 deletions src/core/flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createStore } from 'redux';
import { createGameReducer } from './reducer';
import { makeMove, gameEvent } from './action-creators';
import { Flow, FlowWithPhases, createEventDispatchers } from './flow';
import { Random } from './random';

test('Flow', () => {
const flow = Flow({});
Expand Down Expand Up @@ -95,6 +96,23 @@ test('movesPerTurn', () => {
state = flow.processMove(state, { move: {} });
expect(state.ctx.turn).toBe(2);
}

{
let flow = FlowWithPhases({ movesPerTurn: 2 });
let G = Random.Number({}, 'field1');
G = Random.D6(G, 'field2');
let state = { ctx: flow.ctx(2), G };
state.ctx.seed = 'seed';

state = flow.processMove(state, { move: {} });

expect(state.G.field1).toBeDefined();
expect(state.G.field1).toBeGreaterThanOrEqual(0);
expect(state.G.field1).toBeLessThanOrEqual(1);
expect(state.G.field2).toBeDefined();
expect(state.G.field2).toBeGreaterThanOrEqual(1);
expect(state.G.field2).toBeLessThanOrEqual(6);
}
});

test('onTurnBegin', () => {
Expand Down
11 changes: 9 additions & 2 deletions src/core/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,22 @@ import { FlowWithPhases } from './flow';
* Must contain the return value of Flow().
* If it contains any other object, it is presumed to be a
* configuration object for SimpleFlow() or FlowWithPhases().
* @param {string} seed - If present, used as seed for the PRNG.
* If omitted, the seed will be autogenerated.
*/
function Game({ name, setup, moves, playerView, flow }) {
function Game({ name, setup, moves, playerView, flow, seed }) {
if (!name) name = 'default';
if (!setup) setup = () => ({});
if (!moves) moves = {};
if (!playerView) playerView = G => G;

if (!flow || flow.processGameEvent === undefined) {
flow = FlowWithPhases(flow || {});
// either take the provided seed, or use seedrandom to create one.
// Math.seedrandom returns an autoseed with local entropy only.
const actualseed = seed === undefined ? Math.seedrandom() : seed;

let flowconfig = Object.assign({ seed: actualseed }, flow || {});
flow = FlowWithPhases(flowconfig);
}

return {
Expand Down
37 changes: 37 additions & 0 deletions src/core/random.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2017 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/

import { addrandomop, DICE, NUMBER } from './randomeval';

const SpotValue = {
D4: 4,
D6: 6,
D8: 8,
D10: 10,
D12: 12,
D20: 20,
};

// generate functions for predefined dice values D4 - D20
const predefined = {};
for (const key in SpotValue) {
const value = SpotValue[key];
predefined[key] = (G, fieldname) => {
return addrandomop(G, fieldname, DICE, value);
};
}

export const Random = {
...predefined,
Die: (G, fieldname, spotvalue) => {
return addrandomop(G, fieldname, DICE, spotvalue);
},
Number: (G, fieldname) => {
return addrandomop(G, fieldname, NUMBER);
},
};
75 changes: 75 additions & 0 deletions src/core/randomeval.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2017 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/

import seedrandom from 'seedrandom';

export const DICE = 'DICE';
export const NUMBER = 'NUMBER';

function getrandomfn(ctx) {
let randomfn;
if (ctx.prngstate === undefined) {
// no call to a random function has been made.
// pre-populate the state info
randomfn = new seedrandom.alea(ctx.seed, { state: true });
} else {
randomfn = new seedrandom.alea('', { state: ctx.prngstate });
}
return randomfn;
}

export function randomctx(ctx) {
const r = getrandomfn(ctx);
const randomnumber = r();
const ctx2 = { ...ctx, prngstate: r.state() };
return { randomnumber, ctx: ctx2 };
}

export function addrandomop(G, fieldname, op, ...args) {
let rop = [{ op, fieldname, args }];
let _randomOps = [...(G._randomOps || []), ...rop];
return { ...G, _randomOps };
}

export function evaluaterandomops(G, ctx) {
let randomresults = {};
let ctx2 = ctx;

// some flow tests run without a defined G
if (G && G._randomOps !== undefined) {
G._randomOps.forEach(r => {
const { ctx: ctx3, randomnumber } = randomctx(ctx2);
ctx2 = ctx3;

switch (r.op) {
case DICE: {
const spotvalue = r.args[0];
const dievalue = Math.floor(randomnumber * spotvalue) + 1;
randomresults[r.fieldname] = dievalue;
break;
}

case NUMBER: {
randomresults[r.fieldname] = randomnumber;
break;
}

default:
break;
}
});
}

return { randomresults, ctx: ctx2 };
}

export function runrandom(G, ctx) {
let { randomresults, ctx: ctx2 } = evaluaterandomops(G, ctx);
const G2 = { ...G, ...randomresults, _randomOps: undefined };
return { G: G2, ctx: ctx2 };
}

0 comments on commit d296b36

Please sign in to comment.