예제 소스
오목으로 이해하는 자기대국과 모방 학습 · 파일 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();
}