検査の順序が必要な理由

AIが座標を正しく読んでも、その手が合法である保証はありません。ルールの検査は、モデルの確率から切り離された決定的なコードでなければなりません。同じ盤と同じ座標を入れたら常に同じ結果が出てこそ、テストや障害の再現ができます。

ステップ1: 範囲と空点を確認します

まず行と列が0〜18の範囲にあるかを検査します。次にその交点が空いているかを見ます。ここで失敗すれば盤をコピーしたり石を取り除いたりする必要がないので、最も安い検査を先に置きます。

ステップ2: 一時的な盤で石の取りを計算します

現在の盤をコピーして候補の座標に石を置き、上下左右の相手の石の塊を探します。呼吸点が0の塊は一時的な盤から取り除きます。複数の塊が同時に取られることもあるので、最初の取りで検査を終えてはいけません。

  1. 候補の石を一時的な盤に置きます。
  2. 隣接する相手の石の塊と呼吸点を探します。
  3. 呼吸点のない相手の塊をすべて取り除きます。
  4. 候補の石が属する自分の塊の呼吸点を数え直します。

ステップ3: 自殺手とコウを区別します

相手の石を取り除いた後も自分の塊の呼吸点が0なら、自殺手として拒否します。取りによって新しい呼吸点ができたなら合法手です。最後に、一時的な盤全体が直前の盤と同じかを比べ、すぐに取り返すコウを防ぎます。座標一つを永久に禁止する方法は、盤が変わった後の合法的な着手まで防いでしまうので、盤の状態を比べる必要があります。

ステップ4: 拒否の理由をモデルへの要求に返します

反則の座標を除外リストに入れるだけでなく、「すでに石がある」「自殺手」「コウの繰り返し」のように理由も一緒に記録します。再要求には拒否した座標を明示して、同じ答えの繰り返しを減らします。最大再試行回数を超えたら、サーバーは勝手な手を打つ代わりにユーザーに失敗を知らせ、盤と応答を障害ログに残します。

必ず作るテスト

  • 隅の石一つが取られる盤
  • 二つの塊を同時に取る盤
  • 相手を取るので自殺手にならない手
  • 本当に自殺手になる手
  • すぐに取り返すことだけが禁止されるコウ

この五つの盤は、モデルなしでルール関数だけを呼んで確認できなければなりません。

この段階の実際のコード

Qt クライアントのルールエンジンです。範囲と空点を先に見て、コピーした盤で相手の石をすべて取った後に自殺手を検査し、最後に過去に現れた盤全体と比べてコウを防ぎます。拒否の理由は MoveRejectReason で返します。

src/goboard.cppc++ · 66行
bool GoBoard::isLegalMove(int row, int col, Stone who, MoveRejectReason *outReason) const
{
    auto setReason = [outReason](MoveRejectReason r) {
        if (outReason)
            *outReason = r;
    };
    setReason(MoveRejectReason::None);

    if (m_status != GameStatus::Playing || m_phase != GamePhase::Playing) {
        setReason(MoveRejectReason::GameNotPlaying);
        return false;
    }
    const int N = m_size;
    if (!inBounds(N, row, col)) {
        setReason(MoveRejectReason::OutOfBounds);
        return false;
    }
    if (who != Stone::Black && who != Stone::White) {
        setReason(MoveRejectReason::OutOfBounds);
        return false;
    }
    if (m_cells[row * N + col] != Stone::Empty) {
        setReason(MoveRejectReason::Occupied);
        return false;
    }

    GoBoard sim = *this; // 복사 후 시뮬레이션
    const Stone opp = opponent(who);
    sim.m_cells[row * N + col] = who;

    int captured = 0;
    QVector<int> visited(N * N, 0);
    for (int d = 0; d < 4; ++d) {
        const int nr = row + kDR[d];
        const int nc = col + kDC[d];
        if (!inBounds(N, nr, nc))
            continue;
        const int nidx = nr * N + nc;
        if (sim.m_cells[nidx] != opp || visited[nidx])
            continue;
        QVector<int> grp;
        QVector<int> libs;
        sim.collectGroup(nr, nc, &grp, &libs);
        for (int gi : grp)
            visited[gi] = 1;
        if (libs.isEmpty()) {
            for (int gi : grp) {
                sim.m_cells[gi] = Stone::Empty;
                ++captured;
            }
        }
    }

    if (sim.liberties(row, col) == 0) {
        setReason(MoveRejectReason::Suicide);
        return false;
    }

    if (sim.wouldRecreatePastPosition(sim.packed())) {
        setReason(MoveRejectReason::KoViolation);
        return false;
    }

    Q_UNUSED(captured);
    return true;
}

使用したモデル: Qwen3-VL-8B-Instruct + LoRA アダプター(lora_mix_qwen_r5)。全ソースとモデルの説明は「8. 全ソースコードと使用したモデル」の記事で見られます。