boardgame.io
Version:
library for turn-based games
994 lines (856 loc) • 26.1 kB
text/typescript
/*
* 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 { INVALID_MOVE } from '../core/constants';
import { createStore } from 'redux';
import { CreateGameReducer } from '../core/reducer';
import { InitializeGame } from '../core/initialize';
import { Client, createMoveDispatchers } from './client';
import { ProcessGameConfig } from '../core/game';
import { Transport } from './transport/transport';
import { LocalTransport, Local } from './transport/local';
import { SocketIOTransport, SocketIO } from './transport/socketio';
import {
update,
sync,
makeMove,
gameEvent,
patch,
} from '../core/action-creators';
import * as Actions from '../core/action-types';
import Debug from './debug/Debug.svelte';
import { error } from '../core/logger';
import type { Game, LogEntry, State, SyncInfo } from '../types';
import type { Operation } from 'rfc6902';
import type { TransportData } from '../master/master';
jest.mock('../core/logger', () => ({
info: jest.fn(),
error: jest.fn(),
}));
describe('basic', () => {
let client: ReturnType<typeof Client>;
const initial = { initial: true };
const game: Game = {
setup: () => initial,
};
beforeAll(() => {
client = Client({ game });
});
test('getState', () => {
expect(client.getState().G).toEqual(initial);
});
test('getInitialState', () => {
expect(client.getInitialState().G).toEqual(initial);
});
});
test('move api', () => {
const client = Client({
game: {
moves: {
A: (_, arg) => ({ arg }),
},
},
});
expect(client.getState().G).toEqual({});
client.moves.A(42);
expect(client.getState().G).toEqual({ arg: 42 });
});
describe('namespaced moves', () => {
let client: ReturnType<typeof Client>;
beforeAll(() => {
client = Client({
game: {
moves: {
A: () => 'A',
},
phases: {
PA: {
moves: {
B: () => 'B',
C: () => 'C',
},
},
},
},
});
});
test('top-level moves', () => {
expect(client.moves.A).toBeInstanceOf(Function);
});
test('phase-level moves', () => {
expect(client.moves.B).toBeInstanceOf(Function);
expect(client.moves.C).toBeInstanceOf(Function);
});
test('moves are allowed only when phase is active', () => {
client.moves.A();
expect(client.getState().G).toEqual('A');
client.moves.B();
expect(error).toHaveBeenCalledWith('disallowed move: B');
client.moves.C();
expect(error).toHaveBeenCalledWith('disallowed move: C');
client.events.setPhase('PA');
expect(client.getState().ctx.phase).toBe('PA');
client.moves.A();
expect(error).toHaveBeenCalledWith('disallowed move: A');
client.moves.B();
expect(client.getState().G).toEqual('B');
client.moves.C();
expect(client.getState().G).toEqual('C');
});
});
test('isActive', () => {
const client = Client({
game: {
moves: {
A: (_, arg) => ({ arg }),
},
endIf: ({ G }) => G.arg == 42,
},
});
expect(client.getState().G).toEqual({});
expect(client.getState().isActive).toBe(true);
client.moves.A(42);
expect(client.getState().G).toEqual({ arg: 42 });
expect(client.getState().isActive).toBe(false);
});
describe('multiplayer', () => {
describe('socket.io master', () => {
const host = 'host';
const port = '4321';
let client;
beforeAll(() => {
client = Client({
game: { moves: { A: () => {}, Invalid: () => INVALID_MOVE } },
multiplayer: SocketIO({ server: host + ':' + port }),
});
client.start();
});
afterAll(() => {
client.stop();
jest.restoreAllMocks();
});
test('correct transport used', () => {
expect(client.transport instanceof SocketIOTransport).toBe(true);
});
test('server set when provided', () => {
expect(client.transport.socket.io.engine.hostname).toEqual(host);
expect(client.transport.socket.io.engine.port).toEqual(port);
});
test('onAction called', () => {
jest.spyOn(client.transport, 'sendAction');
const state = { G: {}, ctx: { phase: '' }, plugins: {} };
const filteredMetadata = [];
client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo));
client.moves.A();
expect(client.transport.sendAction).toHaveBeenCalled();
});
test('strip transients action not sent to transport', () => {
jest.spyOn(client.transport, 'sendAction');
const state = { G: {}, ctx: { phase: '' }, plugins: {} };
const filteredMetadata = [];
client.store.dispatch(sync({ state, filteredMetadata } as SyncInfo));
client.moves.Invalid();
expect(client.transport.sendAction).not.toHaveBeenCalledWith(
expect.any(Object),
{ type: Actions.STRIP_TRANSIENTS }
);
});
test('Sends and receives chat messages', () => {
jest.spyOn(client.transport, 'sendAction');
client.updatePlayerID('0');
client.updateMatchID('matchID');
jest.spyOn(client.transport, 'sendChatMessage');
client.sendChatMessage({ message: 'foo' });
expect(client.transport.sendChatMessage).toHaveBeenCalledWith(
'matchID',
expect.objectContaining({ payload: { message: 'foo' }, sender: '0' })
);
});
});
describe('multiplayer: SocketIO()', () => {
let client;
beforeAll(() => {
client = Client({
game: {},
multiplayer: SocketIO(),
});
client.start();
});
afterAll(() => {
client.stop();
});
test('correct transport used', () => {
expect(client.transport instanceof SocketIOTransport).toBe(true);
});
});
describe('local master', () => {
let client0;
let client1;
let spec;
beforeAll(() => {
spec = {
game: { moves: { A: ({ playerID }) => ({ A: playerID }) } },
multiplayer: Local(),
};
client0 = Client({ ...spec, playerID: '0' });
client1 = Client({ ...spec, playerID: '1' });
client0.start();
client1.start();
});
afterAll(() => {
client0.stop();
client1.stop();
});
test('correct transport used', () => {
expect(client0.transport instanceof LocalTransport).toBe(true);
expect(client1.transport instanceof LocalTransport).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' });
client0.sendChatMessage({ message: 'foo' });
expect(client0.chatMessages).toEqual([
expect.objectContaining({ sender: '0', payload: { message: 'foo' } }),
]);
expect(client1.chatMessages).toEqual([
expect.objectContaining({ sender: '0', payload: { message: 'foo' } }),
]);
client1.sendChatMessage({ message: 'bar' });
expect(client0.chatMessages).toEqual([
expect.objectContaining({ sender: '0', payload: { message: 'foo' } }),
expect.objectContaining({ sender: '1', payload: { message: 'bar' } }),
]);
expect(client1.chatMessages).toEqual([
expect.objectContaining({ sender: '0', payload: { message: 'foo' } }),
expect.objectContaining({ sender: '1', payload: { message: 'bar' } }),
]);
});
});
describe('custom transport', () => {
class CustomTransport extends Transport {
connect() {}
disconnect() {}
sendAction() {}
sendChatMessage() {}
requestSync() {}
updateMatchID() {}
updatePlayerID() {}
updateCredentials() {}
setMetadata(metadata) {
this.notifyClient({ type: 'matchData', args: ['default', metadata] });
}
}
const customTransport = (opts) => new CustomTransport(opts);
let client;
beforeAll(() => {
client = Client({
game: { moves: { A: () => {} } },
multiplayer: customTransport,
});
});
test('correct transport used', () => {
expect(client.transport).toBeInstanceOf(CustomTransport);
});
test('metadata callback', () => {
const metadata = { m: true };
client.transport.setMetadata(metadata);
expect(client.matchData).toEqual(metadata);
});
});
});
describe('receiveTransportData', () => {
let sendToClient: (data: TransportData) => void;
let client: ReturnType<typeof Client>;
let requestSync: jest.Mock;
beforeEach(() => {
requestSync = jest.fn();
client = Client({
game: {},
matchID: 'A',
debug: false,
// Use the multiplayer interface to extract the client callback
// and use it to send updates to the client directly.
multiplayer: ({ transportDataCallback }) => {
sendToClient = transportDataCallback;
return {
connect() {},
disconnect() {},
subscribe() {},
requestSync,
} as unknown as Transport;
},
});
client.start();
});
afterEach(() => {
client.stop();
});
test('discards update with wrong matchID', () => {
sendToClient({
type: 'sync',
args: ['wrongID', { state: { G: 'G', ctx: {} } } as SyncInfo],
});
expect(client.getState()).toBeNull();
});
test('applies sync', () => {
const state = { G: 'G', ctx: {} };
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
expect(client.getState().G).toEqual(state.G);
});
test('applies update', () => {
const state1 = { G: 'G1', _stateID: 1, ctx: {} } as State;
const state2 = { G: 'G2', _stateID: 2, ctx: {} } as State;
sendToClient({ type: 'sync', args: ['A', { state: state1 } as SyncInfo] });
sendToClient({ type: 'update', args: ['A', state2, []] });
expect(client.getState().G).toEqual(state2.G);
});
test('ignores stale update', () => {
const state1 = { G: 'G1', _stateID: 1, ctx: {} } as State;
const state2 = { G: 'G2', _stateID: 0, ctx: {} } as State;
sendToClient({ type: 'sync', args: ['A', { state: state1 } as SyncInfo] });
sendToClient({ type: 'update', args: ['A', state2, []] });
expect(client.getState().G).toEqual(state1.G);
});
test('applies a patch', () => {
const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
sendToClient({
type: 'patch',
args: ['A', 1, 2, [{ op: 'replace', path: '/_stateID', value: 2 }], []],
});
expect(client.getState()._stateID).toBe(2);
});
test('ignores patch for different state ID', () => {
const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
sendToClient({
type: 'patch',
args: ['A', 2, 3, [{ op: 'replace', path: '/_stateID', value: 3 }], []],
});
expect(client.getState()._stateID).toBe(1);
});
test('resyncs after failed patch', () => {
const state = { G: 'G1', _stateID: 1, ctx: {} } as State;
sendToClient({ type: 'sync', args: ['A', { state } as SyncInfo] });
expect(requestSync).not.toHaveBeenCalled();
// Send bad patch.
sendToClient({
type: 'patch',
args: ['A', 1, 2, [{ op: 'replace', path: '/_stateIDD', value: 2 }], []],
});
// State is unchanged and the client requested to resync.
expect(client.getState()._stateID).toBe(1);
expect(requestSync).toHaveBeenCalled();
});
test('updates match metadata', () => {
expect(client.matchData).toBeUndefined();
const matchData = [{ id: 0 }];
sendToClient({ type: 'matchData', args: ['A', matchData] });
expect(client.matchData).toEqual(matchData);
});
test('appends a chat message', () => {
expect(client.chatMessages).toEqual([]);
const message = { id: 'x', sender: '0', payload: 'hi' };
sendToClient({ type: 'chat', args: ['A', message] });
expect(client.chatMessages).toEqual([message]);
});
});
describe('strip secret only on server', () => {
type G = { secret?: number[]; sum?: number; A?: string };
let client0;
let client1;
let spec: { game: Game<G>; multiplayer };
const initial = { secret: [1, 2, 3, 4], sum: 0 };
beforeAll(() => {
spec = {
game: {
setup: () => initial,
playerView: ({ G }) => {
const r = { ...G };
r.sum = r.secret.reduce((prev, curr) => prev + curr);
delete r.secret;
return r;
},
moves: { A: ({ playerID }) => ({ A: playerID }) },
},
multiplayer: Local(),
};
client0 = Client({ ...spec, playerID: '0' });
client1 = Client({ ...spec, playerID: '1' });
client0.start();
client1.start();
});
test('secret stripped', () => {
expect(client0.getState().G).toEqual({ sum: 10 });
expect(client1.getState().G).toEqual({ sum: 10 });
});
afterAll(() => {
client0.stop();
client1.stop();
});
});
test('accepts enhancer for store', () => {
let spyDispatcher;
const spyEnhancer =
(vanillaCreateStore) =>
(...args) => {
const vanillaStore = vanillaCreateStore(...args);
return {
...vanillaStore,
dispatch: (spyDispatcher = jest.fn(vanillaStore.dispatch)),
};
};
const client = Client({
game: {
moves: {
A: (_, arg) => ({ arg }),
},
},
enhancer: spyEnhancer,
});
expect(spyDispatcher.mock.calls).toHaveLength(0);
client.moves.A(42);
expect(spyDispatcher.mock.calls).toHaveLength(1);
});
describe('event dispatchers', () => {
const clientEvents = [
'endTurn',
'pass',
'endPhase',
'setPhase',
'endGame',
'setActivePlayers',
'endStage',
'setStage',
];
test('default', () => {
const game: Game = {};
const client = Client({ game });
expect(Object.keys(client.events)).toEqual(clientEvents);
expect(client.getState().ctx.turn).toBe(1);
client.events.endTurn();
expect(client.getState().ctx.turn).toBe(2);
});
test('all events', () => {
const game: Game = {
events: {
endPhase: true,
endGame: true,
},
};
const client = Client({ game });
expect(Object.keys(client.events)).toEqual(clientEvents);
expect(client.getState().ctx.turn).toBe(1);
client.events.endTurn();
expect(client.getState().ctx.turn).toBe(2);
});
test('no events', () => {
const game: Game = {
events: {
endGame: false,
endPhase: false,
setPhase: false,
endTurn: false,
pass: false,
setActivePlayers: false,
endStage: false,
setStage: false,
},
};
const client = Client({ game });
expect(Object.keys(client.events)).toEqual([]);
});
});
describe('move dispatchers', () => {
const game = ProcessGameConfig({
moves: {
A: ({ G }) => G,
B: ({ playerID }) => ({ moved: playerID }),
C: () => ({ victory: true }),
},
endIf: ({ G, ctx }) => (G.victory ? ctx.currentPlayer : undefined),
});
const reducer = CreateGameReducer({ game });
const initialState = InitializeGame({ game });
test('basic', () => {
const store = createStore(reducer, initialState);
const api = createMoveDispatchers(game.moveNames, store);
expect(Object.getOwnPropertyNames(api)).toEqual(['A', 'B', 'C']);
expect(api.unknown).toBe(undefined);
api.A();
expect(store.getState().G).not.toMatchObject({ moved: true });
expect(store.getState().G).not.toMatchObject({ victory: true });
api.B();
expect(store.getState().G).toMatchObject({ moved: '0' });
store.dispatch(gameEvent('endTurn', null, '0'));
api.B();
expect(store.getState().G).toMatchObject({ moved: '1' });
api.C();
expect(store.getState().G).toMatchObject({ victory: true });
});
test('with undefined playerID - single player mode', () => {
const store = createStore(reducer, initialState);
const api = createMoveDispatchers(game.moveNames, store);
api.B();
expect(store.getState().G).toMatchObject({ moved: '0' });
});
test('with undefined playerID - multiplayer mode', () => {
const store = createStore(reducer, initialState);
const api = createMoveDispatchers(
game.moveNames,
store,
undefined,
null,
true
);
api.B();
expect(store.getState().G).toMatchObject({ moved: undefined });
});
test('with null playerID - single player mode', () => {
const store = createStore(reducer, initialState);
const api = createMoveDispatchers(game.moveNames, store, null);
api.B();
expect(store.getState().G).toMatchObject({ moved: '0' });
});
test('with null playerID - multiplayer mode', () => {
const store = createStore(reducer, initialState);
const api = createMoveDispatchers(game.moveNames, store, null, null, true);
api.B();
expect(store.getState().G).toMatchObject({ moved: null });
});
});
describe('transient handling', () => {
let client = null;
beforeEach(() => {
client = Client({
game: {
moves: {
A: () => ({}),
Invalid: () => INVALID_MOVE,
},
},
});
});
test('regular move', () => {
const result = client.moves.A();
// TODO(#723): Check against a successful ActionResult.
expect(result).toBeUndefined();
const state = client.store.getState();
// Slightly paranoid check to ensure we don't erroneously add transients.
expect(state).toEqual(
expect.not.objectContaining({ transients: expect.anything() })
);
});
test('invalid move', () => {
const result = client.moves.Invalid();
// TODO(#723): Check against an errored ActionResult.
expect(result).toBeUndefined();
const state = client.store.getState();
// Ensure we've stripped the transients automagically.
// At the time this test was written, this effectively ensures that Client
// hooks up the TransientHandlingMiddleware correctly.
expect(state).toEqual(
expect.not.objectContaining({ transients: expect.anything() })
);
});
});
describe('log handling', () => {
let client = null;
beforeEach(() => {
client = Client({
game: {
moves: {
A: () => ({}),
},
},
});
});
test('regular', () => {
client.moves.A();
client.moves.A();
expect(client.log).toEqual([
{
action: makeMove('A', [], '0'),
_stateID: 0,
phase: null,
turn: 1,
},
{
action: makeMove('A', [], '0'),
_stateID: 1,
phase: null,
turn: 1,
},
]);
});
test('update', () => {
const state = { restore: true, _stateID: 0 } as unknown as State;
const deltalog = [
{
action: {},
_stateID: 0,
},
{
action: {},
_stateID: 1,
},
] as LogEntry[];
const action = update(state, deltalog);
client.store.dispatch(action);
client.store.dispatch(action);
expect(client.log).toEqual(deltalog);
});
test('patch', () => {
const patches = [
{ op: 'replace', path: '/_stateID', value: 1 },
] as Operation[];
const deltalog = [
{
action: {},
_stateID: 0,
},
{
action: {},
_stateID: 1,
},
] as LogEntry[];
const action = patch(0, 1, patches, deltalog);
client.store.dispatch(action);
expect(client.log).toEqual(deltalog);
});
test('sync', () => {
const state = { restore: true };
const log = ['0', '1'];
const action = sync({ state, log } as unknown as SyncInfo);
client.store.dispatch(action);
client.store.dispatch(action);
expect(client.log).toEqual(log);
});
test('update - log missing', () => {
const action = update(undefined, undefined);
client.store.dispatch(action);
expect(client.log).toEqual([]);
});
test('sync - log missing', () => {
const action = sync({} as SyncInfo);
client.store.dispatch(action);
expect(client.log).toEqual([]);
});
});
describe('undo / redo', () => {
const game: Game = {
moves: {
A: (_, arg) => ({ arg }),
},
};
test('basic', () => {
const client = Client({ game });
expect(client.getState().G).toEqual({});
client.moves.A(42);
expect(client.getState().G).toEqual({ arg: 42 });
client.undo();
expect(client.getState().G).toEqual({});
client.redo();
expect(client.getState().G).toEqual({ arg: 42 });
});
});
describe('subscribe', () => {
let client;
let fn;
beforeAll(() => {
const game: Game = {
moves: {
A: ({ G }) => {
G.moved = true;
},
},
};
client = Client({ game });
fn = jest.fn();
client.subscribe(fn);
});
test('called at the beginning', () => {
expect(fn).toBeCalledWith(
expect.objectContaining({
G: {},
ctx: expect.objectContaining({ turn: 1 }),
})
);
});
test('called after a move', () => {
fn.mockClear();
client.moves.A();
expect(fn).toBeCalledWith(
expect.objectContaining({
G: { moved: true },
})
);
});
test('called after an event', () => {
fn.mockClear();
client.events.endTurn();
expect(fn).toBeCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({ turn: 2 }),
})
);
});
test('multiple subscriptions', () => {
fn.mockClear();
const fn2 = jest.fn();
const unsubscribe = client.subscribe(fn2);
// The subscriber that just subscribed is notified.
expect(fn).not.toBeCalled();
expect(fn2).toBeCalledWith(
expect.objectContaining({
G: { moved: true },
})
);
fn.mockClear();
fn2.mockClear();
client.moves.A();
// Both subscribers are notified.
expect(fn).toBeCalledWith(
expect.objectContaining({
G: { moved: true },
})
);
expect(fn2).toBeCalledWith(
expect.objectContaining({
G: { moved: true },
})
);
unsubscribe();
fn.mockClear();
fn2.mockClear();
// The subscriber the unsubscribed is not notified.
client.moves.A();
expect(fn).toBeCalledWith(
expect.objectContaining({
G: { moved: true },
})
);
expect(fn2).not.toBeCalled();
});
test('transport notifies subscribers', () => {
let transport: ReturnType<ReturnType<typeof SocketIO>>;
const multiplayer = (opts: any) => {
transport = SocketIO()(opts);
return transport;
};
const client = Client({ game: {}, multiplayer });
const fn = jest.fn();
client.subscribe(fn);
client.start();
fn.mockClear();
(transport as any).connectionStatusCallback();
expect(fn).toHaveBeenCalled();
client.stop();
});
describe('multiplayer', () => {
test('subscribe before start', () => {
const fn = jest.fn();
const client = Client({
game: {},
multiplayer: Local(),
});
client.subscribe(fn);
expect(fn).not.toBeCalled();
client.start();
expect(fn).toBeCalled();
client.stop();
});
test('subscribe after start', () => {
const fn = jest.fn();
const client = Client({
game: {},
multiplayer: Local(),
});
client.start();
client.subscribe(fn);
expect(fn).toBeCalled();
client.stop();
});
});
});
test('override game state', () => {
const game: Game = {
moves: {
A: ({ G }) => {
G.moved = true;
},
},
};
const client = Client({ game });
client.moves.A();
expect(client.getState().G).toEqual({ moved: true });
client.overrideGameState({ G: { override: true }, ctx: {} });
expect(client.getState().G).toEqual({ override: true });
client.overrideGameState(null);
expect(client.getState().G).toEqual({ moved: true });
});
// TODO(#941): These tests should validate DOM mounting/unmounting.
describe('start / stop', () => {
beforeEach(() => {
// Don't let other calls to `error` pollute this state.
jest.resetAllMocks();
});
test('mount on custom element', () => {
const el = document.createElement('div');
const client = Client({ game: {}, debug: { target: el } });
expect(() => {
client.start();
client.stop();
}).not.toThrow();
expect(error).not.toHaveBeenCalled();
});
test('no error when mounting on null element', () => {
const client = Client({ game: {}, debug: { target: null } }) as any;
expect(() => {
client.start();
client.stop();
}).not.toThrow();
client.start();
client.stop();
expect(client.manager.debugPanel).toBe(null);
});
test('override debug implementation', () => {
const client = Client({ game: {}, debug: { impl: Debug } });
expect(() => {
client.start();
client.stop();
}).not.toThrow();
client.start();
client.stop();
expect(error).not.toHaveBeenCalled();
});
test('production mode', () => {
process.env.NODE_ENV = 'production';
const client = Client({ game: {} });
expect(() => {
client.start();
client.stop();
}).not.toThrow();
expect(error).not.toHaveBeenCalled();
});
test('try to stop without starting', () => {
const client = Client({ game: {} });
expect(() => {
client.stop();
}).not.toThrow();
expect(error).not.toHaveBeenCalled();
});
});