Skip to content

Commit 3982150

Browse files
committed
synchronous mode for game master
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.
1 parent 8732d9f commit 3982150

10 files changed

Lines changed: 321 additions & 256 deletions

File tree

src/client/client.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,6 @@ class _ClientImpl {
185185
}
186186
}
187187

188-
this.subscribeCallback();
189-
190188
return result;
191189
};
192190

@@ -195,23 +193,40 @@ class _ClientImpl {
195193
* which keeps the authoritative version of the state.
196194
*/
197195
const TransportMiddleware = store => next => action => {
198-
const state = store.getState();
196+
const baseState = store.getState();
199197
const result = next(action);
200198

201199
if (action.clientOnly != true) {
202-
this.transport.onAction(state, action);
200+
this.transport.onAction(baseState, action);
203201
}
204202

205203
return result;
206204
};
207205

206+
/**
207+
* Middleware that intercepts actions and invokes the subscription callback.
208+
*/
209+
const SubscriptionMiddleware = () => next => action => {
210+
const result = next(action);
211+
this.subscribeCallback();
212+
return result;
213+
};
214+
208215
if (enhancer !== undefined) {
209216
enhancer = compose(
210-
applyMiddleware(LogMiddleware, TransportMiddleware),
217+
applyMiddleware(
218+
SubscriptionMiddleware,
219+
TransportMiddleware,
220+
LogMiddleware
221+
),
211222
enhancer
212223
);
213224
} else {
214-
enhancer = applyMiddleware(LogMiddleware, TransportMiddleware);
225+
enhancer = applyMiddleware(
226+
SubscriptionMiddleware,
227+
TransportMiddleware,
228+
LogMiddleware
229+
);
215230
}
216231

217232
this.store = createStore(this.reducer, initialState, enhancer);

src/client/client.test.js

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -148,20 +148,46 @@ describe('multiplayer', () => {
148148
});
149149

150150
describe('local master', () => {
151-
let client;
151+
let client0;
152+
let client1;
153+
let spec;
152154

153155
beforeAll(() => {
154-
client = Client(
155-
GetOpts({
156-
game: Game({ moves: { A: () => {} } }),
157-
multiplayer: { local: true },
158-
})
159-
);
160-
client.connect();
156+
spec = GetOpts({
157+
game: Game({ moves: { A: (G, ctx) => ({ A: ctx.playerID }) } }),
158+
multiplayer: { local: true },
159+
});
160+
161+
client0 = Client({ ...spec, playerID: '0' });
162+
client1 = Client({ ...spec, playerID: '1' });
163+
164+
client0.connect();
165+
client1.connect();
161166
});
162167

163168
test('correct transport used', () => {
164-
expect(client.transport instanceof Local).toBe(true);
169+
expect(client0.transport instanceof Local).toBe(true);
170+
expect(client1.transport instanceof Local).toBe(true);
171+
});
172+
173+
test('multiplayer interactions', () => {
174+
expect(client0.getState().ctx.currentPlayer).toBe('0');
175+
expect(client1.getState().ctx.currentPlayer).toBe('0');
176+
177+
client0.moves.A();
178+
179+
expect(client0.getState().G).toEqual({ A: '0' });
180+
expect(client1.getState().G).toEqual({ A: '0' });
181+
182+
client0.events.endTurn();
183+
184+
expect(client0.getState().ctx.currentPlayer).toBe('1');
185+
expect(client1.getState().ctx.currentPlayer).toBe('1');
186+
187+
client1.moves.A();
188+
189+
expect(client0.getState().G).toEqual({ A: '1' });
190+
expect(client1.getState().G).toEqual({ A: '1' });
165191
});
166192
});
167193

src/client/react.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ test('custom loading component', () => {
7474
game: Game({}),
7575
loading: Loading,
7676
board: TestBoard,
77-
multiplayer: { local: true },
77+
multiplayer: true,
7878
});
7979
const board = Enzyme.mount(<Board />);
8080
expect(board.html()).toContain('custom');

src/client/transport/local.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export function LocalMaster(game) {
3131
}
3232
};
3333

34-
const master = new Master(game, new InMemory(), { send, sendAll });
34+
const master = new Master(game, new InMemory(), { send, sendAll }, false);
35+
master.executeSynchronously = true;
3536
master.connect = (gameID, playerID, callback) => {
3637
clientCallbacks[playerID] = callback;
3738
};
@@ -96,19 +97,14 @@ export class Local {
9697
* Called when an action that has to be relayed to the
9798
* game master is made.
9899
*/
99-
async onAction(state, action) {
100-
await this.master.onUpdate(
101-
action,
102-
state._stateID,
103-
this.gameID,
104-
this.playerID
105-
);
100+
onAction(state, action) {
101+
this.master.onUpdate(action, state._stateID, this.gameID, this.playerID);
106102
}
107103

108104
/**
109105
* Connect to the server.
110106
*/
111-
async connect() {
107+
connect() {
112108
this.master.connect(
113109
this.gameID,
114110
this.playerID,
@@ -121,7 +117,7 @@ export class Local {
121117
}
122118
}
123119
);
124-
await this.master.onSync(this.gameID, this.playerID, this.numPlayers);
120+
this.master.onSync(this.gameID, this.playerID, this.numPlayers);
125121
}
126122

127123
/**
@@ -133,21 +129,21 @@ export class Local {
133129
* Updates the game id.
134130
* @param {string} id - The new game id.
135131
*/
136-
async updateGameID(id) {
132+
updateGameID(id) {
137133
this.gameID = this.gameName + ':' + id;
138134
const action = ActionCreators.reset(null);
139135
this.store.dispatch(action);
140-
await this.master.onSync(this.gameID, this.playerID, this.numPlayers);
136+
this.master.onSync(this.gameID, this.playerID, this.numPlayers);
141137
}
142138

143139
/**
144140
* Updates the player associated with this client.
145141
* @param {string} id - The new player id.
146142
*/
147-
async updatePlayerID(id) {
143+
updatePlayerID(id) {
148144
this.playerID = id;
149145
const action = ActionCreators.reset(null);
150146
this.store.dispatch(action);
151-
await this.master.onSync(this.gameID, this.playerID, this.numPlayers);
147+
this.master.onSync(this.gameID, this.playerID, this.numPlayers);
152148
}
153149
}

src/master/master.js

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { MAKE_MOVE, GAME_EVENT } from '../core/action-types';
1111
import { createStore } from 'redux';
1212
import * as logging from '../core/logger';
1313

14+
const GameMetadataKey = gameID => `${gameID}:metadata`;
15+
1416
/**
1517
* Redact the log.
1618
*
@@ -53,6 +55,45 @@ export function redactLog(redactedMoves, log, playerID) {
5355
});
5456
}
5557

58+
/**
59+
* Verifies that the move came from a player with the
60+
* appropriate credentials.
61+
*/
62+
export const isActionFromAuthenticPlayer = ({
63+
action,
64+
gameMetadata,
65+
playerID,
66+
}) => {
67+
if (!gameMetadata) {
68+
return true;
69+
}
70+
71+
if (!action.payload) {
72+
return true;
73+
}
74+
75+
const hasCredentials = Object.keys(gameMetadata.players).some(key => {
76+
return !!(
77+
gameMetadata.players[key] && gameMetadata.players[key].credentials
78+
);
79+
});
80+
if (!hasCredentials) {
81+
return true;
82+
}
83+
84+
if (!action.payload.credentials) {
85+
return false;
86+
}
87+
88+
if (
89+
action.payload.credentials !== gameMetadata.players[playerID].credentials
90+
) {
91+
return false;
92+
}
93+
94+
return true;
95+
};
96+
5697
/**
5798
* Master
5899
*
@@ -61,14 +102,16 @@ export function redactLog(redactedMoves, log, playerID) {
61102
* storageAPI to communicate with the database.
62103
*/
63104
export class Master {
64-
constructor(game, storageAPI, transportAPI, isActionFromAuthenticPlayer) {
105+
constructor(game, storageAPI, transportAPI, auth) {
65106
this.game = game;
66107
this.storageAPI = storageAPI;
67108
this.transportAPI = transportAPI;
68-
this.isActionFromAuthenticPlayer = () => true;
109+
this.auth = () => true;
69110

70-
if (isActionFromAuthenticPlayer !== undefined) {
71-
this.isActionFromAuthenticPlayer = isActionFromAuthenticPlayer;
111+
if (auth === true) {
112+
this.auth = isActionFromAuthenticPlayer;
113+
} else if (typeof auth === 'function') {
114+
this.auth = auth;
72115
}
73116
}
74117

@@ -78,8 +121,37 @@ export class Master {
78121
* along with a deltalog.
79122
*/
80123
async onUpdate(action, stateID, gameID, playerID) {
124+
let isActionAuthentic;
125+
126+
if (this.executeSynchronously) {
127+
const gameMetadata = this.storageAPI.get(GameMetadataKey(gameID));
128+
isActionAuthentic = this.auth({
129+
action,
130+
gameMetadata,
131+
gameID,
132+
playerID,
133+
});
134+
} else {
135+
const gameMetadata = await this.storageAPI.get(GameMetadataKey(gameID));
136+
isActionAuthentic = this.auth({
137+
action,
138+
gameMetadata,
139+
gameID,
140+
playerID,
141+
});
142+
}
143+
if (!isActionAuthentic) {
144+
return { error: 'unauthorized action' };
145+
}
146+
81147
const key = gameID;
82-
let state = await this.storageAPI.get(key);
148+
149+
let state;
150+
if (this.executeSynchronously) {
151+
state = this.storageAPI.get(key);
152+
} else {
153+
state = await this.storageAPI.get(key);
154+
}
83155

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

95-
const isActionAuthentic = await this.isActionFromAuthenticPlayer({
96-
action,
97-
db: this.storageAPI,
98-
gameID,
99-
playerID,
100-
});
101-
if (!isActionAuthentic) {
102-
return { error: 'unauthorized action' };
103-
}
104-
105167
// Check whether the player is allowed to make the move.
106168
if (
107169
action.type == MAKE_MOVE &&
@@ -162,7 +224,11 @@ export class Master {
162224
log = [...log, ...state.deltalog];
163225
const stateWithLog = { ...state, log };
164226

165-
await this.storageAPI.set(key, stateWithLog);
227+
if (this.executeSynchronously) {
228+
this.storageAPI.set(key, stateWithLog);
229+
} else {
230+
await this.storageAPI.set(key, stateWithLog);
231+
}
166232
}
167233

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

175-
let state = await this.storageAPI.get(key);
241+
let state;
242+
243+
if (this.executeSynchronously) {
244+
state = this.storageAPI.get(key);
245+
} else {
246+
state = await this.storageAPI.get(key);
247+
}
176248

177249
// If the game doesn't exist, then create one on demand.
178250
// TODO: Move this out of the sync call.
179251
if (state === undefined) {
180252
state = InitializeGame({ game: this.game, numPlayers });
181-
await this.storageAPI.set(key, state);
182-
state = await this.storageAPI.get(key);
253+
254+
if (this.executeSynchronously) {
255+
this.storageAPI.set(key, state);
256+
state = this.storageAPI.get(key);
257+
} else {
258+
await this.storageAPI.set(key, state);
259+
state = await this.storageAPI.get(key);
260+
}
183261
}
184262

185263
const filteredState = {

0 commit comments

Comments
 (0)