対戦相手が見つからないと遊べない、というのは学習用アプリとして不便です。コントラクト自身を対戦相手にします。
その前に: なぜ賭けなしなのか
実装に入る前に、経済設計の話を片付けておきます。
まるばつは解けているゲームです。 双方が最善を打つと必ず引き分けになります。ゲーム理論でいう「引き分けが均衡」の状態です。
ということは、完璧な CPU に同額を賭けて挑んだ場合、プレイヤーの結果は次のどちらかにしかなりません。
- 自分も最善を打つ → 引き分け → 返金(増えない)
- どこかでミスする → 負け → 賭け金を失う
勝てる可能性がゼロです。 最良でも収支ゼロ、そこにガス代が乗るので期待値は必ずマイナスになります。上振れが構造的に存在しない賭けは、「難しいゲーム」ではなく「成立しない賭け」です。
技術的には実装できます。CPU の賭け金を胴元のバンクロールから出せばいい。しかしそれは、プレイヤーが構造的に勝てないギャンブルを作るということです。だから本書の CPU 戦は賭けなしにしました。ガス代だけで遊べます。
実装できることと、成立する仕組みであることは別。 これはスマートコントラクトを書くうえで繰り返し出てくる論点で、まるばつのような小さな題材でも実例が作れるのが面白いところです。
では対人戦の賭けは公平なのか
ここは正直に書いておきます。同じ理屈は、対人戦にもかなりの部分まで当てはまります。
まるばつが解けている以上、完璧に打つ相手には誰も勝てません。それが Solidity で書かれた CPU でも、同じ戦略を実行するボットでも同じです。しかも本書は previewCpuMove を pure な公開関数として出しているので、対人戦の相手は、無料でこの完璧な戦略を参照しながら打てます。対人戦に賭け金を置いたのは「公平だから」ではなく、両者が対等に強くなれる余地があるからにすぎません。
さらに、本書の対人戦には構造的な非対称が 2 つあります。
- 作成者が必ず先手(X)です。 交代もコイントスもありません。互角でない者同士なら、先手はそれ自体が有利です
- 持ち時間を決めるのも作成者です。 下限は 1 分なので、短い持ち時間を設定して、参加してすぐ席を立った相手から不戦勝を拾うことができます
参加する側は、賭ける前に持ち時間を確認してください。 「実装できる」と「公平な賭けである」が別だという話は、CPU 戦だけの話ではありません。
そして本書がこれをテストネットに留めている理由は、公平性だけでもありません。本物の資金で同じことをすると、日本では賭博罪の問題が生じます(第 19 章)。
同じトランザクションの中で応手する
function play(uint256 gameId, uint8 cell) external {
// ... 検証 ...
uint16 boardPlayer = g.boardPlayer | Board.bit(cell);
g.boardPlayer = boardPlayer;
uint8 moves = g.moves + 1;
uint8 cpuCell = Board.NONE;
Result result = Result.InProgress;
if (Board.isWin(boardPlayer)) {
result = Result.PlayerWon;
} else if (moves == 9) {
result = Result.Draw;
} else {
cpuCell = _chooseCpuCell(g.boardCpu, boardPlayer, g.difficulty, _seed(gameId, moves));
uint16 boardCpu = g.boardCpu | Board.bit(cpuCell);
g.boardCpu = boardCpu;
moves += 1;
if (Board.isWin(boardCpu)) result = Result.CpuWon;
else if (moves == 9) result = Result.Draw;
}
g.moves = moves;
g.result = result;
emit MovePlayed(gameId, msg.sender, cell, cpuCell, result);
if (result != Result.InProgress) emit GameFinished(gameId, result);
}
細かいようですが、boardCpu をいったんローカル変数に置いてから判定しています。書いた直後に g.boardCpu を読み直す形でも最適化器がたいてい潰してくれますが、最適化器に頼らずとも一度しかストレージに触らない形にしてあります。boardPlayer 側も同じ書き方で揃えています。
プレイヤーの着手を適用し、勝敗を見て、終わっていなければ その場で CPU の手を計算して打ちます。トランザクションは 1 回。相手を待つ必要がありません。
代償として、プレイヤーが CPU の思考コストを負担します。ガス代に乗ってくるので、AI が重ければ重いほど遊ぶのが高くなる。この制約が次の設計判断を決めます。
ミニマックスは重すぎる
ゲーム AI の定番はミニマックス探索です。まるばつの木は、実際のルール(3 つ揃った時点で終了)で 549,946 ノード、完了するゲームは 255,168 通りあります。よく引かれる 9! = 362,880 は「勝敗を無視して 9 マスを埋める順列」で、ゲーム数の緩い上限にすぎません。いずれにせよ、枝刈りしてもかなり残ります。
これをオンチェーンでやると、1 手あたり数百万ガス。L1 では現実的でなく、L2 でも「たかがまるばつ」に払う額ではありません。
代わりに、定石をルールとして書き下します。ニューウェルとサイモンが 1972 年に定式化した手続きで、探索なしに完璧に打てます。
3 段階の強さ
function _chooseCpuCell(uint16 mine, uint16 theirs, Difficulty difficulty, uint256 seed)
private pure returns (uint8)
{
uint16 empty = Board.emptyMask(mine, theirs);
// どの強さでも、勝てる手があれば勝つ
uint8 cell = _winningCell(mine, empty);
if (cell != Board.NONE) return cell;
if (difficulty == Difficulty.Easy) {
return _randomCell(empty, seed);
}
// 詰みを防ぐ
cell = _winningCell(theirs, empty);
if (cell != Board.NONE) return cell;
if (difficulty == Difficulty.Normal) {
// フォークを見ない。これが Normal が負ける理由
return _preferredCell(empty);
}
return _hardCell(mine, theirs, empty);
}
段階を「別のアルゴリズム」ではなく「同じ手続きをどこまで進めるか」で作っているのがポイントです。
| 強さ | 見るもの | 結果 |
|---|---|---|
| 弱 | 自分の勝ちだけ | 防御しない。すぐ勝てる |
| 中 | 勝ち + 詰み防ぎ | フォークを作れば人間が勝てる |
| 強 | 全部 | 理論上負けない |
「中」が一番面白い設定です。二重の狙い(フォーク)を作れば勝てるので、プレイヤーに学びがあります。手順はこうです。角 → 対角の角 → 3 つ目の角。これで 2 本のリーチが同時に立ち、CPU は片方しか防げません。
フォークの判定
「フォークを作る手」とは、打った後に勝ち筋が 2 つ以上できる手です。素直に書けます。
function _threatCount(uint16 mine, uint16 empty) private pure returns (uint8 count) {
for (uint8 cell = 0; cell < 9; ++cell) {
if (!Board.contains(empty, cell)) continue;
if (Board.isWin(mine | Board.bit(cell))) ++count;
}
}
function _forkCells(uint16 mine, uint16 empty) private pure returns (uint16 forks) {
for (uint8 cell = 0; cell < 9; ++cell) {
if (!Board.contains(empty, cell)) continue;
uint16 next = mine | Board.bit(cell);
uint16 rest = empty & ~Board.bit(cell);
if (_threatCount(next, rest) >= 2) forks |= Board.bit(cell);
}
}
最大でも 9 × 9 × 8 回のマスク比較を、_forkCells 2 回と _forcingCell で繰り返します。第 13 章のビットマスク化が、ここで効いています。
ただし計算だけでもタダではありません。Board.isWin が呼ばれるたびに uint16[8] をメモリへ作り直すので、空盤面での Hard は実測 32 万ガスかかります。後段の表の previewCpuMove 最大値 323,438 がそれです。ストレージに触らなければ安い、とは限りません。
相手のフォークを防ぐ
自分がフォークを作れないときは、相手のフォークを潰します。ここが定石の一番細かいところです。
uint16 theirForks = _forkCells(theirs, empty);
if (theirForks != 0) {
// フォーク地点が 1 つなら、そこを取ればよい
if (_isSingleCell(theirForks)) return _onlyCell(theirForks);
// 複数ある場合、1 つ塞いでも他で作られる。
// 代わりに自分がリーチを作り、相手に受けを強制する。
// ただし相手が受けるマスがフォーク地点だと逆効果なので、そうでない手を選ぶ。
uint8 forcing = _forcingCell(mine, empty, theirForks);
if (forcing != Board.NONE) return forcing;
}
_forcingCell は「リーチがちょうど 1 本立ち、かつ相手が塞がされるマスがフォーク地点でない手」を探します。
function _forcingCell(uint16 mine, uint16 empty, uint16 theirForks) private pure returns (uint8) {
for (uint8 cell = 0; cell < 9; ++cell) {
if (!Board.contains(empty, cell)) continue;
uint16 next = mine | Board.bit(cell);
uint16 rest = empty & ~Board.bit(cell);
if (_threatCount(next, rest) != 1) continue;
uint8 reply = _winningCell(next, rest);
if (!Board.contains(theirForks, reply)) return cell;
}
return Board.NONE;
}
相手に「受けなければ負ける」手を打たせることで、フォークを作る手番を奪うわけです。ここを間違えると強さが崩れるので、テストで担保します(後述)。
位置の優先順位
同点の候補が複数あるときは、決まった順で選びます。
function _preferredCell(uint16 candidates) private pure returns (uint8) {
uint8[9] memory order = [4, 0, 2, 6, 8, 1, 3, 5, 7];
for (uint256 i = 0; i < 9; ++i) {
if (Board.contains(candidates, order[i])) return order[i];
}
revert BoardFull();
}
中央 → 隅 → 辺。理由はそのマスを通る勝利ラインの本数です。中央は 4 本、隅は 3 本、辺は 2 本。同じ 1 手なら、多くのラインに関わるマスのほうが価値が高い。
オンチェーンの乱数は危険
「弱」はランダムに打ちます。しかしチェーン上に安全な乱数はありません。
/// @dev NOT secure randomness. A validator can nudge `blockhash`, and anyone can
/// simulate this call before sending it. That is acceptable here only
/// because nothing is at stake.
function _seed(uint256 gameId, uint8 moves) private view returns (uint256) {
return uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), address(this), gameId, moves)));
}
この実装が安全でない理由は 2 つです。
1. blockhash は完全に予測不能ではない。 blockhash(block.number - 1) は親ブロックのハッシュなので、このトランザクションを取り込む提案者にはもう動かせません。しかし一つ前のブロックの提案者は、自分のブロックの中身を調整してハッシュを選べます。提案者の当番表は 1 エポック前から公開されているので、連続したスロットを持つ提案者は狙って仕込めます。
2. 誰でも事前にシミュレートできる。 トランザクションを送る前に eth_call で結果を試せます。気に入らない結果なら送らなければいい。「送らない自由」があるだけで、乱数は乱数でなくなります。
だから、払い出しが乱数で決まる仕組みには絶対に使ってはいけません。ガチャ、抽選、ランダムな報酬。全部だめです。
blockhash の代わりに block.prevrandao、block.timestamp、block.number、keccak256(abi.encodePacked(msg.sender, ...)) を使っても同じことです。すべて、トランザクションを送る前に結果が分かります。「送らない自由」がある限り、オンチェーンで生成した値は乱数になりません。
そしてこの SoloTicTacToe に賭け金を足すのは、やってはいけない改造の筆頭です。Easy の着手が読めるということは、勝敗が読めるということです。
まともにやるなら、
- コミット・リビール — 参加者が先に値のハッシュを提出し、締切後に元の値を公開して合成する
- VRF(Chainlink VRF など) — 検証可能な乱数をオラクルから受け取る
本書で素朴な実装を使っているのは、賭けが一切ないからです。最悪の場合に起きることは「弱い CPU が少し都合よく打つ」だけ。コードにもその旨をコメントで明記してあります。この判断を書き残しておかないと、後から誰かがコピーして事故ります。
「絶対に負けない」を証明する
前章でも触れましたが、ここが CPU 実装の白眉です。
Hard は決定論的なので、分岐するのは人間の手番だけです。全部で 1,300 通り強。全探索できます。
function test_HardNeverLoses() public view {
_explore(0, 0, true); // 人間が先手
uint8 opening = solo.previewCpuMove(0, 0, HARD, 0);
_explore(Board.bit(opening), 0, true); // CPU が先手
}
[PASS] test_HardNeverLoses() (gas: 22627723)
コメントに「この戦略は完璧です」と書くのと、これがあるのとでは意味が違います。 _forcingCell の条件を 1 つ緩めれば、その瞬間にテストが落ちる。
難易度の差もテストする
「強いこと」と同じくらい、「意図した弱さがあること」も大事です。
function test_NormalLosesToAFork() public {
uint256 gameId = _newGame(NORMAL, false);
_play(gameId, 0); // 隅
_play(gameId, 8); // 対角の隅
_play(gameId, 6); // 3 つ目の隅 → 二重の狙い
_play(gameId, 7); // 勝ち
assertEq(uint8(_result(gameId)), uint8(SoloTicTacToe.Result.PlayerWon));
}
同じ手順を Hard に投げると通らない。難易度の差が実在することの証明になります。
実測
| Function Name | Min | Avg | Max |
| newGame | 69840 | 102221 | 393655 |
| play | 26246 | 71994 | 289425 |
| previewCpuMove | 1583 | 21176 | 323438 |
play の平均が 71,994。対人戦の play(42,535)と比べると、CPU の思考が約 3 万ガスです。中盤の複雑な局面が最大値を押し上げています。
ゲーム AI をオンチェーンに載せるコストは、この程度なら許容範囲です。ただしこれはまるばつだからこそであって、探索が必要なゲームでは成立しません。オンチェーンで完結させるか、計算をオフチェーンに出して結果だけ検証するかは、ゲームの複雑さで決まります。
まとめ
- CPU の応手を同じトランザクション内で返せば、待ち時間がない
- 代わりにプレイヤーが AI の思考コストを払う。探索は重すぎる
- 定石をルール化すれば、探索なしで完璧に打てる
- 難易度は「同じ手続きをどこまで進めるか」で作る
- オンチェーンに安全な乱数はない。払い出しが絡むなら commit-reveal か VRF
- 決定論的なロジックは全探索で性質を証明できる
- 解けているゲームに賭けを載せてはいけない。実装できることと成立することは別