Skip to content

Commit 510a082

Browse files
authored
refactor(transport): Consolidate transport interface (#1002)
* feat(transport): Initial P2P transport implementation * docs(examples): Add P2P transport example * docs(examples): Fix lack of viewport tag on HTML template * docs(examples): P2P example tweaks * refactor(transport): Move shared transport logic to base class * test(transport): Test callback initialisation in base class * feat(transport): Allow configuration of `Peer` Pass options object to P2P transport to use when instantiating the `Peer` connection. * refactor(transport): Centralise update handling in the client Previously, different transport implementations received a reference to the client’s Redux store and duplicated logic to map update data from the master to an action which they dispatched to the store. This centralises this in the client, keeping the store a client-only concept and exposing a single callback to transport implementations that expects to receive the same `TransportData` emitted by the master. In this way, transports should be “thinner”, focusing on implementing the glue between a client and a master. * test: Update tests for new transport interface * refactor(transport): Rename transport methods for clarity - `onAction` → `sendAction` - `onChatMessage` → `sendChatMessage` * refactor(transport): Move `isConnected` initialisation to base class * refactor(transport): Move responsibility for calling connection callback to base class * refactor(transport): Rename method for clarity Rename internal `callback` property to `connectionStatusCallback` to better describe purpose. * docs(transport): Move method comments to base class * revert: Remove P2P transport * refactor(transport): Rename method to `subscribeToConnectionStatus` * build: Export base `Transport` class in internal package * refactor(client): Rename private class method * refactor(client): Insulate client callback in `Transport` * docs(master): Add comments to clarify types * fix(types): Include `isConnected` in match metadata interface * style: Be consistent about specifying return types
1 parent e4fe528 commit 510a082

13 files changed

Lines changed: 492 additions & 546 deletions

File tree

examples/react-web/src/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<!DOCTYPE html>
22
<html>
33
<head>
4+
<meta content="width=device-width,initial-scale=1" name="viewport">
45
<link rel="stylesheet" href="//unpkg.com/docsify/lib/themes/vue.css">
56
</head>
67
<body class="ready sticky">

packages/internal.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,13 @@ import { InitializeGame } from '../src/core/initialize';
1010
import { ProcessGameConfig } from '../src/core/game';
1111
import { CreateGameReducer } from '../src/core/reducer';
1212
import { Async, Sync } from '../src/server/db/base';
13+
import { Transport } from '../src/client/transport/transport';
1314

14-
export { Async, Sync, ProcessGameConfig, InitializeGame, CreateGameReducer };
15+
export {
16+
Async,
17+
Sync,
18+
Transport,
19+
ProcessGameConfig,
20+
InitializeGame,
21+
CreateGameReducer,
22+
};

src/client/client.test.ts

Lines changed: 132 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { CreateGameReducer } from '../core/reducer';
1212
import { InitializeGame } from '../core/initialize';
1313
import { Client, createMoveDispatchers } from './client';
1414
import { ProcessGameConfig } from '../core/game';
15-
import type { Transport } from './transport/transport';
15+
import { Transport } from './transport/transport';
1616
import { LocalTransport, Local } from './transport/local';
1717
import { SocketIOTransport, SocketIO } from './transport/socketio';
1818
import {
@@ -27,6 +27,7 @@ import Debug from './debug/Debug.svelte';
2727
import { error } from '../core/logger';
2828
import type { LogEntry, State, SyncInfo } from '../types';
2929
import type { Operation } from 'rfc6902';
30+
import type { TransportData } from '../master/master';
3031

3132
jest.mock('../core/logger', () => ({
3233
info: jest.fn(),
@@ -166,35 +167,35 @@ describe('multiplayer', () => {
166167
});
167168

168169
test('onAction called', () => {
169-
jest.spyOn(client.transport, 'onAction');
170+
jest.spyOn(client.transport, 'sendAction');
170171
const state = { G: {}, ctx: { phase: '' }, plugins: {} };
171172
const filteredMetadata = [];
172173
client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo));
173174
client.moves.A();
174-
expect(client.transport.onAction).toHaveBeenCalled();
175+
expect(client.transport.sendAction).toHaveBeenCalled();
175176
});
176177

177178
test('strip transients action not sent to transport', () => {
178-
jest.spyOn(client.transport, 'onAction');
179+
jest.spyOn(client.transport, 'sendAction');
179180
const state = { G: {}, ctx: { phase: '' }, plugins: {} };
180181
const filteredMetadata = [];
181182
client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo));
182183
client.moves.Invalid();
183-
expect(client.transport.onAction).not.toHaveBeenCalledWith(
184+
expect(client.transport.sendAction).not.toHaveBeenCalledWith(
184185
expect.any(Object),
185186
{ type: Actions.STRIP_TRANSIENTS }
186187
);
187188
});
188189

189190
test('Sends and receives chat messages', () => {
190-
jest.spyOn(client.transport, 'onAction');
191+
jest.spyOn(client.transport, 'sendAction');
191192
client.updatePlayerID('0');
192193
client.updateMatchID('matchID');
193-
jest.spyOn(client.transport, 'onChatMessage');
194+
jest.spyOn(client.transport, 'sendChatMessage');
194195

195196
client.sendChatMessage({ message: 'foo' });
196197

197-
expect(client.transport.onChatMessage).toHaveBeenCalledWith(
198+
expect(client.transport.sendChatMessage).toHaveBeenCalledWith(
198199
'matchID',
199200
expect.objectContaining({ payload: { message: 'foo' }, sender: '0' })
200201
);
@@ -290,20 +291,20 @@ describe('multiplayer', () => {
290291
});
291292

292293
describe('custom transport', () => {
293-
class CustomTransport {
294-
callback;
295-
296-
constructor() {
297-
this.callback = null;
298-
}
299-
300-
subscribeMatchData(fn) {
301-
this.callback = fn;
294+
class CustomTransport extends Transport {
295+
connect() {}
296+
disconnect() {}
297+
sendAction() {}
298+
sendChatMessage() {}
299+
requestSync() {}
300+
updateMatchID() {}
301+
updatePlayerID() {}
302+
updateCredentials() {}
303+
setMetadata(metadata) {
304+
this.notifyClient({ type: 'matchData', args: ['default', metadata] });
302305
}
303-
304-
subscribeChatMessage() {}
305306
}
306-
const customTransport = () => new CustomTransport() as unknown as Transport;
307+
const customTransport = (opts) => new CustomTransport(opts);
307308

308309
let client;
309310

@@ -320,12 +321,121 @@ describe('multiplayer', () => {
320321

321322
test('metadata callback', () => {
322323
const metadata = { m: true };
323-
client.transport.callback(metadata);
324+
client.transport.setMetadata(metadata);
324325
expect(client.matchData).toEqual(metadata);
325326
});
326327
});
327328
});
328329

330+
describe('receiveTransportData', () => {
331+
let sendToClient: (data: TransportData) => void;
332+
let client: ReturnType<typeof Client>;
333+
let requestSync: jest.Mock;
334+
335+
beforeEach(() => {
336+
requestSync = jest.fn();
337+
client = Client({
338+
game: {},
339+
matchID: 'A',
340+
debug: false,
341+
// Use the multiplayer interface to extract the client callback
342+
// and use it to send updates to the client directly.
343+
multiplayer: ({ transportDataCallback }) => {
344+
sendToClient = transportDataCallback;
345+
return {
346+
connect() {},
347+
disconnect() {},
348+
subscribe() {},
349+
requestSync,
350+
} as unknown as Transport;
351+
},
352+
});
353+
client.start();
354+
});
355+
356+
afterEach(() => {
357+
client.stop();
358+
});
359+
360+
test('discards update with wrong matchID', () => {
361+
sendToClient({
362+
type: 'sync',
363+
args: ['wrongID', { state: { G: 'G', ctx: {} } } as SyncInfo],
364+
});
365+
expect(client.getState()).toBeNull();
366+
});
367+
368+
test('applies sync', () => {
369+
const state = { G: 'G', ctx: {} };
370+
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
371+
expect(client.getState().G).toEqual(state.G);
372+
});
373+
374+
test('applies update', () => {
375+
const state1 = { G: 'G1', _stateID: 1, ctx: {} } as State;
376+
const state2 = { G: 'G2', _stateID: 2, ctx: {} } as State;
377+
sendToClient({ type: 'sync', args: ['A', { state: state1 } as SyncInfo] });
378+
sendToClient({ type: 'update', args: ['A', state2, []] });
379+
expect(client.getState().G).toEqual(state2.G);
380+
});
381+
382+
test('ignores stale update', () => {
383+
const state1 = { G: 'G1', _stateID: 1, ctx: {} } as State;
384+
const state2 = { G: 'G2', _stateID: 0, ctx: {} } as State;
385+
sendToClient({ type: 'sync', args: ['A', { state: state1 } as SyncInfo] });
386+
sendToClient({ type: 'update', args: ['A', state2, []] });
387+
expect(client.getState().G).toEqual(state1.G);
388+
});
389+
390+
test('applies a patch', () => {
391+
const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
392+
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
393+
sendToClient({
394+
type: 'patch',
395+
args: ['A', 1, 2, [{ op: 'replace', path: '/_stateID', value: 2 }], []],
396+
});
397+
expect(client.getState()._stateID).toBe(2);
398+
});
399+
400+
test('ignores patch for different state ID', () => {
401+
const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
402+
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
403+
sendToClient({
404+
type: 'patch',
405+
args: ['A', 2, 3, [{ op: 'replace', path: '/_stateID', value: 3 }], []],
406+
});
407+
expect(client.getState()._stateID).toBe(1);
408+
});
409+
410+
test('resyncs after failed patch', () => {
411+
const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
412+
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
413+
expect(requestSync).not.toHaveBeenCalled();
414+
// Send bad patch.
415+
sendToClient({
416+
type: 'patch',
417+
args: ['A', 1, 2, [{ op: 'replace', path: '/_stateIDD', value: 2 }], []],
418+
});
419+
// State is unchanged and the client requested to resync.
420+
expect(client.getState()._stateID).toBe(1);
421+
expect(requestSync).toHaveBeenCalled();
422+
});
423+
424+
test('updates match metadata', () => {
425+
expect(client.matchData).toBeUndefined();
426+
const matchData = [{ id: 0 }];
427+
sendToClient({ type: 'matchData', args: ['A', matchData] });
428+
expect(client.matchData).toEqual(matchData);
429+
});
430+
431+
test('appends a chat message', () => {
432+
expect(client.chatMessages).toEqual([]);
433+
const message = { id: 'x', sender: '0', payload: 'hi' };
434+
sendToClient({ type: 'chat', args: ['A', message] });
435+
expect(client.chatMessages).toEqual([message]);
436+
});
437+
});
438+
329439
describe('strip secret only on server', () => {
330440
let client0;
331441
let client1;
@@ -771,7 +881,7 @@ describe('subscribe', () => {
771881
client.subscribe(fn);
772882
client.start();
773883
fn.mockClear();
774-
transport.callback();
884+
(transport as any).connectionStatusCallback();
775885
expect(fn).toHaveBeenCalled();
776886
client.stop();
777887
});

src/client/client.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { PlayerView } from '../plugins/main';
2323
import type { Transport, TransportOpts } from './transport/transport';
2424
import { DummyTransport } from './transport/dummy';
2525
import { ClientManager } from './manager';
26+
import type { TransportData } from '../master/master';
2627
import type {
2728
ActivePlayersArg,
2829
ActionShape,
@@ -182,7 +183,7 @@ export class _ClientImpl<G extends any = any> {
182183
}: ClientOpts) {
183184
this.game = ProcessGameConfig(game);
184185
this.playerID = playerID;
185-
this.matchID = matchID;
186+
this.matchID = matchID || 'default';
186187
this.credentials = credentials;
187188
this.multiplayer = multiplayer;
188189
this.debugOpt = debug;
@@ -291,7 +292,7 @@ export class _ClientImpl<G extends any = any> {
291292
!('clientOnly' in action) &&
292293
action.type !== Actions.STRIP_TRANSIENTS
293294
) {
294-
this.transport.onAction(baseState, action);
295+
this.transport.sendAction(baseState, action);
295296
}
296297

297298
return result;
@@ -321,9 +322,9 @@ export class _ClientImpl<G extends any = any> {
321322

322323
if (!multiplayer) multiplayer = DummyTransport;
323324
this.transport = multiplayer({
325+
transportDataCallback: (data) => this.receiveTransportData(data),
324326
gameKey: game,
325327
game: this.game,
326-
store: this.store,
327328
matchID,
328329
playerID,
329330
credentials,
@@ -333,23 +334,77 @@ export class _ClientImpl<G extends any = any> {
333334

334335
this.createDispatchers();
335336

336-
this.transport.subscribeMatchData((metadata) => {
337-
this.matchData = metadata;
338-
this.notifySubscribers();
339-
});
340-
341337
this.chatMessages = [];
342338
this.sendChatMessage = (payload) => {
343-
this.transport.onChatMessage(this.matchID, {
339+
this.transport.sendChatMessage(this.matchID, {
344340
id: nanoid(7),
345341
sender: this.playerID,
346342
payload: payload,
347343
});
348344
};
349-
this.transport.subscribeChatMessage((message) => {
350-
this.chatMessages = [...this.chatMessages, message];
351-
this.notifySubscribers();
352-
});
345+
}
346+
347+
/** Handle incoming match data from a multiplayer transport. */
348+
private receiveMatchData(matchData: FilteredMetadata): void {
349+
this.matchData = matchData;
350+
this.notifySubscribers();
351+
}
352+
353+
/** Handle an incoming chat message from a multiplayer transport. */
354+
private receiveChatMessage(message: ChatMessage): void {
355+
this.chatMessages = [...this.chatMessages, message];
356+
this.notifySubscribers();
357+
}
358+
359+
/** Handle all incoming updates from a multiplayer transport. */
360+
private receiveTransportData(data: TransportData): void {
361+
const [matchID] = data.args;
362+
if (matchID !== this.matchID) return;
363+
switch (data.type) {
364+
case 'sync': {
365+
const [, syncInfo] = data.args;
366+
const action = ActionCreators.sync(syncInfo);
367+
this.receiveMatchData(syncInfo.filteredMetadata);
368+
this.store.dispatch(action);
369+
break;
370+
}
371+
case 'update': {
372+
const [, state, deltalog] = data.args;
373+
const currentState = this.store.getState();
374+
if (state._stateID >= currentState._stateID) {
375+
const action = ActionCreators.update(state, deltalog);
376+
this.store.dispatch(action);
377+
}
378+
break;
379+
}
380+
case 'patch': {
381+
const [, prevStateID, stateID, patch, deltalog] = data.args;
382+
const currentStateID = this.store.getState()._stateID;
383+
if (prevStateID !== currentStateID) break;
384+
const action = ActionCreators.patch(
385+
prevStateID,
386+
stateID,
387+
patch,
388+
deltalog
389+
);
390+
this.store.dispatch(action);
391+
// Emit sync if patch apply failed.
392+
if (this.store.getState()._stateID === currentStateID) {
393+
this.transport.requestSync();
394+
}
395+
break;
396+
}
397+
case 'matchData': {
398+
const [, matchData] = data.args;
399+
this.receiveMatchData(matchData);
400+
break;
401+
}
402+
case 'chat': {
403+
const [, chatMessage] = data.args;
404+
this.receiveChatMessage(chatMessage);
405+
break;
406+
}
407+
}
353408
}
354409

355410
private notifySubscribers() {
@@ -376,7 +431,7 @@ export class _ClientImpl<G extends any = any> {
376431
subscribe(fn: (state: ClientState<G>) => void) {
377432
const id = Object.keys(this.subscribers).length;
378433
this.subscribers[id] = fn;
379-
this.transport.subscribe(() => this.notifySubscribers());
434+
this.transport.subscribeToConnectionStatus(() => this.notifySubscribers());
380435

381436
if (this._running || !this.multiplayer) {
382437
fn(this.getState());

src/client/transport/dummy.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@ import type { TransportOpts } from './transport';
88
class DummyImpl extends Transport {
99
connect() {}
1010
disconnect() {}
11-
onAction() {}
12-
onChatMessage() {}
13-
subscribe() {}
14-
subscribeChatMessage() {}
15-
subscribeMatchData() {}
11+
sendAction() {}
12+
sendChatMessage() {}
13+
requestSync() {}
1614
updateCredentials() {}
1715
updateMatchID() {}
1816
updatePlayerID() {}

0 commit comments

Comments
 (0)