Skip to content

Commit

Permalink
Merge d1aa29c into afee0b7
Browse files Browse the repository at this point in the history
  • Loading branch information
vdfdev committed Jul 26, 2021
2 parents afee0b7 + d1aa29c commit 39a9a49
Show file tree
Hide file tree
Showing 7 changed files with 387 additions and 371 deletions.
6 changes: 4 additions & 2 deletions src/client/transport/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
State,
SyncInfo,
} from '../../types';
import { getFilterPlayerView } from '../../master/filter-player-view';

/**
* Returns null if it is not a bot's turn.
Expand Down Expand Up @@ -88,11 +89,12 @@ export class LocalMaster extends Master {
}
};

const filterPlayerView = getFilterPlayerView(game);
const transportAPI: TransportAPI = {
send,
sendAll: (makePlayerData) => {
sendAll: (payload) => {
for (const playerID in clientCallbacks) {
const data = makePlayerData(playerID);
const data = filterPlayerView(playerID, payload);
send({ playerID, ...data });
}
},
Expand Down
208 changes: 208 additions & 0 deletions src/master/filter-player-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { getFilterPlayerView, redactLog } from './filter-player-view';
import * as ActionCreators from '../core/action-creators';
import { Master } from './master';
import { InMemory } from '../server/db/inmemory';

function TransportAPI(send = jest.fn(), sendAll = jest.fn()) {
return { send, sendAll };
}

describe('playerView', () => {
const send = jest.fn();
const sendAll = jest.fn();
const game = {
playerView: (G, ctx, player) => {
return { ...G, player };
},
};
const master = new Master(game, new InMemory(), TransportAPI(send, sendAll));

beforeAll(async () => {
await master.onSync('matchID', '0', undefined, 2);
});

beforeEach(() => {
jest.clearAllMocks();
});

test('sync', async () => {
await master.onSync('matchID', '0', undefined, 2);
expect(send.mock.calls[0][0].args[1].state).toMatchObject({
G: { player: '0' },
});
});

test('update', async () => {
const action = ActionCreators.gameEvent('endTurn');
await master.onSync('matchID', '0', undefined, 2);
await master.onUpdate(action, 0, 'matchID', '0');
const payload = sendAll.mock.calls[sendAll.mock.calls.length - 1][0];
const filterPlayerView = getFilterPlayerView(game);

const transportData0 = filterPlayerView('0', payload);
const G_player0 = (transportData0.args[1] as any).G;
const transportData1 = filterPlayerView('1', payload);
const G_player1 = (transportData1.args[1] as any).G;

expect(G_player0.player).toBe('0');
expect(G_player1.player).toBe('1');
});
});

describe('redactLog', () => {
test('no-op with undefined log', () => {
const result = redactLog(undefined, '0');
expect(result).toBeUndefined();
});

test('no redactedMoves', () => {
const logEvents = [
{
_stateID: 0,
turn: 0,
phase: '',
action: ActionCreators.gameEvent('endTurn'),
},
];
const result = redactLog(logEvents, '0');
expect(result).toMatchObject(logEvents);
});

test('redacted move is only shown with args to the player that made the move', () => {
const logEvents = [
{
_stateID: 0,
turn: 0,
phase: '',
action: ActionCreators.makeMove('clickCell', [1, 2, 3], '0'),
redact: true,
},
];

// player that made the move
let result = redactLog(logEvents, '0');
expect(result).toMatchObject(logEvents);

// other player
result = redactLog(logEvents, '1');
expect(result).toMatchObject([
{
_stateID: 0,
turn: 0,
phase: '',
action: {
type: 'MAKE_MOVE',
payload: {
credentials: undefined,
playerID: '0',
type: 'clickCell',
},
},
},
]);
});

test('not redacted move is shown to all', () => {
const logEvents = [
{
_stateID: 0,
turn: 0,
phase: '',
action: ActionCreators.makeMove('unclickCell', [1, 2, 3], '0'),
},
];

// player that made the move
let result = redactLog(logEvents, '0');
expect(result).toMatchObject(logEvents);
// other player
result = redactLog(logEvents, '1');
expect(result).toMatchObject(logEvents);
});

test('can explicitly set showing args to true', () => {
const logEvents = [
{
_stateID: 0,
turn: 0,
phase: '',
action: ActionCreators.makeMove('unclickCell', [1, 2, 3], '0'),
},
];

// player that made the move
let result = redactLog(logEvents, '0');
expect(result).toMatchObject(logEvents);
// other player
result = redactLog(logEvents, '1');
expect(result).toMatchObject(logEvents);
});

test('events are not redacted', () => {
const logEvents = [
{
_stateID: 0,
turn: 0,
phase: '',
action: ActionCreators.gameEvent('endTurn'),
},
];

// player that made the move
let result = redactLog(logEvents, '0');
expect(result).toMatchObject(logEvents);
// other player
result = redactLog(logEvents, '1');
expect(result).toMatchObject(logEvents);
});

test('make sure sync redacts the log', async () => {
const game = {
moves: {
A: (G) => G,
B: {
move: (G) => G,
redact: true,
},
},
};

const send = jest.fn();
const master = new Master(game, new InMemory(), TransportAPI(send));

const actionA = ActionCreators.makeMove('A', ['not redacted'], '0');
const actionB = ActionCreators.makeMove('B', ['redacted'], '0');

// test: ping-pong two moves, then sync and check the log
await master.onSync('matchID', '0', undefined, 2);
await master.onUpdate(actionA, 0, 'matchID', '0');
await master.onUpdate(actionB, 1, 'matchID', '0');
await master.onSync('matchID', '1', undefined, 2);

const { log } = send.mock.calls[send.mock.calls.length - 1][0].args[1];
expect(log).toMatchObject([
{
action: {
type: 'MAKE_MOVE',
payload: {
type: 'A',
args: ['not redacted'],
playerID: '0',
},
},
_stateID: 0,
},
{
action: {
type: 'MAKE_MOVE',
payload: {
type: 'B',
args: null,
playerID: '0',
},
},
_stateID: 1,
},
]);
});
});
82 changes: 82 additions & 0 deletions src/master/filter-player-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { PlayerView } from '../plugins/main';
import { createPatch } from 'rfc6902';
import type { Game, State, LogEntry, PlayerID } from '../types';
import type { TransportData, IntermediateTransportData } from './master';

const applyPlayerView = (
game: Game,
playerID: string,
state: State
): State => ({
...state,
G: game.playerView(state.G, state.ctx, playerID),
plugins: PlayerView(state, { playerID, game }),
deltalog: undefined,
_undo: [],
_redo: [],
});

/** Gets a function that filters the TransportData for a given player and game. */
export const getFilterPlayerView = (game: Game) => (
playerID: string,
payload: IntermediateTransportData
): TransportData => {
if (payload.type === 'patch') {
const [matchID, stateID, prevState, state] = payload.args;
const log = redactLog(state.deltalog, playerID);
const filteredState = applyPlayerView(game, playerID, state);
const newStateID = state._stateID;
const prevFilteredState = applyPlayerView(game, playerID, prevState);
const patch = createPatch(prevFilteredState, filteredState);
return {
type: 'patch',
args: [matchID, stateID, newStateID, patch, log],
};
} else if (payload.type === 'update') {
const [matchID, state] = payload.args;
const log = redactLog(state.deltalog, playerID);
const filteredState = applyPlayerView(game, playerID, state);
return {
type: 'update',
args: [matchID, filteredState, log],
};
} else {
return payload;
}
};

/**
* Redact the log.
*
* @param {Array} log - The game log (or deltalog).
* @param {String} playerID - The playerID that this log is
* to be sent to.
*/
export function redactLog(log: LogEntry[], playerID: PlayerID) {
if (log === undefined) {
return log;
}

return log.map((logEvent) => {
// filter for all other players and spectators.
if (playerID !== null && +playerID === +logEvent.action.payload.playerID) {
return logEvent;
}

if (logEvent.redact !== true) {
return logEvent;
}

const payload = {
...logEvent.action.payload,
args: null,
};
const filteredEvent = {
...logEvent,
action: { ...logEvent.action, payload },
};

const { redact, ...remaining } = filteredEvent;
return remaining;
});
}
Loading

0 comments on commit 39a9a49

Please sign in to comment.