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, }; diff --git a/docs/documentation/api/Game.md b/docs/documentation/api/Game.md index cc66ad6e0..498910497 100644 --- a/docs/documentation/api/Game.md +++ b/docs/documentation/api/Game.md @@ -10,7 +10,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, @@ -20,12 +20,12 @@ moves: { // short-form move. - A: (G, ctx, ...args) => {}, + A: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, // long-form move. B: { // The move function. - move: (G, ctx, ...args) => {}, + move: ({ G, ctx, playerID, events, random, ...plugins }, ...args) => {}, // Prevents undoing the move. undoable: false, // Prevents the move arguments from showing up in the log. @@ -43,7 +43,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', @@ -53,16 +53,19 @@ order: TurnOrder.DEFAULT, // Called at the beginning of a turn. - onBegin: (G, ctx) => G, + onBegin: ({ G, ctx, events, random, ...plugins }) => G, // Called at the end of a turn. - onEnd: (G, ctx) => G, + onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the turn if this returns true. - endIf: (G, ctx) => true, + // Returning { next }, sets next playerID. + endIf: ({ G, ctx, random, ...plugins }) => ( + true | { next: '0' } + ), - // Called at the end of each move. - onMove: (G, ctx) => G, + // Called after each move. + onMove: ({ G, ctx, events, random, ...plugins }) => G, // Prevents ending the turn before a minimum number of moves. minMoves: 1, @@ -91,13 +94,13 @@ phases: { A: { // Called at the beginning of a phase. - onBegin: (G, ctx) => G, + onBegin: ({ G, ctx, events, random, ...plugins }) => G, // Called at the end of a phase. - onEnd: (G, ctx) => G, + onEnd: ({ G, ctx, events, random, ...plugins }) => G, // Ends the phase if this returns true. - endIf: (G, ctx) => true, + endIf: ({ G, ctx, random, ...plugins }) => true, // Overrides `moves` for the duration of this phase. moves: { ... }, @@ -109,7 +112,7 @@ start: true, // Set the phase to enter when this phase ends. - // Can also be a function: (G, ctx) => 'nextPhaseName' + // Can also be a function: ({ G, ctx }) => 'nextPhaseName' next: 'nextPhaseName', }, @@ -123,11 +126,11 @@ // Ends the game if this returns anything. // The return value is available in `ctx.gameover`. - endIf: (G, ctx) => obj, + endIf: ({ G, ctx, random, ...plugins }) => obj, // Called at the end of the game. // `ctx.gameover` is available at this point. - onEnd: (G, ctx) => G, + onEnd: ({ G, ctx, 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/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'); }; ``` diff --git a/docs/documentation/events.md b/docs/documentation/events.md index 8e85d8ded..2b1084c62 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 7cfa80151..ce9b1dc85 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: { minMoves: 1, maxMoves: 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: { minMoves: 1, maxMoves: 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: { ... }, }, @@ -188,7 +188,7 @@ state at the end of the phase: ```js phases: { phaseA: { - next: (G, ctx) => { + next: ({ G }) => { return G.condition ? 'phaseC' : 'phaseB'; }, }, diff --git a/docs/documentation/plugins.md b/docs/documentation/plugins.md index 3d03acdb2..a2f912a05 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; }, @@ -70,11 +70,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 a172608a3..b2988eefb 100644 --- a/docs/documentation/secret-state.md +++ b/docs/documentation/secret-state.md @@ -10,18 +10,18 @@ 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 = { - // ... // `playerID` could also be null or undefined for spectators. - playerView: (G, ctx, playerID) => { + playerView: ({ G, ctx, playerID }) => { return StripSecrets(G, playerID); }, + // ... }; ``` @@ -79,8 +79,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 ca49df916..d86b98bca 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: @@ -134,8 +144,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', minMoves: 1, maxMoves: 1 }); +function PlayCard({ events }) { + events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 }); } const game = { @@ -218,10 +228,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 72a7b4365..b597afa9c 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 3cb93d011..18b858574 100644 --- a/docs/documentation/tutorial.md +++ b/docs/documentation/tutorial.md @@ -128,11 +128,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 @@ -147,15 +148,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. @@ -270,11 +271,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; } ``` @@ -346,7 +347,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 }) => {}, }, }; ``` diff --git a/examples/react-native/game.js b/examples/react-native/game.js index 787d995ae..a0babbf51 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: { minMoves: 1, maxMoves: 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 d2c5a2b39..eb9782534 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: { minMoves: 1, maxMoves: 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 a72eece5e..baff9a30b 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 19cd16209..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 bbf4fa1c7..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 a442e95c8..a9ef9d63a 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', minMoves: 1, maxMoves: 1, @@ -24,7 +24,7 @@ const code = `{ stages: { discard: { moves: { - discard: G => G, + discard: ({ G }) => G, }, }, }, @@ -47,8 +47,8 @@ export default { }, moves: { - play: (G, ctx) => { - ctx.events.setActivePlayers({ + play: ({ G, events }) => { + events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1, @@ -61,7 +61,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 62749bd1c..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 84603b2dd..ddba07361 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 }; } }, @@ -49,7 +49,7 @@ const TicTacToe = { maxMoves: 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 ce2253fd6..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 cc3dbdf7e..4fe4740b1 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: { minMoves: 1, maxMoves: 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 34f4b7a44..c2d429bec 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: { minMoves: 1, maxMoves: 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 4fdfc2171..c304632d2 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: { minMoves: 1, maxMoves: 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 f5255ee50..219b939ba 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: { minMoves: 1, maxMoves: 1 }, }; diff --git a/examples/snippets/src/phases-2/game.js b/examples/snippets/src/phases-2/game.js index 631cc1497..fda75fd78 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: { minMoves: 1, maxMoves: 1 }, diff --git a/examples/snippets/src/stages-1/game.js b/examples/snippets/src/stages-1/game.js index 328bc6d77..4953d5871 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', minMoves: 1, maxMoves: 1 }); +function militia({ G, events }) { + events.setActivePlayers({ others: 'discard', minMoves: 1, maxMoves: 1 }); } -function discard(G, ctx) {} +function discard({ G, ctx }) {} const game = { moves: { militia }, diff --git a/integration/src/game.js b/integration/src/game.js index eae0bcaa1..d71c39fe1 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) { @@ -56,7 +56,7 @@ const TicTacToe = { maxMoves: 1, }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } diff --git a/src/ai/ai.test.ts b/src/ai/ai.test.ts index ae21c12a5..89aacd005 100644 --- a/src/ai/ai.test.ts +++ b/src/ai/ai.test.ts @@ -44,7 +44,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; @@ -55,7 +55,7 @@ const TicTacToe = ProcessGameConfig({ turn: { minMoves: 1, maxMoves: 1 }, - endIf: (G, ctx) => { + endIf: ({ G, ctx }) => { if (IsVictory(G.cells)) { return { winner: ctx.currentPlayer }; } @@ -66,7 +66,7 @@ const TicTacToe = ProcessGameConfig({ }, }); -const enumerate = (G, ctx, playerID) => { +const enumerate = (G: any, ctx: Ctx, playerID: string) => { const r = []; for (let i = 0; i < 9; i++) { if (G.cells[i] === null) { @@ -78,17 +78,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; }, @@ -120,7 +120,7 @@ describe('Step', () => { const client = Client({ game: { moves: { - A: (G) => { + A: ({ G }) => { G.moved = true; }, }, @@ -173,14 +173,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({ @@ -240,7 +240,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 }); @@ -305,14 +305,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/ai/bot.ts b/src/ai/bot.ts index 36a360c20..443076c78 100644 --- a/src/ai/bot.ts +++ b/src/ai/bot.ts @@ -8,6 +8,7 @@ import { makeMove, gameEvent } from '../core/action-creators'; import { alea } from '../plugins/random/random.alea'; +import type { AleaState } from '../plugins/random/random.alea'; import type { ActionShape, Game, Ctx, PlayerID, State } from '../types'; export type BotAction = ActionShape.GameEvent | ActionShape.MakeMove; @@ -26,7 +27,7 @@ export abstract class Bot { value: any; } >; - private prngstate; + private prngstate?: AleaState; constructor({ enumerate, diff --git a/src/client/client.test.ts b/src/client/client.test.ts index cfc9630c0..b98158d76 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -25,7 +25,7 @@ import { import * as Actions from '../core/action-types'; import Debug from './debug/Debug.svelte'; import { error } from '../core/logger'; -import type { LogEntry, State, SyncInfo } from '../types'; +import type { Game, LogEntry, State, SyncInfo } from '../types'; import type { Operation } from 'rfc6902'; import type { TransportData } from '../master/master'; @@ -35,10 +35,10 @@ jest.mock('../core/logger', () => ({ })); describe('basic', () => { - let client; + let client: ReturnType; const initial = { initial: true }; - const game = { + const game: Game = { setup: () => initial, }; @@ -59,7 +59,7 @@ test('move api', () => { const client = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, }); @@ -70,7 +70,7 @@ test('move api', () => { }); describe('namespaced moves', () => { - let client; + let client: ReturnType; beforeAll(() => { client = Client({ game: { @@ -124,10 +124,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, }, }); @@ -229,7 +229,7 @@ describe('multiplayer', () => { beforeAll(() => { spec = { - game: { moves: { A: (G, ctx) => ({ A: ctx.playerID }) } }, + game: { moves: { A: ({ playerID }) => ({ A: playerID }) } }, multiplayer: Local(), }; @@ -437,21 +437,22 @@ describe('receiveTransportData', () => { }); 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 }; const initial = { secret: [1, 2, 3, 4], sum: 0 }; beforeAll(() => { spec = { game: { setup: () => initial, - playerView: (G) => { + playerView: ({ G }) => { const r = { ...G }; r.sum = r.secret.reduce((prev, curr) => prev + curr); delete r.secret; return r; }, - moves: { A: (G, ctx) => ({ A: ctx.playerID }) }, + moves: { A: ({ playerID }) => ({ A: playerID }) }, }, multiplayer: Local(), }; @@ -488,7 +489,7 @@ test('accepts enhancer for store', () => { const client = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, enhancer: spyEnhancer, @@ -511,7 +512,7 @@ describe('event dispatchers', () => { 'setStage', ]; test('default', () => { - const game = {}; + const game: Game = {}; const client = Client({ game }); expect(Object.keys(client.events)).toEqual(clientEvents); expect(client.getState().ctx.turn).toBe(1); @@ -520,7 +521,7 @@ describe('event dispatchers', () => { }); test('all events', () => { - const game = { + const game: Game = { events: { endPhase: true, endGame: true, @@ -534,7 +535,7 @@ describe('event dispatchers', () => { }); test('no events', () => { - const game = { + const game: Game = { events: { endGame: false, endPhase: false, @@ -554,11 +555,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 }); @@ -758,9 +759,9 @@ describe('log handling', () => { }); describe('undo / redo', () => { - const game = { + const game: Game = { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }; @@ -783,9 +784,9 @@ describe('subscribe', () => { let client; let fn; beforeAll(() => { - const game = { + const game: Game = { moves: { - A: (G) => { + A: ({ G }) => { G.moved = true; }, }, @@ -915,9 +916,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 fc5a976ef..ad80f984a 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -35,7 +35,6 @@ import type { Reducer, State, Store, - Ctx, ChatMessage, } from '../types'; @@ -114,9 +113,9 @@ export const createPluginDispatchers = createDispatchers.bind(null, 'plugin'); export interface ClientOpts< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - game: Game; + game: Game; debug?: DebugOpt | boolean; numPlayers?: number; multiplayer?: (opts: TransportOpts) => Transport; @@ -494,7 +493,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), }; } @@ -572,6 +575,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/client/react-native.test.js b/src/client/react-native.test.js index 0001e8012..5fd641b05 100644 --- a/src/client/react-native.test.js +++ b/src/client/react-native.test.js @@ -71,7 +71,7 @@ test('move api', () => { const Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -94,7 +94,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -109,7 +109,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -151,7 +151,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, @@ -166,7 +166,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 ce2c499b6..e027ece75 100644 --- a/src/client/react.test.tsx +++ b/src/client/react.test.tsx @@ -98,7 +98,7 @@ test('move api', () => { const Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -121,7 +121,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -136,7 +136,7 @@ test('update matchID / playerID', () => { Board = Client({ game: { moves: { - A: (G, ctx, arg) => ({ arg }), + A: (_, arg) => ({ arg }), }, }, board: TestBoard, @@ -178,7 +178,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, @@ -193,7 +193,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.tsx b/src/client/react.tsx index 90874d3df..1dda5897f 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'; @@ -43,8 +42,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; }; @@ -71,8 +70,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 diff --git a/src/client/transport/local.test.ts b/src/client/transport/local.test.ts index 0fccf2aaf..ff10ed769 100644 --- a/src/client/transport/local.test.ts +++ b/src/client/transport/local.test.ts @@ -19,8 +19,8 @@ const sleep = (ms = 500) => new Promise((resolve) => setTimeout(resolve, ms)); describe('bots', () => { const game: Game = { moves: { - A: (_, ctx) => { - ctx.events.endTurn(); + A: ({ events }) => { + events.endTurn(); }, }, ai: { @@ -176,10 +176,10 @@ describe('Local', () => { }); describe('with localStorage persistence', () => { - const game = { + const game: Game = { setup: () => ({ count: 0 }), moves: { - A: (G: any) => { + A: ({ G }) => { G.count++; }, }, diff --git a/src/core/flow.test.ts b/src/core/flow.test.ts index 53769e008..ce0f5f40d 100644 --- a/src/core/flow.test.ts +++ b/src/core/flow.test.ts @@ -32,24 +32,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, }, }, }); @@ -104,9 +104,9 @@ describe('phases', () => { let client: ReturnType; beforeAll(() => { - const game = { + const game: Game = { endIf: () => true, - onEnd: (G) => { + onEnd: ({ G }) => { G.onEnd = true; }, }; @@ -205,7 +205,7 @@ describe('phases', () => { describe('turn', () => { test('onEnd', () => { - const onEnd = jest.fn((G) => G); + const onEnd = jest.fn(({ G }) => G); const flow = Flow({ turn: { onEnd }, }); @@ -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( @@ -496,12 +496,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 }); @@ -513,13 +513,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 }); @@ -532,9 +532,9 @@ describe('turn', () => { }); test('return value', () => { - const game = { + const game: Game = { moves: { - A: (G) => G, + A: ({ G }) => G, }, turn: { endIf: () => ({ next: '2' }) }, }; @@ -550,7 +550,7 @@ describe('turn', () => { const flow = Flow({ turn: { endIf: () => true }, phases: { - A: { start: true, endIf: (G) => G.endPhase, next: 'B' }, + A: { start: true, endIf: ({ G }) => G.endPhase, next: 'B' }, B: {}, }, }); @@ -584,7 +584,7 @@ describe('stages', () => { const A = () => {}; const B = () => {}; - const game = { + const game: Game = { moves: { A }, turn: { stages: { @@ -655,11 +655,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, }, }, }); @@ -690,7 +690,7 @@ describe('stages', () => { currentPlayer: 'A', maxMoves: 1, }, - endIf: (G, ctx) => ctx.activePlayers === null, + endIf: ({ ctx }) => ctx.activePlayers === null, stages: { A: { moves: { @@ -813,8 +813,8 @@ describe('stage events', () => { }); describe('disallowed in hooks', () => { - const setStage: MoveFn = (G, ctx) => { - ctx.events.setStage('A'); + const setStage: MoveFn = ({ events }) => { + events.setStage('A'); }; test('phase.onBegin', () => { @@ -990,8 +990,8 @@ describe('stage events', () => { }); describe('disallowed in hooks', () => { - const endStage: MoveFn = (G, ctx) => { - ctx.events.endStage(); + const endStage: MoveFn = ({ events }) => { + events.endStage(); }; test('phase.onBegin', () => { @@ -1064,13 +1064,13 @@ describe('stage events', () => { numPlayers: 3, game: { turn: { - onBegin: (G, ctx) => { - ctx.events.setActivePlayers({ currentPlayer: 'A' }); + onBegin: ({ events }) => { + events.setActivePlayers({ currentPlayer: 'A' }); }, }, moves: { - updateActivePlayers: (G, ctx) => { - ctx.events.setActivePlayers({ others: 'B' }); + updateActivePlayers: ({ events }) => { + events.setActivePlayers({ others: 'B' }); }, }, }, @@ -1084,8 +1084,8 @@ describe('stage events', () => { }); describe('in hooks', () => { - const setActivePlayers: MoveFn = (G, ctx) => { - ctx.events.setActivePlayers({ currentPlayer: 'A' }); + const setActivePlayers: MoveFn = ({ events }) => { + events.setActivePlayers({ currentPlayer: 'A' }); }; test('disallowed in phase.onBegin', () => { @@ -1186,7 +1186,7 @@ test('next', () => { 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')); @@ -1206,17 +1206,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 }); @@ -1268,7 +1268,7 @@ test('isPlayerActive', () => { describe('endGame', () => { let client: ReturnType; beforeEach(() => { - const game = { + const game: Game = { events: { endGame: true }, }; client = Client({ game }); @@ -1382,11 +1382,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'; }, }, @@ -1437,7 +1437,7 @@ test('undoable moves', () => { }); describe('moveMap', () => { - const game = { + const game: Game = { moves: { A: () => {} }, turn: { @@ -1478,7 +1478,7 @@ describe('moveMap', () => { describe('infinite loops', () => { test('infinite loop of self-ending phases via endIf', () => { const endIf = () => true; - const game = { + const game: Game = { phases: { A: { endIf, next: 'B', start: true }, B: { endIf, next: 'A' }, @@ -1489,15 +1489,15 @@ describe('infinite loops', () => { }); test('infinite endPhase loop from phase.onBegin', () => { - const onBegin = (G, ctx) => ctx.events.endPhase(); - const game = { + const onBegin = ({ events }) => void events.endPhase(); + const game: Game = { phases: { A: { onBegin, next: 'B', start: true, moves: { - a: (G, ctx) => void ctx.events.endPhase(), + a: ({ events }) => void events.endPhase(), }, }, B: { onBegin, next: 'C' }, @@ -1532,9 +1532,9 @@ describe('infinite loops', () => { }); test('double phase ending from client event and turn.onEnd', () => { - const game = { + const game: Game = { turn: { - onEnd: (G, ctx) => void ctx.events.endPhase(), + onEnd: ({ events }) => void events.endPhase(), }, phases: { A: { next: 'B', start: true }, @@ -1555,14 +1555,14 @@ describe('infinite loops', () => { }); test('infinite turn endings from turn.onBegin', () => { - const game = { + const game: Game = { moves: { - endTurn: (G, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, turn: { - onBegin: (G, ctx) => void ctx.events.endTurn(), + onBegin: ({ events }) => void events.endTurn(), }, }; const client = Client({ game }); @@ -1582,10 +1582,10 @@ describe('infinite loops', () => { }); test('double turn ending from event and endIf', () => { - const game = { + const game: Game = { moves: { - endTurn: (G, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, turn: { @@ -1610,8 +1610,8 @@ describe('infinite loops', () => { const game: Game = { phases: { A: { - endIf: (G, ctx) => { - ctx.events.setActivePlayers({ currentPlayer: 'A' }); + endIf: ({ events }) => { + events.setActivePlayers({ currentPlayer: 'A' }); }, }, }, @@ -1633,10 +1633,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', () => { @@ -1761,10 +1761,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', () => { @@ -1883,7 +1883,7 @@ describe('events in hooks', () => { describe('activePlayers', () => { test('sets activePlayers at each turn', () => { - const game = { + const game: Game = { turn: { stages: { A: {}, B: {} }, activePlayers: { @@ -1914,15 +1914,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(); }, }, }; @@ -1943,7 +1943,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, @@ -1954,15 +1954,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: { @@ -2070,16 +2070,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', minMoves: 1, maxMoves: 1 }); + setActivePlayers: ({ events }) => { + events.setActivePlayers({ all: 'A', minMoves: 1, maxMoves: 1 }); calls.push('moves.setActivePlayers'); }, }, diff --git a/src/core/flow.ts b/src/core/flow.ts index f104d4698..02cfd279c 100644 --- a/src/core/flow.ts +++ b/src/core/flow.ts @@ -23,6 +23,7 @@ import type { ActivePlayersArg, State, Ctx, + FnContext, LogEntry, Game, PhaseConfig, @@ -61,7 +62,7 @@ export function Flow({ } if (!endIf) endIf = () => undefined; - if (!onEnd) onEnd = (G) => G; + if (!onEnd) onEnd = ({ G }) => G; if (!turn) turn = {}; const phaseMap = { ...phases }; @@ -79,20 +80,29 @@ export function Flow({ Object.keys(moves).forEach((name) => moveNames.add(name)); const HookWrapper = ( - hook: (G: any, ctx: Ctx) => any, + hook: (context: FnContext) => any, hookType: GameMethod ) => { const withPlugins = plugin.FnWrap(hook, hookType, plugins); - return (state: State) => { - const ctxWithAPI = plugin.EnhanceCtx(state); - return withPlugins(state.G, ctxWithAPI); + return (state: State & { playerID?: PlayerID }) => { + const pluginAPIs = plugin.GetAPIs(state); + return withPlugins({ + ...pluginAPIs, + G: state.G, + ctx: state.ctx, + playerID: state.playerID, + }); }; }; - const TriggerWrapper = (trigger: (G: any, ctx: Ctx) => any) => { + const TriggerWrapper = (trigger: (context: FnContext) => any) => { return (state: State) => { - const ctxWithAPI = plugin.EnhanceCtx(state); - return trigger(state.G, ctxWithAPI); + const pluginAPIs = plugin.GetAPIs(state); + return trigger({ + ...pluginAPIs, + G: state.G, + ctx: state.ctx, + }); }; }; @@ -119,10 +129,10 @@ export function Flow({ phaseConfig.endIf = () => undefined; } if (phaseConfig.onBegin === undefined) { - phaseConfig.onBegin = (G) => G; + phaseConfig.onBegin = ({ G }) => G; } if (phaseConfig.onEnd === undefined) { - phaseConfig.onEnd = (G) => G; + phaseConfig.onEnd = ({ G }) => G; } if (phaseConfig.turn === undefined) { phaseConfig.turn = turn; @@ -131,16 +141,16 @@ export function Flow({ phaseConfig.turn.order = TurnOrder.DEFAULT; } if (phaseConfig.turn.onBegin === undefined) { - phaseConfig.turn.onBegin = (G) => G; + phaseConfig.turn.onBegin = ({ G }) => G; } if (phaseConfig.turn.onEnd === undefined) { - phaseConfig.turn.onEnd = (G) => G; + phaseConfig.turn.onEnd = ({ G }) => G; } if (phaseConfig.turn.endIf === undefined) { phaseConfig.turn.endIf = () => false; } if (phaseConfig.turn.onMove === undefined) { - phaseConfig.turn.onMove = (G) => G; + phaseConfig.turn.onMove = ({ G }) => G; } if (phaseConfig.turn.stages === undefined) { phaseConfig.turn.stages = {}; @@ -183,11 +193,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(); @@ -712,11 +733,7 @@ export function Flow({ state = { ...state, - ctx: { - ...ctx, - numMoves, - _activePlayersNumMoves, - }, + ctx: { ...ctx, numMoves, _activePlayersNumMoves }, }; if ( @@ -727,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/core/game.test.ts b/src/core/game.test.ts index 99d1b3a90..7aa9a9dcb 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 c55bd4cbf..3429a8631 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -53,7 +53,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) => { @@ -89,15 +89,17 @@ export function ProcessGameConfig(game: Game | ProcessedGame): ProcessedGame { if (moveFn instanceof Function) { const fn = plugins.FnWrap(moveFn, GameMethod.MOVE, game.plugins); - const ctxWithAPI = { - ...plugins.EnhanceCtx(state), - playerID: action.playerID, - }; let args = []; if (action.args !== undefined) { args = Array.isArray(action.args) ? action.args : [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 c25708f8e..f7d93beb7 100644 --- a/src/core/initialize.ts +++ b/src/core/initialize.ts @@ -41,10 +41,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/player-view.test.ts b/src/core/player-view.test.ts index 959fd32d6..4d97e5057 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,17 +30,21 @@ describe('players', () => { }; test('playerID: "0"', () => { - 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': {} }); }); test('playerID: "1"', () => { - 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': {} }); }); 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/core/player-view.ts b/src/core/player-view.ts index 6d8f8d7f5..006deea71 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, PlayerID } 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 | null) => { + STRIP_SECRETS: ({ G, playerID }: { G: any; playerID: PlayerID | null }) => { const r = { ...G }; if (r.secret !== undefined) { diff --git a/src/core/reducer.test.ts b/src/core/reducer.test.ts index 67358ab0d..027a67fe7 100644 --- a/src/core/reducer.test.ts +++ b/src/core/reducer.test.ts @@ -21,7 +21,7 @@ import { patch, } from './action-creators'; import { error } from '../core/logger'; -import type { Ctx, Game, State, SyncInfo } from '../types'; +import type { Game, State, SyncInfo } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -30,12 +30,12 @@ jest.mock('../core/logger', () => ({ const game: Game = { moves: { - A: (G) => G, + A: ({ G }) => G, B: () => ({ moved: true }), C: () => ({ victory: true }), Invalid: () => INVALID_MOVE, }, - endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined), + endIf: ({ G, ctx }) => (G.victory ? ctx.currentPlayer : undefined), }; const reducer = CreateGameReducer({ game }); const initialState = InitializeGame({ game }); @@ -49,7 +49,7 @@ test('_stateID is incremented', () => { }); test('move returns INVALID_MOVE', () => { - const game = { + const game: Game = { moves: { A: () => INVALID_MOVE, }, @@ -179,7 +179,7 @@ test('endTurn', () => { test('light client when multiplayer=true', () => { const game: Game = { moves: { A: () => ({ win: true }) }, - endIf: (G) => G.win, + endIf: ({ G }) => G.win, }; { @@ -200,7 +200,7 @@ test('light client when multiplayer=true', () => { }); test('disable optimistic updates', () => { - const game = { + const game: Game = { moves: { A: { move: () => ({ A: true }), @@ -269,7 +269,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: () => ({}), @@ -314,7 +314,7 @@ describe('Plugin Invalid Action API', () => { }, ], moves: { - setValue: (G, _ctx, arg) => { + setValue: ({ G }, arg: number) => { G.value = arg; }, }, @@ -419,9 +419,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(); }, }, turn: { @@ -439,12 +439,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).not.toHaveProperty('events'); + expect(state._undo[1].ctx).not.toHaveProperty('random'); }); test('undo restores previous state after move', () => { @@ -546,7 +547,7 @@ test('disable undo / redo', () => { seed: 0, disableUndo: true, moves: { - move: (G, ctx, arg) => ({ ...G, [arg]: true }), + move: ({ G }, arg: string) => ({ ...G, [arg]: true }), }, }; @@ -584,8 +585,8 @@ describe('undo stack', () => { const game: Game = { moves: { basic: () => {}, - endTurn: (_, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, }; @@ -662,8 +663,8 @@ describe('redo stack', () => { const game: Game = { moves: { basic: () => {}, - endTurn: (_, ctx) => { - ctx.events.endTurn(); + endTurn: ({ events }) => { + events.endTurn(); }, }, }; @@ -731,19 +732,19 @@ 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, + undoable: ({ G }) => G.moveAisReversible > 0, }, }, }, A: { moves: { moveB: { - move: (G, ctx) => { - ctx.events.setStage('B'); + move: ({ G, events }) => { + events.setStage('B'); return { ...G, B: true }; }, undoable: false, @@ -753,8 +754,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/reducer.ts b/src/core/reducer.ts index 2648ad77d..b8cf544bc 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/core/turn-order.test.ts b/src/core/turn-order.test.ts index d27b37f5e..ef47655f4 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 type { State } from '../types'; +import type { Game, State } from '../types'; jest.mock('../core/logger', () => ({ info: jest.fn(), @@ -311,12 +311,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, }; const flow = Flow({ @@ -343,7 +343,7 @@ test('override', () => { }); test('playOrder', () => { - const game = {}; + const game: Game = {}; const reducer = CreateGameReducer({ game }); let state = InitializeGame({ game, numPlayers: 3 }); @@ -414,17 +414,17 @@ 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 }, minMoves: 1, maxMoves: 1, }); return G; }, - A: (G) => G, + A: ({ G }) => G, }, }; @@ -440,17 +440,17 @@ describe('setActivePlayers', () => { }); test('others', () => { - const game = { + const game: Game = { moves: { - B: (G, ctx) => { - ctx.events.setActivePlayers({ + B: ({ G, events }) => { + events.setActivePlayers({ minMoves: 1, maxMoves: 1, others: Stage.NULL, }); return G; }, - A: (G) => G, + A: ({ G }) => G, }, }; @@ -472,11 +472,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({ minMoves: 1, maxMoves: 1, currentPlayer: 'start', @@ -494,8 +494,8 @@ describe('setActivePlayers', () => { stages: { start: { moves: { - S: (G, ctx) => { - ctx.events.setStage(Stage.NULL); + S: ({ G, events }) => { + events.setStage(Stage.NULL); return G; }, }, @@ -523,7 +523,7 @@ describe('setActivePlayers', () => { describe('reset behavior', () => { test('start of turn', () => { - const game = { + const game: Game = { moves: { A: () => {}, }, @@ -551,10 +551,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', minMoves: 1, maxMoves: 1, @@ -600,10 +600,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', minMoves: 1, maxMoves: 1, @@ -681,7 +681,7 @@ describe('setActivePlayers', () => { }); test('set to next', () => { - const game = { + const game: Game = { moves: { A: () => {}, }, @@ -741,7 +741,7 @@ describe('setActivePlayers', () => { describe('move limits', () => { test('shorthand syntax', () => { - const game = { + const game: Game = { turn: { activePlayers: { all: 'play', @@ -795,7 +795,7 @@ describe('setActivePlayers', () => { }); test('long-form syntax', () => { - const game = { + const game: Game = { turn: { activePlayers: { currentPlayer: { stage: 'play', minMoves: 1, maxMoves: 2 }, @@ -842,7 +842,7 @@ describe('setActivePlayers', () => { }); test('player-specific limit overrides move limit args', () => { - const game = { + const game: Game = { turn: { activePlayers: { all: { stage: 'play', minMoves: 2, maxMoves: 2 }, @@ -866,7 +866,7 @@ describe('setActivePlayers', () => { }); test('value syntax', () => { - const game = { + const game: Game = { turn: { activePlayers: { value: { @@ -911,7 +911,7 @@ describe('setActivePlayers', () => { }); test('move counts reset on turn end', () => { - const game = { + const game: Game = { turn: { activePlayers: { all: 'play', @@ -948,10 +948,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', minMoves: 1, maxMoves: 1, @@ -964,7 +964,7 @@ describe('setActivePlayers', () => { stages: { discard: { moves: { - discard: (G) => G, + discard: ({ G }) => G, }, }, }, @@ -1087,15 +1087,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; @@ -1103,7 +1103,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 acc1a331d..a0a2e459e 100644 --- a/src/core/turn-order.ts +++ b/src/core/turn-order.ts @@ -15,6 +15,7 @@ import type { PlayerID, State, TurnConfig, + FnContext, } from '../types'; import { supportDeprecatedMoveLimit } from './backwards-compatibility'; @@ -254,15 +255,16 @@ function getCurrentPlayer( export function InitTurnOrderState(state: State, turn: TurnConfig) { let { G, ctx } = state; const { numPlayers } = ctx; - const ctxWithAPI = plugin.EnhanceCtx(state); + const pluginAPIs = plugin.GetAPIs(state); + const context = { ...pluginAPIs, G, ctx }; const order = turn.order; let playOrder = [...Array.from({ length: 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( @@ -316,8 +318,9 @@ export function UpdateTurnOrderState( } }); } else { - const ctxWithAPI = plugin.EnhanceCtx(state); - const t = order.next(G, ctxWithAPI); + const pluginAPIs = plugin.GetAPIs(state); + const context = { ...pluginAPIs, G, ctx }; + const t = order.next(context); const type = typeof t; if (t !== undefined && type !== 'number') { logging.error( @@ -360,11 +363,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, }, /** @@ -374,7 +377,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, }, /** @@ -383,8 +386,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, }, /** @@ -395,7 +398,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; } @@ -413,7 +416,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, }), /** @@ -426,9 +429,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/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 ff3022067..8aec64806 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/master/master.test.ts b/src/master/master.test.ts index 9000ad660..8b8a2b42b 100644 --- a/src/master/master.test.ts +++ b/src/master/master.test.ts @@ -11,7 +11,7 @@ import { InitializeGame } from '../core/initialize'; import { InMemory } from '../server/db/inmemory'; import { Master } from './master'; import { error } from '../core/logger'; -import type { Game, Server, State, Ctx, LogEntry } from '../types'; +import type { Game, Server, State, LogEntry } from '../types'; import { Auth } from '../server/auth'; import * as StorageAPI from '../server/db/base'; import * as dateMock from 'jest-date-mock'; @@ -78,7 +78,7 @@ class InMemoryAsync extends StorageAPI.Async { } } -const game = { seed: 0 }; +const game: Game = { seed: 0 }; function TransportAPI(send = jest.fn(), sendAll = jest.fn()) { return { send, sendAll }; @@ -173,9 +173,9 @@ describe('sync', () => { describe('update', () => { const send = jest.fn(); const sendAll = jest.fn(); - const game = { + const game: Game = { moves: { - A: (G) => G, + A: ({ G }) => G, }, }; let db; @@ -260,7 +260,7 @@ describe('update', () => { }); test('allow execution of moves with ignoreStaleStateID truthy', async () => { - const game = { + const game: Game = { setup: () => { const G = { players: { @@ -282,14 +282,14 @@ describe('update', () => { stages: { A: { moves: { - A: (G, ctx: Ctx) => { - const card = G.players[ctx.playerID].cards.shift(); + A: ({ G, playerID }) => { + const card = G.players[playerID].cards.shift(); G.discardedCards.push(card); }, B: { - 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); }, ignoreStaleStateID: true, }, @@ -534,17 +534,17 @@ describe('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); }, }, }, diff --git a/src/plugins/events/events.test.ts b/src/plugins/events/events.test.ts index df7e02892..56fc8b785 100644 --- a/src/plugins/events/events.test.ts +++ b/src/plugins/events/events.test.ts @@ -58,10 +58,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; }, }, @@ -73,10 +73,10 @@ test('update ctx', () => { }); test('no duplicate endTurn', () => { - const game = { + const game: Game = { turn: { - onEnd: (G, ctx) => { - ctx.events.endTurn(); + onEnd: ({ events }) => { + events.endTurn(); }, }, }; @@ -88,12 +88,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 ca748c8f5..218c2ef94 100644 --- a/src/plugins/main.test.ts +++ b/src/plugins/main.test.ts @@ -8,65 +8,71 @@ import { Client } from '../client/client'; import { Local } from '../client/transport/local'; -import type { Game } from '../types'; +import type { Game, Plugin } from '../types'; import { GameMethod } from '../../packages/core'; 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 }) => { + const 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 }) => { - const 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 }); @@ -126,7 +132,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() }); @@ -180,11 +189,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'); }, }, }; @@ -200,10 +209,10 @@ describe('isInvalid method', () => { }); describe('actions', () => { - let client; + let client: ReturnType; beforeAll(() => { - const game = { + const game: Game = { plugins: [ { name: 'test', @@ -245,6 +254,8 @@ describe('actions', () => { }); describe('plugins are accessible in events triggered from moves', () => { + type TestPluginAPI = { get: () => boolean }; + type PluginAPIs = { test: TestPluginAPI }; const plugins = [ { name: 'test', @@ -255,7 +266,7 @@ describe('plugins are accessible in events triggered from moves', () => { flush: () => ({ initial: true }), - api: ({ data }) => { + api: ({ data }): TestPluginAPI => { return { get: () => data.initial, }; @@ -264,15 +275,15 @@ describe('plugins are accessible in events triggered from moves', () => { ]; test('turn/onBegin', () => { - const game = { + const game: Game = { plugins, moves: { - stop: (G, ctx) => ctx.events.endTurn(), + stop: ({ events }) => events.endTurn(), }, turn: { - onBegin: (G, ctx) => { - G.onBegin = ctx.random.Die(1); - G.test = ctx.test.get(); + onBegin: ({ G, random, test }) => { + G.onBegin = random.Die(1); + G.test = test.get(); }, }, }; @@ -286,15 +297,15 @@ describe('plugins are accessible in events triggered from moves', () => { }); test('turn/onEnd', () => { - const game = { + const game: Game = { plugins, moves: { - stop: (G, ctx) => ctx.events.endTurn(), + stop: ({ events }) => events.endTurn(), }, turn: { - onEnd: (G, ctx) => { - G.onEnd = ctx.random.Die(1); - G.test = ctx.test.get(); + onEnd: ({ G, random, test }) => { + G.onEnd = random.Die(1); + G.test = test.get(); }, }, }; @@ -308,19 +319,19 @@ describe('plugins are accessible in events triggered from moves', () => { }); test('phase/onBegin', () => { - const game = { + const game: Game = { plugins, moves: { - stop: (G, ctx) => ctx.events.setPhase('second'), + stop: ({ events }) => events.setPhase('second'), }, phases: { first: { start: true, }, second: { - onBegin: (G, ctx) => { - G.onBegin = ctx.random.Die(1); - G.test = ctx.test.get(); + onBegin: ({ G, random, test }) => { + G.onBegin = random.Die(1); + G.test = test.get(); }, }, }, @@ -335,17 +346,17 @@ describe('plugins are accessible in events triggered from moves', () => { }); test('phase/onEnd', () => { - const game = { + const game: Game = { plugins, moves: { - stop: (G, ctx) => ctx.events.endPhase(), + stop: ({ events }) => events.endPhase(), }, phases: { first: { start: true, - onEnd: (G, ctx) => { - G.onEnd = ctx.random.Die(1); - G.test = ctx.test.get(); + onEnd: ({ G, random, test }) => { + G.onEnd = random.Die(1); + G.test = test.get(); }, }, }, @@ -367,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; }, diff --git a/src/plugins/main.ts b/src/plugins/main.ts index 7e220d790..f0fc9ba96 100644 --- a/src/plugins/main.ts +++ b/src/plugins/main.ts @@ -13,11 +13,11 @@ import PluginLog from './plugin-log'; import PluginSerializable from './plugin-serializable'; import type { AnyFn, + DefaultPluginAPIs, PartialGameState, State, Game, Plugin, - Ctx, ActionShape, PlayerID, } from '../types'; @@ -64,7 +64,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: * * { @@ -78,17 +78,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 => { - const 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. @@ -144,10 +141,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-events.ts b/src/plugins/plugin-events.ts index 011c70d8c..d306d152a 100644 --- a/src/plugins/plugin-events.ts +++ b/src/plugins/plugin-events.ts @@ -24,10 +24,10 @@ const EventsPlugin: Plugin = { // endings to dispatch the current turn and phase correctly. fnWrap: (method, methodType) => - (G, ctx, ...args) => { - const api = ctx.events as EventsAPI & PrivateEventsAPI; - if (api) api._private.updateTurnContext(ctx, methodType); - G = method(G, ctx, ...args); + (context, ...args) => { + const api = context.events as EventsAPI & PrivateEventsAPI; + if (api) api._private.updateTurnContext(context.ctx, methodType); + const G = method(context, ...args); if (api) api._private.unsetCurrentMethod(); return G; }, diff --git a/src/plugins/plugin-immer.test.ts b/src/plugins/plugin-immer.test.ts index 2946e8e34..45c2ac868 100644 --- a/src/plugins/plugin-immer.test.ts +++ b/src/plugins/plugin-immer.test.ts @@ -20,10 +20,10 @@ describe('immer', () => { client = Client({ game: { moves: { - A: (G) => { + A: ({ G }) => { G.moveBody = true; }, - invalid: (G) => { + invalid: ({ G }) => { G.madeInvalidMove = true; return INVALID_MOVE; }, @@ -32,23 +32,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 b79f7b16f..e2b1105ae 100644 --- a/src/plugins/plugin-immer.ts +++ b/src/plugins/plugin-immer.ts @@ -7,7 +7,7 @@ */ import produce from 'immer'; -import type { AnyFn, Ctx, Plugin } from '../types'; +import type { Plugin } from '../types'; import { INVALID_MOVE } from '../core/constants'; /** @@ -18,11 +18,11 @@ const ImmerPlugin: Plugin = { name: 'plugin-immer', fnWrap: - (move: AnyFn) => - (G: any, ctx: Ctx, ...args: any[]) => { + (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-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 }); diff --git a/src/plugins/plugin-player.test.ts b/src/plugins/plugin-player.test.ts index db652382a..335f760e9 100644 --- a/src/plugins/plugin-player.test.ts +++ b/src/plugins/plugin-player.test.ts @@ -7,12 +7,14 @@ */ import PluginPlayer from './plugin-player'; +import type { PlayerAPI } from './plugin-player'; import { Client } from '../client/client'; +import type { 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 +25,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 +39,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 +92,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 +121,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/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 ec7062267..e5319b1fa 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'; /** @@ -38,9 +38,9 @@ const SerializablePlugin: Plugin = { name: 'plugin-serializable', fnWrap: - (move: AnyFn) => - (G: unknown, ctx: Ctx, ...args: any[]) => { - const result = move(G, ctx, ...args); + (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( diff --git a/src/plugins/random/random.test.ts b/src/plugins/random/random.test.ts index 724f1256d..f55a7c3a2 100644 --- a/src/plugins/random/random.test.ts +++ b/src/plugins/random/random.test.ts @@ -11,6 +11,7 @@ import { makeMove } from '../../core/action-creators'; import { CreateGameReducer } from '../../core/reducer'; import { InitializeGame } from '../../core/initialize'; import { Client } from '../../client/client'; +import type { Game } from '../../types'; import { PlayerView } from '../main'; function Init(seed) { @@ -100,10 +101,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() }), }, }; @@ -128,7 +129,7 @@ test('Random API works when its state is redacted by playerView', () => { const game = { seed: 0, moves: { - rollDie: (G, ctx) => ({ ...G, die: ctx.random.D6() }), + rollDie: ({ G, random }) => ({ ...G, die: random.D6() }), }, }; @@ -146,11 +147,11 @@ test('turn.onBegin has ctx 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 36511d913..2dcee7944 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -11,9 +11,9 @@ import request from 'supertest'; import { Server, createServerRunConfig, getPortFromServer } from '.'; import type { KoaServer } from '.'; import type { SocketIO } from './transport/socketio'; -import type { StorageAPI } from '../types'; +import type { Game, StorageAPI } from '../types'; -const game = { seed: 0 }; +const game: Game = { seed: 0 }; const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); beforeEach(warn.mockReset); @@ -50,21 +50,21 @@ jest.mock('koa-socket-2', () => { 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/server/transport/socketio-simultaneous.test.ts b/src/server/transport/socketio-simultaneous.test.ts index df8aca53d..321f7a879 100644 --- a/src/server/transport/socketio-simultaneous.test.ts +++ b/src/server/transport/socketio-simultaneous.test.ts @@ -17,7 +17,7 @@ import * as ActionCreators from '../../core/action-creators'; import { InitializeGame } from '../../core/initialize'; import { PlayerView } from '../../core/player-view'; import type { Master } from '../../master/master'; -import type { Ctx, LogEntry, Server, State, StorageAPI } from '../../types'; +import type { Game, LogEntry, Server, State, StorageAPI } from '../../types'; type SyncArgs = Parameters; type UpdateArgs = Parameters; @@ -162,23 +162,20 @@ jest.mock('koa-socket-2', () => { }); describe('simultaneous moves on server game', () => { - const game = { + const game: Game = { name: 'test', - setup: () => { - 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' } }, @@ -187,17 +184,17 @@ describe('simultaneous moves on server 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); }, }, }, @@ -463,12 +460,12 @@ describe('simultaneous moves on server game', () => { }); describe('inauthentic clients', () => { - const game = { + const game: Game = { setup: () => ({ 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 75e2c4f71..b6e2c60da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Object } from 'ts-toolbelt'; +import type { Object, Misc } from 'ts-toolbelt'; import type Koa from 'koa'; import type { Store as ReduxStore } from 'redux'; import type * as ActionCreators from './core/action-creators'; @@ -18,9 +18,9 @@ export type { StorageAPI }; export type AnyFn = (...args: any[]) => any; // "Public" state to be communicated to clients. -export interface State { +export interface State { G: G; - ctx: Ctx | CtxWithPlugins; + ctx: Ctx; deltalog?: Array; plugins: { [pluginName: string]: PluginState; @@ -47,10 +47,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; } @@ -90,7 +87,6 @@ export interface Ctx { numPlayers: number; playOrder: Array; playOrderPos: number; - playerID?: PlayerID; activePlayers: null | ActivePlayers; currentPlayer: PlayerID; numMoves?: number; @@ -110,13 +106,12 @@ 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; - log?: LogAPI; - random?: RandomAPI; +export interface DefaultPluginAPIs { + events: EventsAPI; + log: LogAPI; + random: RandomAPI; } export interface PluginState { @@ -158,147 +153,162 @@ export interface Plugin< > { name: string; noClient?: (context: PluginContext) => boolean; + setup?: (setupCtx: { G: G; ctx: Ctx; game: Game }) => Data; isInvalid?: ( context: Omit, 'api'> ) => false | string; - 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; + state: State; + game: Game; api: API; data: Data; - }) => State; + }) => State; fnWrap?: ( - moveOrHook: (G: G, ctx: Ctx, ...args: any[]) => any, + moveOrHook: (context: FnContext, ...args: SerializableAny[]) => any, methodType: GameMethod - ) => (G: G, ctx: Ctx, ...args: any[]) => any; + ) => (context: FnContext, ...args: SerializableAny[]) => any; playerView?: (context: { G: G; ctx: Ctx; - game: Game; + game: Game; data: Data; playerID?: PlayerID | null; }) => any; } -export type MoveFn = ( - G: G, - ctx: CtxWithPlugins, - ...args: any[] -) => any; +export type FnContext< + G extends any = any, + PluginAPIs extends Record = Record +> = PluginAPIs & + DefaultPluginAPIs & { + G: G; + ctx: Ctx; + }; + +type SerializableAny = Misc.JSON.Value; +export type MoveFn< + G extends any = any, + PluginAPIs extends Record = Record +> = ( + context: FnContext & { playerID: PlayerID }, + ...args: SerializableAny[] +) => void | G | typeof INVALID_MOVE; export interface LongFormMove< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - move: MoveFn; + move: MoveFn; redact?: boolean; noLimit?: boolean; client?: boolean; - undoable?: boolean | ((G: G, ctx: CtxWithPlugins) => boolean); + undoable?: boolean | ((context: { G: G; ctx: Ctx }) => boolean); ignoreStaleStateID?: boolean; } -export type Move = - | MoveFn - | LongFormMove; +export type Move< + G extends any = any, + PluginAPIs extends Record = Record +> = MoveFn | LongFormMove; export interface MoveMap< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - [moveName: string]: Move; + [moveName: string]: Move; } export interface PhaseConfig< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { start?: boolean; - next?: ((G: G, ctx: CtxWithPlugins) => string | void) | 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; + next?: ((context: FnContext) => string | void) | string; + onBegin?: (context: FnContext) => void | G; + onEnd?: (context: FnContext) => void | G; + 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; - next?: (state: State) => string | void; + endIf?: (state: State) => boolean | void | { next: string }; + onBegin?: (state: State) => void | G; + onEnd?: (state: State) => void | G; + next?: (state: State) => string | void; }; } export interface StageConfig< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - moves?: MoveMap; + moves?: MoveMap; next?: string; } export interface StageMap< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - [stageName: string]: StageConfig; + [stageName: string]: StageConfig; } export interface TurnOrderConfig< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - 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 + PluginAPIs extends Record = Record > { activePlayers?: ActivePlayersArg; minMoves?: number; maxMoves?: number; /** @deprecated Use `minMoves` and `maxMoves` instead. */ 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; - order?: TurnOrderConfig; + onBegin?: (context: FnContext) => void | G; + onEnd?: (context: FnContext) => void | G; + endIf?: ( + context: FnContext + ) => boolean | void | { next: PlayerID }; + onMove?: ( + context: FnContext & { playerID: PlayerID } + ) => void | G; + stages?: StageMap; + 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) => void | G; + onEnd?: (state: State) => void | G; + onMove?: (state: State & { playerID: PlayerID }) => void | G; }; } export interface PhaseMap< G extends any = any, - CtxWithPlugins extends Ctx = Ctx + PluginAPIs extends Record = Record > { - [phaseName: string]: PhaseConfig; + [phaseName: string]: PhaseConfig; } export interface Game< G extends any = any, - CtxWithPlugins extends Ctx = Ctx, + PluginAPIs extends Record = Record, SetupData extends any = any > { name?: string; @@ -307,14 +317,17 @@ export interface Game< deltaState?: boolean; disableUndo?: boolean; seed?: string | number; - setup?: (ctx: CtxWithPlugins, setupData?: SetupData) => G; + setup?: ( + context: PluginAPIs & DefaultPluginAPIs & { ctx: Ctx }, + 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; @@ -325,9 +338,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 | null) => any; + endIf?: (context: FnContext) => any; + onEnd?: (context: FnContext) => void | G; + playerView?: (context: { G: G; ctx: Ctx; playerID: PlayerID | null }) => any; plugins?: Array>; ai?: { enumerate: ( @@ -342,9 +355,9 @@ export interface Game< >; }; processMove?: ( - state: State, + state: State, action: ActionPayload.MakeMove - ) => State | typeof INVALID_MOVE; + ) => State | typeof INVALID_MOVE; flow?: ReturnType; }