Use BFS approach for win simulation, not DFS
The original approach here was using depth-limited depth first search, which was a hacky workaround to avoid exploring a single branch too deeply. A much saner approach is to just explore in a breadth-first manner. This also completely negates the need for depth limitations and we can instead let the algorithm run until it either finishes or hits the time limit, as we always want to explore as much depth as we can, without it slowing down the responsiveness of the UI.
This commit is contained in:
parent
7cc52f272d
commit
b209fbc94b
|
@ -1,5 +1,6 @@
|
||||||
#include "gamestate.h"
|
#include "gamestate.h"
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QQueue>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <qlist.h>
|
#include <qlist.h>
|
||||||
#include <qqmllist.h>
|
#include <qqmllist.h>
|
||||||
|
@ -616,149 +617,180 @@ void GameState::ensureColumnRevealed(int columnId) {
|
||||||
qDebug() << "Revealed card " << col->card()->toString() << " in column " << columnId;
|
qDebug() << "Revealed card " << col->card()->toString() << " in column " << columnId;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<bool> GameState::canWinThroughSimulation(QSet<QString>& visitedStates, QElapsedTimer timer, int depth) const {
|
std::pair<std::optional<bool>, int> GameState::canWinThroughSimulation(QSet<QString>& visitedStates, QElapsedTimer timer) const {
|
||||||
// Check if the game is already won
|
|
||||||
if (m_gameWon)
|
if (m_gameWon)
|
||||||
return true;
|
return std::make_pair(true, 0); // Already won at depth 0
|
||||||
|
|
||||||
|
// Go over all possible moves using BFS
|
||||||
|
|
||||||
|
QQueue<std::pair<GameState*, int>> stateQueue; // BFS queue (game state, depth)
|
||||||
|
|
||||||
|
// Clone the current state as the root of BFS
|
||||||
|
GameState* initialState = this->clone();
|
||||||
|
stateQueue.enqueue({initialState, 0});
|
||||||
|
visitedStates.insert(initialState->generateStateHash());
|
||||||
|
|
||||||
|
int lastDepth = 0;
|
||||||
|
while (!stateQueue.isEmpty()) {
|
||||||
|
auto [currentState, depth] = stateQueue.dequeue();
|
||||||
|
auto currentHash = currentState->generateStateHash();
|
||||||
|
lastDepth = depth;
|
||||||
|
|
||||||
// Limit evaluation time (ensures this doesn't block the game)
|
// Limit evaluation time (ensures this doesn't block the game)
|
||||||
if (timer.hasExpired(MAX_EVAL_TIME))
|
if (timer.hasExpired(MAX_EVAL_TIME)) {
|
||||||
return std::nullopt;
|
delete currentState;
|
||||||
|
return std::make_pair(std::nullopt, depth);
|
||||||
|
}
|
||||||
|
|
||||||
// Limit depth (ensures we don't spend all eval time looking at a single branch.)
|
// Try moves from columns to foundation
|
||||||
// Note that it might be a good idea to switch to a BFS type search, instead of depth
|
for (int columnId = 0; columnId < currentState->m_columns.size(); ++columnId) {
|
||||||
// limiting DFS.
|
const auto& columnStack = currentState->m_columns[columnId];
|
||||||
if (depth > MAX_DEPTH)
|
|
||||||
return std::nullopt;
|
|
||||||
|
|
||||||
// Generate the current state hash
|
|
||||||
QString currentStateHash = generateStateHash();
|
|
||||||
|
|
||||||
// Check if the state has already been explored
|
|
||||||
if (visitedStates.contains(currentStateHash))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Mark the current state as visited
|
|
||||||
visitedStates.insert(currentStateHash);
|
|
||||||
|
|
||||||
// Simulate column moves to the foundation
|
|
||||||
for (int columnId = 0; columnId < m_columns.size(); ++columnId) {
|
|
||||||
const auto& columnStack = m_columns[columnId];
|
|
||||||
if (columnStack.isEmpty())
|
if (columnStack.isEmpty())
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const ColumnSlot* topSlot = columnStack.last();
|
const ColumnSlot* topSlot = columnStack.last();
|
||||||
for (int foundationId = 0; foundationId < m_foundation.size(); ++foundationId) {
|
for (int foundationId = 0; foundationId < currentState->m_foundation.size(); ++foundationId) {
|
||||||
if (!isFoundationMoveValid(*topSlot->card(), foundationId))
|
if (!currentState->isFoundationMoveValid(*topSlot->card(), foundationId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
GameState* clonedState = this->clone();
|
GameState* newState = currentState->clone();
|
||||||
assert(clonedState->moveColumnCardToFoundation(columnId, foundationId));
|
assert(newState->moveColumnCardToFoundation(columnId, foundationId));
|
||||||
assert(clonedState->generateStateHash() != generateStateHash());
|
QString stateHash = newState->generateStateHash();
|
||||||
|
assert(currentHash != stateHash);
|
||||||
|
|
||||||
auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1);
|
if (newState->m_gameWon) {
|
||||||
delete clonedState;
|
delete newState;
|
||||||
|
delete currentState;
|
||||||
|
return std::make_pair(true, depth + 1); // Return depth if game won
|
||||||
|
}
|
||||||
|
|
||||||
if (res.value_or(false))
|
if (!visitedStates.contains(stateHash)) {
|
||||||
return true;
|
visitedStates.insert(stateHash);
|
||||||
if (!res.has_value())
|
stateQueue.enqueue({newState, depth + 1});
|
||||||
return std::nullopt;
|
} else {
|
||||||
|
delete newState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate throwaway pile moves
|
// Try moves from throwaway pile to foundation/columns
|
||||||
if (!m_throwawayPile.isEmpty()) {
|
if (!currentState->m_throwawayPile.isEmpty()) {
|
||||||
const PlayingCard* topCard = m_throwawayPile.last();
|
const PlayingCard* topCard = currentState->m_throwawayPile.last();
|
||||||
|
|
||||||
// Move to foundation
|
// Move to foundation
|
||||||
for (int foundationId = 0; foundationId < m_foundation.size(); ++foundationId) {
|
for (int foundationId = 0; foundationId < currentState->m_foundation.size(); ++foundationId) {
|
||||||
if (!isFoundationMoveValid(*topCard, foundationId))
|
if (!currentState->isFoundationMoveValid(*topCard, foundationId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
GameState* clonedState = this->clone();
|
GameState* newState = currentState->clone();
|
||||||
assert(clonedState->moveThrownCardToFoundation(foundationId));
|
assert(newState->moveThrownCardToFoundation(foundationId));
|
||||||
assert(clonedState->generateStateHash() != generateStateHash());
|
QString stateHash = newState->generateStateHash();
|
||||||
|
assert(currentHash != stateHash);
|
||||||
|
|
||||||
auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1);
|
if (newState->m_gameWon) {
|
||||||
delete clonedState;
|
delete newState;
|
||||||
|
delete currentState;
|
||||||
|
return std::make_pair(true, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
if (res.value_or(false))
|
if (!visitedStates.contains(stateHash)) {
|
||||||
return true;
|
visitedStates.insert(stateHash);
|
||||||
if (!res.has_value())
|
stateQueue.enqueue({newState, depth + 1});
|
||||||
return std::nullopt;
|
} else {
|
||||||
|
delete newState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to columns
|
// Move to columns
|
||||||
for (int toColumnId = 0; toColumnId < m_columns.size(); ++toColumnId) {
|
for (int toColumnId = 0; toColumnId < currentState->m_columns.size(); ++toColumnId) {
|
||||||
if (!isColumnMoveValid(*topCard, toColumnId))
|
if (!currentState->isColumnMoveValid(*topCard, toColumnId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
GameState* clonedState = this->clone();
|
GameState* newState = currentState->clone();
|
||||||
assert(clonedState->moveThrownCardToColumn(toColumnId));
|
assert(newState->moveThrownCardToColumn(toColumnId));
|
||||||
assert(clonedState->generateStateHash() != generateStateHash());
|
QString stateHash = newState->generateStateHash();
|
||||||
|
assert(currentHash != stateHash);
|
||||||
|
|
||||||
auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1);
|
if (newState->m_gameWon) {
|
||||||
delete clonedState;
|
delete newState;
|
||||||
|
delete currentState;
|
||||||
|
return std::make_pair(true, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
if (res.value_or(false))
|
if (!visitedStates.contains(stateHash)) {
|
||||||
return true;
|
visitedStates.insert(stateHash);
|
||||||
if (!res.has_value())
|
stateQueue.enqueue({newState, depth + 1});
|
||||||
return std::nullopt;
|
} else {
|
||||||
|
delete newState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate draw pile move
|
// Try draw pile move
|
||||||
// (The condition also handles the case where there's only one card in the throwaway pile,
|
// (The condition also handles the case where there's only one card in the throwaway pile,
|
||||||
// which means drawing would just result in flipping and re-drawing the same card.)
|
// which means drawing would just result in flipping and re-drawing the same card.)
|
||||||
if (!(m_drawPile.isEmpty() && m_throwawayPile.size() <= 1)) {
|
if (!(currentState->m_drawPile.isEmpty() && currentState->m_throwawayPile.size() <= 1)) {
|
||||||
GameState* clonedState = this->clone();
|
GameState* newState = currentState->clone();
|
||||||
assert(clonedState->drawNextCard());
|
assert(newState->drawNextCard());
|
||||||
assert(clonedState->generateStateHash() != generateStateHash());
|
QString stateHash = newState->generateStateHash();
|
||||||
|
assert(currentHash != stateHash);
|
||||||
|
|
||||||
auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1);
|
if (newState->m_gameWon) {
|
||||||
delete clonedState;
|
delete newState;
|
||||||
|
delete currentState;
|
||||||
if (res.value_or(false))
|
return std::make_pair(true, depth + 1);
|
||||||
return true;
|
|
||||||
if (!res.has_value())
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate moves between columns
|
if (!visitedStates.contains(stateHash)) {
|
||||||
for (int fromColumnId = 0; fromColumnId < m_columns.size(); ++fromColumnId) {
|
visitedStates.insert(stateHash);
|
||||||
const auto& fromColumnStack = m_columns[fromColumnId];
|
stateQueue.enqueue({newState, depth + 1});
|
||||||
|
} else {
|
||||||
|
delete newState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try column-to-column moves
|
||||||
|
for (int fromColumnId = 0; fromColumnId < currentState->m_columns.size(); ++fromColumnId) {
|
||||||
|
const auto& fromColumnStack = currentState->m_columns[fromColumnId];
|
||||||
if (fromColumnStack.isEmpty())
|
if (fromColumnStack.isEmpty())
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
for (int toColumnId = 0; toColumnId < m_columns.size(); ++toColumnId) {
|
for (int toColumnId = 0; toColumnId < currentState->m_columns.size(); ++toColumnId) {
|
||||||
if (fromColumnId == toColumnId)
|
if (fromColumnId == toColumnId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Try all revealed cards in the column
|
|
||||||
for (int fromCardIndex = 0; fromCardIndex < fromColumnStack.size(); ++fromCardIndex) {
|
for (int fromCardIndex = 0; fromCardIndex < fromColumnStack.size(); ++fromCardIndex) {
|
||||||
const ColumnSlot* fromSlot = fromColumnStack[fromCardIndex];
|
const ColumnSlot* fromSlot = fromColumnStack[fromCardIndex];
|
||||||
if (!fromSlot->isRevealed())
|
if (!fromSlot->isRevealed())
|
||||||
continue;
|
continue;
|
||||||
if (!isColumnMoveValid(*fromSlot->card(), toColumnId))
|
if (!currentState->isColumnMoveValid(*fromSlot->card(), toColumnId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
GameState* clonedState = this->clone();
|
GameState* newState = currentState->clone();
|
||||||
assert(clonedState->moveColumnCardToColumn(fromColumnId, toColumnId, fromCardIndex));
|
assert(newState->moveColumnCardToColumn(fromColumnId, toColumnId, fromCardIndex));
|
||||||
assert(clonedState->generateStateHash() != generateStateHash());
|
QString stateHash = newState->generateStateHash();
|
||||||
|
assert(currentHash != stateHash);
|
||||||
|
|
||||||
auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1);
|
if (newState->m_gameWon) {
|
||||||
delete clonedState;
|
delete newState;
|
||||||
|
delete currentState;
|
||||||
|
return std::make_pair(true, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
if (res.value_or(false))
|
if (!visitedStates.contains(stateHash)) {
|
||||||
return true;
|
visitedStates.insert(stateHash);
|
||||||
if (!res.has_value())
|
stateQueue.enqueue({newState, depth + 1});
|
||||||
return std::nullopt;
|
} else {
|
||||||
|
delete newState;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No paths lead to a win
|
delete currentState; // Cleanup current state
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
return std::make_pair(false, lastDepth); // No solution
|
||||||
}
|
}
|
||||||
|
|
||||||
QVariantList GameState::drawPile() const {
|
QVariantList GameState::drawPile() const {
|
||||||
|
@ -797,7 +829,7 @@ bool GameState::gameWon() const {
|
||||||
return m_gameWon;
|
return m_gameWon;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<bool> GameState::isWinnable() const {
|
std::pair<std::optional<bool>, int> GameState::isWinnable() const {
|
||||||
qDebug() << "--- Simulating winning scenario ---";
|
qDebug() << "--- Simulating winning scenario ---";
|
||||||
QElapsedTimer timer;
|
QElapsedTimer timer;
|
||||||
|
|
||||||
|
@ -809,7 +841,7 @@ std::optional<bool> GameState::isWinnable() const {
|
||||||
|
|
||||||
timer.start();
|
timer.start();
|
||||||
QSet<QString> visitedStates;
|
QSet<QString> visitedStates;
|
||||||
std::optional<bool> res = canWinThroughSimulation(visitedStates, timer, 0);
|
std::pair<std::optional<bool>, int> res = canWinThroughSimulation(visitedStates, timer);
|
||||||
qint64 elapsedTime = timer.elapsed();
|
qint64 elapsedTime = timer.elapsed();
|
||||||
|
|
||||||
// Restore the original message handler
|
// Restore the original message handler
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
|
|
||||||
// Limits for checking winnability
|
// Limits for checking winnability
|
||||||
#define MAX_EVAL_TIME 100 // Evaluation time limit (ms)
|
#define MAX_EVAL_TIME 100 // Evaluation time limit (ms)
|
||||||
#define MAX_DEPTH 100 // Max moves into the future limit
|
|
||||||
|
|
||||||
class GameState : public QObject {
|
class GameState : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
@ -39,7 +38,7 @@ class GameState : public QObject {
|
||||||
Q_INVOKABLE void dealCards();
|
Q_INVOKABLE void dealCards();
|
||||||
Q_INVOKABLE void setupWinningDeck();
|
Q_INVOKABLE void setupWinningDeck();
|
||||||
Q_INVOKABLE bool drawNextCard();
|
Q_INVOKABLE bool drawNextCard();
|
||||||
Q_INVOKABLE std::optional<bool> isWinnable() const; // TODO: Implement as Q_PROPERTY instead
|
Q_INVOKABLE std::pair<std::optional<bool>, int> isWinnable() const; // TODO: Implement as Q_PROPERTY instead
|
||||||
|
|
||||||
// Manual moves (from X to Y)
|
// Manual moves (from X to Y)
|
||||||
Q_INVOKABLE bool moveThrownCardToColumn(int columnId);
|
Q_INVOKABLE bool moveThrownCardToColumn(int columnId);
|
||||||
|
@ -80,7 +79,7 @@ class GameState : public QObject {
|
||||||
|
|
||||||
void ensureColumnRevealed(int columnId);
|
void ensureColumnRevealed(int columnId);
|
||||||
|
|
||||||
std::optional<bool> canWinThroughSimulation(QSet<QString>& visitedStates, QElapsedTimer timer, int depth) const;
|
std::pair<std::optional<bool>, int> canWinThroughSimulation(QSet<QString>& visitedStates, QElapsedTimer timer) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // GAMESTATE_H
|
#endif // GAMESTATE_H
|
||||||
|
|
Loading…
Reference in a new issue