diff --git a/contracts/foundry.toml b/contracts/foundry.toml index ee05bba..5a5e420 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -2,6 +2,7 @@ src = "src" out = "out" libs = ["lib"] +evm_version = "cancun" fs_permissions = [ { access='read-write', path='../generated' }, @@ -11,4 +12,4 @@ remappings = [ "@contracts-bedrock/=lib/optimism/packages/contracts-bedrock/src/", "@solady/=lib/optimism/packages/contracts-bedrock/lib/solady/src/", "@openzeppelin/contracts=lib/optimism/packages/contracts-bedrock/lib/openzeppelin-contracts/contracts" -] \ No newline at end of file +] diff --git a/contracts/script/tictactoe/Deploy.s.sol b/contracts/script/tictactoe/Deploy.s.sol new file mode 100644 index 0000000..0e0ef68 --- /dev/null +++ b/contracts/script/tictactoe/Deploy.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {TicTacToe} from "../../src/tictactoe/TicTacToe.sol"; + +contract DeployScript is Script { + function setUp() public {} + + function run() public { + vm.broadcast(); + TicTacToe game = new TicTacToe{ salt: "tictactoe" }(); + console.log("Deployed at: ", address(game)); + } +} diff --git a/contracts/src/tictactoe/TicTacToe.sol b/contracts/src/tictactoe/TicTacToe.sol new file mode 100644 index 0000000..f2b0178 --- /dev/null +++ b/contracts/src/tictactoe/TicTacToe.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { Predeploys } from "@contracts-bedrock/libraries/Predeploys.sol"; +import { ICrossL2Inbox } from "@contracts-bedrock/L2/interfaces/ICrossL2Inbox.sol"; + +/// @notice Thrown when cross l2 origin is not the TicTacToe contract +error IdOriginNotTicTacToe(); + +/// @notice Thrown when the reference chain is mismatched +error IdChainMismatch(); + +/// @notice Thrown when a player tries to play themselves; +error SenderIsOpponent(); + +/// @notice Thrown when the exepcted event is not NewGame +error DataNotNewGame(); + +/// @notice Thrown when the exepcted event is not AcceptedGame +error DataNotAcceptedGame(); + +/// @notice Thrown when the exepcted event is not MovePlayed +error DataNotMovePlayed(); + +/// @notice Thrown when the caller is not allowed to act +error SenderNotPlayer(); + +/// @notice Thrown when trying to start a game on the wrong chain +error GameChainMismatch(); + +/// @notice Thrown when the game has already been started +error GameStarted(); + +/// @notice Thrown when a game does not exist +error GameNotExists(); + +/// @notice Thrown when the player makes an invalid move +error MoveInvalid(); + +/// @notice Thrown when the consumed event is not forward progressing the game. +error MoveNotForwardProgressing(); + +/// @notice Thrown when the player make a move that's already been played +error MoveTaken(); + +/// @title TicTacToe +/// @notice TicTacToe is a Superchain interoprable implementation of TicTacToe where players +/// can play each other from any two chains in each others interopable dependency sets +/// without needing to pass messages between themselves. Since a chain is be default in +/// its own dependency set, players on the same chain can also play each other :) +contract TicTacToe { + uint256 public nextGameId; + + /// @notice Magic Square: https://mathworld.wolfram.com/MagicSquare.html + uint8[3][3] private MAGIC_SQUARE = [[8, 3, 4], [1, 5, 9], [6, 7, 2]]; + uint8 private constant MAGIC_SUM = 15; + + /// @notice Structure for a local view of a game + struct Game { + address player; + address opponent; + + // `1` for the player's moves, `2` opposing. + uint8[3][3] moves; + uint8 movesLeft; + + ICrossL2Inbox.Identifier lastId; + } + + /// @notice A game is identifed from the (chainId, gameId) tuple from the chain it was initiated on + /// Since players on the same chain can play each other, we need to subspace by address as well. + mapping(uint256 => mapping(uint256 => mapping(address => Game))) games; + + /// @notice Emitted when broadcasting a new game invitation. Anyone is allowed to accept + event NewGame(uint256 chainId, uint256 gameId, address player); + + /// @notice Emitted when a player accepts an opponent's game + event AcceptedGame(uint256 chainId, uint256 gameId, address opponent, address player); + + /// @notice Emitted when a player makes a move in a game + event MovePlayed(uint256 chainId, uint256 gameId, address player, uint8 _x, uint8 _y); + + /// @notice Emitted when a player has won the game with their latest move + event GameWon(uint256 chainId, uint256 gameId, address winner, uint8 _x, uint8 _y); + + /// @notice Emitted when all spots on the board were played with no winner with their lastest move + event GameDraw(uint256 chainId, uint256 gameId, uint8 _x, uint8 _y); + + /// @notice Creates a new game that any player can accept + function newGame() external { + emit NewGame(block.chainid, nextGameId, msg.sender); + nextGameId++; + } + + /// @notice Send out an acceptance event for a new game + function acceptGame(ICrossL2Inbox.Identifier calldata _newGameId, bytes calldata _newGameData) external { + // Validate Cross Chain Log + if (_newGameId.origin != address(this)) revert IdOriginNotTicTacToe(); + ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_newGameId, keccak256(_newGameData)); + + // Decode `NewGame` Event + bytes32 selector = abi.decode(_newGameData[:32], (bytes32)); + if (selector != NewGame.selector) revert DataNotNewGame(); + + (uint256 chainId, uint256 gameId, address opponent) = abi.decode(_newGameData[32:], (uint256, uint256, address)); + if (opponent == msg.sender) revert SenderIsOpponent(); + + // Record Game Metadata (no moves) + Game storage game = games[chainId][gameId][msg.sender]; + game.player = msg.sender; + game.opponent = opponent; + game.lastId = _newGameId; + game.movesLeft = 9; + + emit AcceptedGame(chainId, gameId, game.opponent, game.player); + } + + /// @notice Start a game accepted by an opponent with a starting move + function startGame(ICrossL2Inbox.Identifier calldata _acceptedGameId, bytes calldata _acceptedGameData, uint8 _x, uint8 _y) external { + // Validate Cross Chain Log + if (_acceptedGameId.origin != address(this)) revert IdOriginNotTicTacToe(); + ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_acceptedGameId, keccak256(_acceptedGameData)); + + // Decode `AcceptedGame` event + bytes32 selector = abi.decode(_acceptedGameData[:32], (bytes32)); + if (selector != AcceptedGame.selector) revert DataNotAcceptedGame(); + + (uint256 chainId, uint256 gameId, address player, address opponent) = // player, opponent swapped in local view + abi.decode(_acceptedGameData[32:], (uint256,uint256,address,address)); + + // The accepted game was started from this chain, from the sender + if (chainId != block.chainid) revert GameChainMismatch(); + if (msg.sender != player) revert SenderNotPlayer(); + + // Game has not already been started with an opponent. + Game storage game = games[chainId][gameId][msg.sender]; + if (game.opponent != address(0)) revert GameStarted(); + + // Record Game Metadata + game.player = msg.sender; + game.opponent = opponent; + game.lastId = _acceptedGameId; + game.movesLeft = 9; + + // Make the first move (any spot on the board) + if (_x >= 3 || _y >= 3) revert MoveInvalid(); + game.moves[_x][_y] = 1; + game.movesLeft--; + emit MovePlayed(chainId, gameId, game.player, _x, _y); + } + + /// @notice Make a move for a game. + function makeMove(ICrossL2Inbox.Identifier calldata _movePlayedId, bytes calldata _movePlayedData, uint8 _x, uint8 _y) external { + // Validate Cross Chain Log + if (_movePlayedId.origin != address(this)) revert IdOriginNotTicTacToe(); + ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_movePlayedId, keccak256(_movePlayedData)); + + // Decode `MovePlayed` event + bytes32 selector = abi.decode(_movePlayedData[:32], (bytes32)); + if (selector != MovePlayed.selector) revert DataNotMovePlayed(); + + (uint256 chainId, uint256 gameId,, uint8 oppX, uint8 oppY) = + abi.decode(_movePlayedData[32:], (uint256,uint256,address,uint8,uint8)); + + // Game was instantiated for this player + Game storage game = games[chainId][gameId][msg.sender]; + if (game.player != msg.sender) revert GameNotExists(); + + // The move played is forward progressing from the same chain + if (_movePlayedId.chainId != game.lastId.chainId) revert IdChainMismatch(); + if (_movePlayedId.blockNumber <= game.lastId.blockNumber) revert MoveNotForwardProgressing(); + game.lastId = _movePlayedId; + + // NOTE: Since the supplied move is valid, `movesLeft > 0` as the code below will emit + // `GameDrawn` when there are no moves left to play and `GameWon` on the winning move + + // Mark the opponents move + game.moves[oppX][oppY] = 2; + game.movesLeft--; + + // Make a move and mark the latest seen opposing move. + if (_x >= 3 || _y >= 3) revert MoveInvalid(); + if (game.moves[_x][_y] != 0) revert MoveTaken(); + game.moves[_x][_y] = 1; + game.movesLeft--; + + if (_isGameWon(game)) { + emit GameWon(chainId, gameId, game.player, _x, _y); + } + else if (game.movesLeft == 0) { + emit GameDraw(chainId, gameId, _x, _y); + } + else { + emit MovePlayed(chainId, gameId, game.player, _x, _y); + } + } + + function gameState(uint256 chainId, uint256 gameId, address player) public view returns (Game memory) { + return games[chainId][gameId][player]; + } + + /// @notice helper to check if a game has been won for the game's local player; moves == 1 + function _isGameWon(Game memory _game) internal view returns (bool) { + // Check for a row/col win + for (uint8 i = 0; i < 3; i++) { + uint8 rowSum = + (_game.moves[i][0] * MAGIC_SQUARE[i][0]) + + (_game.moves[i][1] * MAGIC_SQUARE[i][1]) + + (_game.moves[i][2] * MAGIC_SQUARE[i][2]); + + if (rowSum == MAGIC_SUM) return true; + + uint8 colSum = + (_game.moves[0][i] * MAGIC_SQUARE[0][i]) + + (_game.moves[1][i] * MAGIC_SQUARE[1][i]) + + (_game.moves[2][i] * MAGIC_SQUARE[2][i]); + + if (colSum == MAGIC_SUM) return true; + } + + // Check for a diag win + uint8 leftToRightDiagSum = + (_game.moves[0][0] * MAGIC_SQUARE[0][0]) + + (_game.moves[1][1] * MAGIC_SQUARE[1][1]) + + (_game.moves[2][2] * MAGIC_SQUARE[2][2]); + + if (leftToRightDiagSum == MAGIC_SUM) return true; + + uint8 rightToLeftDiagSum = + (_game.moves[0][2] * MAGIC_SQUARE[0][2]) + + (_game.moves[1][1] * MAGIC_SQUARE[1][1]) + + (_game.moves[2][0] * MAGIC_SQUARE[2][0]); + + if (rightToLeftDiagSum == MAGIC_SUM) return true; + + return false; + } +} diff --git a/contracts/test/tictactoe/TicTacToe.t.sol b/contracts/test/tictactoe/TicTacToe.t.sol new file mode 100644 index 0000000..3947c43 --- /dev/null +++ b/contracts/test/tictactoe/TicTacToe.t.sol @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; + +import { Predeploys } from "@contracts-bedrock/libraries/Predeploys.sol"; +import { ICrossL2Inbox } from "@contracts-bedrock/L2/interfaces/ICrossL2Inbox.sol"; + +import { TicTacToe } from "../../src/tictactoe/TicTacToe.sol"; +import { + IdOriginNotTicTacToe, + DataNotNewGame, + DataNotAcceptedGame, + GameChainMismatch, + GameNotExists, + SenderNotPlayer, + MoveInvalid, + MoveTaken +} from "../../src/tictactoe/TicTacToe.sol"; + +contract TicTacToeTest is Test { + function test_newGame() public { + TicTacToe game = new TicTacToe(); + uint256 expectedGameId = game.nextGameId(); + + vm.recordLogs(); + + game.newGame(); + assertEq(expectedGameId + 1, game.nextGameId()); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], TicTacToe.NewGame.selector); + assertEq(logs[0].data, abi.encode(block.chainid, expectedGameId, address(this))); + } + + function testFuzz_acceptGame_succeeds(uint256 chainId, uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + ICrossL2Inbox.Identifier memory newGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory newGameData = abi.encodePacked(TicTacToe.NewGame.selector, abi.encode(chainId, gameId, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, newGameId, newGameData), + returnData: "" + }); + + vm.recordLogs(); + game.acceptGame(newGameId, newGameData); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], TicTacToe.AcceptedGame.selector); + assertEq(logs[0].data, abi.encode(chainId, gameId, opponent, address(this))); + + TicTacToe.Game memory state = game.gameState(chainId, gameId, address(this)); + assertEq(state.player, address(this)); + assertEq(state.opponent, opponent); + assertEq(state.movesLeft, 9); + for (uint8 i = 0; i < 3; i++) { + for (uint8 j = 0; j < 3; j++) { + assertEq(state.moves[i][j], 0); + } + } + + assertEq(abi.encode(state.lastId), abi.encode(newGameId)); + } + + function testFuzz_acceptGame_invalidIdOrigin_reverts(uint256 chainId, uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + + // Use an invalid origin. This couldn't occur due CrossL2Inbox invalidation but we test for it anyways + ICrossL2Inbox.Identifier memory newGameId = ICrossL2Inbox.Identifier(address(this), 0, 0, 0, chainId); + bytes memory newGameData = abi.encodePacked(TicTacToe.NewGame.selector, abi.encode(chainId, gameId, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, newGameId, newGameData), + returnData: "" + }); + + vm.expectRevert(IdOriginNotTicTacToe.selector); + game.acceptGame(newGameId, newGameData); + } + + function testFuzz_acceptGame_invalidSelector_reverts(uint256 chainId, uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + + // Use an invalid selector. This couldn't occur due CrossL2Inbox invalidation but we test for it anyways + ICrossL2Inbox.Identifier memory newGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory newGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, newGameId, newGameData), + returnData: "" + }); + + vm.expectRevert(DataNotNewGame.selector); + game.acceptGame(newGameId, newGameData); + } + + function testFuzz_startGame_succeeds(uint256 oppChainId, uint256 gameId, address opponent, uint8 x, uint8 y) public { + TicTacToe game = new TicTacToe(); + vm.assume(x < 3 && y < 3); + + // Even though the chainIds can be the same, differ for the test + vm.assume(oppChainId != block.chainid); + uint256 chainId = block.chainid; + address player = address(this); + + // player is the opponent in the AcceptedGame event + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, oppChainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, player, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + vm.recordLogs(); + game.startGame(acceptGameId, acceptGameData, x, y); + + TicTacToe.Game memory state = game.gameState(chainId, gameId, player); + assertEq(state.player, address(this)); + assertEq(state.opponent, opponent); + assertEq(state.movesLeft, 8); // single move has been made + assertEq(state.moves[x][y], 1); + for (uint8 i = 0; i < 3; i++) { + for (uint8 j = 0; j < 3; j++) { + if (i == x && j == y) continue; + assertEq(state.moves[i][j], 0); + } + } + + assertEq(abi.encode(state.lastId), abi.encode(acceptGameId)); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], TicTacToe.MovePlayed.selector); + assertEq(logs[0].data, abi.encode(chainId, gameId, player, x, y)); + } + + function testFuzz_startGame_invalidIdOrigin_reverts(uint256 gameId, address opponent, uint8 x, uint8 y) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + + // Use an invalid origin. This couldn't occur due CrossL2Inbox invalidation but we test for it anyways + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(this), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + vm.expectRevert(IdOriginNotTicTacToe.selector); + game.startGame(acceptGameId, acceptGameData, x, y); + } + + function testFuzz_startGame_invalidSelector_reverts(uint256 gameId, address opponent, uint8 x, uint8 y) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + + // Use an invalid selector. This couldn't occur due CrossL2Inbox invalidation but we test for it anyways + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.NewGame.selector, abi.encode(chainId, gameId, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + vm.expectRevert(DataNotAcceptedGame.selector); + game.startGame(acceptGameId, acceptGameData, x, y); + } + + function testFuzz_startGame_differentChain_reverts(uint256 oppChainId, uint256 gameId, address opponent, uint8 x, uint8 y) public { + TicTacToe game = new TicTacToe(); + address player = address(this); + + // An accepted game that was started on oppChainId and not block.chainId + vm.assume(oppChainId != block.chainid); + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, oppChainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(oppChainId, gameId, opponent, player)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + vm.expectRevert(GameChainMismatch.selector); + game.startGame(acceptGameId, acceptGameData, x, y); + } + + function testFuzz_startGame_incorrectSender_reverts(uint256 gameId, address opponent, uint8 x, uint8 y) public { + TicTacToe game = new TicTacToe(); + + // This test is not authorized to start the game + uint256 chainId = block.chainid; + address player = address(game); + + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, opponent, player)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + vm.expectRevert(SenderNotPlayer.selector); + game.startGame(acceptGameId, acceptGameData, x, y); + } + + function testFuzz_startGame_invalidMove_reverts(uint256 gameId, address opponent, uint8 x, uint8 y) public { + vm.assume(x >= 3 || y >= 3); + + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + address player = address(this); + + // player is the opponent in the AcceptedGame event + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, player, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + vm.expectRevert(MoveInvalid.selector); + game.startGame(acceptGameId, acceptGameData, x, y); + } + + function testFuzz_makeMove_succeeds(uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + address player = address(this); + + // Moves made in a diagnol since fuzzing for 3 random points doess not work + + // Start a game from the local chain + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, player, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + game.startGame(acceptGameId, acceptGameData, 0, 0); + + // Play a move after the opponent + ICrossL2Inbox.Identifier memory movePlayId = ICrossL2Inbox.Identifier(address(game), 1, 0, 0, chainId); + bytes memory movePlayData = abi.encodePacked(TicTacToe.MovePlayed.selector, abi.encode(chainId, gameId, opponent, 1, 1)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, movePlayId, movePlayData), + returnData: "" + }); + + vm.recordLogs(); + game.makeMove(movePlayId, movePlayData, 2,2); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + TicTacToe.Game memory state = game.gameState(chainId, gameId, player); + assertEq(state.movesLeft, 6); + assertEq(state.moves[0][0], 1); // first starting move + assertEq(state.moves[1][1], 2); // second opponents move + assertEq(state.moves[2][2], 1); // third move + + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], TicTacToe.MovePlayed.selector); + assertEq(logs[0].data, abi.encode(chainId, gameId, player, 2, 2)); + } + + function testFuzz_makeMove_nonExistentGame_reverts(uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + + // Game was never started or accepted + ICrossL2Inbox.Identifier memory movePlayId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory movePlayData = abi.encodePacked(TicTacToe.MovePlayed.selector, abi.encode(chainId, gameId, opponent, 1, 1)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, movePlayId, movePlayData), + returnData: "" + }); + + vm.expectRevert(GameNotExists.selector); + game.makeMove(movePlayId, movePlayData, 0, 0); + } + + function testFuzz_makeMove_invalidMove_reverts(uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + address player = address(this); + + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, player, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + game.startGame(acceptGameId, acceptGameData, 0, 0); + + ICrossL2Inbox.Identifier memory movePlayId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory movePlayData = abi.encodePacked(TicTacToe.MovePlayed.selector, abi.encode(chainId, gameId, opponent, 1, 1)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, movePlayId, movePlayData), + returnData: "" + }); + + vm.expectRevert(MoveInvalid.selector); + game.makeMove(movePlayId, movePlayData, 3, 3); + } + + function testFuzz_makeMove_moveTaken_reverts(uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + address player = address(this); + + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, player, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + + game.startGame(acceptGameId, acceptGameData, 0, 0); + + ICrossL2Inbox.Identifier memory movePlayId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory movePlayData = abi.encodePacked(TicTacToe.MovePlayed.selector, abi.encode(chainId, gameId, opponent, 1, 1)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, movePlayId, movePlayData), + returnData: "" + }); + + // Play the same move as the opponent + vm.expectRevert(MoveTaken.selector); + game.makeMove(movePlayId, movePlayData, 1, 1); + } + + function testFuzz_makeMove_gameWon_succeeds(uint256 gameId, address opponent) public { + TicTacToe game = new TicTacToe(); + uint256 chainId = block.chainid; + address player = address(this); + + ICrossL2Inbox.Identifier memory acceptGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory acceptGameData = abi.encodePacked(TicTacToe.AcceptedGame.selector, abi.encode(chainId, gameId, player, opponent)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, acceptGameId, acceptGameData), + returnData: "" + }); + game.startGame(acceptGameId, acceptGameData, 0, 0); + + // Play Row 0 for player and Row 1 for opponent + Vm.Log[] memory logs; + for (uint8 i = 1; i < 3; i++) { + ICrossL2Inbox.Identifier memory movePlayId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); + bytes memory movePlayData = abi.encodePacked(TicTacToe.MovePlayed.selector, abi.encode(chainId, gameId, opponent, 2, i)); + vm.mockCall({ + callee: Predeploys.CROSS_L2_INBOX, + data: abi.encodeWithSelector(ICrossL2Inbox.validateMessage.selector, movePlayId, movePlayData), + returnData: "" + }); + + if (i == 2) { + vm.recordLogs(); + } + game.makeMove(movePlayId, movePlayData, 0, i); + if (i == 2) { + logs = vm.getRecordedLogs(); + } + } + + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], TicTacToe.GameWon.selector); + assertEq(logs[0].data, abi.encode(chainId, gameId, player, 0, 2)); + } + + // TODO: unit test GameDrawn +}