게시판바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기예제 소스

예제 소스

바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기 · 파일 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 &nbsp;vs&nbsp; 백 %5 &nbsp;<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> &nbsp;<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 &nbsp;vs&nbsp; 백 %4 &nbsp;<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 &nbsp;:&nbsp; 백 %5"
                        " &nbsp;<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 &nbsp;|&nbsp; 수 %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);
    }
}