boardgame.io
Version:
library for turn-based games
1,069 lines (952 loc) • 32.6 kB
text/typescript
/*
* Copyright 2018 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 * as ActionCreators from '../core/action-creators';
import { InitializeGame } from '../core/initialize';
import { InMemory } from '../server/db/inmemory';
import { Master } from './master';
import { error } from '../core/logger';
import type { Game, Server, State, LogEntry } from '../types';
import { Auth } from '../server/auth';
import * as StorageAPI from '../server/db/base';
import * as dateMock from 'jest-date-mock';
import { PlayerView } from '../core/player-view';
import { INVALID_MOVE } from '../core/constants';
jest.mock('../core/logger', () => ({
info: jest.fn(),
error: jest.fn(),
}));
beforeEach(() => {
dateMock.clear();
});
class InMemoryAsync extends StorageAPI.Async {
db: InMemory;
constructor() {
super();
this.db = new InMemory();
}
async connect() {
await this.sleep();
}
private sleep(): Promise<void> {
const interval = Math.round(Math.random() * 50 + 50);
return new Promise((resolve) => void setTimeout(resolve, interval));
}
async createMatch(id: string, opts: StorageAPI.CreateMatchOpts) {
await this.sleep();
this.db.createMatch(id, opts);
}
async setMetadata(matchID: string, metadata: Server.MatchData) {
await this.sleep();
this.db.setMetadata(matchID, metadata);
}
async setState(matchID: string, state: State, deltalog?: LogEntry[]) {
await this.sleep();
this.db.setState(matchID, state, deltalog);
}
async fetch<O extends StorageAPI.FetchOpts>(
matchID: string,
opts: O
): Promise<StorageAPI.FetchResult<O>> {
await this.sleep();
return this.db.fetch(matchID, opts);
}
async wipe(matchID: string) {
await this.sleep();
this.db.wipe(matchID);
}
async listMatches(opts?: StorageAPI.ListMatchesOpts): Promise<string[]> {
await this.sleep();
return this.db.listMatches(opts);
}
}
const game: Game = { seed: 0 };
function TransportAPI(send = jest.fn(), sendAll = jest.fn()) {
return { send, sendAll };
}
function validateNotTransientState(state: any) {
expect(state).toEqual(
expect.not.objectContaining({ transients: expect.anything() })
);
}
describe('sync', () => {
const send = jest.fn();
const db = new InMemory();
const master = new Master(game, db, TransportAPI(send));
beforeEach(() => {
jest.clearAllMocks();
});
test('causes server to respond', async () => {
await master.onSync('matchID', '0', undefined, 2);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
type: 'sync',
})
);
});
test('sync a second time does not create a game', async () => {
const fetchResult = db.fetch('matchID', { metadata: true });
await master.onSync('matchID', '0', undefined, 2);
expect(db.fetch('matchID', { metadata: true })).toMatchObject(fetchResult);
});
test('should not have metadata', async () => {
db.setState('oldGameID', {} as State);
await master.onSync('oldGameID', '0');
// [0][0] = first call, first argument
expect(send.mock.calls[0][0].args[1].filteredMetadata).toBeUndefined();
});
test('should have metadata', async () => {
const db = new InMemory();
const metadata = {
gameName: 'tic-tac-toe',
setupData: {},
players: {
'0': {
id: 0,
credentials: 'qS2m4Ujb_',
name: 'Alice',
},
'1': {
id: 1,
credentials: 'nIQtXFybDD',
name: 'Bob',
},
},
createdAt: 0,
updatedAt: 0,
};
db.createMatch('matchID', { metadata, initialState: {} as State });
const masterWithMetadata = new Master(game, db, TransportAPI(send));
await masterWithMetadata.onSync('matchID', '0', undefined, 2);
const expectedMetadata = [
{ id: 0, name: 'Alice' },
{ id: 1, name: 'Bob' },
];
expect(send.mock.calls[0][0].args[1].filteredMetadata).toMatchObject(
expectedMetadata
);
});
test('should not create match for games that require setupData', async () => {
const game: Game = {
validateSetupData: () => 'requires setupData',
};
const db = new InMemory();
const master = new Master(game, db, TransportAPI(send));
const matchID = 'matchID';
const res = await master.onSync(matchID, '0', undefined, 2);
expect(res).toEqual({ error: 'game requires setupData' });
expect(send).not.toHaveBeenCalled();
expect(db.fetch(matchID, { state: true })).toEqual({ state: undefined });
});
});
describe('update', () => {
const send = jest.fn();
const sendAll = jest.fn();
const game: Game = {
moves: {
A: ({ G }) => G,
},
};
let db;
let master;
const action = ActionCreators.gameEvent('endTurn');
beforeEach(async () => {
db = new InMemory();
master = new Master(game, db, TransportAPI(send, sendAll));
await master.onSync('matchID', '0', undefined, 2);
jest.clearAllMocks();
});
test('basic', async () => {
await master.onUpdate(action, 0, 'matchID', '0');
expect(sendAll).toBeCalled();
const value = sendAll.mock.calls[0][0];
expect(value.type).toBe('update');
expect(value.args[0]).toBe('matchID');
expect(value.args[1]).toMatchObject({
G: {},
_stateID: 1,
ctx: {
currentPlayer: '1',
numPlayers: 2,
phase: null,
playOrder: ['0', '1'],
playOrderPos: 1,
turn: 2,
},
});
});
test('missing action', async () => {
const { error } = await master.onUpdate(null, 0, 'matchID', '0');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toBe('missing action or action payload');
});
test('missing action payload', async () => {
const { error } = await master.onUpdate({}, 0, 'matchID', '0');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toBe('missing action or action payload');
});
test('invalid matchID', async () => {
await master.onUpdate(action, 0, 'default:unknown', '1');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`game not found, matchID=[default:unknown]`
);
});
test('invalid stateID', async () => {
await master.onUpdate(action, 100, 'matchID', '0');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`invalid stateID, was=[100], expected=[0] - playerID=[0] - action[endTurn]`
);
});
test('invalid playerID', async () => {
await master.onUpdate(action, 0, 'matchID', '100');
await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '100');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`player not active - playerID=[100] - action[move]`
);
});
test('invalid move', async () => {
await master.onUpdate(ActionCreators.makeMove('move'), 0, 'matchID', '0');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`move not processed - canPlayerMakeMove=false - playerID=[0] - action[move]`
);
});
test('valid matchID / stateID / playerID', async () => {
await master.onUpdate(action, 0, 'matchID', '0');
expect(sendAll).toHaveBeenCalled();
});
test('allow execution of moves with ignoreStaleStateID truthy', async () => {
const game: Game = {
setup: () => {
const G = {
players: {
'0': {
cards: ['card3'],
},
'1': {
cards: [],
},
},
cards: ['card0', 'card1', 'card2'],
discardedCards: [],
};
return G;
},
playerView: PlayerView.STRIP_SECRETS,
turn: {
activePlayers: { currentPlayer: { stage: 'A' } },
stages: {
A: {
moves: {
A: ({ G, playerID }) => {
const card = G.players[playerID].cards.shift();
G.discardedCards.push(card);
},
B: {
move: ({ G, playerID }) => {
const card = G.cards.pop();
G.players[playerID].cards.push(card);
},
ignoreStaleStateID: true,
},
},
},
},
},
};
const send = jest.fn();
const master = new Master(
game,
new InMemory(),
TransportAPI(send, sendAll)
);
const setActivePlayers = ActionCreators.gameEvent(
'setActivePlayers',
[{ all: 'A' }],
'0'
);
const actionA = ActionCreators.makeMove('A', null, '0');
const actionB = ActionCreators.makeMove('B', null, '1');
const actionC = ActionCreators.makeMove('B', null, '0');
// test: simultaneous moves
await master.onSync('matchID', '0', undefined, 2);
await master.onUpdate(actionA, 0, 'matchID', '0');
await master.onUpdate(setActivePlayers, 1, 'matchID', '0');
await Promise.all([
master.onUpdate(actionB, 2, 'matchID', '1'),
master.onUpdate(actionC, 2, 'matchID', '0'),
]);
await Promise.all([
master.onSync('matchID', '0', undefined, 2),
master.onSync('matchID', '1', undefined, 2),
]);
const G = sendAll.mock.calls[sendAll.mock.calls.length - 1][0].args[1].G;
expect(G).toMatchObject({
players: {
'0': {
cards: ['card1'],
},
},
cards: ['card0'],
discardedCards: ['card3'],
});
});
describe('undo / redo', () => {
test('player 0 can undo', async () => {
const move = ActionCreators.makeMove('A', null, '0');
await master.onUpdate(move, 0, 'matchID', '0');
expect(error).not.toHaveBeenCalled();
await master.onUpdate(ActionCreators.undo(), 1, 'matchID', '0');
expect(error).not.toHaveBeenCalled();
// Negative case: All moves already undone.
await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0');
expect(error).toHaveBeenCalledWith(`No moves to undo`);
});
test('player 1 can’t undo', async () => {
await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '1');
expect(error).toHaveBeenCalledWith(
`playerID=[1] cannot undo / redo right now`
);
});
test('player can’t undo with multiple active players', async () => {
const setActivePlayers = ActionCreators.gameEvent(
'setActivePlayers',
[{ all: 'A' }],
'0'
);
await master.onUpdate(setActivePlayers, 0, 'matchID', '0');
await master.onUpdate(ActionCreators.undo('0'), 1, 'matchID', '0');
expect(error).toHaveBeenCalledWith(
`playerID=[0] cannot undo / redo right now`
);
});
test('player can undo if they are the only active player', async () => {
const move = ActionCreators.makeMove('A', null, '0');
await master.onUpdate(move, 0, 'matchID', '0');
expect(error).not.toHaveBeenCalled();
const endStage = ActionCreators.gameEvent('endStage', undefined, '0');
await master.onUpdate(endStage, 1, 'matchID', '0');
expect(error).not.toBeCalled();
await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0');
expect(error).not.toBeCalled();
// Clean-up active players.
const endStage2 = ActionCreators.gameEvent('endStage', undefined, '1');
await master.onUpdate(endStage2, 3, 'matchID', '1');
});
});
test('game over', async () => {
let event = ActionCreators.gameEvent('endGame');
await master.onUpdate(event, 0, 'matchID', '0');
event = ActionCreators.gameEvent('endTurn');
await master.onUpdate(event, 1, 'matchID', '0');
expect(error).toHaveBeenCalledWith(
`game over - matchID=[matchID] - playerID=[0] - action[endTurn]`
);
});
test('writes gameover to metadata', async () => {
const id = 'gameWithMetadata';
const db = new InMemory();
const dbMetadata = {
gameName: 'tic-tac-toe',
setupData: {},
players: { '0': { id: 0 }, '1': { id: 1 } },
createdAt: 0,
updatedAt: 0,
};
db.setMetadata(id, dbMetadata);
const masterWithMetadata = new Master(game, db, TransportAPI(send));
await masterWithMetadata.onSync(id, '0', undefined, 2);
const gameOverArg = 'gameOverArg';
const event = ActionCreators.gameEvent('endGame', gameOverArg);
await masterWithMetadata.onUpdate(event, 0, id, '0');
const { metadata } = db.fetch(id, { metadata: true });
expect(metadata.gameover).toEqual(gameOverArg);
});
test('writes gameover to metadata with null gameover', async () => {
const id = 'gameWithMetadataNullGameover';
const db = new InMemory();
const dbMetadata = {
gameName: 'tic-tac-toe',
gameover: null,
setupData: {},
players: { '0': { id: 0 }, '1': { id: 1 } },
createdAt: 0,
updatedAt: 0,
};
const masterWithMetadata = new Master(game, db, TransportAPI(send));
await masterWithMetadata.onSync(id, '0', undefined, 2);
db.setMetadata(id, dbMetadata);
const gameOverArg = 'gameOverArg';
const event = ActionCreators.gameEvent('endGame', gameOverArg);
await masterWithMetadata.onUpdate(event, 0, id, '0');
const { metadata } = db.fetch(id, { metadata: true });
expect(metadata.gameover).toEqual(gameOverArg);
});
test('writes gameover to metadata with async storage API', async () => {
const id = 'gameWithMetadata';
const db = new InMemoryAsync();
const dbMetadata = {
gameName: 'tic-tac-toe',
setupData: {},
players: { '0': { id: 0 }, '1': { id: 1 } },
createdAt: 0,
updatedAt: 0,
};
await db.setMetadata(id, dbMetadata);
const masterWithMetadata = new Master(game, db, TransportAPI(send));
await masterWithMetadata.onSync(id, '0', undefined, 2);
const gameOverArg = 'gameOverArg';
const event = ActionCreators.gameEvent('endGame', gameOverArg);
await masterWithMetadata.onUpdate(event, 0, id, '0');
const { metadata } = await db.fetch(id, { metadata: true });
expect(metadata.gameover).toEqual(gameOverArg);
});
test('writes updatedAt to metadata with async storage API', async () => {
const id = 'gameWithMetadata';
const db = new InMemoryAsync();
const dbMetadata = {
gameName: 'tic-tac-toe',
setupData: {},
players: { '0': { id: 0 }, '1': { id: 1 } },
createdAt: 0,
updatedAt: 0,
};
await db.setMetadata(id, dbMetadata);
const masterWithMetadata = new Master(game, db, TransportAPI(send));
await masterWithMetadata.onSync(id, '0', undefined, 2);
const updatedAt = new Date(2020, 3, 4, 5, 6, 7);
dateMock.advanceTo(updatedAt);
const event = ActionCreators.gameEvent('endTurn', null, '0');
await masterWithMetadata.onUpdate(event, 0, id, '0');
const { metadata } = await db.fetch(id, { metadata: true });
expect(metadata.updatedAt).toEqual(updatedAt.getTime());
});
test('processes update if there is no metadata', async () => {
const id = 'gameWithoutMetadata';
const db = new InMemory();
const masterWithoutMetadata = new Master(game, db, TransportAPI(send));
// Store state manually to bypass automatic metadata initialization on sync.
let state = InitializeGame({ game });
expect(state.ctx.turn).toBe(1);
db.setState(id, state);
// Dispatch update to end the turn.
const event = ActionCreators.gameEvent('endTurn', null, '0');
await masterWithoutMetadata.onUpdate(event, 0, id, '0');
// Confirm the turn ended.
let metadata: undefined | Server.MatchData;
({ state, metadata } = db.fetch(id, { state: true, metadata: true }));
expect(state.ctx.turn).toBe(2);
expect(metadata).toBeUndefined();
});
test('processes update if there is no metadata with async DB', async () => {
const id = 'gameWithoutMetadata';
const db = new InMemoryAsync();
const masterWithoutMetadata = new Master(game, db, TransportAPI(send));
// Store state manually to bypass automatic metadata initialization on sync.
let state = InitializeGame({ game });
expect(state.ctx.turn).toBe(1);
await db.setState(id, state);
// Dispatch update to end the turn.
const event = ActionCreators.gameEvent('endTurn', null, '0');
await masterWithoutMetadata.onUpdate(event, 0, id, '0');
// Confirm the turn ended.
let metadata: undefined | Server.MatchData;
({ state, metadata } = await db.fetch(id, { state: true, metadata: true }));
expect(state.ctx.turn).toBe(2);
expect(metadata).toBeUndefined();
});
});
describe('patch', () => {
const send = jest.fn();
const sendAll = jest.fn();
const db = new InMemory();
const master = new Master(
{
seed: 0,
deltaState: true,
setup: () => {
return {
players: {
'0': {
cards: ['card3'],
},
'1': {
cards: [],
},
},
cards: ['card0', 'card1', 'card2'],
discardedCards: [],
};
},
playerView: PlayerView.STRIP_SECRETS,
turn: {
activePlayers: { currentPlayer: { stage: 'A' } },
stages: {
A: {
moves: {
Invalid: () => {
return INVALID_MOVE;
},
A: {
client: false,
move: ({ G, playerID }) => {
const card = G.players[playerID].cards.shift();
G.discardedCards.push(card);
},
},
B: {
client: false,
ignoreStaleStateID: true,
move: ({ G, playerID }) => {
const card = G.cards.pop();
G.players[playerID].cards.push(card);
},
},
},
},
},
},
},
db,
TransportAPI(send, sendAll)
);
const move = ActionCreators.makeMove('A', null, '0');
const action = ActionCreators.gameEvent('endTurn');
beforeAll(async () => {
master.subscribe(({ state }) => {
validateNotTransientState(state);
});
await master.onSync('matchID', '0', undefined, 2);
});
beforeEach(() => {
jest.clearAllMocks();
});
test('basic', async () => {
await master.onUpdate(move, 0, 'matchID', '0');
expect(sendAll).toBeCalled();
const value = sendAll.mock.calls[0][0];
expect(value.type).toBe('patch');
expect(value.args[0]).toBe('matchID');
expect(value.args[1]).toBe(0);
// prevState -- had a card
expect(value.args[2].G.players[0].cards).toEqual(['card3']);
// state -- doesnt have a card anymore
expect(value.args[3].G.players[0].cards).toEqual([]);
});
test('invalid matchID', async () => {
await master.onUpdate(action, 1, 'default:unknown', '1');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`game not found, matchID=[default:unknown]`
);
});
test('invalid stateID', async () => {
await master.onUpdate(action, 100, 'matchID', '0');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`invalid stateID, was=[100], expected=[1] - playerID=[0] - action[endTurn]`
);
});
test('invalid playerID', async () => {
await master.onUpdate(action, 1, 'matchID', '102');
await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '102');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`player not active - playerID=[102] - action[move]`
);
});
test('disallowed move', async () => {
await master.onUpdate(ActionCreators.makeMove('move'), 1, 'matchID', '0');
expect(sendAll).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
`move not processed - canPlayerMakeMove=false - playerID=[0] - action[move]`
);
});
test('invalid move', async () => {
await master.onUpdate(
ActionCreators.makeMove('Invalid', null, '0'),
1,
'matchID',
'0'
);
expect(sendAll).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith('invalid move: Invalid args: null');
});
test('valid matchID / stateID / playerID', async () => {
await master.onUpdate(action, 1, 'matchID', '0');
expect(sendAll).toHaveBeenCalled();
});
describe('undo / redo', () => {
test('player 0 can undo', async () => {
await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '1');
// The master allows this, but the reducer does not.
expect(error).toHaveBeenCalledWith(`No moves to undo`);
});
test('player 1 can’t undo', async () => {
await master.onUpdate(ActionCreators.undo(), 2, 'matchID', '0');
expect(error).toHaveBeenCalledWith(
`playerID=[0] cannot undo / redo right now`
);
});
test('player can’t undo with multiple active players', async () => {
const setActivePlayers = ActionCreators.gameEvent(
'setActivePlayers',
[{ all: 'A' }],
'0'
);
await master.onUpdate(setActivePlayers, 2, 'matchID', '0');
await master.onUpdate(ActionCreators.undo('0'), 3, 'matchID', '0');
expect(error).toHaveBeenCalledWith(
`playerID=[0] cannot undo / redo right now`
);
});
test('player can undo if they are the only active player', async () => {
const endStage = ActionCreators.gameEvent('endStage', undefined, '1');
await master.onUpdate(endStage, 2, 'matchID', '1');
await master.onUpdate(ActionCreators.undo('0'), 3, 'matchID', '1');
// The master allows this, but the reducer does not.
expect(error).toHaveBeenCalledWith(`Cannot undo other players' moves`);
// Clean-up active players.
const endStage2 = ActionCreators.gameEvent('endStage', undefined, '1');
await master.onUpdate(endStage2, 4, 'matchID', '1');
});
});
test('game over', async () => {
let event = ActionCreators.gameEvent('endGame');
await master.onUpdate(event, 3, 'matchID', '1');
event = ActionCreators.gameEvent('endTurn');
await master.onUpdate(event, 3, 'matchID', '1');
expect(error).toHaveBeenCalledWith(
`game over - matchID=[matchID] - playerID=[1] - action[endTurn]`
);
});
});
describe('connectionChange', () => {
const send = jest.fn();
const sendAll = jest.fn();
const db = new InMemory();
const master = new Master(game, db, TransportAPI(send, sendAll));
const metadata = {
gameName: 'tic-tac-toe',
setupData: {},
players: {
'0': {
id: 0,
credentials: 'qS2m4Ujb_',
name: 'Alice',
},
'1': {
id: 1,
credentials: 'nIQtXFybDD',
name: 'Bob',
isConnected: true,
},
},
createdAt: 0,
updatedAt: 0,
};
db.createMatch('matchID', { metadata, initialState: {} as State });
beforeEach(() => {
master.subscribe(({ state }) => {
validateNotTransientState(state);
});
jest.clearAllMocks();
});
test('changes players metadata', async () => {
await master.onConnectionChange('matchID', '0', undefined, true);
const expectedPlayerData = { id: 0, name: 'Alice', isConnected: true };
const {
metadata: { players },
} = db.fetch('matchID', { metadata: true });
expect(players['0']).toMatchObject(expectedPlayerData);
});
test('sends metadata to all', async () => {
await master.onConnectionChange('matchID', '1', undefined, false);
const expectedMetadata = [
{ id: 0, name: 'Alice', isConnected: true },
{ id: 1, name: 'Bob', isConnected: false },
];
const sentMessage = sendAll.mock.calls[0][0];
expect(sentMessage.type).toEqual('matchData');
expect(sentMessage.args[1]).toMatchObject(expectedMetadata);
});
test('invalid matchID', async () => {
const result = await master.onConnectionChange(
'invalidMatchID',
'0',
undefined,
true
);
expect(error).toHaveBeenCalledWith(
'metadata not found for matchID=[invalidMatchID]'
);
expect(result && result.error).toEqual('metadata not found');
});
test('invalid playerID', async () => {
const result = await master.onConnectionChange(
'matchID',
'3',
undefined,
true
);
expect(error).toHaveBeenCalledWith(
'Player not in the match, matchID=[matchID] playerID=[3]'
);
expect(result && result.error).toEqual('player not in the match');
});
test('processes connection change with an async db', async () => {
const asyncDb = new InMemoryAsync();
const masterWithAsyncDb = new Master(
game,
asyncDb,
TransportAPI(send, sendAll)
);
await asyncDb.createMatch('matchID', {
metadata,
initialState: {} as State,
});
await masterWithAsyncDb.onConnectionChange('matchID', '0', undefined, true);
expect(sendAll).toHaveBeenCalled();
});
});
describe('subscribe', () => {
const callback = jest.fn();
let master;
beforeAll(() => {
master = new Master({}, new InMemory(), TransportAPI(jest.fn(), jest.fn()));
master.subscribe(callback);
});
test('sync', async () => {
master.onSync('matchID', '0');
expect(callback).toBeCalledWith({
matchID: 'matchID',
state: expect.objectContaining({ _stateID: 0 }),
});
});
test('update', async () => {
const action = ActionCreators.gameEvent('endTurn');
master.onUpdate(action, 0, 'matchID', '0');
expect(callback).toBeCalledWith({
matchID: 'matchID',
action,
state: expect.objectContaining({ _stateID: 1 }),
});
});
});
describe('authentication', () => {
const send = jest.fn();
const sendAll = jest.fn();
const game = { seed: 0 };
const matchID = 'matchID';
let storage = new InMemoryAsync();
const resetTestEnvironment = async () => {
send.mockReset();
sendAll.mockReset();
storage = new InMemoryAsync();
const master = new Master(game, storage, TransportAPI());
await master.onSync(matchID, '0', undefined, 2);
};
describe('onUpdate', () => {
const action = ActionCreators.gameEvent('endTurn');
beforeEach(resetTestEnvironment);
test('auth failure', async () => {
const authenticateCredentials = () => false;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onUpdate(action, 0, matchID, '0');
expect(ret && ret.error).toBe('unauthorized action');
expect(sendAll).not.toHaveBeenCalled();
});
test('auth success', async () => {
const authenticateCredentials = () => true;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onUpdate(action, 0, matchID, '0');
expect(ret).toBeUndefined();
expect(sendAll).toHaveBeenCalled();
});
test('default', async () => {
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth()
);
const ret = await master.onUpdate(action, 0, matchID, '0');
expect(ret).toBeUndefined();
expect(sendAll).toHaveBeenCalled();
});
});
describe('onSync', () => {
beforeEach(resetTestEnvironment);
test('auth failure', async () => {
const authenticateCredentials = () => false;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onSync(matchID, '0');
expect(ret && ret.error).toBe('unauthorized');
expect(send).not.toHaveBeenCalled();
});
test('auth success', async () => {
const authenticateCredentials = () => true;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onSync(matchID, '0');
expect(ret).toBeUndefined();
expect(send).toHaveBeenCalled();
});
test('spectators don’t need to authenticate', async () => {
const authenticateCredentials = () => false;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onSync(matchID, null);
expect(ret).toBeUndefined();
expect(send).toHaveBeenCalled();
});
});
describe('onConnectionChange', () => {
beforeEach(resetTestEnvironment);
test('auth failure', async () => {
const authenticateCredentials = () => false;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onConnectionChange(matchID, '0', null, true);
expect(ret && ret.error).toBe('unauthorized');
expect(sendAll).not.toHaveBeenCalled();
});
test('auth success', async () => {
const authenticateCredentials = () => true;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onConnectionChange(matchID, '0', null, true);
expect(ret).toBeUndefined();
expect(sendAll).toHaveBeenCalled();
});
test('spectators are ignored', async () => {
const authenticateCredentials = jest.fn();
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onConnectionChange(matchID, null, null, true);
expect(ret).toBeUndefined();
expect(authenticateCredentials).not.toHaveBeenCalled();
expect(sendAll).not.toHaveBeenCalled();
});
});
describe('onChatMessage', () => {
const chatMessage = {
id: 'uuid',
payload: { message: 'foo' },
sender: '0',
};
beforeEach(resetTestEnvironment);
test('auth success', async () => {
const authenticateCredentials = () => true;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onChatMessage(matchID, chatMessage, undefined);
expect(ret).toBeUndefined();
expect(sendAll).toHaveBeenCalled();
});
test('auth failure', async () => {
const authenticateCredentials = () => false;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onChatMessage(matchID, chatMessage, undefined);
expect(ret && ret.error).toBe('unauthorized');
expect(sendAll).not.toHaveBeenCalled();
});
test('invalid packet', async () => {
const authenticateCredentials = () => true;
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth({ authenticateCredentials })
);
const ret = await master.onChatMessage(matchID, undefined, undefined);
expect(ret && ret.error).toBe('unauthorized');
expect(sendAll).not.toHaveBeenCalled();
});
test('default', async () => {
const master = new Master(
game,
storage,
TransportAPI(send, sendAll),
new Auth()
);
const ret = await master.onChatMessage(matchID, chatMessage, undefined);
expect(ret).toBeUndefined();
expect(sendAll).toHaveBeenCalled();
});
});
});
describe('chat', () => {
const send = jest.fn();
const sendAll = jest.fn();
const db = new InMemory();
const master = new Master(game, db, TransportAPI(send, sendAll));
beforeEach(() => {
jest.clearAllMocks();
});
test('Sends chat messages to all', async () => {
master.onChatMessage(
'matchID',
{ id: 'uuid', sender: '0', payload: { message: 'foo' } },
undefined
);
expect(sendAll.mock.calls[0][0]).toEqual({
type: 'chat',
args: [
'matchID',
{ id: 'uuid', sender: '0', payload: { message: 'foo' } },
],
});
});
});