From 6e99ff337582a51890a8840e02e8d367d112c3b8 Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 3 Nov 2020 22:52:30 +0100 Subject: [PATCH 01/36] feat: Refactor core to use new move signature This follows the proposal in #662 to use a single context argument for move (and other game functions): ```js function move( { G, ctx, playerID, ...pluginAPIs }, ...args ) {} ``` This is the initial core work to achieve this. Some outstanding TODOs remain, as well as rewriting docs and examples once this is done. --- src/ai/ai.test.ts | 24 ++-- src/client/client.test.ts | 49 ++++---- src/client/client.ts | 8 +- src/client/react-native.test.js | 8 +- src/client/react.test.tsx | 8 +- src/client/transport/local.test.ts | 14 +-- src/core/flow.test.ts | 93 ++++++++-------- src/core/flow.ts | 56 +++++++--- src/core/game.test.ts | 20 ++-- src/core/game.ts | 12 +- src/core/initialize.ts | 6 +- src/core/reducer.test.ts | 47 ++++---- src/core/turn-order.test.ts | 68 ++++++------ src/core/turn-order.ts | 33 +++--- src/master/master.test.ts | 18 +-- src/plugins/events/events.test.ts | 18 +-- src/plugins/main.test.ts | 124 +++++++++++---------- src/plugins/main.ts | 26 ++--- src/plugins/plugin-immer.test.ts | 14 +-- src/plugins/plugin-immer.ts | 8 +- src/plugins/plugin-player.test.ts | 29 ++--- src/plugins/random/random.test.ts | 15 +-- src/server/index.test.ts | 10 +- src/types.ts | 173 ++++++++++++++--------------- 24 files changed, 459 insertions(+), 422 deletions(-) diff --git a/src/ai/ai.test.ts b/src/ai/ai.test.ts index b9195d03e..0bb7de244 100644 --- a/src/ai/ai.test.ts +++ b/src/ai/ai.test.ts @@ -43,7 +43,7 @@ const TicTacToe = ProcessGameConfig({ }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, ctx }, id: number) { const cells = [...G.cells]; if (cells[id] === null) { cells[id] = ctx.currentPlayer; @@ -54,7 +54,7 @@ const TicTacToe = ProcessGameConfig({ turn: { moveLimit: 1 }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } @@ -65,7 +65,7 @@ const TicTacToe = ProcessGameConfig({ }, }); -const enumerate = (G, ctx, playerID) => { +const enumerate = (G: any, ctx: Ctx, playerID: string) => { let r = []; for (let i = 0; i < 9; i++) { if (G.cells[i] === null) { @@ -77,17 +77,17 @@ const enumerate = (G, ctx, playerID) => { describe('Step', () => { test('advances game state', async () => { - const client = Client({ + const client = Client<{ moved: boolean }>({ game: { setup: () => ({ moved: false }), moves: { - clickCell(G) { + clickCell({ G }) { return { moved: !G.moved }; }, }, - endIf(G) { + endIf({ G }) { if (G.moved) return true; }, @@ -119,7 +119,7 @@ describe('Step', () => { const client = Client({ game: { moves: { - A: G => { + A: ({ G }) => { G.moved = true; }, }, @@ -172,14 +172,14 @@ describe('Simulate', () => { test('with activePlayers', async () => { const game = ProcessGameConfig({ moves: { - A: G => { + A: ({ G }) => { G.moved = true; }, }, turn: { activePlayers: { currentPlayer: Stage.NULL }, }, - endIf: G => G.moved, + endIf: ({ G }) => G.moved, }); const bot = new RandomBot({ @@ -239,7 +239,7 @@ describe('Bot', () => { describe('MCTSBot', () => { test('game that never ends', async () => { - const game = {}; + const game: Game = {}; const state = InitializeGame({ game }); const bot = new MCTSBot({ seed: 'test', game, enumerate: () => [] }); const { state: endState } = await Simulate({ game, bots: bot, state }); @@ -304,14 +304,14 @@ describe('MCTSBot', () => { const game = ProcessGameConfig({ setup: () => ({ moves: 0 }), moves: { - A: G => { + A: ({ G }) => { G.moves++; }, }, turn: { activePlayers: { currentPlayer: Stage.NULL }, }, - endIf: G => G.moves > 5, + endIf: ({ G }) => G.moves > 5, }); const bot = new MCTSBot({ diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 4ea0bc3f1..8715c4d74 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -17,7 +17,7 @@ import { SocketIOTransport, SocketIO } from './transport/socketio'; import { update, sync, makeMove, gameEvent } from '../core/action-creators'; import Debug from './debug/Debug.svelte'; import { error } from '../core/logger'; -import { LogEntry, State, SyncInfo } from '../types'; +import { Game, LogEntry, State, SyncInfo } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -25,10 +25,10 @@ jest.mock('../core/logger', () => ({ })); describe('basic', () => { - let client; + let client: ReturnType; let initial = { initial: true }; - const game = { + const game: Game = { setup: () => initial, }; @@ -49,7 +49,7 @@ test('move api', () => { const client = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, }); @@ -60,7 +60,7 @@ test('move api', () => { }); describe('namespaced moves', () => { - let client; + let client: ReturnType; beforeAll(() => { client = Client({ game: { @@ -114,10 +114,10 @@ test('isActive', () => { const client = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, - endIf: G => G.arg == 42, + endIf: ({ G }) => G.arg == 42, }, }); @@ -193,7 +193,7 @@ describe('multiplayer', () => { beforeAll(() => { spec = { - game: { moves: { A: (G, ctx) => ({ A: ctx.playerID }) } }, + game: { moves: { A: ({ playerID }) => ({ A: playerID }) } }, multiplayer: Local(), }; @@ -272,15 +272,16 @@ describe('multiplayer', () => { }); describe('strip secret only on server', () => { + type G = { secret?: number[]; sum?: number; A?: string }; let client0; let client1; - let spec; + let spec: { game: Game; multiplayer }; let initial = { secret: [1, 2, 3, 4], sum: 0 }; beforeAll(() => { spec = { game: { setup: () => initial, - playerView: (G, ctx, playerID) => { + playerView: G => { let r = { ...G }; r.sum = r.secret.reduce((prev, curr) => { return prev + curr; @@ -288,7 +289,7 @@ describe('strip secret only on server', () => { delete r.secret; return r; }, - moves: { A: (G, ctx) => ({ A: ctx.playerID }) }, + moves: { A: ({ playerID }) => ({ A: playerID }) }, }, multiplayer: Local(), }; @@ -323,7 +324,7 @@ test('accepts enhancer for store', () => { const client = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, enhancer: spyEnhancer, @@ -336,7 +337,7 @@ test('accepts enhancer for store', () => { describe('event dispatchers', () => { test('default', () => { - const game = {}; + const game: Game = {}; const client = Client({ game }); expect(Object.keys(client.events)).toEqual([ 'endTurn', @@ -354,7 +355,7 @@ describe('event dispatchers', () => { }); test('all events', () => { - const game = { + const game: Game = { events: { endPhase: true, endGame: true, @@ -377,7 +378,7 @@ describe('event dispatchers', () => { }); test('no events', () => { - const game = { + const game: Game = { events: { endGame: false, endPhase: false, @@ -397,11 +398,11 @@ describe('event dispatchers', () => { describe('move dispatchers', () => { const game = ProcessGameConfig({ moves: { - A: G => G, - B: (G, ctx) => ({ moved: ctx.playerID }), + A: ({ G }) => G, + B: ({ playerID }) => ({ moved: playerID }), C: () => ({ victory: true }), }, - endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined), + endIf: ({ G, ctx }) => (G.victory ? ctx.currentPlayer : undefined), }); const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); @@ -542,9 +543,9 @@ describe('log handling', () => { }); describe('undo / redo', () => { - const game = { + const game: Game = { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }; @@ -567,9 +568,9 @@ describe('subscribe', () => { let client; let fn; beforeAll(() => { - const game = { + const game: Game = { moves: { - A: G => { + A: ({ G }) => { G.moved = true; }, }, @@ -699,9 +700,9 @@ describe('subscribe', () => { }); test('override game state', () => { - const game = { + const game: Game = { moves: { - A: G => { + A: ({ G }) => { G.moved = true; }, }, diff --git a/src/client/client.ts b/src/client/client.ts index 952cfadfa..6ad74a4dc 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -33,7 +33,6 @@ import { Reducer, State, Store, - Ctx, } from '../types'; type ClientAction = ActionShape.Reset | ActionShape.Sync | ActionShape.Update; @@ -103,11 +102,8 @@ export const createEventDispatchers = createDispatchers.bind(null, 'gameEvent'); // Creates a set of dispatchers to dispatch actions to plugins. export const createPluginDispatchers = createDispatchers.bind(null, 'plugin'); -export interface ClientOpts< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { - game: Game; +export interface ClientOpts { + game: Game; debug?: DebugOpt | boolean; numPlayers?: number; multiplayer?: (opts: TransportOpts) => Transport; diff --git a/src/client/react-native.test.js b/src/client/react-native.test.js index 54375dab1..3355ba342 100644 --- a/src/client/react-native.test.js +++ b/src/client/react-native.test.js @@ -70,7 +70,7 @@ test('move api', () => { const Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -93,7 +93,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -108,7 +108,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -165,7 +165,7 @@ test('reset Game', () => { const Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, diff --git a/src/client/react.test.tsx b/src/client/react.test.tsx index e32c4d376..046b5e505 100644 --- a/src/client/react.test.tsx +++ b/src/client/react.test.tsx @@ -96,7 +96,7 @@ test('move api', () => { const Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -119,7 +119,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -134,7 +134,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -191,7 +191,7 @@ test('reset Game', () => { const Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, diff --git a/src/client/transport/local.test.ts b/src/client/transport/local.test.ts index 6e961f8a0..d48f59b7a 100644 --- a/src/client/transport/local.test.ts +++ b/src/client/transport/local.test.ts @@ -15,14 +15,14 @@ import { InitializeGame } from '../../core/initialize'; import { Client } from '../client'; import { RandomBot } from '../../ai/random-bot'; import { Stage } from '../../core/turn-order'; -import { State, Store, SyncInfo } from '../../types'; +import { Game, State, Store, SyncInfo } from '../../types'; jest.useFakeTimers(); describe('bots', () => { - const game = { + const game: Game = { moves: { - A: (G: any) => G, + A: ({ G }) => G, }, ai: { enumerate: () => [{ move: 'A' }], @@ -150,10 +150,10 @@ describe('Local', () => { }); describe('with localStorage persistence', () => { - const game = { + const game: Game = { setup: () => ({ count: 0 }), moves: { - A: (G: any) => { + A: ({ G }) => { G.count++; }, }, @@ -199,7 +199,7 @@ describe('Local', () => { }); describe('LocalMaster', () => { - const game = {}; + const game: Game = {}; const master = new LocalMaster({ game }); const storeA = ({ @@ -310,7 +310,7 @@ describe('LocalTransport', () => { } } const m = new WrappedLocalTransport({ master }); - const game = {}; + const game: Game = {}; let store: Store | null = null; beforeEach(() => { diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index e350ed5a3..8689bf028 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -10,7 +10,7 @@ import { makeMove, gameEvent } from './action-creators'; import { Client } from '../client/client'; import { Flow } from './flow'; import { error } from '../core/logger'; -import { Ctx, State } from '../types'; +import { Ctx, Game, State } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -31,24 +31,24 @@ describe('phases', () => { phases: { A: { start: true, - onBegin: s => ({ ...s, setupA: true }), - onEnd: s => ({ ...s, cleanupA: true }), + onBegin: ({ G }) => ({ ...G, setupA: true }), + onEnd: ({ G }) => ({ ...G, cleanupA: true }), next: 'B', }, B: { - onBegin: s => ({ ...s, setupB: true }), - onEnd: s => ({ ...s, cleanupB: true }), + onBegin: ({ G }) => ({ ...G, setupB: true }), + onEnd: ({ G }) => ({ ...G, cleanupB: true }), next: 'A', }, }, turn: { order: { - first: G => { + first: ({ G }) => { if (G.setupB && !G.cleanupB) return 1; return 0; }, - next: (_, ctx: Ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, }); @@ -103,9 +103,9 @@ describe('phases', () => { let client: ReturnType; beforeAll(() => { - const game = { + const game: Game = { endIf: () => true, - onEnd: G => { + onEnd: ({ G }) => { G.onEnd = true; }, }; @@ -204,7 +204,7 @@ describe('phases', () => { describe('turn', () => { test('onEnd', () => { - const onEnd = jest.fn(G => G); + const onEnd = jest.fn(({ G }) => G); const flow = Flow({ turn: { onEnd }, }); @@ -329,12 +329,12 @@ describe('turn', () => { describe('endIf', () => { test('global', () => { - const game = { + const game: Game = { moves: { A: () => ({ endTurn: true }), - B: G => G, + B: ({ G }) => G, }, - turn: { endIf: G => G.endTurn }, + turn: { endIf: ({ G }) => G.endTurn }, }; const client = Client({ game }); @@ -346,13 +346,13 @@ describe('turn', () => { }); test('phase specific', () => { - const game = { + const game: Game = { moves: { A: () => ({ endTurn: true }), - B: G => G, + B: ({ G }) => G, }, phases: { - A: { start: true, turn: { endIf: G => G.endTurn } }, + A: { start: true, turn: { endIf: ({ G }) => G.endTurn } }, }, }; const client = Client({ game }); @@ -365,9 +365,9 @@ describe('turn', () => { }); test('return value', () => { - const game = { + const game: Game = { moves: { - A: G => G, + A: ({ G }) => G, }, turn: { endIf: () => ({ next: '2' }) }, }; @@ -382,7 +382,10 @@ describe('turn', () => { test('endTurn is not called twice in one move', () => { const flow = Flow({ turn: { endIf: () => true }, - phases: { A: { start: true, endIf: G => G.endPhase, next: 'B' }, B: {} }, + phases: { + A: { start: true, endIf: ({ G }) => G.endPhase, next: 'B' }, + B: {}, + }, }); let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State); @@ -414,7 +417,7 @@ describe('stages', () => { const A = () => {}; const B = () => {}; - const game = { + const game: Game = { moves: { A }, turn: { stages: { @@ -664,7 +667,7 @@ test('init', () => { describe('endIf', () => { test('basic', () => { - const flow = Flow({ endIf: G => G.win }); + const flow = Flow({ endIf: ({ G }) => G.win }); let state = flow.init({ G: {}, ctx: flow.ctx(2) } as State); state = flow.processEvent(state, gameEvent('endTurn')); @@ -684,17 +687,17 @@ describe('endIf', () => { }); test('phase automatically ends', () => { - const game = { + const game: Game = { phases: { A: { start: true, moves: { A: () => ({ win: 'A' }), - B: G => G, + B: ({ G }) => G, }, }, }, - endIf: G => G.win, + endIf: ({ G }) => G.win, }; const client = Client({ game }); @@ -732,7 +735,7 @@ test('isPlayerActive', () => { describe('endGame', () => { let client: ReturnType; beforeEach(() => { - const game = { + const game: Game = { events: { endGame: true }, }; client = Client({ game }); @@ -846,11 +849,11 @@ describe('pass args', () => { }); test('undoable moves', () => { - const game = { + const game: Game = { moves: { A: { move: () => ({ A: true }), - undoable: (G, ctx) => { + undoable: (_, ctx) => { return ctx.phase == 'A'; }, }, @@ -901,7 +904,7 @@ test('undoable moves', () => { }); describe('moveMap', () => { - const game = { + const game: Game = { moves: { A: () => {} }, turn: { @@ -942,7 +945,7 @@ describe('moveMap', () => { describe('infinite loops', () => { test('loop 1', () => { const endIf = () => true; - const game = { + const game: Game = { phases: { A: { endIf, next: 'B', start: true }, B: { endIf, next: 'A' }, @@ -953,10 +956,10 @@ describe('infinite loops', () => { }); test('loop 2', () => { - const game = { + const game: Game = { turn: { - onEnd: (G, ctx) => { - ctx.events.endPhase(); + onEnd: ({ events }) => { + events.endPhase(); }, }, phases: { @@ -973,14 +976,14 @@ describe('infinite loops', () => { }); test('loop 3', () => { - const game = { + const game: Game = { moves: { - endTurn: (G, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, turn: { - onBegin: (G, ctx) => ctx.events.endTurn(), + onBegin: ({ events }) => events.endTurn(), }, }; const client = Client({ game }); @@ -990,10 +993,10 @@ describe('infinite loops', () => { }); test('loop 4', () => { - const game = { + const game: Game = { moves: { - endTurn: (G, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, turn: { @@ -1009,7 +1012,7 @@ describe('infinite loops', () => { describe('activePlayers', () => { test('sets activePlayers at each turn', () => { - const game = { + const game: Game = { turn: { stages: { A: {}, B: {} }, activePlayers: { @@ -1040,15 +1043,15 @@ describe('activePlayers', () => { }); test('events in hooks triggered by moves should be processed', () => { - const game = { + const game: Game = { turn: { - onBegin: (G, ctx) => { - ctx.events.setActivePlayers({ currentPlayer: 'A' }); + onBegin: ({ events }) => { + events.setActivePlayers({ currentPlayer: 'A' }); }, }, moves: { - endTurn: (G, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, }; diff --git a/src/core/flow.ts b/src/core/flow.ts index a645ba2a7..b42d65136 100644 --- a/src/core/flow.ts +++ b/src/core/flow.ts @@ -23,6 +23,7 @@ import { ActionShape, State, Ctx, + FnContext, LogEntry, Game, PhaseConfig, @@ -59,7 +60,7 @@ export function Flow({ } if (!endIf) endIf = () => undefined; - if (!onEnd) onEnd = G => G; + if (!onEnd) onEnd = ({ G }) => G; if (!turn) turn = {}; const phaseMap = { ...phases }; @@ -76,18 +77,30 @@ export function Flow({ Object.keys(moves).forEach(name => moveNames.add(name)); - const HookWrapper = (fn: (G: any, ctx: Ctx) => any) => { + const HookWrapper = (fn: (context: FnContext) => any) => { const withPlugins = plugin.FnWrap(fn, plugins); return (state: State) => { - const ctxWithAPI = plugin.EnhanceCtx(state); - return withPlugins(state.G, ctxWithAPI); + const pluginAPIs = plugin.GetAPIs(state); + // TODO: what should happend with playerID here? + return withPlugins({ + ...pluginAPIs, + G: state.G, + ctx: state.ctx, + playerID: undefined, + }); }; }; - const TriggerWrapper = (endIf: (G: any, ctx: Ctx) => any) => { + const TriggerWrapper = (endIf: (context: FnContext) => any) => { return (state: State) => { - let ctxWithAPI = plugin.EnhanceCtx(state); - return endIf(state.G, ctxWithAPI); + const pluginAPIs = plugin.GetAPIs(state); + // TODO: what should happend with playerID here? + return endIf({ + ...pluginAPIs, + G: state.G, + ctx: state.ctx, + playerID: undefined, + }); }; }; @@ -114,10 +127,10 @@ export function Flow({ conf.endIf = () => undefined; } if (conf.onBegin === undefined) { - conf.onBegin = G => G; + conf.onBegin = ({ G }) => G; } if (conf.onEnd === undefined) { - conf.onEnd = G => G; + conf.onEnd = ({ G }) => G; } if (conf.turn === undefined) { conf.turn = turn; @@ -126,16 +139,16 @@ export function Flow({ conf.turn.order = TurnOrder.DEFAULT; } if (conf.turn.onBegin === undefined) { - conf.turn.onBegin = G => G; + conf.turn.onBegin = ({ G }) => G; } if (conf.turn.onEnd === undefined) { - conf.turn.onEnd = G => G; + conf.turn.onEnd = ({ G }) => G; } if (conf.turn.endIf === undefined) { conf.turn.endIf = () => false; } if (conf.turn.onMove === undefined) { - conf.turn.onMove = G => G; + conf.turn.onMove = ({ G }) => G; } if (conf.turn.stages === undefined) { conf.turn.stages = {}; @@ -169,11 +182,22 @@ export function Flow({ return ctx.phase ? phaseMap[ctx.phase] : phaseMap['']; } - function OnMove(s) { - return s; + function OnMove(state: State) { + return state; } - function Process(state: State, events): State { + function Process( + state: State, + events: { + fn: (state: State, opts: any) => State; + arg?: any; + turn?: Ctx['turn']; + phase?: Ctx['phase']; + automatic?: boolean; + playerID?: PlayerID; + force?: boolean; + }[] + ): State { const phasesEnded = new Set(); const turnsEnded = new Set(); @@ -666,11 +690,11 @@ export function Flow({ !move || typeof move === 'function' || move.noLimit !== true; let { ctx } = state; - let { _activePlayersNumMoves } = ctx; const { playerID } = action; let numMoves = state.ctx.numMoves; + const _activePlayersNumMoves = { ...ctx._activePlayersNumMoves }; if (shouldCount) { if (playerID == state.ctx.currentPlayer) { numMoves++; diff --git a/src/core/game.test.ts b/src/core/game.test.ts index 57e4daa79..4ee2be4e7 100644 --- a/src/core/game.test.ts +++ b/src/core/game.test.ts @@ -22,7 +22,7 @@ describe('basic', () => { beforeAll(() => { game = ProcessGameConfig({ moves: { - A: G => G, + A: ({ G }) => G, B: () => null, C: { move: () => 'C', @@ -66,11 +66,11 @@ describe('basic', () => { // Following turn order is often used in worker placement games like Agricola and Viticulture. test('rounds with starting player token', () => { - const game = { + const game: Game = { setup: () => ({ startingPlayerToken: 0 }), moves: { - takeStartingPlayerToken: (G, ctx) => { + takeStartingPlayerToken: ({ G, ctx }) => { G.startingPlayerToken = ctx.currentPlayer; }, }, @@ -80,8 +80,8 @@ test('rounds with starting player token', () => { start: true, turn: { order: { - first: G => G.startingPlayerToken, - next: (G, ctx) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, + first: ({ G }) => G.startingPlayerToken, + next: ({ ctx }) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, }, @@ -115,14 +115,14 @@ test('rounds with starting player token', () => { // The following pattern is used in Catan, Twilight Imperium, and (sort of) Powergrid. test('serpentine setup phases', () => { - const game = { + const game: Game = { phases: { 'first setup round': { start: true, turn: { order: { first: () => 0, - next: (G, ctx) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, next: 'second setup round', @@ -130,8 +130,8 @@ test('serpentine setup phases', () => { 'second setup round': { turn: { order: { - first: (G, ctx) => ctx.playOrder.length - 1, - next: (G, ctx) => (+ctx.playOrderPos - 1) % ctx.playOrder.length, + first: ({ ctx }) => ctx.playOrder.length - 1, + next: ({ ctx }) => (+ctx.playOrderPos - 1) % ctx.playOrder.length, }, }, next: 'main phase', @@ -140,7 +140,7 @@ test('serpentine setup phases', () => { turn: { order: { first: () => 0, - next: (G, ctx) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }) => (+ctx.playOrderPos + 1) % ctx.playOrder.length, }, }, }, diff --git a/src/core/game.ts b/src/core/game.ts index e0527b126..e924d4fa6 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -87,15 +87,17 @@ export function ProcessGameConfig(game: Game | ProcessedGame): ProcessedGame { if (moveFn instanceof Function) { const fn = plugins.FnWrap(moveFn, game.plugins); - const ctxWithAPI = { - ...plugins.EnhanceCtx(state), - playerID: action.playerID, - }; let args = []; if (action.args !== undefined) { args = args.concat(action.args); } - return fn(state.G, ctxWithAPI, ...args); + const context = { + ...plugins.GetAPIs(state), + G: state.G, + ctx: state.ctx, + playerID: action.playerID, + }; + return fn(context, ...args); } logging.error(`invalid move object: ${action.type}`); diff --git a/src/core/initialize.ts b/src/core/initialize.ts index 4d7fd2722..694f7886f 100644 --- a/src/core/initialize.ts +++ b/src/core/initialize.ts @@ -42,10 +42,10 @@ export function InitializeGame({ // Run plugins over initial state. state = plugins.Setup(state, { game }); - state = plugins.Enhance(state as State, { game, playerID: undefined }); + state = plugins.Enhance(state, { game, playerID: undefined }); - const enhancedCtx = plugins.EnhanceCtx(state); - state.G = game.setup(enhancedCtx, setupData); + const pluginAPIs = plugins.GetAPIs(state); + state.G = game.setup({ ...pluginAPIs, ctx: state.ctx }, setupData); let initial: State = { ...state, diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index ff0368ef6..0bd57b530 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -19,7 +19,7 @@ import { redo, } from './action-creators'; import { error } from '../core/logger'; -import { Ctx, Game, State, SyncInfo } from '../types'; +import { Game, State, SyncInfo } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -28,11 +28,11 @@ jest.mock('../core/logger', () => ({ const game: Game = { moves: { - A: G => G, + A: ({ G }) => G, B: () => ({ moved: true }), C: () => ({ victory: true }), }, - endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined), + endIf: ({ G, ctx }) => (G.victory ? ctx.currentPlayer : undefined), }; const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); @@ -46,7 +46,7 @@ test('_stateID is incremented', () => { }); test('move returns INVALID_MOVE', () => { - const game = { + const game: Game = { moves: { A: () => INVALID_MOVE, }, @@ -153,7 +153,7 @@ test('endTurn', () => { test('light client when multiplayer=true', () => { const game: Game = { moves: { A: () => ({ win: true }) }, - endIf: G => G.win, + endIf: ({ G }) => G.win, }; { @@ -174,7 +174,7 @@ test('light client when multiplayer=true', () => { }); test('disable optimistic updates', () => { - const game = { + const game: Game = { moves: { A: { move: () => ({ A: true }), @@ -243,7 +243,7 @@ test('deltalog', () => { }); describe('Events API', () => { - const fn = (G: any, ctx: Ctx) => (ctx.events ? {} : { error: true }); + const fn = ({ events }) => (events ? {} : { error: true }); const game: Game = { setup: () => ({}), @@ -303,9 +303,9 @@ describe('undo / redo', () => { const game: Game = { seed: 0, moves: { - move: (G, ctx, arg) => ({ ...G, [arg]: true }), - roll: (G, ctx) => { - G.roll = ctx.random.D6(); + move: ({ G }, arg: string) => ({ ...G, [arg]: true }), + roll: ({ G, random }) => { + G.roll = random.D6(); }, }, }; @@ -314,12 +314,13 @@ describe('undo / redo', () => { const initialState = InitializeGame({ game }); + // TODO: Check if this test is still actually required after removal of APIs from ctx test('plugin APIs are not included in undo state', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, makeMove('move', 'B', '0')); expect(state.G).toMatchObject({ A: true, B: true }); - expect(state._undo[1].ctx.events).toBeUndefined(); - expect(state._undo[1].ctx.random).toBeUndefined(); + expect((state._undo[1].ctx as any).events).toBeUndefined(); + expect((state._undo[1].ctx as any).random).toBeUndefined(); }); test('undo restores previous state', () => { @@ -398,7 +399,7 @@ test('disable undo / redo', () => { seed: 0, disableUndo: true, moves: { - move: (G, ctx, arg) => ({ ...G, [arg]: true }), + move: ({ G }, arg: string) => ({ ...G, [arg]: true }), }, }; @@ -436,8 +437,8 @@ describe('undo stack', () => { const game: Game = { moves: { basic: () => {}, - endTurn: (_, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, }; @@ -498,8 +499,8 @@ describe('redo stack', () => { const game: Game = { moves: { basic: () => {}, - endTurn: (_, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, }; @@ -559,8 +560,8 @@ describe('undo / redo with stages', () => { start: { moves: { moveA: { - move: (G, ctx, moveAisReversible) => { - ctx.events.setStage('A'); + move: ({ G, events }, moveAisReversible) => { + events.setStage('A'); return { ...G, moveAisReversible, A: true }; }, undoable: G => G.moveAisReversible > 0, @@ -570,8 +571,8 @@ describe('undo / redo with stages', () => { A: { moves: { moveB: { - move: (G, ctx) => { - ctx.events.setStage('B'); + move: ({ G, events }) => { + events.setStage('B'); return { ...G, B: true }; }, undoable: false, @@ -581,8 +582,8 @@ describe('undo / redo with stages', () => { B: { moves: { moveC: { - move: (G, ctx) => { - ctx.events.setStage('C'); + move: ({ G, events }) => { + events.setStage('C'); return { ...G, C: true }; }, undoable: true, diff --git a/src/core/turn-order.test.ts b/src/core/turn-order.test.ts index 9a6f42f0d..4a5b30650 100644 --- a/src/core/turn-order.test.ts +++ b/src/core/turn-order.test.ts @@ -18,7 +18,7 @@ import { makeMove, gameEvent } from './action-creators'; import { CreateGameReducer } from './reducer'; import { InitializeGame } from './initialize'; import { error } from '../core/logger'; -import { State } from '../types'; +import { Game, State } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -302,12 +302,12 @@ describe('turn orders', () => { test('override', () => { const even = { first: () => 0, - next: (G, ctx) => (+ctx.currentPlayer + 2) % ctx.numPlayers, + next: ({ ctx }) => (+ctx.currentPlayer + 2) % ctx.numPlayers, }; const odd = { first: () => 1, - next: (G, ctx) => (+ctx.currentPlayer + 2) % ctx.numPlayers, + next: ({ ctx }) => (+ctx.currentPlayer + 2) % ctx.numPlayers, }; let flow = Flow({ @@ -334,7 +334,7 @@ test('override', () => { }); test('playOrder', () => { - const game = {}; + const game: Game = {}; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); @@ -396,16 +396,16 @@ describe('setActivePlayers', () => { }); test('once', () => { - const game = { + const game: Game = { moves: { - B: (G, ctx) => { - ctx.events.setActivePlayers({ + B: ({ G, events }) => { + events.setActivePlayers({ value: { '0': Stage.NULL, '1': Stage.NULL }, moveLimit: 1, }); return G; }, - A: G => G, + A: ({ G }) => G, }, }; @@ -421,16 +421,16 @@ describe('setActivePlayers', () => { }); test('others', () => { - const game = { + const game: Game = { moves: { - B: (G, ctx) => { - ctx.events.setActivePlayers({ + B: ({ G, events }) => { + events.setActivePlayers({ moveLimit: 1, others: Stage.NULL, }); return G; }, - A: G => G, + A: ({ G }) => G, }, }; @@ -453,7 +453,7 @@ describe('setActivePlayers', () => { describe('reset behavior', () => { test('start of turn', () => { - const game = { + const game: Game = { moves: { A: () => {}, }, @@ -481,10 +481,10 @@ describe('setActivePlayers', () => { describe('revert', () => { test('resets to previous', () => { - const game = { + const game: Game = { moves: { - A: (G, ctx) => { - ctx.events.setActivePlayers({ + A: ({ events }) => { + events.setActivePlayers({ currentPlayer: 'stage2', moveLimit: 1, revert: true, @@ -528,10 +528,10 @@ describe('setActivePlayers', () => { }); test('restores move limits and counts', () => { - const game = { + const game: Game = { moves: { - A: (G, ctx) => { - ctx.events.setActivePlayers({ + A: ({ events }) => { + events.setActivePlayers({ currentPlayer: 'stage2', moveLimit: 1, revert: true, @@ -602,7 +602,7 @@ describe('setActivePlayers', () => { }); test('set to next', () => { - const game = { + const game: Game = { moves: { A: () => {}, }, @@ -659,7 +659,7 @@ describe('setActivePlayers', () => { describe('move limits', () => { test('shorthand syntax', () => { - const game = { + const game: Game = { turn: { activePlayers: { all: 'play', @@ -706,7 +706,7 @@ describe('setActivePlayers', () => { }); test('long-form syntax', () => { - const game = { + const game: Game = { turn: { activePlayers: { currentPlayer: { stage: 'play', moveLimit: 2 }, @@ -751,7 +751,7 @@ describe('setActivePlayers', () => { }); test('player-specific limit overrides moveLimit arg', () => { - const game = { + const game: Game = { turn: { activePlayers: { all: { stage: 'play', moveLimit: 2 }, @@ -769,7 +769,7 @@ describe('setActivePlayers', () => { }); test('value syntax', () => { - const game = { + const game: Game = { turn: { activePlayers: { value: { @@ -809,7 +809,7 @@ describe('setActivePlayers', () => { }); test('move counts reset on turn end', () => { - const game = { + const game: Game = { turn: { activePlayers: { all: 'play', @@ -846,10 +846,10 @@ describe('setActivePlayers', () => { let state; let reducer; beforeAll(() => { - const game = { + const game: Game = { moves: { - militia: (G, ctx) => { - ctx.events.setActivePlayers({ + militia: ({ events }) => { + events.setActivePlayers({ others: 'discard', moveLimit: 1, revert: true, @@ -861,7 +861,7 @@ describe('setActivePlayers', () => { stages: { discard: { moves: { - discard: G => G, + discard: ({ G }) => G, }, }, }, @@ -984,15 +984,15 @@ describe('Random API is available', () => { const turn = { order: { - first: (_, ctx) => { - if (ctx.random !== undefined) { + first: ({ random }) => { + if (random !== undefined) { first = true; } return 0; }, - next: (_, ctx) => { - if (ctx.random !== undefined) { + next: ({ random }) => { + if (random !== undefined) { next = true; } return 0; @@ -1000,7 +1000,7 @@ describe('Random API is available', () => { }, }; - const game = { turn }; + const game: Game = { turn }; beforeEach(() => { first = next = false; diff --git a/src/core/turn-order.ts b/src/core/turn-order.ts index 597f4e94a..8b315a5ff 100644 --- a/src/core/turn-order.ts +++ b/src/core/turn-order.ts @@ -15,6 +15,7 @@ import { PlayerID, State, TurnConfig, + FnContext, } from '../types'; /** @@ -224,15 +225,17 @@ function getCurrentPlayer( */ export function InitTurnOrderState(state: State, turn: TurnConfig) { let { G, ctx } = state; - const ctxWithAPI = plugin.EnhanceCtx(state); + const pluginAPIs = plugin.GetAPIs(state); + // TODO: Decide if playerID should be included here, or if undefined is acceptable. + const context = { ...pluginAPIs, G, ctx, playerID: undefined }; const order = turn.order; let playOrder = [...new Array(ctx.numPlayers)].map((_, i) => i + ''); if (order.playOrder !== undefined) { - playOrder = order.playOrder(G, ctxWithAPI); + playOrder = order.playOrder(context); } - const playOrderPos = order.first(G, ctxWithAPI); + const playOrderPos = order.first(context); const posType = typeof playOrderPos; if (posType !== 'number') { logging.error( @@ -286,8 +289,10 @@ export function UpdateTurnOrderState( } }); } else { - const ctxWithAPI = plugin.EnhanceCtx(state); - const t = order.next(G, ctxWithAPI); + const pluginAPIs = plugin.GetAPIs(state); + // TODO: Decide if playerID should be included here, or if undefined is acceptable. + const context = { ...pluginAPIs, G, ctx, playerID: undefined }; + const t = order.next(context); const type = typeof t; if (t !== undefined && type !== 'number') { logging.error( @@ -330,11 +335,11 @@ export const TurnOrder = { * The default round-robin turn order. */ DEFAULT: { - first: (G: any, ctx: Ctx) => + first: ({ ctx }: FnContext) => ctx.turn === 0 ? ctx.playOrderPos : (ctx.playOrderPos + 1) % ctx.playOrder.length, - next: (G: any, ctx: Ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** @@ -344,7 +349,7 @@ export const TurnOrder = { */ RESET: { first: () => 0, - next: (G: any, ctx: Ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** @@ -353,8 +358,8 @@ export const TurnOrder = { * Similar to DEFAULT, but starts with the player who ended the last phase. */ CONTINUE: { - first: (G: any, ctx: Ctx) => ctx.playOrderPos, - next: (G: any, ctx: Ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, + first: ({ ctx }: FnContext) => ctx.playOrderPos, + next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }, /** @@ -365,7 +370,7 @@ export const TurnOrder = { */ ONCE: { first: () => 0, - next: (G: any, ctx: Ctx) => { + next: ({ ctx }: FnContext) => { if (ctx.playOrderPos < ctx.playOrder.length - 1) { return ctx.playOrderPos + 1; } @@ -383,7 +388,7 @@ export const TurnOrder = { CUSTOM: (playOrder: string[]) => ({ playOrder: () => playOrder, first: () => 0, - next: (G: any, ctx: Ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), /** @@ -396,9 +401,9 @@ export const TurnOrder = { * @param {string} playOrderField - Field in G. */ CUSTOM_FROM: (playOrderField: string) => ({ - playOrder: (G: any) => G[playOrderField], + playOrder: ({ G }: FnContext) => G[playOrderField], first: () => 0, - next: (G: any, ctx: Ctx) => (ctx.playOrderPos + 1) % ctx.playOrder.length, + next: ({ ctx }: FnContext) => (ctx.playOrderPos + 1) % ctx.playOrder.length, }), }; diff --git a/src/master/master.test.ts b/src/master/master.test.ts index b099ea2c9..f68216a98 100644 --- a/src/master/master.test.ts +++ b/src/master/master.test.ts @@ -17,7 +17,7 @@ import { isActionFromAuthenticPlayer, } from './master'; import { error } from '../core/logger'; -import { Server, State } from '../types'; +import { Game, Server, State } from '../types'; import * as StorageAPI from '../server/db/base'; import * as dateMock from 'jest-date-mock'; @@ -36,7 +36,7 @@ class InMemoryAsync extends InMemory { } } -const game = { seed: 0 }; +const game: Game = { seed: 0 }; function TransportAPI(send = jest.fn(), sendAll = jest.fn()) { return { send, sendAll }; @@ -448,8 +448,8 @@ describe('playerView', () => { const sendAll = jest.fn(arg => { sendAllReturn = arg; }); - const game = { - playerView: (G, ctx, player) => { + const game: Game = { + playerView: (G, _ctx, player) => { return { ...G, player }; }, }; @@ -517,7 +517,7 @@ describe('authentication', () => { describe('async', () => { const send = jest.fn(); const sendAll = jest.fn(); - const game = { seed: 0 }; + const game: Game = { seed: 0 }; const matchID = 'matchID'; const action = ActionCreators.gameEvent('endTurn'); const storage = new InMemoryAsync(); @@ -566,7 +566,7 @@ describe('authentication', () => { describe('sync', () => { const send = jest.fn(); const sendAll = jest.fn(); - const game = { seed: 0 }; + const game: Game = { seed: 0 }; const matchID = 'matchID'; const action = ActionCreators.gameEvent('endTurn'); const storage = new InMemory(); @@ -721,11 +721,11 @@ describe('redactLog', () => { }); test('make sure sync redacts the log', async () => { - const game = { + const game: Game = { moves: { - A: G => G, + A: ({ G }) => G, B: { - move: G => G, + move: ({ G }) => G, redact: true, }, }, diff --git a/src/plugins/events/events.test.ts b/src/plugins/events/events.test.ts index fe3495e6d..3c71751f7 100644 --- a/src/plugins/events/events.test.ts +++ b/src/plugins/events/events.test.ts @@ -34,10 +34,10 @@ test('dispatch', () => { }); test('update ctx', () => { - const game = { + const game: Game = { moves: { - A: (G, ctx) => { - ctx.events.endTurn(); + A: ({ G, events }) => { + events.endTurn(); return G; }, }, @@ -49,10 +49,10 @@ test('update ctx', () => { }); test('no duplicate endTurn', () => { - const game = { + const game: Game = { turn: { - onEnd: (G, ctx) => { - ctx.events.endTurn(); + onEnd: ({ events }) => { + events.endTurn(); }, }, }; @@ -63,12 +63,12 @@ test('no duplicate endTurn', () => { }); test('no duplicate endPhase', () => { - const game = { + const game: Game = { phases: { A: { start: true, - onEnd: (G, ctx) => { - ctx.events.setPhase('C'); + onEnd: ({ events }) => { + events.setPhase('C'); }, }, B: {}, diff --git a/src/plugins/main.test.ts b/src/plugins/main.test.ts index 46a8db71b..5a58f3c23 100644 --- a/src/plugins/main.test.ts +++ b/src/plugins/main.test.ts @@ -8,63 +8,70 @@ import { Client } from '../client/client'; import { Local } from '../client/transport/local'; +import { Game, Plugin } from '../types'; describe('basic', () => { let client: ReturnType; beforeAll(() => { - const game = { + interface TestPluginAPI { + get(): number; + increment(): number; + } + + const TestPlugin = (init: { + n: number; + }): Plugin => ({ + name: 'test', + + setup: () => init, + + api: ({ data }) => { + let state = { value: data.n }; + const increment = () => state.value++; + const get = () => state.value; + return { increment, get }; + }, + + flush: ({ api }) => ({ n: api.get() }), + + fnWrap: fn => context => { + const G = fn(context); + return { ...G, wrap: true }; + }, + }); + + const game: Game< + { beginA: number; endA: number; onMove: number; onTurnEnd: number }, + { test: TestPluginAPI } + > = { moves: { - A: (G, ctx) => { - G.beginA = ctx.test.get(); - ctx.test.increment(); - G.endA = ctx.test.get(); + A: ({ G, test }) => { + G.beginA = test.get(); + test.increment(); + G.endA = test.get(); }, }, - endIf: (_, ctx) => { - if (ctx.test === undefined) { + endIf: ({ test }) => { + if (test === undefined) { throw new Error('API is not defined'); } }, turn: { - onMove: (G, ctx) => { - G.onMove = ctx.test.get(); - ctx.test.increment(); + onMove: ({ G, test }) => { + G.onMove = test.get(); + test.increment(); }, - onEnd: (G, ctx) => { - G.onTurnEnd = ctx.test.get(); - ctx.test.increment(); + onEnd: ({ G, test }) => { + G.onTurnEnd = test.get(); + test.increment(); }, }, - pluginRelated: 10, - - plugins: [ - { - name: 'test', - - setup: ({ game }) => ({ - n: game.pluginRelated, - }), - - api: ({ data }) => { - let state = { value: data.n }; - const increment = () => state.value++; - const get = () => state.value; - return { increment, get }; - }, - - flush: ({ api }) => ({ n: api.get() }), - - fnWrap: fn => (G, ctx) => { - G = fn(G, ctx); - return { ...G, wrap: true }; - }, - }, - ], + plugins: [TestPlugin({ n: 10 })], }; client = Client({ game }); @@ -124,7 +131,10 @@ describe('default values', () => { noClient: () => false, }; - const game = { moves: { A: () => {} }, plugins: [plugin, anotherPlugin] }; + const game: Game = { + moves: { A: () => {} }, + plugins: [plugin, anotherPlugin], + }; test('are used if no setup is present', () => { const client = Client({ game, playerID: '0', multiplayer: Local() }); @@ -135,10 +145,10 @@ describe('default values', () => { }); describe('actions', () => { - let client; + let client: ReturnType; beforeAll(() => { - const game = { + const game: Game = { plugins: [ { name: 'test', @@ -181,13 +191,13 @@ describe('actions', () => { describe('plugins are accessible in events triggered from moves', () => { test('turn/onBegin', () => { - const game = { + const game: Game = { moves: { - stop: (G, ctx) => ctx.events.endTurn(), + stop: ({ events }) => events.endTurn(), }, turn: { - onBegin: (G, ctx) => { - G.onBegin = ctx.random.Die(1); + onBegin: ({ G, random }) => { + G.onBegin = random.Die(1); }, }, }; @@ -200,13 +210,13 @@ describe('plugins are accessible in events triggered from moves', () => { }); test('turn/onEnd', () => { - const game = { + const game: Game = { moves: { - stop: (G, ctx) => ctx.events.endTurn(), + stop: ({ events }) => events.endTurn(), }, turn: { - onEnd: (G, ctx) => { - G.onEnd = ctx.random.Die(1); + onEnd: ({ G, random }) => { + G.onEnd = random.Die(1); }, }, }; @@ -219,17 +229,17 @@ describe('plugins are accessible in events triggered from moves', () => { }); test('phase/onBegin', () => { - const game = { + const game: Game = { moves: { - stop: (G, ctx) => ctx.events.setPhase('second'), + stop: ({ events }) => events.setPhase('second'), }, phases: { first: { start: true, }, second: { - onBegin: (G, ctx) => { - G.onEnd = ctx.random.Die(1); + onBegin: ({ G, random }) => { + G.onEnd = random.Die(1); }, }, }, @@ -243,15 +253,15 @@ describe('plugins are accessible in events triggered from moves', () => { }); test('phase/onEnd', () => { - const game = { + const game: Game = { moves: { - stop: (G, ctx) => ctx.events.endPhase(), + stop: ({ events }) => events.endPhase(), }, phases: { first: { start: true, - onEnd: (G, ctx) => { - G.onEnd = ctx.random.Die(1); + onEnd: ({ G, random }) => { + G.onEnd = random.Die(1); }, }, }, diff --git a/src/plugins/main.ts b/src/plugins/main.ts index cc467bd8f..19c509b41 100644 --- a/src/plugins/main.ts +++ b/src/plugins/main.ts @@ -11,6 +11,7 @@ import PluginRandom from './plugin-random'; import PluginEvents from './plugin-events'; import { AnyFn, + DefaultPluginAPIs, PartialGameState, State, Game, @@ -58,7 +59,7 @@ export const ProcessAction = ( }; /** - * The API's created by various plugins are stored in the plugins + * The APIs created by various plugins are stored in the plugins * section of the state object: * * { @@ -72,17 +73,14 @@ export const ProcessAction = ( * } * } * - * This function takes these API's and stuffs them back into - * ctx for consumption inside a move function or hook. + * This function retrieves plugin APIs and returns them as an object + * for consumption as used by move contexts. */ -export const EnhanceCtx = (state: PartialGameState): Ctx => { - let ctx = { ...state.ctx }; - const plugins = state.plugins || {}; - Object.entries(plugins).forEach(([name, { api }]) => { - ctx[name] = api; - }); - return ctx; -}; +export const GetAPIs = ({ plugins }: PartialGameState) => + Object.entries(plugins || {}).reduce((apis, [name, { api }]) => { + apis[name] = api; + return apis; + }, {} as DefaultPluginAPIs); /** * Applies the provided plugins to the given move / flow function. @@ -131,10 +129,10 @@ export const Setup = ( * the `plugins` section of the state (which is subsequently * merged into ctx). */ -export const Enhance = ( - state: State, +export const Enhance = ( + state: S, opts: PluginOpts & { playerID: PlayerID } -): State => { +): S => { [...DEFAULT_PLUGINS, ...opts.game.plugins] .filter(plugin => plugin.api !== undefined) .forEach(plugin => { diff --git a/src/plugins/plugin-immer.test.ts b/src/plugins/plugin-immer.test.ts index 5fb5c1596..d8e66d698 100644 --- a/src/plugins/plugin-immer.test.ts +++ b/src/plugins/plugin-immer.test.ts @@ -19,10 +19,10 @@ describe('immer', () => { client = Client({ game: { moves: { - A: G => { + A: ({ G }) => { G.moveBody = true; }, - invalid: G => { + invalid: ({ G }) => { G.madeInvalidMove = true; return INVALID_MOVE; }, @@ -31,23 +31,23 @@ describe('immer', () => { phases: { A: { start: true, - onBegin: G => { + onBegin: ({ G }) => { G.onPhaseBegin = true; }, - onEnd: G => { + onEnd: ({ G }) => { G.onPhaseEnd = true; }, }, }, turn: { - onBegin: G => { + onBegin: ({ G }) => { G.onTurnBegin = true; }, - onEnd: G => { + onEnd: ({ G }) => { G.onTurnEnd = true; }, - onMove: G => { + onMove: ({ G }) => { G.onMove = true; }, }, diff --git a/src/plugins/plugin-immer.ts b/src/plugins/plugin-immer.ts index 052852b63..20162431f 100644 --- a/src/plugins/plugin-immer.ts +++ b/src/plugins/plugin-immer.ts @@ -7,7 +7,7 @@ */ import produce from 'immer'; -import { AnyFn, Ctx, Plugin } from '../types'; +import { Plugin } from '../types'; import { INVALID_MOVE } from '../core/constants'; /** @@ -17,10 +17,10 @@ import { INVALID_MOVE } from '../core/constants'; const ImmerPlugin: Plugin = { name: 'plugin-immer', - fnWrap: (move: AnyFn) => (G: any, ctx: Ctx, ...args: any[]) => { + fnWrap: move => (context, ...args) => { let isInvalid = false; - const newG = produce(G, G => { - const result = move(G, ctx, ...args); + const newG = produce(context.G, G => { + const result = move({ ...context, G }, ...args); if (result === INVALID_MOVE) { isInvalid = true; return; diff --git a/src/plugins/plugin-player.test.ts b/src/plugins/plugin-player.test.ts index 2c666eb7d..d3aa49f1d 100644 --- a/src/plugins/plugin-player.test.ts +++ b/src/plugins/plugin-player.test.ts @@ -6,13 +6,14 @@ * https://opensource.org/licenses/MIT. */ -import PluginPlayer from './plugin-player'; +import PluginPlayer, { PlayerAPI } from './plugin-player'; import { Client } from '../client/client'; +import { Game } from '../types'; describe('default values', () => { test('playerState is not passed', () => { const plugin = PluginPlayer(); - const game = { + const game: Game = { plugins: [plugin], }; const client = Client({ game }); @@ -23,7 +24,7 @@ describe('default values', () => { test('playerState is passed', () => { const plugin = PluginPlayer({ setup: () => ({ A: 1 }) }); - const game = { + const game: Game = { plugins: [plugin], }; const client = Client({ game }); @@ -37,16 +38,16 @@ describe('2 player game', () => { let client; beforeAll(() => { - const game = { + const game: Game = { moves: { - A: (_, ctx) => { - ctx.player.set({ field: 'A1' }); - ctx.player.opponent.set({ field: 'A2' }); + A: ({ player }) => { + player.set({ field: 'A1' }); + player.opponent.set({ field: 'A2' }); }, - B: (G, ctx) => { - G.playerValue = ctx.player.get().field; - G.opponentValue = ctx.player.opponent.get().field; + B: ({ G, player }) => { + G.playerValue = player.get().field; + G.opponentValue = player.opponent.get().field; }, }, @@ -90,10 +91,10 @@ describe('3 player game', () => { let client; beforeAll(() => { - const game = { + const game: Game = { moves: { - A: (_, ctx) => { - ctx.player.set({ field: 'A' }); + A: ({ player }) => { + player.set({ field: 'A' }); }, }, @@ -119,7 +120,7 @@ describe('game with phases', () => { let client; beforeAll(() => { - const game = { + const game: Game = { plugins: [PluginPlayer({ setup: id => ({ id }) })], phases: { phase: {}, diff --git a/src/plugins/random/random.test.ts b/src/plugins/random/random.test.ts index d555636a8..e8e654c67 100644 --- a/src/plugins/random/random.test.ts +++ b/src/plugins/random/random.test.ts @@ -10,6 +10,7 @@ import { Random } from './random'; import { makeMove } from '../../core/action-creators'; import { CreateGameReducer } from '../../core/reducer'; import { InitializeGame } from '../../core/initialize'; +import { Game } from '../../types'; function Init(seed) { return new Random({ seed }); @@ -98,10 +99,10 @@ test('Random.Shuffle', () => { }); test('Random API is not executed optimisitically', () => { - const game = { + const game: Game = { seed: 0, moves: { - rollDie: (G, ctx) => ({ ...G, die: ctx.random.D6() }), + rollDie: ({ G, random }) => ({ ...G, die: random.D6() }), }, }; @@ -122,15 +123,15 @@ test('Random API is not executed optimisitically', () => { } }); -test('turn.onBegin has ctx APIs at the beginning of the game', () => { +test('turn.onBegin has plugin APIs at the beginning of the game', () => { let random = null; let events = null; - const game = { + const game: Game = { turn: { - onBegin: (G, ctx) => { - random = ctx.random; - events = ctx.events; + onBegin: context => { + random = context.random; + events = context.events; }, }, }; diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 9f56360ac..91556244e 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -8,9 +8,9 @@ import { Server, createServerRunConfig, KoaServer, getPortFromServer } from '.'; import { SocketIO } from './transport/socketio'; -import { StorageAPI } from '../types'; +import { Game, StorageAPI } from '../types'; -const game = { seed: 0 }; +const game: Game = { seed: 0 }; jest.mock('../core/logger', () => ({ info: () => {}, @@ -60,21 +60,21 @@ jest.mock('koa', () => { describe('new', () => { test('custom db implementation', () => { - const game = {}; + const game: Game = {}; const db = {} as StorageAPI.Sync; const server = Server({ games: [game], db }); expect(server.db).toBe(db); }); test('custom transport implementation', () => { - const game = {}; + const game: Game = {}; const transport = ({ init: jest.fn() } as unknown) as SocketIO; Server({ games: [game], transport }); expect(transport.init).toBeCalled(); }); test('custom auth implementation', () => { - const game = {}; + const game: Game = {}; const authenticateCredentials = () => true; const server = Server({ games: [game], authenticateCredentials }); expect(server.db).not.toBeNull(); diff --git a/src/types.ts b/src/types.ts index e15fbede5..3ef0832d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { Object } from 'ts-toolbelt'; +import { Object, Misc } from 'ts-toolbelt'; import Koa from 'koa'; import { Store as ReduxStore } from 'redux'; import * as ActionCreators from './core/action-creators'; @@ -13,9 +13,9 @@ export { StorageAPI }; export type AnyFn = (...args: any[]) => any; -export interface State { +export interface State { G: G; - ctx: Ctx | CtxWithPlugins; + ctx: Ctx; deltalog?: Array; plugins: { [pluginName: string]: PluginState; @@ -50,7 +50,6 @@ export interface Ctx { numPlayers: number; playOrder: Array; playOrderPos: number; - playerID?: PlayerID; activePlayers: null | ActivePlayers; currentPlayer: PlayerID; numMoves?: number; @@ -68,16 +67,15 @@ export interface Ctx { _random?: { seed: string | number; }; +} - // TODO public api should have these as non-optional - // internally there are two contexts, one is a serialized POJO and another - // "enhanced" context that has plugin api methods attached - events?: EventsAPI; - random?: RandomAPI; +export interface DefaultPluginAPIs { + events: EventsAPI; + random: RandomAPI; } export interface PluginState { - data: any; + data: SerializableAny; api?: any; } @@ -124,116 +122,110 @@ export interface Plugin< }) => API; flush?: (context: PluginContext) => Data; dangerouslyFlushRawState?: (flushCtx: { - state: State; + state: State; game: Game; api: API; data: Data; - }) => State; - fnWrap?: (fn: AnyFn) => (G: G, ctx: Ctx, ...args: any[]) => any; + }) => State; + fnWrap?: ( + fn: (context: FnContext, ...args: SerializableAny[]) => any + ) => (context: FnContext, ...args: SerializableAny[]) => any; } -type MoveFn = ( - G: G, - ctx: CtxWithPlugins, - ...args: any[] -) => any; - -export interface LongFormMove< +export type FnContext< G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { - move: MoveFn; + PluginAPIs extends {} = {} +> = PluginAPIs & + DefaultPluginAPIs & { + G: G; + ctx: Ctx; + playerID: PlayerID; + }; + +type SerializableAny = Misc.JSON.Value; +type MoveFn = ( + context: FnContext, + ...args: SerializableAny[] +) => void | G | typeof INVALID_MOVE; + +export interface LongFormMove { + move: MoveFn; redact?: boolean; noLimit?: boolean; client?: boolean; - undoable?: boolean | ((G: G, ctx: CtxWithPlugins) => boolean); + undoable?: boolean | ((G: G, ctx: Ctx) => boolean); } -export type Move = - | MoveFn - | LongFormMove; +export type Move = + | MoveFn + | LongFormMove; -export interface MoveMap< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { - [moveName: string]: Move; +export interface MoveMap { + [moveName: string]: Move; } -export interface PhaseConfig< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { +export interface PhaseConfig { start?: boolean; next?: string; - onBegin?: (G: G, ctx: CtxWithPlugins) => any; - onEnd?: (G: G, ctx: CtxWithPlugins) => any; - endIf?: (G: G, ctx: CtxWithPlugins) => boolean | void | { next: string }; - moves?: MoveMap; - turn?: TurnConfig; + onBegin?: (context: FnContext) => any; + onEnd?: (context: FnContext) => any; + endIf?: ( + context: FnContext + ) => boolean | void | { next: string }; + moves?: MoveMap; + turn?: TurnConfig; wrapped?: { - endIf?: ( - state: State - ) => boolean | void | { next: string }; - onBegin?: (state: State) => any; - onEnd?: (state: State) => any; + endIf?: (state: State) => boolean | void | { next: string }; + onBegin?: (state: State) => any; + onEnd?: (state: State) => any; }; } -export interface StageConfig< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { - moves?: MoveMap; +export interface StageConfig { + moves?: MoveMap; next?: string; } -export interface StageMap< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { - [stageName: string]: StageConfig; +export interface StageMap { + [stageName: string]: StageConfig; } export interface TurnOrderConfig< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends {} = {} > { - first: (G: G, ctx: CtxWithPlugins) => number; - next: (G: G, ctx: CtxWithPlugins) => number | undefined; - playOrder?: (G: G, ctx: CtxWithPlugins) => PlayerID[]; + first: (context: FnContext) => number; + next: (context: FnContext) => number | undefined; + playOrder?: (context: FnContext) => PlayerID[]; } -export interface TurnConfig< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> { +export interface TurnConfig { activePlayers?: object; moveLimit?: number; - onBegin?: (G: G, ctx: CtxWithPlugins) => any; - onEnd?: (G: G, ctx: CtxWithPlugins) => any; - endIf?: (G: G, ctx: CtxWithPlugins) => boolean | void | { next: PlayerID }; - onMove?: (G: G, ctx: CtxWithPlugins) => any; - stages?: StageMap; - moves?: MoveMap; - order?: TurnOrderConfig; + onBegin?: (context: FnContext) => any; + onEnd?: (context: FnContext) => any; + endIf?: ( + context: FnContext + ) => boolean | void | { next: PlayerID }; + onMove?: (context: FnContext) => any; + stages?: StageMap; + moves?: MoveMap; + order?: TurnOrderConfig; wrapped?: { - endIf?: ( - state: State - ) => boolean | void | { next: PlayerID }; - onBegin?: (state: State) => any; - onEnd?: (state: State) => any; - onMove?: (state: State) => any; + endIf?: (state: State) => boolean | void | { next: PlayerID }; + onBegin?: (state: State) => any; + onEnd?: (state: State) => any; + onMove?: (state: State) => any; }; } -interface PhaseMap { - [phaseName: string]: PhaseConfig; +interface PhaseMap { + [phaseName: string]: PhaseConfig; } export interface Game< G extends any = any, - CtxWithPlugins extends Ctx = Ctx, + PluginAPIs extends {} = {}, SetupData extends any = any > { name?: string; @@ -241,14 +233,17 @@ export interface Game< maxPlayers?: number; disableUndo?: boolean; seed?: string | number; - setup?: (ctx: CtxWithPlugins, setupData?: SetupData) => any; + setup?: ( + context: Omit, 'G' | 'playerID'>, + setupData?: SetupData + ) => G; validateSetupData?: ( setupData: SetupData | undefined, numPlayers: number ) => string | undefined; - moves?: MoveMap; - phases?: PhaseMap; - turn?: TurnConfig; + moves?: MoveMap; + phases?: PhaseMap; + turn?: TurnConfig; events?: { endGame?: boolean; endPhase?: boolean; @@ -259,9 +254,9 @@ export interface Game< pass?: boolean; setActivePlayers?: boolean; }; - endIf?: (G: G, ctx: CtxWithPlugins) => any; - onEnd?: (G: G, ctx: CtxWithPlugins) => any; - playerView?: (G: G, ctx: CtxWithPlugins, playerID: PlayerID) => any; + endIf?: (context: FnContext) => any; + onEnd?: (context: FnContext) => any; + playerView?: (G: G, ctx: Ctx, playerID: PlayerID) => any; plugins?: Array>; ai?: { enumerate: ( @@ -276,9 +271,9 @@ export interface Game< >; }; processMove?: ( - state: State, + state: State, action: ActionPayload.MakeMove - ) => State | typeof INVALID_MOVE; + ) => State | typeof INVALID_MOVE; flow?: ReturnType; } From 3ac0def2ef34298948c588956861d9bbaa8805cd Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 3 Nov 2020 22:53:49 +0100 Subject: [PATCH 02/36] refactor(flow): Reorganise and clarify ProcessMove --- src/core/flow.ts | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/core/flow.ts b/src/core/flow.ts index b42d65136..08da71644 100644 --- a/src/core/flow.ts +++ b/src/core/flow.ts @@ -684,31 +684,23 @@ export function Flow({ } function ProcessMove(state: State, action: ActionPayload.MakeMove): State { - let conf = GetPhase(state.ctx); - const move = GetMove(state.ctx, action.type, action.playerID); + const { ctx } = state; + const { type, playerID } = action; + const conf = GetPhase(ctx); + const move = GetMove(ctx, type, playerID); const shouldCount = !move || typeof move === 'function' || move.noLimit !== true; - let { ctx } = state; - - const { playerID } = action; - - let numMoves = state.ctx.numMoves; const _activePlayersNumMoves = { ...ctx._activePlayersNumMoves }; + let { numMoves } = ctx; if (shouldCount) { - if (playerID == state.ctx.currentPlayer) { - numMoves++; - } + if (playerID == ctx.currentPlayer) numMoves++; if (ctx.activePlayers) _activePlayersNumMoves[playerID]++; } state = { ...state, - ctx: { - ...ctx, - numMoves, - _activePlayersNumMoves, - }, + ctx: { ...ctx, numMoves, _activePlayersNumMoves }, }; if ( @@ -721,7 +713,7 @@ export function Flow({ const G = conf.turn.wrapped.onMove(state); state = { ...state, G }; - let events = [{ fn: OnMove }]; + const events = [{ fn: OnMove }]; return Process(state, events); } From 6355df49b6ea101ca6cc4ae34b90821a23c40963 Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 3 Nov 2020 22:54:11 +0100 Subject: [PATCH 03/36] fix(bot): Improve bot typing --- src/ai/bot.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/bot.ts b/src/ai/bot.ts index 953ad5c3a..187122d7b 100644 --- a/src/ai/bot.ts +++ b/src/ai/bot.ts @@ -7,7 +7,7 @@ */ import { makeMove, gameEvent } from '../core/action-creators'; -import { alea } from '../plugins/random/random.alea'; +import { alea, AleaState } from '../plugins/random/random.alea'; import { ActionShape, Game, Ctx, PlayerID, State } from '../types'; export type BotAction = ActionShape.GameEvent | ActionShape.MakeMove; @@ -26,7 +26,7 @@ export abstract class Bot { value: any; } >; - private prngstate; + private prngstate?: AleaState; constructor({ enumerate, From 189b512ddb81117810da4e5b9b0804362ed68640 Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 3 Nov 2020 23:26:06 +0100 Subject: [PATCH 04/36] docs(examples): Update examples to use new function signatures --- examples/react-native/game.js | 6 +++--- examples/react-web/src/chess/game.js | 4 ++-- examples/react-web/src/random/game.js | 6 +++--- examples/react-web/src/redacted-move/game.js | 2 +- .../react-web/src/simulator/example-all-once.js | 2 +- examples/react-web/src/simulator/example-all.js | 2 +- .../react-web/src/simulator/example-others-once.js | 12 ++++++------ examples/react-web/src/simulator/example-others.js | 2 +- examples/react-web/src/tic-tac-toe/game.js | 6 +++--- examples/react-web/src/undo/game.js | 4 ++-- examples/snippets/src/example-1/index.js | 4 ++-- examples/snippets/src/example-2/index.js | 6 +++--- examples/snippets/src/example-3/index.js | 6 +++--- examples/snippets/src/multiplayer/index.js | 6 +++--- examples/snippets/src/phases-1/game.js | 10 +++++----- examples/snippets/src/phases-2/game.js | 14 +++++++------- examples/snippets/src/stages-1/game.js | 6 +++--- 17 files changed, 49 insertions(+), 49 deletions(-) diff --git a/examples/react-native/game.js b/examples/react-native/game.js index 5dc0a4fd2..d2288b20f 100644 --- a/examples/react-native/game.js +++ b/examples/react-native/game.js @@ -41,11 +41,11 @@ const TicTacToe = { }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, playerID }, id) { const cells = [...G.cells]; if (cells[id] === null) { - cells[id] = ctx.currentPlayer; + cells[id] = playerID; } return { ...G, cells }; @@ -54,7 +54,7 @@ const TicTacToe = { turn: { moveLimit: 1 }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return ctx.currentPlayer; } diff --git a/examples/react-web/src/chess/game.js b/examples/react-web/src/chess/game.js index 1f8f19221..771757fcd 100644 --- a/examples/react-web/src/chess/game.js +++ b/examples/react-web/src/chess/game.js @@ -27,7 +27,7 @@ const ChessGame = { setup: () => ({ pgn: '' }), moves: { - move(G, ctx, san) { + move({ G, ctx }, san) { const chess = Load(G.pgn); if ( (chess.turn() == 'w' && ctx.currentPlayer == '1') || @@ -42,7 +42,7 @@ const ChessGame = { turn: { moveLimit: 1 }, - endIf: G => { + endIf: ({ G }) => { const chess = Load(G.pgn); if (chess.game_over()) { if ( diff --git a/examples/react-web/src/random/game.js b/examples/react-web/src/random/game.js index 1804a8e57..13bba15b6 100644 --- a/examples/react-web/src/random/game.js +++ b/examples/react-web/src/random/game.js @@ -14,9 +14,9 @@ const RandomExample = { }), moves: { - shuffle: (G, ctx) => ({ ...G, deck: ctx.random.Shuffle(G.deck) }), - rollDie: (G, ctx, value) => ({ ...G, dice: ctx.random.Die(value) }), - rollD6: (G, ctx) => ({ ...G, dice: ctx.random.D6() }), + shuffle: ({ G, random }) => ({ ...G, deck: random.Shuffle(G.deck) }), + rollDie: ({ G, random }, value) => ({ ...G, dice: random.Die(value) }), + rollD6: ({ G, random }) => ({ ...G, dice: random.D6() }), }, }; diff --git a/examples/react-web/src/redacted-move/game.js b/examples/react-web/src/redacted-move/game.js index 0cbdadf65..3a0ccb851 100644 --- a/examples/react-web/src/redacted-move/game.js +++ b/examples/react-web/src/redacted-move/game.js @@ -22,7 +22,7 @@ const RedactedMoves = { moves: { clickCell: { /* eslint-disable no-unused-vars */ - move: (G, ctx, secretstuff) => {}, + move: (_, secretstuff) => {}, /* eslint-enable no-unused-vars */ redact: true, }, diff --git a/examples/react-web/src/simulator/example-all-once.js b/examples/react-web/src/simulator/example-all-once.js index 9caf7b579..63183758d 100644 --- a/examples/react-web/src/simulator/example-all-once.js +++ b/examples/react-web/src/simulator/example-all-once.js @@ -24,7 +24,7 @@ export default { description: Description, game: { moves: { - move: G => G, + move: ({ G }) => G, }, turn: { activePlayers: ActivePlayers.ALL_ONCE }, diff --git a/examples/react-web/src/simulator/example-all.js b/examples/react-web/src/simulator/example-all.js index 9938a3c87..ebe260d4a 100644 --- a/examples/react-web/src/simulator/example-all.js +++ b/examples/react-web/src/simulator/example-all.js @@ -24,7 +24,7 @@ export default { description: Description, game: { moves: { - move: G => G, + move: ({ G }) => G, }, turn: { activePlayers: ActivePlayers.ALL }, diff --git a/examples/react-web/src/simulator/example-others-once.js b/examples/react-web/src/simulator/example-others-once.js index a81ddd8fa..472a22455 100644 --- a/examples/react-web/src/simulator/example-others-once.js +++ b/examples/react-web/src/simulator/example-others-once.js @@ -10,8 +10,8 @@ import React from 'react'; const code = `{ moves: { - play: (G, ctx) => { - ctx.events.setActivePlayers({ + play: ({ G, events }) => { + events.setActivePlayers({ others: 'discard', moveLimit: 1 }); @@ -23,7 +23,7 @@ const code = `{ stages: { discard: { moves: { - discard: G => G, + discard: ({ G }) => G, }, }, }, @@ -46,8 +46,8 @@ export default { }, moves: { - play: (G, ctx) => { - ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 }); + play: ({ G, events }) => { + events.setActivePlayers({ others: 'discard', moveLimit: 1 }); return G; }, }, @@ -56,7 +56,7 @@ export default { stages: { discard: { moves: { - discard: G => G, + discard: ({ G }) => G, }, }, }, diff --git a/examples/react-web/src/simulator/example-others.js b/examples/react-web/src/simulator/example-others.js index e9ac7c9a6..93c41e846 100644 --- a/examples/react-web/src/simulator/example-others.js +++ b/examples/react-web/src/simulator/example-others.js @@ -24,7 +24,7 @@ export default { description: Description, game: { moves: { - move: G => G, + move: ({ G }) => G, }, events: { diff --git a/examples/react-web/src/tic-tac-toe/game.js b/examples/react-web/src/tic-tac-toe/game.js index f9eafeb71..b7f6cb321 100644 --- a/examples/react-web/src/tic-tac-toe/game.js +++ b/examples/react-web/src/tic-tac-toe/game.js @@ -34,11 +34,11 @@ const TicTacToe = { }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, playerID }, id) { const cells = [...G.cells]; if (cells[id] === null) { - cells[id] = ctx.currentPlayer; + cells[id] = playerID; return { ...G, cells }; } }, @@ -48,7 +48,7 @@ const TicTacToe = { moveLimit: 1, }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } diff --git a/examples/react-web/src/undo/game.js b/examples/react-web/src/undo/game.js index 5112f8d3b..727c3ea13 100644 --- a/examples/react-web/src/undo/game.js +++ b/examples/react-web/src/undo/game.js @@ -12,10 +12,10 @@ const UndoExample = { setup: () => ({ moves: [] }), moves: { - A: G => { + A: ({ G }) => { G.moves.push('A'); }, - B: G => { + B: ({ G }) => { G.moves.push('B'); }, }, diff --git a/examples/snippets/src/example-1/index.js b/examples/snippets/src/example-1/index.js index e8ada58b1..4db07529a 100644 --- a/examples/snippets/src/example-1/index.js +++ b/examples/snippets/src/example-1/index.js @@ -7,8 +7,8 @@ var TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { - clickCell(G, ctx, id) { - G.cells[id] = ctx.currentPlayer; + clickCell({ G, playerID }, id) { + G.cells[id] = playerID; }, }, }; diff --git a/examples/snippets/src/example-2/index.js b/examples/snippets/src/example-2/index.js index 407a34674..1bc42058f 100644 --- a/examples/snippets/src/example-2/index.js +++ b/examples/snippets/src/example-2/index.js @@ -28,17 +28,17 @@ const TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, playerID }, id) { if (G.cells[id] !== null) { return INVALID_MOVE; } - G.cells[id] = ctx.currentPlayer; + G.cells[id] = playerID; }, }, turn: { moveLimit: 1 }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } diff --git a/examples/snippets/src/example-3/index.js b/examples/snippets/src/example-3/index.js index 5836e506c..17f40748e 100644 --- a/examples/snippets/src/example-3/index.js +++ b/examples/snippets/src/example-3/index.js @@ -28,17 +28,17 @@ const TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, playerID }, id) { if (G.cells[id] !== null) { return INVALID_MOVE; } - G.cells[id] = ctx.currentPlayer; + G.cells[id] = playerID; }, }, turn: { moveLimit: 1 }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } diff --git a/examples/snippets/src/multiplayer/index.js b/examples/snippets/src/multiplayer/index.js index 353c2461c..b6c722107 100644 --- a/examples/snippets/src/multiplayer/index.js +++ b/examples/snippets/src/multiplayer/index.js @@ -34,11 +34,11 @@ const TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, playerID }, id) { const cells = [...G.cells]; if (cells[id] === null) { - cells[id] = ctx.currentPlayer; + cells[id] = playerID; } return { ...G, cells }; @@ -47,7 +47,7 @@ const TicTacToe = { turn: { moveLimit: 1 }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } diff --git a/examples/snippets/src/phases-1/game.js b/examples/snippets/src/phases-1/game.js index ce1cc39d1..990f94e8c 100644 --- a/examples/snippets/src/phases-1/game.js +++ b/examples/snippets/src/phases-1/game.js @@ -1,15 +1,15 @@ -function DrawCard(G, ctx) { +function DrawCard({ G, playerID }) { G.deck--; - G.hand[ctx.currentPlayer]++; + G.hand[playerID]++; } -function PlayCard(G, ctx) { +function PlayCard({ G, playerID }) { G.deck++; - G.hand[ctx.currentPlayer]--; + G.hand[playerID]--; } const game = { - setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), + setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), moves: { DrawCard, PlayCard }, turn: { moveLimit: 1 }, }; diff --git a/examples/snippets/src/phases-2/game.js b/examples/snippets/src/phases-2/game.js index ebdef4be0..4a7cce68f 100644 --- a/examples/snippets/src/phases-2/game.js +++ b/examples/snippets/src/phases-2/game.js @@ -1,26 +1,26 @@ -function DrawCard(G, ctx) { +function DrawCard({ G, playerID }) { G.deck--; - G.hand[ctx.currentPlayer]++; + G.hand[playerID]++; } -function PlayCard(G, ctx) { +function PlayCard({ G, playerID }) { G.deck++; - G.hand[ctx.currentPlayer]--; + G.hand[playerID]--; } const game = { - setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), + setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), phases: { draw: { moves: { DrawCard }, - endIf: G => G.deck <= 0, + endIf: ({ G }) => G.deck <= 0, next: 'play', start: true, }, play: { moves: { PlayCard }, - endIf: G => G.deck >= 6, + endIf: ({ G }) => G.deck >= 6, }, }, turn: { moveLimit: 1 }, diff --git a/examples/snippets/src/stages-1/game.js b/examples/snippets/src/stages-1/game.js index 23d94a112..e7b457355 100644 --- a/examples/snippets/src/stages-1/game.js +++ b/examples/snippets/src/stages-1/game.js @@ -1,8 +1,8 @@ -function militia(G, ctx) { - ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 }); +function militia({ G, events }) { + events.setActivePlayers({ others: 'discard', moveLimit: 1 }); } -function discard(G, ctx) {} +function discard({ G, ctx }) {} const game = { moves: { militia }, From 36bb3b3cdc8e3aafd32edeab48902e75279841d0 Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 3 Nov 2020 23:29:00 +0100 Subject: [PATCH 05/36] test(integration): Update to new function signature --- integration/src/game.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/src/game.js b/integration/src/game.js index f23cdf9e8..3710ede15 100644 --- a/integration/src/game.js +++ b/integration/src/game.js @@ -41,7 +41,7 @@ const TicTacToe = { }), moves: { - clickCell(G, ctx, id) { + clickCell({ G, ctx }, id) { const cells = [...G.cells]; if (cells[id] === null) { @@ -55,7 +55,7 @@ const TicTacToe = { moveLimit: 1, }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } From fc716e79a810a411d653a50a14baab79a57fe981 Mon Sep 17 00:00:00 2001 From: delucis Date: Wed, 4 Nov 2020 15:28:26 +0100 Subject: [PATCH 06/36] docs: Update to new function signature --- docs/documentation/api/Game.md | 29 +++++++++------- docs/documentation/concepts.md | 4 +-- docs/documentation/events.md | 7 ++-- docs/documentation/immutability.md | 10 +++--- docs/documentation/phases.md | 22 ++++++------ docs/documentation/plugins.md | 10 +++--- docs/documentation/random.md | 55 ++++++++++++++++-------------- docs/documentation/secret-state.md | 4 +-- docs/documentation/stages.md | 18 ++++++---- docs/documentation/testing.md | 6 ++-- docs/documentation/turn-order.md | 12 +++---- docs/documentation/tutorial.md | 25 +++++++------- docs/documentation/undo.md | 8 ++--- 13 files changed, 113 insertions(+), 97 deletions(-) diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index ff2406cc1..00fc6333c 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -8,7 +8,7 @@ // Function that returns the initial value of G. // setupData is an optional custom object that is // passed through the Game Creation API. - setup: (ctx, setupData) => G, + setup: ({ ctx, ...plugins }, setupData) => G, // Optional function to validate the setupData before // matches are created. If this returns a value, @@ -18,11 +18,11 @@ moves: { // short-form move. - A: (G, ctx) => {}, + A: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, // long-form move. B: { - move: (G, ctx) => {}, + move: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, undoable: false, // prevents undoing the move. redact: true, // prevents the move arguments from showing up in the log. client: false, // prevents the move from running on the client. @@ -43,16 +43,19 @@ order: TurnOrder.DEFAULT, // Called at the beginning of a turn. - onBegin: (G, ctx) => G, + onBegin: ({ G, ctx, playerID, events, random, ...plugins }) => G, // Called at the end of a turn. - onEnd: (G, ctx) => G, + onEnd: ({ G, ctx, playerID, events, random, ...plugins }) => G, // Ends the turn if this returns true. - endIf: (G, ctx) => true, + // Returning { next }, sets next playerID. + endIf: ({ G, ctx, playerID, events, random, ...plugins }) => ( + true | { next: '0' } + ), // Called at the end of each move. - onMove: (G, ctx) => G, + onMove: ({ G, ctx, playerID, events, random, ...plugins }) => G, // Ends the turn automatically after a number of moves. moveLimit: 1, @@ -78,13 +81,13 @@ phases: { A: { // Called at the beginning of a phase. - onBegin: (G, ctx) => G, + onBegin: ({ G, ctx, playerID, events, random, ...plugins }) => G, // Called at the end of a phase. - onEnd: (G, ctx) => G, + onEnd: ({ G, ctx, playerID, events, random, ...plugins }) => G, // Ends the phase if this returns true. - endIf: (G, ctx) => true, + endIf: ({ G, ctx, playerID, events, random, ...plugins }) => true, // Overrides `moves` for the duration of this phase. moves: { ... }, @@ -103,12 +106,12 @@ // Ends the game if this returns anything. // The return value is available in `ctx.gameover`. - endIf: (G, ctx) => obj, + endIf: ({ G, ctx, playerID, events, random, ...plugins }) => obj, // Called at the end of the game. // `ctx.gameover` is available at this point. - onEnd: (G, ctx) => G, - + onEnd: ({ G, ctx, playerID, events, random, ...plugins }) => G, + // Disable undo feature for all the moves in the game disableUndo: true, } diff --git a/docs/documentation/concepts.md b/docs/documentation/concepts.md index 5a09a0167..6dd27df17 100644 --- a/docs/documentation/concepts.md +++ b/docs/documentation/concepts.md @@ -41,7 +41,7 @@ immutability is handled by the framework. ```js moves: { - drawCard: (G, ctx) => { + drawCard: ({ G, ctx }) => { const card = G.deck.pop(); G.hand.push(card); }, @@ -78,7 +78,7 @@ onClick() { ### Events -These are framework-provided functions that are analagous to moves, except that they work on `ctx`. These typically advance the game state by doing things like +These are framework-provided functions that are analogous to moves, except that they work on `ctx`. These typically advance the game state by doing things like ending the turn, changing the game phase etc. Events are dispatched from the client in a similar way to moves. diff --git a/docs/documentation/events.md b/docs/documentation/events.md index a05ead766..92f739264 100644 --- a/docs/documentation/events.md +++ b/docs/documentation/events.md @@ -82,12 +82,13 @@ for more details. You can trigger events from a move or code inside your game logic (a phase’s `onBegin` hook, for example). -This is done through the `ctx.events` object: +This is done through the `events` API in the object passed +as the first argument to moves: ```js moves: { - drawCard: (G, ctx) => { - ctx.events.endPhase(); + drawCard: ({ G, ctx, events }) => { + events.endPhase(); }; } ``` diff --git a/docs/documentation/immutability.md b/docs/documentation/immutability.md index 614c9ef33..31141026c 100644 --- a/docs/documentation/immutability.md +++ b/docs/documentation/immutability.md @@ -16,7 +16,7 @@ A traditional pure function just accepts arguments and then returns the new state. Something like this: ```js -function move(G, ctx) { +function move({ G }) { // Return new value of G without modifying the arguments. return { ...G, hand: G.hand + 1 }; } @@ -33,7 +33,7 @@ immutability principle. Both styles are supported interchangeably, so use the one that you prefer. ```js -function move(G, ctx) { +function move({ G }) { G.hand++; } ``` @@ -42,7 +42,9 @@ function move(G, ctx) { In fact, returning something while also mutating `G` is considered an error. -!> `ctx` is a read-only object and is never modified in either style. +!> You can only modify `G`. Other values passed to your moves + are read-only and should never be modified in either style. + Changes to `ctx` can be made using [events](events.md). ### Invalid moves @@ -57,7 +59,7 @@ Tic-Tac-Toe. import { INVALID_MOVE } from 'boardgame.io/core'; moves: { - clickCell: function(G, ctx, id) { + clickCell: function({ G, ctx }, id) { // Illegal move: Cell is filled. if (G.cells[id] !== null) { return INVALID_MOVE; diff --git a/docs/documentation/phases.md b/docs/documentation/phases.md index 9b7a57989..81d5cad3f 100644 --- a/docs/documentation/phases.md +++ b/docs/documentation/phases.md @@ -20,18 +20,18 @@ two moves: - play a card from your hand onto the deck. ```js -function DrawCard(G, ctx) { +function DrawCard({ G, playerID }) { G.deck--; - G.hand[ctx.currentPlayer]++; + G.hand[playerID]++; } -function PlayCard(G, ctx) { +function PlayCard({ G, playerID }) { G.deck++; - G.hand[ctx.currentPlayer]--; + G.hand[playerID]--; } const game = { - setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), + setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), moves: { DrawCard, PlayCard }, turn: { moveLimit: 1 }, }; @@ -58,7 +58,7 @@ list of moves, which come into effect during that phase: ```js const game = { - setup: ctx => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), + setup: ({ ctx }) => ({ deck: 6, hand: Array(ctx.numPlayers).fill(0) }), turn: { moveLimit: 1 }, phases: { @@ -102,7 +102,7 @@ empty. phases: { draw: { moves: { DrawCard }, -+ endIf: G => (G.deck <= 0), ++ endIf: ({ G }) => (G.deck <= 0), + next: 'play', start: true, }, @@ -134,8 +134,8 @@ You can also run code automatically at the beginning or end of a phase. These ar ```js phases: { phaseA: { - onBegin: (G, ctx) => { ... }, - onEnd: (G, ctx) => { ... }, + onBegin: ({ G, ctx }) => { ... }, + onEnd: ({ G, ctx }) => { ... }, }, }; ``` @@ -170,7 +170,7 @@ You can also end a phase by returning a truthy value from its phases: { phaseA: { next: 'phaseB', - endIf: (G, ctx) => true, + endIf: ({ G, ctx }) => true, }, phaseB: { ... }, }, @@ -182,7 +182,7 @@ an object containing a `next` field from your `endIf`: ```js phases: { phaseA: { - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { return { next: G.condition ? 'phaseB' : 'phaseC' } }, }, diff --git a/docs/documentation/plugins.md b/docs/documentation/plugins.md index df2d08888..6bfbcf465 100644 --- a/docs/documentation/plugins.md +++ b/docs/documentation/plugins.md @@ -34,9 +34,9 @@ A plugin is an object that contains the following fields. // wrapper can modify G before passing it down to // the wrapped function. It is a good practice to // undo the change at the end of the call. - fnWrap: (fn) => (G, ctx, ...args) => { + fnWrap: (fn) => ({ G, ...rest }, ...args) => { G = preprocess(G); - G = fn(G, ctx, ...args); + G = fn({ G, ...rest }, ...args); G = postprocess(G); return G; }, @@ -59,11 +59,9 @@ import { PluginA, PluginB } from 'boardgame.io/plugins'; const game = { name: 'my-game', - moves: { - ... - }, - plugins: [PluginA, PluginB], + + // ... }; ``` diff --git a/docs/documentation/random.md b/docs/documentation/random.md index 4773c8c01..8f77a1ec0 100644 --- a/docs/documentation/random.md +++ b/docs/documentation/random.md @@ -10,10 +10,11 @@ This poses interesting challenges regarding the implementation. - **AI**. Randomness makes games interesting since you cannot predict the future, but it needs to be controlled in order for allowing games that can be replayed exactly (e.g. for AI purposes). -- **PRNG State**. The game runs on both the server and client. +- **PRNG State**. + The game runs on both the server and client. All code and data on the client can be viewed and used to a player's advantage. If a client could predict the next random numbers that are to be generated, the future flow of a game stops being unpredictable. - The library must not allow such a scenario. The RNG and its state must stay at the server. + The library must not allow such a scenario. The RNG and its state must stay on the server. - **Pure Functions**. The library is built using Redux. This is important for games since each move is a [reducer](https://redux.js.org/docs/basics/Reducers.html), and thus must be pure. Calling `Math.random()` and other functions that @@ -21,34 +22,41 @@ This poses interesting challenges regarding the implementation. ### Using Randomness in Games +The object passed to moves and other game logic contains an object `random`, +which exposes a range of functions for generating randomness. + +For example, the `random.D6` function is similar to rolling six-sided dice: + ```js { moves: { - rollDie: (G, ctx) => { - G.dieRoll = ctx.random.D6(); + rollDie: ({ G, random }) => { + G.dieRoll = random.D6(); // dieRoll = 1–6 }, + + rollThreeDice: ({ G, random }) => { + G.diceRoll = random.D6(3); // diceRoll = [1–6, 1–6, 1–6] + } }, } ``` -?> The PRNG state is maintained inside `ctx._random` by the `Random` -package automatically. +You can see details for all the available random functions below. ### Seed -The library uses a `seed` in `ctx._random` that is stripped before it -is sent to the client. All the code that needs randomness uses this -`seed` to generate random numbers. - -You can override the initial `seed` like this: +You can set the initial `seed` used for the random number generator +on your game object: ```js const game = { - seed: - ... + seed: 42, + // ... }; ``` +?> `seed` can be either a string or a number. + ## API Reference ### 1. Die @@ -67,10 +75,9 @@ The die roll value (or an array of values if `diceCount` is greater than `1`). ```js const game = { moves: { - move(G, ctx) { - const die = ctx.random.Die(6); // die = 1-6 - const dice = ctx.random.Die(6, 3); // dice = [1-6, 1-6, 1-6] - ... + move({ random }) { + const die = random.Die(6); // die = 1-6 + const dice = random.Die(6, 3); // dice = [1-6, 1-6, 1-6] }, } }; @@ -85,9 +92,8 @@ Returns a random number between `0` and `1`. ```js const game = { moves: { - move(G, ctx) { - const n = ctx.random.Number(); - ... + move({ random }) { + const n = random.Number(); }, } }; @@ -108,8 +114,8 @@ The shuffled array. ```js const game = { moves: { - move(G, ctx) { - G.deck = ctx.random.Shuffle(G.deck); + move({ G, random }) { + G.deck = random.Shuffle(G.deck); }, }, }; @@ -129,9 +135,8 @@ const game = { ```js const game = { moves: { - move(G, ctx) { - const die = ctx.random.D6(); - ... + move({ random }) { + const die = random.D6(); }, } }; diff --git a/docs/documentation/secret-state.md b/docs/documentation/secret-state.md index 6b469bf18..f2372582b 100644 --- a/docs/documentation/secret-state.md +++ b/docs/documentation/secret-state.md @@ -78,8 +78,8 @@ These can be marked as server-only by setting `client: false` on move: ```js moves: { moveThatUsesSecret: { - move: (G, ctx) => { - ... + move: ({ G, random }) => { + G.secret.value = random.Number(); }, client: false, diff --git a/docs/documentation/stages.md b/docs/documentation/stages.md index 2dab6931e..66e156118 100644 --- a/docs/documentation/stages.md +++ b/docs/documentation/stages.md @@ -19,6 +19,16 @@ players don't have to all be in the same stage either (each player can be in their own stage). Each player that is in a stage is now considered an "active" player that can make moves as allowed by the stage that they are in. +You can check `playerID` inside a move to figure out +which player made it. This may be necessary in situations +where multiple players are active (and could simultaneously make a move). + +```js +const move = ({ G, ctx, playerID }) => { + console.log(`move made by player ${playerID}`); +}; +``` + ### Defining Stages Stages are defined inside a `turn` section: @@ -130,8 +140,8 @@ Let's go back to the example we discussed earlier where we require every other player to discard a card when we play one: ```js -function PlayCard(G, ctx) { - ctx.events.setActivePlayers({ others: 'discard', moveLimit: 1 }); +function PlayCard({ events }) { + events.setActivePlayers({ others: 'discard', moveLimit: 1 }); } const game = { @@ -194,10 +204,6 @@ that you want in the set of active players: setActivePlayers(['0', '3']); ``` -?> You can check `ctx.playerID` inside a move to figure out -which player made it. This may be necessary in situations -where multiple players are active (and could simultaneously move). - ### Configuring active players at the beginning of a turn. You can have `setActivePlayers` called automatically diff --git a/docs/documentation/testing.md b/docs/documentation/testing.md index a77cdfcf4..c8df1b61d 100644 --- a/docs/documentation/testing.md +++ b/docs/documentation/testing.md @@ -9,8 +9,8 @@ before passing them to the game object: `Game.js` ```js -export function clickCell(G, ctx, id) { - G.cells[id] = ctx.currentPlayer; +export function clickCell({ G, playerID }, id) { + G.cells[id] = playerID; } export const TicTacToe = { @@ -31,7 +31,7 @@ it('should place the correct value in the cell', () => { }; // make move. - clickCell(G, { currentPlayer: '1' }, 3); + clickCell({ G, playerID: '1' }, 3); // verify new state. expect(G).toEqual({ diff --git a/docs/documentation/turn-order.md b/docs/documentation/turn-order.md index 4a667b7d3..a3107786f 100644 --- a/docs/documentation/turn-order.md +++ b/docs/documentation/turn-order.md @@ -114,15 +114,15 @@ works the same way. Player `3` is made the new player in both examples below: ```js -function Move(G, ctx) { - ctx.events.endTurn({ next: '3' }); +function Move({ events }) { + events.endTurn({ next: '3' }); } ``` ```js const game = { turn: { - endIf: (G, ctx) => ({ next: '3' }), + endIf: () => ({ next: '3' }), }, }; ``` @@ -137,17 +137,17 @@ turn: { order: { // Get the initial value of playOrderPos. // This is called at the beginning of the phase. - first: (G, ctx) => 0, + first: ({ G, ctx }) => 0, // Get the next value of playOrderPos. // This is called at the end of each turn. // The phase ends if this returns undefined. - next: (G, ctx) => (ctx.playOrderPos + 1) % ctx.numPlayers, + next: ({ G, ctx }) => (ctx.playOrderPos + 1) % ctx.numPlayers, // OPTIONAL: // Override the initial value of playOrder. // This is called at the beginning of the game / phase. - playOrder: (G, ctx) => [...], + playOrder: ({ G, ctx }) => [...], } } ``` diff --git a/docs/documentation/tutorial.md b/docs/documentation/tutorial.md index c3379675f..e87c5d558 100644 --- a/docs/documentation/tutorial.md +++ b/docs/documentation/tutorial.md @@ -112,11 +112,12 @@ To start, we’ll add a `setup` function, which will set the initial value of the game state `G`, and a `moves` object containing the moves that make up the game. -A move function receives -the game state `G` and updates it to the desired new state. -It also receives `ctx`, an object managed by boardgame.io -that contains metadata like `turn` and `currentPlayer`. -After `G` and `ctx`, moves can receive arbitrary arguments +A move is a function that updates `G` to the desired new state. +It receives an object containing various fields +as its first argument. This object includes the game state `G` and +`ctx` — an object managed by boardgame.io that contains game metadata. +It also includes `playerID`, which identifies the player making the move. +After the object containing `G` and `ctx`, moves can receive arbitrary arguments that you pass in when making the move. In Tic-Tac-Toe, we only have one type of move and we will @@ -131,15 +132,15 @@ export const TicTacToe = { setup: () => ({ cells: Array(9).fill(null) }), moves: { - clickCell: (G, ctx, id) => { - G.cells[id] = ctx.currentPlayer; + clickCell: ({ G, playerID }, id) => { + G.cells[id] = playerID; }, }, }; ``` -?> The `setup` function will receive `ctx` as its first argument. -This is useful if you need to customize the initial +?> The `setup` function also receives an object as its first argument +like moves. This is useful if you need to customize the initial state based on some field in `ctx` — the number of players, for example — but we don't need that for Tic-Tac-Toe. @@ -254,11 +255,11 @@ import { INVALID_MOVE } from 'boardgame.io/core'; Now we can return `INVALID_MOVE` from `clickCell`: ```js -clickCell: (G, ctx, id) => { +clickCell: ({ G, playerID }, id) => { if (G.cells[id] !== null) { return INVALID_MOVE; } - G.cells[id] = ctx.currentPlayer; + G.cells[id] = playerID; } ``` @@ -328,7 +329,7 @@ check if the game is over. export const TicTacToe = { // setup, moves, etc. - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } diff --git a/docs/documentation/undo.md b/docs/documentation/undo.md index 615bdf212..92892d769 100644 --- a/docs/documentation/undo.md +++ b/docs/documentation/undo.md @@ -5,8 +5,8 @@ moves in the current turn. This is a common pattern in games that allow a player to make multiple moves per turn, and can be a useful feature to allow the player to experiment with different move combinations (and seeing what they do) -before committing to one. You can disable this feature by -setting `disableUndo` to true in the game config. +before committing to one. You can disable this feature by +setting `disableUndo` to true in the game config. ### Usage @@ -44,11 +44,11 @@ indicates whether the move can be undone: const game = { moves: { rollDice: { - move: (G, ctx) => ... + move: ({ G, ctx }) => {}, undoable: false, }, - playCard: (G, ctx) => ... + playCard: ({ G, ctx }) => {}, }, }; ``` From b78364440f8bd94eec48fa5cd82f77a928c84e6b Mon Sep 17 00:00:00 2001 From: delucis Date: Sat, 7 Nov 2020 23:53:06 +0100 Subject: [PATCH 07/36] test(socketio): Fix tests for new move signature --- .../transport/socketio-simultaneous.test.ts | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/server/transport/socketio-simultaneous.test.ts b/src/server/transport/socketio-simultaneous.test.ts index ad3e0b3db..ed6a96133 100644 --- a/src/server/transport/socketio-simultaneous.test.ts +++ b/src/server/transport/socketio-simultaneous.test.ts @@ -15,7 +15,7 @@ import * as ActionCreators from '../../core/action-creators'; import { InitializeGame } from '../../core/initialize'; import { PlayerView } from '../../core/player-view'; import { _ClientImpl } from '../../client/client'; -import { Ctx, LogEntry, Server, State, StorageAPI } from '../../types'; +import { Game, LogEntry, Server, State, StorageAPI } from '../../types'; type SocketIOTestAdapterOpts = SocketOpts & { clientInfo?: Map; @@ -150,23 +150,20 @@ jest.mock('koa-socket-2', () => { return MockIO; }); -const game = { +const game: Game = { name: 'test', - setup: ctx => { - const G = { - players: { - '0': { - cards: ['card3'], - }, - '1': { - cards: [], - }, + setup: () => ({ + players: { + '0': { + cards: ['card3'], }, - cards: ['card0', 'card1', 'card2'], - discardedCards: [], - }; - return G; - }, + '1': { + cards: [], + }, + }, + cards: ['card0', 'card1', 'card2'], + discardedCards: [], + }), playerView: PlayerView.STRIP_SECRETS, turn: { activePlayers: { currentPlayer: { stage: 'A' } }, @@ -175,17 +172,17 @@ const game = { moves: { A: { client: false, - move: (G, ctx: Ctx) => { - const card = G.players[ctx.playerID].cards.shift(); + move: ({ G, playerID }) => { + const card = G.players[playerID].cards.shift(); G.discardedCards.push(card); }, }, B: { client: false, ignoreStaleStateID: true, - move: (G, ctx: Ctx) => { + move: ({ G, playerID }) => { const card = G.cards.pop(); - G.players[ctx.playerID].cards.push(card); + G.players[playerID].cards.push(card); }, }, }, From 7dd170392957f93419ac3725c85937b6b82210a0 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 8 Nov 2020 00:29:22 +0100 Subject: [PATCH 08/36] refactor: Settle on hooks not receiving `playerID` --- docs/documentation/api/Game.md | 18 +++++++++--------- src/core/flow.ts | 4 ---- src/core/turn-order.ts | 6 ++---- src/types.ts | 5 ++--- 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index 59b628d9b..25246c261 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -51,19 +51,19 @@ order: TurnOrder.DEFAULT, // Called at the beginning of a turn. - onBegin: ({ G, ctx, playerID, events, random, ...plugins }) => G, + onBegin: ({ G, ctx, events, random, ...plugins }) => G, // Called at the end of a turn. - onEnd: ({ G, ctx, playerID, events, random, ...plugins }) => G, + onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the turn if this returns true. // Returning { next }, sets next playerID. - endIf: ({ G, ctx, playerID, events, random, ...plugins }) => ( + endIf: ({ G, ctx, events, random, ...plugins }) => ( true | { next: '0' } ), // Called at the end of each move. - onMove: ({ G, ctx, playerID, events, random, ...plugins }) => G, + onMove: ({ G, ctx, events, random, ...plugins }) => G, // Ends the turn automatically after a number of moves. moveLimit: 1, @@ -89,13 +89,13 @@ phases: { A: { // Called at the beginning of a phase. - onBegin: ({ G, ctx, playerID, events, random, ...plugins }) => G, + onBegin: ({ G, ctx, events, random, ...plugins }) => G, // Called at the end of a phase. - onEnd: ({ G, ctx, playerID, events, random, ...plugins }) => G, + onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the phase if this returns true. - endIf: ({ G, ctx, playerID, events, random, ...plugins }) => true, + endIf: ({ G, ctx, events, random, ...plugins }) => true, // Overrides `moves` for the duration of this phase. moves: { ... }, @@ -114,11 +114,11 @@ // Ends the game if this returns anything. // The return value is available in `ctx.gameover`. - endIf: ({ G, ctx, playerID, events, random, ...plugins }) => obj, + endIf: ({ G, ctx, events, random, ...plugins }) => obj, // Called at the end of the game. // `ctx.gameover` is available at this point. - onEnd: ({ G, ctx, playerID, events, random, ...plugins }) => G, + onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Disable undo feature for all the moves in the game disableUndo: true, diff --git a/src/core/flow.ts b/src/core/flow.ts index 08da71644..b9750af44 100644 --- a/src/core/flow.ts +++ b/src/core/flow.ts @@ -81,12 +81,10 @@ export function Flow({ const withPlugins = plugin.FnWrap(fn, plugins); return (state: State) => { const pluginAPIs = plugin.GetAPIs(state); - // TODO: what should happend with playerID here? return withPlugins({ ...pluginAPIs, G: state.G, ctx: state.ctx, - playerID: undefined, }); }; }; @@ -94,12 +92,10 @@ export function Flow({ const TriggerWrapper = (endIf: (context: FnContext) => any) => { return (state: State) => { const pluginAPIs = plugin.GetAPIs(state); - // TODO: what should happend with playerID here? return endIf({ ...pluginAPIs, G: state.G, ctx: state.ctx, - playerID: undefined, }); }; }; diff --git a/src/core/turn-order.ts b/src/core/turn-order.ts index 8b315a5ff..13f6b379a 100644 --- a/src/core/turn-order.ts +++ b/src/core/turn-order.ts @@ -226,8 +226,7 @@ function getCurrentPlayer( export function InitTurnOrderState(state: State, turn: TurnConfig) { let { G, ctx } = state; const pluginAPIs = plugin.GetAPIs(state); - // TODO: Decide if playerID should be included here, or if undefined is acceptable. - const context = { ...pluginAPIs, G, ctx, playerID: undefined }; + const context = { ...pluginAPIs, G, ctx }; const order = turn.order; let playOrder = [...new Array(ctx.numPlayers)].map((_, i) => i + ''); @@ -290,8 +289,7 @@ export function UpdateTurnOrderState( }); } else { const pluginAPIs = plugin.GetAPIs(state); - // TODO: Decide if playerID should be included here, or if undefined is acceptable. - const context = { ...pluginAPIs, G, ctx, playerID: undefined }; + const context = { ...pluginAPIs, G, ctx }; const t = order.next(context); const type = typeof t; if (t !== undefined && type !== 'number') { diff --git a/src/types.ts b/src/types.ts index 9c5950b03..0df64f645 100644 --- a/src/types.ts +++ b/src/types.ts @@ -139,12 +139,11 @@ export type FnContext< DefaultPluginAPIs & { G: G; ctx: Ctx; - playerID: PlayerID; }; type SerializableAny = Misc.JSON.Value; type MoveFn = ( - context: FnContext, + context: FnContext & { playerID: PlayerID }, ...args: SerializableAny[] ) => void | G | typeof INVALID_MOVE; @@ -235,7 +234,7 @@ export interface Game< disableUndo?: boolean; seed?: string | number; setup?: ( - context: Omit, 'G' | 'playerID'>, + context: Omit, 'G'>, setupData?: SetupData ) => G; validateSetupData?: ( From ce4d65702718b9d631c9129197686263bfffce11 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 8 Nov 2020 01:03:20 +0100 Subject: [PATCH 09/36] style(plugins): Remove unused import --- src/plugins/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/main.ts b/src/plugins/main.ts index 19c509b41..460f20a03 100644 --- a/src/plugins/main.ts +++ b/src/plugins/main.ts @@ -16,7 +16,6 @@ import { State, Game, Plugin, - Ctx, ActionShape, PlayerID, } from '../types'; From b0b56936f112a4a3962b4a9316d8dcc3969a114b Mon Sep 17 00:00:00 2001 From: delucis Date: Thu, 24 Dec 2020 23:13:00 +0100 Subject: [PATCH 10/36] fix(types): Restore plugin state type as `any` --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 888f98114..3d320c9e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,7 +78,7 @@ export interface DefaultPluginAPIs { } export interface PluginState { - data: SerializableAny; + data: any; api?: any; } From 9315a02bc3548fa1cab829d7288b8237ac839308 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 17 Jan 2021 00:28:12 +0100 Subject: [PATCH 11/36] fix(types): Fix PluginAPIs type parameters --- src/client/client.ts | 5 +++- src/types.ts | 63 +++++++++++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/client/client.ts b/src/client/client.ts index d37a9eb90..2a80dc09d 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -101,7 +101,10 @@ export const createEventDispatchers = createDispatchers.bind(null, 'gameEvent'); // Creates a set of dispatchers to dispatch actions to plugins. export const createPluginDispatchers = createDispatchers.bind(null, 'plugin'); -export interface ClientOpts { +export interface ClientOpts< + G extends any = any, + PluginAPIs extends Record = Record +> { game: Game; debug?: DebugOpt | boolean; numPlayers?: number; diff --git a/src/types.ts b/src/types.ts index b75eb1db4..9d93dfb69 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,19 +115,19 @@ export interface Plugin< > { name: string; noClient?: (context: PluginContext) => boolean; - setup?: (setupCtx: { G: G; ctx: Ctx; game: Game }) => Data; + setup?: (setupCtx: { G: G; ctx: Ctx; game: Game }) => Data; action?: (data: Data, payload: ActionShape.Plugin['payload']) => Data; api?: (context: { G: G; ctx: Ctx; - game: Game; + game: Game; data: Data; playerID?: PlayerID; }) => API; flush?: (context: PluginContext) => Data; dangerouslyFlushRawState?: (flushCtx: { state: State; - game: Game; + game: Game; api: API; data: Data; }) => State; @@ -137,7 +137,7 @@ export interface Plugin< playerView?: (context: { G: G; ctx: Ctx; - game: Game; + game: Game; data: Data; playerID?: PlayerID | null; }) => any; @@ -145,7 +145,7 @@ export interface Plugin< export type FnContext< G extends any = any, - PluginAPIs extends {} = {} + PluginAPIs extends Record = Record > = PluginAPIs & DefaultPluginAPIs & { G: G; @@ -153,12 +153,18 @@ export type FnContext< }; type SerializableAny = Misc.JSON.Value; -type MoveFn = ( +type MoveFn< + G extends any = any, + PluginAPIs extends Record = Record +> = ( context: FnContext & { playerID: PlayerID }, ...args: SerializableAny[] ) => void | G | typeof INVALID_MOVE; -export interface LongFormMove { +export interface LongFormMove< + G extends any = any, + PluginAPIs extends Record = Record +> { move: MoveFn; redact?: boolean; noLimit?: boolean; @@ -167,15 +173,22 @@ export interface LongFormMove { ignoreStaleStateID?: boolean; } -export type Move = - | MoveFn - | LongFormMove; +export type Move< + G extends any = any, + PluginAPIs extends Record = Record +> = MoveFn | LongFormMove; -export interface MoveMap { +export interface MoveMap< + G extends any = any, + PluginAPIs extends Record = Record +> { [moveName: string]: Move; } -export interface PhaseConfig { +export interface PhaseConfig< + G extends any = any, + PluginAPIs extends Record = Record +> { start?: boolean; next?: string; onBegin?: (context: FnContext) => any; @@ -192,25 +205,34 @@ export interface PhaseConfig { }; } -export interface StageConfig { +export interface StageConfig< + G extends any = any, + PluginAPIs extends Record = Record +> { moves?: MoveMap; next?: string; } -export interface StageMap { +export interface StageMap< + G extends any = any, + PluginAPIs extends Record = Record +> { [stageName: string]: StageConfig; } export interface TurnOrderConfig< G extends any = any, - PluginAPIs extends {} = {} + PluginAPIs extends Record = Record > { first: (context: FnContext) => number; next: (context: FnContext) => number | undefined; playOrder?: (context: FnContext) => PlayerID[]; } -export interface TurnConfig { +export interface TurnConfig< + G extends any = any, + PluginAPIs extends Record = Record +> { activePlayers?: ActivePlayersArg; moveLimit?: number; onBegin?: (context: FnContext) => any; @@ -230,13 +252,16 @@ export interface TurnConfig { }; } -interface PhaseMap { +interface PhaseMap< + G extends any = any, + PluginAPIs extends Record = Record +> { [phaseName: string]: PhaseConfig; } export interface Game< G extends any = any, - PluginAPIs extends {} = {}, + PluginAPIs extends Record = Record, SetupData extends any = any > { name?: string; @@ -245,7 +270,7 @@ export interface Game< disableUndo?: boolean; seed?: string | number; setup?: ( - context: Omit, 'G'>, + context: PluginAPIs & DefaultPluginAPIs & { ctx: Ctx }, setupData?: SetupData ) => G; validateSetupData?: ( From 05ee1e6643eb3da9f38e38acd86f14b9f1389931 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 17 Jan 2021 00:31:51 +0100 Subject: [PATCH 12/36] test: Use new signature in merged tests --- src/core/turn-order.test.ts | 12 ++++++------ src/plugins/plugin-log.test.ts | 9 +++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/core/turn-order.test.ts b/src/core/turn-order.test.ts index fd5e8f1e4..fdd0f2625 100644 --- a/src/core/turn-order.test.ts +++ b/src/core/turn-order.test.ts @@ -452,11 +452,11 @@ describe('setActivePlayers', () => { }); test('set stages to Stage.NULL', () => { - const game = { + const game: Game = { moves: { - A: (G) => G, - B: (G, ctx) => { - ctx.events.setActivePlayers({ + A: ({ G }) => G, + B: ({ G, events }) => { + events.setActivePlayers({ moveLimit: 1, currentPlayer: 'start', }); @@ -473,8 +473,8 @@ describe('setActivePlayers', () => { stages: { start: { moves: { - S: (G, ctx) => { - ctx.events.setStage(Stage.NULL); + S: ({ G, events }) => { + events.setStage(Stage.NULL); return G; }, }, diff --git a/src/plugins/plugin-log.test.ts b/src/plugins/plugin-log.test.ts index 5e9cf355f..c3f710fdf 100644 --- a/src/plugins/plugin-log.test.ts +++ b/src/plugins/plugin-log.test.ts @@ -7,17 +7,18 @@ */ import { Client } from '../client/client'; +import type { Game } from '../types'; describe('log-metadata', () => { test('It sets metadata in a move and then clears the metadata', () => { - const game = { + const game: Game = { moves: { - setMetadataMove: (G, ctx) => { - ctx.log.setMetadata({ + setMetadataMove: ({ log }) => { + log.setMetadata({ message: 'test', }); }, - doNothing: (G) => G, + doNothing: ({ G }) => G, }, }; const client = Client({ game }); From 869fe824e3da299a18473b7c52b476f28d84c04c Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 17 Jan 2021 22:37:50 +0100 Subject: [PATCH 13/36] feat(types): Improve hook return types --- src/types.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/types.ts b/src/types.ts index 9d93dfb69..7cbd163db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -191,8 +191,8 @@ export interface PhaseConfig< > { start?: boolean; next?: string; - onBegin?: (context: FnContext) => any; - onEnd?: (context: FnContext) => any; + onBegin?: (context: FnContext) => void | G; + onEnd?: (context: FnContext) => void | G; endIf?: ( context: FnContext ) => boolean | void | { next: string }; @@ -200,8 +200,8 @@ export interface PhaseConfig< turn?: TurnConfig; wrapped?: { endIf?: (state: State) => boolean | void | { next: string }; - onBegin?: (state: State) => any; - onEnd?: (state: State) => any; + onBegin?: (state: State) => void | G; + onEnd?: (state: State) => void | G; }; } @@ -235,20 +235,20 @@ export interface TurnConfig< > { activePlayers?: ActivePlayersArg; moveLimit?: number; - onBegin?: (context: FnContext) => any; - onEnd?: (context: FnContext) => any; + onBegin?: (context: FnContext) => void | G; + onEnd?: (context: FnContext) => void | G; endIf?: ( context: FnContext ) => boolean | void | { next: PlayerID }; - onMove?: (context: FnContext) => any; + onMove?: (context: FnContext) => void | G; stages?: StageMap; moves?: MoveMap; order?: TurnOrderConfig; wrapped?: { endIf?: (state: State) => boolean | void | { next: PlayerID }; - onBegin?: (state: State) => any; - onEnd?: (state: State) => any; - onMove?: (state: State) => any; + onBegin?: (state: State) => void | G; + onEnd?: (state: State) => void | G; + onMove?: (state: State) => void | G; }; } @@ -291,7 +291,7 @@ export interface Game< setActivePlayers?: boolean; }; endIf?: (context: FnContext) => any; - onEnd?: (context: FnContext) => any; + onEnd?: (context: FnContext) => void | G; playerView?: (G: G, ctx: Ctx, playerID: PlayerID) => any; plugins?: Array>; ai?: { From cb4fee4f6c7446a21405b01aeb06093bd944d81e Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 17 Jan 2021 22:42:03 +0100 Subject: [PATCH 14/36] docs(debugging): Update to use new move signature --- docs/documentation/debugging.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/documentation/debugging.md b/docs/documentation/debugging.md index 9c88dffdd..6fb60bed9 100644 --- a/docs/documentation/debugging.md +++ b/docs/documentation/debugging.md @@ -25,8 +25,8 @@ It can sometimes be helpful to surface some metadata during a move. You can do this by using the log plugin. For example, ```js -const move = (G, ctx) => { - ctx.log.setMetadata('metadata for this move'); +const move = ({ log }) => { + log.setMetadata('metadata for this move'); }; ``` From 9ace4f51fe882ccb764041f07989b5f548c34169 Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 19 Jan 2021 16:29:20 +0100 Subject: [PATCH 15/36] fix: Use new signature in benchmark --- benchmark/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/index.js b/benchmark/index.js index 1af748417..ed7efc165 100644 --- a/benchmark/index.js +++ b/benchmark/index.js @@ -14,7 +14,7 @@ import { makeMove, gameEvent } from '../src/core/action-creators'; const game = { moves: { - A: (G) => G, + A: ({ G }) => G, }, endIf: () => false, }; From ff9d5eaefe702940c274871fc20dc2825b44349f Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 16 Feb 2021 00:48:08 +0100 Subject: [PATCH 16/36] refactor(plugins): Update PluginSerializable for new move signature --- src/plugins/plugin-serializable.test.ts | 5 +++-- src/plugins/plugin-serializable.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plugins/plugin-serializable.test.ts b/src/plugins/plugin-serializable.test.ts index 85163b660..38256a2ad 100644 --- a/src/plugins/plugin-serializable.test.ts +++ b/src/plugins/plugin-serializable.test.ts @@ -1,10 +1,11 @@ import { Client } from '../client/client'; +import type { Game } from '../types'; describe('plugin-serializable', () => { - let client; + let client: ReturnType; beforeAll(() => { - const game = { + const game: Game = { moves: { serializable: () => { return { hello: 'world' }; diff --git a/src/plugins/plugin-serializable.ts b/src/plugins/plugin-serializable.ts index 279b3cfac..d4bfcb61b 100644 --- a/src/plugins/plugin-serializable.ts +++ b/src/plugins/plugin-serializable.ts @@ -1,4 +1,4 @@ -import type { Plugin, AnyFn, Ctx } from '../types'; +import type { Plugin } from '../types'; import isPlainObject from 'lodash.isplainobject'; /** @@ -37,8 +37,8 @@ function isSerializable(value: any) { const SerializablePlugin: Plugin = { name: 'plugin-serializable', - fnWrap: (move: AnyFn) => (G: unknown, ctx: Ctx, ...args: any[]) => { - const result = move(G, ctx, ...args); + fnWrap: (move) => (context, ...args) => { + const result = move(context, ...args); // Check state in non-production environments. if (process.env.NODE_ENV !== 'production' && !isSerializable(result)) { throw new Error( From 3fcf383063873fb2f9d374b0410ac0b0e4ffe515 Mon Sep 17 00:00:00 2001 From: delucis Date: Wed, 12 May 2021 22:41:24 +0200 Subject: [PATCH 17/36] test: Fix tests after merge --- src/master/master.test.ts | 9 +++++---- src/plugins/main.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/master/master.test.ts b/src/master/master.test.ts index ee65409f6..b3f968609 100644 --- a/src/master/master.test.ts +++ b/src/master/master.test.ts @@ -535,17 +535,17 @@ describe('patch', () => { moves: { A: { client: false, - move: (G, ctx: Ctx) => { - const card = G.players[ctx.playerID].cards.shift(); + move: ({ G, playerID }) => { + const card = G.players[playerID].cards.shift(); G.discardedCards.push(card); }, }, B: { client: false, ignoreStaleStateID: true, - move: (G, ctx: Ctx) => { + move: ({ G, playerID }) => { const card = G.cards.pop(); - G.players[ctx.playerID].cards.push(card); + G.players[playerID].cards.push(card); }, }, }, @@ -581,6 +581,7 @@ describe('patch', () => { expect(value.args[3]).toMatchObject([ { op: 'remove', path: '/G/players/0/cards/0' }, { op: 'add', path: '/G/discardedCards/-', value: 'card3' }, + { op: 'replace', path: '/ctx/_activePlayersNumMoves/0', value: 1 }, { op: 'replace', path: '/ctx/numMoves', value: 1 }, { op: 'replace', path: '/_stateID', value: 1 }, ]); diff --git a/src/plugins/main.test.ts b/src/plugins/main.test.ts index 4df4219be..b9b5cc729 100644 --- a/src/plugins/main.test.ts +++ b/src/plugins/main.test.ts @@ -266,7 +266,7 @@ describe('plugins are accessible in events triggered from moves', () => { }, second: { onBegin: ({ G, random, test }) => { - G.onEnd = random.Die(1); + G.onBegin = random.Die(1); G.test = test.get(); }, }, From f22b2efb3f3d3ef117fc2ed9a6ed02790db4dbc6 Mon Sep 17 00:00:00 2001 From: delucis Date: Wed, 12 May 2021 22:42:21 +0200 Subject: [PATCH 18/36] fix(client): Fix typings after merge --- src/client/react.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/react.tsx b/src/client/react.tsx index 3ee88938b..762491ea1 100644 --- a/src/client/react.tsx +++ b/src/client/react.tsx @@ -10,7 +10,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Client as RawClient } from './client'; import type { ClientOpts, ClientState, _ClientImpl } from './client'; -import type { Ctx } from '../types'; type WrappedBoardDelegates = 'matchID' | 'playerID' | 'credentials'; @@ -42,8 +41,8 @@ export type BoardProps = ClientState & type ReactClientOpts< G extends any = any, P extends BoardProps = BoardProps, - CtxWithPlugins extends Ctx = Ctx -> = Omit, WrappedBoardDelegates> & { + PluginAPIs extends Record = Record +> = Omit, WrappedBoardDelegates> & { board?: React.ComponentType

; loading?: React.ComponentType; }; @@ -70,8 +69,8 @@ type ReactClientOpts< export function Client< G extends any = any, P extends BoardProps = BoardProps, - ContextWithPlugins extends Ctx = Ctx ->(opts: ReactClientOpts) { + PluginAPIs extends Record = Record +>(opts: ReactClientOpts) { let { game, numPlayers, loading, board, multiplayer, enhancer, debug } = opts; // Component that is displayed before the client has synced From 0946d4598c0f5e5496b6b4f8d2bc573033b38af2 Mon Sep 17 00:00:00 2001 From: delucis Date: Thu, 3 Jun 2021 19:12:33 +0200 Subject: [PATCH 19/36] fix: Clean up after merge dce2fac --- src/master/master.test.ts | 4 ++-- src/types.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/master/master.test.ts b/src/master/master.test.ts index 7d6278139..621fa6c65 100644 --- a/src/master/master.test.ts +++ b/src/master/master.test.ts @@ -177,9 +177,9 @@ describe('update', () => { const sendAll = jest.fn((arg) => { sendAllReturn = arg; }); - const game = { + const game: Game = { moves: { - A: (G) => G, + A: ({ G }) => G, }, }; let db; diff --git a/src/types.ts b/src/types.ts index 0406e4eb7..c0ffc27ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,10 +46,7 @@ export type ActionResult = any; // "Private" state that may include garbage that should be stripped before // being handed back to a client. -export interface TransientState< - G extends any = any, - CtxWithPlugins extends Ctx = Ctx -> extends State { +export interface TransientState extends State { transients?: TransientMetadata; } From c230b18ca78f370ba644320b4e6b195a0deb8932 Mon Sep 17 00:00:00 2001 From: delucis Date: Thu, 3 Jun 2021 23:22:19 +0200 Subject: [PATCH 20/36] feat: Use new signature for playerView functions --- docs/documentation/api/Game.md | 2 +- docs/documentation/secret-state.md | 6 +++--- src/client/client.test.ts | 2 +- src/client/client.ts | 6 +++++- src/client/react-native.test.js | 2 +- src/client/react.test.tsx | 2 +- src/core/game.ts | 2 +- src/core/player-view.test.ts | 8 ++++---- src/core/player-view.ts | 6 +++--- src/master/master.test.ts | 2 +- src/master/master.ts | 10 +++++++--- src/server/transport/socketio-simultaneous.test.ts | 2 +- src/types.ts | 2 +- 13 files changed, 30 insertions(+), 22 deletions(-) diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index f1e811ba3..fbf84cdce 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -41,7 +41,7 @@ // Everything below is OPTIONAL. // Function that allows you to tailor the game state to a specific player. - playerView: (G, ctx, playerID) => G, + playerView: ({ G, ctx, playerID }) => G, // The seed used by the pseudo-random number generator. seed: 'random-string', diff --git a/docs/documentation/secret-state.md b/docs/documentation/secret-state.md index f2372582b..d62b14122 100644 --- a/docs/documentation/secret-state.md +++ b/docs/documentation/secret-state.md @@ -10,15 +10,15 @@ provides support for not even sending such data to the client. In order to do this, use the `playerView` setting in -the game object. It accepts a function that -takes `G`, `ctx`, `playerID` and returns a version of `G` +the game object. It accepts a function that receives an +object containing `G`, `ctx`, and `playerID`, and returns a version of `G` that is stripped of any information that should be hidden from that specific player. ```js const game = { // ... - playerView: (G, ctx, playerID) => { + playerView: ({ G, ctx, playerID }) => { return StripSecrets(G, playerID); }, }; diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 51ba88573..c81e89c68 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -324,7 +324,7 @@ describe('strip secret only on server', () => { spec = { game: { setup: () => initial, - playerView: (G) => { + playerView: ({ G }) => { const r = { ...G }; r.sum = r.secret.reduce((prev, curr) => { return prev + curr; diff --git a/src/client/client.ts b/src/client/client.ts index 88eb52a7e..d2bba6ab2 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -436,7 +436,11 @@ export class _ClientImpl { if (!this.multiplayer) { state = { ...state, - G: this.game.playerView(state.G, state.ctx, this.playerID), + G: this.game.playerView({ + G: state.G, + ctx: state.ctx, + playerID: this.playerID, + }), plugins: PlayerView(state, this), }; } diff --git a/src/client/react-native.test.js b/src/client/react-native.test.js index 463a4e668..65111243f 100644 --- a/src/client/react-native.test.js +++ b/src/client/react-native.test.js @@ -150,7 +150,7 @@ test('local playerView', () => { const Board = Client({ game: { setup: () => ({ secret: true }), - playerView: (G, ctx, playerID) => ({ stripped: playerID }), + playerView: ({ playerID }) => ({ stripped: playerID }), }, board: TestBoard, numPlayers: 2, diff --git a/src/client/react.test.tsx b/src/client/react.test.tsx index 4692a9a4c..3e2be03be 100644 --- a/src/client/react.test.tsx +++ b/src/client/react.test.tsx @@ -177,7 +177,7 @@ test('local playerView', () => { const Board = Client({ game: { setup: () => ({ secret: true }), - playerView: (G, ctx, playerID) => ({ stripped: playerID }), + playerView: ({ playerID }) => ({ stripped: playerID }), }, board: TestBoard, numPlayers: 2, diff --git a/src/core/game.ts b/src/core/game.ts index 2d14dc8f5..e9edd7418 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -52,7 +52,7 @@ export function ProcessGameConfig(game: Game | ProcessedGame): ProcessedGame { if (game.disableUndo === undefined) game.disableUndo = false; if (game.setup === undefined) game.setup = () => ({}); if (game.moves === undefined) game.moves = {}; - if (game.playerView === undefined) game.playerView = (G) => G; + if (game.playerView === undefined) game.playerView = ({ G }) => G; if (game.plugins === undefined) game.plugins = []; game.plugins.forEach((plugin) => { diff --git a/src/core/player-view.test.ts b/src/core/player-view.test.ts index 52af774c0..86aa1019e 100644 --- a/src/core/player-view.test.ts +++ b/src/core/player-view.test.ts @@ -11,13 +11,13 @@ import type { Ctx } from '../types'; test('no change', () => { const G = { test: true }; - const newG = PlayerView.STRIP_SECRETS(G, {} as Ctx, '0'); + const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '0' }); expect(newG).toEqual(G); }); test('secret', () => { const G = { secret: true }; - const newG = PlayerView.STRIP_SECRETS(G, {} as Ctx, '0'); + const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '0' }); expect(newG).toEqual({}); }); @@ -30,12 +30,12 @@ test('players', () => { }; { - const newG = PlayerView.STRIP_SECRETS(G, {} as Ctx, '0'); + const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '0' }); expect(newG.players).toEqual({ '0': {} }); } { - const newG = PlayerView.STRIP_SECRETS(G, {} as Ctx, '1'); + const newG = PlayerView.STRIP_SECRETS({ G, ctx: {} as Ctx, playerID: '1' }); expect(newG.players).toEqual({ '1': {} }); } }); diff --git a/src/core/player-view.ts b/src/core/player-view.ts index 4fdf563e6..c76cc5d54 100644 --- a/src/core/player-view.ts +++ b/src/core/player-view.ts @@ -6,12 +6,12 @@ * https://opensource.org/licenses/MIT. */ -import type { Ctx, PlayerID } from '../types'; +import type { Game } from '../types'; /** * PlayerView reducers. */ -export const PlayerView = { +export const PlayerView: { STRIP_SECRETS: Game['playerView'] } = { /** * STRIP_SECRETS * @@ -19,7 +19,7 @@ export const PlayerView = { * removes all the keys in `players`, except for the one * corresponding to the current playerID. */ - STRIP_SECRETS: (G: any, ctx: Ctx, playerID: PlayerID) => { + STRIP_SECRETS: ({ G, playerID }) => { const r = { ...G }; if (r.secret !== undefined) { diff --git a/src/master/master.test.ts b/src/master/master.test.ts index 621fa6c65..183ae64cb 100644 --- a/src/master/master.test.ts +++ b/src/master/master.test.ts @@ -844,7 +844,7 @@ describe('playerView', () => { sendAllReturn = arg; }); const game: Game = { - playerView: (G, _ctx, player) => { + playerView: ({ G, playerID: player }) => { return { ...G, player }; }, }; diff --git a/src/master/master.ts b/src/master/master.ts index ddb5960f2..3b973d721 100644 --- a/src/master/master.ts +++ b/src/master/master.ts @@ -281,7 +281,7 @@ export class Master { const log = redactLog(state.deltalog, playerID); const filteredState = { ...state, - G: this.game.playerView(state.G, state.ctx, playerID), + G: this.game.playerView({ G: state.G, ctx: state.ctx, playerID }), plugins: PlayerView(state, { playerID, game: this.game }), deltalog: undefined, _undo: [], @@ -292,7 +292,11 @@ export class Master { const newStateID = state._stateID; const prevFilteredState = { ...prevState, - G: this.game.playerView(prevState.G, prevState.ctx, playerID), + G: this.game.playerView({ + G: prevState.G, + ctx: prevState.ctx, + playerID, + }), plugins: PlayerView(prevState, { playerID, game: this.game }), deltalog: undefined, _undo: [], @@ -405,7 +409,7 @@ export class Master { const filteredState = { ...state, - G: this.game.playerView(state.G, state.ctx, playerID), + G: this.game.playerView({ G: state.G, ctx: state.ctx, playerID }), plugins: PlayerView(state, { playerID, game: this.game }), deltalog: undefined, _undo: [], diff --git a/src/server/transport/socketio-simultaneous.test.ts b/src/server/transport/socketio-simultaneous.test.ts index 3d62c1cd8..321f7a879 100644 --- a/src/server/transport/socketio-simultaneous.test.ts +++ b/src/server/transport/socketio-simultaneous.test.ts @@ -465,7 +465,7 @@ describe('inauthentic clients', () => { 0: 'foo', 1: 'bar', }), - playerView: (G, _ctx, playerID) => ({ [playerID]: G[playerID] }), + playerView: ({ G, playerID }) => ({ [playerID]: G[playerID] }), }; let app; diff --git a/src/types.ts b/src/types.ts index c0ffc27ed..6b02b6bb9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -317,7 +317,7 @@ export interface Game< }; endIf?: (context: FnContext) => any; onEnd?: (context: FnContext) => void | G; - playerView?: (G: G, ctx: Ctx, playerID: PlayerID) => any; + playerView?: (context: { G: G; ctx: Ctx; playerID: PlayerID }) => any; plugins?: Array>; ai?: { enumerate: ( From 399ab718f276347e2215a5765e4380a658eb3c01 Mon Sep 17 00:00:00 2001 From: delucis Date: Thu, 3 Jun 2021 23:29:29 +0200 Subject: [PATCH 21/36] feat: Update `undoable` method of long-form move to use new signature --- src/core/flow.test.ts | 2 +- src/core/reducer.test.ts | 2 +- src/core/reducer.ts | 2 +- src/types.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index a54dbdafe..956fc3cbb 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -867,7 +867,7 @@ test('undoable moves', () => { moves: { A: { move: () => ({ A: true }), - undoable: (_, ctx) => { + undoable: ({ ctx }) => { return ctx.phase == 'A'; }, }, diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index f132d052a..571e22456 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -645,7 +645,7 @@ describe('undo / redo with stages', () => { events.setStage('A'); return { ...G, moveAisReversible, A: true }; }, - undoable: (G) => G.moveAisReversible > 0, + undoable: ({ G }) => G.moveAisReversible > 0, }, }, }, diff --git a/src/core/reducer.ts b/src/core/reducer.ts index beba12b13..210c91de7 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -60,7 +60,7 @@ const CanUndoMove = (G: any, ctx: Ctx, move: Move): boolean => { } if (IsFunction(move.undoable)) { - return move.undoable(G, ctx); + return move.undoable({ G, ctx }); } return move.undoable; diff --git a/src/types.ts b/src/types.ts index 6b02b6bb9..cb3243085 100644 --- a/src/types.ts +++ b/src/types.ts @@ -193,7 +193,7 @@ export interface LongFormMove< redact?: boolean; noLimit?: boolean; client?: boolean; - undoable?: boolean | ((G: G, ctx: Ctx) => boolean); + undoable?: boolean | ((context: { G: G; ctx: Ctx }) => boolean); ignoreStaleStateID?: boolean; } From e1a348c9121d4e696f693daae0669229a2e09cf8 Mon Sep 17 00:00:00 2001 From: delucis Date: Wed, 28 Jul 2021 00:38:59 +0200 Subject: [PATCH 22/36] fix: Cleanup after merge b077b3e --- src/core/reducer.test.ts | 2 +- src/master/filter-player-view.test.ts | 24 ++++++++++++------------ src/master/filter-player-view.ts | 2 +- src/plugins/main.test.ts | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index b4574684b..c49e38104 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -314,7 +314,7 @@ describe('Plugin Invalid Action API', () => { }, ], moves: { - setValue: (G, _ctx, arg) => { + setValue: ({ G }, arg: number) => { G.value = arg; }, }, diff --git a/src/master/filter-player-view.test.ts b/src/master/filter-player-view.test.ts index c7b9fb1af..ab65f8b14 100644 --- a/src/master/filter-player-view.test.ts +++ b/src/master/filter-player-view.test.ts @@ -4,7 +4,7 @@ import { Master } from './master'; import { InMemory } from '../server/db/inmemory'; import { PlayerView } from '../core/player-view'; import { INVALID_MOVE } from '../core/constants'; -import type { Ctx, SyncInfo } from '../types'; +import type { Game, SyncInfo } from '../types'; function TransportAPI(send = jest.fn(), sendAll = jest.fn()) { return { send, sendAll }; @@ -19,9 +19,9 @@ function validateNotTransientState(state: any) { describe('playerView - update', () => { const send = jest.fn(); const sendAll = jest.fn(); - const game = { - playerView: (G, ctx, player) => { - return { ...G, player }; + const game: Game = { + playerView: ({ G, playerID }) => { + return { ...G, player: playerID }; }, }; const master = new Master(game, new InMemory(), TransportAPI(send, sendAll)); @@ -66,7 +66,7 @@ describe('playerView - patch', () => { const send = jest.fn(); const sendAll = jest.fn(); const db = new InMemory(); - const game = { + const game: Game = { seed: 0, deltaState: true, setup: () => { @@ -94,17 +94,17 @@ describe('playerView - patch', () => { }, A: { client: false, - move: (G, ctx: Ctx) => { - const card = G.players[ctx.playerID].cards.shift(); + move: ({ G, playerID }) => { + const card = G.players[playerID].cards.shift(); G.discardedCards.push(card); }, }, B: { client: false, ignoreStaleStateID: true, - move: (G, ctx: Ctx) => { + move: ({ G, playerID }) => { const card = G.cards.pop(); - G.players[ctx.playerID].cards.push(card); + G.players[playerID].cards.push(card); }, }, }, @@ -256,11 +256,11 @@ describe('redactLog', () => { }); test('make sure filter player view redacts the log', async () => { - const game = { + const game: Game = { moves: { - A: (G) => G, + A: ({ G }) => G, B: { - move: (G) => G, + move: ({ G }) => G, redact: true, }, }, diff --git a/src/master/filter-player-view.ts b/src/master/filter-player-view.ts index 22cf5499c..f42596636 100644 --- a/src/master/filter-player-view.ts +++ b/src/master/filter-player-view.ts @@ -9,7 +9,7 @@ const applyPlayerView = ( state: State ): State => ({ ...state, - G: game.playerView(state.G, state.ctx, playerID), + G: game.playerView({ G: state.G, ctx: state.ctx, playerID }), plugins: PlayerView(state, { playerID, game }), deltalog: undefined, _undo: [], diff --git a/src/plugins/main.test.ts b/src/plugins/main.test.ts index acfd301c5..e84942809 100644 --- a/src/plugins/main.test.ts +++ b/src/plugins/main.test.ts @@ -188,11 +188,11 @@ describe('isInvalid method', () => { }, ], moves: { - good: (_, ctx) => { - ctx.test.set('good', 'nice'); + good: ({ test }) => { + test.set('good', 'nice'); }, - bad: (_, ctx) => { - ctx.test.set('bad', 'not ok'); + bad: ({ test }) => { + test.set('bad', 'not ok'); }, }, }; From 8bc458e5dd3dc05a6b4e5cc0f7e90ea5c7cc9b0f Mon Sep 17 00:00:00 2001 From: delucis Date: Fri, 6 Aug 2021 19:08:51 +0200 Subject: [PATCH 23/36] refactor: Clean up after merge --- src/core/flow.test.ts | 34 +++++++++++++++++----------------- src/plugins/plugin-events.ts | 9 ++++----- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index d7266ab81..e30eeccb5 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -489,11 +489,11 @@ describe('stages', () => { stages: { A: { moves: { - leaveStage: (G, ctx) => void ctx.events.endStage(), + leaveStage: ({ events }) => void events.endStage(), }, }, }, - endIf: (G, ctx) => ctx.activePlayers === null, + endIf: ({ ctx }) => ctx.activePlayers === null, }, }, }); @@ -524,7 +524,7 @@ describe('stages', () => { currentPlayer: 'A', moveLimit: 1, }, - endIf: (G, ctx) => ctx.activePlayers === null, + endIf: ({ ctx }) => ctx.activePlayers === null, stages: { A: { moves: { @@ -1170,10 +1170,10 @@ describe('events in hooks', () => { }; describe('endTurn', () => { - const conditionalEndTurn = (G, ctx) => { + const conditionalEndTurn = ({ G, events }) => { if (!G.shouldEnd) return; G.shouldEnd = false; - ctx.events.endTurn(); + events.endTurn(); }; test('can end turn from turn.onBegin', () => { @@ -1290,10 +1290,10 @@ describe('events in hooks', () => { }); describe('endPhase', () => { - const conditionalEndPhase = (G, ctx) => { + const conditionalEndPhase = ({ G, events }) => { if (!G.shouldEnd) return; G.shouldEnd = false; - ctx.events.endPhase(); + events.endPhase(); }; test('can end phase from turn.onBegin', () => { @@ -1466,7 +1466,7 @@ test('events in hooks triggered by moves should be processed', () => { }); test('stage events should not be processed out of turn', () => { - const game = { + const game: Game = { phases: { A: { start: true, @@ -1477,15 +1477,15 @@ test('stage events should not be processed out of turn', () => { stages: { A1: { moves: { - endStage: (G, ctx) => { + endStage: ({ G, events }) => { G.endStage = true; - ctx.events.endStage(); + events.endStage(); }, }, }, }, }, - endIf: (G) => G.endStage, + endIf: ({ G }) => G.endStage, next: 'B', }, B: { @@ -1531,16 +1531,16 @@ describe('hook execution order', () => { game: { moves: { move: () => void calls.push('move'), - setStage: (G, ctx) => { - ctx.events.setStage('A'); + setStage: ({ events }) => { + events.setStage('A'); calls.push('moves.setStage'); }, - endStage: (G, ctx) => { - ctx.events.endStage(); + endStage: ({ events }) => { + events.endStage(); calls.push('moves.endStage'); }, - setActivePlayers: (G, ctx) => { - ctx.events.setActivePlayers({ all: 'A', moveLimit: 1 }); + setActivePlayers: ({ events }) => { + events.setActivePlayers({ all: 'A', moveLimit: 1 }); calls.push('moves.setActivePlayers'); }, }, diff --git a/src/plugins/plugin-events.ts b/src/plugins/plugin-events.ts index a81efea92..bf16c6c8d 100644 --- a/src/plugins/plugin-events.ts +++ b/src/plugins/plugin-events.ts @@ -24,11 +24,10 @@ const EventsPlugin: Plugin = { // endings to dispatch the current turn and phase correctly. fnWrap: (fn) => - (G, ctx, ...args) => { - const api = ctx.events as PrivateEventsAPI; - if (api) api._obj.updateTurnContext(ctx); - G = fn(G, ctx, ...args); - return G; + (context, ...args) => { + const api = context.events as PrivateEventsAPI; + if (api) api._obj.updateTurnContext(context.ctx); + return fn(context, ...args); }, dangerouslyFlushRawState: ({ state, api }) => api._obj.update(state), From e58dd33cea6f8477e5035982d12b650db706f575 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 29 Aug 2021 11:51:30 +0200 Subject: [PATCH 24/36] fix: Cleanup after merge --- src/client/client.ts | 5 ++++- src/core/player-view.test.ts | 6 +++++- src/plugins/main.test.ts | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/client/client.ts b/src/client/client.ts index a8666623f..da9c5f293 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -520,6 +520,9 @@ export class _ClientImpl { * A JS object that provides an API to interact with the * game by dispatching moves and events. */ -export function Client(opts: ClientOpts) { +export function Client< + G extends any = any, + PluginAPIs extends Record = Record +>(opts: ClientOpts) { return new _ClientImpl(opts); } diff --git a/src/core/player-view.test.ts b/src/core/player-view.test.ts index 682304011..4d97e5057 100644 --- a/src/core/player-view.test.ts +++ b/src/core/player-view.test.ts @@ -40,7 +40,11 @@ describe('players', () => { }); test('playerID: null', () => { - const newG = PlayerView.STRIP_SECRETS(G, {} as Ctx, null); + const newG = PlayerView.STRIP_SECRETS({ + G, + ctx: {} as Ctx, + playerID: null, + }); expect(newG.players).toEqual({}); }); }); diff --git a/src/plugins/main.test.ts b/src/plugins/main.test.ts index 694b7e90c..218c2ef94 100644 --- a/src/plugins/main.test.ts +++ b/src/plugins/main.test.ts @@ -378,13 +378,13 @@ describe('plugins can use events in fnWrap', () => { name: 'test', fnWrap: (fn, type) => - (G, ctx, ...args) => { - G = fn(G, ctx, ...args); + (context, ...args) => { + const G = fn(context, ...args); if (G.endTurn && type === GameMethod.MOVE) { - ctx.events.endTurn(); + context.events.endTurn(); } if (G.endGame) { - ctx.events.endGame(G.endGame); + context.events.endGame(G.endGame); } return G; }, From f11596e87400b40c18729ac49114eaa649dcf282 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 29 Aug 2021 11:55:14 +0200 Subject: [PATCH 25/36] docs(Game): Remove `events` plugin in `endIf` hook docs --- docs/documentation/api/Game.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index 3fedf00be..08d422714 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -60,11 +60,11 @@ // Ends the turn if this returns true. // Returning { next }, sets next playerID. - endIf: ({ G, ctx, events, random, ...plugins }) => ( + endIf: ({ G, ctx, random, ...plugins }) => ( true | { next: '0' } ), - // Called at the end of each move. + // Called after each move. onMove: ({ G, ctx, events, random, ...plugins }) => G, // Ends the turn automatically after a number of moves. @@ -97,7 +97,7 @@ onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the phase if this returns true. - endIf: ({ G, ctx, events, random, ...plugins }) => true, + endIf: ({ G, ctx, random, ...plugins }) => true, // Overrides `moves` for the duration of this phase. moves: { ... }, @@ -123,7 +123,7 @@ // Ends the game if this returns anything. // The return value is available in `ctx.gameover`. - endIf: ({ G, ctx, events, random, ...plugins }) => obj, + endIf: ({ G, ctx, random, ...plugins }) => obj, // Called at the end of the game. // `ctx.gameover` is available at this point. From 094b364b800b5e1cb44cf410b7f826879ece7c30 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 29 Aug 2021 13:59:51 +0200 Subject: [PATCH 26/36] test(reducer): Improve test --- src/core/reducer.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index c49e38104..027a67fe7 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -444,8 +444,8 @@ describe('undo / redo', () => { let state = reducer(initialState, makeMove('move', 'A', '0')); state = reducer(state, makeMove('move', 'B', '0')); expect(state.G).toMatchObject({ A: true, B: true }); - expect((state._undo[1].ctx as any).events).toBeUndefined(); - expect((state._undo[1].ctx as any).random).toBeUndefined(); + expect(state._undo[1].ctx).not.toHaveProperty('events'); + expect(state._undo[1].ctx).not.toHaveProperty('random'); }); test('undo restores previous state after move', () => { From ce31e91a014cac8f9a36b2adf9e947a63e67fef7 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 17 Oct 2021 15:51:32 +0100 Subject: [PATCH 27/36] fix(flow): Pass `playerID` to wrapped `onMove` hook --- src/core/flow.test.ts | 2 +- src/core/flow.ts | 8 +++----- src/types.ts | 6 ++++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index 2ab819c97..ce0f5f40d 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -243,7 +243,7 @@ describe('turn', () => { test('ctx with playerID', () => { const playerID = 'playerID'; const flow = Flow({ - turn: { onMove: (G, ctx) => ({ playerID: ctx.playerID }) }, + turn: { onMove: ({ playerID }) => ({ playerID }) }, }); let state = { G: {}, ctx: flow.ctx(2) } as State; state = flow.processMove( diff --git a/src/core/flow.ts b/src/core/flow.ts index 7d482f31f..02cfd279c 100644 --- a/src/core/flow.ts +++ b/src/core/flow.ts @@ -84,12 +84,13 @@ export function Flow({ hookType: GameMethod ) => { const withPlugins = plugin.FnWrap(hook, hookType, plugins); - return (state: State) => { + return (state: State & { playerID?: PlayerID }) => { const pluginAPIs = plugin.GetAPIs(state); return withPlugins({ ...pluginAPIs, G: state.G, ctx: state.ctx, + playerID: state.playerID, }); }; }; @@ -743,10 +744,7 @@ export function Flow({ } const phaseConfig = GetPhase(ctx); - const G = phaseConfig.turn.wrapped.onMove({ - ...state, - ctx: { ...ctx, playerID }, - }); + const G = phaseConfig.turn.wrapped.onMove({ ...state, playerID }); state = { ...state, G }; const events = [{ fn: OnMove }]; diff --git a/src/types.ts b/src/types.ts index 2859a76de..b6e2c60da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -286,14 +286,16 @@ export interface TurnConfig< endIf?: ( context: FnContext ) => boolean | void | { next: PlayerID }; - onMove?: (context: FnContext) => void | G; + onMove?: ( + context: FnContext & { playerID: PlayerID } + ) => void | G; stages?: StageMap; order?: TurnOrderConfig; wrapped?: { endIf?: (state: State) => boolean | void | { next: PlayerID }; onBegin?: (state: State) => void | G; onEnd?: (state: State) => void | G; - onMove?: (state: State) => void | G; + onMove?: (state: State & { playerID: PlayerID }) => void | G; }; } From 317a5d34b7f4e48428d82fe54a965e8574101d64 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 23 Jan 2022 16:44:31 +0100 Subject: [PATCH 28/36] fix(mock-random): update changes in 636ce8f6 for new move signature --- docs/documentation/testing.md | 4 ++-- src/testing/mock-random.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/documentation/testing.md b/docs/documentation/testing.md index ab93fd217..9d6396e0d 100644 --- a/docs/documentation/testing.md +++ b/docs/documentation/testing.md @@ -96,8 +96,8 @@ import { Client } from 'boardgame.io/client'; const Game = { moves: { - rollDice: (G, ctx) => { - G.roll = ctx.random.D6(); + rollDice: ({ G, random }) => { + G.roll = random.D6(); }, }, }; diff --git a/src/testing/mock-random.test.ts b/src/testing/mock-random.test.ts index be88767d3..e9945c4d8 100644 --- a/src/testing/mock-random.test.ts +++ b/src/testing/mock-random.test.ts @@ -16,8 +16,8 @@ test('it creates a plugin object', () => { test('it can override random API methods', () => { const game: Game<{ roll: number }> = { moves: { - roll: (G, ctx) => { - G.roll = ctx.random.D6(); + roll: ({ G, random }) => { + G.roll = random.D6(); }, }, plugins: [MockRandom({ D6: () => 1 })], @@ -31,8 +31,8 @@ test('it can override random API methods', () => { test('it can use non-overridden API methods', () => { const game: Game<{ roll: number }> = { moves: { - roll: (G, ctx) => { - G.roll = ctx.random.D6(); + roll: ({ G, random }) => { + G.roll = random.D6(); }, }, plugins: [MockRandom({ D10: () => 1 })], From c57983ac0b6a8aabfdb420b2a34f005b32c648ba Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 23 Jan 2022 16:45:29 +0100 Subject: [PATCH 29/36] docs(Game): update changes in d091a118 for new move signature --- docs/documentation/api/Game.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index 82baab6a1..7ce6fe99c 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -27,7 +27,7 @@ // The move function. move: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, // Prevents undoing the move. - // Can also be a function: (G, ctx) => true/false + // Can also be a function: ({ G, ctx }) => true/false undoable: false, // Prevents the move arguments from showing up in the log. redact: true, From 98941556ed36db3e36a743f1b9706229b0fffa56 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 23 Jan 2022 18:28:07 +0100 Subject: [PATCH 30/36] feat(types): loosen typing of move arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I had the idea of typing move arguments using ts-toolbelt’s `Misc.JSON.Value` type which represents the various possible results of parsing a JSON object because that is what is permitted for serialization over the wire. This makes it harder to type moves though because you can no longer do `({ G }, id: number) => {}`. On the one hand this could be nice as it reminds the move to narrow the type as it comes potentially from an untrusted source (similar to if typing with `unknown`) but that doesn’t seem to quite justify the additional frustrating overhead when writing moves. --- src/types.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 8249ad884..891012540 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Object, Misc } from 'ts-toolbelt'; +import type { Object } from 'ts-toolbelt'; import type Koa from 'koa'; import type { Store as ReduxStore } from 'redux'; import type * as ActionCreators from './core/action-creators'; @@ -175,9 +175,9 @@ export interface Plugin< data: Data; }) => State; fnWrap?: ( - moveOrHook: (context: FnContext, ...args: SerializableAny[]) => any, + moveOrHook: (context: FnContext, ...args: any[]) => any, methodType: GameMethod - ) => (context: FnContext, ...args: SerializableAny[]) => any; + ) => (context: FnContext, ...args: any[]) => any; playerView?: (context: { G: G; ctx: Ctx; @@ -196,13 +196,12 @@ export type FnContext< ctx: Ctx; }; -type SerializableAny = Misc.JSON.Value; export type MoveFn< G extends any = any, PluginAPIs extends Record = Record > = ( context: FnContext & { playerID: PlayerID }, - ...args: SerializableAny[] + ...args: any[] ) => void | G | typeof INVALID_MOVE; export interface LongFormMove< From 6b32f1085c40542ee9d53d33fd421a4353ee3c20 Mon Sep 17 00:00:00 2001 From: delucis Date: Sun, 23 Jan 2022 19:08:14 +0100 Subject: [PATCH 31/36] test: test the signatures different game methods are called with --- src/core/flow.test.ts | 165 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index ce0f5f40d..d65f5556b 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -9,8 +9,9 @@ import { makeMove, gameEvent } from './action-creators'; import { Client } from '../client/client'; import { Flow } from './flow'; +import { TurnOrder } from './turn-order'; import { error } from '../core/logger'; -import type { Ctx, State, Game, MoveFn } from '../types'; +import type { Ctx, State, Game, PlayerID, MoveFn } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -2268,3 +2269,165 @@ describe('hook execution order', () => { expect(calls).toEqual(['phaseB.onEnd', 'game.onEnd']); }); }); + +describe('game function signatures', () => { + const moveA = jest.fn(); + let game: Game; + + let client: ReturnType; + + // Helpers to check the objects game functions are called with. + const expectCtx = expect.objectContaining({ numPlayers: 2 }); + const expectEvents = expect.objectContaining({ + endTurn: expect.any(Function), + }); + const expectRandom = expect.objectContaining({ + D6: expect.any(Function), + }); + const FnContext = ({ + playerID, + G = 'G', + }: { playerID?: PlayerID; G?: any } = {}) => { + const context: any = { + G, + ctx: expectCtx, + events: expectEvents, + random: expectRandom, + testPluginAPI: { foo: 'bar' }, + }; + if (playerID !== undefined) context.playerID = playerID; + return expect.objectContaining(context); + }; + + beforeEach(() => { + game = { + setup: jest.fn(() => 'G'), + + plugins: [ + { + name: 'testPluginAPI', + api: () => ({ foo: 'bar' }), + }, + ], + + onEnd: jest.fn(), + endIf: jest.fn(({ G }) => G == 'gameover'), + + moves: { + A: (...args) => moveA(...args), + endGame: () => 'gameover', + }, + + turn: { + order: { + playOrder: jest.fn(({ ctx }) => + [...Array.from({ length: ctx.numPlayers })].map((_, i) => i + '') + ), + first: jest.fn(TurnOrder.DEFAULT.first), + next: jest.fn(TurnOrder.DEFAULT.next), + }, + onBegin: jest.fn(), + onMove: jest.fn(), + onEnd: jest.fn(), + endIf: jest.fn(), + }, + + phases: { + A: { + onBegin: jest.fn(), + onEnd: jest.fn(), + endIf: jest.fn(), + }, + }, + + events: { + endPhase: true, + }, + }; + + client = Client({ game, playerID: '0' }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('game.setup', () => { + expect(game.setup).lastCalledWith( + // setup context object + expect.objectContaining({ + ctx: expectCtx, + events: expectEvents, + random: expectRandom, + }), + // setupData + undefined + ); + }); + + test('game.onEnd', () => { + client.events.endGame(); + expect(game.onEnd).lastCalledWith(FnContext()); + }); + + test('game.endIf', () => { + client.moves.endGame(); + expect(game.endIf).lastCalledWith(FnContext({ G: 'gameover' })); + }); + + test('game.turn.order.playOrder', () => { + expect(game.turn.order.playOrder).lastCalledWith(FnContext()); + }); + + test('game.turn.order.first', () => { + expect(game.turn.order.first).lastCalledWith(FnContext()); + }); + + test('game.turn.order.next', () => { + client.events.endTurn(); + expect(game.turn.order.next).lastCalledWith(FnContext()); + }); + + test('game.turn.onBegin', () => { + expect(game.turn.onBegin).lastCalledWith(FnContext()); + }); + + test('game.turn.onMove', () => { + client.moves.A(); + expect(game.turn.onMove).lastCalledWith(FnContext()); + }); + + test('game.turn.onEnd', () => { + client.events.endTurn(); + expect(game.turn.onEnd).lastCalledWith(FnContext()); + }); + + test('game.turn.endIf', () => { + client.moves.A(); + expect(game.turn.endIf).lastCalledWith(FnContext()); + }); + + test('move', () => { + client.moves.A('arg'); + expect(moveA).lastCalledWith(FnContext({ playerID: '0' }), 'arg'); + client.moves.A(2, 'args'); + expect(moveA).lastCalledWith(FnContext({ playerID: '0' }), 2, 'args'); + }); + + test('game.phases.phase.onBegin', () => { + client.events.setPhase('A'); + expect(game.phases.A.onBegin).lastCalledWith(FnContext()); + }); + + test('game.phases.phase.onEnd', () => { + client.events.setPhase('A'); + client.updatePlayerID('1'); + client.events.endPhase(); + expect(game.phases.A.onEnd).lastCalledWith(FnContext()); + }); + + test('game.phases.phase.endIf', () => { + client.events.setPhase('A'); + expect(game.phases.A.endIf).lastCalledWith(FnContext()); + }); +}); From 12170f556046c31b9d41da758f88b26a3aa0388f Mon Sep 17 00:00:00 2001 From: delucis Date: Tue, 25 Jan 2022 22:30:28 +0100 Subject: [PATCH 32/36] docs(typescript): use new signature in move example --- docs/documentation/typescript.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/documentation/typescript.md b/docs/documentation/typescript.md index 031f5859e..b5a2a38b9 100644 --- a/docs/documentation/typescript.md +++ b/docs/documentation/typescript.md @@ -12,7 +12,7 @@ export interface MyGameState { // aka 'G', your game's state } -const move: Move = (G, ctx) => {}; +const move: Move = ({ G, ctx }) => {}; export const MyGame: Game = { // ... From bff9fe0ff02ff598cbaca8e5aa18993b27d1a3c5 Mon Sep 17 00:00:00 2001 From: vdfdev Date: Mon, 10 Oct 2022 21:34:26 -0700 Subject: [PATCH 33/36] Revert "Merge branch 'main' into delucis/feat/new-move-signature" This reverts commit 7de8c67aac8c9ea279c32e90103e06ec10523793, reversing changes made to decf26dc6beddf3976815bde510e5248f8866fb3. --- docs/documentation/CHANGELOG.md | 7 --- package.json | 2 +- src/core/reducer.test.ts | 56 --------------------- src/core/reducer.ts | 2 - src/master/filter-player-view.test.ts | 71 --------------------------- 5 files changed, 1 insertion(+), 137 deletions(-) diff --git a/docs/documentation/CHANGELOG.md b/docs/documentation/CHANGELOG.md index 5dad93be2..8665ebacb 100644 --- a/docs/documentation/CHANGELOG.md +++ b/docs/documentation/CHANGELOG.md @@ -1,10 +1,3 @@ -### 0.49.13 - -### Features - -* [[aa99a9c](https://github.com/boardgameio/boardgame.io/commit/aa99a9cce28012cb747fa6db8b3f8ad73c28be0a) feat: Conditional log redacting in long form move ([#1089](https://github.com/boardgameio/boardgame.io/pull/1089)) -* [[4bf203c](https://github.com/boardgameio/boardgame.io/commit/4bf203c1c1ec42e3193935a39e4cfb54a5658627) TypeScript: AiEnumerate return type ([#1080](https://github.com/boardgameio/boardgame.io/pull/1080)) - ### v0.49.12 #### Bugfixes diff --git a/package.json b/package.json index e123621bf..58905590d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "boardgame.io", - "version": "0.49.13", + "version": "0.49.12", "description": "library for turn-based games", "repository": "https://github.com/boardgameio/boardgame.io", "scripts": { diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index 8097df0a6..027a67fe7 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -415,62 +415,6 @@ describe('Random inside setup()', () => { }); }); -describe('redact', () => { - const game: Game = { - setup: () => ({ - isASecret: false, - }), - moves: { - A: { - move: (G) => G, - redact: (G) => G.isASecret, - }, - B: (G) => { - return { ...G, isASecret: true }; - }, - }, - }; - - const reducer = CreateGameReducer({ game }); - - let state = InitializeGame({ game }); - - test('move A is not secret and is not redact', () => { - state = reducer(state, makeMove('A', ['not redact'], '0')); - expect(state.G).toMatchObject({ - isASecret: false, - }); - const [lastLogEntry] = state.deltalog.slice(-1); - expect(lastLogEntry).toMatchObject({ - action: { - payload: { - type: 'A', - args: ['not redact'], - }, - }, - redact: false, - }); - }); - - test('move A is secret and is redact', () => { - state = reducer(state, makeMove('B', ['not redact'], '0')); - state = reducer(state, makeMove('A', ['redact'], '0')); - expect(state.G).toMatchObject({ - isASecret: true, - }); - const [lastLogEntry] = state.deltalog.slice(-1); - expect(lastLogEntry).toMatchObject({ - action: { - payload: { - type: 'A', - args: ['redact'], - }, - }, - redact: true, - }); - }); -}); - describe('undo / redo', () => { const game: Game = { seed: 0, diff --git a/src/core/reducer.ts b/src/core/reducer.ts index 1be2bd007..b8cf544bc 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -120,8 +120,6 @@ function initializeDeltalog( if (typeof move === 'object' && move.redact === true) { logEntry.redact = true; - } else if (typeof move === 'object' && move.redact instanceof Function) { - logEntry.redact = move.redact(state.G, state.ctx); } return { diff --git a/src/master/filter-player-view.test.ts b/src/master/filter-player-view.test.ts index 4fc1429ec..ab65f8b14 100644 --- a/src/master/filter-player-view.test.ts +++ b/src/master/filter-player-view.test.ts @@ -308,74 +308,3 @@ describe('redactLog', () => { ]); }); }); - -test('make move args to be secret depends on G via conditional redact', async () => { - const game = { - setup: () => ({ - isASecret: false, - }), - moves: { - A: { - move: (G) => G, - redact: (G) => G.isASecret, - }, - B: (G) => { - return { ...G, isASecret: true }; - }, - }, - }; - - const send = jest.fn(); - const master = new Master(game, new InMemory(), TransportAPI(send)); - const filterPlayerView = getFilterPlayerView(game); - - const actionA0 = ActionCreators.makeMove('A', ['not redacted'], '0'); - const actionB = ActionCreators.makeMove('B', ['not redacted'], '0'); - const actionA1 = ActionCreators.makeMove('A', ['redacted'], '0'); - - // test: ping-pong two moves, then sync and check the log - await master.onSync('matchID', '0', undefined, 2); - await master.onUpdate(actionA0, 0, 'matchID', '0'); - await master.onUpdate(actionB, 1, 'matchID', '0'); - await master.onUpdate(actionA1, 2, 'matchID', '0'); - await master.onSync('matchID', '1', undefined, 2); - - const payload = send.mock.calls[send.mock.calls.length - 1][0]; - expect( - (filterPlayerView('1', payload).args[1] as SyncInfo).log - ).toMatchObject([ - { - action: { - type: 'MAKE_MOVE', - payload: { - type: 'A', - args: ['not redacted'], - playerID: '0', - }, - }, - _stateID: 0, - }, - { - action: { - type: 'MAKE_MOVE', - payload: { - type: 'B', - args: ['not redacted'], - playerID: '0', - }, - }, - _stateID: 1, - }, - { - action: { - type: 'MAKE_MOVE', - payload: { - type: 'A', - args: null, - playerID: '0', - }, - }, - _stateID: 2, - }, - ]); -}); From 9f10e129b2b81a6c3f323fabdd5d43d2e40bb126 Mon Sep 17 00:00:00 2001 From: vdfdev Date: Mon, 10 Oct 2022 22:09:50 -0700 Subject: [PATCH 34/36] Revert "Revert "Merge branch 'main' into delucis/feat/new-move-signature"" This reverts commit bff9fe0ff02ff598cbaca8e5aa18993b27d1a3c5. --- docs/documentation/CHANGELOG.md | 7 +++ package.json | 2 +- src/core/reducer.test.ts | 56 +++++++++++++++++++++ src/core/reducer.ts | 2 + src/master/filter-player-view.test.ts | 71 +++++++++++++++++++++++++++ src/types.ts | 2 +- 6 files changed, 138 insertions(+), 2 deletions(-) diff --git a/docs/documentation/CHANGELOG.md b/docs/documentation/CHANGELOG.md index 8665ebacb..5dad93be2 100644 --- a/docs/documentation/CHANGELOG.md +++ b/docs/documentation/CHANGELOG.md @@ -1,3 +1,10 @@ +### 0.49.13 + +### Features + +* [[aa99a9c](https://github.com/boardgameio/boardgame.io/commit/aa99a9cce28012cb747fa6db8b3f8ad73c28be0a) feat: Conditional log redacting in long form move ([#1089](https://github.com/boardgameio/boardgame.io/pull/1089)) +* [[4bf203c](https://github.com/boardgameio/boardgame.io/commit/4bf203c1c1ec42e3193935a39e4cfb54a5658627) TypeScript: AiEnumerate return type ([#1080](https://github.com/boardgameio/boardgame.io/pull/1080)) + ### v0.49.12 #### Bugfixes diff --git a/package.json b/package.json index 58905590d..e123621bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "boardgame.io", - "version": "0.49.12", + "version": "0.49.13", "description": "library for turn-based games", "repository": "https://github.com/boardgameio/boardgame.io", "scripts": { diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index 027a67fe7..8097df0a6 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -415,6 +415,62 @@ describe('Random inside setup()', () => { }); }); +describe('redact', () => { + const game: Game = { + setup: () => ({ + isASecret: false, + }), + moves: { + A: { + move: (G) => G, + redact: (G) => G.isASecret, + }, + B: (G) => { + return { ...G, isASecret: true }; + }, + }, + }; + + const reducer = CreateGameReducer({ game }); + + let state = InitializeGame({ game }); + + test('move A is not secret and is not redact', () => { + state = reducer(state, makeMove('A', ['not redact'], '0')); + expect(state.G).toMatchObject({ + isASecret: false, + }); + const [lastLogEntry] = state.deltalog.slice(-1); + expect(lastLogEntry).toMatchObject({ + action: { + payload: { + type: 'A', + args: ['not redact'], + }, + }, + redact: false, + }); + }); + + test('move A is secret and is redact', () => { + state = reducer(state, makeMove('B', ['not redact'], '0')); + state = reducer(state, makeMove('A', ['redact'], '0')); + expect(state.G).toMatchObject({ + isASecret: true, + }); + const [lastLogEntry] = state.deltalog.slice(-1); + expect(lastLogEntry).toMatchObject({ + action: { + payload: { + type: 'A', + args: ['redact'], + }, + }, + redact: true, + }); + }); +}); + describe('undo / redo', () => { const game: Game = { seed: 0, diff --git a/src/core/reducer.ts b/src/core/reducer.ts index b8cf544bc..1be2bd007 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -120,6 +120,8 @@ function initializeDeltalog( if (typeof move === 'object' && move.redact === true) { logEntry.redact = true; + } else if (typeof move === 'object' && move.redact instanceof Function) { + logEntry.redact = move.redact(state.G, state.ctx); } return { diff --git a/src/master/filter-player-view.test.ts b/src/master/filter-player-view.test.ts index ab65f8b14..4fc1429ec 100644 --- a/src/master/filter-player-view.test.ts +++ b/src/master/filter-player-view.test.ts @@ -308,3 +308,74 @@ describe('redactLog', () => { ]); }); }); + +test('make move args to be secret depends on G via conditional redact', async () => { + const game = { + setup: () => ({ + isASecret: false, + }), + moves: { + A: { + move: (G) => G, + redact: (G) => G.isASecret, + }, + B: (G) => { + return { ...G, isASecret: true }; + }, + }, + }; + + const send = jest.fn(); + const master = new Master(game, new InMemory(), TransportAPI(send)); + const filterPlayerView = getFilterPlayerView(game); + + const actionA0 = ActionCreators.makeMove('A', ['not redacted'], '0'); + const actionB = ActionCreators.makeMove('B', ['not redacted'], '0'); + const actionA1 = ActionCreators.makeMove('A', ['redacted'], '0'); + + // test: ping-pong two moves, then sync and check the log + await master.onSync('matchID', '0', undefined, 2); + await master.onUpdate(actionA0, 0, 'matchID', '0'); + await master.onUpdate(actionB, 1, 'matchID', '0'); + await master.onUpdate(actionA1, 2, 'matchID', '0'); + await master.onSync('matchID', '1', undefined, 2); + + const payload = send.mock.calls[send.mock.calls.length - 1][0]; + expect( + (filterPlayerView('1', payload).args[1] as SyncInfo).log + ).toMatchObject([ + { + action: { + type: 'MAKE_MOVE', + payload: { + type: 'A', + args: ['not redacted'], + playerID: '0', + }, + }, + _stateID: 0, + }, + { + action: { + type: 'MAKE_MOVE', + payload: { + type: 'B', + args: ['not redacted'], + playerID: '0', + }, + }, + _stateID: 1, + }, + { + action: { + type: 'MAKE_MOVE', + payload: { + type: 'A', + args: null, + playerID: '0', + }, + }, + _stateID: 2, + }, + ]); +}); diff --git a/src/types.ts b/src/types.ts index 865ed9c43..94d6fbf5d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,7 +209,7 @@ export interface LongFormMove< PluginAPIs extends Record = Record > { move: MoveFn; - redact?: boolean; + redact?: boolean | ((context: { G: G; ctx: Ctx }) => boolean); noLimit?: boolean; client?: boolean; undoable?: boolean | ((context: { G: G; ctx: Ctx }) => boolean); From b9a97ec761c2a9fc33dcb1b93f9fa72afad26647 Mon Sep 17 00:00:00 2001 From: vdfdev Date: Mon, 10 Oct 2022 22:19:49 -0700 Subject: [PATCH 35/36] Trying to fix merge conflicts --- src/core/reducer.test.ts | 6 +++--- src/core/reducer.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index 8097df0a6..79773c0d5 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -422,10 +422,10 @@ describe('redact', () => { }), moves: { A: { - move: (G) => G, - redact: (G) => G.isASecret, + move: ({ G }) => G, + redact: ({ G }) => G.isASecret, }, - B: (G) => { + B: ({ G }) => { return { ...G, isASecret: true }; }, }, diff --git a/src/core/reducer.ts b/src/core/reducer.ts index 1be2bd007..ff095859d 100644 --- a/src/core/reducer.ts +++ b/src/core/reducer.ts @@ -121,7 +121,7 @@ function initializeDeltalog( if (typeof move === 'object' && move.redact === true) { logEntry.redact = true; } else if (typeof move === 'object' && move.redact instanceof Function) { - logEntry.redact = move.redact(state.G, state.ctx); + logEntry.redact = move.redact({ G: state.G, ctx: state.ctx }); } return { From 6f1d3a335d4a759fdb99b44470ab983a92df55c6 Mon Sep 17 00:00:00 2001 From: vdfdev Date: Mon, 10 Oct 2022 22:25:52 -0700 Subject: [PATCH 36/36] Fixes filter-player-view.test.ts --- src/master/filter-player-view.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/master/filter-player-view.test.ts b/src/master/filter-player-view.test.ts index 4fc1429ec..773bbaa4a 100644 --- a/src/master/filter-player-view.test.ts +++ b/src/master/filter-player-view.test.ts @@ -310,16 +310,16 @@ describe('redactLog', () => { }); test('make move args to be secret depends on G via conditional redact', async () => { - const game = { + const game: Game = { setup: () => ({ isASecret: false, }), moves: { A: { - move: (G) => G, - redact: (G) => G.isASecret, + move: ({ G }) => G, + redact: ({ G }) => G.isASecret, }, - B: (G) => { + B: ({ G }) => { return { ...G, isASecret: true }; }, },