Query - 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 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? To run a query to check the validity of a move you need to pass:
The game ID: call the field IdValue
. The player
, 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:
Copy
$ ignite scaffold query canPlayMove idValue player fromX:uint fromY:uint toX:uint toY:uint --module checkers --response possible:bool,reason
Among other files, you should now have this:
Copy
message QueryCanPlayMoveRequest {
string idValue = 1 ;
string player = 2 ;
uint64 fromX = 3 ;
uint64 fromY = 4 ;
uint64 toX = 5 ;
uint64 toY = 6 ;
}
message QueryCanPlayMoveResponse {
bool possible = 1 ;
string reason = 2 ;
}
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 a move test is impossible, returning an error. 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. IdValue)
if ! found {
return nil , sdkerrors. Wrapf ( types. ErrGameNotFound, types. ErrGameNotFound. Error ( ) , req. IdValue)
}
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
}
Is the player
given actually one of the game players?
Copy
var player rules. Player
if strings. Compare ( rules. PieceStrings[ rules. RED_PLAYER] , req. Player) == 0 {
player = rules. RED_PLAYER
} else if strings. Compare ( rules. PieceStrings[ rules. BLACK_PLAYER] , req. Player) == 0 {
player = rules. BLACK_PLAYER
} else {
return & types. QueryCanPlayMoveResponse{
Possible: false ,
Reason: fmt. Sprintf ( types. ErrCreatorNotPlayer. Error ( ) , req. Player) ,
} , nil
}
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 ( types. ErrNotPlayerTurn. Error ( ) , player. Color) ,
} , nil
}
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 ( types. ErrWrongMove. Error ( ) , moveErr. Error ( ) ) ,
} , nil
}
If all went well:
Copy
return & types. QueryCanPlayMoveResponse{
Possible: true ,
Reason: "ok" ,
} , nil
Integration 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. For example, you can pretend the game has been progressed through a number of moves even though you have only pasted the board in that state. For this reason, you are going to test the new method with unit tests, even though you painstakingly prepared integration tests.
Battery of unit tests Take inspiration from the other ones (opens new window) , which create a battery of tests to run in a loop. Running a battery of test cases makes it easier to insert new code and surface any unintended impact:
Declare a struct
that describes a test:
Copy
type canPlayBoard struct {
desc string
board string
turn string
request * types. QueryCanPlayMoveRequest
response * types. QueryCanPlayMoveResponse
err error
}
Create the common OK response, so as to reuse it later:
Copy
var ( canPlayOkResponse = & types. QueryCanPlayMoveResponse{
Possible: true ,
Reason: "ok" ,
} )
Create the first test case, which you will reuse:
Copy
var ( firstTestCase = canPlayBoard{
desc: "First move by black" ,
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" ,
request: & types. QueryCanPlayMoveRequest{
IdValue: "1" ,
Player: "b" ,
FromX: 1 ,
FromY: 2 ,
ToX: 2 ,
ToY: 3 ,
} ,
response: canPlayOkResponse,
} )
Create the list of test cases you want to run, including the just-defined firstTestCase
:
Copy
var ( canPlayTestRange = [ ] canPlayBoard{
firstTestCase,
{
desc: "First move by red, wrong" ,
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" ,
request: & types. QueryCanPlayMoveRequest{
IdValue: "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" ,
} ,
} ,
{
desc: "Black can win" ,
board: "*b*b****|**b*b***|*****b**|********|********|**r*****|*B***b**|********" ,
turn: "b" ,
request: & types. QueryCanPlayMoveRequest{
IdValue: "1" ,
Player: "b" ,
FromX: 1 ,
FromY: 6 ,
ToX: 3 ,
ToY: 4 ,
} ,
response: canPlayOkResponse,
} ,
{
desc: "Black must capture, see next for right move" ,
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" ,
request: & types. QueryCanPlayMoveRequest{
IdValue: "1" ,
Player: "b" ,
FromX: 7 ,
FromY: 2 ,
ToX: 6 ,
ToY: 3 ,
} ,
response: & types. QueryCanPlayMoveResponse{
Possible: false ,
Reason: "wrong move%!(EXTRA string=Invalid move: {7 2} to {6 3})" ,
} ,
} ,
{
desc: "Black can capture, same board as previous" ,
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" ,
request: & types. QueryCanPlayMoveRequest{
IdValue: "1" ,
Player: "b" ,
FromX: 2 ,
FromY: 3 ,
ToX: 0 ,
ToY: 5 ,
} ,
response: canPlayOkResponse,
} ,
{
desc: "Black king can capture backwards" ,
board: "*b*b***b|**b*b***|***b***r|********|***r****|********|***r****|r*B*r*r*" ,
turn: "b" ,
request: & types. QueryCanPlayMoveRequest{
IdValue: "1" ,
Player: "b" ,
FromX: 2 ,
FromY: 7 ,
ToX: 4 ,
ToY: 5 ,
} ,
response: canPlayOkResponse,
} ,
} )
Fortunately you already have a test file with all the steps (opens new window) to a complete game.
With this preparation, add the single test function that runs all the cases:
Copy
func TestCanPlayAsExpected ( t * testing. T) {
keeper, ctx := setupKeeper ( t)
goCtx := sdk. WrapSDKContext ( ctx)
for _ , testCase := range canPlayTestRange {
t. Run ( testCase. desc, func ( t * testing. T) {
keeper. SetStoredGame ( ctx, types. StoredGame{
Creator: alice,
Index: testCase. request. IdValue,
Game: testCase. board,
Turn: testCase. turn,
Red: bob,
Black: carol,
MoveCount: 1 ,
BeforeId: "-1" ,
AfterId: "-1" ,
Deadline: "" ,
Winner: "*" ,
Wager: 0 ,
} )
response, err := keeper. CanPlayMove ( goCtx, testCase. request)
require. Nil ( t, err)
require. EqualValues ( t, testCase. response, response)
} )
}
}
Finally, add the error tests that cannot be covered with the previous test cases:
Copy
func TestCanPlayWrongNoRequest ( t * testing. T) {
keeper, ctx := setupKeeper ( t)
goCtx := sdk. WrapSDKContext ( ctx)
_ , err := keeper. CanPlayMove ( goCtx, nil )
require. ErrorIs ( t, err, status. Error ( codes. InvalidArgument, "invalid request" ) )
}
func TestCanPlayWrongNoGame ( t * testing. T) {
keeper, ctx := setupKeeper ( t)
goCtx := sdk. WrapSDKContext ( ctx)
keeper. SetStoredGame ( ctx, types. StoredGame{
Creator: alice,
Index: "1" ,
Game: firstTestCase. board,
Turn: firstTestCase. turn,
Red: bob,
Black: carol,
MoveCount: 1 ,
BeforeId: "-1" ,
AfterId: "-1" ,
Deadline: "" ,
Winner: "*" ,
Wager: 0 ,
} )
_ , err := keeper. CanPlayMove ( goCtx, & types. QueryCanPlayMoveRequest{
IdValue: "2" ,
Player: "b" ,
FromX: 2 ,
FromY: 7 ,
ToX: 4 ,
ToY: 5 ,
} )
require. NotNil ( t, err)
require. EqualError ( t, err, "game by id not found: 2: game by id not found: %s" )
}
Note that this reuses firstTestCase
.
One integration test Since you have set up the tests to work as integrated, why not create one integration test that makes use of them in the same file? Test the first case of the battery, which is the initial situation anyway:
Copy
func ( suite * IntegrationTestSuite) TestCanPlayAfterCreate ( ) {
suite. setupSuiteWithOneGameForPlayMove ( )
goCtx := sdk. WrapSDKContext ( suite. ctx)
response, err := suite. queryClient. CanPlayMove ( goCtx, canPlayTestRange[ 0 ] . request)
suite. Require ( ) . Nil ( err)
suite. Require ( ) . EqualValues ( firstTestCase. response, response)
}
With these, your function should be covered.
Interact via the CLI A friendly reminder that the CLI can always inform you about available commands:
You can test this query at any point in a game's life.
1
When there is no such game:
Copy
$ checkersd query checkers can-play-move 2048 red 1 2 2 3
Trying this on a game that does not exist returns:
Copy
Error: rpc error: code = InvalidArgument desc = game by id not found: 2048: game by id not found: %s: 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 color:
Copy
$ checkersd tx checkers create-game $alice $bob 1000000 --from $alice -y
$ checkersd query checkers can-play-move 1 white 1 2 2 3
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: white'
This is a proper message response, and a reason elaborating on the message.
3
When you ask for a player out of turn:
Copy
$ checkersd query checkers can-play-move 1 red 0 5 1 4
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:
Copy
$ checkersd query checkers can-play-move 1 black 0 5 1 4
If black tries to play a red piece, it returns:
Copy
possible: false
reason: wrong move%!(EXTRA string=Not {red}s turn)
5
When it is correct:
Copy
$ checkersd query checkers can-play-move 1 black 1 2 2 3
If black tests a correct move, it returns:
Copy
possible: true
reason: ok
6
When the player must capture:
Copy
$ checkersd tx checkers play-move 1 1 2 2 3 --from $bob -y
$ checkersd tx checkers play-move 1 0 5 1 4 --from $alice -y
$ checkersd query checkers can-play-move 1 black 2 3 3 4
If black fails to capture a mandatory red piece, it returns:
Copy
possible: false
reason: 'wrong move%!(EXTRA string=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:
Copy
$ checkersd tx checkers create-game $alice $bob 1000000 --from $alice -y
$ checkersd tx checkers play-move 1 1 2 2 3 --from $bob -y
$ checkersd tx checkers play-move 1 0 5 1 4 --from $alice -y
$ checkersd query checkers can-play-move 1 black 2 3 0 5
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:
Copy
$ checkersd query checkers can-play-move 1 black 2 3 0 5
Now it returns:
Copy
possible: false
reason: game is already finished
These queries are all satisfactory.
Next up Do you want to give players more flexibility about which tokens they can use for games? Let players wager any fungible token in the next section .