게시판오목으로 이해하는 자기대국과 모방 학습예제 소스

예제 소스

오목으로 이해하는 자기대국과 모방 학습 · 파일 59개

mainwindow.cpp

#include "mainwindow.h"
#include "aiplayer.h"
#include "boardwidget.h"
#include "tcpvlmclient.h"

#include <QApplication>
#include <QByteArray>
#include <QCheckBox>
#include <QComboBox>
#include <QDebug>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSpinBox>
#include <QThread>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>

namespace {

/// 0-based (row, col) → 사람이 읽는 좌표 라벨.
/// 예) (11, 5) → "(행 12, 열 6 / F12)"
/// 알파벳 컬럼은 바둑식 (I 제외): A B C D E F G H J K L M N O P Q R S T.
QString formatCoord(int row, int col)
{
    QString letters;
    if (col >= 0 && col < 26) {
        // I 를 건너뛰는 알파벳 시퀀스.
        static const char *kColLetters = "ABCDEFGHJKLMNOPQRSTUVWXYZ";
        letters = QString(QLatin1Char(kColLetters[col]));
    }
    const QString numeric = QStringLiteral("행 %1, 열 %2").arg(row + 1).arg(col + 1);
    if (letters.isEmpty())
        return QStringLiteral("(%1)").arg(numeric);
    return QStringLiteral("(%1 / %2%3)").arg(numeric, letters).arg(row + 1);
}

} // namespace

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);

    auto *topBar = new QHBoxLayout();
    topBar->setContentsMargins(0, 0, 0, 0);
    topBar->setSpacing(8);

    auto *lblMode = new QLabel(QStringLiteral("플레이 모드:"));
    topBar->addWidget(lblMode);

    m_modeCombo = new QComboBox(this);
    m_modeCombo->addItem(QStringLiteral("사람 대 사람"),
                         QVariant::fromValue(static_cast<int>(GameMode::HumanVsHuman)));
    m_modeCombo->addItem(QStringLiteral("사람 대 AI"),
                         QVariant::fromValue(static_cast<int>(GameMode::HumanVsAi)));
    m_modeCombo->addItem(QStringLiteral("사람 대 VLM (학습/평가)"),
                         QVariant::fromValue(static_cast<int>(GameMode::HumanVsVlmTcp)));
    m_modeCombo->addItem(QStringLiteral("AI 대 AI"),
                         QVariant::fromValue(static_cast<int>(GameMode::AiVsAi)));
    m_modeCombo->addItem(QStringLiteral("AI 대 VLM (학습/평가)"),
                         QVariant::fromValue(static_cast<int>(GameMode::AiVsVlm)));
    m_modeCombo->addItem(QStringLiteral("VLM 대 VLM (학습/평가)"),
                         QVariant::fromValue(static_cast<int>(GameMode::VlmVsVlm)));
    connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
            this, &MainWindow::onModeChanged);
    lblMode->setBuddy(m_modeCombo);
    topBar->addWidget(m_modeCombo, 1);

    auto *lblSize = new QLabel(QStringLiteral("판 크기:"), this);
    topBar->addWidget(lblSize);
    m_sizeCombo = new QComboBox(this);
    m_sizeCombo->addItem(QStringLiteral("13×13"), 13);
    m_sizeCombo->addItem(QStringLiteral("15×15"), 15);
    m_sizeCombo->addItem(QStringLiteral("19×19"), 19);
    {
        const int initIdx = m_sizeCombo->findData(m_board.size());
        m_sizeCombo->setCurrentIndex(initIdx >= 0 ? initIdx : 0);
    }
    connect(m_sizeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
            this, &MainWindow::onBoardSizeChanged);
    lblSize->setBuddy(m_sizeCombo);
    topBar->addWidget(m_sizeCombo);

    m_humanSideLabel = new QLabel(QStringLiteral("사람 돌:"), this);
    topBar->addWidget(m_humanSideLabel);
    m_humanSideCombo = new QComboBox(this);
    m_humanSideCombo->addItem(QStringLiteral("흑(선)"),
                              QVariant::fromValue(static_cast<int>(Cell::Black)));
    m_humanSideCombo->addItem(QStringLiteral("백(후)"),
                              QVariant::fromValue(static_cast<int>(Cell::White)));
    m_humanSideCombo->setCurrentIndex(0);
    connect(m_humanSideCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
            this, &MainWindow::onHumanSideChanged);
    m_humanSideLabel->setBuddy(m_humanSideCombo);
    topBar->addWidget(m_humanSideCombo);

    auto *restartBtn = new QPushButton(QStringLiteral("새 게임"), this);
    connect(restartBtn, &QPushButton::clicked, this, &MainWindow::onNewGameClicked);
    topBar->addWidget(restartBtn);

    m_autoRestartCheck = new QCheckBox(QStringLiteral("자동 재시작"), this);
    m_autoRestartCheck->setToolTip(QStringLiteral(
        "게임이 끝나면 자동으로 새 게임을 시작합니다. 체크 해제 시 ‘새 게임’ 버튼을 눌러 수동 재시작."));
    m_autoRestartCheck->setChecked(false);
    topBar->addWidget(m_autoRestartCheck);

    root->addLayout(topBar);

    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("VLM 서버:"), m_vlmConfigRow));
    m_vlmHostEdit = new QLineEdit(QStringLiteral("127.0.0.1"), m_vlmConfigRow);
    m_vlmHostEdit->setPlaceholderText(QStringLiteral("호스트"));
    m_vlmPortEdit = new QLineEdit(QStringLiteral("9299"), m_vlmConfigRow);
    m_vlmPortEdit->setMaximumWidth(72);
    m_vlmPortEdit->setPlaceholderText(QStringLiteral("포트"));
    vlmLay->addWidget(m_vlmHostEdit, 1);
    vlmLay->addWidget(m_vlmPortEdit);

    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);
    vlmLay->addWidget(m_vlmTimeoutSpin);

    m_vlmConnectBtn = new QPushButton(QStringLiteral("서버 접속 확인"), m_vlmConfigRow);
    connect(m_vlmConnectBtn, &QPushButton::clicked, this, &MainWindow::onPingServerClicked);
    vlmLay->addWidget(m_vlmConnectBtn);

    m_vlmCancelBtn = new QPushButton(QStringLiteral("VLM 응답 취소"), m_vlmConfigRow);
    m_vlmCancelBtn->setEnabled(false);
    connect(m_vlmCancelBtn, &QPushButton::clicked, this, &MainWindow::onCancelVlmClicked);
    vlmLay->addWidget(m_vlmCancelBtn);

    m_vlmConnLabel = new QLabel(QStringLiteral("● 미접속"), m_vlmConfigRow);
    m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#888;"));
    m_vlmConnLabel->setToolTip(QStringLiteral("VLM 서버와의 TCP 연결 상태."));
    vlmLay->addWidget(m_vlmConnLabel);

    vlmLay->addStretch();
    root->addWidget(m_vlmConfigRow);
    m_vlmConfigRow->hide();

    m_statusLabel = new QLabel(QStringLiteral("(상태 없음)"), this);
    m_statusLabel->setWordWrap(true);
    m_statusLabel->setContentsMargins(2, 4, 2, 6);
    root->addWidget(m_statusLabel);

    m_boardWidget = new BoardWidget(this);
    connect(m_boardWidget, &BoardWidget::intersectionClicked,
            this, &MainWindow::onIntersectionClicked);
    root->addWidget(m_boardWidget, 1);

    // ---- 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::connectionStateChanged,
            this, &MainWindow::onVlmConnectionStateChanged, Qt::QueuedConnection);
    m_vlmThread->start();

    m_vlmProgressTimer = new QTimer(this);
    m_vlmProgressTimer->setInterval(500);
    connect(m_vlmProgressTimer, &QTimer::timeout, this, &MainWindow::onVlmTickProgress);

    onModeChanged(0);
}

MainWindow::~MainWindow()
{
    if (m_vlmWorker) {
        QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
        QMetaObject::invokeMethod(m_vlmWorker.data(), "closeConnection", Qt::QueuedConnection);
    }
    if (m_vlmThread) {
        m_vlmThread->quit();
        m_vlmThread->wait(2000);
    }
}

void MainWindow::selectGameMode(GameMode mode)
{
    if (!m_modeCombo)
        return;
    const int needle = static_cast<int>(mode);
    for (int i = 0; i < m_modeCombo->count(); ++i) {
        if (m_modeCombo->itemData(i).toInt() == needle) {
            m_modeCombo->setCurrentIndex(i);
            return;
        }
    }
}

void MainWindow::setVlmUiVisible(bool visible)
{
    if (m_vlmConfigRow)
        m_vlmConfigRow->setVisible(visible);
}

bool MainWindow::aiShouldAnswerThisTurn() const
{
    if (m_board.status() != GameStatus::Playing)
        return false;
    switch (m_board.mode()) {
    case GameMode::HumanVsHuman:
        return false;
    case GameMode::HumanVsAi:
    case GameMode::HumanVsVlmTcp:
        return m_board.currentTurn() != m_board.humanSide();
    case GameMode::AiVsAi:
    case GameMode::AiVsVlm:
    case GameMode::VlmVsVlm:
        return true;
    }
    return false;
}

Cell MainWindow::vlmSideForCurrentMode() const
{
    switch (m_board.mode()) {
    case GameMode::HumanVsVlmTcp:
        return (m_board.humanSide() == Cell::Black) ? Cell::White : Cell::Black;
    case GameMode::AiVsVlm:
        return m_board.vlmSide();
    case GameMode::VlmVsVlm:
        // 양쪽 모두 VLM — 이번 수를 둘 색이 곧 VLM 측.
        return m_board.currentTurn();
    default:
        return Cell::None;
    }
}

bool MainWindow::isVlmTurn() const
{
    const Cell vs = vlmSideForCurrentMode();
    return vs != Cell::None && m_board.currentTurn() == vs;
}

void MainWindow::maybeScheduleAi()
{
    if (!aiShouldAnswerThisTurn())
        return;

    constexpr int delayMs = 280;
    const quint64 sid = m_sessionId;
    QTimer::singleShot(delayMs, this, [this, sid]() {
        if (sid != m_sessionId)
            return;
        doOneAiMove();
    });
}

void MainWindow::markLastMove(int row, int col)
{
    if (!m_boardWidget)
        return;
    const bool blink = (m_board.status() == GameStatus::Playing);
    m_boardWidget->setLastMove(row, col, blink);
}

void MainWindow::logMoveToServer(int row, int col, Cell stone, int actorKind)
{
    // VLM 이 직접 둔 수는 서버가 predict 단계에서 이미 기록함 → 중복 방지.
    if (actorKind == 2 /* vlm */)
        return;
    if (stone != Cell::Black && stone != Cell::White)
        return;
    if (!m_vlmWorker || !m_vlmThread || !m_vlmThread->isRunning())
        return;
    if (!m_vlmHostEdit || !m_vlmPortEdit)
        return;

    bool okPort = false;
    const quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
    if (!okPort || port == 0)
        return;
    const QString host = m_vlmHostEdit->text().trimmed();
    if (host.isEmpty())
        return;

    const int N = m_board.size();
    QByteArray packed;
    packed.resize(N * N);
    for (int r = 0; r < N; ++r) {
        for (int c = 0; c < N; ++c) {
            packed[r * N + c] = static_cast<char>(static_cast<unsigned char>(
                m_board.cellAt(r, c)));
        }
    }

    int statusCode = 0;
    switch (m_board.status()) {
    case GameStatus::Playing:    statusCode = 0; break;
    case GameStatus::BlackWins:  statusCode = 1; break;
    case GameStatus::WhiteWins:  statusCode = 2; break;
    case GameStatus::Draw:       statusCode = 3; break;
    }
    int modeCode = static_cast<int>(m_board.mode());
    int stoneCode = (stone == Cell::Black) ? 1 : 2;

    QMetaObject::invokeMethod(
        m_vlmWorker.data(), "requestLog", Qt::QueuedConnection,
        Q_ARG(int, N), Q_ARG(int, stoneCode), Q_ARG(int, row), Q_ARG(int, col),
        Q_ARG(int, actorKind), Q_ARG(int, modeCode), Q_ARG(int, statusCode),
        Q_ARG(int, m_moveSeq), Q_ARG(QByteArray, packed),
        Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, 5000));
}

QVector<QVector<Cell>> MainWindow::shallowCopyCells() const
{
    const int N = m_board.size();
    QVector<QVector<Cell>> g(N, QVector<Cell>(N, Cell::None));
    for (int r = 0; r < N; ++r)
        for (int c = 0; c < N; ++c)
            g[r][c] = m_board.cellAt(r, c);
    return g;
}

void MainWindow::pushBoardToView()
{
    m_boardWidget->refreshFromBoard(shallowCopyCells());
}

void MainWindow::updateStatus()
{
    QString turn;
    if (m_board.status() != GameStatus::Playing) {
        switch (m_board.status()) {
        case GameStatus::BlackWins:
            turn = QStringLiteral("흑 승!");
            break;
        case GameStatus::WhiteWins:
            turn = QStringLiteral("백 승!");
            break;
        case GameStatus::Draw:
            turn = QStringLiteral("무승부입니다.");
            break;
        case GameStatus::Playing:
            break;
        }
    } else if (m_board.currentTurn() == Cell::Black) {
        turn = QStringLiteral("차례: 흑");
    } else {
        turn = QStringLiteral("차례: 백");
    }

    QString helper;
    QString ruleSansan =
        QStringLiteral("<b>금수 (사용자 룰):</b> "
                       "<b>흑·백 동일</b> — 활삼 두 개 이상(<b>금삼삼</b>) 또는 직선 6목 이상(<b>장목</b>). "
                       "사사(4-4)는 금수가 아닙니다 (broken-4 조합은 33 으로 처리). "
                       "정확히 <b>5목</b>은 두 색 모두 즉시 승리.");
    const QString humanColorName =
        (m_board.humanSide() == Cell::Black) ? QStringLiteral("흑(선)")
                                             : QStringLiteral("백(후)");
    const QString aiColorName =
        (m_board.humanSide() == Cell::Black) ? QStringLiteral("백") : QStringLiteral("흑");
    const QString sizeName = QStringLiteral("%1×%1").arg(m_board.size());
    switch (m_board.mode()) {
    case GameMode::HumanVsHuman:
        helper = QStringLiteral("판 위 교차점을 클릭해 돌을 둡니다. (판 %1)").arg(sizeName);
        break;
    case GameMode::HumanVsAi:
        helper = QStringLiteral("사람=%1 / 컴퓨터=%2. 판 %3.")
                     .arg(humanColorName, aiColorName, sizeName);
        break;
    case GameMode::HumanVsVlmTcp:
        helper =
            QStringLiteral("사람=%1 / VLM=%2 (TCP). 판 %3. "
                           "VLM 평가/학습 데이터 수집용 — 서버에 <code>--log-jsonl</code> 옵션이 켜져 있으면 "
                           "매 응답이 기록됩니다. <b>‘서버 접속 확인’</b> 으로 연결 점검, "
                           "응답이 너무 오래 걸리면 <b>‘VLM 응답 취소’</b> → 로컬 AI 가 대신 둡니다.")
                .arg(humanColorName, aiColorName, sizeName);
        break;
    case GameMode::AiVsAi:
        helper = QStringLiteral("양쪽 컴퓨터가 자동으로 교대로 둡니다. (판 %1)").arg(sizeName);
        break;
    case GameMode::AiVsVlm: {
        const QString vlmColor = (m_board.vlmSide() == Cell::Black)
                                     ? QStringLiteral("흑(선)") : QStringLiteral("백(후)");
        const QString aiColor = (m_board.vlmSide() == Cell::Black)
                                    ? QStringLiteral("백") : QStringLiteral("흑");
        helper = QStringLiteral(
            "로컬 AI=%1 / VLM=%2 (TCP). 판 %3. 양쪽 자동 진행 — "
            "VLM 평가/대량 대국 데이터 수집용. VLM 실패/취소 시 로컬 AI 가 대체 둡니다.")
                     .arg(aiColor, vlmColor, sizeName);
        break;
    }
    case GameMode::VlmVsVlm:
        helper = QStringLiteral(
            "VLM 대 VLM (TCP). 판 %1. 동일 서버·모델로 흑·백이 번갈아 자동 진행합니다. "
            "한 수만 응답 실패/취소 시 그 수만 로컬 AI 가 대신 둡니다.")
                     .arg(sizeName);
        break;
    }

    m_statusLabel->setText(
        QStringLiteral("<b>%1</b><br>%2<br><small>%3</small>").arg(turn).arg(helper).arg(ruleSansan));

    // 자동 재시작: 게임 끝 + 체크박스 ON + 아직 예약 안 됨 → 짧은 지연 후 새 게임.
    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();
        });
    }
}

void MainWindow::restartMatch()
{
    ++m_sessionId;
    m_moveSeq = 0;
    m_autoRestartScheduled = false;
    if (m_vlmWorker)
        m_vlmWorker->cancel();
    finishVlmCleanup();
    m_board.reset();
    if (m_boardWidget)
        m_boardWidget->setLastMove(-1, -1, false);
    pushBoardToView();
    updateStatus();
    maybeScheduleAi();
}

void MainWindow::onNewGameClicked()
{
    restartMatch();
}

void MainWindow::onModeChanged(int index)
{
    Q_UNUSED(index);
    const int raw = m_modeCombo->currentData().toInt();
    const auto mode = static_cast<GameMode>(raw);
    m_board.setMode(mode);
    const bool needsVlm = (mode == GameMode::HumanVsVlmTcp || mode == GameMode::AiVsVlm
                           || mode == GameMode::VlmVsVlm);
    setVlmUiVisible(needsVlm);
    // 라벨 / 콤보 활성화: 모드별 의미 변경.
    if (m_humanSideLabel && m_humanSideCombo) {
        switch (mode) {
        case GameMode::HumanVsAi:
        case GameMode::HumanVsVlmTcp:
            m_humanSideLabel->setText(QStringLiteral("사람 돌:"));
            m_humanSideCombo->setEnabled(true);
            break;
        case GameMode::AiVsVlm:
            m_humanSideLabel->setText(QStringLiteral("VLM 돌:"));
            m_humanSideCombo->setEnabled(true);
            break;
        case GameMode::VlmVsVlm:
            m_humanSideLabel->setText(QStringLiteral("—"));
            m_humanSideCombo->setEnabled(false);
            break;
        case GameMode::HumanVsHuman:
        case GameMode::AiVsAi:
            m_humanSideLabel->setText(QStringLiteral("사람 돌:"));
            m_humanSideCombo->setEnabled(false);
            break;
        }
    }
    // 콤보 값을 모드 의미에 따라 board 에 반영 (restart 는 한 번만).
    if (m_humanSideCombo) {
        const auto chosen = static_cast<Cell>(m_humanSideCombo->currentData().toInt());
        const Cell other = (chosen == Cell::Black) ? Cell::White : Cell::Black;
        if (mode == GameMode::AiVsVlm) {
            m_board.setVlmSide(chosen);
            m_board.setHumanSide(other);
        } else if (mode != GameMode::VlmVsVlm) {
            m_board.setHumanSide(chosen);
            m_board.setVlmSide(other);
        }
    }
    restartMatch();
}

void MainWindow::onBoardSizeChanged(int index)
{
    Q_UNUSED(index);
    if (!m_sizeCombo)
        return;
    const int sz = m_sizeCombo->currentData().toInt();
    if (sz == m_board.size())
        return;
    if (m_vlmWorker)
        m_vlmWorker->cancel();
    finishVlmCleanup();
    m_board.resize(sz);
    pushBoardToView();
    updateStatus();
    maybeScheduleAi();
}

void MainWindow::onHumanSideChanged(int index)
{
    Q_UNUSED(index);
    if (!m_humanSideCombo)
        return;
    const auto chosen = static_cast<Cell>(m_humanSideCombo->currentData().toInt());
    const Cell other = (chosen == Cell::Black) ? Cell::White : Cell::Black;
    if (m_board.mode() == GameMode::VlmVsVlm)
        return;
    if (m_board.mode() == GameMode::AiVsVlm) {
        m_board.setVlmSide(chosen);
        m_board.setHumanSide(other); // 사용 안 하지만 일관성 유지.
    } else {
        m_board.setHumanSide(chosen);
        m_board.setVlmSide(other);
    }
    restartMatch();
}

void MainWindow::onIntersectionClicked(int row, int col)
{
    if (m_board.mode() == GameMode::AiVsAi || m_board.mode() == GameMode::AiVsVlm
        || m_board.mode() == GameMode::VlmVsVlm)
        return;

    if (m_board.mode() == GameMode::HumanVsAi || m_board.mode() == GameMode::HumanVsVlmTcp) {
        if (m_board.status() != GameStatus::Playing)
            return;
        if (m_board.currentTurn() != m_board.humanSide())
            return;
    }

    MoveRejectReason reason = MoveRejectReason::None;
    const int humanRow = row;
    const int humanCol = col;
    const Cell justPlayed = m_board.currentTurn();
    if (!m_board.playAt(row, col, &reason)) {
        updateStatus();
        const QString coordLabel = formatCoord(row, col);
        const QString sideLabel = (justPlayed == Cell::Black ? QStringLiteral("흑")
                                                             : QStringLiteral("백"));
        QString diag;
        if (reason == MoveRejectReason::SansanBan
            || reason == MoveRejectReason::DoubleFourBanBlack) {
            diag = m_board.explainOpenLines(row, col, justPlayed);
            qWarning().noquote() << "[reject]" << sideLabel << coordLabel
                                 << (reason == MoveRejectReason::SansanBan ? "금삼삼" : "금사사")
                                 << "\n" << diag;
        } else if (reason == MoveRejectReason::ChangmokBan) {
            qWarning().noquote() << "[reject]" << sideLabel << coordLabel << "장목";
        }
        auto withDiag = [&](const QString &core) -> QString {
            QString out = core;
            if (!diag.isEmpty()) {
                QString safe = diag.toHtmlEscaped();
                safe.replace(QLatin1Char('\n'), QStringLiteral("<br>"));
                out += QStringLiteral(
                    "<br><pre style=\"font-family:monospace;color:#444;font-size:11px;\">%1</pre>")
                           .arg(safe);
            }
            return out;
        };
        if (reason == MoveRejectReason::SansanBan) {
            m_statusLabel->setText(m_statusLabel->text()
                + withDiag(QStringLiteral(
                      "<br><span style=\"color:#b00020;\"><b>금삼삼 금수</b> %1 %2 — "
                      "다른 교차점을 선택하세요.</span>")
                       .arg(sideLabel, coordLabel)));
        } else if (reason == MoveRejectReason::DoubleFourBanBlack) {
            m_statusLabel->setText(m_statusLabel->text()
                + withDiag(QStringLiteral(
                      "<br><span style=\"color:#b00020;\"><b>금사사 금수</b> %1 %2 — "
                      "다른 교차점을 선택하세요.</span>")
                       .arg(sideLabel, coordLabel)));
        } else if (reason == MoveRejectReason::ChangmokBan) {
            m_statusLabel->setText(m_statusLabel->text()
                + QStringLiteral("<br><span style=\"color:#b00020;\"><b>장목 금수</b> %1 %2 — "
                                 "(흑·백 모두 직선 6목 이상은 둘 수 없습니다.)</span>")
                       .arg(sideLabel, coordLabel));
        }
        return;
    }

    ++m_moveSeq;
    markLastMove(humanRow, humanCol);
    logMoveToServer(humanRow, humanCol, justPlayed, /*actor=human*/ 0);
    pushBoardToView();
    updateStatus();
    maybeScheduleAi();
}

void MainWindow::onPingServerClicked()
{
    bool okPort = false;
    const quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
    if (!okPort || port == 0) {
        m_statusLabel->setText(QStringLiteral(
            "<span style=\"color:#b00020;\">VLM 포트 번호가 올바르지 않습니다.</span>"));
        return;
    }
    const QString host = m_vlmHostEdit->text().trimmed();
    if (!m_vlmWorker || !m_vlmThread || !m_vlmThread->isRunning()) {
        m_statusLabel->setText(QStringLiteral(
            "<span style=\"color:#b00020;\">VLM 워커가 준비되지 않았습니다.</span>"));
        return;
    }
    m_vlmConnectBtn->setEnabled(false);
    QMetaObject::invokeMethod(m_vlmWorker.data(), "doPing", Qt::QueuedConnection,
                              Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, 3000));
}

void MainWindow::onVlmPingReady(bool success, QString errorMessage)
{
    m_vlmConnectBtn->setEnabled(true);
    updateStatus();
    if (success) {
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#0a7d2c;\"><b>서버 연결 OK</b> "
                             "(persistent — 같은 소켓 유지)</span>"));
    } else {
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#b00020;\"><b>서버 연결 실패:</b> %1</span>")
                  .arg(errorMessage.isEmpty() ? QStringLiteral("알 수 없음")
                                              : errorMessage.toHtmlEscaped()));
    }
}

void MainWindow::onVlmConnectionStateChanged(bool connected, QString endpoint)
{
    if (!m_vlmConnLabel)
        return;
    if (connected) {
        m_vlmConnLabel->setText(QStringLiteral("● 연결됨 %1").arg(endpoint));
        m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#0a7d2c;font-weight:600;"));
    } else {
        m_vlmConnLabel->setText(QStringLiteral("● 미접속"));
        m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#888;"));
    }
}

void MainWindow::onCancelVlmClicked()
{
    if (!m_vlmInFlight)
        return;
    if (m_vlmWorker)
        QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::DirectConnection);
    // cancel 은 워커 안에서 cancel flag 만 set → 안전. 직접 호출 OK.
    // 결과 콜백(onVlmMoveReady)이 곧 들어오면서 finishVlmCleanup → 폴백 처리됩니다.
}

void MainWindow::startVlmTcpRequest()
{
    if (!aiShouldAnswerThisTurn())
        return;
    if (!isVlmTurn())
        return; // VLM 차례가 아니면 호출 안 함 (예: AiVsVlm 의 로컬 AI 차례).
    if (m_vlmInFlight) {
        return;
    }
    if (!m_vlmWorker || !m_vlmThread || !m_vlmThread->isRunning()) {
        playLocalFallbackForVlm();
        return;
    }

    bool okPort = false;
    const quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
    if (!okPort || port == 0) {
        updateStatus();
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#b00020;\">VLM 포트 번호가 올바르지 않습니다. 로컬 AI 가 대신 둡니다.</span>"));
        playLocalFallbackForVlm();
        return;
    }

    const int N = m_board.size();
    QImage shot = m_boardWidget->grab().toImage();
    QByteArray packed;
    packed.resize(N * N);
    for (int r = 0; r < N; ++r) {
        for (int c = 0; c < N; ++c) {
            packed[r * N + c] = static_cast<char>(static_cast<unsigned char>(
                m_board.cellAt(r, c)));
        }
    }

    const Cell aiSide = vlmSideForCurrentMode();
    const QString host = m_vlmHostEdit->text().trimmed();
    const int timeoutSec = m_vlmTimeoutSpin ? m_vlmTimeoutSpin->value() : 60;
    const int timeoutMs = qMax(2000, timeoutSec * 1000);

    m_vlmInFlight = true;
    ++m_vlmRequestSession;
    m_vlmCancelBtn->setEnabled(true);
    m_vlmConnectBtn->setEnabled(false);
    m_vlmProgressClock.restart();
    m_vlmProgressTimer->start();

    updateStatus();
    m_statusLabel->setText(m_statusLabel->text()
        + QStringLiteral("<br><span style=\"color:#555;\">VLM 응답 대기 중…</span>"));

    QMetaObject::invokeMethod(m_vlmWorker.data(), "requestMove", Qt::QueuedConnection,
                              Q_ARG(QImage, shot), Q_ARG(QByteArray, packed),
                              Q_ARG(int, N),
                              Q_ARG(int, static_cast<int>(aiSide)),
                              Q_ARG(QString, host), Q_ARG(quint16, port),
                              Q_ARG(int, timeoutMs));
}

void MainWindow::onVlmTickProgress()
{
    if (!m_vlmInFlight)
        return;
    const int sec = int(m_vlmProgressClock.elapsed() / 1000);
    updateStatus();
    m_statusLabel->setText(m_statusLabel->text()
        + QStringLiteral("<br><span style=\"color:#555;\">VLM 응답 대기 중… <b>%1초</b> 경과 "
                         "(필요하면 ‘VLM 응답 취소’ 버튼)</span>")
              .arg(sec));
}

void MainWindow::finishVlmCleanup()
{
    m_vlmInFlight = false;
    if (m_vlmCancelBtn)
        m_vlmCancelBtn->setEnabled(false);
    if (m_vlmConnectBtn)
        m_vlmConnectBtn->setEnabled(true);
    if (m_vlmProgressTimer)
        m_vlmProgressTimer->stop();
}

void MainWindow::onVlmMoveReady(bool success, int row, int col, QString errorMessage)
{
    finishVlmCleanup();

    const Cell vlm = vlmSideForCurrentMode();
    const Cell opp = (vlm == Cell::Black) ? Cell::White : Cell::Black;
    const bool vlmModeActive =
        (m_board.mode() == GameMode::HumanVsVlmTcp || m_board.mode() == GameMode::AiVsVlm
         || m_board.mode() == GameMode::VlmVsVlm);

    if (!vlmModeActive || m_board.status() != GameStatus::Playing
        || vlm == Cell::None || m_board.currentTurn() != vlm) {
        updateStatus();
        return;
    }

    if (!success) {
        updateStatus();
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#b00020;\"><b>VLM 실패:</b> %1<br>"
                             "로컬 AI 가 대신 둡니다.</span>")
                  .arg(errorMessage.isEmpty() ? QStringLiteral("알 수 없음")
                                              : errorMessage.toHtmlEscaped()));
        playLocalFallbackForVlm();
        return;
    }

    // ---- 즉시승수 가드 ----
    // VLM 이 합법적인 좌표를 답했더라도, 'VLM 자신이 한 수로 이기는 자리' 또는
    // '상대가 다음에 한 수로 이기는 자리(반드시 막아야 함)' 가 있으면 그것을 우선합니다.
    const int N = m_board.size();
    bool overrideMove = false;
    int useRow = row;
    int useCol = col;
    QString overrideReason;
    auto findImmediateMove = [&](Cell who, bool *foundOut, int *outR, int *outC) {
        for (int rr = 0; rr < N; ++rr) {
            for (int cc = 0; cc < N; ++cc) {
                if (m_board.cellAt(rr, cc) != Cell::None)
                    continue;
                if (m_board.wouldWinIfPlaced(rr, cc, who)) {
                    *foundOut = true;
                    *outR = rr;
                    *outC = cc;
                    return;
                }
            }
        }
    };

    bool vlmWin = false;
    int vR = -1;
    int vC = -1;
    findImmediateMove(vlm, &vlmWin, &vR, &vC);
    if (vlmWin && (vR != row || vC != col)) {
        overrideMove = true;
        useRow = vR;
        useCol = vC;
        overrideReason = QStringLiteral("VLM 즉시승수 자동 적용");
    } else {
        bool oppWin = false;
        int oR = -1;
        int oC = -1;
        findImmediateMove(opp, &oppWin, &oR, &oC);
        if (oppWin && (oR != row || oC != col)) {
            overrideMove = true;
            useRow = oR;
            useCol = oC;
            overrideReason = QStringLiteral("상대 즉시승수 차단 자동 적용");
        } else {
            const QPoint blockPt = m_board.findOpponentDoubleThreatBlock(opp);
            if (blockPt.x() >= 0 && (blockPt.y() != row || blockPt.x() != col)) {
                overrideMove = true;
                useRow = blockPt.y();
                useCol = blockPt.x();
                overrideReason = QStringLiteral("상대 위협(오픈3/4) 자동 차단");
            }
        }
    }

    // ---- 벽에 붙은 3/4 회피 가드 ----
    // VLM 이 (즉시승수/차단 자리도 아니면서) 벽에 붙은 3 또는 4 모양을 만드는 자리를
    // 골랐다면 약한 모양이라 보고 로컬 AI 추천으로 교체한다. 로컬 AI 도 같은 패널티를
    // 갖고 있어 가급적 다른 자리를 고른다. (수가 정말 그것뿐이면 그대로 됨.)
    if (!overrideMove
        && (m_board.formsWallAttachedRun(useRow, useCol, vlm, 3)
            || m_board.formsWallAttachedRun(useRow, useCol, vlm, 4))) {
        const QPoint p = AiPlayer::suggestMove(m_board, vlm);
        if (p.x() >= 0 && (p.y() != useRow || p.x() != useCol)
            && !m_board.formsWallAttachedRun(p.y(), p.x(), vlm, 3)
            && !m_board.formsWallAttachedRun(p.y(), p.x(), vlm, 4)) {
            overrideMove = true;
            useRow = p.y();
            useCol = p.x();
            overrideReason = QStringLiteral("벽에 붙은 3·4 회피 (로컬 AI 추천)");
        }
    }

    MoveRejectReason reason = MoveRejectReason::None;
    if (!m_board.playAt(useRow, useCol, &reason)) {
        updateStatus();
        const QString why = (reason == MoveRejectReason::Occupied)
            ? QStringLiteral("이미 돌이 있는 칸")
            : QStringLiteral("규칙 위반(금수 등) 또는 게임 종료");
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#b00020;\"><b>VLM 좌표 불가:</b> (%1,%2) — %3<br>"
                             "로컬 AI 가 대신 둡니다.</span>")
                  .arg(useRow)
                  .arg(useCol)
                  .arg(why));
        playLocalFallbackForVlm();
        return;
    }

    ++m_moveSeq;
    markLastMove(useRow, useCol);
    // override 면 로컬 AI 가 둔 셈, 아니면 VLM 이 둔 것.
    const int actor = overrideMove ? /*local_ai*/ 1 : /*vlm*/ 2;
    logMoveToServer(useRow, useCol, vlm, actor);
    pushBoardToView();
    updateStatus();
    if (overrideMove) {
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#0a7d2c;\"><b>%1</b>: (%2,%3) "
                             "— VLM 응답 (%4,%5) 대신.</span>")
                  .arg(overrideReason)
                  .arg(useRow)
                  .arg(useCol)
                  .arg(row)
                  .arg(col));
    }
    maybeScheduleAi();
}

void MainWindow::playLocalFallbackForVlm()
{
    if (m_board.status() != GameStatus::Playing)
        return;
    const Cell vlm = vlmSideForCurrentMode();
    if (vlm == Cell::None)
        return;
    if (m_board.currentTurn() != vlm)
        return;

    const int N = m_board.size();
    QPoint p = AiPlayer::suggestMove(m_board, vlm);
    int playedRow = p.y();
    int playedCol = p.x();
    bool ok = m_board.playAt(playedRow, playedCol);
    if (!ok) {
        for (int r = 0; r < N && !ok; ++r) {
            for (int c = 0; c < N && !ok; ++c) {
                if (m_board.cellAt(r, c) != Cell::None)
                    continue;
                if (m_board.playAt(r, c)) {
                    ok = true;
                    playedRow = r;
                    playedCol = c;
                }
            }
        }
    }
    if (ok) {
        ++m_moveSeq;
        markLastMove(playedRow, playedCol);
        logMoveToServer(playedRow, playedCol, vlm, /*local_ai*/ 1);
    }
    pushBoardToView();
    updateStatus();
    maybeScheduleAi();
}

void MainWindow::doOneAiMove()
{
    if (!aiShouldAnswerThisTurn())
        return;

    // VLM 사용 모드에서 *VLM 차례* 면 비동기 TCP 요청.
    if (isVlmTurn()) {
        startVlmTcpRequest();
        return;
    }

    Cell side = m_board.currentTurn();
    const int N = m_board.size();
    QPoint p = AiPlayer::suggestMove(m_board, side);
    int playedRow = p.y();
    int playedCol = p.x();
    bool ok = m_board.playAt(playedRow, playedCol);
    if (!ok) {
        for (int r = 0; r < N && !ok; ++r) {
            for (int c = 0; c < N && !ok; ++c) {
                if (m_board.cellAt(r, c) != Cell::None)
                    continue;
                if (m_board.hasKyungsuBan(r, c, side))
                    continue;
                if (m_board.playAt(r, c)) {
                    ok = true;
                    playedRow = r;
                    playedCol = c;
                }
            }
        }
    }

    if (ok) {
        ++m_moveSeq;
        markLastMove(playedRow, playedCol);
        logMoveToServer(playedRow, playedCol, side, /*local_ai*/ 1);
    }
    pushBoardToView();
    updateStatus();
    maybeScheduleAi();
}