Por que a ordem das verificações importa

Mesmo que a IA leia as coordenadas corretamente, não há garantia de que a jogada seja legal. A verificação das regras deve ser um código determinístico, separado das probabilidades do modelo. O mesmo tabuleiro e a mesma coordenada precisam sempre dar o mesmo resultado; caso contrário, testes e reprodução de falhas ficam impossíveis.

Etapa 1: Verificar o intervalo e o ponto vazio

Primeiro, verifique se a linha e a coluna estão no intervalo de 0 a 18. Em seguida, veja se a interseção está vazia. Se falhar aqui, não é preciso copiar o tabuleiro nem remover pedras, então as verificações mais baratas vêm primeiro.

Etapa 2: Calcular capturas em um tabuleiro temporário

Copie o tabuleiro atual, coloque uma pedra na coordenada candidata e encontre os grupos adversários acima, abaixo, à esquerda e à direita. Todo grupo com zero liberdades é removido do tabuleiro temporário. Vários grupos podem ser capturados ao mesmo tempo, então a verificação não pode parar na primeira captura.

  1. Colocar a pedra candidata no tabuleiro temporário.
  2. Encontrar os grupos adversários adjacentes e suas liberdades.
  3. Remover todos os grupos adversários sem liberdades.
  4. Contar de novo as liberdades do próprio grupo da pedra candidata.

Etapa 3: Distinguir suicídio de ko

Se o próprio grupo continuar com zero liberdades depois de remover as pedras adversárias, a jogada é suicídio e é recusada. Se a captura criou novas liberdades, a jogada é legal. Por fim, compare o tabuleiro temporário inteiro com o tabuleiro anterior para bloquear a recaptura imediata do ko. Proibir permanentemente uma única coordenada bloquearia também jogadas legais depois que o tabuleiro mudou, então é preciso comparar os estados do tabuleiro.

Etapa 4: Devolver o motivo da recusa na requisição ao modelo

Não coloque apenas a coordenada ilegal em uma lista de exclusão; registre também o motivo, como "já existe uma pedra", "suicídio" ou "repetição de ko". A nova requisição informa explicitamente a coordenada recusada para reduzir respostas repetidas. Se o número máximo de tentativas for ultrapassado, o servidor não faz uma jogada aleatória; ele avisa o usuário sobre a falha e grava o tabuleiro e as respostas no log de erros.

Testes que você precisa criar

  • Um tabuleiro em que uma única pedra do canto é capturada
  • Um tabuleiro em que dois grupos são capturados ao mesmo tempo
  • Uma jogada que captura e, por isso, não é suicídio
  • Uma jogada que realmente é suicídio
  • Um ko em que só a recaptura imediata é proibida

Esses cinco tabuleiros devem poder ser verificados chamando apenas as funções de regras, sem o modelo.

O código real desta etapa

Este é o motor de regras do cliente Qt. Ele verifica primeiro o intervalo e o ponto vazio, captura todas as pedras adversárias em um tabuleiro copiado, depois verifica o suicídio e, por fim, bloqueia o ko comparando com todos os tabuleiros já vistos. O motivo da recusa é devolvido como MoveRejectReason.

src/goboard.cppc++ · 66 linhas
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;
}

Modelo usado: Qwen3-VL-8B-Instruct + adaptador LoRA (lora_mix_qwen_r5). O código-fonte completo e a descrição do modelo estão no post "8. O código-fonte completo e os modelos usados".