为什么需要检查顺序

即使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. 完整源代码与使用的模型”一文中查看。