Skip to content

Commit 262d867

Browse files
authored
refactor: Decouple player view calculation from Master (#966)
Co-authored-by: vdf.dev <vdfdev@users.noreply.github.com>
1 parent afee0b7 commit 262d867

7 files changed

Lines changed: 508 additions & 396 deletions

File tree

src/client/transport/local.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
State,
2323
SyncInfo,
2424
} from '../../types';
25+
import { getFilterPlayerView } from '../../master/filter-player-view';
2526

2627
/**
2728
* Returns null if it is not a bot's turn.
@@ -84,16 +85,16 @@ export class LocalMaster extends Master {
8485
const send: TransportAPI['send'] = ({ playerID, ...data }) => {
8586
const callback = clientCallbacks[playerID];
8687
if (callback !== undefined) {
87-
callback(data);
88+
callback(filterPlayerView(playerID, data));
8889
}
8990
};
9091

92+
const filterPlayerView = getFilterPlayerView(game);
9193
const transportAPI: TransportAPI = {
9294
send,
93-
sendAll: (makePlayerData) => {
95+
sendAll: (payload) => {
9496
for (const playerID in clientCallbacks) {
95-
const data = makePlayerData(playerID);
96-
send({ playerID, ...data });
97+
send({ playerID, ...payload });
9798
}
9899
},
99100
};
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { getFilterPlayerView, redactLog } from './filter-player-view';
2+
import * as ActionCreators from '../core/action-creators';
3+
import { Master } from './master';
4+
import { InMemory } from '../server/db/inmemory';
5+
import { PlayerView } from '../core/player-view';
6+
import { INVALID_MOVE } from '../core/constants';
7+
import type { Ctx, SyncInfo } from '../types';
8+
9+
function TransportAPI(send = jest.fn(), sendAll = jest.fn()) {
10+
return { send, sendAll };
11+
}
12+
13+
function validateNotTransientState(state: any) {
14+
expect(state).toEqual(
15+
expect.not.objectContaining({ transients: expect.anything() })
16+
);
17+
}
18+
19+
describe('playerView - update', () => {
20+
const send = jest.fn();
21+
const sendAll = jest.fn();
22+
const game = {
23+
playerView: (G, ctx, player) => {
24+
return { ...G, player };
25+
},
26+
};
27+
const master = new Master(game, new InMemory(), TransportAPI(send, sendAll));
28+
29+
beforeAll(async () => {
30+
await master.onSync('matchID', '0', undefined, 2);
31+
});
32+
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
test('sync', async () => {
38+
await master.onSync('matchID', '0', undefined, 2);
39+
const payload = send.mock.calls[0][0];
40+
const filterPlayerView = getFilterPlayerView(game);
41+
expect(
42+
(filterPlayerView('0', payload).args[1] as SyncInfo).state
43+
).toMatchObject({
44+
G: { player: '0' },
45+
});
46+
});
47+
48+
test('update', async () => {
49+
const action = ActionCreators.gameEvent('endTurn');
50+
await master.onSync('matchID', '0', undefined, 2);
51+
await master.onUpdate(action, 0, 'matchID', '0');
52+
const payload = sendAll.mock.calls[sendAll.mock.calls.length - 1][0];
53+
const filterPlayerView = getFilterPlayerView(game);
54+
55+
const transportData0 = filterPlayerView('0', payload);
56+
const G_player0 = (transportData0.args[1] as any).G;
57+
const transportData1 = filterPlayerView('1', payload);
58+
const G_player1 = (transportData1.args[1] as any).G;
59+
60+
expect(G_player0.player).toBe('0');
61+
expect(G_player1.player).toBe('1');
62+
});
63+
});
64+
65+
describe('playerView - patch', () => {
66+
const send = jest.fn();
67+
const sendAll = jest.fn();
68+
const db = new InMemory();
69+
const game = {
70+
seed: 0,
71+
deltaState: true,
72+
setup: () => {
73+
return {
74+
players: {
75+
'0': {
76+
cards: ['card3'],
77+
},
78+
'1': {
79+
cards: [],
80+
},
81+
},
82+
cards: ['card0', 'card1', 'card2'],
83+
discardedCards: [],
84+
};
85+
},
86+
playerView: PlayerView.STRIP_SECRETS,
87+
turn: {
88+
activePlayers: { currentPlayer: { stage: 'A' } },
89+
stages: {
90+
A: {
91+
moves: {
92+
Invalid: () => {
93+
return INVALID_MOVE;
94+
},
95+
A: {
96+
client: false,
97+
move: (G, ctx: Ctx) => {
98+
const card = G.players[ctx.playerID].cards.shift();
99+
G.discardedCards.push(card);
100+
},
101+
},
102+
B: {
103+
client: false,
104+
ignoreStaleStateID: true,
105+
move: (G, ctx: Ctx) => {
106+
const card = G.cards.pop();
107+
G.players[ctx.playerID].cards.push(card);
108+
},
109+
},
110+
},
111+
},
112+
},
113+
},
114+
};
115+
const master = new Master(game, db, TransportAPI(send, sendAll));
116+
const move = ActionCreators.makeMove('A', null, '0');
117+
118+
beforeAll(async () => {
119+
master.subscribe(({ state }) => {
120+
validateNotTransientState(state);
121+
});
122+
await master.onSync('matchID', '0', undefined, 2);
123+
});
124+
125+
beforeEach(() => {
126+
jest.clearAllMocks();
127+
});
128+
129+
test('patch', async () => {
130+
await master.onUpdate(move, 0, 'matchID', '0');
131+
expect(sendAll).toBeCalled();
132+
133+
const payload = sendAll.mock.calls[sendAll.mock.calls.length - 1][0];
134+
expect(payload.type).toBe('patch');
135+
136+
const filterPlayerView = getFilterPlayerView(game);
137+
const value = filterPlayerView('0', payload);
138+
expect(value.type).toBe('patch');
139+
expect(value.args[0]).toBe('matchID');
140+
expect(value.args[1]).toBe(0);
141+
expect(value.args[2]).toBe(1);
142+
expect(value.args[3]).toMatchObject([
143+
{ op: 'remove', path: '/G/players/0/cards/0' },
144+
{ op: 'add', path: '/G/discardedCards/-', value: 'card3' },
145+
{ op: 'replace', path: '/ctx/numMoves', value: 1 },
146+
{ op: 'replace', path: '/_stateID', value: 1 },
147+
]);
148+
});
149+
});
150+
151+
describe('redactLog', () => {
152+
test('no-op with undefined log', () => {
153+
const result = redactLog(undefined, '0');
154+
expect(result).toBeUndefined();
155+
});
156+
157+
test('no redactedMoves', () => {
158+
const logEvents = [
159+
{
160+
_stateID: 0,
161+
turn: 0,
162+
phase: '',
163+
action: ActionCreators.gameEvent('endTurn'),
164+
},
165+
];
166+
const result = redactLog(logEvents, '0');
167+
expect(result).toMatchObject(logEvents);
168+
});
169+
170+
test('redacted move is only shown with args to the player that made the move', () => {
171+
const logEvents = [
172+
{
173+
_stateID: 0,
174+
turn: 0,
175+
phase: '',
176+
action: ActionCreators.makeMove('clickCell', [1, 2, 3], '0'),
177+
redact: true,
178+
},
179+
];
180+
181+
// player that made the move
182+
let result = redactLog(logEvents, '0');
183+
expect(result).toMatchObject(logEvents);
184+
185+
// other player
186+
result = redactLog(logEvents, '1');
187+
expect(result).toMatchObject([
188+
{
189+
_stateID: 0,
190+
turn: 0,
191+
phase: '',
192+
action: {
193+
type: 'MAKE_MOVE',
194+
payload: {
195+
credentials: undefined,
196+
playerID: '0',
197+
type: 'clickCell',
198+
},
199+
},
200+
},
201+
]);
202+
});
203+
204+
test('not redacted move is shown to all', () => {
205+
const logEvents = [
206+
{
207+
_stateID: 0,
208+
turn: 0,
209+
phase: '',
210+
action: ActionCreators.makeMove('unclickCell', [1, 2, 3], '0'),
211+
},
212+
];
213+
214+
// player that made the move
215+
let result = redactLog(logEvents, '0');
216+
expect(result).toMatchObject(logEvents);
217+
// other player
218+
result = redactLog(logEvents, '1');
219+
expect(result).toMatchObject(logEvents);
220+
});
221+
222+
test('can explicitly set showing args to true', () => {
223+
const logEvents = [
224+
{
225+
_stateID: 0,
226+
turn: 0,
227+
phase: '',
228+
action: ActionCreators.makeMove('unclickCell', [1, 2, 3], '0'),
229+
},
230+
];
231+
232+
// player that made the move
233+
let result = redactLog(logEvents, '0');
234+
expect(result).toMatchObject(logEvents);
235+
// other player
236+
result = redactLog(logEvents, '1');
237+
expect(result).toMatchObject(logEvents);
238+
});
239+
240+
test('events are not redacted', () => {
241+
const logEvents = [
242+
{
243+
_stateID: 0,
244+
turn: 0,
245+
phase: '',
246+
action: ActionCreators.gameEvent('endTurn'),
247+
},
248+
];
249+
250+
// player that made the move
251+
let result = redactLog(logEvents, '0');
252+
expect(result).toMatchObject(logEvents);
253+
// other player
254+
result = redactLog(logEvents, '1');
255+
expect(result).toMatchObject(logEvents);
256+
});
257+
258+
test('make sure filter player view redacts the log', async () => {
259+
const game = {
260+
moves: {
261+
A: (G) => G,
262+
B: {
263+
move: (G) => G,
264+
redact: true,
265+
},
266+
},
267+
};
268+
269+
const send = jest.fn();
270+
const master = new Master(game, new InMemory(), TransportAPI(send));
271+
const filterPlayerView = getFilterPlayerView(game);
272+
273+
const actionA = ActionCreators.makeMove('A', ['not redacted'], '0');
274+
const actionB = ActionCreators.makeMove('B', ['redacted'], '0');
275+
276+
// test: ping-pong two moves, then sync and check the log
277+
await master.onSync('matchID', '0', undefined, 2);
278+
await master.onUpdate(actionA, 0, 'matchID', '0');
279+
await master.onUpdate(actionB, 1, 'matchID', '0');
280+
await master.onSync('matchID', '1', undefined, 2);
281+
282+
const payload = send.mock.calls[send.mock.calls.length - 1][0];
283+
expect(
284+
(filterPlayerView('1', payload).args[1] as SyncInfo).log
285+
).toMatchObject([
286+
{
287+
action: {
288+
type: 'MAKE_MOVE',
289+
payload: {
290+
type: 'A',
291+
args: ['not redacted'],
292+
playerID: '0',
293+
},
294+
},
295+
_stateID: 0,
296+
},
297+
{
298+
action: {
299+
type: 'MAKE_MOVE',
300+
payload: {
301+
type: 'B',
302+
args: null,
303+
playerID: '0',
304+
},
305+
},
306+
_stateID: 1,
307+
},
308+
]);
309+
});
310+
});

0 commit comments

Comments
 (0)