예제 소스
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