diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb596fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: Build & Test + +on: + pull_request: + branches: + - main + merge_group: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + macos-unit-tests: + name: macOS Build & Test + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run unit tests (macOS) + run: | + xcodebuild test \ + -project ComputerSolitaire.xcodeproj \ + -scheme ComputerSolitaire \ + -configuration Debug \ + -destination "platform=macOS" \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -resultBundlePath "$RUNNER_TEMP/ComputerSolitaireTests.xcresult" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + - name: Upload test result bundle on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: macos-test-results + path: ${{ runner.temp }}/ComputerSolitaireTests.xcresult + + ios-build: + name: iOS Build + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build app (iOS Simulator) + run: | + xcodebuild build \ + -project ComputerSolitaire.xcodeproj \ + -scheme ComputerSolitaire \ + -configuration Debug \ + -destination "generic/platform=iOS Simulator" \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO diff --git a/.gitignore b/.gitignore index f275d47..c854aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,9 @@ xcuserdata/ ## Visual Studio Code .vscode -## Build folder +## Build artifacts build/ +.build/ .derivedData/ ## macOS custom folder icon diff --git a/AGENTS.md b/AGENTS.md index dd26e08..91c691a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,31 @@ This file defines hard project constraints for any coding agent working in this - No speculative abstraction layers that make SwiftUI code harder to read. When in doubt, choose the simplest modern SwiftUI-first solution. + +## Build Workflow + +This command builds the macOS destination only. + +From repo root (`/Users/austinsmith/Developer/Repos/ComputerSolitaire`), run: + +```bash +/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild \ + -project ComputerSolitaire.xcodeproj \ + -scheme ComputerSolitaire \ + -configuration Debug \ + -destination 'generic/platform=macOS' \ + build +``` + +## Test Workflow + +From repo root (`/Users/austinsmith/Developer/Repos/ComputerSolitaire`), run: + +```bash +/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild \ + -project ComputerSolitaire.xcodeproj \ + -scheme ComputerSolitaire \ + -configuration Debug \ + -destination 'platform=macOS' \ + test +``` diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index e75f6c0..d588227 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -6,8 +6,19 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + 4B75C1EA2F1A233500761A12 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4B75C1C92F1A233400761A12 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4B75C1D02F1A233400761A12; + remoteInfo = ComputerSolitaire; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 4B75C1D12F1A233400761A12 /* Computer Solitaire.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Computer Solitaire.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ComputerSolitaireTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -29,6 +40,11 @@ path = ComputerSolitaire; sourceTree = ""; }; + 4B75C1E52F1A233500761A12 /* ComputerSolitaireTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ComputerSolitaireTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -39,6 +55,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1E62F1A233500761A12 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -46,6 +69,7 @@ isa = PBXGroup; children = ( 4B75C1D32F1A233400761A12 /* ComputerSolitaire */, + 4B75C1E52F1A233500761A12 /* ComputerSolitaireTests */, 4B75C1D22F1A233400761A12 /* Products */, ); sourceTree = ""; @@ -54,6 +78,7 @@ isa = PBXGroup; children = ( 4B75C1D12F1A233400761A12 /* Computer Solitaire.app */, + 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */, ); name = Products; sourceTree = ""; @@ -83,6 +108,29 @@ productReference = 4B75C1D12F1A233400761A12 /* Computer Solitaire.app */; productType = "com.apple.product-type.application"; }; + 4B75C1E92F1A233500761A12 /* ComputerSolitaireTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B75C1EC2F1A233500761A12 /* Build configuration list for PBXNativeTarget "ComputerSolitaireTests" */; + buildPhases = ( + 4B75C1E72F1A233500761A12 /* Sources */, + 4B75C1E62F1A233500761A12 /* Frameworks */, + 4B75C1E82F1A233500761A12 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4B75C1EB2F1A233500761A12 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4B75C1E52F1A233500761A12 /* ComputerSolitaireTests */, + ); + name = ComputerSolitaireTests; + packageProductDependencies = ( + ); + productName = ComputerSolitaireTests; + productReference = 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -96,6 +144,10 @@ 4B75C1D02F1A233400761A12 = { CreatedOnToolsVersion = 26.2; }; + 4B75C1E92F1A233500761A12 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 4B75C1D02F1A233400761A12; + }; }; }; buildConfigurationList = 4B75C1CC2F1A233400761A12 /* Build configuration list for PBXProject "ComputerSolitaire" */; @@ -113,6 +165,7 @@ projectRoot = ""; targets = ( 4B75C1D02F1A233400761A12 /* ComputerSolitaire */, + 4B75C1E92F1A233500761A12 /* ComputerSolitaireTests */, ); }; /* End PBXProject section */ @@ -125,6 +178,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1E82F1A233500761A12 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -135,8 +195,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1E72F1A233500761A12 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 4B75C1EB2F1A233500761A12 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4B75C1D02F1A233400761A12 /* ComputerSolitaire */; + targetProxy = 4B75C1EA2F1A233500761A12 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 4B75C1E02F1A233500761A12 /* Debug */ = { isa = XCBuildConfiguration; @@ -170,7 +245,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.4.1; + MARKETING_VERSION = 0.5.0; PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; @@ -220,7 +295,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.4.1; + MARKETING_VERSION = 0.5.0; PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; @@ -354,6 +429,42 @@ }; name = Release; }; + 4B75C1ED2F1A233500761A12 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T778QMSML9; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaireTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Computer Solitaire.app/Contents/MacOS/Computer Solitaire"; + }; + name = Debug; + }; + 4B75C1EE2F1A233500761A12 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T778QMSML9; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaireTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Computer Solitaire.app/Contents/MacOS/Computer Solitaire"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -375,6 +486,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4B75C1EC2F1A233500761A12 /* Build configuration list for PBXNativeTarget "ComputerSolitaireTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B75C1ED2F1A233500761A12 /* Debug */, + 4B75C1EE2F1A233500761A12 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 4B75C1C92F1A233400761A12 /* Project object */; diff --git a/ComputerSolitaire/Game/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/AutoMoveAdvisor.swift index cb95af7..083913e 100644 --- a/ComputerSolitaire/Game/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/AutoMoveAdvisor.swift @@ -13,6 +13,18 @@ enum AutoMoveAdvisor { )?.destination } + static func bestAdvisableDestination( + for selection: Selection, + in state: GameState, + stockDrawCount: Int + ) -> Destination? { + bestAdvisableMoveEvaluation( + for: selection, + in: state, + stockDrawCount: stockDrawCount + )?.destination + } + static func bestMoveEvaluation( for selection: Selection, in state: GameState, @@ -27,7 +39,27 @@ enum AutoMoveAdvisor { stockDrawCount: stockDrawCount, baselineMobility: baselineMobility, baselineFoundationCount: baselineFoundationCount, - baselineEmptyTableauCount: baselineEmptyTableauCount + baselineEmptyTableauCount: baselineEmptyTableauCount, + requireAdvisable: false + ) + } + + static func bestAdvisableMoveEvaluation( + for selection: Selection, + in state: GameState, + stockDrawCount: Int + ) -> MoveEvaluation? { + let baselineMobility = mobilityScore(in: state, stockDrawCount: stockDrawCount) + let baselineFoundationCount = totalFoundationCards(in: state) + let baselineEmptyTableauCount = countEmptyTableauPiles(in: state) + return bestEvaluation( + for: selection, + in: state, + stockDrawCount: stockDrawCount, + baselineMobility: baselineMobility, + baselineFoundationCount: baselineFoundationCount, + baselineEmptyTableauCount: baselineEmptyTableauCount, + requireAdvisable: true ) } @@ -65,6 +97,34 @@ enum AutoMoveAdvisor { return destinations } + + static func candidateSelections(in state: GameState) -> [Selection] { + var selections: [Selection] = [] + + if let topWasteCard = state.waste.last, state.wasteDrawCount > 0 { + selections.append(Selection(source: .waste, cards: [topWasteCard])) + } + + for foundationIndex in state.foundations.indices { + guard let topFoundationCard = state.foundations[foundationIndex].last else { continue } + selections.append( + Selection(source: .foundation(pile: foundationIndex), cards: [topFoundationCard]) + ) + } + + for pileIndex in state.tableau.indices { + let pile = state.tableau[pileIndex] + for cardIndex in pile.indices where pile[cardIndex].isFaceUp { + let cards = Array(pile[cardIndex...]) + guard isValidTableauSequence(cards) else { continue } + selections.append( + Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) + ) + } + } + + return selections + } } private extension AutoMoveAdvisor { @@ -74,12 +134,51 @@ private extension AutoMoveAdvisor { stockDrawCount: Int, baselineMobility: Int, baselineFoundationCount: Int, - baselineEmptyTableauCount: Int + baselineEmptyTableauCount: Int, + requireAdvisable: Bool ) -> MoveEvaluation? { + let evaluations = allEvaluations( + for: selection, + in: state, + stockDrawCount: stockDrawCount, + baselineMobility: baselineMobility, + baselineFoundationCount: baselineFoundationCount, + baselineEmptyTableauCount: baselineEmptyTableauCount + ) + let filteredEvaluations = requireAdvisable + ? evaluations.filter { + isAdvisableMove( + selection: selection, + evaluation: $0, + in: state, + stockDrawCount: stockDrawCount + ) + } + : evaluations + + guard var bestEvaluation = filteredEvaluations.first else { return nil } + for evaluation in filteredEvaluations.dropFirst() { + if MoveEvaluationRanking.isBetter(evaluation, than: bestEvaluation) { + bestEvaluation = evaluation + } + } + return bestEvaluation + } + + static func allEvaluations( + for selection: Selection, + in state: GameState, + stockDrawCount: Int, + baselineMobility: Int, + baselineFoundationCount: Int, + baselineEmptyTableauCount: Int + ) -> [MoveEvaluation] { let destinations = legalDestinations(for: selection, in: state) - guard !destinations.isEmpty else { return nil } + guard !destinations.isEmpty else { return [] } + + var evaluations: [MoveEvaluation] = [] + evaluations.reserveCapacity(destinations.count) - var bestEvaluation: MoveEvaluation? for destination in destinations { guard let nextState = simulatedState( afterMoving: selection, @@ -103,17 +202,304 @@ private extension AutoMoveAdvisor { resultingMobility: nextMobility, destinationPriority: destinationPriority(for: destination, in: state) ) + evaluations.append(evaluation) + } + + return evaluations + } + + static func isAdvisableMove( + selection: Selection, + evaluation: MoveEvaluation, + in state: GameState, + stockDrawCount: Int + ) -> Bool { + if case .foundation = selection.source, + case .foundation = evaluation.destination { + return false + } + + if hasImmediateForwardGain(evaluation) { + return true + } + + switch selection.source { + case .waste: + // Waste moves are resource-limited and usually unblock future draws. + return true + + case .foundation(let sourceFoundationIndex): + guard case .tableau(let destinationTableauIndex) = evaluation.destination else { + return false + } + return isFoundationRollbackAdvisable( + selection: selection, + rollbackEvaluation: evaluation, + sourceFoundationIndex: sourceFoundationIndex, + destinationTableauIndex: destinationTableauIndex, + in: state, + stockDrawCount: stockDrawCount + ) + + case .tableau: + break + } - if let currentBest = bestEvaluation { - if MoveEvaluationRanking.isBetter(evaluation, than: currentBest) { - bestEvaluation = evaluation + if case .tableau = evaluation.destination { + // Avoid neutral tableau reshuffles unless they strongly improve options. + return evaluation.mobilityDelta > 1 + } + + return evaluation.mobilityDelta > 0 + } + + static func isFoundationRollbackAdvisable( + selection: Selection, + rollbackEvaluation: MoveEvaluation, + sourceFoundationIndex: Int, + destinationTableauIndex: Int, + in state: GameState, + stockDrawCount: Int + ) -> Bool { + guard let movedCard = selection.cards.first else { return false } + guard let rollbackState = simulatedState( + afterMoving: selection, + to: rollbackEvaluation.destination, + in: state, + stockDrawCount: stockDrawCount + ) else { + return false + } + + let baseline = OpportunityBaseline( + foundationCount: totalFoundationCards(in: state), + emptyTableauCount: countEmptyTableauPiles(in: state), + mobility: mobilityScore(in: state, stockDrawCount: stockDrawCount) + ) + let rollbackPolicy = FoundationRollbackPolicy( + movedCardID: movedCard.id, + sourceFoundationIndex: sourceFoundationIndex, + destinationTableauIndex: destinationTableauIndex + ) + + let bestBefore = bestImmediateForwardOpportunity( + in: state, + relativeTo: baseline, + stockDrawCount: stockDrawCount, + rollbackPolicy: rollbackPolicy, + excludeMovedCardRefoundation: false + ) + let bestAfter = bestImmediateForwardOpportunity( + in: rollbackState, + relativeTo: baseline, + stockDrawCount: stockDrawCount, + rollbackPolicy: rollbackPolicy, + excludeMovedCardRefoundation: true + ) + + guard let bestAfter else { return false } + guard let bestBefore else { return true } + return ImmediateOpportunityRanking.isBetter(bestAfter, than: bestBefore) + } + + static func bestImmediateForwardOpportunity( + in state: GameState, + relativeTo baseline: OpportunityBaseline, + stockDrawCount: Int, + rollbackPolicy: FoundationRollbackPolicy, + excludeMovedCardRefoundation: Bool + ) -> ImmediateOpportunity? { + var opportunities: [ImmediateOpportunity] = [] + + for followUpSelection in candidateSelections(in: state) { + for followUpDestination in legalDestinations(for: followUpSelection, in: state) { + if excludeMovedCardRefoundation && isImmediateMovedCardRefoundation( + selection: followUpSelection, + destination: followUpDestination, + rollbackPolicy: rollbackPolicy + ) { + continue } - } else { - bestEvaluation = evaluation + guard followUpTouchesRollbackImpact( + selection: followUpSelection, + destination: followUpDestination, + rollbackPolicy: rollbackPolicy + ) else { + continue + } + guard let nextState = simulatedState( + afterMoving: followUpSelection, + to: followUpDestination, + in: state, + stockDrawCount: stockDrawCount + ) else { + continue + } + + let resultingMobility = mobilityScore(in: nextState, stockDrawCount: stockDrawCount) + let opportunity = ImmediateOpportunity( + selection: followUpSelection, + destination: followUpDestination, + revealsFaceDownCard: revealsFaceDownCard(selection: followUpSelection, in: state), + clearsSourcePile: clearsSourcePile(selection: followUpSelection, in: state), + netFoundationProgress: totalFoundationCards(in: nextState) - baseline.foundationCount, + mobilityDelta: resultingMobility - baseline.mobility, + netEmptyTableauGain: countEmptyTableauPiles(in: nextState) - baseline.emptyTableauCount, + destinationPriority: destinationPriority(for: followUpDestination, in: state), + resultingMobility: resultingMobility + ) + guard opportunity.isForwardProgress else { continue } + opportunities.append(opportunity) } } - return bestEvaluation + guard var bestOpportunity = opportunities.first else { return nil } + for opportunity in opportunities.dropFirst() { + if ImmediateOpportunityRanking.isBetter(opportunity, than: bestOpportunity) { + bestOpportunity = opportunity + } + } + return bestOpportunity + } + + static func isImmediateMovedCardRefoundation( + selection: Selection, + destination: Destination, + rollbackPolicy: FoundationRollbackPolicy + ) -> Bool { + guard case .tableau(let sourcePile, _) = selection.source, + sourcePile == rollbackPolicy.destinationTableauIndex else { + return false + } + guard selection.cards.count == 1, + selection.cards[0].id == rollbackPolicy.movedCardID else { + return false + } + guard case .foundation = destination else { + return false + } + return true + } + + static func followUpTouchesRollbackImpact( + selection: Selection, + destination: Destination, + rollbackPolicy: FoundationRollbackPolicy + ) -> Bool { + if selection.cards.contains(where: { $0.id == rollbackPolicy.movedCardID }) { + return true + } + + switch selection.source { + case .foundation(let pile): + if pile == rollbackPolicy.sourceFoundationIndex { + return true + } + case .tableau(let pile, _): + if pile == rollbackPolicy.destinationTableauIndex { + return true + } + case .waste: + break + } + + switch destination { + case .foundation(let pile): + return pile == rollbackPolicy.sourceFoundationIndex + case .tableau(let pile): + return pile == rollbackPolicy.destinationTableauIndex + } + } + + static func hasImmediateForwardGain(_ evaluation: MoveEvaluation) -> Bool { + evaluation.revealsFaceDownCard + || evaluation.foundationProgressDelta > 0 + || evaluation.emptyTableauDelta > 0 + } + + struct OpportunityBaseline { + let foundationCount: Int + let emptyTableauCount: Int + let mobility: Int + } + + struct FoundationRollbackPolicy { + let movedCardID: UUID + let sourceFoundationIndex: Int + let destinationTableauIndex: Int + } + + struct ImmediateOpportunity { + let selection: Selection + let destination: Destination + let revealsFaceDownCard: Bool + let clearsSourcePile: Bool + let netFoundationProgress: Int + let mobilityDelta: Int + let netEmptyTableauGain: Int + let destinationPriority: Int + let resultingMobility: Int + + var isForwardProgress: Bool { + revealsFaceDownCard || netFoundationProgress > 0 || netEmptyTableauGain > 0 + } + } + + enum ImmediateOpportunityRanking { + static func isBetter(_ lhs: ImmediateOpportunity, than rhs: ImmediateOpportunity) -> Bool { + if lhs.revealsFaceDownCard != rhs.revealsFaceDownCard { + return lhs.revealsFaceDownCard && !rhs.revealsFaceDownCard + } + if lhs.netFoundationProgress != rhs.netFoundationProgress { + return lhs.netFoundationProgress > rhs.netFoundationProgress + } + if lhs.mobilityDelta != rhs.mobilityDelta { + return lhs.mobilityDelta > rhs.mobilityDelta + } + if lhs.netEmptyTableauGain != rhs.netEmptyTableauGain { + return lhs.netEmptyTableauGain > rhs.netEmptyTableauGain + } + if lhs.clearsSourcePile != rhs.clearsSourcePile { + return lhs.clearsSourcePile && !rhs.clearsSourcePile + } + if lhs.destinationPriority != rhs.destinationPriority { + return lhs.destinationPriority > rhs.destinationPriority + } + if lhs.resultingMobility != rhs.resultingMobility { + return lhs.resultingMobility > rhs.resultingMobility + } + return tieBreakKey(lhs) < tieBreakKey(rhs) + } + + private static func tieBreakKey(_ opportunity: ImmediateOpportunity) -> String { + let sourceKey = sourceSortKey(opportunity.selection.source) + let destinationKey = destinationSortKey(opportunity.destination) + let cardIDs = opportunity.selection.cards + .map(\.id) + .map(\.uuidString) + .joined(separator: ",") + return "\(sourceKey)|\(destinationKey)|\(cardIDs)" + } + + private static func sourceSortKey(_ source: Selection.Source) -> Int { + switch source { + case .waste: + return 0 + case .foundation(let pile): + return 100 + pile + case .tableau(let pile, let index): + return 1000 + (pile * 100) + index + } + } + + private static func destinationSortKey(_ destination: Destination) -> Int { + switch destination { + case .foundation(let index): + return index + case .tableau(let index): + return 100 + index + } + } } static func totalFoundationCards(in state: GameState) -> Int { @@ -173,24 +559,7 @@ private extension AutoMoveAdvisor { static func mobilityScore(in state: GameState, stockDrawCount: Int) -> Int { var score = 0 - if let topWasteCard = state.waste.last, state.wasteDrawCount > 0 { - let wasteSelection = Selection(source: .waste, cards: [topWasteCard]) - score += legalDestinations(for: wasteSelection, in: state).count - } - - for pileIndex in state.tableau.indices { - let pile = state.tableau[pileIndex] - for cardIndex in pile.indices where pile[cardIndex].isFaceUp { - let cards = Array(pile[cardIndex...]) - guard isValidTableauSequence(cards) else { continue } - let selection = Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) - score += legalDestinations(for: selection, in: state).count - } - } - - for foundationIndex in state.foundations.indices { - guard let topCard = state.foundations[foundationIndex].last else { continue } - let selection = Selection(source: .foundation(pile: foundationIndex), cards: [topCard]) + for selection in candidateSelections(in: state) { score += legalDestinations(for: selection, in: state).count } diff --git a/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/GameSession.swift index 2e5ad98..6dc989e 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/GameSession.swift @@ -344,6 +344,14 @@ final class SolitaireViewModel { if tryMoveSelection(to: .foundation(index)) { return } + } else if let topCard = state.foundations[index].last { + let tappedSelection = Selection(source: .foundation(pile: index), cards: [topCard]) + if queueBestAutoMove( + for: tappedSelection, + playFailureFeedback: false + ) { + return + } } isDragging = false selectFromFoundation(index: index) @@ -707,14 +715,19 @@ private extension SolitaireViewModel { } @discardableResult - func queueBestAutoMove(for sourceSelection: Selection) -> Bool { + func queueBestAutoMove( + for sourceSelection: Selection, + playFailureFeedback: Bool = true + ) -> Bool { isDragging = false - guard let destination = AutoMoveAdvisor.bestDestination( + guard let destination = AutoMoveAdvisor.bestAdvisableDestination( for: sourceSelection, in: state, stockDrawCount: stockDrawCount ) else { - HapticManager.shared.play(.invalidDrop) + if playFailureFeedback { + HapticManager.shared.play(.invalidDrop) + } return false } diff --git a/ComputerSolitaire/Game/HintAdvisor.swift b/ComputerSolitaire/Game/HintAdvisor.swift index 7331d06..19fa7b6 100644 --- a/ComputerSolitaire/Game/HintAdvisor.swift +++ b/ComputerSolitaire/Game/HintAdvisor.swift @@ -26,8 +26,8 @@ enum HintAdvisor { static func bestHintMove(in state: GameState, stockDrawCount: Int) -> HintMove? { var bestChoice: (selection: Selection, evaluation: MoveEvaluation)? - for selection in candidateSelections(in: state) { - guard let evaluation = AutoMoveAdvisor.bestMoveEvaluation( + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + guard let evaluation = AutoMoveAdvisor.bestAdvisableMoveEvaluation( for: selection, in: state, stockDrawCount: stockDrawCount @@ -35,10 +35,6 @@ enum HintAdvisor { continue } - guard isActionableHintMove(selection: selection, evaluation: evaluation) else { - continue - } - if let currentBest = bestChoice { if MoveEvaluationRanking.isBetter(evaluation, than: currentBest.evaluation) { bestChoice = (selection: selection, evaluation: evaluation) @@ -57,38 +53,6 @@ enum HintAdvisor { } private extension HintAdvisor { - static func isActionableHintMove(selection: Selection, evaluation: MoveEvaluation) -> Bool { - if case .foundation = selection.source, - case .foundation = evaluation.destination { - // Avoid hinting foundation-to-foundation shuffles. - return false - } - - if evaluation.revealsFaceDownCard || evaluation.foundationProgressDelta > 0 || evaluation.emptyTableauDelta > 0 { - return true - } - - switch selection.source { - case .waste: - // Waste moves are resource-limited and usually unblock future draws. - return true - - case .foundation: - // Only suggest moving off foundations when it meaningfully expands options. - return evaluation.mobilityDelta > 0 - - case .tableau: - break - } - - if case .tableau = evaluation.destination { - // Avoid neutral tableau reshuffles unless they strongly improve options. - return evaluation.mobilityDelta > 1 - } - - return evaluation.mobilityDelta > 0 - } - static func canRevealPlayableMoveViaStockTap(in state: GameState, stockDrawCount: Int) -> Bool { var simulatedState = state let maxLookaheadSteps = stockTapLookaheadSteps(in: state, stockDrawCount: stockDrawCount) @@ -145,43 +109,4 @@ private extension HintAdvisor { nextState.wasteDrawCount = 0 return nextState } - - static func candidateSelections(in state: GameState) -> [Selection] { - var selections: [Selection] = [] - - if let topWasteCard = state.waste.last, state.wasteDrawCount > 0 { - selections.append(Selection(source: .waste, cards: [topWasteCard])) - } - - for foundationIndex in state.foundations.indices { - guard let topFoundationCard = state.foundations[foundationIndex].last else { continue } - selections.append( - Selection(source: .foundation(pile: foundationIndex), cards: [topFoundationCard]) - ) - } - - for pileIndex in state.tableau.indices { - let pile = state.tableau[pileIndex] - for cardIndex in pile.indices where pile[cardIndex].isFaceUp { - let cards = Array(pile[cardIndex...]) - guard isValidTableauSequence(cards) else { continue } - selections.append( - Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) - ) - } - } - - return selections - } - - static func isValidTableauSequence(_ cards: [Card]) -> Bool { - guard cards.count > 1 else { return true } - for index in 0..<(cards.count - 1) { - let upper = cards[index] - let lower = cards[index + 1] - guard upper.suit.isRed != lower.suit.isRed else { return false } - guard upper.rank.rawValue == lower.rank.rawValue + 1 else { return false } - } - return true - } } diff --git a/ComputerSolitaireTests/HintAdvisabilityTests.swift b/ComputerSolitaireTests/HintAdvisabilityTests.swift new file mode 100644 index 0000000..08fd098 --- /dev/null +++ b/ComputerSolitaireTests/HintAdvisabilityTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class HintAdvisabilityTests: XCTestCase { + func testAceOnTwoRollbackIsNotAdvisableWithoutNewOpportunity() { + let aceSpades = makeCard(.spades, .ace, isFaceUp: true) + let twoHearts = makeCard(.hearts, .two, isFaceUp: true) + + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[aceSpades], [], [], []], + tableau: [[twoHearts], [], [], [], [], [], []] + ) + let selection = Selection(source: .foundation(pile: 0), cards: [aceSpades]) + + XCTAssertNil( + AutoMoveAdvisor.bestAdvisableDestination( + for: selection, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + ) + } + + func testFoundationRollbackIsAdvisableWhenItUnlocksImmediateReveal() { + let aceSpades = makeCard(.spades, .ace, isFaceUp: true) + let twoSpades = makeCard(.spades, .two, isFaceUp: true) + let threeSpades = makeCard(.spades, .three, isFaceUp: true) + let fourSpades = makeCard(.spades, .four, isFaceUp: true) + let fiveSpades = makeCard(.spades, .five, isFaceUp: true) + let sixSpades = makeCard(.spades, .six, isFaceUp: true) + let sevenHearts = makeCard(.hearts, .seven, isFaceUp: true) + let kingClubsFaceDown = makeCard(.clubs, .king, isFaceUp: false) + let fiveDiamonds = makeCard(.diamonds, .five, isFaceUp: true) + + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[aceSpades, twoSpades, threeSpades, fourSpades, fiveSpades, sixSpades], [], [], []], + tableau: [[sevenHearts], [kingClubsFaceDown, fiveDiamonds], [], [], [], [], []] + ) + let selection = Selection(source: .foundation(pile: 0), cards: [sixSpades]) + + XCTAssertEqual( + AutoMoveAdvisor.bestAdvisableDestination( + for: selection, + in: state, + stockDrawCount: DrawMode.three.rawValue + ), + .tableau(0) + ) + } + + func testUnrelatedExistingRevealDoesNotJustifyFoundationRollback() { + let aceSpades = makeCard(.spades, .ace, isFaceUp: true) + let twoHearts = makeCard(.hearts, .two, isFaceUp: true) + let queenSpadesFaceDown = makeCard(.spades, .queen, isFaceUp: false) + let nineClubs = makeCard(.clubs, .nine, isFaceUp: true) + let tenDiamonds = makeCard(.diamonds, .ten, isFaceUp: true) + + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[aceSpades], [], [], []], + tableau: [[twoHearts], [queenSpadesFaceDown, nineClubs], [tenDiamonds], [], [], [], []] + ) + let selection = Selection(source: .foundation(pile: 0), cards: [aceSpades]) + + XCTAssertNil( + AutoMoveAdvisor.bestAdvisableDestination( + for: selection, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + ) + } + + func testBestHintMoveSelectionIsDeterministicAcrossCalls() { + let fiveHearts = makeCard(.hearts, .five, isFaceUp: true) + let sixClubs = makeCard(.clubs, .six, isFaceUp: true) + let sixSpades = makeCard(.spades, .six, isFaceUp: true) + + let state = GameState( + stock: [], + waste: [fiveHearts], + wasteDrawCount: 1, + foundations: [[], [], [], []], + tableau: [[sixClubs], [sixSpades], [], [], [], [], []] + ) + let stockDrawCount = DrawMode.three.rawValue + + let first = HintAdvisor.bestHintMove(in: state, stockDrawCount: stockDrawCount) + XCTAssertNotNil(first) + for _ in 0..<20 { + XCTAssertEqual(HintAdvisor.bestHintMove(in: state, stockDrawCount: stockDrawCount), first) + } + } + + func testHintEvaluationPerformanceSmokeTest() { + let aceSpades = makeCard(.spades, .ace, isFaceUp: true) + let twoSpades = makeCard(.spades, .two, isFaceUp: true) + let threeSpades = makeCard(.spades, .three, isFaceUp: true) + let fourSpades = makeCard(.spades, .four, isFaceUp: true) + let fiveSpades = makeCard(.spades, .five, isFaceUp: true) + let sixSpades = makeCard(.spades, .six, isFaceUp: true) + let sevenHearts = makeCard(.hearts, .seven, isFaceUp: true) + let queenClubsFaceDown = makeCard(.clubs, .queen, isFaceUp: false) + let jackDiamonds = makeCard(.diamonds, .jack, isFaceUp: true) + let tenClubs = makeCard(.clubs, .ten, isFaceUp: true) + let nineHearts = makeCard(.hearts, .nine, isFaceUp: true) + + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[aceSpades, twoSpades, threeSpades, fourSpades, fiveSpades, sixSpades], [], [], []], + tableau: [ + [sevenHearts], + [queenClubsFaceDown, jackDiamonds, tenClubs, nineHearts], + [], + [], + [], + [], + [] + ] + ) + + let start = CFAbsoluteTimeGetCurrent() + for _ in 0..<400 { + _ = HintAdvisor.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) + } + let elapsed = CFAbsoluteTimeGetCurrent() - start + + // Wide threshold to catch pathological regressions without being flaky. + XCTAssertLessThan(elapsed, 3.0) + } + + private func makeCard(_ suit: Suit, _ rank: Rank, isFaceUp: Bool) -> Card { + Card(suit: suit, rank: rank, isFaceUp: isFaceUp) + } +}