검사 순서가 필요한 이유

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. 전체 소스 코드와 사용한 모델’ 글에서 볼 수 있습니다.