게시판SGF 기보로 학습하는 바둑 LLM 인공지능예제 소스

예제 소스

SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개

atari_smoketest.cpp

// 단수 탈출 휴리스틱 샘플 시험.
//
// 시나리오 1: 흑 한 점이 백 3점에 둘러싸여 단수 (활로 1).
//             흑 차례라면 그 마지막 활로로 뻗어야 함.
//   . . W . .
//   . W B W .          (B=(1,2), 활로=(1,3) 라고 가정한 후 W를 추가해 활로 1로 만든다)
//   . . W . .
//
// 시나리오 2: 백이 단수, AI=백 차례. 흑이 잡으러 오면 잡힘. 백은 살리는 자리로 뻗어야 함.
//
// PASS 조건: 추천 좌표 == 그 그룹의 유일한 활로 자리.

#include "../src/aiplayer.h"
#include "../src/gamejudge.h"
#include "../src/goboard.h"

#include <QPoint>
#include <cstdio>
#include <cstdlib>
#include <vector>

namespace {

void placeRaw(GoBoard &b, int r, int c, Stone s);

// GoBoard 가 직접 set API를 노출하지 않으므로 playAt 으로 차례를 맞춰가며 둔다.
// 차례 컨트롤이 까다로우니 reflection 대신 같은 색을 한 점 더 두고 한 점 따내는 식의 트릭은 피하고,
// "교체 진행" 으로 보드를 만들어 본다.

bool playSeq(GoBoard &b, std::vector<std::pair<int,int>> moves)
{
    for (auto [r, c] : moves) {
        MoveRejectReason rr;
        if (!b.playAt(r, c, &rr)) {
            std::fprintf(stderr, "play failed at (%d,%d) reason=%d\n", r, c, int(rr));
            return false;
        }
    }
    return true;
}

void dumpBoard(const GoBoard &b)
{
    const int N = b.size();
    for (int r = 0; r < N; ++r) {
        for (int c = 0; c < N; ++c) {
            const Stone v = b.cellAt(r, c);
            char ch = '.';
            if (v == Stone::Black) ch = 'X';
            else if (v == Stone::White) ch = 'O';
            std::putchar(ch);
        }
        std::putchar('\n');
    }
}

int test_black_in_atari_should_extend()
{
    // 보드 중앙에 만든다. 흑 한 점을 두고 백으로 3면을 막아 활로 1 만들고, 흑 차례에 호출.
    // 진행 순서:
    //   B (5,5)            // 흑 한 점
    //   W (4,5)
    //   B (10,10)          // 무관한 빈 자리 (차례 진행 위해)
    //   W (5,4)
    //   B (10,11)
    //   W (5,6)
    //   → 이 시점에 흑 차례. (5,5) 흑은 활로 (6,5) 만 남음 (단수).
    GoBoard b;
    b.setMode(GameMode::HumanVsHuman);
    if (!playSeq(b, {
        {5, 5},   // B
        {4, 5},   // W
        {10, 10}, // B (filler)
        {5, 4},   // W
        {10, 11}, // B (filler)
        {5, 6},   // W
    }))
        return 1;
    if (b.currentTurn() != Stone::Black) {
        std::fprintf(stderr, "차례가 흑이 아님\n");
        return 1;
    }

    auto sug = AiPlayer::suggestMove(b, Stone::Black);
    std::printf("suggestion (Black to save): row=%d col=%d pass=%d\n",
                sug.row, sug.col, int(sug.pass));
    dumpBoard(b);

    if (sug.pass)
        return 2;
    if (sug.row == 6 && sug.col == 5)
        return 0; // 정확히 활로로 뻗음
    // (5,5) 흑이 단수 → 정답은 (6,5). 그 외 자리면 fail.
    std::fprintf(stderr, "단수 탈출에 실패 — 정답 (6,5) 였음\n");
    return 3;
}

int test_white_capture_priority()
{
    // 흑 한 점을 백 3 면이 막은 상태에서 백 차례.
    // 마지막 활로에 백이 두면 흑을 잡을 수 있다. 잡기 우선이 정확하면 그 자리를 골라야.
    GoBoard b;
    b.setMode(GameMode::HumanVsHuman);
    // B 시작 → W → B → W → B → W → 이번엔 백 차례에 마지막 활로 막기 시나리오 만들기.
    // 흑 한 점을 만들고 백 3 면. 마지막 자리에 백이 두면 잡힘.
    if (!playSeq(b, {
        {5, 5},   // B
        {4, 5},   // W
        {10, 10}, // B filler
        {5, 4},   // W
        {10, 11}, // B filler
        {5, 6},   // W
        {12, 12}, // B filler
        // 이제 백 차례. (5,5) 흑은 활로 1=(6,5). 백이 (6,5) 두면 흑 잡음.
    }))
        return 1;
    if (b.currentTurn() != Stone::White) {
        std::fprintf(stderr, "차례가 백이 아님\n");
        return 1;
    }

    auto sug = AiPlayer::suggestMove(b, Stone::White);
    std::printf("suggestion (White to capture): row=%d col=%d pass=%d\n",
                sug.row, sug.col, int(sug.pass));
    dumpBoard(b);
    if (!sug.pass && sug.row == 6 && sug.col == 5)
        return 0;
    std::fprintf(stderr, "잡기 우선 실패 — 정답 (6,5) 였음\n");
    return 4;
}

/// 시나리오 3: "잇는 수" — 흑 두 그룹이 한 점만 끼면 연결.
///   . X . X .         (5,5)=X (5,7)=X 사이의 (5,6) 에 백이 두면 끊김.
///   백이 그 자리를 노릴 수 있는 상황을 만들고, 흑 차례에 (5,6) 으로 잇는지 확인.
int test_black_should_connect()
{
    GoBoard b;
    b.setMode(GameMode::HumanVsHuman);
    // 흑 두 점을 (5,5), (5,7) 에 배치. 백은 그 사이를 끊기 직전 (예: (4,6),(6,6))
    // 으로 세팅해 (5,6) 에 백이 두면 (5,5) 와 (5,7) 흑이 모두 단수가 되도록 만든다.
    if (!playSeq(b, {
        {5, 5},   // B
        {4, 6},   // W
        {5, 7},   // B
        {6, 6},   // W
        // 이제 흑 차례. (5,6) 빈자리:
        //   - 우리(흑) 가 두면 (5,5)+(5,7) 두 그룹이 (5,6) 통해 한 그룹으로 연결.
        //   - 상대(백) 가 두면 (5,5) 흑 활로 = (5,4),(6,5) → 2, (5,7) 흑 활로 = (5,8),(6,7) → 2.
        //     단수까지는 아니지만 연결 보너스만으로도 (5,6) 이 최선이어야 함.
    }))
        return 1;
    if (b.currentTurn() != Stone::Black) {
        std::fprintf(stderr, "차례가 흑이 아님\n");
        return 1;
    }
    auto sug = AiPlayer::suggestMove(b, Stone::Black);
    std::printf("suggestion (Black to connect): row=%d col=%d pass=%d\n",
                sug.row, sug.col, int(sug.pass));
    dumpBoard(b);
    if (!sug.pass && sug.row == 5 && sug.col == 6)
        return 0;
    std::fprintf(stderr, "잇는 수 실패 — 정답 (5,6)\n");
    return 5;
}

/// 시나리오 4: 안 이으면 단수당하는 자리 (must-connect). 흑 두 그룹이 (5,6) 빈자리 위에서
/// 백이 두면 흑 한 그룹이 단수가 되는 형태를 만들고, 흑 차례에 (5,6) 을 두는지 확인.
int test_black_must_connect_against_atari_threat()
{
    GoBoard b;
    b.setMode(GameMode::HumanVsHuman);
    // 흑 (5,5) 한 점을 백이 (4,5),(5,4),(6,5) 로 둘러싸 활로 1=(5,6).
    // 동시에 흑 (5,7) 한 점을 두어, (5,6) 에 흑이 두면 단수 탈출 + 연결.
    if (!playSeq(b, {
        {5, 5},   // B  - 곧 단수에 걸릴 흑 한 점
        {4, 5},   // W
        {5, 7},   // B  - 친구 한 점 (이으면 같이 살릴 수 있음)
        {5, 4},   // W
        {10, 10}, // B (filler)
        {6, 5},   // W  - 이제 (5,5) 흑 활로 1=(5,6). 흑 차례.
    }))
        return 1;
    if (b.currentTurn() != Stone::Black) {
        std::fprintf(stderr, "차례가 흑이 아님\n");
        return 1;
    }
    auto sug = AiPlayer::suggestMove(b, Stone::Black);
    std::printf("suggestion (Black must connect): row=%d col=%d pass=%d\n",
                sug.row, sug.col, int(sug.pass));
    dumpBoard(b);
    if (!sug.pass && sug.row == 5 && sug.col == 6)
        return 0;
    std::fprintf(stderr, "must-connect 실패 — 정답 (5,6)\n");
    return 6;
}

/// 시나리오 5: 심판이 "산 그룹의 집 안" 빈 자리를 자해 영역으로 인식.
/// 6x6 정사각형 흑 벽 안에 두 개의 빈 자리(눈) 가 있는 상황을 만들고,
/// 그 빈 자리를 흑이 메우려는 후보가 큰 페널티로 거절되는지 확인 — 즉 PASS 가 나오는지.
int test_judge_recognizes_alive_territory()
{
    GoBoard b;
    b.setMode(GameMode::HumanVsHuman);
    // 흑 8자 모양의 큰 사각형 + 안쪽 눈 두 개.
    // 좌표 평면:
    //   row 4: . X X X X X .
    //   row 5: X . X . X X .   (4,2)와(5,4) 가 살아 있는 흑 영역의 두 눈
    //  ... 너무 복잡하므로 간단히: 벽으로 둘러싸인 12-점 흑 그룹 + 두 점 빈자리.
    // 그냥 단순 사각 벽 설계:
    //   row 3..7, col 3..7 의 외곽을 흑으로 채우고 내부 (4,4)와 (4,5)만 비움.
    //   외곽 흑 그룹은 잠재 눈 2 (= 두 점 짜리 한 영역? 아니 빈 영역이 2개여야 한다).
    //   따라서 (4,4), (4,5) 가 따로따로 떨어진 두 눈이 되도록 배치한다.
    //
    // 절차 (충분한 백 수도 끼워 차례 맞추기 위해 멀리 둔다):
    auto B = [&](int r, int c) { return playSeq(b, {{r, c}}); };
    auto W = [&](int r, int c) { return playSeq(b, {{r, c}}); };

    // 외곽 사각형: (3,3..7), (7,3..7), (3..7, 3), (3..7, 7) — 흑.
    // 내부: (4,4..6) 비움(중간 (4,5) 도 비움) — 그러면 영역이 한 덩어리(=눈 1) 가 됨.
    // 두 눈을 만들려면 사이를 흑으로 막아야 한다. 그래서 (4,5) 흑.
    // 내부 빈자리 = (4,4) 와 (4,6) — 떨어져 두 눈.
    std::vector<std::pair<int,int>> blackStones = {
        {3,3},{3,4},{3,5},{3,6},{3,7},
        {7,3},{7,4},{7,5},{7,6},{7,7},
        {4,3},{5,3},{6,3},
        {4,7},{5,7},{6,7},
        {5,4},{5,5},{5,6}, // 중앙 가로벽
        {6,4},{6,5},{6,6}, // 한 줄 더
        {4,5},             // 두 눈을 분리
    };
    // 백은 멀리 그냥 두면서 차례를 맞춘다.
    int wr = 0, wc = 0;
    for (auto p : blackStones) {
        if (!B(p.first, p.second)) return 1;
        // 백 차례 — 멀리 둔다 (보드 가장자리)
        while (b.cellAt(wr, wc) != Stone::Empty
               || (wr >= 3 && wr <= 7 && wc >= 3 && wc <= 7)) {
            ++wc;
            if (wc >= 19) { wc = 0; ++wr; }
        }
        if (!W(wr, wc)) return 1;
        ++wc;
        if (wc >= 19) { wc = 0; ++wr; }
    }

    // 이제 흑 차례. 흑 외곽 그룹은 안쪽 (4,4), (4,6) 두 개의 1-점 눈을 가짐 → alive.
    // 따라서 흑이 (4,4) 또는 (4,6) 에 두려고 하면 안 됨 (자기 집 메우기 = 자해).
    // suggestMove 가 패스를 내거나, 적어도 (4,4)/(4,6) 이 아닌 곳을 골라야 한다.
    if (b.currentTurn() != Stone::Black) {
        std::fprintf(stderr, "차례가 흑이 아님\n");
        return 1;
    }

    // 심판 자체 검사
    auto judge = GameJudge::analyze(b);
    Stone owner44 = judge.settledOwner(4, 4);
    Stone owner46 = judge.settledOwner(4, 6);
    std::printf("judge: settledOwner(4,4)=%d  settledOwner(4,6)=%d\n",
                int(owner44), int(owner46));
    if (owner44 != Stone::Black || owner46 != Stone::Black) {
        std::fprintf(stderr, "심판이 (4,4)/(4,6) 을 흑 settled 영역으로 인식 못 함\n");
        return 7;
    }

    auto sug = AiPlayer::suggestMove(b, Stone::Black);
    std::printf("suggestion (alive territory test): row=%d col=%d pass=%d\n",
                sug.row, sug.col, int(sug.pass));
    if (!sug.pass && sug.row == 4 && (sug.col == 4 || sug.col == 6)) {
        std::fprintf(stderr, "AI 가 자기 눈을 메우려 함 — 실패\n");
        dumpBoard(b);
        return 8;
    }
    return 0;
}

/// 시나리오 6: 미리 잇기/뻗기. 흑 두 점 (5,5),(5,6) 의 활로가 (5,4),(4,5),(4,6),(6,5),(6,6),(5,7).
/// 백을 4면 둘러싸 활로 2 (= "위태로운 약한 그룹") 로 만들고 흑 차례에 안전권으로 보강하는지 확인.
/// 둘만 활로 2 인 상황을 만든 뒤, 흑이 활로를 늘리는 자리(예: (5,7) 같은 자리) 로 뻗는지.
int test_black_should_preempt_extend_weak_group()
{
    GoBoard b;
    b.setMode(GameMode::HumanVsHuman);
    // 시나리오 구성:
    //   흑 두 점 (5,5),(5,6) 을 두고
    //   백으로 (4,5),(4,6),(6,5),(6,6),(5,4) 를 둬서 흑 그룹 활로 = (5,7) 1개 → 단수.
    // 단수가 아니라 활로 2 짜리를 만들려면 백 한 점 빼야 함. 대신:
    //   흑 (5,5),(5,6) 두 점 + 백 (4,5),(4,6),(6,5),(6,6) 4점 → 흑 활로 = (5,4),(5,7) → 2.
    if (!playSeq(b, {
        {5, 5},   // B
        {4, 5},   // W
        {5, 6},   // B
        {4, 6},   // W
        {10, 10}, // B (filler)
        {6, 5},   // W
        {10, 11}, // B (filler)
        {6, 6},   // W
        // 흑 차례. (5,5)+(5,6) 흑 그룹 활로 2 = {(5,4), (5,7)}.
        // 단수는 아니지만 위험. 미리 보강해야 함.
        // 보강 자리: (5,4) 또는 (5,7) → 활로가 1 또는 2 더 늘어남.
        //   (5,4) 두면 합쳐진 그룹 활로 = (5,3),(4,4),(6,4),(5,7) = 4 → 안전권 (weakSaved 트리거)
        //   (5,7) 두면 합쳐진 그룹 활로 = (5,4),(4,7),(6,7),(5,8) = 4 → 안전권
    }))
        return 1;
    if (b.currentTurn() != Stone::Black) {
        std::fprintf(stderr, "차례가 흑이 아님\n");
        return 1;
    }
    auto sug = AiPlayer::suggestMove(b, Stone::Black);
    std::printf("suggestion (preempt extend): row=%d col=%d pass=%d\n",
                sug.row, sug.col, int(sug.pass));
    dumpBoard(b);
    if (sug.pass) {
        std::fprintf(stderr, "약한 그룹이 있는데 패스함 — 실패\n");
        return 9;
    }
    // 정답은 (5,4) 또는 (5,7).
    const bool ok = (sug.row == 5 && (sug.col == 4 || sug.col == 7));
    if (!ok) {
        std::fprintf(stderr, "약한 대마를 미리 보강 안 함 — 정답 (5,4) 또는 (5,7)\n");
        return 10;
    }
    return 0;
}

} // namespace

int main()
{
    int rc = 0;
    rc |= test_black_in_atari_should_extend();
    rc |= test_white_capture_priority();
    rc |= test_black_should_connect();
    rc |= test_black_must_connect_against_atari_threat();
    rc |= test_judge_recognizes_alive_territory();
    rc |= test_black_should_preempt_extend_weak_group();
    if (rc == 0)
        std::printf("ALL OK\n");
    else
        std::printf("FAIL rc=%d\n", rc);
    return rc;
}