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

예제 소스

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

aiplayer.cpp

#include "aiplayer.h"

#include "gamejudge.h"

#include <QQueue>
#include <QRandomGenerator>
#include <QVector>
#include <algorithm>
#include <limits>

namespace {

constexpr int kDR[4] = {-1, 1, 0, 0};
constexpr int kDC[4] = {0, 0, -1, 1};

inline bool inBounds(int n, int r, int c)
{
    return r >= 0 && r < n && c >= 0 && c < n;
}

/// (r,c) 그룹의 활로(libs) 와 그룹 인덱스 리스트.
/// (r,c) 가 빈칸이면 (-1, {}).
struct GroupInfo {
    int libs = -1;
    QVector<int> stones;
};

GroupInfo groupOnGrid(const QVector<Stone> &cells, int N, int r, int c)
{
    GroupInfo info;
    if (cells[r * N + c] == Stone::Empty)
        return info;
    const Stone color = cells[r * N + c];
    QVector<bool> visited(N * N, false);
    QVector<bool> libSeen(N * N, false);
    QQueue<int> q;
    q.enqueue(r * N + c);
    visited[r * N + c] = true;
    int libs = 0;
    while (!q.isEmpty()) {
        const int idx = q.dequeue();
        info.stones.append(idx);
        const int rr = idx / N;
        const int cc = idx % N;
        for (int d = 0; d < 4; ++d) {
            const int nr = rr + kDR[d];
            const int nc = cc + kDC[d];
            if (!inBounds(N, nr, nc))
                continue;
            const int nidx = nr * N + nc;
            const Stone v = cells[nidx];
            if (v == Stone::Empty) {
                if (!libSeen[nidx]) {
                    libSeen[nidx] = true;
                    ++libs;
                }
            } else if (v == color && !visited[nidx]) {
                visited[nidx] = true;
                q.enqueue(nidx);
            }
        }
    }
    info.libs = libs;
    return info;
}

/// 보드 상태를 1D 배열로 복사.
QVector<Stone> snapshot(const GoBoard &b)
{
    const int N = b.size();
    QVector<Stone> cells(N * N);
    for (int r = 0; r < N; ++r)
        for (int c = 0; c < N; ++c)
            cells[r * N + c] = b.cellAt(r, c);
    return cells;
}

/// 시뮬레이션 결과.
struct SimResult {
    bool legal = false;        ///< 자살수면 false
    int captured = 0;          ///< 두면서 따낸 상대 돌 수
    int ownLibsAfter = 0;      ///< 둔 직후 우리 그룹의 활로 (자살수면 0)
    int ownGroupSize = 0;      ///< 우리 그룹의 돌 수
    int rescuedFromAtari = 0;  ///< 두기 전에 활로 1이었다가 뻗어서 활로≥2가 된 인접 우리 그룹의 돌 수 합
    int opponentAtariMade = 0; ///< 두면서 활로 1로 만든 인접 상대 그룹의 돌 수 합 (잡힘 제외)
    int ownGroupsMerged = 0;   ///< 두면서 합친 인접 우리 그룹 개수 (>=2 면 연결수)
    int minOwnLibsBefore = -1; ///< 합쳐진 우리 그룹들 중 두기 전 최소 활로 (없으면 -1)
    int weakSaved = 0;         ///< 활로 ≤2 였다가 두고 나서 활로 ≥4 가 된 인접 우리 그룹 돌 수 합
    int weakExtended = 0;      ///< 활로 ==2 였다가 두고 나서 활로 ==3 가 된 인접 우리 그룹 돌 수 합 (부분 보강)
};

SimResult simulatePlay(const GoBoard &b, int r, int c, Stone who)
{
    SimResult sr;
    const int N = b.size();
    if (b.cellAt(r, c) != Stone::Empty)
        return sr;
    QVector<Stone> cells = snapshot(b);
    const Stone opp = GoBoard::opponent(who);

    // 두기 전에: 우리 색의 인접 그룹 정보 (활로 추적 위해)
    QVector<int> myNeighborGroupLibsBefore;
    QVector<int> myNeighborGroupSizesBefore;
    QVector<int> myNeighborStarts;
    QVector<bool> seenMy(N * N, false);
    for (int d = 0; d < 4; ++d) {
        const int nr = r + kDR[d];
        const int nc = c + kDC[d];
        if (!inBounds(N, nr, nc))
            continue;
        const int nidx = nr * N + nc;
        if (cells[nidx] != who || seenMy[nidx])
            continue;
        GroupInfo gi = groupOnGrid(cells, N, nr, nc);
        for (int gj : gi.stones)
            seenMy[gj] = true;
        myNeighborGroupLibsBefore.append(gi.libs);
        myNeighborGroupSizesBefore.append(gi.stones.size());
        myNeighborStarts.append(nidx);
    }

    // 두기
    cells[r * N + c] = who;

    // 인접 상대 그룹 처리: 활로 0 → 따냄
    QVector<bool> seenOpp(N * N, false);
    int captured = 0;
    QVector<int> oppNeighborStartsAfter; // 잡히지 않고 남은 상대 그룹의 시작 인덱스
    for (int d = 0; d < 4; ++d) {
        const int nr = r + kDR[d];
        const int nc = c + kDC[d];
        if (!inBounds(N, nr, nc))
            continue;
        const int nidx = nr * N + nc;
        if (cells[nidx] != opp || seenOpp[nidx])
            continue;
        GroupInfo gi = groupOnGrid(cells, N, nr, nc);
        for (int gj : gi.stones)
            seenOpp[gj] = true;
        if (gi.libs == 0) {
            for (int gj : gi.stones) {
                cells[gj] = Stone::Empty;
                ++captured;
            }
        } else {
            oppNeighborStartsAfter.append(nidx);
        }
    }
    sr.captured = captured;

    // 둔 자리 우리 그룹 정보 (자살수 판정 + 활로 수)
    GroupInfo myGroup = groupOnGrid(cells, N, r, c);
    sr.ownLibsAfter = myGroup.libs;
    sr.ownGroupSize = myGroup.stones.size();
    sr.legal = myGroup.libs > 0;

    if (!sr.legal)
        return sr;

    // 둔 직후, 인접 우리 그룹 변화 분석:
    //   - rescuedFromAtari : 활로 1 → 활로 ≥ 2 가 된 그룹 돌 수
    //   - weakSaved        : 활로 ≤ 2 → 활로 ≥ 4 가 된 그룹 돌 수 (= 안전권으로 보강)
    //   - weakExtended     : 활로 == 2 → 활로 == 3 가 된 그룹 돌 수 (부분 보강)
    int rescued = 0;
    int weakSaved = 0;
    int weakExtended = 0;
    int minLibBefore = -1;
    if (!myNeighborStarts.isEmpty()) {
        for (int i = 0; i < myNeighborStarts.size(); ++i) {
            const int lb = myNeighborGroupLibsBefore[i];
            const int sz = myNeighborGroupSizesBefore[i];
            if (lb == 1 && sr.ownLibsAfter >= 2)
                rescued += sz;
            if (lb <= 2 && sr.ownLibsAfter >= 4)
                weakSaved += sz;
            else if (lb == 2 && sr.ownLibsAfter == 3)
                weakExtended += sz;
            if (minLibBefore < 0 || lb < minLibBefore)
                minLibBefore = lb;
        }
    }
    sr.rescuedFromAtari = rescued;
    sr.weakSaved = weakSaved;
    sr.weakExtended = weakExtended;
    sr.ownGroupsMerged = myNeighborStarts.size();
    sr.minOwnLibsBefore = minLibBefore;

    // 잡히지 않고 남은 상대 그룹 중에 활로 1 이 된 그룹(=내가 단수를 만든 그룹) 돌 수 합.
    int oppAtari = 0;
    QVector<bool> seen2(N * N, false);
    for (int idx : oppNeighborStartsAfter) {
        if (seen2[idx])
            continue;
        if (cells[idx] != opp)
            continue; // (혹시 캡쳐로 사라졌으면 스킵)
        GroupInfo gi = groupOnGrid(cells, N, idx / N, idx % N);
        for (int gj : gi.stones)
            seen2[gj] = true;
        if (gi.libs == 1)
            oppAtari += gi.stones.size();
    }
    sr.opponentAtariMade = oppAtari;

    return sr;
}

/// "상대가 (r,c) 에 둔다면" 을 시뮬레이션해서, 그 결과 우리(=who) 인접 그룹의
/// 최소 활로가 얼마가 되는지 계산. (캡쳐 발생 시 그 그룹은 0 으로 본다.)
/// 빈자리에 (r,c) 가 우리 인접 그룹과 닿지 않으면 INT_MAX 반환.
struct CutThreat {
    int minOwnLibsAfterOpp = std::numeric_limits<int>::max();
    int ownGroupsTouched = 0;
    int ownStonesAtRisk = 0; ///< 단수/잡힘 위협 받는 우리 그룹의 돌 수 합
};

CutThreat opponentCutThreat(const GoBoard &b, int r, int c, Stone who)
{
    CutThreat ct;
    const int N = b.size();
    if (b.cellAt(r, c) != Stone::Empty)
        return ct;
    QVector<Stone> cells = snapshot(b);
    const Stone opp = GoBoard::opponent(who);

    // 우리 색 이웃 그룹 미리 수집 (두기 전).
    QVector<int> ownNbrStarts;
    QVector<bool> seen(N * N, false);
    for (int d = 0; d < 4; ++d) {
        const int nr = r + kDR[d];
        const int nc = c + kDC[d];
        if (!inBounds(N, nr, nc))
            continue;
        const int nidx = nr * N + nc;
        if (cells[nidx] != who || seen[nidx])
            continue;
        GroupInfo gi = groupOnGrid(cells, N, nr, nc);
        for (int gj : gi.stones)
            seen[gj] = true;
        ownNbrStarts.append(nidx);
    }
    ct.ownGroupsTouched = ownNbrStarts.size();
    if (ownNbrStarts.isEmpty())
        return ct;

    // 상대가 둔다.
    cells[r * N + c] = opp;

    // 상대 입장에서 자기 인접 그룹 활로 0 검사 (자살수면 그냥 넘어감 — 즉 위협 없음).
    GroupInfo oppGroup = groupOnGrid(cells, N, r, c);
    if (oppGroup.libs == 0) {
        // 상대가 둘 수 없는 자리 → 위협 없음.
        return ct;
    }

    // 우리 인접 그룹들의 활로 측정. (상대가 따낼 수 있는 그룹은 활로 0.)
    int minLibs = std::numeric_limits<int>::max();
    int risk = 0;
    QVector<bool> seen2(N * N, false);
    for (int idx : ownNbrStarts) {
        if (seen2[idx])
            continue;
        if (cells[idx] != who)
            continue;
        GroupInfo gi = groupOnGrid(cells, N, idx / N, idx % N);
        for (int gj : gi.stones)
            seen2[gj] = true;
        if (gi.libs < minLibs)
            minLibs = gi.libs;
        if (gi.libs <= 1)
            risk += gi.stones.size();
    }
    ct.minOwnLibsAfterOpp = minLibs;
    ct.ownStonesAtRisk = risk;
    return ct;
}

int positionalBonus(int N, int r, int c, int moveCount)
{
    const int mid = N / 2;
    const int dist = std::abs(r - mid) + std::abs(c - mid);
    int base = (N - dist);
    const int edge = std::min(std::min(r, N - 1 - r), std::min(c, N - 1 - c));
    int edgePenalty = 0;
    if (edge == 0)
        edgePenalty = 18;
    else if (edge == 1)
        edgePenalty = 8;
    if (moveCount > N * N / 3)
        edgePenalty = edgePenalty / 2;
    return base - edgePenalty;
}

int neighborStoneScore(const GoBoard &b, int r, int c, Stone who)
{
    const int N = b.size();
    int score = 0;
    for (int d = 0; d < 4; ++d) {
        const int nr = r + kDR[d];
        const int nc = c + kDC[d];
        if (!inBounds(N, nr, nc))
            continue;
        const Stone v = b.cellAt(nr, nc);
        if (v == who)
            score += 4; // 연결
        else if (v == GoBoard::opponent(who))
            score += 2; // 접촉
    }
    return score;
}

} // namespace

namespace AiPlayer {

MoveSuggestion suggestMove(const GoBoard &board, Stone aiColor)
{
    MoveSuggestion s;
    if (board.status() != GameStatus::Playing)
        return s;
    if (aiColor != Stone::Black && aiColor != Stone::White)
        return s;

    const int N = board.size();

    // 새 정책: AI 는 자동으로 패스하지 않는다.
    // 패스 = "패배 선언 의사" 이므로, AI 가 둘 곳이 거의 없거나 점수가 떨어져도
    // 무조건 합법수 중 가장 덜 나쁜 한 수를 둔다. 이 함수는 합법수가 아예 없을
    // 때만 pass=true 를 반환한다 (그 경우는 사실상 종국 직전이며 매우 드뭄).
    const GameJudge::Analysis judge = GameJudge::analyze(board);
    Q_UNUSED(judge);

    // 1차 탐지:
    //   weHaveAtari       : 활로 1 그룹 보유 → 살리기 최우선
    //   weHaveWeakGroup   : 활로 ≤ 2 그룹 보유 → 미리 보강해야 함
    //   weakGroupMaxSize  : 가장 큰 약한 그룹의 돌 수 (대마 일수록 더 강하게 방어)
    bool weHaveAtari = false;
    bool weHaveWeakGroup = false;
    int weakGroupMaxSize = 0;
    {
        QVector<Stone> cells = snapshot(board);
        QVector<bool> seen(N * N, false);
        for (int r = 0; r < N; ++r) {
            for (int c = 0; c < N; ++c) {
                if (cells[r * N + c] != aiColor || seen[r * N + c])
                    continue;
                GroupInfo gi = groupOnGrid(cells, N, r, c);
                for (int gj : gi.stones)
                    seen[gj] = true;
                if (gi.libs == 1)
                    weHaveAtari = true;
                if (gi.libs <= 2) {
                    weHaveWeakGroup = true;
                    if (gi.stones.size() > weakGroupMaxSize)
                        weakGroupMaxSize = gi.stones.size();
                }
            }
        }
    }

    // ----- 모든 합법 후보 점수 매기기 -----
    // 가중치 (큰 게 우선):
    //   잡기:                +1000 * captured
    //   단수 탈출(rescue):    +800  * rescuedStones      (잡기보다는 살짝 낮게)
    //   상대 그룹 단수 만들기: +60   * opponentAtariMade
    //   자진 단수 (자기 그룹 활로==1): -400 * ownGroupSize
    //                          (단, 잡기/탈출이 같이 일어나면 그 효과로 보상받음)
    //   인접/위치 점수:       작게
    //
    // 우리 측에 단수가 있고 그 단수 그룹을 못 살리는 수면 큰 페널티(상대가 다음 수에 잡음).

    int bestScore = std::numeric_limits<int>::min();
    QVector<QPoint> best;

    for (int r = 0; r < N; ++r) {
        for (int c = 0; c < N; ++c) {
            MoveRejectReason rej;
            if (!board.isLegalMove(r, c, aiColor, &rej))
                continue;

            const SimResult sim = simulatePlay(board, r, c, aiColor);
            if (!sim.legal)
                continue; // 안전망 (isLegalMove 와 일치하므로 여기 도달 잘 안 함)

            // ── 1) 전술 점수 (실제로 무언가 이루는 수만 양수) ──
            int tactical = 0;
            tactical += sim.captured * 1000;
            tactical += sim.rescuedFromAtari * 800;
            // 약한(활로 ≤ 2) 그룹을 안전권(활로 ≥ 4) 으로 보강 — 미리 잇기/뻗기.
            // 대마(돌 수 큼)일수록 가산점이 자연히 커진다 (weakSaved 가 돌 수 합이라).
            tactical += sim.weakSaved * 250;
            // 부분 보강 (활로 2 → 3): 결정적은 아니지만 덜 위험해짐.
            tactical += sim.weakExtended * 60;
            tactical += sim.opponentAtariMade * 60;

            // 연결 보너스: 약한 그룹을 잇는 경우에만 의미 있음.
            // 이미 활로 4 이상으로 안전한 그룹들끼리 잇는 건 종반 자기 집 메우기와
            // 다를 바 없으므로 거의 0 으로 둔다.
            if (sim.ownGroupsMerged >= 2) {
                int weak = 0;
                if (sim.minOwnLibsBefore == 1)
                    weak = 500;       // 단수였던 돌을 잇는 결정적인 수
                else if (sim.minOwnLibsBefore == 2)
                    weak = 200;
                else if (sim.minOwnLibsBefore == 3)
                    weak = 60;
                const int safeBase = (sim.minOwnLibsBefore >= 4) ? 0 : 40;
                tactical += safeBase * (sim.ownGroupsMerged - 1) + weak;
            }

            // 방어 (must-connect / 끊김 방지 / 압박 회피)
            const CutThreat ct = opponentCutThreat(board, r, c, aiColor);
            if (ct.ownGroupsTouched > 0
                && ct.minOwnLibsAfterOpp != std::numeric_limits<int>::max()) {
                if (ct.minOwnLibsAfterOpp == 0)
                    tactical += 700 + ct.ownStonesAtRisk * 80; // 상대가 두면 잡힘
                else if (ct.minOwnLibsAfterOpp == 1)
                    tactical += 250 + ct.ownStonesAtRisk * 30; // 상대가 두면 단수
                else if (ct.minOwnLibsAfterOpp == 2)
                    tactical += 80 + ct.ownStonesAtRisk * 8;   // 상대가 두면 활로 2 (위험)
            }

            int score = tactical;

            // ── 2) 자진 단수 회피 ──
            if (sim.ownLibsAfter == 1 && sim.captured == 0)
                score -= sim.ownGroupSize * 400 + 80;
            else if (sim.ownLibsAfter == 2 && sim.captured == 0)
                score -= sim.ownGroupSize * 30;

            // ── 3) 우리 측에 단수가 있는데 풀지도 잡지도 않으면 큰 페널티 ──
            if (weHaveAtari && sim.rescuedFromAtari == 0 && sim.captured == 0)
                score -= 600;

            // ── 3b) 약한(활로 ≤ 2) 그룹이 있는데 보강도/잡기도 안 함 → 페널티 ──
            //   - 그룹이 클수록 (대마) 더 큰 페널티.
            //   - 단수까지는 아니므로 -600 보다는 약하게.
            if (weHaveWeakGroup && !weHaveAtari
                && sim.weakSaved == 0 && sim.weakExtended == 0
                && sim.captured == 0)
                score -= 200 + weakGroupMaxSize * 8;

            // ── 4) 위치/이웃 점수 (전술이 0 인 후보들 사이의 결정용) ──
            score += neighborStoneScore(board, r, c, aiColor);
            score += positionalBonus(N, r, c, board.totalMoves());

            // ── 5a) 심판 기반 페널티: settled 영역 / dame 자리는 둘 이유 없음 ──
            if (tactical == 0) {
                const Stone owner = judge.settledOwner(r, c);
                if (owner == aiColor)
                    score -= 1500;     // 자기 집 메우기 = 큰 자해
                else if (owner == GoBoard::opponent(aiColor))
                    score -= 600;      // 상대 집에 들어가기 = 자살 가까움
                else if (judge.isDame(r, c))
                    score -= 200;      // 양쪽 살아 있는 사이 = 둘 의미 없음
            }

            // ── 5b) 자기 영역 메우기 페널티 강화 ──
            int ownAround = 0;
            int oppAround = 0;
            int emptyAround = 0;
            for (int d = 0; d < 4; ++d) {
                const int nr = r + kDR[d];
                const int nc = c + kDC[d];
                if (!inBounds(N, nr, nc)) {
                    continue;
                }
                const Stone v = board.cellAt(nr, nc);
                if (v == aiColor)
                    ++ownAround;
                else if (v == GoBoard::opponent(aiColor))
                    ++oppAround;
                else
                    ++emptyAround;
            }
            // 4면이 전부 우리 돌 = 명백한 자기 집(또는 눈) 메우기. 매우 큰 페널티.
            if (ownAround == 4 && tactical == 0)
                score -= 1500;
            // 3면 우리 돌 + 상대 없음 = 자기 영역 메우기. 종반 자충수 주범.
            else if (ownAround >= 3 && oppAround == 0 && tactical == 0)
                score -= 800;
            // 우리 돌 2 + 빈자리 거의 없음 = 자충 위험.
            else if (ownAround >= 2 && oppAround == 0 && emptyAround <= 1
                     && tactical == 0)
                score -= 250;

            if (score > bestScore) {
                bestScore = score;
                best.clear();
                best.append(QPoint(c, r));
            } else if (score == bestScore) {
                best.append(QPoint(c, r));
            }
        }
    }

    if (best.isEmpty()) {
        // 합법수가 한 자리도 없는 매우 드문 경우에만 pass.
        s.pass = true;
        return s;
    }

    s.bestScore = bestScore;

    // 종반 자충수 방지: 모든 후보가 음수 점수(=자해/자기 집 메우기/자충 위험)
    // 이면 더 두는 것이 손해다. "noGoodMove" 신호를 보내 MainWindow 가 자동
    // 계가 모드로 진입하도록 한다 (이 신호는 패스/기권이 아님 — UI 가 사석/영토
    // 시각화로 종국을 처리).
    //
    // 임계: bestScore <= 0  (개막엔 항상 위치/이웃 점수가 양수라 안 걸림)
    //       AND totalMoves >= 60 (충분히 진행된 상태에서만)
    const int totalMoves = board.totalMoves();
    if (bestScore <= 0 && totalMoves >= 60) {
        s.noGoodMove = true;
        // 좌표는 fallback 으로 채워 둠 (MainWindow 가 무시하고 계가로 진입할 것).
        const int idx = QRandomGenerator::global()->bounded(best.size());
        const QPoint p = best.at(idx);
        s.row = p.y();
        s.col = p.x();
        s.pass = false;
        return s;
    }

    const int idx = QRandomGenerator::global()->bounded(best.size());
    const QPoint p = best.at(idx);
    s.row = p.y();
    s.col = p.x();
    s.pass = false;
    return s;
}

} // namespace AiPlayer