Skip to content

Commit

Permalink
synchronous mode for game master
Browse files Browse the repository at this point in the history
This allows the local multiplayer mode to work without any
async / await, which simplifies testing. It also allows
using the vanilla JS client more easily without having
to worry about async logic.
  • Loading branch information
nicolodavis committed May 3, 2019
1 parent 8732d9f commit 3982150
Show file tree
Hide file tree
Showing 10 changed files with 321 additions and 256 deletions.
27 changes: 21 additions & 6 deletions src/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,6 @@ class _ClientImpl {
}
}

this.subscribeCallback();

return result;
};

Expand All @@ -195,23 +193,40 @@ class _ClientImpl {
* which keeps the authoritative version of the state.
*/
const TransportMiddleware = store => next => action => {
const state = store.getState();
const baseState = store.getState();
const result = next(action);

if (action.clientOnly != true) {
this.transport.onAction(state, action);
this.transport.onAction(baseState, action);
}

return result;
};

/**
* Middleware that intercepts actions and invokes the subscription callback.
*/
const SubscriptionMiddleware = () => next => action => {
const result = next(action);
this.subscribeCallback();
return result;
};

if (enhancer !== undefined) {
enhancer = compose(
applyMiddleware(LogMiddleware, TransportMiddleware),
applyMiddleware(
SubscriptionMiddleware,
TransportMiddleware,
LogMiddleware
),
enhancer
);
} else {
enhancer = applyMiddleware(LogMiddleware, TransportMiddleware);
enhancer = applyMiddleware(
SubscriptionMiddleware,
TransportMiddleware,
LogMiddleware
);
}

this.store = createStore(this.reducer, initialState, enhancer);
Expand Down
44 changes: 35 additions & 9 deletions src/client/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,20 +148,46 @@ describe('multiplayer', () => {
});

describe('local master', () => {
let client;
let client0;
let client1;
let spec;

beforeAll(() => {
client = Client(
GetOpts({
game: Game({ moves: { A: () => {} } }),
multiplayer: { local: true },
})
);
client.connect();
spec = GetOpts({
game: Game({ moves: { A: (G, ctx) => ({ A: ctx.playerID }) } }),
multiplayer: { local: true },
});

client0 = Client({ ...spec, playerID: '0' });
client1 = Client({ ...spec, playerID: '1' });

client0.connect();
client1.connect();
});

test('correct transport used', () => {
expect(client.transport instanceof Local).toBe(true);
expect(client0.transport instanceof Local).toBe(true);
expect(client1.transport instanceof Local).toBe(true);
});

test('multiplayer interactions', () => {
expect(client0.getState().ctx.currentPlayer).toBe('0');
expect(client1.getState().ctx.currentPlayer).toBe('0');

client0.moves.A();

expect(client0.getState().G).toEqual({ A: '0' });
expect(client1.getState().G).toEqual({ A: '0' });

client0.events.endTurn();

expect(client0.getState().ctx.currentPlayer).toBe('1');
expect(client1.getState().ctx.currentPlayer).toBe('1');

client1.moves.A();

expect(client0.getState().G).toEqual({ A: '1' });
expect(client1.getState().G).toEqual({ A: '1' });
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/client/react.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ test('custom loading component', () => {
game: Game({}),
loading: Loading,
board: TestBoard,
multiplayer: { local: true },
multiplayer: true,
});
const board = Enzyme.mount(<Board />);
expect(board.html()).toContain('custom');
Expand Down
24 changes: 10 additions & 14 deletions src/client/transport/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export function LocalMaster(game) {
}
};

const master = new Master(game, new InMemory(), { send, sendAll });
const master = new Master(game, new InMemory(), { send, sendAll }, false);
master.executeSynchronously = true;
master.connect = (gameID, playerID, callback) => {
clientCallbacks[playerID] = callback;
};
Expand Down Expand Up @@ -96,19 +97,14 @@ export class Local {
* Called when an action that has to be relayed to the
* game master is made.
*/
async onAction(state, action) {
await this.master.onUpdate(
action,
state._stateID,
this.gameID,
this.playerID
);
onAction(state, action) {
this.master.onUpdate(action, state._stateID, this.gameID, this.playerID);
}

/**
* Connect to the server.
*/
async connect() {
connect() {
this.master.connect(
this.gameID,
this.playerID,
Expand All @@ -121,7 +117,7 @@ export class Local {
}
}
);
await this.master.onSync(this.gameID, this.playerID, this.numPlayers);
this.master.onSync(this.gameID, this.playerID, this.numPlayers);
}

/**
Expand All @@ -133,21 +129,21 @@ export class Local {
* Updates the game id.
* @param {string} id - The new game id.
*/
async updateGameID(id) {
updateGameID(id) {
this.gameID = this.gameName + ':' + id;
const action = ActionCreators.reset(null);
this.store.dispatch(action);
await this.master.onSync(this.gameID, this.playerID, this.numPlayers);
this.master.onSync(this.gameID, this.playerID, this.numPlayers);
}

/**
* Updates the player associated with this client.
* @param {string} id - The new player id.
*/
async updatePlayerID(id) {
updatePlayerID(id) {
this.playerID = id;
const action = ActionCreators.reset(null);
this.store.dispatch(action);
await this.master.onSync(this.gameID, this.playerID, this.numPlayers);
this.master.onSync(this.gameID, this.playerID, this.numPlayers);
}
}
116 changes: 97 additions & 19 deletions src/master/master.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { MAKE_MOVE, GAME_EVENT } from '../core/action-types';
import { createStore } from 'redux';
import * as logging from '../core/logger';

const GameMetadataKey = gameID => `${gameID}:metadata`;

/**
* Redact the log.
*
Expand Down Expand Up @@ -53,6 +55,45 @@ export function redactLog(redactedMoves, log, playerID) {
});
}

/**
* Verifies that the move came from a player with the
* appropriate credentials.
*/
export const isActionFromAuthenticPlayer = ({
action,
gameMetadata,
playerID,
}) => {
if (!gameMetadata) {
return true;
}

if (!action.payload) {
return true;
}

const hasCredentials = Object.keys(gameMetadata.players).some(key => {
return !!(
gameMetadata.players[key] && gameMetadata.players[key].credentials
);
});
if (!hasCredentials) {
return true;
}

if (!action.payload.credentials) {
return false;
}

if (
action.payload.credentials !== gameMetadata.players[playerID].credentials
) {
return false;
}

return true;
};

/**
* Master
*
Expand All @@ -61,14 +102,16 @@ export function redactLog(redactedMoves, log, playerID) {
* storageAPI to communicate with the database.
*/
export class Master {
constructor(game, storageAPI, transportAPI, isActionFromAuthenticPlayer) {
constructor(game, storageAPI, transportAPI, auth) {
this.game = game;
this.storageAPI = storageAPI;
this.transportAPI = transportAPI;
this.isActionFromAuthenticPlayer = () => true;
this.auth = () => true;

if (isActionFromAuthenticPlayer !== undefined) {
this.isActionFromAuthenticPlayer = isActionFromAuthenticPlayer;
if (auth === true) {
this.auth = isActionFromAuthenticPlayer;
} else if (typeof auth === 'function') {
this.auth = auth;
}
}

Expand All @@ -78,8 +121,37 @@ export class Master {
* along with a deltalog.
*/
async onUpdate(action, stateID, gameID, playerID) {
let isActionAuthentic;

if (this.executeSynchronously) {
const gameMetadata = this.storageAPI.get(GameMetadataKey(gameID));
isActionAuthentic = this.auth({
action,
gameMetadata,
gameID,
playerID,
});
} else {
const gameMetadata = await this.storageAPI.get(GameMetadataKey(gameID));
isActionAuthentic = this.auth({
action,
gameMetadata,
gameID,
playerID,
});
}
if (!isActionAuthentic) {
return { error: 'unauthorized action' };
}

const key = gameID;
let state = await this.storageAPI.get(key);

let state;
if (this.executeSynchronously) {
state = this.storageAPI.get(key);
} else {
state = await this.storageAPI.get(key);
}

if (state === undefined) {
logging.error(`game not found, gameID=[${key}]`);
Expand All @@ -92,16 +164,6 @@ export class Master {
});
const store = createStore(reducer, state);

const isActionAuthentic = await this.isActionFromAuthenticPlayer({
action,
db: this.storageAPI,
gameID,
playerID,
});
if (!isActionAuthentic) {
return { error: 'unauthorized action' };
}

// Check whether the player is allowed to make the move.
if (
action.type == MAKE_MOVE &&
Expand Down Expand Up @@ -162,7 +224,11 @@ export class Master {
log = [...log, ...state.deltalog];
const stateWithLog = { ...state, log };

await this.storageAPI.set(key, stateWithLog);
if (this.executeSynchronously) {
this.storageAPI.set(key, stateWithLog);
} else {
await this.storageAPI.set(key, stateWithLog);
}
}

/**
Expand All @@ -172,14 +238,26 @@ export class Master {
async onSync(gameID, playerID, numPlayers) {
const key = gameID;

let state = await this.storageAPI.get(key);
let state;

if (this.executeSynchronously) {
state = this.storageAPI.get(key);
} else {
state = await this.storageAPI.get(key);
}

// If the game doesn't exist, then create one on demand.
// TODO: Move this out of the sync call.
if (state === undefined) {
state = InitializeGame({ game: this.game, numPlayers });
await this.storageAPI.set(key, state);
state = await this.storageAPI.get(key);

if (this.executeSynchronously) {
this.storageAPI.set(key, state);
state = this.storageAPI.get(key);
} else {
await this.storageAPI.set(key, state);
state = await this.storageAPI.get(key);
}
}

const filteredState = {
Expand Down
Loading

0 comments on commit 3982150

Please sign in to comment.