ここからゲームを作ります。ただしまだ、お金も時間制限もライブラリもビット演算も出てきません。素朴に書いたらどうなるかをまず見ます。
全部
これで動きます。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract Step2MinimalTicTacToe {
/// 盤面。左上から右下へ。0 = 空、1 = X、2 = O
uint8[9] public board;
address public playerX; // デプロイした人。先手
address public playerO;
uint8 public moves; // 0..9
address public winner; // 引き分けならゼロのまま
bool public finished;
event Played(address indexed player, uint8 cell, uint8 mark);
event Finished(address winner); // ゼロアドレスは引き分け
constructor(address opponent) {
require(opponent != msg.sender, "you cannot play yourself");
playerX = msg.sender;
playerO = opponent;
}
function play(uint8 cell) external {
require(!finished, "the game is over");
require(cell < 9, "no such cell");
require(board[cell] == 0, "that cell is taken");
address expected = moves % 2 == 0 ? playerX : playerO;
require(msg.sender == expected, "not your turn");
uint8 mark = moves % 2 == 0 ? 1 : 2;
board[cell] = mark;
moves += 1;
emit Played(msg.sender, cell, mark);
if (_hasWon(mark)) {
finished = true;
winner = msg.sender;
emit Finished(msg.sender);
} else if (moves == 9) {
finished = true;
emit Finished(address(0));
}
}
function getBoard() external view returns (uint8[9] memory) {
return board;
}
function _hasWon(uint8 mark) private view returns (bool) {
uint8[3][8] memory lines = [
[uint8(0), 1, 2], [uint8(3), 4, 5], [uint8(6), 7, 8], // 横
[uint8(0), 3, 6], [uint8(1), 4, 7], [uint8(2), 5, 8], // 縦
[uint8(0), 4, 8], [uint8(2), 4, 6] // 斜め
];
for (uint256 i = 0; i < 8; ++i) {
if (board[lines[i][0]] == mark && board[lines[i][1]] == mark && board[lines[i][2]] == mark) {
return true;
}
}
return false;
}
}
70 行ほどです。読める分量だと思います。順に見ていきます。
手番を「保存しない」
address expected = moves % 2 == 0 ? playerX : playerO;
「いま誰の番か」を変数に持っていません。手数の偶奇から毎回導出しています。
Web アプリなら currentPlayer カラムを持ちたくなるところです。しかしそれは、moves と currentPlayer という 2 つの状態を常に一致させ続ける責任を負うということでもあります。片方だけ更新するバグは、この手のコードで最もありふれた事故です。
導出できるものは保存しない。 Solidity ではこの原則がさらに強く働きます。保存には費用がかかり、計算には(この程度なら)ほぼかからないからです。
require が事前条件のすべて
require(!finished, "the game is over");
require(cell < 9, "no such cell");
require(board[cell] == 0, "that cell is taken");
require(msg.sender == expected, "not your turn");
4 行で、このゲームのルール違反を全部弾いています。
重要なのは、これがフロントエンドではなくコントラクトに書かれていることです。フロントで「埋まっているマスはクリックできない」ようにしても、それは UX の話でしかありません。誰でも cast send で直接コントラクトを叩けるからです。
フロントは信頼境界ではない。ルールはコントラクトに書く。 Web 開発でいう「サーバ側バリデーション必須」と同じ話ですが、こちらは「サーバ」が公開されていて誰でも直接叩ける分、徹底が要求されます。
msg.sender で認証が終わる
require(msg.sender == expected, "not your turn");
これだけで「対戦相手以外は着手できない」が保証されます。セッションもトークンも要りません。第 5 章で見たとおり、人が直接呼んだときの msg.sender は署名から復元されたアドレスなので、偽装できないからです。
イベントで結果を告知する
emit Played(msg.sender, cell, mark);
play() は値を返せません(トランザクションだから)。だから何が起きたかはイベントで知らせます。フロントエンドはこれを購読するか、単に状態を読み直します。
indexed を付けた player は、後からフィルタできます。「このアドレスが打った手を全部見せて」という検索が、ログのインデックスだけでできます。
テストを書く
Foundry のテストは Solidity です。
contract Step2MinimalTicTacToeTest is Test {
Step2MinimalTicTacToe internal game;
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
function setUp() public {
vm.prank(alice); // 次の呼び出しの msg.sender を alice にする
game = new Step2MinimalTicTacToe(bob);
}
function test_XWinsTheTopRow() public {
vm.prank(alice); game.play(0);
vm.prank(bob); game.play(3);
vm.prank(alice); game.play(1);
vm.prank(bob); game.play(4);
vm.prank(alice); game.play(2);
assertTrue(game.finished());
assertEq(game.winner(), alice);
}
function test_RevertUndoesEverything() public {
vm.prank(alice);
game.play(0);
vm.prank(bob);
vm.expectRevert("no such cell");
game.play(99);
// 失敗した呼び出しは痕跡を残さない
assertEq(game.moves(), 1);
}
}
最後のテストが revert の意味を示しています。失敗した play(99) は moves を進めていません。「途中まで実行された」状態が存在しないことを、テストとして書けます。
トレース付きで走らせると、中で何が起きたか全部見えます。
forge test --match-test test_RejectsATakenCell -vvvv
├─ [38903] Step2MinimalTicTacToe::play(4)
│ ├─ emit Played(player: alice, cell: 4, mark: 1)
│ └─ ← [Stop]
├─ [798] Step2MinimalTicTacToe::play(4)
│ └─ ← [Revert] that cell is taken
左の数字がガス消費です。成功した着手が 38,903、リバートは 798。
ただしこれは内部呼び出しのガスです。トランザクションとしてリバートした場合、基本料金 21,000 とそこまでに実行した分のガス代はそのまま請求されます。返金はありません。「revert したから無料」ではなく、revert したから状態が変わらなかった。代金は払った、ということです。
このコントラクトの限界
動きますが、実用にはほど遠いです。問題を並べます。
1. 1 ゲームにつき 1 デプロイが必要
constructor(address opponent) で相手を固定しているので、別の対戦をするにはデプロイし直すしかありません。デプロイは initcode 2,404 バイトを送って 2,164 バイトをチェーンに残す作業で、ゲーム 1 回あたり数十万ガスかかります。これは高すぎます。
2. お金が絡まない
賭け金を預かる仕組みがありません。
3. 相手が消えたら終わり
playerO が二度と着手しなければ、ゲームは永久に finished == false のままです。いまは何も預けていないので実害がありませんが、賭け金を入れた瞬間に資金が永久に凍結する問題になります。
4. uint8[9] は高い
9 個の uint8 を配列で持つと、Solidity は各要素を…実は 1 スロットに詰めてくれます。しかしアクセスのたびにマスク・シフトのコードが走り、勝敗判定は 8 本 × 3 マス = 24 回のストレージ読み出しになります。全部同じスロットなので cold は 1 回だけ(2,100 + 23 × 100 = 4,400 ガス)ですが、そのたびにマスク・シフトのコードが走り、走査表をメモリに組み立てる分も乗ります。もっと安い表現があります。
次章から、この 4 つを順に潰していきます。完成品のコードにある「なぜこうなっているのか分からない部分」は、すべてこの 4 つへの対処です。