예제 소스
SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개
mainwindow.cpp
#include "mainwindow.h"
#include "aiplayer.h"
#include "boardwidget.h"
#include "gamejudge.h"
#include "tcpboardechoclient.h"
#include "tcpvlmclient.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QTextStream>
#include <QApplication>
#include <QByteArray>
#include <QCheckBox>
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSpinBox>
#include <QThread>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
namespace {
QString stoneName(Stone s)
{
if (s == Stone::Black) return QStringLiteral("흑");
if (s == Stone::White) return QStringLiteral("백");
return QStringLiteral("-");
}
QString rejectReasonText(MoveRejectReason r)
{
switch (r) {
case MoveRejectReason::None: return QString();
case MoveRejectReason::GameNotPlaying: return QStringLiteral("이미 종국된 게임입니다.");
case MoveRejectReason::OutOfBounds: return QStringLiteral("바둑판 범위 밖입니다.");
case MoveRejectReason::Occupied: return QStringLiteral("이미 돌이 놓여 있습니다.");
case MoveRejectReason::Suicide: return QStringLiteral("자살수입니다 (자기 그룹의 활로가 0).");
case MoveRejectReason::KoViolation: return QStringLiteral("패(코) 위반: 직전과 같은 위치 재현.");
}
return QString();
}
QString coordText19(int row, int col)
{
// 19x19 좌표: A..T 에서 I 제외 (일반적인 바둑 좌표 관례).
static const QString kCols = QStringLiteral("ABCDEFGHJKLMNOPQRST");
if (row < 0 || col < 0 || row >= 19 || col >= 19)
return QStringLiteral("??");
const QChar cc = kCols.at(col);
const int rr = 19 - row;
return QStringLiteral("%1%2").arg(cc).arg(rr);
}
} // namespace
bool MainWindow::alwaysYesPopups() const
{
return m_popupAlwaysYesCheck && m_popupAlwaysYesCheck->isChecked();
}
bool MainWindow::scoringTemporarilyBlocked() const
{
return m_blockScoringCheck && m_blockScoringCheck->isChecked();
}
QString MainWindow::uiModeLabel() const
{
const auto tag = [](PlayerKind k) -> QString {
switch (k) {
case PlayerKind::Human: return QStringLiteral("H");
case PlayerKind::Heuristic: return QStringLiteral("AI");
case PlayerKind::Vlm: return QStringLiteral("VLM");
}
return QStringLiteral("?");
};
return QStringLiteral("%1-%2").arg(tag(blackPlayer()), tag(whitePlayer()));
}
void MainWindow::uiAppendCurrentLine(const QString &line)
{
m_uiCurrentLines.append(line);
if (m_moveList && m_viewingCurrentGame) {
m_moveList->addItem(line);
m_moveList->scrollToBottom();
}
}
void MainWindow::uiFinalizeCurrentGame(const QString &resultLine)
{
if (m_uiCurrentLines.isEmpty())
return;
const QDateTime now = QDateTime::currentDateTime();
const QString stamp = now.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
UiGameHistory g;
g.title = QStringLiteral("%1 [%2] %3").arg(stamp, uiModeLabel(), resultLine);
g.lines = m_uiCurrentLines;
g.lines.append(QStringLiteral("— %1").arg(resultLine));
m_uiGames.append(g);
if (m_gameList) {
auto *it = new QListWidgetItem(g.title);
it->setData(Qt::UserRole, m_uiGames.size() - 1);
m_gameList->addItem(it);
}
}
void MainWindow::uiInitHistoryPanel()
{
if (!m_gameList || !m_moveList)
return;
// "현재 게임" 은 고정 0번.
m_gameList->clear();
auto *cur = new QListWidgetItem(QStringLiteral("현재 게임"));
cur->setData(Qt::UserRole, -1);
m_gameList->addItem(cur);
for (int i = 0; i < m_uiGames.size(); ++i) {
auto *it = new QListWidgetItem(m_uiGames[i].title);
it->setData(Qt::UserRole, i);
m_gameList->addItem(it);
}
m_gameList->setCurrentRow(0);
m_moveList->clear();
for (const QString &ln : m_uiCurrentLines)
m_moveList->addItem(ln);
m_moveList->scrollToBottom();
QObject::connect(m_gameList, &QListWidget::currentItemChanged, this,
[this](QListWidgetItem *curItem, QListWidgetItem *) {
if (!curItem || !m_moveList) return;
const int idx = curItem->data(Qt::UserRole).toInt();
m_moveList->clear();
if (idx < 0) {
m_viewingCurrentGame = true;
for (const QString &ln : m_uiCurrentLines)
m_moveList->addItem(ln);
} else if (idx < m_uiGames.size()) {
m_viewingCurrentGame = false;
for (const QString &ln : m_uiGames[idx].lines)
m_moveList->addItem(ln);
}
m_moveList->scrollToBottom();
});
}
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
auto *central = new QWidget(this);
setCentralWidget(central);
auto *root = new QVBoxLayout(central);
root->setContentsMargins(10, 10, 12, 10);
root->setSpacing(8);
// ---- top bar 1: 모드 / 사람 색 / 덤 / 새 게임 / 자동 재시작 ----
auto *topBar = new QHBoxLayout();
topBar->setContentsMargins(0, 0, 0, 0);
topBar->setSpacing(8);
auto *lblBlack = new QLabel(QStringLiteral("흑:"), this);
topBar->addWidget(lblBlack);
m_blackPlayerLabel = lblBlack;
m_blackPlayerCombo = new QComboBox(this);
m_blackPlayerCombo->addItem(QStringLiteral("사람"),
QVariant::fromValue(static_cast<int>(PlayerKind::Human)));
m_blackPlayerCombo->addItem(QStringLiteral("휴리스틱"),
QVariant::fromValue(static_cast<int>(PlayerKind::Heuristic)));
m_blackPlayerCombo->addItem(QStringLiteral("VLM"),
QVariant::fromValue(static_cast<int>(PlayerKind::Vlm)));
m_blackPlayerCombo->setCurrentIndex(0);
m_blackPlayerCombo->setToolTip(QStringLiteral(
"흑을 누가 둘지. 대국 중에도 바꿀 수 있으며 판은 유지되고 이어서 둡니다."));
connect(m_blackPlayerCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MainWindow::onBlackPlayerChanged);
lblBlack->setBuddy(m_blackPlayerCombo);
topBar->addWidget(m_blackPlayerCombo);
auto *lblWhite = new QLabel(QStringLiteral("백:"), this);
topBar->addWidget(lblWhite);
m_whitePlayerLabel = lblWhite;
m_whitePlayerCombo = new QComboBox(this);
m_whitePlayerCombo->addItem(QStringLiteral("사람"),
QVariant::fromValue(static_cast<int>(PlayerKind::Human)));
m_whitePlayerCombo->addItem(QStringLiteral("휴리스틱"),
QVariant::fromValue(static_cast<int>(PlayerKind::Heuristic)));
m_whitePlayerCombo->addItem(QStringLiteral("VLM"),
QVariant::fromValue(static_cast<int>(PlayerKind::Vlm)));
m_whitePlayerCombo->setCurrentIndex(1); // 기본: 흑 사람 / 백 휴리스틱
m_whitePlayerCombo->setToolTip(QStringLiteral(
"백을 누가 둘지. 대국 중에도 바꿀 수 있으며 판은 유지되고 이어서 둡니다."));
connect(m_whitePlayerCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MainWindow::onWhitePlayerChanged);
lblWhite->setBuddy(m_whitePlayerCombo);
topBar->addWidget(m_whitePlayerCombo);
m_modeCombo = nullptr;
m_humanSideLabel = new QLabel(QStringLiteral("사람 돌:"), this);
m_humanSideLabel->setVisible(false);
topBar->addWidget(m_humanSideLabel);
m_humanSideCombo = new QComboBox(this);
m_humanSideCombo->addItem(QStringLiteral("흑(선)"),
QVariant::fromValue(static_cast<int>(Stone::Black)));
m_humanSideCombo->addItem(QStringLiteral("백(후)"),
QVariant::fromValue(static_cast<int>(Stone::White)));
m_humanSideCombo->setCurrentIndex(0);
m_humanSideCombo->setVisible(false);
connect(m_humanSideCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MainWindow::onHumanSideChanged);
m_humanSideLabel->setBuddy(m_humanSideCombo);
topBar->addWidget(m_humanSideCombo);
auto *lblKomi = new QLabel(QStringLiteral("덤:"), this);
topBar->addWidget(lblKomi);
m_komiSpin = new QDoubleSpinBox(this);
m_komiSpin->setRange(0.0, 30.5);
m_komiSpin->setSingleStep(0.5);
m_komiSpin->setDecimals(1);
m_komiSpin->setValue(kDefaultKomi);
m_komiSpin->setMaximumWidth(86);
connect(m_komiSpin, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
this, &MainWindow::onKomiChanged);
topBar->addWidget(m_komiSpin);
auto *restartBtn = new QPushButton(QStringLiteral("새 게임"), this);
connect(restartBtn, &QPushButton::clicked, this, &MainWindow::onNewGameClicked);
topBar->addWidget(restartBtn);
m_startBtn = new QPushButton(QStringLiteral("시작"), this);
m_startBtn->setToolTip(QStringLiteral(
"빈 판에서 설정을 마친 뒤 누르면 대국(VLM/휴리스틱 포함)이 시작됩니다."));
m_startBtn->setStyleSheet(QStringLiteral(
"QPushButton { font-weight: bold; padding: 4px 14px; }"));
connect(m_startBtn, &QPushButton::clicked, this, &MainWindow::onStartClicked);
topBar->addWidget(m_startBtn);
m_passBtn = new QPushButton(QStringLiteral("패스"), this);
connect(m_passBtn, &QPushButton::clicked, this, &MainWindow::onPassClicked);
topBar->addWidget(m_passBtn);
m_resignBtn = new QPushButton(QStringLiteral("기권"), this);
connect(m_resignBtn, &QPushButton::clicked, this, &MainWindow::onResignClicked);
topBar->addWidget(m_resignBtn);
m_popupAlwaysYesCheck = new QCheckBox(QStringLiteral("팝업 항상 예"), this);
m_popupAlwaysYesCheck->setToolTip(QStringLiteral(
"주요 확인 팝업(패스/계가 등)을 다시 묻지 않고 기본(예) 동작으로 자동 진행합니다."));
m_popupAlwaysYesCheck->setChecked(false);
topBar->addWidget(m_popupAlwaysYesCheck);
m_blockScoringCheck = new QCheckBox(QStringLiteral("계가 임시 차단"), this);
m_blockScoringCheck->setToolTip(QStringLiteral(
"임시 안전장치: 자동 계가 진입/계가 팝업/계가 모드를 막습니다. "
"AI가 계가/포기를 악용하는 문제를 완화하기 위한 옵션입니다."));
m_blockScoringCheck->setChecked(true);
topBar->addWidget(m_blockScoringCheck);
m_autoRestartCheck = new QCheckBox(QStringLiteral("자동 재시작"), this);
m_autoRestartCheck->setToolTip(
QStringLiteral("종국되면 자동으로 새 게임을 시작합니다. (AI 대 VLM 자동 진행 평가용)"));
m_autoRestartCheck->setChecked(false);
topBar->addWidget(m_autoRestartCheck);
m_recordCheck = new QCheckBox(QStringLiteral("학습 모드 (기보 저장)"), this);
m_recordCheck->setToolTip(QStringLiteral(
"종국된 각 판을 SGF 파일로 'recordings/' 에 자동 저장합니다. "
"나중에 sgf_data 로 옮겨 dataset_baduk 로 학습 데이터에 포함시킬 수 있습니다. "
"자동 재시작과 함께 켜면 연속 자가 대국을 수집할 수 있습니다."));
m_recordCheck->setChecked(false);
topBar->addWidget(m_recordCheck);
root->addLayout(topBar);
// ---- 서버1 행: 항상 사용 (단일 서버 모드, VlmVsVlm 의 흑 측) ----
m_vlmServer1Row = new QWidget(this);
auto *s1Lay = new QHBoxLayout(m_vlmServer1Row);
s1Lay->setContentsMargins(0, 0, 0, 0);
s1Lay->setSpacing(6);
{
auto *lbl = new QLabel(QStringLiteral("<b>서버1</b>"), m_vlmServer1Row);
lbl->setMinimumWidth(72);
lbl->setToolTip(QStringLiteral(
"AI 대 VLM / 사람 대 VLM 모드의 VLM 서버.\n"
"VLM 대 VLM 모드에서는 흑 측 VLM 엔드포인트로 사용됩니다."));
s1Lay->addWidget(lbl);
}
m_vlmHostEdit = new QLineEdit(QStringLiteral("127.0.0.1"), m_vlmServer1Row);
m_vlmHostEdit->setPlaceholderText(QStringLiteral("호스트"));
m_vlmPortEdit = new QLineEdit(QStringLiteral("9319"), m_vlmServer1Row);
m_vlmPortEdit->setMaximumWidth(72);
m_vlmPortEdit->setPlaceholderText(QStringLiteral("포트"));
s1Lay->addWidget(m_vlmHostEdit, 1);
s1Lay->addWidget(m_vlmPortEdit);
m_vlmConnectBtn = new QPushButton(QStringLiteral("접속 확인"), m_vlmServer1Row);
m_vlmConnectBtn->setToolTip(QStringLiteral(
"지정한 타임아웃 안에 PING/PONG 왕복이 끝나는지 검사합니다.\n"
"인게임 연결과는 별도의 일회성 검사라서 진행 중인 게임에는 영향 없음."));
connect(m_vlmConnectBtn, &QPushButton::clicked, this, &MainWindow::onPingServer1Clicked);
s1Lay->addWidget(m_vlmConnectBtn);
m_vlmConnLabel = new QLabel(QStringLiteral("● 미확인"), m_vlmServer1Row);
m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#888;"));
m_vlmConnLabel->setToolTip(QStringLiteral("서버1 접속 확인 결과 / 인게임 TCP 연결 상태."));
m_vlmConnLabel->setMinimumWidth(220);
s1Lay->addWidget(m_vlmConnLabel, 1);
root->addWidget(m_vlmServer1Row);
m_vlmServer1Row->hide();
// ---- 서버2 행: VlmVsVlm 모드 전용 (백 측). 비우면 서버1 과 동일. ----
m_vlmWhiteRow = new QWidget(this);
auto *s2Lay = new QHBoxLayout(m_vlmWhiteRow);
s2Lay->setContentsMargins(0, 0, 0, 0);
s2Lay->setSpacing(6);
{
auto *lbl = new QLabel(QStringLiteral("<b>서버2</b>"), m_vlmWhiteRow);
lbl->setMinimumWidth(72);
lbl->setToolTip(QStringLiteral(
"VLM 대 VLM 모드의 백 측 VLM 엔드포인트(선택).\n"
"host·port 를 비워 두면 서버1 과 동일 (단일 self-play).\n"
"두 서버 비교는 host와 port를 모두 다른 값으로 채울 때만 사용합니다."));
s2Lay->addWidget(lbl);
}
// 기본값 비움 = 단일 서버 self-play (Metal 에서 Qwen 두 개 불가).
// host/port 둘 다 채울 때만 서버2 로 분리 접속.
m_vlmHostWhiteEdit = new QLineEdit(QString(), m_vlmWhiteRow);
m_vlmHostWhiteEdit->setPlaceholderText(QStringLiteral("비우면 서버1 (self-play)"));
m_vlmPortWhiteEdit = new QLineEdit(QString(), m_vlmWhiteRow);
m_vlmPortWhiteEdit->setMaximumWidth(72);
m_vlmPortWhiteEdit->setPlaceholderText(QStringLiteral("비우면 서버1"));
s2Lay->addWidget(m_vlmHostWhiteEdit, 1);
s2Lay->addWidget(m_vlmPortWhiteEdit);
m_vlmConnectBtn2 = new QPushButton(QStringLiteral("접속 확인"), m_vlmWhiteRow);
m_vlmConnectBtn2->setToolTip(QStringLiteral(
"서버2 만 따로 PING/PONG 으로 확인합니다.\n"
"host/port 가 비어 있으면 서버1 과 같은 서버라는 의미라서 검사 생략."));
connect(m_vlmConnectBtn2, &QPushButton::clicked, this, &MainWindow::onPingServer2Clicked);
s2Lay->addWidget(m_vlmConnectBtn2);
m_vlmConnLabel2 = new QLabel(QStringLiteral("● 미확인"), m_vlmWhiteRow);
m_vlmConnLabel2->setStyleSheet(QStringLiteral("color:#888;"));
m_vlmConnLabel2->setToolTip(QStringLiteral("서버2 접속 확인 결과."));
m_vlmConnLabel2->setMinimumWidth(220);
s2Lay->addWidget(m_vlmConnLabel2, 1);
root->addWidget(m_vlmWhiteRow);
m_vlmWhiteRow->hide();
// ---- 공용 옵션 행: 타임아웃 / 응답 취소 ----
m_vlmConfigRow = new QWidget(this);
auto *vlmLay = new QHBoxLayout(m_vlmConfigRow);
vlmLay->setContentsMargins(0, 0, 0, 0);
vlmLay->setSpacing(6);
vlmLay->addWidget(new QLabel(QStringLiteral("타임아웃(s):"), m_vlmConfigRow));
m_vlmTimeoutSpin = new QSpinBox(m_vlmConfigRow);
m_vlmTimeoutSpin->setRange(5, 600);
m_vlmTimeoutSpin->setValue(60);
m_vlmTimeoutSpin->setSuffix(QStringLiteral("s"));
m_vlmTimeoutSpin->setMaximumWidth(82);
m_vlmTimeoutSpin->setToolTip(QStringLiteral(
"각 VLM 추론 요청과 접속 확인 검사에 적용되는 타임아웃."));
vlmLay->addWidget(m_vlmTimeoutSpin);
m_vlmCancelBtn = new QPushButton(QStringLiteral("VLM 응답 취소"), m_vlmConfigRow);
m_vlmCancelBtn->setEnabled(false);
m_vlmCancelBtn->setToolTip(QStringLiteral("진행 중인 VLM 추론 요청을 즉시 취소합니다."));
connect(m_vlmCancelBtn, &QPushButton::clicked, this, &MainWindow::onCancelVlmClicked);
vlmLay->addWidget(m_vlmCancelBtn);
m_vlmAutoReconnectCheck = new QCheckBox(QStringLiteral("자동 재접속"), m_vlmConfigRow);
m_vlmAutoReconnectCheck->setToolTip(QStringLiteral(
"접속 확인이 실패하면 1.5s → 3s → 4.5s … (최대 15s) 백오프로 자동 재시도합니다.\n"
"성공하거나 [접속 확인] 을 다시 누르면 카운터가 리셋됩니다.\n"
"6회 연속 실패하면 자동 재접속을 중단합니다."));
m_vlmAutoReconnectCheck->setChecked(false);
connect(m_vlmAutoReconnectCheck, &QCheckBox::toggled, this, [this](bool on) {
if (!on) {
// 켜짐 → 꺼짐: 진행 중인 재시도를 모두 중단
if (m_retryTimer1) m_retryTimer1->stop();
if (m_retryTimer2) m_retryTimer2->stop();
m_retryAttempts1 = m_retryAttempts2 = 0;
}
});
vlmLay->addWidget(m_vlmAutoReconnectCheck);
vlmLay->addStretch();
root->addWidget(m_vlmConfigRow);
m_vlmConfigRow->hide();
// ---- 보드 시각화 서버 행 (BRD1/RTXT, 모델이 격자·텍스트 반환) ----
m_boardEchoRow = new QWidget(this);
auto *beLay = new QHBoxLayout(m_boardEchoRow);
beLay->setContentsMargins(0, 0, 0, 0);
beLay->setSpacing(6);
{
auto *lbl = new QLabel(QStringLiteral("<b>보드 시각화</b>"), m_boardEchoRow);
lbl->setMinimumWidth(72);
lbl->setToolTip(QStringLiteral(
"9321 등 별도 포트의 보드 시각화 서버에 PNG+격자를 보내고,\n"
"모델 응답(격자 JSON·ASCII 등)을 텍스트로 받습니다.\n"
"VLM 한 수 서버(GO19)와 소켓을 공유하지 않습니다."));
beLay->addWidget(lbl);
}
m_boardEchoHostEdit = new QLineEdit(QStringLiteral("127.0.0.1"), m_boardEchoRow);
m_boardEchoHostEdit->setPlaceholderText(QStringLiteral("호스트"));
m_boardEchoPortEdit = new QLineEdit(QStringLiteral("9321"), m_boardEchoRow);
m_boardEchoPortEdit->setMaximumWidth(72);
m_boardEchoPortEdit->setPlaceholderText(QStringLiteral("포트"));
beLay->addWidget(m_boardEchoHostEdit, 1);
beLay->addWidget(m_boardEchoPortEdit);
m_boardEchoPingBtn = new QPushButton(QStringLiteral("접속 확인"), m_boardEchoRow);
m_boardEchoPingBtn->setToolTip(QStringLiteral("PING/PONG 으로 보드 시각화 서버만 검사합니다."));
connect(m_boardEchoPingBtn, &QPushButton::clicked, this, &MainWindow::onPingBoardEchoClicked);
beLay->addWidget(m_boardEchoPingBtn);
m_boardEchoRunBtn = new QPushButton(QStringLiteral("보드 인식"), m_boardEchoRow);
m_boardEchoRunBtn->setToolTip(
QStringLiteral("현재 화면 보드를 PNG+packed 로 보내 RTXT 응답을 아래에 표시합니다."));
connect(m_boardEchoRunBtn, &QPushButton::clicked, this, &MainWindow::onBoardEchoRunClicked);
beLay->addWidget(m_boardEchoRunBtn);
root->addWidget(m_boardEchoRow);
m_boardEchoRow->hide();
m_boardEchoOutput = new QPlainTextEdit(this);
m_boardEchoOutput->setPlaceholderText(
QStringLiteral("보드 시각화 서버 응답(모델 텍스트)이 여기에 표시됩니다."));
m_boardEchoOutput->setReadOnly(true);
m_boardEchoOutput->setMaximumBlockCount(8000);
m_boardEchoOutput->setMinimumHeight(120);
m_boardEchoOutput->hide();
root->addWidget(m_boardEchoOutput);
// 재접속용 카운트다운 타이머 (250ms 마다 라벨 갱신, 만료되면 probe 재실행)
m_retryTimer1 = new QTimer(this);
m_retryTimer1->setInterval(250);
connect(m_retryTimer1, &QTimer::timeout, this, [this]() { onRetryTick(1); });
m_retryTimer2 = new QTimer(this);
m_retryTimer2->setInterval(250);
connect(m_retryTimer2, &QTimer::timeout, this, [this]() { onRetryTick(2); });
// ---- 상태/도움말 라벨 ----
m_statusLabel = new QLabel(QStringLiteral("(상태 없음)"), this);
m_statusLabel->setWordWrap(true);
m_statusLabel->setContentsMargins(2, 4, 2, 6);
m_statusLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
root->addWidget(m_statusLabel);
// ---- 보드 + (점수 + 히스토리) ----
auto *midRow = new QHBoxLayout();
midRow->setContentsMargins(0, 0, 0, 0);
midRow->setSpacing(10);
m_boardWidget = new BoardWidget(this);
connect(m_boardWidget, &BoardWidget::intersectionClicked,
this, &MainWindow::onIntersectionClicked);
midRow->addWidget(m_boardWidget, 1);
// 오른쪽: 점수 패널(항상 보임) + 히스토리
auto *histWrap = new QWidget(this);
histWrap->setMinimumWidth(280);
auto *histLay = new QVBoxLayout(histWrap);
histLay->setContentsMargins(0, 0, 0, 0);
histLay->setSpacing(6);
m_scoreLabel = new QLabel(histWrap);
m_scoreLabel->setTextFormat(Qt::RichText);
m_scoreLabel->setWordWrap(true);
m_scoreLabel->setMinimumHeight(96);
m_scoreLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_scoreLabel->setStyleSheet(QStringLiteral(
"QLabel {"
" font-size: 13pt;"
" background: #f7faf7;"
" border: 1px solid #b9cbb9;"
" border-radius: 8px;"
" padding: 8px 12px;"
"}"));
m_scoreLabel->setToolTip(QStringLiteral(
"매 수마다 어림 계가(중국식 면적: 산 돌 + 확정 집)를 갱신합니다.\n"
"공배·분쟁 영역은 점수에 넣지 않습니다. 사석 판정은 휴리스틱이라 중반에는 흔들릴 수 있습니다.\n"
"계가(생사/영역) 로직이 좋아질수록 이 표시도 더 정확해집니다."));
m_scoreLabel->setText(QStringLiteral(
"<div style='font-size:10pt;color:#888;'><i>어림 계가</i></div>"
"<div style='font-size:16pt;font-weight:700;'>—</div>"
"<div style='font-size:10pt;color:#666;'>대국이 시작되면 매 수 갱신</div>"));
histLay->addWidget(m_scoreLabel, 0);
{
auto *ttl = new QLabel(QStringLiteral("<b>히스토리</b>"), histWrap);
ttl->setToolTip(QStringLiteral("게임별로 저장되며, 클릭하면 수순을 보여줍니다."));
histLay->addWidget(ttl);
}
m_gameList = new QListWidget(histWrap);
m_gameList->setSelectionMode(QAbstractItemView::SingleSelection);
m_gameList->setUniformItemSizes(true);
m_gameList->setWordWrap(false);
m_gameList->setMinimumHeight(100);
m_gameList->setStyleSheet(QStringLiteral(
"QListWidget { background:#fcfcfc; border:1px solid #d7d7d7; border-radius:8px; }"
"QListWidget::item { padding:4px 8px; }"));
histLay->addWidget(m_gameList, 0);
m_moveList = new QListWidget(histWrap);
m_moveList->setSelectionMode(QAbstractItemView::NoSelection);
m_moveList->setUniformItemSizes(true);
m_moveList->setWordWrap(false);
m_moveList->setStyleSheet(QStringLiteral(
"QListWidget { background:#ffffff; border:1px solid #d7d7d7; border-radius:8px; }"
"QListWidget::item { padding:3px 8px; }"));
histLay->addWidget(m_moveList, 1);
midRow->addWidget(histWrap, 0);
root->addLayout(midRow, 1);
// ---- 수순 네비게이션 ----
m_navRow = new QWidget(this);
auto *navLay = new QHBoxLayout(m_navRow);
navLay->setContentsMargins(0, 4, 0, 0);
navLay->setSpacing(6);
auto addNav = [&](QPushButton **slot, const QString &text, const QString &tip) {
auto *b = new QPushButton(text, m_navRow);
b->setToolTip(tip);
b->setMinimumWidth(72);
navLay->addWidget(b);
*slot = b;
};
addNav(&m_navFirstBtn, QStringLiteral("처음으로"),
QStringLiteral("빈 바둑판(첫 수 전)으로 이동합니다. 판/기보는 유지됩니다."));
addNav(&m_navPrevBtn, QStringLiteral("이전"),
QStringLiteral("한 수 전 국면으로 이동합니다."));
addNav(&m_navPauseBtn, QStringLiteral("중지"),
QStringLiteral("자동(휴리스틱/VLM) 진행을 멈춥니다. 다시 누르면 해제됩니다."));
addNav(&m_navContinueBtn, QStringLiteral("여기서 이어두기"),
QStringLiteral("지금 보이는 국면 이후 수는 버리고, 여기서부터 다시 둡니다."));
addNav(&m_navNextBtn, QStringLiteral("다음"),
QStringLiteral("한 수 뒤 국면으로 이동합니다."));
addNav(&m_navLastBtn, QStringLiteral("마지막"),
QStringLiteral("최신 수까지 이동합니다."));
m_navPosLabel = new QLabel(QStringLiteral("0 / 0"), m_navRow);
m_navPosLabel->setMinimumWidth(72);
m_navPosLabel->setAlignment(Qt::AlignCenter);
navLay->addWidget(m_navPosLabel, 1);
connect(m_navFirstBtn, &QPushButton::clicked, this, &MainWindow::onNavFirstClicked);
connect(m_navPrevBtn, &QPushButton::clicked, this, &MainWindow::onNavPrevClicked);
connect(m_navPauseBtn, &QPushButton::clicked, this, &MainWindow::onNavPauseClicked);
connect(m_navContinueBtn, &QPushButton::clicked, this, &MainWindow::onNavContinueClicked);
connect(m_navNextBtn, &QPushButton::clicked, this, &MainWindow::onNavNextClicked);
connect(m_navLastBtn, &QPushButton::clicked, this, &MainWindow::onNavLastClicked);
root->addWidget(m_navRow);
// ---- VLM 워커 스레드 ----
m_vlmThread = new QThread(this);
m_vlmThread->setObjectName(QStringLiteral("VlmWorkerThread"));
auto *worker = new TcpVlmClient::Worker();
worker->moveToThread(m_vlmThread);
m_vlmWorker = worker;
connect(m_vlmThread, &QThread::finished, worker, &QObject::deleteLater);
connect(worker, &TcpVlmClient::Worker::moveReady,
this, &MainWindow::onVlmMoveReady, Qt::QueuedConnection);
connect(worker, &TcpVlmClient::Worker::pingReady,
this, &MainWindow::onVlmPingReady, Qt::QueuedConnection);
connect(worker, &TcpVlmClient::Worker::pingProbeReady,
this, &MainWindow::onVlmPingProbeReady, Qt::QueuedConnection);
connect(worker, &TcpVlmClient::Worker::connectionStateChanged,
this, &MainWindow::onVlmConnectionStateChanged, Qt::QueuedConnection);
m_vlmThread->start();
m_boardEchoThread = new QThread(this);
m_boardEchoThread->setObjectName(QStringLiteral("BoardEchoWorkerThread"));
auto *beWorker = new TcpBoardEchoClient::Worker();
beWorker->moveToThread(m_boardEchoThread);
m_boardEchoWorker = beWorker;
connect(m_boardEchoThread, &QThread::finished, beWorker, &QObject::deleteLater);
connect(beWorker, &TcpBoardEchoClient::Worker::pingReady, this,
[this](bool ok, QString err) {
if (!m_boardEchoOutput)
return;
const QString line = ok
? QStringLiteral("[%1] 보드 시각화 서버 PING OK\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs))
: QStringLiteral("[%1] 보드 시각화 서버 PING 실패: %2\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs), err);
m_boardEchoOutput->appendPlainText(line);
},
Qt::QueuedConnection);
connect(beWorker, &TcpBoardEchoClient::Worker::echoReady, this,
&MainWindow::onBoardEchoReady, Qt::QueuedConnection);
m_boardEchoThread->start();
m_vlmProgressTimer = new QTimer(this);
m_vlmProgressTimer->setInterval(500);
connect(m_vlmProgressTimer, &QTimer::timeout, this, &MainWindow::onVlmTickProgress);
syncBoardModeFromPlayers();
setVlmUiVisible(blackPlayer() == PlayerKind::Vlm || whitePlayer() == PlayerKind::Vlm);
m_matchStarted = false;
updateStatus();
updateNavUi();
updateStartUi();
// 시작 버튼을 누르기 전에는 자동 착수하지 않음.
uiInitHistoryPanel();
}
MainWindow::~MainWindow()
{
// VLM/보드 에코 워커가 readBytes 안 QEventLoop 에 막혀 있을 때,
// QueuedConnection 만 쓰면 소켓 abort 가 늦게 가서 QThread 가 "실행 중 파괴" 로 abort 된다.
if (m_vlmWorker && m_vlmThread && m_vlmThread->isRunning()) {
QMetaObject::invokeMethod(m_vlmWorker.data(), "shutdownForAppExit",
Qt::BlockingQueuedConnection);
m_vlmThread->quit();
if (!m_vlmThread->wait(60000))
qWarning("VlmWorkerThread: quit 대기 60초 초과");
}
if (m_boardEchoWorker && m_boardEchoThread && m_boardEchoThread->isRunning()) {
QMetaObject::invokeMethod(m_boardEchoWorker.data(), "shutdownForAppExit",
Qt::BlockingQueuedConnection);
m_boardEchoThread->quit();
if (!m_boardEchoThread->wait(60000))
qWarning("BoardEchoWorkerThread: quit 대기 60초 초과");
}
}
void MainWindow::selectGameMode(GameMode mode)
{
PlayerKind b = PlayerKind::Human;
PlayerKind w = PlayerKind::Human;
switch (mode) {
case GameMode::HumanVsHuman:
b = PlayerKind::Human;
w = PlayerKind::Human;
break;
case GameMode::HumanVsAi:
b = PlayerKind::Human;
w = PlayerKind::Heuristic;
break;
case GameMode::AiVsVlm:
b = PlayerKind::Heuristic;
w = PlayerKind::Vlm;
break;
case GameMode::HumanVsVlm:
b = PlayerKind::Human;
w = PlayerKind::Vlm;
break;
case GameMode::VlmVsVlm:
b = PlayerKind::Vlm;
w = PlayerKind::Vlm;
break;
}
const auto setCombo = [](QComboBox *box, PlayerKind k) {
if (!box) return;
const QSignalBlocker blocker(box);
for (int i = 0; i < box->count(); ++i) {
if (box->itemData(i).toInt() == static_cast<int>(k)) {
box->setCurrentIndex(i);
return;
}
}
};
setCombo(m_blackPlayerCombo, b);
setCombo(m_whitePlayerCombo, w);
applyPlayerSelectionChange();
}
PlayerKind MainWindow::blackPlayer() const
{
if (!m_blackPlayerCombo)
return PlayerKind::Human;
return static_cast<PlayerKind>(m_blackPlayerCombo->currentData().toInt());
}
PlayerKind MainWindow::whitePlayer() const
{
if (!m_whitePlayerCombo)
return PlayerKind::Human;
return static_cast<PlayerKind>(m_whitePlayerCombo->currentData().toInt());
}
PlayerKind MainWindow::playerKind(Stone side) const
{
return (side == Stone::White) ? whitePlayer() : blackPlayer();
}
void MainWindow::syncBoardModeFromPlayers()
{
const PlayerKind b = blackPlayer();
const PlayerKind w = whitePlayer();
if (b == PlayerKind::Human && w != PlayerKind::Human)
m_board.setHumanSide(Stone::Black);
else if (w == PlayerKind::Human && b != PlayerKind::Human)
m_board.setHumanSide(Stone::White);
if (b == PlayerKind::Vlm && w != PlayerKind::Vlm)
m_board.setVlmSide(Stone::Black);
else if (w == PlayerKind::Vlm && b != PlayerKind::Vlm)
m_board.setVlmSide(Stone::White);
else if (b == PlayerKind::Vlm && w == PlayerKind::Vlm)
m_board.setVlmSide(Stone::White);
GameMode mode = GameMode::HumanVsHuman;
const bool hb = (b == PlayerKind::Human);
const bool hw = (w == PlayerKind::Human);
const bool ab = (b == PlayerKind::Heuristic);
const bool aw = (w == PlayerKind::Heuristic);
const bool vb = (b == PlayerKind::Vlm);
const bool vw = (w == PlayerKind::Vlm);
if (hb && hw)
mode = GameMode::HumanVsHuman;
else if ((hb || hw) && (ab || aw))
mode = GameMode::HumanVsAi;
else if ((hb || hw) && (vb || vw))
mode = GameMode::HumanVsVlm;
else if ((ab || aw) && (vb || vw))
mode = GameMode::AiVsVlm;
else if (vb && vw)
mode = GameMode::VlmVsVlm;
else if (ab && aw)
mode = GameMode::AiVsVlm; // 휴리스틱 대 휴리스틱
m_board.setMode(mode);
}
void MainWindow::applyPlayerSelectionChange()
{
syncBoardModeFromPlayers();
const bool useVlm = (blackPlayer() == PlayerKind::Vlm || whitePlayer() == PlayerKind::Vlm);
setVlmUiVisible(useVlm);
// 진행 중 VLM 요청이 있는데 지금 차례가 더 이상 VLM이 아니면 취소.
if (m_vlmInFlight && !isVlmTurn() && m_vlmWorker) {
QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
finishVlmCleanup();
}
updateStatus();
// 새 판 시작하지 않음 — 현재 차례에 맞는 actor만 이어 둠.
updateNavUi();
maybeScheduleNext();
}
void MainWindow::setVlmUiVisible(bool visible)
{
if (m_vlmServer1Row)
m_vlmServer1Row->setVisible(visible);
if (m_vlmConfigRow)
m_vlmConfigRow->setVisible(visible);
if (m_boardEchoRow)
m_boardEchoRow->setVisible(visible);
if (m_boardEchoOutput)
m_boardEchoOutput->setVisible(visible);
// 서버2: 백이 VLM 일 때만 (흑만 VLM이면 서버1만).
const bool whiteRow = visible && whitePlayer() == PlayerKind::Vlm;
if (m_vlmWhiteRow)
m_vlmWhiteRow->setVisible(whiteRow);
if (!visible) {
cancelAutoReconnect(1);
cancelAutoReconnect(2);
} else if (!whiteRow) {
cancelAutoReconnect(2);
}
}
bool MainWindow::autoActorShouldMove() const
{
if (m_board.status() != GameStatus::Playing)
return false;
return playerKind(m_board.currentTurn()) != PlayerKind::Human;
}
Stone MainWindow::vlmSideForCurrentMode() const
{
// 현재 차례가 VLM이면 그 색, 아니면 Empty (레거시 호환).
if (playerKind(m_board.currentTurn()) == PlayerKind::Vlm)
return m_board.currentTurn();
// 한쪽만 VLM인 모드용
if (blackPlayer() == PlayerKind::Vlm && whitePlayer() != PlayerKind::Vlm)
return Stone::Black;
if (whitePlayer() == PlayerKind::Vlm && blackPlayer() != PlayerKind::Vlm)
return Stone::White;
if (blackPlayer() == PlayerKind::Vlm && whitePlayer() == PlayerKind::Vlm)
return m_board.currentTurn();
return Stone::Empty;
}
Stone MainWindow::aiSideForCurrentMode() const
{
if (playerKind(m_board.currentTurn()) == PlayerKind::Heuristic)
return m_board.currentTurn();
if (blackPlayer() == PlayerKind::Heuristic && whitePlayer() != PlayerKind::Heuristic)
return Stone::Black;
if (whitePlayer() == PlayerKind::Heuristic && blackPlayer() != PlayerKind::Heuristic)
return Stone::White;
if (blackPlayer() == PlayerKind::Heuristic && whitePlayer() == PlayerKind::Heuristic)
return m_board.currentTurn();
return Stone::Empty;
}
bool MainWindow::isVlmTurn() const
{
return playerKind(m_board.currentTurn()) == PlayerKind::Vlm;
}
bool MainWindow::isLocalAiTurn() const
{
return playerKind(m_board.currentTurn()) == PlayerKind::Heuristic;
}
bool MainWindow::isHumanTurn() const
{
return playerKind(m_board.currentTurn()) == PlayerKind::Human;
}
void MainWindow::maybeScheduleNext()
{
if (m_board.status() != GameStatus::Playing)
return;
if (m_scoringMode)
return;
if (!canAutoPlay())
return;
// 심판: 양쪽 모두 둘 의미 있는 수가 더 없으면 자동으로 계가 모드 진입.
if (!scoringTemporarilyBlocked() && GameJudge::gameIsOver(m_board)) {
const quint64 sid = m_sessionId;
QTimer::singleShot(350, this, [this, sid]() {
if (sid != m_sessionId) return;
if (m_board.status() != GameStatus::Playing) return;
if (m_scoringMode) return;
if (!canAutoPlay()) return;
qInfo("심판: 더 둘 곳 없음 → 자동 계가 모드 진입");
enterScoringMode();
});
return;
}
if (!autoActorShouldMove())
return;
constexpr int delayMs = 280;
const quint64 sid = m_sessionId;
QTimer::singleShot(delayMs, this, [this, sid]() {
if (sid != m_sessionId)
return;
if (m_board.status() != GameStatus::Playing)
return;
if (m_scoringMode)
return;
if (!canAutoPlay())
return;
if (isVlmTurn()) {
startVlmRequest();
} else if (isLocalAiTurn()) {
doOneAiMove();
}
});
}
// 패스 = "패배 선언 의사". 게임은 자동으로 끝나지 않는다 — 사람이 명시적으로
// "패배 선언" 을 선택하거나 기권 버튼을 눌러야 종료된다.
//
// 사람이 패스 버튼을 누르면:
// 1) 본인에게 다이얼로그: "패배 선언" / "계속 두기 (취소)"
// 2) 패배 선언 → resign(); 계속 두기 → 아무 일도 안 일어남 (같은 차례 유지)
//
// 자동 actor (AI/VLM) 가 둘 곳이 정말 없어서 패스 의사를 보이면:
// - estimateScore 로 자기 입장에서 압도적으로 지고 있으면 (≥ 30 점 차) resign
// - 그 외엔 패스를 무시하고 강제로 한 수를 두게 한다 (AiPlayer 안전망)
void MainWindow::pushBoardToView()
{
QVector<Stone> cells(m_board.size() * m_board.size());
for (int r = 0; r < m_board.size(); ++r)
for (int c = 0; c < m_board.size(); ++c)
cells[r * m_board.size() + c] = m_board.cellAt(r, c);
m_boardWidget->refreshFromBoard(cells, m_board.size());
}
void MainWindow::updateStatus()
{
QString turn;
QString scoreHtml; // m_scoreLabel 에 들어갈 큰 점수 패널.
if (m_board.status() != GameStatus::Playing) {
QString winnerStr;
QString leaderColor = QStringLiteral("#888");
switch (m_board.status()) {
case GameStatus::BlackWins:
winnerStr = QStringLiteral("흑 승");
leaderColor = QStringLiteral("#1a3");
break;
case GameStatus::WhiteWins:
winnerStr = QStringLiteral("백 승");
leaderColor = QStringLiteral("#36b");
break;
case GameStatus::Draw:
winnerStr = QStringLiteral("무승부");
break;
case GameStatus::Playing:
break;
}
turn = QStringLiteral("<b>%1</b>").arg(winnerStr);
if (m_board.hasFinalReport()) {
const ScoreReport rep = m_board.scoreNow();
const double margin = (m_board.status() == GameStatus::BlackWins)
? (rep.finalBlack - rep.finalWhite)
: (rep.finalWhite - rep.finalBlack);
scoreHtml = QStringLiteral(
"<div style='font-size:10pt;color:#888;'>계가 결과</div>"
"<div style='font-size:18pt;font-weight:700;color:%1;'>%2 +%3</div>"
"<div style='font-size:11pt;color:#444;'>"
"흑 %4 vs 백 %5 <span style='color:#888;'>(덤 %6)</span></div>"
"<div style='font-size:9pt;color:#888;'>"
"흑 영역 %7 · 백 영역 %8 · 공배 %9</div>")
.arg(leaderColor, winnerStr)
.arg(margin, 0, 'f', 1)
.arg(rep.finalBlack, 0, 'f', 1)
.arg(rep.finalWhite, 0, 'f', 1)
.arg(rep.komi, 0, 'f', 1)
.arg(rep.blackTerritory)
.arg(rep.whiteTerritory)
.arg(rep.dame);
} else {
scoreHtml = QStringLiteral(
"<div style='font-size:10pt;color:#888;'>종료</div>"
"<div style='font-size:18pt;font-weight:700;color:%1;'>%2 (불계)</div>"
"<div style='font-size:10pt;color:#666;'>패배 선언으로 종료</div>")
.arg(leaderColor, winnerStr);
}
} else if (m_scoringMode) {
// 계가 모드: 임시 점수 (사석/영토 표시 기반).
const ScoreReport rep = computeScoreFromMarks();
QString leaderColor;
QString lead;
if (rep.finalBlack > rep.finalWhite) {
leaderColor = QStringLiteral("#1a3");
lead = tr("흑 +%1").arg(rep.finalBlack - rep.finalWhite, 0, 'f', 1);
} else if (rep.finalWhite > rep.finalBlack) {
leaderColor = QStringLiteral("#36b");
lead = tr("백 +%1").arg(rep.finalWhite - rep.finalBlack, 0, 'f', 1);
} else {
leaderColor = QStringLiteral("#888");
lead = tr("대등");
}
turn = QStringLiteral("<b>계가 모드</b> <span style='color:#888;'>"
"(돌 클릭 = 사석 토글)</span>");
scoreHtml = QStringLiteral(
"<div style='font-size:10pt;color:#888;'>계가 (조정 중)</div>"
"<div style='font-size:18pt;font-weight:700;color:%1;'>%2</div>"
"<div style='font-size:11pt;color:#444;'>"
"흑 %3 vs 백 %4 <span style='color:#888;'>(덤 %5)</span></div>"
"<div style='font-size:9pt;color:#888;'>"
"흑 영역 %6 · 백 영역 %7 · 공배 %8 · 사석 %9</div>")
.arg(leaderColor, lead)
.arg(rep.finalBlack, 0, 'f', 1)
.arg(rep.finalWhite, 0, 'f', 1)
.arg(rep.komi, 0, 'f', 1)
.arg(rep.blackTerritory)
.arg(rep.whiteTerritory)
.arg(rep.dame)
.arg(m_deadSet.size());
} else {
turn = QStringLiteral("차례: <b>%1</b>").arg(stoneName(m_board.currentTurn()));
if (m_board.lastWasPass())
turn += QStringLiteral(" (직전 수: 패스)");
// (심판이 "더 둘 곳 없음" 으로 판정되면 maybeScheduleNext 가 자동으로
// 계가 모드로 진입하므로 여기에 별도 안내 문구는 두지 않는다.)
// 어림 점수 패널.
const GameJudge::Estimate est = GameJudge::estimateScore(m_board);
QString leadHtml;
QString leaderColor;
if (est.leader == Stone::Black) {
leaderColor = QStringLiteral("#1a3");
leadHtml = QStringLiteral("흑 +%1").arg(est.margin, 0, 'f', 1);
} else if (est.leader == Stone::White) {
leaderColor = QStringLiteral("#36b");
leadHtml = QStringLiteral("백 +%1").arg(est.margin, 0, 'f', 1);
} else {
leaderColor = QStringLiteral("#888");
leadHtml = QStringLiteral("대등");
}
QString deadNote;
if (est.blackDeadStones > 0 || est.whiteDeadStones > 0) {
deadNote = QStringLiteral(" · 사석 흑%1/백%2")
.arg(est.blackDeadStones)
.arg(est.whiteDeadStones);
}
scoreHtml = QStringLiteral(
"<div style='font-size:10pt;color:#666;'><i>어림 계가</i> · 수 %1</div>"
"<div style='font-size:20pt;font-weight:700;color:%2;'>%3</div>"
"<div style='font-size:12pt;color:#222;'>"
"흑 %4 : 백 %5"
" <span style='color:#888;'>(덤 %6)</span></div>"
"<div style='font-size:9pt;color:#666;'>"
"산돌 흑%7/백%8 · 집 흑%9/백%10</div>"
"<div style='font-size:9pt;color:#888;'>"
"분쟁 %11 · 공배 %12%13</div>")
.arg(m_board.totalMoves())
.arg(leaderColor)
.arg(leadHtml)
.arg(est.finalBlack, 0, 'f', 1)
.arg(est.finalWhite, 0, 'f', 1)
.arg(est.komi, 0, 'f', 1)
.arg(est.blackAliveStones)
.arg(est.whiteAliveStones)
.arg(est.blackTerritory)
.arg(est.whiteTerritory)
.arg(est.active)
.arg(est.dame)
.arg(deadNote);
}
if (m_scoreLabel) {
if (scoreHtml.isEmpty()) {
scoreHtml = QStringLiteral(
"<div style='font-size:10pt;color:#888;'><i>어림 계가</i></div>"
"<div style='font-size:16pt;font-weight:700;'>—</div>");
}
m_scoreLabel->setText(scoreHtml);
m_scoreLabel->setVisible(true);
}
QString helper;
const auto kindName = [](PlayerKind k) -> QString {
switch (k) {
case PlayerKind::Human: return QStringLiteral("사람");
case PlayerKind::Heuristic: return QStringLiteral("휴리스틱");
case PlayerKind::Vlm: return QStringLiteral("VLM");
}
return QStringLiteral("?");
};
helper = QStringLiteral("흑=%1 / 백=%2. 셀렉터를 바꾸면 판을 유지한 채 이어서 둡니다.")
.arg(kindName(blackPlayer()), kindName(whitePlayer()));
const bool playing = (m_board.status() == GameStatus::Playing);
if (playing && !m_matchStarted)
helper = QStringLiteral("시작 대기 — [시작] 을 누르면 대국이 진행됩니다. (%1)")
.arg(helper);
const QString caps = QStringLiteral("따냄: 흑 %1 / 백 %2 | 수 %3")
.arg(m_board.capturedByBlack())
.arg(m_board.capturedByWhite())
.arg(m_board.totalMoves());
m_statusLabel->setText(QStringLiteral("<b>%1</b><br>%2<br><small>%3</small>")
.arg(turn).arg(caps).arg(helper));
if (m_passBtn)
m_passBtn->setEnabled(playing && m_matchStarted && isHumanTurn() && !isReviewing());
if (m_resignBtn)
m_resignBtn->setEnabled(playing && m_matchStarted && isHumanTurn() && !isReviewing());
updateStartUi();
// 종국 시 1회만 SGF 자동 저장 (학습 모드가 켜져 있을 때).
if (m_board.status() != GameStatus::Playing && !m_saveScheduled) {
m_saveScheduled = true;
if (m_recordCheck && m_recordCheck->isChecked())
saveGameAsSgf();
}
if (m_board.status() != GameStatus::Playing && !m_autoRestartScheduled
&& m_autoRestartCheck && m_autoRestartCheck->isChecked()) {
m_autoRestartScheduled = true;
const quint64 sid = m_sessionId;
QTimer::singleShot(1500, this, [this, sid]() {
if (sid != m_sessionId) return;
restartMatch();
// 자동 재시작: 빈 판 준비 후 즉시 시작
onStartClicked();
});
}
}
void MainWindow::restartMatch()
{
++m_sessionId;
++m_vlmGameEpoch;
m_vlmRejectCoords.clear();
m_vlmTurnRetryCount = 0;
m_vlmPassClearTries = 0;
m_vlmAtariMissCount = 0;
m_autoRestartScheduled = false;
m_saveScheduled = false;
m_autoBadMarginStreakBlack = 0;
m_autoBadMarginStreakWhite = 0;
m_matchStarted = false;
if (m_vlmInFlight && m_vlmWorker) {
QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
finishVlmCleanup();
}
const GameMode mode = m_board.mode();
const Stone humanSide = m_board.humanSide();
const Stone vlmSide = m_board.vlmSide();
const double komi = m_board.komi();
m_board.reset();
m_board.setMode(mode);
m_board.setHumanSide(humanSide);
m_board.setVlmSide(vlmSide);
m_board.setKomi(komi);
recordGameStart();
if (m_boardWidget) m_boardWidget->setLastMove(-1, -1, false);
pushBoardToView();
updateStatus();
updateStartUi();
}
void MainWindow::onNewGameClicked() { restartMatch(); }
void MainWindow::onStartClicked()
{
if (m_board.status() != GameStatus::Playing) return;
if (m_matchStarted) return;
setMatchStarted(true);
updateStatus();
maybeScheduleNext();
}
// ===================== 학습 모드 (기보 기록/저장) =====================
void MainWindow::recordGameStart()
{
m_moveLog.clear();
m_navPly = 0;
m_playPaused = false;
m_uiCurrentLines.clear();
m_viewingCurrentGame = true;
if (m_moveList)
m_moveList->clear();
if (m_gameList)
m_gameList->setCurrentRow(0);
updateNavUi();
}
void MainWindow::recordMove(Stone color, int row, int col, bool isPass)
{
if (m_rebuilding)
return;
if (color != Stone::Black && color != Stone::White)
return;
// 복기 중 착수는 "여기서 이어두기" 후에만 허용.
if (isReviewing())
return;
// UI 히스토리 (항상 기록).
{
const int moveNo = m_uiCurrentLines.size() + 1;
const QString who = (color == Stone::Black) ? QStringLiteral("흑") : QStringLiteral("백");
const QString mv = isPass ? QStringLiteral("PASS")
: coordText19(row, col);
uiAppendCurrentLine(QStringLiteral("%1. %2 %3").arg(moveNo).arg(who, mv));
}
// 수순 네비게이션용 — 항상 기록 (SGF 저장은 체크박스와 별개로 기보를 가질 수 있음)
RecordedMove m{color, isPass ? -1 : row, isPass ? -1 : col, isPass};
m_moveLog.append(m);
m_navPly = m_moveLog.size();
updateNavUi();
}
void MainWindow::saveGameAsSgf()
{
if (!(m_recordCheck && m_recordCheck->isChecked()))
return;
if (m_moveLog.isEmpty())
return;
// 저장 폴더 준비
if (m_recordDir.isEmpty())
m_recordDir = QStringLiteral("recordings");
QDir().mkpath(m_recordDir);
const QDateTime now = QDateTime::currentDateTime();
const QString stamp = now.toString(QStringLiteral("yyyyMMdd-HHmmss-zzz"));
const QString path = QStringLiteral("%1/baduk-%2.sgf").arg(m_recordDir, stamp);
QFile f(path);
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
qWarning("SGF 저장 실패 (열기): %s", path.toUtf8().constData());
return;
}
QTextStream s(&f);
s.setEncoding(QStringConverter::Utf8);
// 메타: 흑/백 PlayerKind 기준 PB/PW
auto playerLabel = [this](Stone stone) -> QString {
switch (playerKind(stone)) {
case PlayerKind::Human:
return QStringLiteral("Human");
case PlayerKind::Heuristic:
return QStringLiteral("LocalAI");
case PlayerKind::Vlm: {
if (stone == Stone::White && m_vlmHostWhiteEdit && m_vlmPortWhiteEdit
&& m_vlmHostEdit && m_vlmPortEdit) {
const QString hostB = m_vlmHostEdit->text().trimmed();
const QString portB = m_vlmPortEdit->text().trimmed();
const QString hostW = m_vlmHostWhiteEdit->text().trimmed();
const QString portW = m_vlmPortWhiteEdit->text().trimmed();
bool okPw = false;
const quint16 pw = portW.toUShort(&okPw);
const bool whiteDistinct = !hostW.isEmpty() && okPw && pw != 0
&& !(hostW == hostB && portW == portB);
if (whiteDistinct)
return QStringLiteral("VLM@%1:%2").arg(hostW, portW);
}
const QString hostB = m_vlmHostEdit ? m_vlmHostEdit->text().trimmed() : QString();
const QString portB = m_vlmPortEdit ? m_vlmPortEdit->text().trimmed() : QString();
if (hostB.isEmpty())
return QStringLiteral("VLM");
return QStringLiteral("VLM@%1:%2").arg(hostB, portB);
}
}
return QStringLiteral("?");
};
// RE 태그 생성
QString reTag;
const ScoreReport rep = m_board.scoreNow();
switch (m_board.status()) {
case GameStatus::BlackWins: {
const double margin = rep.finalBlack - rep.finalWhite;
if (m_board.lastWasPass() == false && m_board.totalMoves() > 0
&& rep.finalBlack == 0.0 && rep.finalWhite == 0.0) {
// 기권 (finalize 건너뜀)
reTag = QStringLiteral("B+R");
} else {
reTag = QStringLiteral("B+%1").arg(margin, 0, 'f', 1);
}
break;
}
case GameStatus::WhiteWins: {
const double margin = rep.finalWhite - rep.finalBlack;
if (rep.finalBlack == 0.0 && rep.finalWhite == 0.0)
reTag = QStringLiteral("W+R");
else
reTag = QStringLiteral("W+%1").arg(margin, 0, 'f', 1);
break;
}
case GameStatus::Draw: reTag = QStringLiteral("Draw"); break;
case GameStatus::Playing: reTag = QStringLiteral("Void"); break;
}
s << "(;FF[4]GM[1]CA[UTF-8]AP[baduk-qt]SZ[" << m_board.size() << "]"
<< "KM[" << m_board.komi() << "]"
<< "PB[" << playerLabel(Stone::Black) << "]"
<< "PW[" << playerLabel(Stone::White) << "]"
<< "DT[" << now.toString(Qt::ISODate) << "]"
<< "RE[" << reTag << "]";
auto coord = [](int r, int c) -> QString {
// SGF 좌표: col → 'a'+col, row → 'a'+row. 19x19 이므로 'a'..'s' 안전.
QChar cc = QChar(static_cast<char>('a' + c));
QChar rr = QChar(static_cast<char>('a' + r));
return QString(cc) + QString(rr);
};
for (const RecordedMove &m : m_moveLog) {
const QString tag = (m.color == Stone::Black) ? QStringLiteral("B") : QStringLiteral("W");
if (m.isPass)
s << ";" << tag << "[]";
else
s << ";" << tag << "[" << coord(m.row, m.col) << "]";
}
s << ")\n";
f.close();
qInfo("기보 저장: %s (%lld 수, RE=%s)",
path.toUtf8().constData(),
static_cast<long long>(m_moveLog.size()),
reTag.toUtf8().constData());
}
bool MainWindow::isReviewing() const
{
return m_navPly < m_moveLog.size();
}
bool MainWindow::canAutoPlay() const
{
return m_matchStarted && !m_playPaused && !isReviewing() && !m_rebuilding;
}
void MainWindow::setMatchStarted(bool started)
{
m_matchStarted = started;
updateStartUi();
}
void MainWindow::updateStartUi()
{
if (!m_startBtn) return;
const bool playing = (m_board.status() == GameStatus::Playing);
m_startBtn->setEnabled(playing && !m_matchStarted);
if (!playing)
m_startBtn->setText(QStringLiteral("시작"));
else if (m_matchStarted)
m_startBtn->setText(QStringLiteral("진행 중"));
else
m_startBtn->setText(QStringLiteral("시작"));
}
void MainWindow::updateNavUi()
{
const int n = m_moveLog.size();
const int ply = qBound(0, m_navPly, n);
if (m_navPosLabel) {
QString extra;
if (m_playPaused)
extra = QStringLiteral(" · 중지");
else if (isReviewing())
extra = QStringLiteral(" · 복기");
m_navPosLabel->setText(QStringLiteral("%1 / %2%3").arg(ply).arg(n).arg(extra));
}
if (m_navFirstBtn) m_navFirstBtn->setEnabled(ply > 0);
if (m_navPrevBtn) m_navPrevBtn->setEnabled(ply > 0);
if (m_navNextBtn) m_navNextBtn->setEnabled(ply < n);
if (m_navLastBtn) m_navLastBtn->setEnabled(ply < n);
if (m_navContinueBtn) m_navContinueBtn->setEnabled(isReviewing() || m_playPaused);
if (m_navPauseBtn) {
m_navPauseBtn->setText(m_playPaused ? QStringLiteral("재개") : QStringLiteral("중지"));
}
}
void MainWindow::rebuildBoardToPly(int ply)
{
ply = qBound(0, ply, m_moveLog.size());
m_rebuilding = true;
if (m_vlmInFlight && m_vlmWorker) {
QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
finishVlmCleanup();
}
const GameMode mode = m_board.mode();
const Stone humanSide = m_board.humanSide();
const Stone vlmSide = m_board.vlmSide();
const double komi = m_board.komi();
m_board.reset();
m_board.setMode(mode);
m_board.setHumanSide(humanSide);
m_board.setVlmSide(vlmSide);
m_board.setKomi(komi);
int lastR = -1, lastC = -1;
for (int i = 0; i < ply; ++i) {
const RecordedMove &mv = m_moveLog[i];
if (mv.isPass)
continue;
MoveRejectReason rr = MoveRejectReason::None;
if (!m_board.playAt(mv.row, mv.col, &rr)) {
qWarning("rebuildBoardToPly: 불법수 스킵 ply=%d (%d,%d) rr=%d",
i, mv.row, mv.col, int(rr));
continue;
}
lastR = mv.row;
lastC = mv.col;
}
m_navPly = ply;
m_rebuilding = false;
if (m_boardWidget)
m_boardWidget->setLastMove(lastR, lastC,
m_board.status() == GameStatus::Playing && lastR >= 0);
pushBoardToView();
updateStatus();
updateNavUi();
}
void MainWindow::onNavFirstClicked() { rebuildBoardToPly(0); }
void MainWindow::onNavPrevClicked() { rebuildBoardToPly(m_navPly - 1); }
void MainWindow::onNavNextClicked() { rebuildBoardToPly(m_navPly + 1); }
void MainWindow::onNavLastClicked() { rebuildBoardToPly(m_moveLog.size()); }
void MainWindow::onNavPauseClicked()
{
m_playPaused = !m_playPaused;
if (m_playPaused && m_vlmInFlight && m_vlmWorker) {
QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
finishVlmCleanup();
}
updateNavUi();
updateStatus();
if (!m_playPaused)
maybeScheduleNext();
}
void MainWindow::onNavContinueClicked()
{
if (m_navPly < m_moveLog.size()) {
m_moveLog.resize(m_navPly);
if (m_uiCurrentLines.size() > m_navPly)
m_uiCurrentLines.resize(m_navPly);
if (m_moveList && m_viewingCurrentGame) {
while (m_moveList->count() > m_navPly)
delete m_moveList->takeItem(m_moveList->count() - 1);
}
}
m_playPaused = false;
setMatchStarted(true);
rebuildBoardToPly(m_navPly);
uiAppendCurrentLine(QStringLiteral("— 여기서 이어두기 (수 %1)").arg(m_navPly));
maybeScheduleNext();
}
void MainWindow::onModeChanged(int index)
{
Q_UNUSED(index);
// 레거시 슬롯 — 흑/백 셀렉터 변경으로 대체.
applyPlayerSelectionChange();
}
void MainWindow::onBlackPlayerChanged(int index)
{
Q_UNUSED(index);
applyPlayerSelectionChange();
}
void MainWindow::onWhitePlayerChanged(int index)
{
Q_UNUSED(index);
applyPlayerSelectionChange();
}
void MainWindow::onHumanSideChanged(int index)
{
Q_UNUSED(index);
// 사람 돌 콤보는 UI에서 숨김. 남아 있어도 판을 리셋하지 않음.
if (m_humanSideCombo)
m_board.setHumanSide(static_cast<Stone>(m_humanSideCombo->currentData().toInt()));
applyPlayerSelectionChange();
}
void MainWindow::onKomiChanged(double v)
{
m_board.setKomi(v);
updateStatus();
}
void MainWindow::onIntersectionClicked(int row, int col)
{
// 계가 모드 중 클릭은 그 자리의 그룹을 사석/생석 토글.
if (m_scoringMode) {
onScoringClick(row, col);
return;
}
if (m_board.status() != GameStatus::Playing)
return;
if (!m_matchStarted) {
QMessageBox::information(
this, QStringLiteral("시작 전"),
QStringLiteral("아직 대국이 시작되지 않았습니다.\n[시작] 버튼을 눌러 주세요."));
return;
}
if (isReviewing()) {
QMessageBox::information(
this, QStringLiteral("복기 중"),
QStringLiteral("이전/다음으로 보는 중입니다.\n"
"이 국면에서 두려면 [여기서 이어두기] 를 누르세요."));
return;
}
if (!isHumanTurn())
return;
const Stone mover = m_board.currentTurn();
MoveRejectReason rr = MoveRejectReason::None;
if (!m_board.playAt(row, col, &rr)) {
const QString msg = rejectReasonText(rr);
if (!msg.isEmpty()) {
QMessageBox::information(this, QStringLiteral("둘 수 없는 자리"), msg);
}
return;
}
recordMove(mover, row, col, false);
if (m_boardWidget)
m_boardWidget->setLastMove(row, col, m_board.status() == GameStatus::Playing);
pushBoardToView();
updateStatus();
maybeScheduleNext();
}
void MainWindow::onPassClicked()
{
if (m_board.status() != GameStatus::Playing)
return;
if (!m_matchStarted) {
QMessageBox::information(
this, QStringLiteral("시작 전"),
QStringLiteral("아직 대국이 시작되지 않았습니다.\n[시작] 버튼을 눌러 주세요."));
return;
}
if (isReviewing()) {
QMessageBox::information(
this, QStringLiteral("복기 중"),
QStringLiteral("복기 중에는 패스할 수 없습니다.\n[여기서 이어두기] 후 진행하세요."));
return;
}
if (!isHumanTurn())
return;
const Stone passer = m_board.currentTurn();
// 패스 버튼 → 본인에게 3선택 다이얼로그.
switch (askPassChoice(passer)) {
case PassChoice::Resign:
uiAppendCurrentLine(QStringLiteral("패스→패배 선언: %1 기권")
.arg((passer == Stone::Black) ? QStringLiteral("흑") : QStringLiteral("백")));
uiFinalizeCurrentGame(QStringLiteral("%1 불계승")
.arg((passer == Stone::Black) ? QStringLiteral("백") : QStringLiteral("흑")));
m_board.resign();
if (m_boardWidget)
m_boardWidget->setLastMove(-1, -1, false);
pushBoardToView();
updateStatus();
break;
case PassChoice::RequestScoring:
enterScoringMode();
break;
case PassChoice::Continue:
// 아무 일 안 일어남.
break;
}
}
MainWindow::PassChoice MainWindow::askPassChoice(Stone passer)
{
const QString passerName = (passer == Stone::Black) ? tr("흑") : tr("백");
// 임시 차단 중이면 계가로 넘어가지 않는다.
if (scoringTemporarilyBlocked())
return PassChoice::Continue;
// "팝업 항상 예" 가 켜져 있으면, 패스 버튼의 기본 동작(계가 진행)으로 즉시 진입.
if (alwaysYesPopups())
return PassChoice::RequestScoring;
QMessageBox box(this);
box.setWindowTitle(tr("패스 / 종국"));
box.setText(tr("%1 차례에서 패스 버튼을 눌렀습니다.").arg(passerName));
box.setInformativeText(
tr("• 계가 진행: 사석은 X, 영토는 O 로 표시한 뒤 점수를 계산합니다 "
"(클릭으로 사석을 토글해 정정할 수 있습니다)\n"
"• 패배 선언: %1 의 패배(기권)로 종료\n"
"• 계속 두기: 패스를 취소하고 같은 차례에서 다음 수를 둡니다").arg(passerName));
QPushButton *scoreBtn =
box.addButton(tr("계가 진행"), QMessageBox::AcceptRole);
QPushButton *resignBtn =
box.addButton(tr("패배 선언 (%1 기권)").arg(passerName),
QMessageBox::DestructiveRole);
QPushButton *cancelBtn =
box.addButton(tr("계속 두기"), QMessageBox::RejectRole);
box.setDefaultButton(scoreBtn);
box.exec();
if (box.clickedButton() == scoreBtn) return PassChoice::RequestScoring;
if (box.clickedButton() == resignBtn) return PassChoice::Resign;
return PassChoice::Continue;
}
// ============================== 계가 모드 ==============================
void MainWindow::enterScoringMode()
{
if (scoringTemporarilyBlocked()) {
QMessageBox::information(this, tr("계가"), tr("현재 계가 기능이 임시로 차단되어 있습니다."));
return;
}
if (m_scoringMode) return;
m_scoringMode = true;
m_deadSet.clear();
m_territoryBlk.clear();
m_territoryWht.clear();
m_dameSet.clear();
// 1) GameJudge 의 alive 판정으로 사석 자동 추정.
const GameJudge::Analysis a = GameJudge::analyze(m_board);
const int N = m_board.size();
for (int idx = 0; idx < N * N; ++idx) {
const int gid = a.stoneToGroup[idx];
if (gid < 0) continue;
if (!a.groups[gid].alive)
m_deadSet.insert(idx);
}
// 2) 사석 set 기반 영토/공배 분류.
recomputeScoringMarks();
// 3) 보드 위에 X/O 오버레이 표시.
if (m_boardWidget) {
m_boardWidget->setLastMove(-1, -1, false);
m_boardWidget->setScoringOverlay(m_deadSet, m_territoryBlk,
m_territoryWht, m_dameSet);
}
// 4) 다이얼로그로 확정/취소.
showScoringConfirmDialog();
}
void MainWindow::exitScoringMode()
{
m_scoringMode = false;
m_deadSet.clear();
m_territoryBlk.clear();
m_territoryWht.clear();
m_dameSet.clear();
if (m_boardWidget) m_boardWidget->clearScoringOverlay();
updateStatus();
}
void MainWindow::recomputeScoringMarks()
{
m_territoryBlk.clear();
m_territoryWht.clear();
m_dameSet.clear();
const int N = m_board.size();
// 사석으로 표시된 돌들은 비어 있는 것처럼 취급해 영역을 BFS.
QVector<bool> isStone(N * N, false);
QVector<bool> isDeadStone(N * N, false);
QVector<Stone> color(N * N, Stone::Empty);
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
const int idx = r * N + c;
const Stone v = m_board.cellAt(r, c);
if (v == Stone::Empty) continue;
isStone[idx] = true;
color[idx] = v;
if (m_deadSet.contains(idx)) isDeadStone[idx] = true;
}
}
// BFS: 빈 점 + 사석 자리를 한 영역으로 통합. alive 인 살아 있는 돌만 border 색으로 본다.
QVector<bool> visited(N * N, false);
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
const int sidx = r * N + c;
if (visited[sidx]) continue;
const bool startEmpty = !isStone[sidx];
const bool startDead = isDeadStone[sidx];
if (!startEmpty && !startDead) { visited[sidx] = true; continue; }
// BFS 영역 모음.
QVector<int> region;
QVector<int> emptyCells;
QVector<int> q;
q.append(sidx);
visited[sidx] = true;
bool seenBlack = false;
bool seenWhite = false;
while (!q.isEmpty()) {
const int idx = q.takeLast();
region.append(idx);
if (!isStone[idx]) emptyCells.append(idx);
const int rr = idx / N;
const int cc = idx % N;
for (int d = 0; d < 4; ++d) {
const int nr = rr + DR[d];
const int nc = cc + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
const int nidx = nr * N + nc;
if (visited[nidx]) continue;
if (!isStone[nidx]) { visited[nidx] = true; q.append(nidx); }
else if (isDeadStone[nidx]) { visited[nidx] = true; q.append(nidx); }
else {
// 살아 있는 (사석으로 표시되지 않은) 돌 → border 색.
if (color[nidx] == Stone::Black) seenBlack = true;
else if (color[nidx] == Stone::White) seenWhite = true;
}
}
}
// 분류: 빈 점만 territory/dame 으로 표시 (사석 자리는 X 로 이미 표시됨).
if (seenBlack && !seenWhite) {
for (int e : emptyCells) m_territoryBlk.insert(e);
} else if (seenWhite && !seenBlack) {
for (int e : emptyCells) m_territoryWht.insert(e);
} else {
for (int e : emptyCells) m_dameSet.insert(e);
}
}
}
if (m_boardWidget)
m_boardWidget->setScoringOverlay(m_deadSet, m_territoryBlk,
m_territoryWht, m_dameSet);
}
ScoreReport MainWindow::computeScoreFromMarks() const
{
ScoreReport rep;
rep.komi = m_board.komi();
const int N = m_board.size();
for (int r = 0; r < N; ++r) {
for (int c = 0; c < N; ++c) {
const int idx = r * N + c;
const Stone v = m_board.cellAt(r, c);
if (v == Stone::Empty) continue;
if (m_deadSet.contains(idx)) {
// 사석 → 상대 영역 점수에 가산
if (v == Stone::Black) ++rep.whiteTerritory;
else ++rep.blackTerritory;
} else {
if (v == Stone::Black) ++rep.blackStones;
else ++rep.whiteStones;
}
}
}
rep.blackTerritory += m_territoryBlk.size();
rep.whiteTerritory += m_territoryWht.size();
rep.dame = m_dameSet.size();
rep.finalBlack = rep.blackStones + rep.blackTerritory;
rep.finalWhite = rep.whiteStones + rep.whiteTerritory + rep.komi;
if (rep.finalBlack > rep.finalWhite) rep.winner = Stone::Black;
else if (rep.finalWhite > rep.finalBlack) rep.winner = Stone::White;
else rep.winner = Stone::Empty;
return rep;
}
void MainWindow::onScoringClick(int row, int col)
{
if (!m_scoringMode) return;
const int N = m_board.size();
if (row < 0 || row >= N || col < 0 || col >= N) return;
const Stone v = m_board.cellAt(row, col);
if (v == Stone::Empty) return; // 빈 점은 토글 의미 없음 (자동 영토 계산).
// 클릭한 돌이 속한 그룹 전체를 사석/생석 토글.
QVector<int> stack;
QVector<bool> seen(N * N, false);
QVector<int> grp;
stack.append(row * N + col);
seen[row * N + col] = true;
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};
while (!stack.isEmpty()) {
const int idx = stack.takeLast();
grp.append(idx);
const int rr = idx / N, cc = idx % N;
for (int d = 0; d < 4; ++d) {
const int nr = rr + DR[d], nc = cc + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
const int nidx = nr * N + nc;
if (seen[nidx]) continue;
if (m_board.cellAt(nr, nc) == v) {
seen[nidx] = true;
stack.append(nidx);
}
}
}
const bool wasDead = m_deadSet.contains(grp.first());
if (wasDead) {
for (int g : grp) m_deadSet.remove(g);
} else {
for (int g : grp) m_deadSet.insert(g);
}
recomputeScoringMarks();
updateStatus();
}
void MainWindow::showScoringConfirmDialog()
{
// "팝업 항상 예" 가 켜져 있으면 자동으로 점수 확정까지 진행.
// (계가 모드 진입 자체는 이미 UI 상에서 의도된 동작이므로, 여기서는 확정만 자동화)
if (alwaysYesPopups()) {
const ScoreReport rep = computeScoreFromMarks();
m_board.acceptScoring(rep);
if (m_moveList && m_viewingCurrentGame) {
const QString win = (rep.winner == Stone::Black)
? QStringLiteral("흑")
: (rep.winner == Stone::White)
? QStringLiteral("백")
: QStringLiteral("무승부");
uiFinalizeCurrentGame(QStringLiteral("계가 %1").arg(win));
}
exitScoringMode();
return;
}
QMessageBox *box = new QMessageBox(this);
box->setAttribute(Qt::WA_DeleteOnClose);
box->setWindowTitle(tr("계가"));
box->setText(tr("사석은 X, 영토는 O 로 표시되었습니다."));
box->setInformativeText(
tr("돌을 클릭하면 그 그룹의 사석/생석을 토글할 수 있습니다.\n"
"조정이 끝나면 [점수 확정] 을 누르세요.\n"
"[취소] 를 누르면 계가 모드를 종료하고 대국을 이어 갑니다."));
QPushButton *confirmBtn =
box->addButton(tr("점수 확정"), QMessageBox::AcceptRole);
QPushButton *cancelBtn =
box->addButton(tr("취소 (계속 두기)"), QMessageBox::RejectRole);
box->setDefaultButton(confirmBtn);
// 모달이지만 보드 클릭은 받아야 하므로 비모달로 띄움.
box->setModal(false);
connect(box, &QMessageBox::buttonClicked, this,
[this, confirmBtn, cancelBtn](QAbstractButton *btn) {
if (btn == confirmBtn) {
const ScoreReport rep = computeScoreFromMarks();
m_board.acceptScoring(rep);
const QString win = (rep.winner == Stone::Black)
? QStringLiteral("흑")
: (rep.winner == Stone::White)
? QStringLiteral("백")
: QStringLiteral("무승부");
uiFinalizeCurrentGame(QStringLiteral("계가 %1").arg(win));
exitScoringMode();
} else if (btn == cancelBtn) {
uiAppendCurrentLine(QStringLiteral("계가 취소: 대국 계속"));
exitScoringMode();
}
});
box->show();
// 어림 점수 즉시 표시.
updateStatus();
}
void MainWindow::onResignClicked()
{
if (m_board.status() != GameStatus::Playing)
return;
if (!m_matchStarted)
return;
if (isReviewing())
return;
if (!isHumanTurn())
return;
const QString who = (m_board.currentTurn() == Stone::Black) ? QStringLiteral("흑") : QStringLiteral("백");
uiAppendCurrentLine(QStringLiteral("기권: %1").arg(who));
uiFinalizeCurrentGame(QStringLiteral("%1 불계승").arg((who == QStringLiteral("흑")) ? QStringLiteral("백") : QStringLiteral("흑")));
m_board.resign();
if (m_boardWidget)
m_boardWidget->setLastMove(-1, -1, false);
pushBoardToView();
updateStatus();
}
void MainWindow::doOneAiMove()
{
if (m_board.status() != GameStatus::Playing)
return;
if (m_scoringMode)
return;
if (!isLocalAiTurn())
return;
const Stone aiSide = m_board.currentTurn();
auto sug = AiPlayer::suggestMove(m_board, aiSide);
// 종반 자충수 방지: AiPlayer 가 "둘 가치 있는 자리 없음" 신호를 보내면
// 더 두지 않고 자동 계가 모드로 진입한다 (자충수로 역전 당하는 문제 방지).
if (sug.noGoodMove) {
if (!scoringTemporarilyBlocked()) {
qInfo("AI: 둘 가치 있는 자리 없음 (bestScore=%d) → 자동 계가", sug.bestScore);
enterScoringMode();
return;
}
handleAutoActorPassIntent(aiSide);
return;
}
if (sug.pass || sug.row < 0 || sug.col < 0) {
handleAutoActorPassIntent(aiSide);
return;
}
MoveRejectReason rr;
if (!m_board.playAt(sug.row, sug.col, &rr)) {
handleAutoActorPassIntent(aiSide);
return;
}
recordMove(aiSide, sug.row, sug.col, false);
if (m_boardWidget)
m_boardWidget->setLastMove(sug.row, sug.col,
m_board.status() == GameStatus::Playing);
pushBoardToView();
updateStatus();
maybeScheduleNext();
}
void MainWindow::handleAutoActorPassIntent(Stone aiSide)
{
// 자동 actor 가 합법수를 못 찾았거나 패스를 내려고 함.
// 기존에는 점수 어림으로 즉시 기권(>=30점 열세)했는데, 한 싸움 결과로 점수가 급변하면서
// "여지 있는데도 포기"가 자주 발생했다. 따라서 '연속으로 크게 불리'하고 '충분히 후반'일 때만
// 제한적으로 기권을 허용한다.
const GameJudge::Estimate est = GameJudge::estimateScore(m_board);
const double mine = (aiSide == Stone::Black) ? est.finalBlack : est.finalWhite;
const double opp = (aiSide == Stone::Black) ? est.finalWhite : est.finalBlack;
const double margin = mine - opp;
auto &streak = (aiSide == Stone::Black) ? m_autoBadMarginStreakBlack
: m_autoBadMarginStreakWhite;
const bool lateEnough = m_board.totalMoves() >= 140;
const bool veryBad = margin <= -55.0;
if (lateEnough && veryBad)
++streak;
else
streak = 0;
// 계가 차단 중에는 자동 기권도 막아 둔다 (악용/오판 방지).
if (!scoringTemporarilyBlocked() && streak >= 5) {
qInfo("자동 actor 기권 (연속 크게 불리: %.1f 점, streak=%d)", -margin, streak);
uiAppendCurrentLine(QStringLiteral("자동 actor 기권: %1")
.arg((aiSide == Stone::Black) ? QStringLiteral("흑") : QStringLiteral("백")));
uiFinalizeCurrentGame(QStringLiteral("%1 불계승")
.arg((aiSide == Stone::Black) ? QStringLiteral("백") : QStringLiteral("흑")));
m_board.resign();
if (m_boardWidget)
m_boardWidget->setLastMove(-1, -1, false);
pushBoardToView();
updateStatus();
return;
}
// 합법수: 1·2선·자기집 메우기 제외. (좌상단부터 스캔하면 (0,0)/(1,1) 돌던지기가 됨)
const int N = m_board.size();
int chosenR = -1, chosenC = -1;
int fallbackR = -1, fallbackC = -1;
for (int r = 0; r < N && chosenR < 0; ++r) {
for (int c = 0; c < N; ++c) {
MoveRejectReason rr;
if (!m_board.isLegalMove(r, c, aiSide, &rr))
continue;
const int edgeLine = qMin(qMin(r, c), qMin(N - 1 - r, N - 1 - c)) + 1;
if (edgeLine <= 2)
continue;
if (fallbackR < 0) { fallbackR = r; fallbackC = c; }
int ownAround = 0;
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};
for (int d = 0; d < 4; ++d) {
const int nr = r + DR[d], nc = c + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
if (m_board.cellAt(nr, nc) == aiSide) ++ownAround;
}
if (ownAround < 3) { chosenR = r; chosenC = c; break; }
}
}
if (chosenR < 0) { chosenR = fallbackR; chosenC = fallbackC; }
if (chosenR < 0) {
if (!scoringTemporarilyBlocked()) {
qWarning("자동 actor: 둘 곳 없음 → 계가");
enterScoringMode();
return;
}
// 합법수가 정말 한 자리도 없음 → 기권으로 처리.
qWarning("자동 actor: 합법수 0 → 기권");
uiAppendCurrentLine(QStringLiteral("자동 actor: 합법수 0 → 기권 (%1)")
.arg((aiSide == Stone::Black) ? QStringLiteral("흑") : QStringLiteral("백")));
uiFinalizeCurrentGame(QStringLiteral("%1 불계승")
.arg((aiSide == Stone::Black) ? QStringLiteral("백") : QStringLiteral("흑")));
m_board.resign();
pushBoardToView();
updateStatus();
return;
}
MoveRejectReason rr;
if (!m_board.playAt(chosenR, chosenC, &rr)) {
qWarning("강제 한 수 실패 (%d,%d) → 기권", chosenR, chosenC);
uiAppendCurrentLine(QStringLiteral("자동 actor: 강제 한 수 실패 → 기권 (%1)")
.arg((aiSide == Stone::Black) ? QStringLiteral("흑") : QStringLiteral("백")));
uiFinalizeCurrentGame(QStringLiteral("%1 불계승")
.arg((aiSide == Stone::Black) ? QStringLiteral("백") : QStringLiteral("흑")));
m_board.resign();
pushBoardToView();
updateStatus();
return;
}
recordMove(aiSide, chosenR, chosenC, false);
if (m_boardWidget)
m_boardWidget->setLastMove(chosenR, chosenC,
m_board.status() == GameStatus::Playing);
pushBoardToView();
updateStatus();
maybeScheduleNext();
}
// ===================== VLM =====================
namespace {
// 라벨 색상/문구 헬퍼.
void setProbeLabelChecking(QLabel *lbl, const QString &endpoint)
{
if (!lbl) return;
lbl->setText(QStringLiteral("● 확인 중… (%1)").arg(endpoint));
lbl->setStyleSheet(QStringLiteral("color:#a76; font-weight:600;"));
}
void setProbeLabelOk(QLabel *lbl, const QString &endpoint, int latencyMs)
{
if (!lbl) return;
lbl->setText(QStringLiteral("● 응답 OK %1 (%2 ms)").arg(endpoint).arg(latencyMs));
lbl->setStyleSheet(QStringLiteral("color:#2a8c2a; font-weight:600;"));
}
void setProbeLabelFail(QLabel *lbl, const QString &endpoint, int latencyMs, const QString &err)
{
if (!lbl) return;
const QString detail = err.isEmpty() ? QStringLiteral("실패") : err;
lbl->setText(QStringLiteral("● 실패 %1: %2 (%3 ms)").arg(endpoint, detail).arg(latencyMs));
lbl->setStyleSheet(QStringLiteral("color:#c43; font-weight:600;"));
}
} // namespace
void MainWindow::onPingServer1Clicked() { triggerProbe(1, /*manual=*/true); }
void MainWindow::onPingServer2Clicked() { triggerProbe(2, /*manual=*/true); }
void MainWindow::onPingBoardEchoClicked()
{
if (!m_boardEchoWorker || !m_boardEchoHostEdit || !m_boardEchoPortEdit)
return;
const QString host = m_boardEchoHostEdit->text().trimmed();
bool okPort = false;
const quint16 port = m_boardEchoPortEdit->text().trimmed().toUShort(&okPort);
if (host.isEmpty() || !okPort || port == 0) {
if (m_boardEchoOutput) {
m_boardEchoOutput->appendPlainText(
QStringLiteral("[%1] 보드 시각화: host/port 를 확인하세요.\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs)));
}
return;
}
const int timeoutMs = m_vlmTimeoutSpin ? m_vlmTimeoutSpin->value() * 1000 : 60000;
QMetaObject::invokeMethod(m_boardEchoWorker.data(), "doPing", Qt::QueuedConnection,
Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, timeoutMs));
}
void MainWindow::onBoardEchoRunClicked()
{
if (!m_boardEchoWorker || m_boardEchoBusy)
return;
if (!m_boardEchoHostEdit || !m_boardEchoPortEdit)
return;
const QString host = m_boardEchoHostEdit->text().trimmed();
bool okPort = false;
const quint16 port = m_boardEchoPortEdit->text().trimmed().toUShort(&okPort);
if (host.isEmpty() || !okPort || port == 0) {
if (m_boardEchoOutput) {
m_boardEchoOutput->appendPlainText(
QStringLiteral("[%1] 보드 인식: host/port 를 확인하세요.\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs)));
}
return;
}
const int N = m_board.size();
if (N != 19) {
if (m_boardEchoOutput) {
m_boardEchoOutput->appendPlainText(
QStringLiteral("[%1] 보드 인식: 19×19 만 지원합니다.\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs)));
}
return;
}
const QByteArray packed = m_board.packed();
const QImage img = m_boardWidget ? m_boardWidget->renderToImage(720) : QImage();
if (img.isNull()) {
if (m_boardEchoOutput) {
m_boardEchoOutput->appendPlainText(
QStringLiteral("[%1] 보드 인식: 이미지를 만들 수 없습니다.\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs)));
}
return;
}
m_boardEchoBusy = true;
if (m_boardEchoRunBtn)
m_boardEchoRunBtn->setEnabled(false);
if (m_boardEchoOutput) {
m_boardEchoOutput->appendPlainText(
QStringLiteral("[%1] 보드 인식 요청 전송…\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs)));
}
const int timeoutMs = m_vlmTimeoutSpin ? m_vlmTimeoutSpin->value() * 1000 : 60000;
QMetaObject::invokeMethod(
m_boardEchoWorker.data(), "requestBoardEcho", Qt::QueuedConnection, Q_ARG(QImage, img),
Q_ARG(QByteArray, packed), Q_ARG(int, N), Q_ARG(QString, host), Q_ARG(quint16, port),
Q_ARG(int, timeoutMs));
}
void MainWindow::onBoardEchoReady(bool success, QString textOrError)
{
m_boardEchoBusy = false;
if (m_boardEchoRunBtn)
m_boardEchoRunBtn->setEnabled(true);
if (!m_boardEchoOutput)
return;
if (success) {
m_boardEchoOutput->appendPlainText(QStringLiteral("----- 응답 -----\n"));
m_boardEchoOutput->appendPlainText(textOrError);
m_boardEchoOutput->appendPlainText(QStringLiteral("\n----- 끝 -----\n"));
} else {
m_boardEchoOutput->appendPlainText(
QStringLiteral("[%1] 오류: %2\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs), textOrError));
}
}
void MainWindow::cancelAutoReconnect(int idx)
{
if (idx == 1) {
if (m_retryTimer1) m_retryTimer1->stop();
m_retryAttempts1 = 0;
m_retryDelayMs1 = 0;
m_retryEndpointStr1.clear();
} else {
if (m_retryTimer2) m_retryTimer2->stop();
m_retryAttempts2 = 0;
m_retryDelayMs2 = 0;
m_retryEndpointStr2.clear();
}
}
void MainWindow::triggerProbe(int idx, bool manual)
{
if (!m_vlmWorker) return;
QLineEdit *hostE = (idx == 2) ? m_vlmHostWhiteEdit : m_vlmHostEdit;
QLineEdit *portE = (idx == 2) ? m_vlmPortWhiteEdit : m_vlmPortEdit;
QPushButton *btn = (idx == 2) ? m_vlmConnectBtn2 : m_vlmConnectBtn;
QLabel *lbl = (idx == 2) ? m_vlmConnLabel2 : m_vlmConnLabel;
const QString tag = (idx == 2) ? QStringLiteral("2") : QStringLiteral("1");
const QString srvLabel = (idx == 2) ? QStringLiteral("서버2") : QStringLiteral("서버1");
const QString host = hostE ? hostE->text().trimmed() : QString();
bool okPort = false;
const quint16 port = portE ? portE->text().trimmed().toUShort(&okPort) : 0;
// 사용자가 직접 누른 경우 카운터/대기중 재시도 모두 리셋.
if (manual)
cancelAutoReconnect(idx);
if (host.isEmpty() || !okPort || port == 0) {
setProbeLabelFail(lbl, srvLabel, 0,
QStringLiteral("host/port 비어 있음/형식 오류"));
if (btn) btn->setEnabled(true);
return;
}
const int timeoutMs = m_vlmTimeoutSpin ? m_vlmTimeoutSpin->value() * 1000 : 60000;
setProbeLabelChecking(lbl, QStringLiteral("%1:%2").arg(host).arg(port));
if (btn) btn->setEnabled(false);
QMetaObject::invokeMethod(
m_vlmWorker.data(), "doPingProbe", Qt::QueuedConnection,
Q_ARG(QString, tag),
Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, timeoutMs));
}
void MainWindow::onRetryTick(int idx)
{
QLabel *lbl = (idx == 2) ? m_vlmConnLabel2 : m_vlmConnLabel;
QTimer *tmr = (idx == 2) ? m_retryTimer2 : m_retryTimer1;
QElapsedTimer &clk= (idx == 2) ? m_retryClock2 : m_retryClock1;
const int delay = (idx == 2) ? m_retryDelayMs2 : m_retryDelayMs1;
const int att = (idx == 2) ? m_retryAttempts2 : m_retryAttempts1;
const QString &ep = (idx == 2) ? m_retryEndpointStr2 : m_retryEndpointStr1;
if (!tmr || delay <= 0) return;
const qint64 elapsed = clk.elapsed();
if (elapsed >= delay) {
// 대기 끝 — 다시 probe 시도. (수동 아님 → 카운터 유지)
tmr->stop();
triggerProbe(idx, /*manual=*/false);
return;
}
if (lbl) {
const double remain = (delay - elapsed) / 1000.0;
lbl->setText(QStringLiteral("● 재접속 대기 #%1 — %2s 후 재시도 (%3)")
.arg(att).arg(remain, 0, 'f', 1).arg(ep));
lbl->setStyleSheet(QStringLiteral("color:#a76;"));
}
}
void MainWindow::onCancelVlmClicked()
{
if (m_vlmWorker)
QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
}
void MainWindow::startVlmRequest()
{
if (!m_vlmWorker || m_vlmInFlight) return;
if (m_board.status() != GameStatus::Playing) return;
// 차례별 endpoint 선택.
// - 백 차례이고 백이 VLM 이며 백 측 host/port 가 채워져 있으면 그쪽으로.
// - 그 외엔 기본 host/port (= 서버1).
QString host = m_vlmHostEdit->text().trimmed();
bool okPort = false;
quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
if (m_board.currentTurn() == Stone::White
&& whitePlayer() == PlayerKind::Vlm
&& m_vlmHostWhiteEdit && m_vlmPortWhiteEdit) {
// 서버2는 host·port 가 모두 유효하고, 서버1 과 다를 때만 사용.
const QString hostW = m_vlmHostWhiteEdit->text().trimmed();
bool okPortW = false;
const quint16 portW = m_vlmPortWhiteEdit->text().trimmed().toUShort(&okPortW);
const bool whiteDistinct = !hostW.isEmpty() && okPortW && portW != 0
&& !(hostW == host && okPort && portW == port);
if (whiteDistinct) {
host = hostW;
port = portW;
okPort = true;
}
}
const int timeoutMs = m_vlmTimeoutSpin->value() * 1000;
if (!okPort || port == 0 || host.isEmpty()) {
playLocalFallbackForVlm();
return;
}
const int N = m_board.size();
const QByteArray packed = m_board.packed();
const QImage img = m_boardWidget ? m_boardWidget->renderToImage(720) : QImage();
// 항상 "지금 두어야 할 색" 을 서버에 알려준다. VlmVsVlm 에선 매수마다 색이 바뀌므로
// m_board.vlmSide() 로는 흑 호출이 영영 안 됨 (서버 로그가 모두 side=W 가 되는 원인).
const int aiSide = (m_board.currentTurn() == Stone::Black) ? 1 : 2;
// 이미 돌이 놓인 거부 좌표는 제거.
if (!m_vlmRejectCoords.isEmpty()) {
QByteArray pruned;
for (int i = 0; i + 1 < m_vlmRejectCoords.size(); i += 2) {
const int rr = int(static_cast<unsigned char>(m_vlmRejectCoords.at(i)));
const int cc = int(static_cast<unsigned char>(m_vlmRejectCoords.at(i + 1)));
if (rr < 0 || rr >= N || cc < 0 || cc >= N) continue;
if (m_board.cellAt(rr, cc) != Stone::Empty) continue;
pruned.append(char(static_cast<unsigned char>(rr)));
pruned.append(char(static_cast<unsigned char>(cc)));
}
m_vlmRejectCoords = pruned;
}
m_vlmInFlight = true;
++m_vlmRequestSession;
m_vlmCancelBtn->setEnabled(true);
m_vlmProgressClock.restart();
m_vlmProgressTimer->start();
QMetaObject::invokeMethod(
m_vlmWorker.data(), "requestMove", Qt::QueuedConnection,
Q_ARG(QImage, img), Q_ARG(QByteArray, packed), Q_ARG(int, N), Q_ARG(int, aiSide),
Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, timeoutMs),
Q_ARG(quint32, m_vlmGameEpoch), Q_ARG(QByteArray, m_vlmRejectCoords));
}
void MainWindow::finishVlmCleanup()
{
m_vlmInFlight = false;
if (m_vlmCancelBtn) m_vlmCancelBtn->setEnabled(false);
if (m_vlmProgressTimer) m_vlmProgressTimer->stop();
}
void MainWindow::noteVlmRejectedMove(int row, int col)
{
if (row < 0 || col < 0) return;
const int N = m_board.size();
if (row >= N || col >= N) return;
for (int i = 0; i + 1 < m_vlmRejectCoords.size(); i += 2) {
if (int(static_cast<unsigned char>(m_vlmRejectCoords.at(i))) == row
&& int(static_cast<unsigned char>(m_vlmRejectCoords.at(i + 1))) == col)
return;
}
if (m_vlmRejectCoords.size() >= 32) // 16 pairs
m_vlmRejectCoords.remove(0, 2);
m_vlmRejectCoords.append(char(static_cast<unsigned char>(row)));
m_vlmRejectCoords.append(char(static_cast<unsigned char>(col)));
}
bool MainWindow::retryVlmInsteadOfLocal(int row, int col, const QString &reason)
{
if (row >= 0 && col >= 0)
noteVlmRejectedMove(row, col);
++m_vlmTurnRetryCount;
if (reason == QStringLiteral("atari-miss"))
++m_vlmAtariMissCount;
else
m_vlmAtariMissCount = 0;
qWarning("VLM 거부 (%s) at (%d,%d) — 로컬 대체 없이 재요청 #%d (forbid=%d atariMiss=%d)",
reason.toUtf8().constData(), row, col, m_vlmTurnRetryCount,
int(m_vlmRejectCoords.size() / 2), m_vlmAtariMissCount);
if (m_vlmTurnRetryCount >= kVlmTurnRetrySoftCap) {
qWarning("VLM 재요청 %d회 초과 — 계속 재시도(로컬 대체 안 함)", m_vlmTurnRetryCount);
}
const quint64 session = m_sessionId;
const quint32 epoch = m_vlmGameEpoch;
QTimer::singleShot(50, this, [this, session, epoch]() {
if (session != m_sessionId || epoch != m_vlmGameEpoch) return;
if (!isVlmTurn() || m_board.status() != GameStatus::Playing) return;
if (m_vlmInFlight) return;
startVlmRequest();
});
return true;
}
bool MainWindow::playAtariEmergencyRescue(Stone who)
{
const GameJudge::Analysis judge = GameJudge::analyze(m_board);
const int N = m_board.size();
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};
const Stone opp = GoBoard::opponent(who);
auto tryPlay = [&](int r, int c, const char *tag) -> bool {
MoveRejectReason rr = MoveRejectReason::None;
if (!m_board.isLegalMove(r, c, who, &rr))
return false;
if (!m_board.playAt(r, c, &rr))
return false;
qWarning("아다리 비상응수 %s (%d,%d)", tag, r, c);
m_vlmTurnRetryCount = 0;
m_vlmPassClearTries = 0;
m_vlmAtariMissCount = 0;
m_vlmRejectCoords.clear();
recordMove(who, r, c, false);
if (m_boardWidget)
m_boardWidget->setLastMove(r, c, m_board.status() == GameStatus::Playing);
pushBoardToView();
updateStatus();
maybeScheduleNext();
return true;
};
// 1) 상대 단수 따냄
for (int gid = 0; gid < judge.groups.size(); ++gid) {
const auto &g = judge.groups[gid];
if (g.color != opp || g.liberties != 1)
continue;
for (int i = 0; i < N * N; ++i) {
if (judge.stoneToGroup[i] != gid)
continue;
const int r0 = i / N, c0 = i % N;
for (int d = 0; d < 4; ++d) {
const int nr = r0 + DR[d], nc = c0 + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
if (m_board.cellAt(nr, nc) != Stone::Empty) continue;
if (tryPlay(nr, nc, "capture"))
return true;
}
}
}
// 2) 내 단수 활로 (합법만 — 매우면 자살/자동따임인 활로는 스킵)
for (int gid = 0; gid < judge.groups.size(); ++gid) {
const auto &g = judge.groups[gid];
if (g.color != who || g.liberties != 1)
continue;
for (int i = 0; i < N * N; ++i) {
if (judge.stoneToGroup[i] != gid)
continue;
const int r0 = i / N, c0 = i % N;
for (int d = 0; d < 4; ++d) {
const int nr = r0 + DR[d], nc = c0 + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
if (m_board.cellAt(nr, nc) != Stone::Empty) continue;
if (tryPlay(nr, nc, "rescue"))
return true;
}
}
}
return false;
}
bool MainWindow::hasLegalAtariResponse(Stone who) const
{
/// 내 단수 활로 또는 상대 단수 따냄 중 합법수가 하나라도 있으면 true.
/// 활로를 메우면 자살(자동 따임)뿐이면 false → 아다리 강제 응수 면제.
const GameJudge::Analysis judge = GameJudge::analyze(m_board);
const int N = m_board.size();
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};
const Stone opp = GoBoard::opponent(who);
auto libertyLegal = [&](int gid) -> bool {
for (int i = 0; i < N * N; ++i) {
if (judge.stoneToGroup[i] != gid)
continue;
const int r0 = i / N, c0 = i % N;
for (int d = 0; d < 4; ++d) {
const int nr = r0 + DR[d], nc = c0 + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
if (m_board.cellAt(nr, nc) != Stone::Empty) continue;
MoveRejectReason rr = MoveRejectReason::None;
if (m_board.isLegalMove(nr, nc, who, &rr))
return true;
}
}
return false;
};
for (int gid = 0; gid < judge.groups.size(); ++gid) {
const auto &g = judge.groups[gid];
if (g.liberties != 1)
continue;
if (g.color == opp || g.color == who) {
if (libertyLegal(gid))
return true;
}
}
return false;
}
void MainWindow::playLocalFallbackForVlm()
{
if (m_board.status() != GameStatus::Playing) return;
if (m_scoringMode) return;
const Stone who = m_board.currentTurn();
const auto sug = AiPlayer::suggestMove(m_board, who);
// 종반 자충수 방지: 둘 가치 있는 자리가 없으면 자동 계가.
if (sug.noGoodMove) {
if (!scoringTemporarilyBlocked()) {
qInfo("VLM fallback: 둘 가치 있는 자리 없음 (bestScore=%d) → 자동 계가",
sug.bestScore);
enterScoringMode();
return;
}
handleAutoActorPassIntent(who);
return;
}
if (sug.pass || sug.row < 0 || sug.col < 0) {
handleAutoActorPassIntent(who);
return;
}
MoveRejectReason rr;
if (!m_board.playAt(sug.row, sug.col, &rr)) {
handleAutoActorPassIntent(who);
return;
}
recordMove(who, sug.row, sug.col, false);
if (m_boardWidget)
m_boardWidget->setLastMove(sug.row, sug.col,
m_board.status() == GameStatus::Playing);
pushBoardToView();
updateStatus();
maybeScheduleNext();
}
void MainWindow::onVlmMoveReady(bool success, int row, int col, bool isPass, QString errorMessage)
{
finishVlmCleanup();
if (!isVlmTurn()) {
// 응답이 늦게 도착했거나, 셀렉터로 VLM이 아닌 쪽으로 바꾼 경우 무시.
return;
}
if (m_board.status() != GameStatus::Playing) return;
if (!success) {
if (!errorMessage.isEmpty())
qWarning("VLM 실패: %s — 로컬 대체 없이 재요청", errorMessage.toUtf8().constData());
// 연결 거부는 50ms 스팸 대신 백오프. 과도하면 중단.
const bool connFail = errorMessage.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive)
|| errorMessage.contains(QStringLiteral("연결 실패"));
if (connFail && m_vlmTurnRetryCount >= 20) {
qWarning("VLM 서버 연결 실패 반복 — 재시도 중단. 서버/시작 상태를 확인하세요.");
m_vlmTurnRetryCount = 0;
updateStatus();
return;
}
if (connFail) {
++m_vlmTurnRetryCount;
const quint64 session = m_sessionId;
const quint32 epoch = m_vlmGameEpoch;
const int delay = qMin(5000, 200 * m_vlmTurnRetryCount);
QTimer::singleShot(delay, this, [this, session, epoch]() {
if (session != m_sessionId || epoch != m_vlmGameEpoch) return;
if (!isVlmTurn() || m_board.status() != GameStatus::Playing) return;
if (m_vlmInFlight) return;
startVlmRequest();
});
return;
}
retryVlmInsteadOfLocal(-1, -1, errorMessage.isEmpty()
? QStringLiteral("request-fail")
: errorMessage);
return;
}
const Stone vlmStone = m_board.currentTurn();
if (isPass) {
// PASS 루프 + 거부목록 과다 → 1선 강제착수(돌 던짐)로 이어지지 않게 처리.
if (m_vlmPassClearTries < 1 && !m_vlmRejectCoords.isEmpty()) {
qWarning("VLM PASS — 거부 목록(%d) 비우고 1회 재요청",
int(m_vlmRejectCoords.size() / 2));
m_vlmRejectCoords.clear();
++m_vlmPassClearTries;
retryVlmInsteadOfLocal(-1, -1, QStringLiteral("pass-clear-forbid"));
return;
}
if (m_vlmTurnRetryCount < kVlmPassRetryBeforeScore) {
qWarning("VLM PASS — 재요청 #%d", m_vlmTurnRetryCount + 1);
retryVlmInsteadOfLocal(-1, -1, QStringLiteral("pass"));
return;
}
m_vlmTurnRetryCount = 0;
m_vlmPassClearTries = 0;
if (!scoringTemporarilyBlocked()) {
qWarning("VLM PASS 반복 → 계가 모드 (1선 강제착수 금지)");
enterScoringMode();
return;
}
qWarning("VLM PASS 반복·계가 차단 → 자동 actor (단 1·2선 제외)");
handleAutoActorPassIntent(vlmStone);
return;
}
{
const int N = m_board.size();
// 심판 안전망: settled/자충/아다리 — 1·2선 거부는 아다리 응수 판정 뒤에.
const GameJudge::Analysis judge = GameJudge::analyze(m_board);
const Stone owner = judge.settledOwner(row, col);
constexpr int DR[4] = {-1, 1, 0, 0};
constexpr int DC[4] = {0, 0, -1, 1};
int ownAround = 0;
int oppAround = 0;
bool rescuesAtari = false;
bool capturesOpp = false;
const Stone oppCheck = GoBoard::opponent(vlmStone);
for (int d = 0; d < 4; ++d) {
const int nr = row + DR[d];
const int nc = col + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;
const Stone v = m_board.cellAt(nr, nc);
if (v == vlmStone) {
++ownAround;
const int gid = judge.stoneToGroup[nr * N + nc];
if (gid >= 0 && judge.groups[gid].liberties == 1)
rescuesAtari = true;
} else if (v == oppCheck) {
++oppAround;
const int gid = judge.stoneToGroup[nr * N + nc];
if (gid >= 0 && judge.groups[gid].liberties == 1)
capturesOpp = true;
}
}
const bool selfFill = (ownAround >= 3 && oppAround == 0
&& !rescuesAtari && !capturesOpp);
if (owner == vlmStone || owner == GoBoard::opponent(vlmStone) || selfFill) {
retryVlmInsteadOfLocal(row, col,
selfFill ? QStringLiteral("selfFill")
: QStringLiteral("settled"));
return;
}
{
// 아다리는 VLM/계가 판단에 맡긴다.
// (무조건 응수 강제는 텐우키·희생이 이득인 수를 거부 → 무한 루프의 원인이었음)
QVector<int> ataridGids;
for (int gid = 0; gid < judge.groups.size(); ++gid) {
const auto &g = judge.groups[gid];
if (g.color == vlmStone && g.liberties == 1)
ataridGids.append(gid);
}
bool hasAtariRescue = false;
bool hasOppCapture = false;
for (int d = 0; d < 4; ++d) {
const int nr = row + DR[d];
const int nc = col + DC[d];
if (nr < 0 || nr >= N || nc < 0 || nc >= N)
continue;
const int nGid = judge.stoneToGroup[nr * N + nc];
if (nGid < 0)
continue;
const auto &g = judge.groups[nGid];
if (ataridGids.contains(nGid))
hasAtariRescue = true;
if (g.color == oppCheck && g.liberties == 1)
hasOppCapture = true;
}
if (!ataridGids.isEmpty() && !hasAtariRescue && !hasOppCapture) {
qInfo("VLM 아다리 미응수 (%d,%d) — 허용(텐우키/희생 가능)", row, col);
}
// 아다리 응수·따냄이 아니면 1·2선 거부
const int edgeLine = qMin(qMin(row, col), qMin(N - 1 - row, N - 1 - col)) + 1;
if (edgeLine <= 2 && !hasAtariRescue && !hasOppCapture) {
retryVlmInsteadOfLocal(row, col, QStringLiteral("low-line"));
return;
}
MoveRejectReason rr = MoveRejectReason::None;
if (!m_board.playAt(row, col, &rr)) {
retryVlmInsteadOfLocal(row, col, rejectReasonText(rr));
return;
}
m_vlmTurnRetryCount = 0;
m_vlmPassClearTries = 0;
m_vlmAtariMissCount = 0;
m_vlmRejectCoords.clear();
recordMove(vlmStone, row, col, false);
if (m_boardWidget) m_boardWidget->setLastMove(row, col,
m_board.status() == GameStatus::Playing);
}
}
pushBoardToView();
updateStatus();
maybeScheduleNext();
}
void MainWindow::onVlmPingReady(bool success, QString errorMessage)
{
// (구) 영구 소켓 기반 PING 결과 — 현재 UI 는 doPingProbe 만 사용.
// 호환을 위해 남겨 두지만 여기로 들어오는 경로는 없다.
Q_UNUSED(success);
Q_UNUSED(errorMessage);
}
void MainWindow::onVlmPingProbeReady(QString tag, bool success, QString endpoint, int latencyMs,
QString errorMessage)
{
const int idx = (tag == QStringLiteral("2")) ? 2 : 1;
QLabel *lbl = (idx == 2) ? m_vlmConnLabel2 : m_vlmConnLabel;
QPushButton *btn = (idx == 2) ? m_vlmConnectBtn2 : m_vlmConnectBtn;
if (btn) btn->setEnabled(true);
if (success) {
setProbeLabelOk(lbl, endpoint, latencyMs);
cancelAutoReconnect(idx); // 성공했으니 재시도 큐 정리.
return;
}
setProbeLabelFail(lbl, endpoint, latencyMs, errorMessage);
// 자동 재접속 처리: 체크박스 ON + 해당 행 실제로 보이는 경우에만.
const bool autoOn = m_vlmAutoReconnectCheck && m_vlmAutoReconnectCheck->isChecked();
QWidget *row = (idx == 2) ? m_vlmWhiteRow : m_vlmServer1Row;
const bool rowVisible = row && row->isVisible();
if (!autoOn || !rowVisible)
return;
int &att = (idx == 2) ? m_retryAttempts2 : m_retryAttempts1;
int &delay = (idx == 2) ? m_retryDelayMs2 : m_retryDelayMs1;
QElapsedTimer &clk = (idx == 2) ? m_retryClock2 : m_retryClock1;
QTimer *tmr = (idx == 2) ? m_retryTimer2 : m_retryTimer1;
QString &epStr = (idx == 2) ? m_retryEndpointStr2 : m_retryEndpointStr1;
constexpr int kMaxAttempts = 6;
if (att >= kMaxAttempts) {
// 너무 오래 실패 — 자동 중지하고 사용자 액션 유도.
if (lbl) {
lbl->setText(QStringLiteral(
"● 자동 재접속 중지 — %1 (%2회 실패). [접속 확인] 으로 재시도.")
.arg(endpoint).arg(att));
lbl->setStyleSheet(QStringLiteral("color:#888; font-weight:600;"));
}
cancelAutoReconnect(idx);
return;
}
att += 1;
delay = qMin(15000, 1500 * att); // 1.5s, 3s, 4.5s, 6s, 7.5s, 9s
epStr = endpoint;
clk.restart();
if (tmr) tmr->start();
// 라벨은 다음 tick 에서 즉시 갱신됨 (250ms 이내).
}
void MainWindow::onVlmConnectionStateChanged(bool connected, QString endpoint)
{
// 인게임 영구 소켓의 연결 상태. 유저가 직접 누른 "접속 확인" 결과는
// onVlmPingProbeReady 가 더 우선. 여기서는 인게임 연결이 끊어졌을 때만
// 서버1 라벨에 "● 인게임 연결 끊김" 으로 가볍게 표시한다.
if (!m_vlmConnLabel) return;
if (!connected) {
// probe 직후 OK 표시를 덮어쓰지 않도록, 현재 라벨이 "응답 OK" 인 경우엔 그대로 둔다.
const QString cur = m_vlmConnLabel->text();
if (cur.contains(QStringLiteral("응답 OK")))
return;
m_vlmConnLabel->setText(QStringLiteral("● 인게임 미연결"));
m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#888;"));
} else {
// 인게임 연결됨 — probe 결과가 더 자세하므로 덮어쓰지 않음.
Q_UNUSED(endpoint);
}
}
void MainWindow::onVlmTickProgress()
{
if (!m_vlmInFlight) return;
const qint64 ms = m_vlmProgressClock.elapsed();
if (m_statusLabel) {
// 상태 라벨 끝에 진행 시간 표시 정도만.
const QString cur = m_statusLabel->text();
const QString tag = QStringLiteral(" <small>[VLM 응답 대기 %1.%2s]</small>")
.arg(ms / 1000).arg((ms % 1000) / 100);
if (!cur.contains(QStringLiteral("[VLM 응답 대기")))
m_statusLabel->setText(cur + tag);
}
}