From b209fbc94bebc08a401f95f368c9874f2270a347 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 7 Dec 2024 20:52:09 +0100 Subject: [PATCH] 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. --- src/gamestate.cpp | 286 ++++++++++++++++++++++++++-------------------- src/gamestate.h | 5 +- 2 files changed, 161 insertions(+), 130 deletions(-) diff --git a/src/gamestate.cpp b/src/gamestate.cpp index 999d758..1ce3811 100644 --- a/src/gamestate.cpp +++ b/src/gamestate.cpp @@ -1,5 +1,6 @@ #include "gamestate.h" #include +#include #include #include #include @@ -616,149 +617,180 @@ void GameState::ensureColumnRevealed(int columnId) { qDebug() << "Revealed card " << col->card()->toString() << " in column " << columnId; } -std::optional GameState::canWinThroughSimulation(QSet& visitedStates, QElapsedTimer timer, int depth) const { - // Check if the game is already won +std::pair, int> GameState::canWinThroughSimulation(QSet& visitedStates, QElapsedTimer timer) const { if (m_gameWon) - return true; + return std::make_pair(true, 0); // Already won at depth 0 - // Limit evaluation time (ensures this doesn't block the game) - if (timer.hasExpired(MAX_EVAL_TIME)) - return std::nullopt; + // Go over all possible moves using BFS - // Limit depth (ensures we don't spend all eval time looking at a single branch.) - // Note that it might be a good idea to switch to a BFS type search, instead of depth - // limiting DFS. - if (depth > MAX_DEPTH) - return std::nullopt; + QQueue> stateQueue; // BFS queue (game state, depth) - // Generate the current state hash - QString currentStateHash = generateStateHash(); + // Clone the current state as the root of BFS + GameState* initialState = this->clone(); + stateQueue.enqueue({initialState, 0}); + visitedStates.insert(initialState->generateStateHash()); - // Check if the state has already been explored - if (visitedStates.contains(currentStateHash)) - return false; + int lastDepth = 0; + while (!stateQueue.isEmpty()) { + auto [currentState, depth] = stateQueue.dequeue(); + auto currentHash = currentState->generateStateHash(); + lastDepth = depth; - // 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()) - continue; - - const ColumnSlot* topSlot = columnStack.last(); - for (int foundationId = 0; foundationId < m_foundation.size(); ++foundationId) { - if (!isFoundationMoveValid(*topSlot->card(), foundationId)) - continue; - - GameState* clonedState = this->clone(); - assert(clonedState->moveColumnCardToFoundation(columnId, foundationId)); - assert(clonedState->generateStateHash() != generateStateHash()); - - auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1); - delete clonedState; - - if (res.value_or(false)) - return true; - if (!res.has_value()) - return std::nullopt; - } - } - - // Simulate throwaway pile moves - if (!m_throwawayPile.isEmpty()) { - const PlayingCard* topCard = m_throwawayPile.last(); - - // Move to foundation - for (int foundationId = 0; foundationId < m_foundation.size(); ++foundationId) { - if (!isFoundationMoveValid(*topCard, foundationId)) - continue; - - GameState* clonedState = this->clone(); - assert(clonedState->moveThrownCardToFoundation(foundationId)); - assert(clonedState->generateStateHash() != generateStateHash()); - - auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1); - delete clonedState; - - if (res.value_or(false)) - return true; - if (!res.has_value()) - return std::nullopt; + // Limit evaluation time (ensures this doesn't block the game) + if (timer.hasExpired(MAX_EVAL_TIME)) { + delete currentState; + return std::make_pair(std::nullopt, depth); } - // Move to columns - for (int toColumnId = 0; toColumnId < m_columns.size(); ++toColumnId) { - if (!isColumnMoveValid(*topCard, toColumnId)) + // Try moves from columns to foundation + for (int columnId = 0; columnId < currentState->m_columns.size(); ++columnId) { + const auto& columnStack = currentState->m_columns[columnId]; + if (columnStack.isEmpty()) continue; - GameState* clonedState = this->clone(); - assert(clonedState->moveThrownCardToColumn(toColumnId)); - assert(clonedState->generateStateHash() != generateStateHash()); - - auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1); - delete clonedState; - - if (res.value_or(false)) - return true; - if (!res.has_value()) - return std::nullopt; - } - } - - // Simulate draw pile move - // (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.) - if (!(m_drawPile.isEmpty() && m_throwawayPile.size() <= 1)) { - GameState* clonedState = this->clone(); - assert(clonedState->drawNextCard()); - assert(clonedState->generateStateHash() != generateStateHash()); - - auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1); - delete clonedState; - - if (res.value_or(false)) - return true; - if (!res.has_value()) - return std::nullopt; - } - - // Simulate moves between columns - for (int fromColumnId = 0; fromColumnId < m_columns.size(); ++fromColumnId) { - const auto& fromColumnStack = m_columns[fromColumnId]; - if (fromColumnStack.isEmpty()) - continue; - - for (int toColumnId = 0; toColumnId < m_columns.size(); ++toColumnId) { - if (fromColumnId == toColumnId) - continue; - - // Try all revealed cards in the column - for (int fromCardIndex = 0; fromCardIndex < fromColumnStack.size(); ++fromCardIndex) { - const ColumnSlot* fromSlot = fromColumnStack[fromCardIndex]; - if (!fromSlot->isRevealed()) - continue; - if (!isColumnMoveValid(*fromSlot->card(), toColumnId)) + const ColumnSlot* topSlot = columnStack.last(); + for (int foundationId = 0; foundationId < currentState->m_foundation.size(); ++foundationId) { + if (!currentState->isFoundationMoveValid(*topSlot->card(), foundationId)) continue; - GameState* clonedState = this->clone(); - assert(clonedState->moveColumnCardToColumn(fromColumnId, toColumnId, fromCardIndex)); - assert(clonedState->generateStateHash() != generateStateHash()); + GameState* newState = currentState->clone(); + assert(newState->moveColumnCardToFoundation(columnId, foundationId)); + QString stateHash = newState->generateStateHash(); + assert(currentHash != stateHash); - auto res = clonedState->canWinThroughSimulation(visitedStates, timer, depth + 1); - delete clonedState; + if (newState->m_gameWon) { + delete newState; + delete currentState; + return std::make_pair(true, depth + 1); // Return depth if game won + } - if (res.value_or(false)) - return true; - if (!res.has_value()) - return std::nullopt; + if (!visitedStates.contains(stateHash)) { + visitedStates.insert(stateHash); + stateQueue.enqueue({newState, depth + 1}); + } else { + delete newState; + } } } + + // Try moves from throwaway pile to foundation/columns + if (!currentState->m_throwawayPile.isEmpty()) { + const PlayingCard* topCard = currentState->m_throwawayPile.last(); + + // Move to foundation + for (int foundationId = 0; foundationId < currentState->m_foundation.size(); ++foundationId) { + if (!currentState->isFoundationMoveValid(*topCard, foundationId)) + continue; + + GameState* newState = currentState->clone(); + assert(newState->moveThrownCardToFoundation(foundationId)); + QString stateHash = newState->generateStateHash(); + assert(currentHash != stateHash); + + if (newState->m_gameWon) { + delete newState; + delete currentState; + return std::make_pair(true, depth + 1); + } + + if (!visitedStates.contains(stateHash)) { + visitedStates.insert(stateHash); + stateQueue.enqueue({newState, depth + 1}); + } else { + delete newState; + } + } + + // Move to columns + for (int toColumnId = 0; toColumnId < currentState->m_columns.size(); ++toColumnId) { + if (!currentState->isColumnMoveValid(*topCard, toColumnId)) + continue; + + GameState* newState = currentState->clone(); + assert(newState->moveThrownCardToColumn(toColumnId)); + QString stateHash = newState->generateStateHash(); + assert(currentHash != stateHash); + + if (newState->m_gameWon) { + delete newState; + delete currentState; + return std::make_pair(true, depth + 1); + } + + if (!visitedStates.contains(stateHash)) { + visitedStates.insert(stateHash); + stateQueue.enqueue({newState, depth + 1}); + } else { + delete newState; + } + } + } + + // Try draw pile move + // (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.) + if (!(currentState->m_drawPile.isEmpty() && currentState->m_throwawayPile.size() <= 1)) { + GameState* newState = currentState->clone(); + assert(newState->drawNextCard()); + QString stateHash = newState->generateStateHash(); + assert(currentHash != stateHash); + + if (newState->m_gameWon) { + delete newState; + delete currentState; + return std::make_pair(true, depth + 1); + } + + if (!visitedStates.contains(stateHash)) { + visitedStates.insert(stateHash); + 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()) + continue; + + for (int toColumnId = 0; toColumnId < currentState->m_columns.size(); ++toColumnId) { + if (fromColumnId == toColumnId) + continue; + + for (int fromCardIndex = 0; fromCardIndex < fromColumnStack.size(); ++fromCardIndex) { + const ColumnSlot* fromSlot = fromColumnStack[fromCardIndex]; + if (!fromSlot->isRevealed()) + continue; + if (!currentState->isColumnMoveValid(*fromSlot->card(), toColumnId)) + continue; + + GameState* newState = currentState->clone(); + assert(newState->moveColumnCardToColumn(fromColumnId, toColumnId, fromCardIndex)); + QString stateHash = newState->generateStateHash(); + assert(currentHash != stateHash); + + if (newState->m_gameWon) { + delete newState; + delete currentState; + return std::make_pair(true, depth + 1); + } + + if (!visitedStates.contains(stateHash)) { + visitedStates.insert(stateHash); + stateQueue.enqueue({newState, depth + 1}); + } else { + delete newState; + } + } + } + } + + delete currentState; // Cleanup current state } - // No paths lead to a win - return false; + return std::make_pair(false, lastDepth); // No solution } QVariantList GameState::drawPile() const { @@ -797,7 +829,7 @@ bool GameState::gameWon() const { return m_gameWon; } -std::optional GameState::isWinnable() const { +std::pair, int> GameState::isWinnable() const { qDebug() << "--- Simulating winning scenario ---"; QElapsedTimer timer; @@ -809,7 +841,7 @@ std::optional GameState::isWinnable() const { timer.start(); QSet visitedStates; - std::optional res = canWinThroughSimulation(visitedStates, timer, 0); + std::pair, int> res = canWinThroughSimulation(visitedStates, timer); qint64 elapsedTime = timer.elapsed(); // Restore the original message handler diff --git a/src/gamestate.h b/src/gamestate.h index b7daf1c..f5d746b 100644 --- a/src/gamestate.h +++ b/src/gamestate.h @@ -12,7 +12,6 @@ // Limits for checking winnability #define MAX_EVAL_TIME 100 // Evaluation time limit (ms) -#define MAX_DEPTH 100 // Max moves into the future limit class GameState : public QObject { Q_OBJECT @@ -39,7 +38,7 @@ class GameState : public QObject { Q_INVOKABLE void dealCards(); Q_INVOKABLE void setupWinningDeck(); Q_INVOKABLE bool drawNextCard(); - Q_INVOKABLE std::optional isWinnable() const; // TODO: Implement as Q_PROPERTY instead + Q_INVOKABLE std::pair, int> isWinnable() const; // TODO: Implement as Q_PROPERTY instead // Manual moves (from X to Y) Q_INVOKABLE bool moveThrownCardToColumn(int columnId); @@ -80,7 +79,7 @@ class GameState : public QObject { void ensureColumnRevealed(int columnId); - std::optional canWinThroughSimulation(QSet& visitedStates, QElapsedTimer timer, int depth) const; + std::pair, int> canWinThroughSimulation(QSet& visitedStates, QElapsedTimer timer) const; }; #endif // GAMESTATE_H