Filters

# Help Find a Correct Move

Make sure you have everything you need before proceeding:

In this section, you will:

  • Improve usability with queries.
  • Create a battery of unit and integration tests.

A player sends a MsgPlayMove when making a move. This message can succeed or fail for several reasons. One error situation is when the message represents an invalid move. A GUI is the first place where a bad move can be caught, but it is still possible that a GUI wrongly enforces the rules.

Since sending transactions includes costs, how do you assist participants in making sure they at least do not make a wrong move?

Players should be able to confirm that a move is valid before burning gas. To add this functionality, you need to create a way for the player to call the Move (opens new window) function without changing the game's state. Use a query because they are evaluated in memory and do not commit anything permanently to storage.

# Some initial thoughts

When it comes to finding a correct move, ask:

  • What structure will facilitate this check?
  • Who do you let make such checks?
  • What acceptable limitations do you have for this?
  • Are there new errors to report back?
  • What event should you emit?

# Code needs

  • What Ignite CLI commands, if any, will assist you?
  • How do you adjust what Ignite CLI created for you?
  • Where do you make your changes?
  • How would you unit-test these new elements?
  • How would you use Ignite CLI to locally run a one-node blockchain and interact with it via the CLI to see what you get?

# New information

To run a query to check the validity of a move you need to pass:

  • The game ID: call the field gameIndex.
  • The player color, as queries do not have a signer.
  • The origin board position: fromX and fromY.
  • The target board position: toX and toY.

The information to be returned is:

  • A boolean for whether the move is valid, called possible.
  • A text which explains why the move is not valid, called reason.

As with other data structures, you can create the query message object with Ignite CLI:

Among other files, you should now have this:

Copy message QueryCanPlayMoveRequest { string gameIndex = 1; string player = 2; uint64 fromX = 3; uint64 fromY = 4; uint64 toX = 5; uint64 toY = 6; } message QueryCanPlayMoveResponse { bool possible = 1; string reason = 2; } proto checkers query.proto View source

Ignite CLI has created the following boilerplate for you:

# Query handling

Now you need to implement the answer to the player's query in grpc_query_can_play_move.go. Differentiate between two types of errors:

  • Errors relating to the move, returning a reason.
  • Errors indicating that testing the move is impossible, returning an error.
  1. The game needs to be fetched. If it does not exist at all, you can return an error message because you did not test the move:

    Copy storedGame, found := k.GetStoredGame(ctx, req.GameIndex) if !found { return nil, sdkerrors.Wrapf(types.ErrGameNotFound, "%s", req.GameIndex) } x checkers keeper grpc_query_can_play_move.go View source
  2. Has the game already been won?

    Copy if storedGame.Winner != rules.PieceStrings[rules.NO_PLAYER] { return &types.QueryCanPlayMoveResponse{ Possible: false, Reason: types.ErrGameFinished.Error(), }, nil } x checkers keeper grpc_query_can_play_move.go View source
  3. Is the player given actually one of the game players?

    Copy isBlack := rules.PieceStrings[rules.BLACK_PLAYER] == req.Player isRed := rules.PieceStrings[rules.RED_PLAYER] == req.Player var player rules.Player if isBlack && isRed { player = rules.StringPieces[storedGame.Turn].Player } else if isBlack { player = rules.BLACK_PLAYER } else if isRed { player = rules.RED_PLAYER } else { return &types.QueryCanPlayMoveResponse{ Possible: false, Reason: fmt.Sprintf("%s: %s", types.ErrCreatorNotPlayer.Error(), req.Player), }, nil } x checkers keeper grpc_query_can_play_move.go View source
  4. Is it the player's turn?

    Copy game, err := storedGame.ParseGame() if err != nil { return nil, err } if !game.TurnIs(player) { return &types.QueryCanPlayMoveResponse{ Possible: false, Reason: fmt.Sprintf("%s: %s", types.ErrNotPlayerTurn.Error(), player.Color), }, nil } x checkers keeper grpc_query_can_play_move.go View source
  5. Attempt the move and report back:

    Copy _, moveErr := game.Move( rules.Pos{ X: int(req.FromX), Y: int(req.FromY), }, rules.Pos{ X: int(req.ToX), Y: int(req.ToY), }, ) if moveErr != nil { return &types.QueryCanPlayMoveResponse{ Possible: false, Reason: fmt.Sprintf("%s: %s", types.ErrWrongMove.Error(), moveErr.Error()), }, nil } x checkers keeper grpc_query_can_play_move.go View source
  6. If all went well:

    Copy return &types.QueryCanPlayMoveResponse{ Possible: true, Reason: "ok", }, nil x checkers keeper grpc_query_can_play_move.go View source

Quite straightforward.

# Unit tests

A query is evaluated in memory, while using the current state in a read-only mode. Thanks to this, you can take some liberties with the current state before running a test, as long as reading the state works as intended. For example, you can pretend that the game has been progressed through a number of moves even though you have only just planted the board in that state in the keeper. For this reason, you can easily test the new method with unit tests, even though you painstakingly prepared integration tests.

Take inspiration from the other tests on queries (opens new window), which create an array of cases to test in a loop. Running a battery of test cases makes it easier to insert new cases and surface any unintended impact. Create a new grpc_query_can_play_move_test.go file where you:

  1. Declare a struct that describes a test case:

    Copy type canPlayGameCase struct { desc string game types.StoredGame request *types.QueryCanPlayMoveRequest response *types.QueryCanPlayMoveResponse err string } x checkers keeper grpc_query_can_play_move_test.go View source
  2. Create the common OK response, so as to reuse it:

    Copy var ( canPlayOkResponse = &types.QueryCanPlayMoveResponse{ Possible: true, Reason: "ok", } ) x checkers keeper grpc_query_can_play_move_test.go View source
  3. Prepare your array of cases:

    Copy canPlayTestRange = []canPlayGameCase{ // TODO } x checkers keeper grpc_query_can_play_move_test.go View source
  4. In the array add your first test case, one that returns an OK response:

    Copy { desc: "First move by black", game: types.StoredGame{ Index: "1", Board: "*b*b*b*b|b*b*b*b*|*b*b*b*b|********|********|r*r*r*r*|*r*r*r*r|r*r*r*r*", Turn: "b", Winner: "*", }, request: &types.QueryCanPlayMoveRequest{ GameIndex: "1", Player: "b", FromX: 1, FromY: 2, ToX: 2, ToY: 3, }, response: canPlayOkResponse, err: "nil", }, x checkers keeper grpc_query_can_play_move_test.go View source
  5. Add other test cases (opens new window). Examples include a missing request:

    Copy { desc: "Nil request, wrong", game: types.StoredGame{ Index: "1", Board: "*b*b*b*b|b*b*b*b*|*b*b*b*b|********|********|r*r*r*r*|*r*r*r*r|r*r*r*r*", Turn: "b", Winner: "*", }, request: nil, response: nil, err: "rpc error: code = InvalidArgument desc = invalid request", }, x checkers keeper grpc_query_can_play_move_test.go View source

    Or a player playing out of turn:

    Copy { desc: "First move by red, wrong", game: types.StoredGame{ Index: "1", Board: "*b*b*b*b|b*b*b*b*|*b*b*b*b|********|********|r*r*r*r*|*r*r*r*r|r*r*r*r*", Turn: "b", Winner: "*", }, request: &types.QueryCanPlayMoveRequest{ GameIndex: "1", Player: "r", FromX: 1, FromY: 2, ToX: 2, ToY: 3, }, response: &types.QueryCanPlayMoveResponse{ Possible: false, Reason: "player tried to play out of turn: red", }, err: "nil", }, x checkers keeper grpc_query_can_play_move_test.go View source
  6. With the test cases defined, add a single test function that runs all the cases:

    Copy func TestCanPlayCasesAsExpected(t *testing.T) { keeper, ctx := keepertest.CheckersKeeper(t) goCtx := sdk.WrapSDKContext(ctx) for _, testCase := range canPlayTestRange { t.Run(testCase.desc, func(t *testing.T) { keeper.SetStoredGame(ctx, testCase.game) response, err := keeper.CanPlayMove(goCtx, testCase.request) if testCase.response == nil { require.Nil(t, response) } else { require.EqualValues(t, testCase.response, response) } if testCase.err == "nil" { require.Nil(t, err) } else { require.EqualError(t, err, testCase.err) } keeper.RemoveStoredGame(ctx, testCase.game.Index) }) } } x checkers keeper grpc_query_can_play_move_test.go View source

Note how all test cases are run within a single unit test. In other words, the keeper used for the second case is the same as that used for the first case, and so on for all. So to mitigate the risk of interference from one case to the next, you ought to do keeper.RemoveStoredGame(ctx, testCase.game.Index) at the end of the test case.

# Integration tests

You can also add integration tests on top of your unit tests. Although not compulsory, it is preferable that you put them in a separate file. Pick grpc_query_can_play_move_integration_test.go.

Test if it is possible to play on the first game that is created in the system:

Copy func (suite *IntegrationTestSuite) TestCanPlayAfterCreate() { suite.setupSuiteWithOneGameForPlayMove() goCtx := sdk.WrapSDKContext(suite.ctx) response, err := suite.queryClient.CanPlayMove(goCtx, &types.QueryCanPlayMoveRequest{ GameIndex: "1", Player: "b", FromX: 1, FromY: 2, ToX: 2, ToY: 3, }) suite.Require().Nil(err) suite.Require().EqualValues(canPlayOkResponse, response) } x checkers keeper grpc_query_can_play_move_integration_test.go View source

With these, your query handling function should be covered.

# Interact via the CLI

Set the game expiry to 5 minutes and start ignite chain serve. Remember that the CLI can always inform you about available commands:

Which prints:

Copy ... Available Commands: can-play-move Query canPlayMove ...

What can checkersd tell you about the command:

Which prints:

Copy ... Usage: checkersd query checkers can-play-move [gameIndex] [player] [fromX] [fromY] [toX] [toY] [flags] ...

You can test this query at any point in a game's life.

1

When there is no such game:

Trying this on a game that does not exist returns:

Copy Error: rpc error: code = InvalidArgument desc = 2048: game by id not found: invalid request ...

Confirm this was an error from the point of view of the executable:

Copy $ echo $?

This prints:

Copy 1

There is room to improve the error message, but it is important that you got an error, as expected.

2

When you ask for a bad player color:

If the player tries to play the wrong color on a game that exists, it returns:

Copy possible: false reason: 'message creator is not a player: w'

This is a proper message response, and a reason elaborating on the message.

3

When you ask for a player out of turn:

If the opponent tries to play out of turn, it returns:

Copy possible: false reason: 'player tried to play out of turn: red'
4

When you ask for a piece that is not that of the player:

If black tries to play a red piece, it returns:

Copy possible: false reason: 'wrong move: Not {red}''s turn'
5

When it is correct:

If black tests a correct move, it returns:

Copy possible: true reason: ok
6

When the player must capture:

If black fails to capture a mandatory red piece, it returns:

Copy possible: false reason: 'wrong move: Invalid move: {2 3} to {3 4}'

The reason given is understandable, but it does not clarify why the move is invalid. There is room to improve this message.

7

After the game has been forfeited:

If black tries to capture a red piece on a running game, it returns:

Copy possible: true reason: ok

Wait five minutes for the forfeit:

Now it returns:

Copy possible: false reason: game is already finished

These query results satisfy our expectations.

synopsis

To summarize, this section has explored:

  • How application usability can be improved with queries, such as by avoiding the cost of sending technically valid transactions which will nevertheless inevitably be rejected due to the application's current state.
  • How queries allow the user to evaluate the application state in read-only mode, without committing anything permanently to storage, with the result that a planned transaction can be judged as acceptable or not before burning gas.
  • How effective query construction will allow the application to signal not just that a planned transaction will fail but also the reason it will fail, improving the user's knowledge base for future actions.
  • How to create a query object with Ignite CLI; implement appropriate answers to a player's query; perform integration tests which extrapolate on the application's actual current state; and interact via the CLI to test the effectiveness of the query object.