From 73b24c6fff533e954fb2328db3f0833dc394144b Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sat, 21 Feb 2026 11:53:50 -0800 Subject: [PATCH 1/6] Refactor auto-move/hint logic and add build workflow Consolidate and enhance move evaluation logic in AutoMoveAdvisor: add candidateSelections, allEvaluations, bestAdvisableMoveEvaluation/bestAdvisableDestination, isAdvisableMove and helper heuristics (immediate gains, foundation unlock checks). Change mobility scoring to use candidateSelections. Simplify HintAdvisor to rely on AutoMoveAdvisor (remove duplicated selection and filtering code). Update GameSession to attempt advisable auto-moves from tapped foundations and make queueBestAutoMove accept an optional playFailureFeedback flag. Also add a macOS build workflow snippet to AGENTS.md. --- AGENTS.md | 15 ++ ComputerSolitaire/Game/AutoMoveAdvisor.swift | 257 +++++++++++++++++-- ComputerSolitaire/Game/GameSession.swift | 19 +- ComputerSolitaire/Game/HintAdvisor.swift | 79 +----- 4 files changed, 262 insertions(+), 108 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd26e08..e7ccd96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,18 @@ 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 +``` diff --git a/ComputerSolitaire/Game/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/AutoMoveAdvisor.swift index cb95af7..da26db3 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,136 @@ 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 - if let currentBest = bestEvaluation { - if MoveEvaluationRanking.isBetter(evaluation, than: currentBest) { - bestEvaluation = evaluation + case .foundation(let sourceFoundationIndex): + guard case .tableau(let destinationTableauIndex) = evaluation.destination else { + return false + } + guard let movedCard = selection.cards.first else { return false } + return foundationMoveUnlocksForwardFollowUp( + movedCardID: movedCard.id, + sourceFoundationIndex: sourceFoundationIndex, + destinationTableauIndex: destinationTableauIndex, + afterApplying: selection, + using: evaluation.destination, + in: state, + stockDrawCount: stockDrawCount + ) + + 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 foundationMoveUnlocksForwardFollowUp( + movedCardID: UUID, + sourceFoundationIndex: Int, + destinationTableauIndex: Int, + afterApplying selection: Selection, + using destination: Destination, + in state: GameState, + stockDrawCount: Int + ) -> Bool { + guard let nextState = simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: stockDrawCount + ) else { + return false + } + + let baselineMobility = mobilityScore(in: nextState, stockDrawCount: stockDrawCount) + let baselineFoundationCount = totalFoundationCards(in: nextState) + let baselineEmptyTableauCount = countEmptyTableauPiles(in: nextState) + + for followUpSelection in candidateSelections(in: nextState) { + let followUpEvaluations = allEvaluations( + for: followUpSelection, + in: nextState, + stockDrawCount: stockDrawCount, + baselineMobility: baselineMobility, + baselineFoundationCount: baselineFoundationCount, + baselineEmptyTableauCount: baselineEmptyTableauCount + ) + + for followUpEvaluation in followUpEvaluations { + if isImmediateFoundationReversal( + followUpSelection: followUpSelection, + followUpEvaluation: followUpEvaluation, + movedCardID: movedCardID, + sourceFoundationIndex: sourceFoundationIndex, + destinationTableauIndex: destinationTableauIndex + ) { + continue + } + + if hasImmediateForwardGain(followUpEvaluation) { + return true } - } else { - bestEvaluation = evaluation } } - return bestEvaluation + return false + } + + static func isImmediateFoundationReversal( + followUpSelection: Selection, + followUpEvaluation: MoveEvaluation, + movedCardID: UUID, + sourceFoundationIndex: Int, + destinationTableauIndex: Int + ) -> Bool { + guard case .tableau(let sourcePile, _) = followUpSelection.source, + sourcePile == destinationTableauIndex else { + return false + } + guard followUpSelection.cards.count == 1, + followUpSelection.cards[0].id == movedCardID else { + return false + } + guard case .foundation(let destinationFoundationIndex) = followUpEvaluation.destination else { + return false + } + return destinationFoundationIndex == sourceFoundationIndex + } + + static func hasImmediateForwardGain(_ evaluation: MoveEvaluation) -> Bool { + evaluation.revealsFaceDownCard + || evaluation.foundationProgressDelta > 0 + || evaluation.emptyTableauDelta > 0 } static func totalFoundationCards(in state: GameState) -> Int { @@ -173,24 +391,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 - } } From e091f7cac5b0fef551c19e8700b3e162c7e5c140 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sat, 21 Feb 2026 13:14:48 -0800 Subject: [PATCH 2/6] Add test target and refactor AutoMoveAdvisor Add a new ComputerSolitaireTests target and test suite, update project files, and refactor advisor logic. Updates include: adding ComputerSolitaireTests.xctest target to the Xcode project, adding HintAdvisabilityTests.swift with multiple unit and performance tests, and adding a Test Workflow snippet to AGENTS.md. AutoMoveAdvisor.swift was refactored: foundationMoveUnlocksForwardFollowUp was renamed and replaced by isFoundationRollbackAdvisable and decomposed into smaller helpers (OpportunityBaseline, FoundationRollbackPolicy, ImmediateOpportunity and ranking logic), plus new evaluation and selection logic for assessing rollback/advisability. Also update .gitignore (build/ -> .build/). --- .gitignore | 2 +- AGENTS.md | 13 + ComputerSolitaire.xcodeproj/project.pbxproj | 120 ++++++++ ComputerSolitaire/Game/AutoMoveAdvisor.swift | 260 ++++++++++++++---- .../HintAdvisabilityTests.swift | 146 ++++++++++ 5 files changed, 494 insertions(+), 47 deletions(-) create mode 100644 ComputerSolitaireTests/HintAdvisabilityTests.swift diff --git a/.gitignore b/.gitignore index f275d47..47799a1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ xcuserdata/ .vscode ## Build folder -build/ +.build/ .derivedData/ ## macOS custom folder icon diff --git a/AGENTS.md b/AGENTS.md index e7ccd96..91c691a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,3 +58,16 @@ From repo root (`/Users/austinsmith/Developer/Repos/ComputerSolitaire`), run: -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..ce7edbf 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* 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 +30,11 @@ path = ComputerSolitaire; sourceTree = ""; }; + 4B75C1E52F1A233500761A12 /* ComputerSolitaireTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ComputerSolitaireTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -39,6 +45,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4B75C1E62F1A233500761A12 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -46,6 +59,7 @@ isa = PBXGroup; children = ( 4B75C1D32F1A233400761A12 /* ComputerSolitaire */, + 4B75C1E52F1A233500761A12 /* ComputerSolitaireTests */, 4B75C1D22F1A233400761A12 /* Products */, ); sourceTree = ""; @@ -54,12 +68,23 @@ isa = PBXGroup; children = ( 4B75C1D12F1A233400761A12 /* Computer Solitaire.app */, + 4B75C1E42F1A233500761A12 /* ComputerSolitaireTests.xctest */, ); name = Products; sourceTree = ""; }; /* End PBXGroup section */ +/* Begin PBXContainerItemProxy section */ + 4B75C1EA2F1A233500761A12 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4B75C1C92F1A233400761A12 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4B75C1D02F1A233400761A12; + remoteInfo = ComputerSolitaire; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXNativeTarget section */ 4B75C1D02F1A233400761A12 /* ComputerSolitaire */ = { isa = PBXNativeTarget; @@ -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; @@ -238,6 +313,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; + }; 4B75C1E22F1A233500761A12 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -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 da26db3..083913e 100644 --- a/ComputerSolitaire/Game/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/AutoMoveAdvisor.swift @@ -232,13 +232,11 @@ private extension AutoMoveAdvisor { guard case .tableau(let destinationTableauIndex) = evaluation.destination else { return false } - guard let movedCard = selection.cards.first else { return false } - return foundationMoveUnlocksForwardFollowUp( - movedCardID: movedCard.id, + return isFoundationRollbackAdvisable( + selection: selection, + rollbackEvaluation: evaluation, sourceFoundationIndex: sourceFoundationIndex, destinationTableauIndex: destinationTableauIndex, - afterApplying: selection, - using: evaluation.destination, in: state, stockDrawCount: stockDrawCount ) @@ -255,77 +253,162 @@ private extension AutoMoveAdvisor { return evaluation.mobilityDelta > 0 } - static func foundationMoveUnlocksForwardFollowUp( - movedCardID: UUID, + static func isFoundationRollbackAdvisable( + selection: Selection, + rollbackEvaluation: MoveEvaluation, sourceFoundationIndex: Int, destinationTableauIndex: Int, - afterApplying selection: Selection, - using destination: Destination, in state: GameState, stockDrawCount: Int ) -> Bool { - guard let nextState = simulatedState( + guard let movedCard = selection.cards.first else { return false } + guard let rollbackState = simulatedState( afterMoving: selection, - to: destination, + to: rollbackEvaluation.destination, in: state, stockDrawCount: stockDrawCount ) else { return false } - let baselineMobility = mobilityScore(in: nextState, stockDrawCount: stockDrawCount) - let baselineFoundationCount = totalFoundationCards(in: nextState) - let baselineEmptyTableauCount = countEmptyTableauPiles(in: nextState) + 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 + ) - for followUpSelection in candidateSelections(in: nextState) { - let followUpEvaluations = allEvaluations( - for: followUpSelection, - in: nextState, - stockDrawCount: stockDrawCount, - baselineMobility: baselineMobility, - baselineFoundationCount: baselineFoundationCount, - baselineEmptyTableauCount: baselineEmptyTableauCount - ) + 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 + ) - for followUpEvaluation in followUpEvaluations { - if isImmediateFoundationReversal( - followUpSelection: followUpSelection, - followUpEvaluation: followUpEvaluation, - movedCardID: movedCardID, - sourceFoundationIndex: sourceFoundationIndex, - destinationTableauIndex: destinationTableauIndex + 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 } - - if hasImmediateForwardGain(followUpEvaluation) { - return true + 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 false + 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 isImmediateFoundationReversal( - followUpSelection: Selection, - followUpEvaluation: MoveEvaluation, - movedCardID: UUID, - sourceFoundationIndex: Int, - destinationTableauIndex: Int + static func isImmediateMovedCardRefoundation( + selection: Selection, + destination: Destination, + rollbackPolicy: FoundationRollbackPolicy ) -> Bool { - guard case .tableau(let sourcePile, _) = followUpSelection.source, - sourcePile == destinationTableauIndex else { + guard case .tableau(let sourcePile, _) = selection.source, + sourcePile == rollbackPolicy.destinationTableauIndex else { return false } - guard followUpSelection.cards.count == 1, - followUpSelection.cards[0].id == movedCardID else { + guard selection.cards.count == 1, + selection.cards[0].id == rollbackPolicy.movedCardID else { return false } - guard case .foundation(let destinationFoundationIndex) = followUpEvaluation.destination else { + guard case .foundation = destination else { return false } - return destinationFoundationIndex == sourceFoundationIndex + 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 { @@ -334,6 +417,91 @@ private extension AutoMoveAdvisor { || 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 { state.foundations.reduce(0) { partialResult, foundation in partialResult + foundation.count 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) + } +} From 5f8276ed6049ef3114c30563a910a7ecd88f3af2 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sat, 21 Feb 2026 13:40:56 -0800 Subject: [PATCH 3/6] Add macOS CI workflow for unit tests Add .github/workflows/ci.yml to run macOS unit tests on pull requests and merge groups. Introduces a macOS job (runs-on: macos-26) that checks out the repo, runs xcodebuild test for ComputerSolitaire (Debug configuration, platform=macOS) with code signing disabled, and uploads the test result bundle on failure. Also configures minimal permissions and concurrency to cancel in-progress runs for the same workflow/ref. --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ddfb6e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +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 Unit Tests + 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 From 7176ea65e4643d217440678162c9bf459dc948e4 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sat, 21 Feb 2026 13:51:35 -0800 Subject: [PATCH 4/6] Rename workflow to 'Build & Test' Update the GitHub Actions workflow name in .github/workflows/ci.yml from 'CI' to 'Build & Test' to make the purpose more explicit in the Actions UI. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddfb6e1..14c6258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Build & Test on: pull_request: From 8d599b680919394580826b620b5e9344887a3aad Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sat, 21 Feb 2026 13:55:43 -0800 Subject: [PATCH 5/6] Add iOS build job Rename macOS job to "macOS Build & Test" and add a new iOS Build job to the GitHub Actions workflow that runs xcodebuild for the iOS Simulator (code signing disabled). Update the Xcode project file: insert a PBXContainerItemProxy section, move/re-add the test XCBuildConfiguration blocks, and bump MARKETING_VERSION from 0.4.1 to 0.5.0 for the relevant targets. These changes enable basic iOS CI builds and update the project metadata. --- .github/workflows/ci.yml | 21 ++++- ComputerSolitaire.xcodeproj/project.pbxproj | 96 ++++++++++----------- 2 files changed, 68 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c6258..eb596fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: macos-unit-tests: - name: macOS Unit Tests + name: macOS Build & Test runs-on: macos-26 steps: @@ -40,3 +40,22 @@ jobs: 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/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index ce7edbf..d588227 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -6,6 +6,16 @@ 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; }; @@ -75,16 +85,6 @@ }; /* End PBXGroup section */ -/* Begin PBXContainerItemProxy section */ - 4B75C1EA2F1A233500761A12 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 4B75C1C92F1A233400761A12 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 4B75C1D02F1A233400761A12; - remoteInfo = ComputerSolitaire; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXNativeTarget section */ 4B75C1D02F1A233400761A12 /* ComputerSolitaire */ = { isa = PBXNativeTarget; @@ -245,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; @@ -295,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; @@ -313,42 +313,6 @@ }; 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; - }; 4B75C1E22F1A233500761A12 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -465,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 */ From a566d748fa1f9c9ad47e3306f83101943f1e46cd Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sat, 21 Feb 2026 14:08:44 -0800 Subject: [PATCH 6/6] Ignore "build" folder --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 47799a1..c854aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,8 @@ xcuserdata/ ## Visual Studio Code .vscode -## Build folder +## Build artifacts +build/ .build/ .derivedData/