diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index d5c429e..4abcade 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -218,6 +218,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = ComputerSolitaireAppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = T778QMSML9; @@ -245,8 +246,8 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.6.0; - PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaire; + MARKETING_VERSION = 0.7.0; + PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; SDKROOT = auto; @@ -268,6 +269,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = ComputerSolitaireAppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = T778QMSML9; @@ -295,8 +297,8 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.6.0; - PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaire; + MARKETING_VERSION = 0.7.0; + PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; SDKROOT = auto; diff --git a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/cards.png b/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/cards.png new file mode 100644 index 0000000..2a78c6a Binary files /dev/null and b/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/cards.png differ diff --git a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/computer-solitaire.png b/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/computer-solitaire.png deleted file mode 100644 index dcd7ac9..0000000 Binary files a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/Assets/computer-solitaire.png and /dev/null differ diff --git a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/icon.json b/ComputerSolitaire/ComputerSolitaireAppIcon.icon/icon.json index 7d78004..57a15d5 100644 --- a/ComputerSolitaire/ComputerSolitaireAppIcon.icon/icon.json +++ b/ComputerSolitaire/ComputerSolitaireAppIcon.icon/icon.json @@ -1,6 +1,6 @@ { "fill" : { - "automatic-gradient" : "display-p3:0.45728,0.63330,0.83252,1.00000", + "automatic-gradient" : "display-p3:0.18250,0.41357,0.66016,1.00000", "orientation" : { "start" : { "x" : 0.5, @@ -17,10 +17,11 @@ "layers" : [ { "glass" : false, - "image-name" : "computer-solitaire.png", - "name" : "computer-solitaire", + "hidden" : false, + "image-name" : "cards.png", + "name" : "cards", "position" : { - "scale" : 0.6, + "scale" : 1, "translation-in-points" : [ 0, 0 diff --git a/ComputerSolitaire/Game/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/AutoMoveAdvisor.swift deleted file mode 100644 index 083913e..0000000 --- a/ComputerSolitaire/Game/AutoMoveAdvisor.swift +++ /dev/null @@ -1,643 +0,0 @@ -import Foundation - -enum AutoMoveAdvisor { - static func bestDestination( - for selection: Selection, - in state: GameState, - stockDrawCount: Int - ) -> Destination? { - bestMoveEvaluation( - for: selection, - in: state, - stockDrawCount: stockDrawCount - )?.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, - 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: 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 - ) - } - - static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { - guard selectionMatchesState(selection, in: state) else { return [] } - guard let movingCard = selection.cards.first else { return [] } - - var destinations: [Destination] = [] - - if selection.cards.count == 1 { - for foundationIndex in state.foundations.indices { - let foundation = state.foundations[foundationIndex] - if GameRules.canMoveToFoundation(card: movingCard, foundation: foundation) { - destinations.append(.foundation(foundationIndex)) - } - } - } - - for tableauIndex in state.tableau.indices { - if case .tableau(let sourcePile, _) = selection.source, sourcePile == tableauIndex { - continue - } - let tableauPile = state.tableau[tableauIndex] - if GameRules.canMoveToTableau(card: movingCard, destinationPile: tableauPile) { - if isRedundantEmptyColumnTransfer( - selection: selection, - destinationTableauIndex: tableauIndex, - in: state - ) { - continue - } - destinations.append(.tableau(tableauIndex)) - } - } - - 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 { - static func bestEvaluation( - for selection: Selection, - in state: GameState, - stockDrawCount: Int, - baselineMobility: Int, - baselineFoundationCount: 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 [] } - - var evaluations: [MoveEvaluation] = [] - evaluations.reserveCapacity(destinations.count) - - for destination in destinations { - guard let nextState = simulatedState( - afterMoving: selection, - to: destination, - in: state, - stockDrawCount: stockDrawCount - ) else { - continue - } - - let nextMobility = mobilityScore(in: nextState, stockDrawCount: stockDrawCount) - let nextFoundationCount = totalFoundationCards(in: nextState) - let nextEmptyTableauCount = countEmptyTableauPiles(in: nextState) - let evaluation = MoveEvaluation( - destination: destination, - revealsFaceDownCard: revealsFaceDownCard(selection: selection, in: state), - clearsSourcePile: clearsSourcePile(selection: selection, in: state), - emptyTableauDelta: nextEmptyTableauCount - baselineEmptyTableauCount, - foundationProgressDelta: nextFoundationCount - baselineFoundationCount, - mobilityDelta: nextMobility - baselineMobility, - 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 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 - } - 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) - } - } - - 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 { - state.foundations.reduce(0) { partialResult, foundation in - partialResult + foundation.count - } - } - - static func countEmptyTableauPiles(in state: GameState) -> Int { - state.tableau.reduce(0) { partialResult, pile in - partialResult + (pile.isEmpty ? 1 : 0) - } - } - - static func destinationPriority(for destination: Destination, in state: GameState) -> Int { - switch destination { - case .tableau(let index): - return state.tableau[index].isEmpty ? 0 : 2 - case .foundation: - return 1 - } - } - - static func revealsFaceDownCard(selection: Selection, in state: GameState) -> Bool { - guard case .tableau(let pile, let index) = selection.source else { return false } - guard index > 0 else { return false } - return !state.tableau[pile][index - 1].isFaceUp - } - - static func clearsSourcePile(selection: Selection, in state: GameState) -> Bool { - guard case .tableau(let pile, let index) = selection.source else { return false } - let sourcePile = state.tableau[pile] - return index == 0 && !sourcePile.isEmpty - } - - static func isRedundantEmptyColumnTransfer( - selection: Selection, - destinationTableauIndex: Int, - in state: GameState - ) -> Bool { - guard case .tableau(let sourcePile, let sourceIndex) = selection.source else { return false } - guard sourcePile != destinationTableauIndex else { return false } - guard state.tableau.indices.contains(sourcePile), - state.tableau.indices.contains(destinationTableauIndex) else { return false } - guard state.tableau[destinationTableauIndex].isEmpty else { return false } - guard sourceIndex == 0 else { return false } - - let sourceCards = state.tableau[sourcePile] - guard selection.cards.count == sourceCards.count else { return false } - guard let movingCard = selection.cards.first else { return false } - - // Moving an entire king-led tableau stack to another empty column is a no-op - // for advisor quality purposes (manual play can still do this). - return movingCard.rank == .king - } - - static func mobilityScore(in state: GameState, stockDrawCount: Int) -> Int { - var score = 0 - - for selection in candidateSelections(in: state) { - score += legalDestinations(for: selection, in: state).count - } - - return score - } - - 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 - } - - static func simulatedState( - afterMoving selection: Selection, - to destination: Destination, - in state: GameState, - stockDrawCount: Int - ) -> GameState? { - guard selectionMatchesState(selection, in: state) else { return nil } - guard legalDestinations(for: selection, in: state).contains(destination) else { return nil } - - var nextState = state - - switch selection.source { - case .waste: - _ = nextState.waste.popLast() - if stockDrawCount == DrawMode.one.rawValue { - nextState.wasteDrawCount = min(1, nextState.waste.count) - } else { - nextState.wasteDrawCount = max(0, nextState.wasteDrawCount - 1) - } - case .foundation(let pile): - _ = nextState.foundations[pile].popLast() - case .tableau(let pile, let index): - nextState.tableau[pile].removeSubrange(index.. Bool { - guard !selection.cards.isEmpty else { return false } - - switch selection.source { - case .waste: - guard selection.cards.count == 1, let topWaste = state.waste.last else { return false } - return topWaste.id == selection.cards[0].id - - case .foundation(let pile): - guard selection.cards.count == 1 else { return false } - guard state.foundations.indices.contains(pile), - let topFoundation = state.foundations[pile].last else { return false } - return topFoundation.id == selection.cards[0].id - - case .tableau(let pile, let index): - guard state.tableau.indices.contains(pile) else { return false } - let sourcePile = state.tableau[pile] - guard sourcePile.indices.contains(index) else { return false } - let selectedCards = Array(sourcePile[index...]) - guard selectedCards.count == selection.cards.count else { return false } - return zip(selectedCards, selection.cards).allSatisfy { $0.id == $1.id } - } - } -} diff --git a/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift b/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift new file mode 100644 index 0000000..f1f1708 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift @@ -0,0 +1,40 @@ +import Foundation + +enum FreeCellAutoMoveAdvisor { + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + guard selection.cards.count > 1 else { return true } + guard GameRules.isValidDescendingAlternatingSequence(selection.cards) else { + return false + } + let maxTransferCount = GameRules.maxFreeCellTransferCount( + freeCellSlots: state.freeCells, + tableau: state.tableau, + destination: .tableau(destinationTableauIndex) + ) + return selection.cards.count <= maxTransferCount + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + guard selection.cards.count == 1 else { return } + for freeCellIndex in state.freeCells.indices { + if case .freeCell = selection.source { + continue + } + if GameRules.canMoveToFreeCell(destination: state.freeCells[freeCellIndex]) { + destinations.append(.freeCell(freeCellIndex)) + } + } + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + // FreeCell has no hidden cards to reveal. + } +} diff --git a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift new file mode 100644 index 0000000..faebf03 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift @@ -0,0 +1,490 @@ +import Foundation + +/// A FreeCell solver. +/// +/// Runs a weighted best-first search over a compact board encoding with a transposition +/// table, safe autoplay, and supermove-aware transfers that mirror the app's +/// `maxFreeCellTransferCount` rule, so every move in a returned solution is directly +/// executable in the UI. Typical deals solve in a few thousand nodes; the search stops +/// at `Limits.maxNodes` or `Limits.deadline`, whichever comes first. +enum FreeCellSolver { + /// A card is `suitIndex << 4 | rank` (rank 1...13); suit order follows `Suit.allCases`. + typealias Code = UInt8 + + struct Move: Equatable { + enum Source: Equatable { + case cascade(pile: Int, count: Int) + case cell(Int) + } + enum Target: Equatable { + case cascade(Int) + case cell(Int) + case foundation + } + + let source: Source + let target: Target + } + + struct Solution { + let moves: [Move] + } + + struct Limits { + var maxNodes: Int + var deadline: Date? + + init(maxNodes: Int = 120_000, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.deadline = deadline + } + } + + static func solve(_ state: GameState, limits: Limits = Limits()) -> Solution? { + guard state.variant == .freecell else { return nil } + guard var rootBoard = Board(state: state) else { return nil } + let rootAutoplay = applySafeAutoplay(&rootBoard) + + var nodes: [Node] = [Node(board: rootBoard, parent: -1, movesFromParent: rootAutoplay, g: rootAutoplay.count)] + var visited: Set = [rootBoard.canonical()] + var heap = Heap() + heap.push(HeapEntry(f: heuristic(rootBoard), order: 0, index: 0)) + var order = 0 + var expansions = 0 + + while let entry = heap.pop() { + let nodeIndex = entry.index + let board = nodes[nodeIndex].board + + if board.isWon { + return Solution(moves: reconstructMoves(endingAt: nodeIndex, nodes: nodes)) + } + + expansions += 1 + if nodes.count >= limits.maxNodes { return nil } + if expansions % 128 == 0, let deadline = limits.deadline, Date() > deadline { + return nil + } + + for move in generateMoves(from: board) { + var nextBoard = board + applyMove(move, to: &nextBoard) + let autoplay = applySafeAutoplay(&nextBoard) + + let canonical = nextBoard.canonical() + guard visited.insert(canonical).inserted else { continue } + + let g = nodes[nodeIndex].g + 1 + autoplay.count + nodes.append( + Node(board: nextBoard, parent: nodeIndex, movesFromParent: [move] + autoplay, g: g) + ) + order += 1 + heap.push( + HeapEntry( + f: g + heuristicWeight * heuristic(nextBoard), + order: order, + index: nodes.count - 1 + ) + ) + } + } + + return nil + } + + /// Exact (non-canonical) key of a game state, used to match cached plan steps to the + /// live game. Encodes rank/suit layout only, so it is stable across Card identities. + static func stateKey(for state: GameState) -> String { + guard let board = Board(state: state) else { return "" } + return key(for: board) + } + + /// Replays a solution and returns every intermediate position keyed to the move to + /// play from it, so cached hints stay valid while the player follows the line. + static func keyedMoves(along solution: Solution, from state: GameState) -> [String: Move] { + guard var board = Board(state: state) else { return [:] } + var plan: [String: Move] = [:] + for move in solution.moves { + plan[key(for: board)] = move + applyMove(move, to: &board) + } + return plan + } + + static func key(for board: Board) -> String { + var parts: [String] = [] + parts.append("c:" + board.cells.map(String.init).joined(separator: ",")) + parts.append("f:" + board.foundations.map(String.init).joined(separator: ",")) + parts.append("t:" + board.cascades.map { $0.map(String.init).joined(separator: ",") }.joined(separator: "|")) + return parts.joined(separator: ";") + } + + /// Converts a solver move into an executable selection/destination against the live + /// state the move was planned for. + static func materialize( + _ move: Move, + in state: GameState + ) -> (selection: Selection, destination: Destination)? { + let selection: Selection + switch move.source { + case .cascade(let pile, let count): + guard state.tableau.indices.contains(pile) else { return nil } + let cards = state.tableau[pile] + guard count >= 1, count <= cards.count else { return nil } + selection = Selection( + source: .tableau(pile: pile, index: cards.count - count), + cards: Array(cards[(cards.count - count)...]) + ) + case .cell(let slot): + guard state.freeCells.indices.contains(slot), let card = state.freeCells[slot] else { return nil } + selection = Selection(source: .freeCell(slot: slot), cards: [card]) + } + + let destination: Destination + switch move.target { + case .cascade(let pile): + guard state.tableau.indices.contains(pile) else { return nil } + destination = .tableau(pile) + case .cell(let slot): + guard state.freeCells.indices.contains(slot), state.freeCells[slot] == nil else { return nil } + destination = .freeCell(slot) + case .foundation: + guard let card = selection.cards.first, selection.cards.count == 1 else { return nil } + guard let index = foundationPileIndex(for: card, in: state) else { return nil } + destination = .foundation(index) + } + + return (selection, destination) + } + + static func foundationPileIndex(for card: Card, in state: GameState) -> Int? { + if let matching = state.foundations.firstIndex(where: { $0.last?.suit == card.suit }) { + return GameRules.canMoveToFoundation(card: card, foundation: state.foundations[matching]) + ? matching + : nil + } + guard card.rank.rawValue == 1 else { return nil } + return state.foundations.firstIndex(where: \.isEmpty) + } +} + +// MARK: - Board model + +extension FreeCellSolver { + struct Board: Hashable { + var cascades: [[Code]] + var cells: [Code] // 0 = empty + var foundations: [Code] // top rank per suit index, 0 = none + + init?(state: GameState) { + cascades = state.tableau.map { pile in pile.map { FreeCellSolver.code(for: $0) } } + cells = state.freeCells.map { $0.map { FreeCellSolver.code(for: $0) } ?? 0 } + foundations = [0, 0, 0, 0] + for pile in state.foundations { + guard let top = pile.last else { continue } + foundations[FreeCellSolver.suitIndex(of: top.suit)] = Code(top.rank.rawValue) + } + guard cascades.count == 8, cells.count == 4 else { return nil } + } + + var isWon: Bool { + foundations.allSatisfy { $0 == 13 } + } + + /// Cell order and cascade order don't affect strategy; canonicalize for the + /// transposition table so equivalent layouts aren't explored twice. + func canonical() -> Board { + var canonicalBoard = self + canonicalBoard.cells.sort() + canonicalBoard.cascades.sort { lhs, rhs in + for (a, b) in zip(lhs, rhs) where a != b { return a < b } + return lhs.count < rhs.count + } + return canonicalBoard + } + } + + static func code(for card: Card) -> Code { + Code(suitIndex(of: card.suit) << 4 | card.rank.rawValue) + } + + static func suitIndex(of suit: Suit) -> Int { + Suit.allCases.firstIndex(of: suit) ?? 0 + } + + @inline(__always) static func rank(_ code: Code) -> Int { Int(code) & 0b1111 } + @inline(__always) static func suit(_ code: Code) -> Int { Int(code) >> 4 } + @inline(__always) static func isRed(_ code: Code) -> Bool { + let suitValue = suit(code) + return suitValue == redSuitIndexA || suitValue == redSuitIndexB + } + + private static let redSuitIndexA = Suit.allCases.firstIndex(where: \.isRed) ?? 1 + private static let redSuitIndexB = Suit.allCases.lastIndex(where: \.isRed) ?? 2 +} + +// MARK: - Search internals + +private extension FreeCellSolver { + /// Bias strongly toward foundation progress and untangling cascades; solution + /// length matters less than finding one quickly. Tuned empirically: this config + /// solves ~99% of random deals in a median of ~3ms (p95 ~35ms). + static let heuristicWeight = 10 + + struct Node { + let board: Board + let parent: Int + let movesFromParent: [Move] + let g: Int + } + + struct HeapEntry { + let f: Int + let order: Int + let index: Int + + func takesPriority(over other: HeapEntry) -> Bool { + f != other.f ? f < other.f : order < other.order + } + } + + struct Heap { + private var entries: [HeapEntry] = [] + + mutating func push(_ entry: HeapEntry) { + entries.append(entry) + var child = entries.count - 1 + while child > 0 { + let parent = (child - 1) / 2 + guard entries[child].takesPriority(over: entries[parent]) else { break } + entries.swapAt(child, parent) + child = parent + } + } + + mutating func pop() -> HeapEntry? { + guard let top = entries.first else { return nil } + let last = entries.removeLast() + if !entries.isEmpty { + entries[0] = last + var parent = 0 + while true { + let left = parent * 2 + 1 + let right = left + 1 + var candidate = parent + if left < entries.count, entries[left].takesPriority(over: entries[candidate]) { + candidate = left + } + if right < entries.count, entries[right].takesPriority(over: entries[candidate]) { + candidate = right + } + guard candidate != parent else { break } + entries.swapAt(parent, candidate) + parent = candidate + } + } + return top + } + } + + static func heuristic(_ board: Board) -> Int { + var estimate = 0 + for suitValue in 0..<4 { + estimate += 13 - Int(board.foundations[suitValue]) + } + for cascade in board.cascades { + for (depth, code) in cascade.enumerated() { + // Cards stacked above the next card a foundation needs. + if rank(code) == Int(board.foundations[suit(code)]) + 1 { + estimate += cascade.count - 1 - depth + } + // Same-suit inversions: a higher card above a lower one guarantees + // extra moves before the lower card can ever reach its foundation. + for upper in (depth + 1).. rank(code) { + estimate += 3 + } + } + } + estimate += board.cells.count(where: { $0 != 0 }) + return estimate + } + + static func reconstructMoves(endingAt index: Int, nodes: [Node]) -> [Move] { + var chunks: [[Move]] = [] + var cursor = index + while cursor >= 0 { + chunks.append(nodes[cursor].movesFromParent) + cursor = nodes[cursor].parent + } + return chunks.reversed().flatMap { $0 } + } + + static func maxTransferCount(in board: Board, toEmptyCascade: Bool) -> Int { + let emptyCells = board.cells.count(where: { $0 == 0 }) + var emptyCascades = board.cascades.count(where: \.isEmpty) + if toEmptyCascade { + emptyCascades = max(0, emptyCascades - 1) + } + return (emptyCells + 1) * (1 << emptyCascades) + } + + /// Length of the maximal movable run at the top of a cascade. + static func topRunLength(of cascade: [Code]) -> Int { + guard !cascade.isEmpty else { return 0 } + var length = 1 + var index = cascade.count - 1 + while index > 0 { + let upper = cascade[index - 1] + let lower = cascade[index] + guard rank(lower) == rank(upper) - 1, isRed(lower) != isRed(upper) else { break } + length += 1 + index -= 1 + } + return length + } + + static func isFoundationEligible(_ code: Code, in board: Board) -> Bool { + rank(code) == Int(board.foundations[suit(code)]) + 1 + } + + static func isSafeAutoplay(_ code: Code, in board: Board) -> Bool { + guard isFoundationEligible(code, in: board) else { return false } + let cardRank = rank(code) + if cardRank <= 2 { return true } + let red = isRed(code) + var oppositeMin = 13 + var sameColorOther = 13 + for suitValue in 0..<4 { + let isRedSuit = suitValue == redSuitIndexA || suitValue == redSuitIndexB + let foundationRank = Int(board.foundations[suitValue]) + if isRedSuit != red { + oppositeMin = min(oppositeMin, foundationRank) + } else if suitValue != suit(code) { + sameColorOther = min(sameColorOther, foundationRank) + } + } + return oppositeMin >= cardRank - 1 && sameColorOther >= cardRank - 2 + } + + @discardableResult + static func applySafeAutoplay(_ board: inout Board) -> [Move] { + var moves: [Move] = [] + var progressed = true + while progressed { + progressed = false + for pile in board.cascades.indices { + guard let top = board.cascades[pile].last, isSafeAutoplay(top, in: board) else { continue } + board.cascades[pile].removeLast() + board.foundations[suit(top)] = Code(rank(top)) + moves.append(Move(source: .cascade(pile: pile, count: 1), target: .foundation)) + progressed = true + } + for slot in board.cells.indices { + let code = board.cells[slot] + guard code != 0, isSafeAutoplay(code, in: board) else { continue } + board.cells[slot] = 0 + board.foundations[suit(code)] = Code(rank(code)) + moves.append(Move(source: .cell(slot), target: .foundation)) + progressed = true + } + } + return moves + } + + static func applyMove(_ move: Move, to board: inout Board) { + var moving: [Code] + switch move.source { + case .cascade(let pile, let count): + let cascade = board.cascades[pile] + moving = Array(cascade[(cascade.count - count)...]) + board.cascades[pile].removeLast(count) + case .cell(let slot): + moving = [board.cells[slot]] + board.cells[slot] = 0 + } + + switch move.target { + case .cascade(let pile): + board.cascades[pile].append(contentsOf: moving) + case .cell(let slot): + board.cells[slot] = moving[0] + case .foundation: + board.foundations[suit(moving[0])] = Code(rank(moving[0])) + } + } + + static func generateMoves(from board: Board) -> [Move] { + var moves: [Move] = [] + let firstEmptyCell = board.cells.firstIndex(of: 0) + let firstEmptyCascade = board.cascades.firstIndex(where: \.isEmpty) + let transferCap = maxTransferCount(in: board, toEmptyCascade: false) + let transferCapToEmpty = maxTransferCount(in: board, toEmptyCascade: true) + + // Foundation moves (including unsafe ones; safety is only for autoplay). + for pile in board.cascades.indices { + if let top = board.cascades[pile].last, isFoundationEligible(top, in: board) { + moves.append(Move(source: .cascade(pile: pile, count: 1), target: .foundation)) + } + } + for slot in board.cells.indices where board.cells[slot] != 0 { + if isFoundationEligible(board.cells[slot], in: board) { + moves.append(Move(source: .cell(slot), target: .foundation)) + } + } + + // Cell → cascade. + for slot in board.cells.indices { + let code = board.cells[slot] + guard code != 0 else { continue } + for pile in board.cascades.indices { + guard let top = board.cascades[pile].last else { continue } + if rank(code) == rank(top) - 1, isRed(code) != isRed(top) { + moves.append(Move(source: .cell(slot), target: .cascade(pile))) + } + } + if let emptyPile = firstEmptyCascade { + moves.append(Move(source: .cell(slot), target: .cascade(emptyPile))) + } + } + + // Cascade → cascade (supermoves included; the fitting length is unique per pair). + for source in board.cascades.indices { + let cascade = board.cascades[source] + guard !cascade.isEmpty else { continue } + let runLength = topRunLength(of: cascade) + + for destination in board.cascades.indices where destination != source { + guard let top = board.cascades[destination].last else { continue } + let neededCount = rank(top) - rank(cascade.last!) + guard neededCount >= 1, neededCount <= runLength, neededCount <= transferCap else { continue } + let bottomMoving = cascade[cascade.count - neededCount] + if rank(bottomMoving) == rank(top) - 1, isRed(bottomMoving) != isRed(top) { + moves.append( + Move(source: .cascade(pile: source, count: neededCount), target: .cascade(destination)) + ) + } + } + + // Only the first empty cascade: the rest are symmetric. Relocating an entire + // cascade into another empty column is a no-op, so skip that count. + if let emptyPile = firstEmptyCascade { + let cap = min(runLength, transferCapToEmpty) + for count in stride(from: cap, through: 1, by: -1) where count < cascade.count { + moves.append( + Move(source: .cascade(pile: source, count: count), target: .cascade(emptyPile)) + ) + } + } + } + + // Cascade top → first empty cell. + if let cellSlot = firstEmptyCell { + for pile in board.cascades.indices where !board.cascades[pile].isEmpty { + moves.append(Move(source: .cascade(pile: pile, count: 1), target: .cell(cellSlot))) + } + } + + return moves + } +} diff --git a/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift new file mode 100644 index 0000000..e2b3f99 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift @@ -0,0 +1,9 @@ +import Foundation + +enum FreeCellPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == 8 else { return false } + guard state.stock.isEmpty, state.waste.isEmpty else { return false } + return state.wasteDrawCount == 0 + } +} diff --git a/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift new file mode 100644 index 0000000..abe1cb6 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift @@ -0,0 +1,30 @@ +enum FreeCellGameRules { + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + if destinationPile.isEmpty { + return true + } + guard let top = destinationPile.last else { return false } + return top.isFaceUp + && top.suit.isRed != card.suit.isRed + && card.rank.rawValue == top.rank.rawValue - 1 + } + + static func canMoveToFreeCell(destination: Card?) -> Bool { + destination == nil + } + + static func maxTransferCount( + freeCellSlots: [Card?], + tableau: [[Card]], + destination: Destination + ) -> Int { + let emptyFreeCells = freeCellSlots.filter { $0 == nil }.count + var emptyTableau = tableau.count(where: \.isEmpty) + if case .tableau(let destinationIndex) = destination, + tableau.indices.contains(destinationIndex), + tableau[destinationIndex].isEmpty { + emptyTableau = max(0, emptyTableau - 1) + } + return (emptyFreeCells + 1) * (1 << emptyTableau) + } +} diff --git a/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift new file mode 100644 index 0000000..11379d7 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift @@ -0,0 +1,68 @@ +import Foundation + +extension SolitaireViewModel { + func configureFreeCellNewGame() { + setStockDrawCount(DrawMode.three.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(0) + } + + func configureFreeCellRedeal() { + setScoringDrawCount(stockDrawCount) + setWasteDrawCount(0) + } + + func sanitizeFreeCellRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = 0 + return sanitizedState + } + + func canSelectFreeCellTableauCards(_ cards: [Card]) -> Bool { + GameRules.isValidDescendingAlternatingSequence(cards) + } + + func handleFreeCellTap(index: Int) { + guard state.variant == .freecell else { return } + guard state.freeCells.indices.contains(index) else { return } + if selection != nil || state.freeCells[index] != nil { + HapticManager.shared.play(.cardPickUp) + } + if selection != nil { + if tryMoveSelection(to: .freeCell(index)) { + return + } + } else if let card = state.freeCells[index] { + let tappedSelection = Selection(source: .freeCell(slot: index), cards: [card]) + if queueBestAutoMove( + for: tappedSelection, + playFailureFeedback: false + ) { + return + } + } + isDragging = false + selectFromFreeCell(index: index) + } + + @discardableResult + func startDragFromFreeCell(index: Int) -> Bool { + guard state.variant == .freecell else { return false } + guard state.freeCells.indices.contains(index), let card = state.freeCells[index] else { return false } + clearHint() + selection = Selection(source: .freeCell(slot: index), cards: [card]) + isDragging = true + return true + } + + func freeCellCanMoveStack(_ cards: [Card], to destination: Destination) -> Bool { + guard cards.count > 1 else { return true } + guard GameRules.isValidDescendingAlternatingSequence(cards) else { return false } + let maxTransferCount = GameRules.maxFreeCellTransferCount( + freeCellSlots: state.freeCells, + tableau: state.tableau, + destination: destination + ) + return cards.count <= maxTransferCount + } +} diff --git a/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift new file mode 100644 index 0000000..5a4d871 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift @@ -0,0 +1,24 @@ +import Foundation + +extension GameState { + static func newFreeCellGame() -> GameState { + var deck = Card.fullDeck().shuffled() + var tableau = Array(repeating: [Card](), count: 8) + + for cardIndex in 0..<52 { + var card = deck.removeLast() + card.isFaceUp = true + tableau[cardIndex % 8].append(card) + } + + return GameState( + variant: .freecell, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } +} diff --git a/ComputerSolitaire/Game/GameModels.swift b/ComputerSolitaire/Game/GameModels.swift deleted file mode 100644 index de9e803..0000000 --- a/ComputerSolitaire/Game/GameModels.swift +++ /dev/null @@ -1,201 +0,0 @@ -import Foundation - -enum DrawMode: Int, CaseIterable, Codable { - case one = 1 - case three = 3 - - var title: String { - switch self { - case .one: - return "1-card" - case .three: - return "3-card" - } - } -} - -enum Suit: CaseIterable, Codable { - case spades - case hearts - case diamonds - case clubs - - var isRed: Bool { - switch self { - case .hearts, .diamonds: - return true - case .spades, .clubs: - return false - } - } - - var symbolName: String { - switch self { - case .spades: - return "suit.spade.fill" - case .hearts: - return "suit.heart.fill" - case .diamonds: - return "suit.diamond.fill" - case .clubs: - return "suit.club.fill" - } - } -} - -enum Rank: Int, CaseIterable, Comparable, Codable { - case ace = 1 - case two = 2 - case three = 3 - case four = 4 - case five = 5 - case six = 6 - case seven = 7 - case eight = 8 - case nine = 9 - case ten = 10 - case jack = 11 - case queen = 12 - case king = 13 - - static func < (lhs: Rank, rhs: Rank) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var label: String { - switch self { - case .ace: - return "A" - case .jack: - return "J" - case .queen: - return "Q" - case .king: - return "K" - default: - return String(rawValue) - } - } -} - -struct Card: Identifiable, Equatable, Codable { - let id: UUID - let suit: Suit - let rank: Rank - var isFaceUp: Bool - - init(id: UUID = UUID(), suit: Suit, rank: Rank, isFaceUp: Bool = false) { - self.id = id - self.suit = suit - self.rank = rank - self.isFaceUp = isFaceUp - } -} - -struct GameState: Equatable, Codable { - var stock: [Card] - var waste: [Card] - var wasteDrawCount: Int - var foundations: [[Card]] - var tableau: [[Card]] - - static func newGame() -> GameState { - var deck = Card.fullDeck().shuffled() - var tableau = Array(repeating: [Card](), count: 7) - - for pileIndex in 0..<7 { - for cardIndex in 0...pileIndex { - var card = deck.removeLast() - card.isFaceUp = cardIndex == pileIndex - tableau[pileIndex].append(card) - } - } - - return GameState( - stock: deck, - waste: [], - wasteDrawCount: 0, - foundations: Array(repeating: [], count: 4), - tableau: tableau - ) - } -} - -extension Card { - static func fullDeck() -> [Card] { - var deck: [Card] = [] - for suit in Suit.allCases { - for rank in Rank.allCases { - deck.append(Card(suit: suit, rank: rank)) - } - } - return deck - } -} - -struct Selection: Equatable { - enum Source: Equatable { - case waste - case foundation(pile: Int) - case tableau(pile: Int, index: Int) - } - - let source: Source - let cards: [Card] -} - -enum Destination: Equatable { - case foundation(Int) - case tableau(Int) -} - -struct GameSnapshot: Codable { - let state: GameState - let movesCount: Int - let score: Int - let hasAppliedTimeBonus: Bool - let undoContext: UndoAnimationContext? - - enum CodingKeys: String, CodingKey { - case state - case movesCount - case score - case hasAppliedTimeBonus - case undoContext - } - - init( - state: GameState, - movesCount: Int, - score: Int = 0, - hasAppliedTimeBonus: Bool = false, - undoContext: UndoAnimationContext? - ) { - self.state = state - self.movesCount = movesCount - self.score = score - self.hasAppliedTimeBonus = hasAppliedTimeBonus - self.undoContext = undoContext - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - state = try container.decode(GameState.self, forKey: .state) - movesCount = try container.decode(Int.self, forKey: .movesCount) - score = try container.decodeIfPresent(Int.self, forKey: .score) ?? 0 - hasAppliedTimeBonus = try container.decodeIfPresent(Bool.self, forKey: .hasAppliedTimeBonus) ?? false - undoContext = try container.decodeIfPresent(UndoAnimationContext.self, forKey: .undoContext) - } -} - -struct UndoAnimationContext: Codable { - enum Action: String, Codable { - case moveSelection - case drawFromStock - case recycleWaste - case flipTableauTop - } - - let action: Action - let cardIDs: [UUID] -} diff --git a/ComputerSolitaire/Game/GameRules.swift b/ComputerSolitaire/Game/GameRules.swift deleted file mode 100644 index 5a830d1..0000000 --- a/ComputerSolitaire/Game/GameRules.swift +++ /dev/null @@ -1,17 +0,0 @@ -enum GameRules { - static func canMoveToFoundation(card: Card, foundation: [Card]) -> Bool { - if foundation.isEmpty { - return card.rank == .ace - } - guard let top = foundation.last else { return false } - return top.suit == card.suit && card.rank.rawValue == top.rank.rawValue + 1 - } - - static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { - if destinationPile.isEmpty { - return card.rank == .king - } - guard let top = destinationPile.last else { return false } - return top.isFaceUp && top.suit.isRed != card.suit.isRed && card.rank.rawValue == top.rank.rawValue - 1 - } -} diff --git a/ComputerSolitaire/Game/HintAdvisor.swift b/ComputerSolitaire/Game/HintAdvisor.swift deleted file mode 100644 index 19fa7b6..0000000 --- a/ComputerSolitaire/Game/HintAdvisor.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation - -enum HintAdvisor { - enum Hint: Equatable { - case move(HintMove) - case stockTap - } - - struct HintMove: Equatable { - let selection: Selection - let destination: Destination - } - - static func bestHint(in state: GameState, stockDrawCount: Int) -> Hint? { - if let move = bestHintMove(in: state, stockDrawCount: stockDrawCount) { - return .move(move) - } - - if canRevealPlayableMoveViaStockTap(in: state, stockDrawCount: stockDrawCount) { - return .stockTap - } - - return nil - } - - static func bestHintMove(in state: GameState, stockDrawCount: Int) -> HintMove? { - var bestChoice: (selection: Selection, evaluation: MoveEvaluation)? - - for selection in AutoMoveAdvisor.candidateSelections(in: state) { - guard let evaluation = AutoMoveAdvisor.bestAdvisableMoveEvaluation( - for: selection, - in: state, - stockDrawCount: stockDrawCount - ) else { - continue - } - - if let currentBest = bestChoice { - if MoveEvaluationRanking.isBetter(evaluation, than: currentBest.evaluation) { - bestChoice = (selection: selection, evaluation: evaluation) - } - } else { - bestChoice = (selection: selection, evaluation: evaluation) - } - } - - guard let bestChoice else { return nil } - return HintMove( - selection: bestChoice.selection, - destination: bestChoice.evaluation.destination - ) - } -} - -private extension HintAdvisor { - static func canRevealPlayableMoveViaStockTap(in state: GameState, stockDrawCount: Int) -> Bool { - var simulatedState = state - let maxLookaheadSteps = stockTapLookaheadSteps(in: state, stockDrawCount: stockDrawCount) - - for _ in 0.. Int { - let totalCardsInStockCycle = state.stock.count + state.waste.count - guard totalCardsInStockCycle > 0 else { return 0 } - - let drawCount = max(1, stockDrawCount) - let drawsPerPass = (totalCardsInStockCycle + drawCount - 1) / drawCount - - // One pass explores all draw groups in the current cycle. - // A second pass covers cases where recycle state and draw grouping interact. - return (drawsPerPass * 2) + 2 - } - - static func stockTapState(from state: GameState, stockDrawCount: Int) -> GameState? { - var nextState = state - - if !nextState.stock.isEmpty { - let drawCount = min(max(1, stockDrawCount), nextState.stock.count) - for _ in 0.. Bool { guard !isWin(state) else { return false } - guard state.stock.isEmpty, state.waste.isEmpty else { return false } - return !state.tableau.joined().contains(where: { !$0.isFaceUp }) + switch state.variant { + case .klondike: + guard state.stock.isEmpty, state.waste.isEmpty else { return false } + return !state.tableau.joined().contains(where: { !$0.isFaceUp }) + case .freecell: + return true + } } static func isWin(_ state: GameState) -> Bool { @@ -44,7 +54,7 @@ private extension AutoFinishPlanner { } static func nextAutoFinishMoveInternal(in state: GameState) -> AutoFinishMove? { - var candidates: [(move: AutoFinishMove, rankValue: Int, tableauPile: Int, foundationPile: Int)] = [] + var candidates: [(move: AutoFinishMove, rankValue: Int, sourceOrder: Int, foundationPile: Int)] = [] for pileIndex in state.tableau.indices { guard let topIndex = state.tableau[pileIndex].indices.last else { continue } @@ -63,19 +73,39 @@ private extension AutoFinishPlanner { ( move: AutoFinishMove(selection: selection, destination: .foundation(foundationIndex)), rankValue: card.rank.rawValue, - tableauPile: pileIndex, + sourceOrder: pileIndex, foundationPile: foundationIndex ) ) } } + if state.variant == .freecell { + for slot in state.freeCells.indices { + guard let card = state.freeCells[slot] else { continue } + for foundationIndex in state.foundations.indices { + let foundation = state.foundations[foundationIndex] + guard GameRules.canMoveToFoundation(card: card, foundation: foundation) else { continue } + + let selection = Selection(source: .freeCell(slot: slot), cards: [card]) + candidates.append( + ( + move: AutoFinishMove(selection: selection, destination: .foundation(foundationIndex)), + rankValue: card.rank.rawValue, + sourceOrder: state.tableau.count + slot, + foundationPile: foundationIndex + ) + ) + } + } + } + let sorted = candidates.sorted { lhs, rhs in if lhs.rankValue != rhs.rankValue { return lhs.rankValue < rhs.rankValue } - if lhs.tableauPile != rhs.tableauPile { - return lhs.tableauPile < rhs.tableauPile + if lhs.sourceOrder != rhs.sourceOrder { + return lhs.sourceOrder < rhs.sourceOrder } return lhs.foundationPile < rhs.foundationPile } @@ -84,30 +114,42 @@ private extension AutoFinishPlanner { @discardableResult static func applyAutoFinishMove(_ move: AutoFinishMove, in state: inout GameState) -> Bool { - guard case .tableau(let pileIndex, let cardIndex) = move.selection.source, - case .foundation(let foundationIndex) = move.destination else { - return false - } - guard state.tableau.indices.contains(pileIndex), - state.foundations.indices.contains(foundationIndex), - state.tableau[pileIndex].indices.contains(cardIndex), - cardIndex == state.tableau[pileIndex].count - 1 else { + guard case .foundation(let foundationIndex) = move.destination, + state.foundations.indices.contains(foundationIndex) else { return false } - guard let movingCard = state.tableau[pileIndex].last else { return false } - guard move.selection.cards.count == 1, - move.selection.cards[0].id == movingCard.id else { + guard move.selection.cards.count == 1, let movingCard = move.selection.cards.first else { return false } guard GameRules.canMoveToFoundation(card: movingCard, foundation: state.foundations[foundationIndex]) else { return false } - _ = state.tableau[pileIndex].popLast() - if let newTopIndex = state.tableau[pileIndex].indices.last, - !state.tableau[pileIndex][newTopIndex].isFaceUp { - state.tableau[pileIndex][newTopIndex].isFaceUp = true + switch move.selection.source { + case .tableau(let pileIndex, let cardIndex): + guard state.tableau.indices.contains(pileIndex), + state.tableau[pileIndex].indices.contains(cardIndex), + cardIndex == state.tableau[pileIndex].count - 1, + state.tableau[pileIndex][cardIndex].id == movingCard.id else { + return false + } + _ = state.tableau[pileIndex].popLast() + if let newTopIndex = state.tableau[pileIndex].indices.last, + !state.tableau[pileIndex][newTopIndex].isFaceUp { + state.tableau[pileIndex][newTopIndex].isFaceUp = true + } + + case .freeCell(let slot): + guard state.freeCells.indices.contains(slot), + state.freeCells[slot]?.id == movingCard.id else { + return false + } + state.freeCells[slot] = nil + + case .waste, .foundation: + return false } + state.foundations[foundationIndex].append(movingCard) return true } diff --git a/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift b/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift new file mode 100644 index 0000000..6a1d986 --- /dev/null +++ b/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift @@ -0,0 +1,49 @@ +import Foundation + +enum KlondikeAutoMoveAdvisor { + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + true + } + + static func isRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + guard case .tableau(let sourcePile, let sourceIndex) = selection.source else { return false } + guard sourcePile != destinationTableauIndex else { return false } + guard state.tableau.indices.contains(sourcePile), + state.tableau.indices.contains(destinationTableauIndex) else { return false } + guard state.tableau[destinationTableauIndex].isEmpty else { return false } + guard sourceIndex == 0 else { return false } + + let sourceCards = state.tableau[sourcePile] + guard selection.cards.count == sourceCards.count else { return false } + guard let movingCard = selection.cards.first else { return false } + + // Moving an entire king-led tableau stack to another empty column is a no-op + // for advisor quality purposes (manual play can still do this). + return movingCard.rank == .king + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + // Klondike has no auxiliary destination type beyond tableau/foundation. + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + guard let topIndex = state.tableau[pileIndex].indices.last, + !state.tableau[pileIndex][topIndex].isFaceUp else { + return + } + state.tableau[pileIndex][topIndex].isFaceUp = true + } + +} diff --git a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift new file mode 100644 index 0000000..c7c61df --- /dev/null +++ b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift @@ -0,0 +1,8 @@ +import Foundation + +enum KlondikePersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == 7 else { return false } + return state.wasteDrawCount >= 0 && state.wasteDrawCount <= state.waste.count + } +} diff --git a/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift b/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift new file mode 100644 index 0000000..a160132 --- /dev/null +++ b/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift @@ -0,0 +1,11 @@ +enum KlondikeGameRules { + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + if destinationPile.isEmpty { + return card.rank == .king + } + guard let top = destinationPile.last else { return false } + return top.isFaceUp + && top.suit.isRed != card.suit.isRed + && card.rank.rawValue == top.rank.rawValue - 1 + } +} diff --git a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift new file mode 100644 index 0000000..4bad037 --- /dev/null +++ b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift @@ -0,0 +1,196 @@ +import Foundation + +extension SolitaireViewModel { + func configureKlondikeNewGame(drawMode: DrawMode) { + setStockDrawCount(drawMode.rawValue) + setScoringDrawCount(drawMode.rawValue) + setWasteDrawCount(0) + } + + func configureKlondikeRedeal() { + setScoringDrawCount(stockDrawCount) + let clampedWasteDrawCount = min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) + setWasteDrawCount(clampedWasteDrawCount) + } + + func sanitizeKlondikeRedealState(_ baseState: GameState, stockDrawCount: Int) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = min( + max(0, sanitizedState.wasteDrawCount), + min(stockDrawCount, sanitizedState.waste.count) + ) + return sanitizedState + } + + @discardableResult + func handleKlondikeTableauFaceDownTap( + pile: [Card], + pileIndex: Int, + cardIndex: Int, + card: Card + ) -> Bool { + guard !card.isFaceUp else { return false } + guard cardIndex == pile.count - 1 else { + selection = nil + return true + } + clearHint() + pushHistory( + undoContext: UndoAnimationContext( + action: .flipTableauTop, + cardIDs: [card.id] + ) + ) + state.tableau[pileIndex][cardIndex].isFaceUp = true + incrementMovesCount() + applyScore(.turnOverTableauCard) + SoundManager.shared.play(.cardFlipFaceUp) + HapticManager.shared.play(.cardFlipFaceUp) + refreshAutoFinishAvailability() + selection = nil + return true + } + + var supportsDrawMode: Bool { + state.variant == .klondike + } + + func updateDrawMode(_ drawMode: DrawMode) { + guard state.variant == .klondike else { return } + clearHint() + setStockDrawCount(drawMode.rawValue) + if drawMode == .one { + setWasteDrawCount(min(1, state.waste.count)) + } else { + setWasteDrawCount(min(state.wasteDrawCount, drawMode.rawValue)) + } + selection = nil + isDragging = false + pendingAutoMove = nil + refreshAutoFinishAvailability() + } + + func visibleWasteCards() -> [Card] { + guard state.variant == .klondike else { return [] } + let count = min(state.wasteDrawCount, stockDrawCount) + return Array(state.waste.suffix(count)) + } + + func handleStockTap() { + guard state.variant == .klondike else { return } + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + if state.stock.isEmpty { + recycleWaste() + } else { + drawFromStock() + } + } + + func handleWasteTap() { + guard state.variant == .klondike else { return } + guard let top = state.waste.last, state.wasteDrawCount > 0 else { return } + HapticManager.shared.play(.cardPickUp) + let wasteSelection = Selection(source: .waste, cards: [top]) + if queueBestAutoMove(for: wasteSelection) { + return + } + if selection?.source == .waste { + selection = nil + return + } + isDragging = false + selection = wasteSelection + } + + @discardableResult + func startDragFromWaste() -> Bool { + guard state.variant == .klondike else { return false } + guard let top = state.waste.last, state.wasteDrawCount > 0 else { return false } + clearHint() + selection = Selection(source: .waste, cards: [top]) + isDragging = true + return true + } + + func drawFromStock() { + guard !state.stock.isEmpty else { return } + clearHint() + let drawCount = min(stockDrawCount, state.stock.count) + let drawnCardIDs = (0.. GameState { + var deck = Card.fullDeck().shuffled() + var tableau = Array(repeating: [Card](), count: 7) + + for pileIndex in 0..<7 { + for cardIndex in 0...pileIndex { + var card = deck.removeLast() + card.isFaceUp = cardIndex == pileIndex + tableau[pileIndex].append(card) + } + } + + return GameState( + variant: .klondike, + stock: deck, + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } +} diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift new file mode 100644 index 0000000..2283dae --- /dev/null +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -0,0 +1,307 @@ +import Foundation + +/// Bounded best-first hint planner for Klondike. +/// +/// Searches sequences of real moves and stock taps up to a node/time budget, scoring +/// positions by foundation progress, revealed cards, and open columns. The hint is the +/// first action of the best line found that strictly improves on the current position; +/// nil means nothing within the horizon makes progress (the game is stuck or lost). +/// +/// The search reads the true state, including cards the player hasn't seen yet, but it +/// only ever recommends actions that are legal right now. +enum KlondikePlanner { + struct Limits { + var maxNodes: Int + var maxDepth: Int + var deadline: Date? + + init(maxNodes: Int = 8_000, maxDepth: Int = 40, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.maxDepth = maxDepth + self.deadline = deadline + } + } + + static func bestHint( + in state: GameState, + stockDrawCount: Int, + limits: Limits = Limits() + ) -> HintAdvisor.Hint? { + guard state.variant == .klondike else { return nil } + + let rootScore = score(state) + var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] + var visited: Set = [stateHash(state)] + var heap = Heap() + heap.push(HeapEntry(priority: rootScore, order: 0, index: 0)) + var order = 0 + var expansions = 0 + var best: (index: Int, score: Int, depth: Int)? + + while let entry = heap.pop() { + let nodeIndex = entry.index + let node = nodes[nodeIndex] + + if node.score > rootScore { + if best == nil + || node.score > best!.score + || (node.score == best!.score && node.depth < best!.depth) { + best = (nodeIndex, node.score, node.depth) + } + if isWon(node.state) { break } + } + + guard node.depth < limits.maxDepth else { continue } + expansions += 1 + if nodes.count >= limits.maxNodes { break } + if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { + break + } + // A line that reveals a card or banks a foundation card is a solid hint; + // once one is in hand, cap how long we keep hunting for something better. + if let best, best.score - rootScore >= 20, expansions >= 768 { + break + } + + for action in actions(from: node.state, stockDrawCount: stockDrawCount) { + guard let nextState = apply(action, to: node.state, stockDrawCount: stockDrawCount) else { + continue + } + guard visited.insert(stateHash(nextState)).inserted else { continue } + + let nextScore = score(nextState) + nodes.append( + Node( + state: nextState, + parent: nodeIndex, + action: action, + depth: node.depth + 1, + score: nextScore + ) + ) + order += 1 + // Best-first on score, shallow bias so equal outcomes prefer short lines. + heap.push( + HeapEntry( + priority: nextScore * 4 - (node.depth + 1), + order: order, + index: nodes.count - 1 + ) + ) + } + } + + guard let best else { return nil } + return firstAction(leadingTo: best.index, nodes: nodes) + } +} + +// MARK: - Search internals + +private extension KlondikePlanner { + enum Action { + case move(Selection, Destination) + case stockTap + } + + struct Node { + let state: GameState + let parent: Int + let action: Action? + let depth: Int + let score: Int + } + + struct HeapEntry { + let priority: Int + let order: Int + let index: Int + + func takesPriority(over other: HeapEntry) -> Bool { + priority != other.priority ? priority > other.priority : order < other.order + } + } + + struct Heap { + private var entries: [HeapEntry] = [] + + mutating func push(_ entry: HeapEntry) { + entries.append(entry) + var child = entries.count - 1 + while child > 0 { + let parent = (child - 1) / 2 + guard entries[child].takesPriority(over: entries[parent]) else { break } + entries.swapAt(child, parent) + child = parent + } + } + + mutating func pop() -> HeapEntry? { + guard let top = entries.first else { return nil } + let last = entries.removeLast() + if !entries.isEmpty { + entries[0] = last + var parent = 0 + while true { + let left = parent * 2 + 1 + let right = left + 1 + var candidate = parent + if left < entries.count, entries[left].takesPriority(over: entries[candidate]) { + candidate = left + } + if right < entries.count, entries[right].takesPriority(over: entries[candidate]) { + candidate = right + } + guard candidate != parent else { break } + entries.swapAt(parent, candidate) + parent = candidate + } + } + return top + } + } + + static func isWon(_ state: GameState) -> Bool { + state.foundations.allSatisfy { $0.count == Rank.allCases.count } + } + + static func score(_ state: GameState) -> Int { + var hiddenCount = 0 + var emptyPiles = 0 + for pile in state.tableau { + if pile.isEmpty { emptyPiles += 1 } + for card in pile where !card.isFaceUp { hiddenCount += 1 } + } + let foundationCount = state.foundations.reduce(0) { $0 + $1.count } + let undevelopedCount = state.stock.count + state.waste.count + // Developing cards out of the stock/waste cycle counts as progress too, so + // waste-to-tableau lines rank above pure reshuffles. + return foundationCount * 20 - hiddenCount * 25 + emptyPiles * 4 - undevelopedCount * 2 + } + + static func actions(from state: GameState, stockDrawCount: Int) -> [Action] { + var actions: [Action] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + // Foundation rollbacks explode the branching factor for marginal benefit. + if case .foundation = selection.source { continue } + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + actions.append(.move(selection, destination)) + } + } + if !state.stock.isEmpty || !state.waste.isEmpty { + actions.append(.stockTap) + } + return actions + } + + /// Applies an action without re-validating legality: the planner only feeds in + /// actions it just generated from `legalDestinations`, and revalidating each one + /// there dominates search cost. Mirrors the session's move effects. + static func apply(_ action: Action, to state: GameState, stockDrawCount: Int) -> GameState? { + switch action { + case .move(let selection, let destination): + var nextState = state + switch selection.source { + case .waste: + _ = nextState.waste.popLast() + if stockDrawCount == DrawMode.one.rawValue { + nextState.wasteDrawCount = min(1, nextState.waste.count) + } else { + nextState.wasteDrawCount = max(0, nextState.wasteDrawCount - 1) + } + case .freeCell(let slot): + nextState.freeCells[slot] = nil + case .foundation(let pile): + _ = nextState.foundations[pile].popLast() + case .tableau(let pile, let index): + nextState.tableau[pile].removeSubrange(index.. GameState? { + var nextState = state + + if !nextState.stock.isEmpty { + let drawCount = min(max(1, stockDrawCount), nextState.stock.count) + for _ in 0.. UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { + hash = (hash ^ UInt64(value)) &* 0x100000001b3 + } + func mix(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + mix(UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0))) + } + for card in state.stock { mix(card: card) } + mix(0xFF) + for card in state.waste { mix(card: card) } + mix(UInt8(min(255, max(0, state.wasteDrawCount)))) + for pile in state.foundations { + mix(0xFE) + for card in pile { mix(card: card) } + } + for pile in state.tableau { + mix(0xFD) + for card in pile { mix(card: card) } + } + return hash + } + + static func firstAction(leadingTo index: Int, nodes: [Node]) -> HintAdvisor.Hint? { + var cursor = index + var action: Action? + while cursor >= 0, nodes[cursor].parent >= 0 { + action = nodes[cursor].action + cursor = nodes[cursor].parent + } + switch action { + case .move(let selection, let destination): + return .move(HintAdvisor.HintMove(selection: selection, destination: destination)) + case .stockTap: + return .stockTap + case nil: + return nil + } + } +} diff --git a/ComputerSolitaire/Game/MoveEvaluation.swift b/ComputerSolitaire/Game/MoveEvaluation.swift deleted file mode 100644 index 224e9c4..0000000 --- a/ComputerSolitaire/Game/MoveEvaluation.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -struct MoveEvaluation { - let destination: Destination - let revealsFaceDownCard: Bool - let clearsSourcePile: Bool - let emptyTableauDelta: Int - let foundationProgressDelta: Int - let mobilityDelta: Int - let resultingMobility: Int - let destinationPriority: Int -} - -enum MoveEvaluationRanking { - // Priority order: - // 1) reveal hidden cards, 2) increase foundation progress, - // 3) improve mobility, 4) increase empty tableau columns, - // 5) clear source pile, 6) destination preference, - // 7) resulting mobility, 8) deterministic tiebreak. - static func isBetter(_ lhs: MoveEvaluation, than rhs: MoveEvaluation) -> Bool { - if lhs.revealsFaceDownCard != rhs.revealsFaceDownCard { - return lhs.revealsFaceDownCard && !rhs.revealsFaceDownCard - } - if lhs.foundationProgressDelta != rhs.foundationProgressDelta { - return lhs.foundationProgressDelta > rhs.foundationProgressDelta - } - if lhs.mobilityDelta != rhs.mobilityDelta { - return lhs.mobilityDelta > rhs.mobilityDelta - } - if lhs.emptyTableauDelta != rhs.emptyTableauDelta { - return lhs.emptyTableauDelta > rhs.emptyTableauDelta - } - 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 destinationSortKey(lhs.destination) < destinationSortKey(rhs.destination) - } - - private static func destinationSortKey(_ destination: Destination) -> Int { - switch destination { - case .foundation(let index): - return index - case .tableau(let index): - return 100 + index - } - } -} diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift new file mode 100644 index 0000000..616cdb1 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -0,0 +1,234 @@ +import Foundation + +/// Move generation shared by the tap policy, hint planners, and solver plumbing: +/// which selections a player could pick up, where each can legally go, and what the +/// state looks like after a move. +enum AutoMoveAdvisor { + static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { + guard selectionMatchesState(selection, in: state) else { return [] } + guard let movingCard = selection.cards.first else { return [] } + + var destinations: [Destination] = [] + + if selection.cards.count == 1 { + for foundationIndex in state.foundations.indices { + let foundation = state.foundations[foundationIndex] + if GameRules.canMoveToFoundation(card: movingCard, foundation: foundation) { + destinations.append(.foundation(foundationIndex)) + } + } + } + + for tableauIndex in state.tableau.indices { + if case .tableau(let sourcePile, _) = selection.source, sourcePile == tableauIndex { + continue + } + let tableauPile = state.tableau[tableauIndex] + if GameRules.canMoveToTableau( + card: movingCard, + destinationPile: tableauPile, + variant: state.variant + ) { + guard variantAllowsTableauTransfer( + selection: selection, + destinationTableauIndex: tableauIndex, + in: state + ) else { + continue + } + if isVariantRedundantEmptyColumnTransfer( + selection: selection, + destinationTableauIndex: tableauIndex, + in: state + ) { + continue + } + destinations.append(.tableau(tableauIndex)) + } + } + + appendVariantAuxiliaryDestinations(for: selection, in: state, destinations: &destinations) + + 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 freeCellIndex in state.freeCells.indices { + guard let freeCellCard = state.freeCells[freeCellIndex] else { continue } + selections.append( + Selection(source: .freeCell(slot: freeCellIndex), cards: [freeCellCard]) + ) + } + + 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 simulatedState( + afterMoving selection: Selection, + to destination: Destination, + in state: GameState, + stockDrawCount: Int + ) -> GameState? { + guard selectionMatchesState(selection, in: state) else { return nil } + guard legalDestinations(for: selection, in: state).contains(destination) else { return nil } + + var nextState = state + + switch selection.source { + case .waste: + _ = nextState.waste.popLast() + if stockDrawCount == DrawMode.one.rawValue { + nextState.wasteDrawCount = min(1, nextState.waste.count) + } else { + nextState.wasteDrawCount = max(0, nextState.wasteDrawCount - 1) + } + case .freeCell(let slot): + nextState.freeCells[slot] = nil + case .foundation(let pile): + _ = nextState.foundations[pile].popLast() + case .tableau(let pile, let index): + nextState.tableau[pile].removeSubrange(index.. Bool { + guard !selection.cards.isEmpty else { return false } + + switch selection.source { + case .waste: + guard selection.cards.count == 1, let topWaste = state.waste.last else { return false } + return topWaste.id == selection.cards[0].id + + case .freeCell(let slot): + guard selection.cards.count == 1 else { return false } + guard state.freeCells.indices.contains(slot), let freeCellCard = state.freeCells[slot] else { return false } + return freeCellCard.id == selection.cards[0].id + + case .foundation(let pile): + guard selection.cards.count == 1 else { return false } + guard state.foundations.indices.contains(pile), + let topFoundation = state.foundations[pile].last else { return false } + return topFoundation.id == selection.cards[0].id + + case .tableau(let pile, let index): + guard state.tableau.indices.contains(pile) else { return false } + let sourcePile = state.tableau[pile] + guard sourcePile.indices.contains(index) else { return false } + let selectedCards = Array(sourcePile[index...]) + guard selectedCards.count == selection.cards.count else { return false } + return zip(selectedCards, selection.cards).allSatisfy { $0.id == $1.id } + } + } + + static func isValidTableauSequence(_ cards: [Card]) -> Bool { + GameRules.isValidDescendingAlternatingSequence(cards) + } +} + +private extension AutoMoveAdvisor { + static func variantAllowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + switch state.variant { + case .klondike: + return KlondikeAutoMoveAdvisor.allowsTableauTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) + case .freecell: + return FreeCellAutoMoveAdvisor.allowsTableauTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) + } + } + + static func isVariantRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + switch state.variant { + case .klondike: + return KlondikeAutoMoveAdvisor.isRedundantEmptyColumnTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) + case .freecell: + return false + } + } + + static func appendVariantAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + switch state.variant { + case .klondike: + KlondikeAutoMoveAdvisor.appendAuxiliaryDestinations( + for: selection, + in: state, + destinations: &destinations + ) + case .freecell: + FreeCellAutoMoveAdvisor.appendAuxiliaryDestinations( + for: selection, + in: state, + destinations: &destinations + ) + } + } + + static func applyVariantTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + switch state.variant { + case .klondike: + KlondikeAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) + case .freecell: + FreeCellAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) + } + } +} diff --git a/ComputerSolitaire/Game/Shared/Card.swift b/ComputerSolitaire/Game/Shared/Card.swift new file mode 100644 index 0000000..f67f0df --- /dev/null +++ b/ComputerSolitaire/Game/Shared/Card.swift @@ -0,0 +1,91 @@ +import Foundation + +enum Suit: CaseIterable, Codable { + case spades + case hearts + case diamonds + case clubs + + var isRed: Bool { + switch self { + case .hearts, .diamonds: + return true + case .spades, .clubs: + return false + } + } + + var symbolName: String { + switch self { + case .spades: + return "suit.spade.fill" + case .hearts: + return "suit.heart.fill" + case .diamonds: + return "suit.diamond.fill" + case .clubs: + return "suit.club.fill" + } + } +} + +enum Rank: Int, CaseIterable, Comparable, Codable { + case ace = 1 + case two = 2 + case three = 3 + case four = 4 + case five = 5 + case six = 6 + case seven = 7 + case eight = 8 + case nine = 9 + case ten = 10 + case jack = 11 + case queen = 12 + case king = 13 + + static func < (lhs: Rank, rhs: Rank) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var label: String { + switch self { + case .ace: + return "A" + case .jack: + return "J" + case .queen: + return "Q" + case .king: + return "K" + default: + return String(rawValue) + } + } +} + +struct Card: Identifiable, Equatable, Codable { + let id: UUID + let suit: Suit + let rank: Rank + var isFaceUp: Bool + + init(id: UUID = UUID(), suit: Suit, rank: Rank, isFaceUp: Bool = false) { + self.id = id + self.suit = suit + self.rank = rank + self.isFaceUp = isFaceUp + } +} + +extension Card { + static func fullDeck() -> [Card] { + var deck: [Card] = [] + for suit in Suit.allCases { + for rank in Rank.allCases { + deck.append(Card(suit: suit, rank: rank)) + } + } + return deck + } +} diff --git a/ComputerSolitaire/Game/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift similarity index 77% rename from ComputerSolitaire/Game/GamePersistence.swift rename to ComputerSolitaire/Game/Shared/GamePersistence.swift index 1dbc8b6..0a72671 100644 --- a/ComputerSolitaire/Game/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -1,6 +1,7 @@ import Foundation import SwiftData + @Model final class SavedGameRecord { static let currentRecordKey = "current" @@ -139,12 +140,22 @@ struct SavedGamePayload: Codable { guard schemaVersion == Self.currentSchemaVersion else { return nil } guard state.isValidForPersistence else { return nil } - let sanitizedStockDrawCount = DrawMode(rawValue: stockDrawCount)?.rawValue ?? DrawMode.three.rawValue + let sanitizedStockDrawCount: Int = { + if state.variant == .klondike { + return DrawMode(rawValue: stockDrawCount)?.rawValue ?? DrawMode.three.rawValue + } + return DrawMode.three.rawValue + }() let sanitizedMovesCount = max(0, movesCount) let sanitizedScore = Scoring.clamped(score) let sanitizedSavedAt = min(savedAt, now) let sanitizedStartedAt = min(gameStartedAt, now) - let sanitizedScoringDrawCount = DrawMode(rawValue: scoringDrawCount)?.rawValue ?? sanitizedStockDrawCount + let sanitizedScoringDrawCount: Int = { + if state.variant == .klondike { + return DrawMode(rawValue: scoringDrawCount)?.rawValue ?? sanitizedStockDrawCount + } + return DrawMode.three.rawValue + }() let sanitizedPauseStartedAt = pauseStartedAt .map { min($0, now) } .flatMap { $0 >= sanitizedStartedAt ? $0 : nil } @@ -171,17 +182,25 @@ struct SavedGamePayload: Codable { .suffix(SolitaireViewModel.maxUndoHistoryCount) var sanitizedState = state - sanitizedState.wasteDrawCount = min( - max(0, sanitizedState.wasteDrawCount), - min(sanitizedStockDrawCount, sanitizedState.waste.count) - ) + if sanitizedState.variant == .klondike { + sanitizedState.wasteDrawCount = min( + max(0, sanitizedState.wasteDrawCount), + min(sanitizedStockDrawCount, sanitizedState.waste.count) + ) + } else { + sanitizedState.wasteDrawCount = 0 + } let sanitizedRedealState: GameState? = { guard var baseState = redealState, baseState.isValidForPersistence else { return nil } - baseState.wasteDrawCount = min( - max(0, baseState.wasteDrawCount), - min(sanitizedStockDrawCount, baseState.waste.count) - ) + if baseState.variant == .klondike { + baseState.wasteDrawCount = min( + max(0, baseState.wasteDrawCount), + min(sanitizedStockDrawCount, baseState.waste.count) + ) + } else { + baseState.wasteDrawCount = 0 + } return baseState }() @@ -208,6 +227,7 @@ struct SavedGamePayload: Codable { } } + enum GamePersistenceError: Error { case invalidPayload } @@ -343,6 +363,61 @@ struct GameStatistics: Codable, Equatable { return Double(cleanWins) / Double(gamesWon) } + static func aggregated(_ statsByVariant: [GameStatistics]) -> GameStatistics { + var gamesPlayed = 0 + var gamesWon = 0 + var totalTimeSeconds = 0 + var cleanWins = 0 + var trackedSince: Date? + var bestTimeSeconds: Int? + var highScoreDrawThree: Int? + var highScoreDrawOne: Int? + + for stats in statsByVariant { + gamesPlayed = addingSafely(gamesPlayed, stats.gamesPlayed) + gamesWon = addingSafely(gamesWon, stats.gamesWon) + totalTimeSeconds = addingSafely(totalTimeSeconds, stats.totalTimeSeconds) + cleanWins = addingSafely(cleanWins, stats.cleanWins) + + if let candidate = stats.trackedSince { + if let existing = trackedSince { + trackedSince = min(existing, candidate) + } else { + trackedSince = candidate + } + } + + if let candidate = stats.bestTimeSeconds { + if let existing = bestTimeSeconds { + bestTimeSeconds = min(existing, candidate) + } else { + bestTimeSeconds = candidate + } + } + + if let candidate = stats.highScoreDrawThree { + highScoreDrawThree = max(highScoreDrawThree ?? 0, candidate) + } + if let candidate = stats.highScoreDrawOne { + highScoreDrawOne = max(highScoreDrawOne ?? 0, candidate) + } + } + + gamesWon = min(gamesWon, gamesPlayed) + cleanWins = min(cleanWins, gamesWon) + + return GameStatistics( + trackedSince: trackedSince, + gamesPlayed: gamesPlayed, + gamesWon: gamesWon, + totalTimeSeconds: totalTimeSeconds, + bestTimeSeconds: bestTimeSeconds, + highScoreDrawThree: highScoreDrawThree, + highScoreDrawOne: highScoreDrawOne, + cleanWins: cleanWins + ) + } + mutating func recordCompletedGame( didWin: Bool, elapsedSeconds: Int, @@ -371,7 +446,7 @@ struct GameStatistics: Codable, Equatable { if drawCount == DrawMode.one.rawValue { highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) - } else { + } else if drawCount == DrawMode.three.rawValue { highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) } @@ -397,13 +472,23 @@ struct GameStatistics: Codable, Equatable { let (sum, overflow) = lhs.addingReportingOverflow(rhs) return overflow ? Int.max : sum } + + private static func addingSafely(_ lhs: Int, _ rhs: Int) -> Int { + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? Int.max : sum + } } enum GameStatisticsStore { - static let defaultsKey = "stats.gameStatistics" + static func defaultsKey(for variant: GameVariant) -> String { + "stats.gameStatistics.\(variant.rawValue)" + } - static func load(userDefaults: UserDefaults = .standard) -> GameStatistics { - guard let data = userDefaults.data(forKey: defaultsKey), + static func load( + for variant: GameVariant, + userDefaults: UserDefaults = .standard + ) -> GameStatistics { + guard let data = userDefaults.data(forKey: defaultsKey(for: variant)), let stats = try? JSONDecoder().decode(GameStatistics.self, from: data), stats.schemaVersion == GameStatistics.currentSchemaVersion else { return GameStatistics() @@ -411,34 +496,41 @@ enum GameStatisticsStore { return stats } - static func save(_ stats: GameStatistics, userDefaults: UserDefaults = .standard) { + static func save( + _ stats: GameStatistics, + for variant: GameVariant, + userDefaults: UserDefaults = .standard + ) { guard let data = try? JSONEncoder().encode(stats) else { return } - userDefaults.set(data, forKey: defaultsKey) + userDefaults.set(data, forKey: defaultsKey(for: variant)) } static func update( + for variant: GameVariant, userDefaults: UserDefaults = .standard, _ mutate: (inout GameStatistics) -> Void ) { - var stats = load(userDefaults: userDefaults) + var stats = load(for: variant, userDefaults: userDefaults) mutate(&stats) - save(stats, userDefaults: userDefaults) + save(stats, for: variant, userDefaults: userDefaults) } static func markTrackingStarted( + for variant: GameVariant, userDefaults: UserDefaults = .standard, at date: Date = .now ) { - update(userDefaults: userDefaults) { stats in + update(for: variant, userDefaults: userDefaults) { stats in stats.markTrackingStarted(at: date) } } static func reset( + for variant: GameVariant, userDefaults: UserDefaults = .standard, at date: Date = .now ) { - save(GameStatistics(trackedSince: date), userDefaults: userDefaults) + save(GameStatistics(trackedSince: date), for: variant, userDefaults: userDefaults) } } @@ -449,12 +541,13 @@ private struct CardIdentity: Hashable { private extension GameState { var allCards: [Card] { - stock + waste + foundations.flatMap { $0 } + tableau.flatMap { $0 } + stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + tableau.flatMap { $0 } } var isValidForPersistence: Bool { - guard foundations.count == 4, tableau.count == 7 else { return false } - guard wasteDrawCount >= 0, wasteDrawCount <= waste.count else { return false } + guard foundations.count == 4 else { return false } + guard freeCells.count == 4 else { return false } + guard hasValidVariantPersistenceLayout else { return false } let allCards = allCards guard allCards.count == 52 else { return false } @@ -462,4 +555,13 @@ private extension GameState { guard Set(allCards.map { CardIdentity(suit: $0.suit, rank: $0.rank) }).count == 52 else { return false } return true } + + private var hasValidVariantPersistenceLayout: Bool { + switch variant { + case .klondike: + return KlondikePersistenceRules.hasValidLayout(state: self) + case .freecell: + return FreeCellPersistenceRules.hasValidLayout(state: self) + } + } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift new file mode 100644 index 0000000..031532f --- /dev/null +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -0,0 +1,59 @@ +enum GameRules { + static func canMoveToFoundation(card: Card, foundation: [Card]) -> Bool { + if foundation.isEmpty { + return card.rank == .ace + } + guard let top = foundation.last else { return false } + return top.suit == card.suit && card.rank.rawValue == top.rank.rawValue + 1 + } + + static func canMoveToTableau( + card: Card, + destinationPile: [Card], + variant: GameVariant + ) -> Bool { + switch variant { + case .klondike: + return KlondikeGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + case .freecell: + return FreeCellGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + } + } + + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + KlondikeGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + } + + static func canMoveToFreeCell(destination: Card?) -> Bool { + FreeCellGameRules.canMoveToFreeCell(destination: destination) + } + + static func isValidDescendingAlternatingSequence(_ cards: [Card]) -> Bool { + SharedGameRules.isValidDescendingAlternatingSequence(cards) + } + + static func maxFreeCellTransferCount( + freeCellSlots: [Card?], + tableau: [[Card]], + destination: Destination + ) -> Int { + FreeCellGameRules.maxTransferCount( + freeCellSlots: freeCellSlots, + tableau: tableau, + destination: destination + ) + } +} + +enum SharedGameRules { + static func isValidDescendingAlternatingSequence(_ 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/ComputerSolitaire/Game/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift similarity index 72% rename from ComputerSolitaire/Game/GameSession.swift rename to ComputerSolitaire/Game/Shared/GameSession.swift index 4d148ce..505cf05 100644 --- a/ComputerSolitaire/Game/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -16,8 +16,10 @@ final class SolitaireViewModel { static let maxUndoHistoryCount = 200 private static let hintVisibilityDuration: TimeInterval = 1.5 - private(set) var state: GameState + var state: GameState private(set) var isAutoFinishAvailable: Bool + // Optimistic: true when any legal action exists (cheap check after every move); + // set false when a full hint search comes back empty for the current position. private(set) var isHintAvailable: Bool private var redealState: GameState var selection: Selection? { @@ -45,6 +47,7 @@ final class SolitaireViewModel { private var undosUsedInCurrentGame: Int = 0 private var usedRedealInCurrentGame = false private let dateProvider: any DateProviding + @ObservationIgnored private let hintPlanner = HintPlanner() private var history: [GameSnapshot] = [] @@ -54,20 +57,24 @@ final class SolitaireViewModel { let destination: Destination } - init(dateProvider: any DateProviding = SystemDateProvider()) { + init( + dateProvider: any DateProviding = SystemDateProvider(), + variant: GameVariant = .klondike + ) { self.dateProvider = dateProvider let startedAt = dateProvider.now - let initialState = GameState.newGame() + let initialState = GameState.newGame(variant: variant) state = initialState isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: initialState) - isHintAvailable = HintAdvisor.bestHint( - in: initialState, - stockDrawCount: DrawMode.three.rawValue - ) != nil + isHintAvailable = HintAdvisor.anyPlayerMoveExists(in: initialState) redealState = initialState gameStartedAt = startedAt hasStartedTrackedGame = false - GameStatisticsStore.markTrackingStarted(at: startedAt) + GameStatisticsStore.markTrackingStarted(for: variant, at: startedAt) + } + + var gameVariant: GameVariant { + state.variant } var isWin: Bool { @@ -122,8 +129,12 @@ final class SolitaireViewModel { return } - guard let hint = HintAdvisor.bestHint(in: state, stockDrawCount: stockDrawCount) else { + guard let hint = hintPlanner.bestHint(in: state, stockDrawCount: stockDrawCount) else { clearHint() + // The cheap availability check can't know the planner would come up empty + // (e.g. a dead stock cycle); now that the full search has, keep the button + // honest until the next state change re-evaluates it. + isHintAvailable = false HapticManager.shared.play(.invalidDrop) return } @@ -202,10 +213,11 @@ final class SolitaireViewModel { return true } - func newGame(drawMode: DrawMode = .three) { + func newGame(variant: GameVariant? = nil, drawMode: DrawMode = .three) { finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) clearHint() - let initialState = GameState.newGame() + let nextVariant = variant ?? state.variant + let initialState = GameState.newGame(variant: nextVariant) state = initialState redealState = initialState selection = nil @@ -217,14 +229,13 @@ final class SolitaireViewModel { hasAppliedTimeBonus = false finalElapsedSeconds = nil pauseStartedAt = nil - stockDrawCount = drawMode.rawValue - scoringDrawCount = drawMode.rawValue + applyNewGameVariantConfiguration(variant: nextVariant, drawMode: drawMode) + GameStatisticsStore.markTrackingStarted(for: nextVariant, at: gameStartedAt) hasStartedTrackedGame = true isCurrentGameFinalized = false hintRequestsInCurrentGame = 0 undosUsedInCurrentGame = 0 usedRedealInCurrentGame = false - state.wasteDrawCount = 0 history.removeAll() refreshAutoFinishAvailability() } @@ -242,36 +253,17 @@ final class SolitaireViewModel { hasAppliedTimeBonus = false finalElapsedSeconds = nil pauseStartedAt = nil - scoringDrawCount = stockDrawCount + applyRedealVariantConfiguration() + GameStatisticsStore.markTrackingStarted(for: state.variant, at: gameStartedAt) hasStartedTrackedGame = true isCurrentGameFinalized = false hintRequestsInCurrentGame = 0 undosUsedInCurrentGame = 0 usedRedealInCurrentGame = true - state.wasteDrawCount = min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) history.removeAll() refreshAutoFinishAvailability() } - func updateDrawMode(_ drawMode: DrawMode) { - clearHint() - stockDrawCount = drawMode.rawValue - if drawMode == .one { - state.wasteDrawCount = min(1, state.waste.count) - } else { - state.wasteDrawCount = min(state.wasteDrawCount, drawMode.rawValue) - } - selection = nil - isDragging = false - pendingAutoMove = nil - refreshAutoFinishAvailability() - } - - func visibleWasteCards() -> [Card] { - let count = min(state.wasteDrawCount, stockDrawCount) - return Array(state.waste.suffix(count)) - } - func undo() { guard !isWin else { return } guard let snapshot = history.popLast() else { return } @@ -332,6 +324,7 @@ final class SolitaireViewModel { } stockDrawCount = sanitizedPayload.stockDrawCount scoringDrawCount = sanitizedPayload.scoringDrawCount + GameStatisticsStore.markTrackingStarted(for: state.variant, at: gameStartedAt) hasStartedTrackedGame = sanitizedPayload.hasStartedTrackedGame isCurrentGameFinalized = sanitizedPayload.isCurrentGameFinalized hintRequestsInCurrentGame = sanitizedPayload.hintRequestsInCurrentGame @@ -339,9 +332,9 @@ final class SolitaireViewModel { usedRedealInCurrentGame = sanitizedPayload.usedRedealInCurrentGame history = Array(sanitizedPayload.history.suffix(Self.maxUndoHistoryCount)) var restoredRedealState = sanitizedPayload.redealState ?? history.first?.state ?? state - restoredRedealState.wasteDrawCount = min( - max(0, restoredRedealState.wasteDrawCount), - min(stockDrawCount, restoredRedealState.waste.count) + restoredRedealState = normalizedRedealStateForCurrentVariant( + from: restoredRedealState, + stockDrawCount: stockDrawCount ) redealState = restoredRedealState selection = nil @@ -351,33 +344,6 @@ final class SolitaireViewModel { return true } - func handleStockTap() { - clearHint() - selection = nil - isDragging = false - pendingAutoMove = nil - if state.stock.isEmpty { - recycleWaste() - } else { - drawFromStock() - } - } - - func handleWasteTap() { - guard let top = state.waste.last, state.wasteDrawCount > 0 else { return } - HapticManager.shared.play(.cardPickUp) - let wasteSelection = Selection(source: .waste, cards: [top]) - if queueBestAutoMove(for: wasteSelection) { - return - } - if selection?.source == .waste { - selection = nil - return - } - isDragging = false - selection = wasteSelection - } - func handleFoundationTap(index: Int) { if selection != nil || state.foundations[index].last != nil { HapticManager.shared.play(.cardPickUp) @@ -405,41 +371,38 @@ final class SolitaireViewModel { guard cardIndex < pile.count else { return } let card = pile[cardIndex] - if !card.isFaceUp { - if cardIndex == pile.count - 1 { - clearHint() - pushHistory( - undoContext: UndoAnimationContext( - action: .flipTableauTop, - cardIDs: [card.id] - ) - ) - state.tableau[pileIndex][cardIndex].isFaceUp = true - movesCount += 1 - applyScore(.turnOverTableauCard) - SoundManager.shared.play(.cardFlipFaceUp) - HapticManager.shared.play(.cardFlipFaceUp) - refreshAutoFinishAvailability() - } - selection = nil + if handleVariantTableauTapIfNeeded( + pile: pile, + pileIndex: pileIndex, + cardIndex: cardIndex, + card: card + ) { return } HapticManager.shared.play(.cardPickUp) + let selectedCards = Array(pile[cardIndex...]) + guard canSelectTableauCards(selectedCards) else { + selection = nil + HapticManager.shared.play(.invalidDrop) + return + } let tappedSelection = Selection( source: .tableau(pile: pileIndex, index: cardIndex), - cards: Array(pile[cardIndex...]) + cards: selectedCards ) if selection?.source == tappedSelection.source { self.selection = nil return } - if queueBestAutoMove(for: tappedSelection) { + // An active selection dropping onto this pile wins over auto-moving the + // tapped card, so tap-select-then-tap-destination behaves as expected. + if selection != nil, tryMoveSelection(to: .tableau(pileIndex)) { return } - if selection != nil, tryMoveSelection(to: .tableau(pileIndex)) { + if queueBestAutoMove(for: tappedSelection) { return } @@ -457,15 +420,6 @@ final class SolitaireViewModel { selectedCardIDs.contains(card.id) } - @discardableResult - func startDragFromWaste() -> Bool { - guard let top = state.waste.last, state.wasteDrawCount > 0 else { return false } - clearHint() - selection = Selection(source: .waste, cards: [top]) - isDragging = true - return true - } - @discardableResult func startDragFromFoundation(index: Int) -> Bool { guard let top = state.foundations[index].last else { return false } @@ -475,37 +429,6 @@ final class SolitaireViewModel { return true } - @discardableResult - func startDragFromTableau(pileIndex: Int, cardIndex: Int) -> Bool { - let pile = state.tableau[pileIndex] - guard cardIndex < pile.count else { return false } - let card = pile[cardIndex] - guard card.isFaceUp else { return false } - clearHint() - let cards = Array(pile[cardIndex...]) - selection = Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) - isDragging = true - return true - } - - func canDrop(to destination: Destination) -> Bool { - guard let selection, let movingCard = selection.cards.first else { return false } - - switch destination { - case .foundation(let index): - guard selection.cards.count == 1 else { return false } - return GameRules.canMoveToFoundation( - card: movingCard, - foundation: state.foundations[index] - ) - case .tableau(let index): - return GameRules.canMoveToTableau( - card: movingCard, - destinationPile: state.tableau[index] - ) - } - } - @discardableResult func handleDrop(to destination: Destination) -> Bool { let moved = tryMoveSelection(to: destination) @@ -525,84 +448,96 @@ final class SolitaireViewModel { pendingAutoMove = nil } - @discardableResult - func queueNextAutoFinishMove() -> Bool { - isDragging = false - guard let move = AutoFinishPlanner.nextAutoFinishMove(in: state) else { - return false - } + func setStockDrawCount(_ count: Int) { + stockDrawCount = count + } - pendingAutoMove = PendingAutoMove( - id: UUID(), - selection: move.selection, - destination: move.destination - ) - return true + func setScoringDrawCount(_ count: Int) { + scoringDrawCount = count } - func refreshAutoFinishAvailability() { - isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: state) - isHintAvailable = !isWin && HintAdvisor.bestHint(in: state, stockDrawCount: stockDrawCount) != nil + func setWasteDrawCount(_ count: Int) { + state.wasteDrawCount = max(0, count) } -} -private extension SolitaireViewModel { - func drawFromStock() { - guard !state.stock.isEmpty else { return } - clearHint() - let drawCount = min(stockDrawCount, state.stock.count) - let drawnCardIDs = (0.. GameState { + switch state.variant { + case .klondike: + return sanitizeKlondikeRedealState(state, stockDrawCount: stockDrawCount) + case .freecell: + return sanitizeFreeCellRedealState(state) + } + } + + private func handleVariantTableauTapIfNeeded( + pile: [Card], + pileIndex: Int, + cardIndex: Int, + card: Card + ) -> Bool { + switch state.variant { + case .klondike: + return handleKlondikeTableauFaceDownTap( + pile: pile, + pileIndex: pileIndex, + cardIndex: cardIndex, + card: card ) - ) - var newStock: [Card] = [] - for card in state.waste.reversed() { - var newCard = card - newCard.isFaceUp = false - newStock.append(newCard) + case .freecell: + return false } - state.stock = newStock - state.waste.removeAll() - state.wasteDrawCount = 0 - movesCount += 1 - if stockDrawCount == DrawMode.one.rawValue { - applyScore(.recycleWasteInDrawOne) + } + + private func canSelectTableauCards(_ cards: [Card]) -> Bool { + switch state.variant { + case .klondike: + return true + case .freecell: + return canSelectFreeCellTableauCards(cards) } - SoundManager.shared.play(.wasteRecycleToStock) - HapticManager.shared.play(.wasteRecycle) - refreshAutoFinishAvailability() } + private func statisticsDrawCountForCurrentVariant() -> Int { + switch state.variant { + case .klondike: + return scoringDrawCount + case .freecell: + return 0 + } + } + + func refreshAutoFinishAvailability() { + isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: state) + isHintAvailable = !isWin && HintAdvisor.anyPlayerMoveExists(in: state) + } +} + +extension SolitaireViewModel { func selectFromTableau(pileIndex: Int, cardIndex: Int) { let pile = state.tableau[pileIndex] guard cardIndex < pile.count else { return } @@ -617,6 +552,11 @@ private extension SolitaireViewModel { selection = Selection(source: .foundation(pile: index), cards: [top]) } + func selectFromFreeCell(index: Int) { + guard state.freeCells.indices.contains(index), let card = state.freeCells[index] else { return } + selection = Selection(source: .freeCell(slot: index), cards: [card]) + } + func tryMoveSelection(to destination: Destination) -> Bool { guard let selection, let movingCard = selection.cards.first else { return false } @@ -642,7 +582,7 @@ private extension SolitaireViewModel { return true case .tableau(let index): - guard GameRules.canMoveToTableau(card: movingCard, destinationPile: state.tableau[index]) else { return false } + guard canDrop(to: destination) else { return false } clearHint() pushHistory( undoContext: UndoAnimationContext( @@ -659,6 +599,25 @@ private extension SolitaireViewModel { SoundManager.shared.play(.cardPlaced) refreshAutoFinishAvailability() return true + + case .freeCell(let index): + guard canDrop(to: destination) else { return false } + clearHint() + pushHistory( + undoContext: UndoAnimationContext( + action: .moveSelection, + cardIDs: selection.cards.map(\.id) + ) + ) + removeSelection(selection) + state.freeCells[index] = movingCard + movesCount += 1 + applyScore(for: selection.source, destination: .freeCell(index)) + applyTimeBonusIfWon() + self.selection = nil + SoundManager.shared.play(.cardPlaced) + refreshAutoFinishAvailability() + return true } } @@ -673,6 +632,8 @@ private extension SolitaireViewModel { } case .foundation(let pile): _ = state.foundations[pile].popLast() + case .freeCell(let slot): + state.freeCells[slot] = nil case .tableau(let pile, let index): var cards = state.tableau[pile] cards.removeSubrange(index.. Bool { + isDragging = false + guard let move = AutoFinishPlanner.nextAutoFinishMove(in: state) else { + return false + } + + pendingAutoMove = PendingAutoMove( + id: UUID(), + selection: move.selection, + destination: move.destination + ) + return true + } + @discardableResult func queueBestAutoMove( for sourceSelection: Selection, playFailureFeedback: Bool = true ) -> Bool { isDragging = false - guard let destination = AutoMoveAdvisor.bestAdvisableDestination( + guard let destination = TapMovePolicy.bestDestination( for: sourceSelection, - in: state, - stockDrawCount: stockDrawCount + in: state ) else { if playFailureFeedback { HapticManager.shared.play(.invalidDrop) diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift new file mode 100644 index 0000000..3c2ee39 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -0,0 +1,48 @@ +import Foundation + +extension SolitaireViewModel { + @discardableResult + func startDragFromTableau(pileIndex: Int, cardIndex: Int) -> Bool { + let pile = state.tableau[pileIndex] + guard cardIndex < pile.count else { return false } + let card = pile[cardIndex] + guard card.isFaceUp else { return false } + clearHint() + let cards = Array(pile[cardIndex...]) + if state.variant == .freecell, + !freeCellCanMoveStack(cards, to: .tableau(pileIndex)) { + return false + } + selection = Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) + isDragging = true + return true + } + + func canDrop(to destination: Destination) -> Bool { + guard let selection, let movingCard = selection.cards.first else { return false } + + switch destination { + case .foundation(let index): + guard selection.cards.count == 1 else { return false } + return GameRules.canMoveToFoundation( + card: movingCard, + foundation: state.foundations[index] + ) + case .tableau(let index): + guard GameRules.canMoveToTableau( + card: movingCard, + destinationPile: state.tableau[index], + variant: state.variant + ) else { return false } + if state.variant == .freecell { + return freeCellCanMoveStack(selection.cards, to: destination) + } + return true + case .freeCell(let index): + guard state.variant == .freecell else { return false } + guard state.freeCells.indices.contains(index) else { return false } + guard selection.cards.count == 1 else { return false } + return GameRules.canMoveToFreeCell(destination: state.freeCells[index]) + } + } +} diff --git a/ComputerSolitaire/Game/Shared/GameSnapshot.swift b/ComputerSolitaire/Game/Shared/GameSnapshot.swift new file mode 100644 index 0000000..c6ae51a --- /dev/null +++ b/ComputerSolitaire/Game/Shared/GameSnapshot.swift @@ -0,0 +1,52 @@ +import Foundation + +struct GameSnapshot: Codable { + let state: GameState + let movesCount: Int + let score: Int + let hasAppliedTimeBonus: Bool + let undoContext: UndoAnimationContext? + + enum CodingKeys: String, CodingKey { + case state + case movesCount + case score + case hasAppliedTimeBonus + case undoContext + } + + init( + state: GameState, + movesCount: Int, + score: Int = 0, + hasAppliedTimeBonus: Bool = false, + undoContext: UndoAnimationContext? + ) { + self.state = state + self.movesCount = movesCount + self.score = score + self.hasAppliedTimeBonus = hasAppliedTimeBonus + self.undoContext = undoContext + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + state = try container.decode(GameState.self, forKey: .state) + movesCount = try container.decode(Int.self, forKey: .movesCount) + score = try container.decodeIfPresent(Int.self, forKey: .score) ?? 0 + hasAppliedTimeBonus = try container.decodeIfPresent(Bool.self, forKey: .hasAppliedTimeBonus) ?? false + undoContext = try container.decodeIfPresent(UndoAnimationContext.self, forKey: .undoContext) + } +} + +struct UndoAnimationContext: Codable { + enum Action: String, Codable { + case moveSelection + case drawFromStock + case recycleWaste + case flipTableauTop + } + + let action: Action + let cardIDs: [UUID] +} diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift new file mode 100644 index 0000000..f9d4f0a --- /dev/null +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -0,0 +1,64 @@ +import Foundation + +struct GameState: Equatable, Codable { + var variant: GameVariant + var stock: [Card] + var waste: [Card] + var wasteDrawCount: Int + var freeCells: [Card?] + var foundations: [[Card]] + var tableau: [[Card]] + + enum CodingKeys: String, CodingKey { + case variant + case stock + case waste + case wasteDrawCount + case freeCells + case foundations + case tableau + } + + init( + variant: GameVariant = .klondike, + stock: [Card], + waste: [Card], + wasteDrawCount: Int, + freeCells: [Card?] = Array(repeating: nil, count: 4), + foundations: [[Card]], + tableau: [[Card]] + ) { + self.variant = variant + self.stock = stock + self.waste = waste + self.wasteDrawCount = wasteDrawCount + self.freeCells = freeCells + self.foundations = foundations + self.tableau = tableau + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + variant = try container.decodeIfPresent(GameVariant.self, forKey: .variant) ?? .klondike + stock = try container.decode([Card].self, forKey: .stock) + waste = try container.decode([Card].self, forKey: .waste) + wasteDrawCount = try container.decode(Int.self, forKey: .wasteDrawCount) + freeCells = try container.decodeIfPresent([Card?].self, forKey: .freeCells) + ?? Array(repeating: nil, count: 4) + foundations = try container.decode([[Card]].self, forKey: .foundations) + tableau = try container.decode([[Card]].self, forKey: .tableau) + } + + static func newGame() -> GameState { + newGame(variant: .klondike) + } + + static func newGame(variant: GameVariant) -> GameState { + switch variant { + case .klondike: + return newKlondikeGame() + case .freecell: + return newFreeCellGame() + } + } +} diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift new file mode 100644 index 0000000..1a6adda --- /dev/null +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -0,0 +1,38 @@ +import Foundation + +enum GameVariant: String, CaseIterable, Codable { + case klondike + case freecell + + var title: String { + switch self { + case .klondike: + return "Klondike" + case .freecell: + return "FreeCell" + } + } + + var subtitle: String { + switch self { + case .klondike: + return "Classic Solitaire" + case .freecell: + return "Strategic open layout" + } + } +} + +enum DrawMode: Int, CaseIterable, Codable { + case one = 1 + case three = 3 + + var title: String { + switch self { + case .one: + return "1-card" + case .three: + return "3-card" + } + } +} diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift new file mode 100644 index 0000000..e557392 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -0,0 +1,100 @@ +import Foundation + +enum HintAdvisor { + enum Hint: Equatable { + case move(HintMove) + case stockTap + } + + struct HintMove: Equatable { + let selection: Selection + let destination: Destination + } + + /// Cheap check that some player action exists; used to enable the hint button + /// after every move without paying for a full hint search. + static func anyPlayerMoveExists(in state: GameState) -> Bool { + if state.variant == .klondike, !state.stock.isEmpty || !state.waste.isEmpty { + return true + } + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source { continue } + if !AutoMoveAdvisor.legalDestinations(for: selection, in: state).isEmpty { + return true + } + } + return false + } + + /// One-shot hint without plan caching, for tests and tools. Interactive callers + /// should hold a `HintPlanner` so consecutive hints reuse the solved line. + static func bestHint(in state: GameState, stockDrawCount: Int) -> Hint? { + HintPlanner().bestHint(in: state, stockDrawCount: stockDrawCount) + } +} + +/// Produces hints for both variants. +/// +/// FreeCell hints come from the solver: the hint is the first move of an actual winning +/// line. The full line is cached keyed by position, so as long as the player follows it +/// (or plays ahead along it), subsequent hints are instant. Klondike hints come from the +/// bounded `KlondikePlanner` search. +final class HintPlanner { + /// How long a single interactive hint request may spend searching. + private static let freeCellSearchBudget: TimeInterval = 0.3 + private static let klondikeSearchBudget: TimeInterval = 0.15 + + private var freeCellPlan: [String: FreeCellSolver.Move] = [:] + + func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { + switch state.variant { + case .klondike: + return KlondikePlanner.bestHint( + in: state, + stockDrawCount: stockDrawCount, + limits: KlondikePlanner.Limits( + deadline: Date().addingTimeInterval(Self.klondikeSearchBudget) + ) + ) + case .freecell: + return freeCellHint(in: state) + } + } +} + +private extension HintPlanner { + func freeCellHint(in state: GameState) -> HintAdvisor.Hint? { + let key = FreeCellSolver.stateKey(for: state) + if let hint = materializedHint(for: key, in: state) { + return hint + } + + freeCellPlan.removeAll() + let limits = FreeCellSolver.Limits( + deadline: Date().addingTimeInterval(Self.freeCellSearchBudget) + ) + if let solution = FreeCellSolver.solve(state, limits: limits) { + freeCellPlan = FreeCellSolver.keyedMoves(along: solution, from: state) + if let hint = materializedHint(for: key, in: state) { + return hint + } + } + + // No winning line found (lost position or budget exceeded): still point at the + // most constructive legal move rather than shrugging. + guard let fallback = TapMovePolicy.bestMove(in: state) else { return nil } + return .move( + HintAdvisor.HintMove(selection: fallback.selection, destination: fallback.destination) + ) + } + + func materializedHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + guard let planned = freeCellPlan[key], + let move = FreeCellSolver.materialize(planned, in: state) else { + return nil + } + return .move( + HintAdvisor.HintMove(selection: move.selection, destination: move.destination) + ) + } +} diff --git a/ComputerSolitaire/Game/Shared/MoveTypes.swift b/ComputerSolitaire/Game/Shared/MoveTypes.swift new file mode 100644 index 0000000..87f9d69 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/MoveTypes.swift @@ -0,0 +1,19 @@ +import Foundation + +struct Selection: Equatable { + enum Source: Equatable { + case waste + case freeCell(slot: Int) + case foundation(pile: Int) + case tableau(pile: Int, index: Int) + } + + let source: Source + let cards: [Card] +} + +enum Destination: Equatable { + case foundation(Int) + case tableau(Int) + case freeCell(Int) +} diff --git a/ComputerSolitaire/Game/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift similarity index 100% rename from ComputerSolitaire/Game/Scoring.swift rename to ComputerSolitaire/Game/Shared/Scoring.swift diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift new file mode 100644 index 0000000..99216df --- /dev/null +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -0,0 +1,133 @@ +import Foundation + +/// Resolves where a tapped selection should move. +/// +/// Unlike hint planning, a tap always resolves to the best *legal* destination so a tap +/// never dead-ends while a legal move exists. Destination preference is deterministic: +/// higher tier wins, then a larger resulting build, then the lowest pile index. +enum TapMovePolicy { + static func bestDestination(for selection: Selection, in state: GameState) -> Destination? { + // Tapping a foundation card only selects it; pulling cards back off the + // foundation is deliberate enough to require a drag. + if case .foundation = selection.source { return nil } + + let destinations = AutoMoveAdvisor.legalDestinations(for: selection, in: state) + guard !destinations.isEmpty else { return nil } + + var best: (destination: Destination, priority: Priority)? + for destination in destinations { + let priority = priority(of: destination, for: selection, in: state) + if best == nil || priority.isBetter(than: best!.priority) { + best = (destination, priority) + } + } + return best?.destination + } + + /// The single best legal move across every pickable selection, using the same + /// destination preferences as taps. Used as the hint of last resort when the + /// FreeCell solver can't find a winning line. + static func bestMove(in state: GameState) -> (selection: Selection, destination: Destination)? { + var best: (selection: Selection, destination: Destination, priority: Priority)? + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source { continue } + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + let priority = priority(of: destination, for: selection, in: state) + if best == nil || priority.isBetter(than: best!.priority) { + best = (selection, destination, priority) + } + } + } + return best.map { ($0.selection, $0.destination) } + } + + /// A card is safe to send to the foundation when doing so can never cost the game: + /// aces and twos always are; a higher card is safe once both opposite-color foundations + /// reach at least rank − 1 and the other same-color foundation reaches rank − 2. + static func isSafeFoundationMove(card: Card, in state: GameState) -> Bool { + let rank = card.rank.rawValue + if rank <= 2 { return true } + + var topRankBySuit: [Suit: Int] = [:] + for foundation in state.foundations { + if let top = foundation.last { + topRankBySuit[top.suit] = top.rank.rawValue + } + } + + let oppositeMin = Suit.allCases + .filter { $0.isRed != card.suit.isRed } + .map { topRankBySuit[$0] ?? 0 } + .min() ?? 0 + let sameColorOther = Suit.allCases + .first { $0.isRed == card.suit.isRed && $0 != card.suit } + let sameColorOtherRank = sameColorOther.flatMap { topRankBySuit[$0] } ?? 0 + + return oppositeMin >= rank - 1 && sameColorOtherRank >= rank - 2 + } +} + +private extension TapMovePolicy { + struct Priority { + let tier: Int + let buildLength: Int + let pileOrder: Int + + func isBetter(than other: Priority) -> Bool { + if tier != other.tier { return tier > other.tier } + if buildLength != other.buildLength { return buildLength > other.buildLength } + return pileOrder > other.pileOrder + } + } + + static func priority( + of destination: Destination, + for selection: Selection, + in state: GameState + ) -> Priority { + switch destination { + case .foundation(let index): + guard let card = selection.cards.first else { + return Priority(tier: 0, buildLength: 0, pileOrder: -index) + } + let tier: Int + switch state.variant { + case .klondike: + tier = 100 + case .freecell: + tier = isSafeFoundationMove(card: card, in: state) ? 100 : 60 + } + return Priority(tier: tier, buildLength: 0, pileOrder: -index) + + case .tableau(let index): + let pile = state.tableau[index] + let tier = pile.isEmpty ? 40 : 80 + return Priority( + tier: tier, + buildLength: topRunLength(of: pile) + selection.cards.count, + pileOrder: -index + ) + + case .freeCell(let index): + return Priority(tier: 20, buildLength: 0, pileOrder: -index) + } + } + + /// Length of the valid descending, alternating-color run ending at the pile's top card. + static func topRunLength(of pile: [Card]) -> Int { + guard var index = pile.indices.last else { return 0 } + var length = 1 + while index > 0 { + let upper = pile[index - 1] + let lower = pile[index] + guard upper.isFaceUp, + upper.suit.isRed != lower.suit.isRed, + lower.rank.rawValue == upper.rank.rawValue - 1 else { + break + } + length += 1 + index -= 1 + } + return length + } +} diff --git a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift index 1f19289..068ec54 100644 --- a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift +++ b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift @@ -3,11 +3,13 @@ import SwiftUI enum DropTarget: Hashable { case foundation(Int) case tableau(Int) + case freeCell(Int) } enum DragOrigin: Hashable { case waste case foundation(Int) + case freeCell(Int) case tableau(pile: Int, index: Int) } @@ -17,6 +19,10 @@ struct DropTargetGeometry: Equatable { } enum DropTargetHitArea { + static let freeCellHorizontalGrace: CGFloat = 16 + static let freeCellTopGrace: CGFloat = 14 + static let freeCellBottomGrace: CGFloat = 18 + static let foundationHorizontalGrace: CGFloat = 16 static let foundationTopGrace: CGFloat = 14 static let foundationBottomGrace: CGFloat = 18 diff --git a/ComputerSolitaire/Interaction/DragDropCoordinator.swift b/ComputerSolitaire/Interaction/DragDropCoordinator.swift index 189efd0..fd9e931 100644 --- a/ComputerSolitaire/Interaction/DragDropCoordinator.swift +++ b/ComputerSolitaire/Interaction/DragDropCoordinator.swift @@ -42,10 +42,12 @@ enum DragDropCoordinator { static func dropTargetSortKey(_ target: DropTarget) -> Int { switch target { - case .foundation(let index): + case .freeCell(let index): return index - case .tableau(let index): + case .foundation(let index): return 100 + index + case .tableau(let index): + return 200 + index } } } diff --git a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift new file mode 100644 index 0000000..649678d --- /dev/null +++ b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import Observation + +struct FreeCellView: View { + @Bindable var viewModel: SolitaireViewModel + let index: Int + let cardSize: CGSize + let isTargeted: Bool + let isHintTargeted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + let card = viewModel.state.freeCells[index] + let isDragSource: Bool = { + guard viewModel.isDragging, let selection = viewModel.selection else { return false } + if case .freeCell(let slot) = selection.source { + return slot == index + } + return false + }() + + ZStack { + PilePlaceholderView(cardSize: cardSize) + DropHighlightView( + cardSize: cardSize, + isTargeted: isTargeted, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + if let card { + CardView( + card: card, + isSelected: viewModel.isSelected(card: card), + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + ) + .opacity((viewModel.isDragging && viewModel.isSelected(card: card)) || hiddenCardIDs.contains(card.id) ? 0 : 1) + .gesture(dragGesture(.freeCell(index))) + .cardFramePreference(card.id) + } + } + .onTapGesture { + viewModel.handleFreeCellTap(index: index) + } + .background( + GeometryReader { proxy in + let boardFrame = proxy.frame(in: .named("board")) + let hitFrame = boardFrame.expanded( + horizontal: DropTargetHitArea.freeCellHorizontalGrace, + top: DropTargetHitArea.freeCellTopGrace, + bottom: DropTargetHitArea.freeCellBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .freeCell(index): DropTargetGeometry( + snapFrame: boardFrame, + hitFrame: hitFrame + ) + ] + ) + } + ) + .zIndex(isDragSource ? 10 : 0) + .accessibilityLabel("Free Cell \(index + 1)") + } +} diff --git a/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift b/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift new file mode 100644 index 0000000..93d7fc2 --- /dev/null +++ b/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import Observation + +struct FreeCellTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + let middleGap = columnSpacing * 2 + let innerGap = max(0, ((7 * columnSpacing) - middleGap) / 6) + let groupWidth = (cardSize.width * 4) + (innerGap * 3) + + HStack(alignment: .top, spacing: 0) { + HStack(alignment: .top, spacing: innerGap) { + ForEach(0..<4, id: \.self) { index in + FreeCellView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isTargeted: activeTarget == .freeCell(index), + isHintTargeted: hintedTarget == .freeCell(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } + } + .frame(width: groupWidth, alignment: .leading) + + Color.clear + .frame(width: middleGap, height: cardSize.height) + .accessibilityHidden(true) + + HStack(alignment: .top, spacing: innerGap) { + ForEach(0..<4, id: \.self) { index in + FoundationView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isTargeted: activeTarget == .foundation(index), + isHintTargeted: hintedTarget == .foundation(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } + } + .frame(width: groupWidth, alignment: .leading) + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift new file mode 100644 index 0000000..11ab8ec --- /dev/null +++ b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift @@ -0,0 +1,120 @@ +import SwiftUI +import Observation + +struct StockView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let isHintTargeted: Bool + let hintHighlightOpacity: Double + let hintWiggleToken: UUID + + var body: some View { + ZStack { + PilePlaceholderView(cardSize: cardSize) + .allowsHitTesting(false) + if viewModel.state.stock.isEmpty { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + } else { + CardBackView(cardSize: cardSize) + } + Text("\(viewModel.state.stock.count)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + + DropHighlightView( + cardSize: cardSize, + isTargeted: false, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + .allowsHitTesting(false) + } + .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + } + ) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.handleStockTap() + } + .accessibilityLabel("Stock") + } +} + +struct WasteView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let fanSpacing: CGFloat + let isHintTargeted: Bool + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let drawingCardIDs: Set + let fanProgress: [UUID: Double] + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + let isDragSource: Bool = { + guard viewModel.isDragging, let selection = viewModel.selection else { return false } + if case .waste = selection.source { + return true + } + return false + }() + let visibleWaste = viewModel.visibleWasteCards() + let isSelected = visibleWaste.contains(where: { viewModel.isSelected(card: $0) }) + let fanWidth = fanSpacing * CGFloat(max(0, visibleWaste.count - 1)) + + ZStack(alignment: .topLeading) { + PilePlaceholderView(cardSize: cardSize) + .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) + ForEach(Array(visibleWaste.enumerated()), id: \.element.id) { index, card in + let isTopCard = index == visibleWaste.count - 1 + let isDragged = isTopCard && viewModel.isDragging && viewModel.isSelected(card: card) + let isDrawing = drawingCardIDs.contains(card.id) + let isHidden = hiddenCardIDs.contains(card.id) + let progress = fanProgress[card.id] ?? 1 + let xOffset = CGFloat(index) * fanSpacing * progress + let cardView = CardView( + card: card, + isSelected: viewModel.isSelected(card: card), + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + ) + .opacity(isDragged || isDrawing || isHidden ? 0 : 1) + .offset(x: xOffset, y: 0) + .zIndex(isTopCard ? 2 : Double(index)) + .allowsHitTesting(isTopCard && !isDrawing && !isHidden) + .cardFramePreference(card.id, xOffset: xOffset) + + if isTopCard { + cardView.gesture(dragGesture(.waste)) + } else { + cardView + } + } + } + .frame(width: cardSize.width + fanWidth, height: cardSize.height, alignment: .leading) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: WasteFrameKey.self, value: proxy.frame(in: .named("board"))) + } + ) + .onTapGesture { + viewModel.handleWasteTap() + } + .zIndex(isDragSource || isSelected ? 10 : 0) + .accessibilityLabel("Waste") + } +} diff --git a/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift b/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift new file mode 100644 index 0000000..255755b --- /dev/null +++ b/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import Observation + +struct KlondikeTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let wasteFanSpacing: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let isStockHinted: Bool + let isWasteHinted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let drawingCardIDs: Set + let fanProgress: [UUID: Double] + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + StockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken + ) + .frame(width: cardSize.width, alignment: .leading) + + WasteView( + viewModel: viewModel, + cardSize: cardSize, + fanSpacing: wasteFanSpacing, + isHintTargeted: isWasteHinted, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) + // Keep top-row columns aligned with tableau; waste fan can overflow visually + // without changing foundation positions. + .frame(width: cardSize.width, alignment: .leading) + + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + + ForEach(0..<4, id: \.self) { index in + FoundationView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isTargeted: activeTarget == .foundation(index), + isHintTargeted: hintedTarget == .foundation(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index bffb927..6951ebc 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -2,6 +2,7 @@ import SwiftUI struct RulesAndScoringView: View { @Environment(\.dismiss) private var dismiss + @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue enum Section: String, CaseIterable, Identifiable { case rules = "Rules" @@ -38,6 +39,10 @@ struct RulesAndScoringView: View { TermRow(term: "Draw mode", definition: "How many cards are drawn from stock each time: 1-card or 3-card.") ] + private var gameVariant: GameVariant { + GameVariant(rawValue: gameVariantRawValue) ?? .klondike + } + private let scoringRows: [ScoringRow] = [ ScoringRow(move: "Waste to Tableau", points: Scoring.delta(for: .wasteToTableau), note: nil), ScoringRow(move: "Waste to Foundation", points: Scoring.delta(for: .wasteToFoundation), note: nil), @@ -129,7 +134,7 @@ struct RulesAndScoringView: View { private var termsCard: some View { sectionCard(title: "Terms") { VStack(alignment: .leading, spacing: 10) { - ForEach(terms) { row in + ForEach(termsForCurrentVariant) { row in VStack(alignment: .leading, spacing: 2) { Text(row.term) .font(.subheadline.weight(.semibold)) @@ -145,12 +150,9 @@ struct RulesAndScoringView: View { private var rulesCard: some View { sectionCard(title: "Rules") { VStack(alignment: .leading, spacing: 8) { - rulesRow("Build tableau piles down by alternating colors.") - rulesRow("Move Aces to foundations first, then build each suit up to King.") - rulesRow("Only Kings can fill an empty tableau pile.") - rulesRow("In 1-card draw, flip one stock card at a time. In 3-card draw, flip three.") - rulesRow("When stock is empty, recycle waste to stock and continue.") - rulesRow("You win by moving all 52 cards to foundations.") + ForEach(rulesForCurrentVariant, id: \.self) { rule in + rulesRow(rule) + } } } } @@ -168,7 +170,7 @@ struct RulesAndScoringView: View { GridRow { Divider().gridCellColumns(2) } - ForEach(scoringRows) { row in + ForEach(scoringRowsForCurrentVariant) { row in GridRow(alignment: .top) { VStack(alignment: .leading, spacing: 2) { Text(row.move) @@ -211,6 +213,55 @@ struct RulesAndScoringView: View { } return "\(points)" } + + private var termsForCurrentVariant: [TermRow] { + switch gameVariant { + case .klondike: + return terms + case .freecell: + return [ + TermRow(term: "Cascade", definition: "One of eight tableau columns where all cards are face up."), + TermRow(term: "Free Cell", definition: "A temporary single-card holding slot (four total)."), + TermRow(term: "Foundation", definition: "Four suit piles built from Ace to King."), + TermRow(term: "Supermove", definition: "A multi-card move enabled by available free cells and empty cascades.") + ] + } + } + + private var rulesForCurrentVariant: [String] { + switch gameVariant { + case .klondike: + return [ + "Build tableau piles down by alternating colors.", + "Move Aces to foundations first, then build each suit up to King.", + "Only Kings can fill an empty tableau pile.", + "In 1-card draw, flip one stock card at a time. In 3-card draw, flip three.", + "When stock is empty, recycle waste to stock and continue.", + "You win by moving all 52 cards to foundations." + ] + case .freecell: + return [ + "Deal all 52 cards face up into eight cascades (four with 7 cards, four with 6 cards).", + "Build cascades down by alternating colors.", + "Use the four free cells as temporary storage for one card each.", + "Build foundations by suit from Ace to King.", + "Any card may move to an empty cascade.", + "You win by moving all 52 cards to foundations." + ] + } + } + + private var scoringRowsForCurrentVariant: [ScoringRow] { + switch gameVariant { + case .klondike: + return scoringRows + case .freecell: + return [ + ScoringRow(move: "Move cards", points: 0, note: "FreeCell currently tracks time and completion."), + ScoringRow(move: "Win time bonus", points: Scoring.timedMaxBonusDrawThree, note: "Reduced by elapsed time.") + ] + } + } } #Preview { diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index b774595..aa5cb2e 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -41,35 +41,68 @@ enum TableBackgroundColor: String, CaseIterable, Identifiable { } } +enum CardStyle: String, CaseIterable, Identifiable { + case classic + case pixel + + var id: String { rawValue } + + var title: String { + switch self { + case .classic: return "Classic" + case .pixel: return "Pixel" + } + } + + var subtitle: String { + switch self { + case .classic: return "Parchment" + case .pixel: return "8-bit Retro" + } + } +} + enum SettingsKey { static let cardTiltEnabled = "settings.cardTiltEnabled" + static let gameVariant = "settings.gameVariant" static let drawMode = "settings.drawMode" static let tableBackgroundColor = "settings.tableBackgroundColor" static let feltEffectEnabled = "settings.feltEffectEnabled" static let soundEffectsEnabled = "settings.soundEffectsEnabled" static let showHintButton = "settings.showHintButton" + static let cardStyle = "settings.cardStyle" } struct SettingsView: View { @Environment(\.dismiss) private var dismiss @State private var isShowingRulesAndScoring = false @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true + @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true @AppStorage(SettingsKey.soundEffectsEnabled) private var isSoundEffectsEnabled = true @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true + @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.classic.rawValue var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { SettingsCard(title: "Table") { VStack(alignment: .leading, spacing: 12) { - Text("Background color") - .font(.subheadline.weight(.semibold)) - VStack(spacing: 8) { + HStack { + Text("Background color") + .font(.subheadline.weight(.semibold)) + Spacer() + if let selected = TableBackgroundColor(rawValue: tableBackgroundColorRawValue) { + Text(selected.label) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + HStack(spacing: 8) { ForEach(TableBackgroundColor.allCases) { option in - backgroundColorRow(option) + colorSwatch(option) } } Toggle(isOn: $isFeltEffectEnabled) { @@ -86,16 +119,25 @@ struct SettingsView: View { } SettingsCard(title: "Cards") { - Toggle(isOn: $isCardTiltEnabled) { - VStack(alignment: .leading, spacing: 4) { - Text("Natural card tilt") - .font(.subheadline.weight(.semibold)) - Text("Adds a subtle organic angle to each card.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 12) { + Text("Card style") + .font(.subheadline.weight(.semibold)) + HStack(spacing: 12) { + ForEach(CardStyle.allCases) { style in + cardStyleCard(style) + } + } + Toggle(isOn: $isCardTiltEnabled) { + VStack(alignment: .leading, spacing: 4) { + Text("Natural card tilt") + .font(.subheadline.weight(.semibold)) + Text("Adds a subtle organic angle to each card.") + .font(.caption) + .foregroundStyle(.secondary) + } } + .toggleStyle(.switch) } - .toggleStyle(.switch) } SettingsCard(title: "Audio") { @@ -124,20 +166,34 @@ struct SettingsView: View { .toggleStyle(.switch) } - SettingsCard(title: "Draw Mode") { - VStack(alignment: .leading, spacing: 4) { - Text("Stock draw") - .font(.subheadline.weight(.semibold)) - Picker("Stock draw", selection: $drawModeRawValue) { - ForEach(DrawMode.allCases, id: \.rawValue) { mode in - Text(mode.title).tag(mode.rawValue) + VStack(alignment: .leading, spacing: 10) { + Text("Game Type") + .font(.headline) + .padding(.leading, 4) + + HStack(spacing: 12) { + ForEach(GameVariant.allCases, id: \.rawValue) { variant in + variantCard(variant) + } + } + } + + if gameVariantRawValue == GameVariant.klondike.rawValue { + SettingsCard(title: "Draw Mode") { + VStack(alignment: .leading, spacing: 4) { + Text("Stock draw") + .font(.subheadline.weight(.semibold)) + Picker("Stock draw", selection: $drawModeRawValue) { + ForEach(DrawMode.allCases, id: \.rawValue) { mode in + Text(mode.title).tag(mode.rawValue) + } } + .labelsHidden() + .pickerStyle(.segmented) + Text("Choose how many cards to draw from the stock.") + .font(.caption) + .foregroundStyle(.secondary) } - .labelsHidden() - .pickerStyle(.segmented) - Text("Choose how many cards to draw from the stock.") - .font(.caption) - .foregroundStyle(.secondary) } } @@ -182,33 +238,184 @@ struct SettingsView: View { guard oldValue != newValue else { return } HapticManager.shared.play(.settingsSelection) } + .onChange(of: gameVariantRawValue) { oldValue, newValue in + guard oldValue != newValue else { return } + HapticManager.shared.play(.settingsSelection) + } + .onChange(of: cardStyleRawValue) { oldValue, newValue in + guard oldValue != newValue else { return } + HapticManager.shared.play(.settingsSelection) + } } - private func backgroundColorRow(_ option: TableBackgroundColor) -> some View { - let isSelected = tableBackgroundColorRawValue == option.rawValue + private func variantCard(_ variant: GameVariant) -> some View { + let isSelected = gameVariantRawValue == variant.rawValue return Button { guard !isSelected else { return } HapticManager.shared.play(.settingsSelection) - tableBackgroundColorRawValue = option.rawValue + withAnimation(.smooth(duration: 0.3)) { + gameVariantRawValue = variant.rawValue + } + } label: { + VStack(spacing: 3) { + Text(variant.title) + .font(.subheadline.weight(.bold)) + + Text(variant.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 10) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? .thickMaterial : .thinMaterial) + .shadow( + color: .black.opacity(isSelected ? 0.12 : 0.04), + radius: isSelected ? 8 : 2, + y: isSelected ? 4 : 1 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + Color.accentColor.opacity(isSelected ? 1 : 0), + lineWidth: 2.5 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + Color.primary.opacity(isSelected ? 0 : 0.1), + lineWidth: 1 + ) + } + .opacity(isSelected ? 1 : 0.7) + .scaleEffect(isSelected ? 1.0 : 0.96) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + private func cardStyleCard(_ style: CardStyle) -> some View { + let isSelected = cardStyleRawValue == style.rawValue + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + withAnimation(.smooth(duration: 0.3)) { + cardStyleRawValue = style.rawValue + } } label: { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(option.color) - .frame(width: 36, height: 24) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.primary.opacity(0.2), lineWidth: 1) + VStack(spacing: 6) { + cardStylePreview(style) + .frame(width: 44, height: 64) + + VStack(spacing: 1) { + Text(style.title) + .font(.caption.weight(.bold)) + Text(style.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? .thickMaterial : .thinMaterial) + .shadow( + color: .black.opacity(isSelected ? 0.12 : 0.04), + radius: isSelected ? 8 : 2, + y: isSelected ? 4 : 1 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + Color.accentColor.opacity(isSelected ? 1 : 0), + lineWidth: 2.5 + ) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + Color.primary.opacity(isSelected ? 0 : 0.1), + lineWidth: 1 ) - Text(option.label) - .font(.subheadline.weight(.semibold)) - Spacer() - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) } + .opacity(isSelected ? 1 : 0.7) + .scaleEffect(isSelected ? 1.0 : 0.96) .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + + @ViewBuilder + private func cardStylePreview(_ style: CardStyle) -> some View { + switch style { + case .classic: + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(red: 0.98, green: 0.96, blue: 0.91)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.black.opacity(0.2), lineWidth: 0.5) + ) + .overlay( + VStack(alignment: .leading, spacing: 1) { + Text("A") + .font(.system(size: 11, weight: .bold, design: .serif)) + Image(systemName: "suit.spade.fill") + .font(.system(size: 8)) + } + .foregroundStyle(Color(red: 0.12, green: 0.12, blue: 0.12)) + .padding(4), + alignment: .topLeading + ) + case .pixel: + PixelCardFrontView( + card: Card(suit: .hearts, rank: .king, isFaceUp: true), + cardSize: CGSize(width: 44, height: 64), + isSelected: false + ) + } + } + + private func colorSwatch(_ option: TableBackgroundColor) -> some View { + let isSelected = tableBackgroundColorRawValue == option.rawValue + + return Button { + guard !isSelected else { return } + HapticManager.shared.play(.settingsSelection) + tableBackgroundColorRawValue = option.rawValue + } label: { + Circle() + .fill(option.color) + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + } + .overlay { + Circle() + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.18), + lineWidth: isSelected ? 2.5 : 1 + ) + } + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + .buttonStyle(.plain) + .accessibilityLabel(option.label) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/ComputerSolitaire/Views/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift similarity index 100% rename from ComputerSolitaire/Views/BoardOverlayViews.swift rename to ComputerSolitaire/Views/Shared/BoardOverlayViews.swift diff --git a/ComputerSolitaire/Views/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift similarity index 75% rename from ComputerSolitaire/Views/BoardViews.swift rename to ComputerSolitaire/Views/Shared/BoardViews.swift index e901ab1..2786a9e 100644 --- a/ComputerSolitaire/Views/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -1,6 +1,19 @@ import SwiftUI import Observation +// MARK: - CardStyle Environment Key + +private struct CardStyleKey: EnvironmentKey { + static let defaultValue: CardStyle = .classic +} + +extension EnvironmentValues { + var cardStyle: CardStyle { + get { self[CardStyleKey.self] } + set { self[CardStyleKey.self] = newValue } + } +} + enum Layout { struct Metrics { let horizontalPadding: CGFloat @@ -13,7 +26,12 @@ enum Layout { let wasteFanSpacing: CGFloat } - static func metrics(for boardSize: CGSize, isRegularWidth: Bool = false) -> Metrics { + static func metrics( + for boardSize: CGSize, + isRegularWidth: Bool = false, + tableauColumnCount: Int = 7 + ) -> Metrics { + let columnCount = max(1, tableauColumnCount) let boardWidth = boardSize.width #if os(iOS) let isCompactBoard = boardWidth <= 430 @@ -26,7 +44,7 @@ enum Layout { let columnSpacing: CGFloat = isCompactBoard ? 8 : (isMediumBoard ? 10 : 18) let usableWidth = max(0, boardWidth - (horizontalPadding * 2)) - let fittedCardWidth = floor((usableWidth - (columnSpacing * 6)) / 7) + let fittedCardWidth = floor((usableWidth - (columnSpacing * CGFloat(columnCount - 1))) / CGFloat(columnCount)) let maxCardWidth: CGFloat = isPadLandscape ? 112 : (boardWidth < 760 ? 96 : 120) let cardWidth = max(32, min(maxCardWidth, fittedCardWidth)) let cardSize = CGSize(width: cardWidth, height: cardWidth * 1.45) @@ -64,7 +82,7 @@ enum Layout { let rowSpacing = min(22, max(14, columnSpacing + 4)) let usableWidth = max(0, boardWidth - (horizontalPadding * 2)) - let fittedCardWidth = floor((usableWidth - (columnSpacing * 6)) / 7) + let fittedCardWidth = floor((usableWidth - (columnSpacing * CGFloat(columnCount - 1))) / CGFloat(columnCount)) let maxCardWidth = min(124, max(88, boardWidth * 0.095)) let cardWidth = max(52, min(maxCardWidth, fittedCardWidth)) let cardSize = CGSize(width: cardWidth, height: cardWidth * 1.45) @@ -188,6 +206,7 @@ struct StatTileView: View { struct TopRowView: View { @Bindable var viewModel: SolitaireViewModel + let variant: GameVariant let cardSize: CGSize let columnSpacing: CGFloat let wasteFanSpacing: CGFloat @@ -206,44 +225,35 @@ struct TopRowView: View { let dragGesture: (DragOrigin) -> AnyGesture var body: some View { - HStack(alignment: .top, spacing: columnSpacing) { - StockView( - viewModel: viewModel, - cardSize: cardSize, - isHintTargeted: isStockHinted, - hintHighlightOpacity: hintHighlightOpacity, - hintWiggleToken: hintWiggleToken - ) - .frame(width: cardSize.width, alignment: .leading) - WasteView( - viewModel: viewModel, - cardSize: cardSize, - fanSpacing: wasteFanSpacing, - isHintTargeted: isWasteHinted, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hiddenCardIDs: hiddenCardIDs, - hintedCardIDs: hintedCardIDs, - hintWiggleToken: hintWiggleToken, - drawingCardIDs: drawingCardIDs, - fanProgress: fanProgress, - dragGesture: dragGesture - ) - // Keep top-row columns aligned with tableau; waste fan can overflow visually - // without changing foundation positions. - .frame(width: cardSize.width, alignment: .leading) - - Color.clear - .frame(width: cardSize.width, height: cardSize.height) - .accessibilityHidden(true) - - ForEach(0..<4, id: \.self) { index in - FoundationView( + Group { + switch variant { + case .klondike: + KlondikeTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + wasteFanSpacing: wasteFanSpacing, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + isStockHinted: isStockHinted, + isWasteHinted: isWasteHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) + case .freecell: + FreeCellTopRowView( viewModel: viewModel, - index: index, cardSize: cardSize, - isTargeted: activeTarget == .foundation(index), - isHintTargeted: hintedTarget == .foundation(index), + columnSpacing: columnSpacing, + activeTarget: activeTarget, + hintedTarget: hintedTarget, hintHighlightOpacity: hintHighlightOpacity, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, @@ -252,12 +262,8 @@ struct TopRowView: View { hintWiggleToken: hintWiggleToken, dragGesture: dragGesture ) - .frame(width: cardSize.width, alignment: .leading) } } -#if os(iOS) - .frame(maxWidth: .infinity, alignment: .leading) -#endif } } @@ -279,7 +285,7 @@ struct TableauRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { - ForEach(0..<7, id: \.self) { index in + ForEach(Array(viewModel.state.tableau.indices), id: \.self) { index in TableauPileView( viewModel: viewModel, pileIndex: index, @@ -304,124 +310,6 @@ struct TableauRowView: View { } } -struct StockView: View { - @Bindable var viewModel: SolitaireViewModel - let cardSize: CGSize - let isHintTargeted: Bool - let hintHighlightOpacity: Double - let hintWiggleToken: UUID - - var body: some View { - ZStack { - PilePlaceholderView(cardSize: cardSize) - .allowsHitTesting(false) - if viewModel.state.stock.isEmpty { - Image(systemName: "arrow.counterclockwise") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) - } else { - CardBackView(cardSize: cardSize) - } - Text("\(viewModel.state.stock.count)") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white.opacity(0.8)) - .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) - - DropHighlightView( - cardSize: cardSize, - isTargeted: false, - isHintTargeted: isHintTargeted, - hintOpacity: hintHighlightOpacity - ) - .allowsHitTesting(false) - } - .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) - } - ) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.handleStockTap() - } - .accessibilityLabel("Stock") - } -} - -struct WasteView: View { - @Bindable var viewModel: SolitaireViewModel - let cardSize: CGSize - let fanSpacing: CGFloat - let isHintTargeted: Bool - let isCardTiltEnabled: Bool - @Binding var cardTilts: [UUID: Double] - let hiddenCardIDs: Set - let hintedCardIDs: Set - let hintWiggleToken: UUID - let drawingCardIDs: Set - let fanProgress: [UUID: Double] - let dragGesture: (DragOrigin) -> AnyGesture - - var body: some View { - let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .waste = selection.source { - return true - } - return false - }() - let visibleWaste = viewModel.visibleWasteCards() - let isSelected = visibleWaste.contains(where: { viewModel.isSelected(card: $0) }) - let fanWidth = fanSpacing * CGFloat(max(0, visibleWaste.count - 1)) - - ZStack(alignment: .topLeading) { - PilePlaceholderView(cardSize: cardSize) - .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) - ForEach(Array(visibleWaste.enumerated()), id: \.element.id) { index, card in - let isTopCard = index == visibleWaste.count - 1 - let isDragged = isTopCard && viewModel.isDragging && viewModel.isSelected(card: card) - let isDrawing = drawingCardIDs.contains(card.id) - let isHidden = hiddenCardIDs.contains(card.id) - let progress = fanProgress[card.id] ?? 1 - let xOffset = CGFloat(index) * fanSpacing * progress - let cardView = CardView( - card: card, - isSelected: viewModel.isSelected(card: card), - cardSize: cardSize, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil - ) - .opacity(isDragged || isDrawing || isHidden ? 0 : 1) - .offset(x: xOffset, y: 0) - .zIndex(isTopCard ? 2 : Double(index)) - .allowsHitTesting(isTopCard && !isDrawing && !isHidden) - .cardFramePreference(card.id, xOffset: xOffset) - - if isTopCard { - cardView.gesture(dragGesture(.waste)) - } else { - cardView - } - } - } - .frame(width: cardSize.width + fanWidth, height: cardSize.height, alignment: .leading) - .background( - GeometryReader { proxy in - Color.clear - .preference(key: WasteFrameKey.self, value: proxy.frame(in: .named("board"))) - } - ) - .onTapGesture { - viewModel.handleWasteTap() - } - .zIndex(isDragSource || isSelected ? 10 : 0) - .accessibilityLabel("Waste") - } -} - struct FoundationView: View { @Bindable var viewModel: SolitaireViewModel let index: Int @@ -450,6 +338,12 @@ struct FoundationView: View { let highlightZ: Double = 1 ZStack { PilePlaceholderView(cardSize: cardSize) + if foundation.isEmpty { + Image(systemName: "a") + .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.28)) + .allowsHitTesting(false) + } DropHighlightView( cardSize: cardSize, isTargeted: isTargeted, @@ -528,111 +422,118 @@ struct TableauPileView: View { let dragGesture: (DragOrigin) -> AnyGesture var body: some View { - let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .tableau(let pile, _) = selection.source { - return pile == pileIndex - } - return false - }() - - let pile = viewModel.state.tableau[pileIndex] - let yOffsets = tableauYOffsets(for: pile) - let topCardYOffset = yOffsets.last ?? 0 - let stackDropYOffset = dropYOffset(for: pile, yOffsets: yOffsets) - let height = max(cardSize.height, cardSize.height + topCardYOffset) - let highlightYOffset: CGFloat = { - guard viewModel.isDragging, let selection = viewModel.selection else { - return stackDropYOffset - } - if case .tableau(let sourcePile, let sourceIndex) = selection.source, - sourcePile == pileIndex, - sourceIndex < yOffsets.count { - return yOffsets[sourceIndex] - } - return stackDropYOffset - }() - let highlightZ: Double = Double(pile.count) + 0.5 - - ZStack(alignment: .top) { - Color.clear - .frame(width: cardSize.width, height: height) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: nil) + if viewModel.state.tableau.indices.contains(pileIndex) { + let isDragSource: Bool = { + guard viewModel.isDragging, let selection = viewModel.selection else { return false } + if case .tableau(let pile, _) = selection.source { + return pile == pileIndex } + return false + }() + + let pile = viewModel.state.tableau[pileIndex] + let yOffsets = tableauYOffsets(for: pile) + let topCardYOffset = yOffsets.last ?? 0 + let stackDropYOffset = dropYOffset(for: pile, yOffsets: yOffsets) + let height = max(cardSize.height, cardSize.height + topCardYOffset) + let highlightYOffset: CGFloat = { + guard viewModel.isDragging, let selection = viewModel.selection else { + return stackDropYOffset + } + if case .tableau(let sourcePile, let sourceIndex) = selection.source, + sourcePile == pileIndex, + sourceIndex < yOffsets.count { + return yOffsets[sourceIndex] + } + return stackDropYOffset + }() + let highlightZ: Double = Double(pile.count) + 0.5 - PilePlaceholderView(cardSize: cardSize) - DropHighlightView( - cardSize: cardSize, - isTargeted: isTargeted, - isHintTargeted: isHintTargeted, - hintOpacity: hintHighlightOpacity - ) - .offset(y: highlightYOffset) - .zIndex(highlightZ) + ZStack(alignment: .top) { + Color.clear + .frame(width: cardSize.width, height: height) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: nil) + } - ForEach(Array(pile.enumerated()), id: \.element.id) { index, card in - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) - let isHidden = hiddenCardIDs.contains(card.id) - let yOffset = yOffsets[index] - let cardView = CardView( - card: card, - isSelected: viewModel.isSelected(card: card), + PilePlaceholderView(cardSize: cardSize) + DropHighlightView( cardSize: cardSize, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + isTargeted: isTargeted, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity ) - .opacity(isDragged || isHidden ? 0 : 1) - .offset(x: 0, y: yOffset) - .zIndex(isDragged ? 20 + Double(index) : Double(index)) - .allowsHitTesting(!isHidden) - .onTapGesture { - viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: index) - } - .cardFramePreference(card.id, yOffset: yOffset) + .offset(y: highlightYOffset) + .zIndex(highlightZ) + + ForEach(Array(pile.enumerated()), id: \.element.id) { index, card in + let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isHidden = hiddenCardIDs.contains(card.id) + let yOffset = yOffsets[index] + let cardView = CardView( + card: card, + isSelected: viewModel.isSelected(card: card), + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil + ) + .opacity(isDragged || isHidden ? 0 : 1) + .offset(x: 0, y: yOffset) + .zIndex(isDragged ? 20 + Double(index) : Double(index)) + .allowsHitTesting(!isHidden) + .onTapGesture { + viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: index) + } + .cardFramePreference(card.id, yOffset: yOffset) - cardView.gesture(dragGesture(.tableau(pile: pileIndex, index: index))) + cardView.gesture(dragGesture(.tableau(pile: pileIndex, index: index))) + } } - } - .frame(width: cardSize.width, height: height, alignment: .top) - .background( - GeometryReader { proxy in - let boardFrame = proxy.frame(in: .named("board")) - let snapFrame = CGRect( - x: boardFrame.minX, - y: boardFrame.minY + highlightYOffset, - width: cardSize.width, - height: cardSize.height - ) - let topCardFrame = CGRect( - x: boardFrame.minX, - y: boardFrame.minY + topCardYOffset, - width: cardSize.width, - height: cardSize.height - ) - let hitFrame = snapFrame - .union(topCardFrame) - .expanded( - horizontal: DropTargetHitArea.tableauHorizontalGrace, - top: DropTargetHitArea.tableauTopGrace, - bottom: DropTargetHitArea.tableauBottomGrace + .frame(width: cardSize.width, height: height, alignment: .top) + .background( + GeometryReader { proxy in + let boardFrame = proxy.frame(in: .named("board")) + let snapFrame = CGRect( + x: boardFrame.minX, + y: boardFrame.minY + highlightYOffset, + width: cardSize.width, + height: cardSize.height ) - Color.clear - .preference( - key: DropTargetFrameKey.self, - value: [ - .tableau(pileIndex): DropTargetGeometry( - snapFrame: snapFrame, - hitFrame: hitFrame - ) - ] + let topCardFrame = CGRect( + x: boardFrame.minX, + y: boardFrame.minY + topCardYOffset, + width: cardSize.width, + height: cardSize.height ) - } - ) - .zIndex(isDragSource ? 10 : 0) - .accessibilityLabel("Tableau \(pileIndex + 1)") + let hitFrame = snapFrame + .union(topCardFrame) + .expanded( + horizontal: DropTargetHitArea.tableauHorizontalGrace, + top: DropTargetHitArea.tableauTopGrace, + bottom: DropTargetHitArea.tableauBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .tableau(pileIndex): DropTargetGeometry( + snapFrame: snapFrame, + hitFrame: hitFrame + ) + ] + ) + } + ) + .zIndex(isDragSource ? 10 : 0) + .accessibilityLabel("Tableau \(pileIndex + 1)") + } else { + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .allowsHitTesting(false) + .accessibilityHidden(true) + } } private func tableauYOffsets(for pile: [Card]) -> [CGFloat] { @@ -657,13 +558,13 @@ struct TableauPileView: View { } } -private enum HintWiggleStyle { +enum HintWiggleStyle { static let angles: [Double] = [-1.4, 1.4, -0.8, 0.8, 0] static let stepDuration: Double = 0.13 static let stepSleepNanoseconds: UInt64 = 200_000_000 } -private struct HintWiggleModifier: ViewModifier { +struct HintWiggleModifier: ViewModifier { let token: UUID? @State private var wiggleAngle: Double = 0 @State private var wiggleTask: Task? @@ -705,7 +606,7 @@ private struct HintWiggleModifier: ViewModifier { } } -private extension View { +extension View { func hintWiggle(token: UUID?) -> some View { modifier(HintWiggleModifier(token: token)) } @@ -722,6 +623,7 @@ struct CardView: View { let flipDelay: Double @State private var flipRotation: Double @State private var tiltAngle: Double = 0 + @Environment(\.cardStyle) private var cardStyle init( card: Card, @@ -758,7 +660,7 @@ struct CardView: View { let backOpacity = flipRotation < 90 ? 0.0 : 1.0 ZStack { - cardFront( + cardFrontView( cornerRadius: cornerRadius, borderColor: borderColor, borderWidth: borderWidth, @@ -769,7 +671,7 @@ struct CardView: View { .opacity(frontOpacity) .rotation3DEffect(.degrees(frontAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.7) - cardBack( + cardBackView( cornerRadius: cornerRadius, borderColor: borderColor, borderWidth: borderWidth, @@ -821,7 +723,55 @@ struct CardView: View { } } - private func cardFront( + @ViewBuilder + private func cardFrontView( + cornerRadius: CGFloat, + borderColor: Color, + borderWidth: CGFloat, + shadowColor: Color, + shadowRadius: CGFloat, + shadowYOffset: CGFloat + ) -> some View { + switch cardStyle { + case .pixel: + PixelCardFrontView(card: card, cardSize: cardSize, isSelected: isSelected) + case .classic: + classicCardFront( + cornerRadius: cornerRadius, + borderColor: borderColor, + borderWidth: borderWidth, + shadowColor: shadowColor, + shadowRadius: shadowRadius, + shadowYOffset: shadowYOffset + ) + } + } + + @ViewBuilder + private func cardBackView( + cornerRadius: CGFloat, + borderColor: Color, + borderWidth: CGFloat, + shadowColor: Color, + shadowRadius: CGFloat, + shadowYOffset: CGFloat + ) -> some View { + switch cardStyle { + case .pixel: + PixelCardBackView(cardSize: cardSize, isSelected: isSelected) + case .classic: + classicCardBack( + cornerRadius: cornerRadius, + borderColor: borderColor, + borderWidth: borderWidth, + shadowColor: shadowColor, + shadowRadius: shadowRadius, + shadowYOffset: shadowYOffset + ) + } + } + + private func classicCardFront( cornerRadius: CGFloat, borderColor: Color, borderWidth: CGFloat, @@ -893,7 +843,7 @@ struct CardView: View { } } - private func cardBack( + private func classicCardBack( cornerRadius: CGFloat, borderColor: Color, borderWidth: CGFloat, @@ -984,13 +934,23 @@ struct CardBackPattern: View { struct CardBackView: View { let cardSize: CGSize + @Environment(\.cardStyle) private var cardStyle var body: some View { + switch cardStyle { + case .pixel: + PixelStandaloneCardBackView(cardSize: cardSize) + case .classic: + classicCardBack + } + } + + private var classicCardBack: some View { let cornerRadius = cardSize.width * 0.12 let lacquer = Color(red: 0.18, green: 0.26, blue: 0.52) let trim = Color(red: 0.78, green: 0.85, blue: 0.95) - ZStack { + return ZStack { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(lacquer) .overlay( diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift similarity index 93% rename from ComputerSolitaire/Views/ContentView.swift rename to ComputerSolitaire/Views/Shared/ContentView.swift index 6ce28ef..14a4a8d 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -72,6 +72,7 @@ extension View { } } + struct ContentView: View { @Environment(\.modelContext) private var modelContext @Environment(\.scenePhase) private var scenePhase @@ -127,8 +128,14 @@ struct ContentView: View { @State private var winCelebration = WinCelebrationController() @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true + @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true + @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.classic.rawValue + + private var gameVariant: GameVariant { + GameVariant(rawValue: gameVariantRawValue) ?? .klondike + } private var drawMode: DrawMode { DrawMode(rawValue: drawModeRawValue) ?? .three @@ -154,12 +161,17 @@ struct ContentView: View { #endif } + private var currentCardStyle: CardStyle { + CardStyle(rawValue: cardStyleRawValue) ?? .classic + } + var body: some View { sceneDecorations( for: AnyView( GeometryReader { geometry in boardRoot(for: geometry) } + .environment(\.cardStyle, currentCardStyle) ) ) } @@ -223,14 +235,16 @@ struct ContentView: View { startNewGameFromUI() } label: { Label("New Game", systemImage: "plus") - .labelStyle(.titleAndIcon) } + .labelStyle(.iconOnly) + .help("New Game") Button { redealFromUI() } label: { Label("Redeal", systemImage: "arrow.clockwise") - .labelStyle(.titleAndIcon) } + .labelStyle(.iconOnly) + .help("Redeal") } ToolbarSpacer(.fixed) ToolbarItemGroup(placement: .primaryAction) { @@ -248,6 +262,7 @@ struct ContentView: View { } label: { Label("Auto Finish", systemImage: "bolt") } + .labelStyle(.iconOnly) .help("Auto Finish") .disabled(isAutoFinishDisabled) if isHintButtonVisible { @@ -256,6 +271,7 @@ struct ContentView: View { } label: { Label("Hint", systemImage: "lightbulb") } + .labelStyle(.iconOnly) .help("Hint") .disabled(isHintDisabled) } @@ -264,12 +280,14 @@ struct ContentView: View { } label: { Label("Statistics", systemImage: "chart.bar") } + .labelStyle(.iconOnly) .help("Statistics") Button { isShowingSettings = true } label: { Label("Settings", systemImage: "gearshape") } + .labelStyle(.iconOnly) .help("Settings") } #endif @@ -296,10 +314,10 @@ struct ContentView: View { .sheet(isPresented: $isShowingStats) { #if os(iOS) NavigationStack { - StatisticsView(viewModel: viewModel) + StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) } #else - StatisticsView(viewModel: viewModel) + StatisticsView(viewModel: viewModel, initialVariant: viewModel.gameVariant) #endif } ) @@ -318,7 +336,16 @@ struct ContentView: View { let gameStateObservedView = AnyView( commandObservedView + .onChange(of: gameVariantRawValue) { _, newValue in + guard hasLoadedGame, !isHydratingGame else { return } + let variant = GameVariant(rawValue: newValue) ?? .klondike + stopAutoFinish() + winCelebration.reset(to: .idle) + viewModel.newGame(variant: variant, drawMode: drawMode) + persistGameNow() + } .onChange(of: drawModeRawValue) { (_, newValue: Int) in + guard viewModel.supportsDrawMode else { return } let mode = DrawMode(rawValue: newValue) ?? .three viewModel.updateDrawMode(mode) scheduleAutosave() @@ -391,15 +418,21 @@ struct ContentView: View { @ViewBuilder private func boardRoot(for geometry: GeometryProxy) -> some View { + let boardColumnCount = max(viewModel.state.tableau.count, viewModel.gameVariant == .freecell ? 8 : 7) #if os(iOS) - let metrics = Layout.metrics(for: geometry.size, isRegularWidth: horizontalSizeClass == .regular) + let metrics = Layout.metrics( + for: geometry.size, + isRegularWidth: horizontalSizeClass == .regular, + tableauColumnCount: boardColumnCount + ) #else - let metrics = Layout.metrics(for: geometry.size) + let metrics = Layout.metrics(for: geometry.size, tableauColumnCount: boardColumnCount) #endif let cardSize = metrics.cardSize let boardScaleFactor = boardScaleFactor(for: geometry.size) let effectiveCardSize = CGSize(width: cardSize.width * boardScaleFactor, height: cardSize.height * boardScaleFactor) - let boardContentWidth = (cardSize.width * 7) + (metrics.columnSpacing * 6) + let boardContentWidth = (cardSize.width * CGFloat(boardColumnCount)) + + (metrics.columnSpacing * CGFloat(max(0, boardColumnCount - 1))) let isBoardReady = hasLoadedGame && !isHydratingGame let hintedTarget: DropTarget? = { guard let destination = viewModel.hintedDestination else { return nil } @@ -425,6 +458,7 @@ struct ContentView: View { } TopRowView( viewModel: viewModel, + variant: viewModel.gameVariant, cardSize: cardSize, columnSpacing: metrics.columnSpacing, wasteFanSpacing: metrics.wasteFanSpacing, @@ -668,7 +702,7 @@ struct ContentView: View { #if os(macOS) private var gameMenuActions: GameMenuActions { GameMenuActions( - newGame: startNewGameFromUI, + newGame: { startNewGameFromUI() }, redeal: redealFromUI, undo: { stopAutoFinish() @@ -703,10 +737,11 @@ struct ContentView: View { viewModel.requestHint() } - private func startNewGameFromUI() { + private func startNewGameFromUI(variant: GameVariant? = nil) { stopAutoFinish() winCelebration.reset(to: .idle) - viewModel.newGame(drawMode: drawMode) + let selectedVariant = variant ?? gameVariant + viewModel.newGame(variant: selectedVariant, drawMode: drawMode) persistGameNow() } @@ -808,6 +843,8 @@ struct ContentView: View { started = viewModel.startDragFromWaste() case .foundation(let index): started = viewModel.startDragFromFoundation(index: index) + case .freeCell(let index): + started = viewModel.startDragFromFreeCell(index: index) case .tableau(let pile, let index): started = viewModel.startDragFromTableau(pileIndex: pile, cardIndex: index) } @@ -1013,6 +1050,8 @@ struct ContentView: View { private func destination(for target: DropTarget) -> Destination { switch target { + case .freeCell(let index): + return .freeCell(index) case .foundation(let index): return .foundation(index) case .tableau(let index): @@ -1022,6 +1061,8 @@ struct ContentView: View { private func dropTarget(for destination: Destination) -> DropTarget { switch destination { + case .freeCell(let index): + return .freeCell(index) case .foundation(let index): return .foundation(index) case .tableau(let index): @@ -1186,6 +1227,7 @@ struct ContentView: View { var lookup: [UUID: Card] = [:] for card in state.stock { lookup[card.id] = card } for card in state.waste { lookup[card.id] = card } + for card in state.freeCells.compactMap({ $0 }) { lookup[card.id] = card } for pile in state.foundations { for card in pile { lookup[card.id] = card } } @@ -1198,6 +1240,7 @@ struct ContentView: View { private enum CardLocation: Equatable { case stock(Int) case waste(Int) + case freeCell(Int) case foundation(pile: Int, index: Int) case tableau(pile: Int, index: Int) } @@ -1211,6 +1254,11 @@ struct ContentView: View { for (index, card) in state.waste.enumerated() { locations[card.id] = .waste(index) } + for (index, card) in state.freeCells.enumerated() { + if let card { + locations[card.id] = .freeCell(index) + } + } for (pile, cards) in state.foundations.enumerated() { for (index, card) in cards.enumerated() { locations[card.id] = .foundation(pile: pile, index: index) @@ -1287,7 +1335,8 @@ struct ContentView: View { case (.none, _), (_, .none): return false case (.some(.stock(_)), .some(.stock(_))), - (.some(.waste(_)), .some(.waste(_))): + (.some(.waste(_)), .some(.waste(_))), + (.some(.freeCell(_)), .some(.freeCell(_))): return false default: return true @@ -1305,12 +1354,15 @@ struct ContentView: View { } if let payload = GamePersistence.load(from: modelContext), viewModel.restore(from: payload) { - if drawModeRawValue != viewModel.stockDrawCount { + if gameVariantRawValue != viewModel.gameVariant.rawValue { + gameVariantRawValue = viewModel.gameVariant.rawValue + } + if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { drawModeRawValue = viewModel.stockDrawCount } } else { winCelebration.reset(to: .idle) - viewModel.newGame(drawMode: drawMode) + viewModel.newGame(variant: gameVariant, drawMode: drawMode) persistGameNow() } winCelebration.syncForLoadedGame( diff --git a/ComputerSolitaire/Views/Shared/PixelCardRenderer.swift b/ComputerSolitaire/Views/Shared/PixelCardRenderer.swift new file mode 100644 index 0000000..182c155 --- /dev/null +++ b/ComputerSolitaire/Views/Shared/PixelCardRenderer.swift @@ -0,0 +1,766 @@ +import SwiftUI + +// MARK: - Pixel Art Color Palette + +enum PixelPalette { + // Card face + static let cardFace = Color(red: 0.97, green: 0.96, blue: 0.93) + static let outline = Color(red: 0.13, green: 0.12, blue: 0.15) + + // Suit inks + static let red = Color(red: 0.80, green: 0.14, blue: 0.16) + static let redLight = Color(red: 0.93, green: 0.42, blue: 0.40) + static let black = Color(red: 0.13, green: 0.12, blue: 0.15) + static let blackLight = Color(red: 0.38, green: 0.38, blue: 0.44) + + // Face card palette + static let skinTone = Color(red: 0.95, green: 0.80, blue: 0.63) + static let skinShadow = Color(red: 0.80, green: 0.60, blue: 0.44) + static let gold = Color(red: 0.89, green: 0.71, blue: 0.22) + static let robeRed = Color(red: 0.70, green: 0.15, blue: 0.20) + static let robeRedDark = Color(red: 0.48, green: 0.09, blue: 0.14) + static let robeBlue = Color(red: 0.22, green: 0.32, blue: 0.66) + static let robeBlueDark = Color(red: 0.13, green: 0.19, blue: 0.44) + static let hair = Color(red: 0.32, green: 0.22, blue: 0.13) + static let ermine = Color(red: 0.96, green: 0.95, blue: 0.92) + static let accent = Color(red: 0.83, green: 0.22, blue: 0.25) + + // Card back + static let backDeep = Color(red: 0.11, green: 0.16, blue: 0.37) + static let backMid = Color(red: 0.21, green: 0.29, blue: 0.56) + static let backBright = Color(red: 0.55, green: 0.64, blue: 0.90) + static let backGold = Color(red: 0.85, green: 0.68, blue: 0.25) + + static func suitColor(for suit: Suit) -> Color { + suit.isRed ? red : black + } + + static func suitHighlight(for suit: Suit) -> Color { + suit.isRed ? redLight : blackLight + } +} + +// MARK: - Card Silhouette (stepped pixel corners) + +struct PixelCardShape: InsettableShape { + /// One virtual pixel unit (card width / PixelCardArt.gridWidth). + let px: CGFloat + var insetAmount: CGFloat = 0 + + func path(in rect: CGRect) -> Path { + let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount) + let maxCorner = min(insetRect.width, insetRect.height) / 2 + let step = max(1, min(px, floor(maxCorner / 3))) + let corner = step * 3 + + var path = Path() + path.move(to: CGPoint(x: insetRect.minX + corner, y: insetRect.minY)) + path.addLine(to: CGPoint(x: insetRect.maxX - corner, y: insetRect.minY)) + path.addLine(to: CGPoint(x: insetRect.maxX - step * 2, y: insetRect.minY)) + path.addLine(to: CGPoint(x: insetRect.maxX - step * 2, y: insetRect.minY + step)) + path.addLine(to: CGPoint(x: insetRect.maxX - step, y: insetRect.minY + step)) + path.addLine(to: CGPoint(x: insetRect.maxX - step, y: insetRect.minY + step * 2)) + path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.minY + step * 2)) + path.addLine(to: CGPoint(x: insetRect.maxX, y: insetRect.maxY - step * 2)) + path.addLine(to: CGPoint(x: insetRect.maxX - step, y: insetRect.maxY - step * 2)) + path.addLine(to: CGPoint(x: insetRect.maxX - step, y: insetRect.maxY - step)) + path.addLine(to: CGPoint(x: insetRect.maxX - step * 2, y: insetRect.maxY - step)) + path.addLine(to: CGPoint(x: insetRect.maxX - step * 2, y: insetRect.maxY)) + path.addLine(to: CGPoint(x: insetRect.minX + step * 2, y: insetRect.maxY)) + path.addLine(to: CGPoint(x: insetRect.minX + step * 2, y: insetRect.maxY - step)) + path.addLine(to: CGPoint(x: insetRect.minX + step, y: insetRect.maxY - step)) + path.addLine(to: CGPoint(x: insetRect.minX + step, y: insetRect.maxY - step * 2)) + path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.maxY - step * 2)) + path.addLine(to: CGPoint(x: insetRect.minX, y: insetRect.minY + step * 2)) + path.addLine(to: CGPoint(x: insetRect.minX + step, y: insetRect.minY + step * 2)) + path.addLine(to: CGPoint(x: insetRect.minX + step, y: insetRect.minY + step)) + path.addLine(to: CGPoint(x: insetRect.minX + step * 2, y: insetRect.minY + step)) + path.addLine(to: CGPoint(x: insetRect.minX + step * 2, y: insetRect.minY)) + path.closeSubpath() + return path + } + + func inset(by amount: CGFloat) -> some InsettableShape { + var copy = self + copy.insetAmount += amount + return copy + } +} + +// MARK: - Sprite Storage + +/// Palette indices used inside sprite art strings. +enum PixelInk: UInt8 { + case none = 0 + case ink = 1 // "#" suit color + case inkHi = 2 // "+" suit highlight + case outlineDark = 3 // "K" + case skin = 4 // "S" + case skinShade = 5 // "s" + case gold = 6 // "G" + case robe = 7 // "R" + case robeDark = 8 // "D" + case hair = 9 // "H" + case white = 10 // "W" + case accent = 11 // "A" + case altRobe = 12 // "B" — contrasting garment (blue on red suits, red on black) +} + +struct PixelSprite { + let width: Int + let height: Int + let cells: [[UInt8]] + + init(_ art: String) { + let map: [Character: UInt8] = [ + ".": 0, "#": 1, "+": 2, "K": 3, "S": 4, "s": 5, + "G": 6, "R": 7, "D": 8, "H": 9, "W": 10, "A": 11, "B": 12, + ] + let lines = art.split(separator: "\n").map(String.init) + let w = lines.map(\.count).max() ?? 0 + cells = lines.map { line in + var row = line.map { map[$0] ?? 0 } + while row.count < w { row.append(0) } + return row + } + width = w + height = cells.count + } +} + +// MARK: - Sprite Art + +enum PixelSprites { + // Suits — 7x7, "+" marks the sheen used on large renditions. + static let spade = PixelSprite(""" + ...#... + ..###.. + .#+###. + ####### + ####### + ##.#.## + ..###.. + """) + + static let heart = PixelSprite(""" + .##.##. + #+##### + #+##### + ####### + .#####. + ..###.. + ...#... + """) + + static let diamond = PixelSprite(""" + ...#... + ..+##.. + .#+###. + ####### + .#####. + ..###.. + ...#... + """) + + static let club = PixelSprite(""" + ..###.. + ..+##.. + ####### + ####### + ##.#.## + ...#... + ..###.. + """) + + static func suit(_ suit: Suit) -> PixelSprite { + switch suit { + case .spades: return spade + case .hearts: return heart + case .diamonds: return diamond + case .clubs: return club + } + } + + // Compact pip suits — 5x5, sized so pip rows and columns never collide. + static let spadePip = PixelSprite(""" + ..#.. + .###. + ##### + ##### + ..#.. + """) + + static let heartPip = PixelSprite(""" + .#.#. + ##### + ##### + .###. + ..#.. + """) + + static let diamondPip = PixelSprite(""" + ..#.. + .###. + ##### + .###. + ..#.. + """) + + static let clubPip = PixelSprite(""" + .###. + ##### + ##### + ##.## + ..#.. + """) + + static func pipSuit(_ suit: Suit) -> PixelSprite { + switch suit { + case .spades: return spadePip + case .hearts: return heartPip + case .diamonds: return diamondPip + case .clubs: return clubPip + } + } + + // Rank glyphs — 5x7 ("10" is 7 wide). + static let ranks: [String: PixelSprite] = [ + "A": PixelSprite(""" + .###. + #...# + #...# + ##### + #...# + #...# + #...# + """), + "2": PixelSprite(""" + .###. + #...# + ....# + ...#. + ..#.. + .#... + ##### + """), + "3": PixelSprite(""" + ####. + ....# + ..##. + ....# + ....# + #...# + .###. + """), + "4": PixelSprite(""" + ...#. + ..##. + .#.#. + #..#. + ##### + ...#. + ...#. + """), + "5": PixelSprite(""" + ##### + #.... + ####. + ....# + ....# + #...# + .###. + """), + "6": PixelSprite(""" + .###. + #.... + #.... + ####. + #...# + #...# + .###. + """), + "7": PixelSprite(""" + ##### + ....# + ...#. + ..#.. + ..#.. + .#... + .#... + """), + "8": PixelSprite(""" + .###. + #...# + #...# + .###. + #...# + #...# + .###. + """), + "9": PixelSprite(""" + .###. + #...# + #...# + .#### + ....# + ....# + .###. + """), + "10": PixelSprite(""" + .#..##. + ##.#..# + .#.#..# + .#.#..# + .#.#..# + .#.#..# + .#..##. + """), + "J": PixelSprite(""" + ..### + ...#. + ...#. + ...#. + ...#. + #..#. + .##.. + """), + "Q": PixelSprite(""" + .###. + #...# + #...# + #...# + #.#.# + #..#. + .##.# + """), + "K": PixelSprite(""" + #...# + #..#. + #.#.. + ##... + #.#.. + #..#. + #...# + """), + ] + + static func rank(_ rank: Rank) -> PixelSprite { + ranks[rank.label] ?? ranks["A"]! + } + + // Face card portraits — 28x35, outlined forms in the classic style. + static let king = PixelSprite(""" + ........G..G..G..G..G....... + .......KGGGGGGGGGGGGGGK..... + .......KGGAGGGAAGGGAGGK..... + .......KGGGGGGGGGGGGGGK..... + .......KHHHHHHHHHHHHHHK..... + ......KHHSSSSSSSSSSSSHHK.... + ......KHHSSSSSSSSSSSSHHK.... + ......KHHSKSSSSSSSSKSHHK.... + ......KHHSSSSSssSSSSSHHK.... + ......KHHSHHHSSSSHHHSHHK.... + ......KHHHHHHSAASHHHHHHK.... + ......KHHHHHHHHHHHHHHHHK.... + .......KHHHHHHHHHHHHHHK..... + ........KHHHHHHHHHHHHK...... + ....KKKWWKKHHHHHHHHKKWWKKK.. + ...KRRKWWWWKHHHHHHKWWWWKRRK. + ..KRRRKWWWWKKHHHHKKWWWWKRRRK + ..KRRRRKWWWWKHHHHKWWWWKRRRRK + ..KRRRRKWWWWKKHHKKWWWWKRRRRK + ..KRRRRRKWWWWKKKKWWWWKRRRRRK + ..KRRRRRKKWWWWWWWWWWKKRRRRRK + ..KRRDRRRKKWWWWWWWWKKRRDRRRK + ..KRRDRRRRKKWWWWWWKKRRRDRRRK + ..KRRDRRRRRKKWWWWKKRRRRDRRRK + ..KRRDRRRRRRKWWWWKRRRRRDRRRK + ..KRRDRRRRRRKWWWWKRRRRRDRRRK + ..KRRDRRRRRRKWWWWKRRRRRDRRRK + ..KRRDRRRRRGKWWWWKGRRRRDRRRK + ..KRRDRRRRGGKWWWWKGGRRRDRRRK + ..KRRDRRRGGRKWWWWKRGGRRDRRRK + ..KRRDRRGGRRKWWWWKRRGGRDRRRK + ..KRRDRGGRRRKWWWWKRRRGGDRRRK + ..KRRDGGRRRRKWWWWKRRRRGGRRRK + ..KKKKKKKKKKKKKKKKKKKKKKKKKK + ............................ + """) + + static let queen = PixelSprite(""" + ..........G...G...G......... + .........KGGGGGGGGGK........ + .........KGAGGGGGAGK........ + .........KGGGGGGGGGK........ + ........KHHHHHHHHHHHK....... + .......KHHHSSSSSSHHHK....... + ......KHHHSSSSSSSSHHHK...... + ......KHHSSKSSSSKSSHHK...... + ......KHHSSSSSSSSSSHHK...... + ......KHHSSSSssSSSSHHK...... + ......KHHSSSSAASSSSHHK...... + ......KHHSSSSSSSSSSHHK...... + ......KHHHKSSSSSSKHHHK...... + ......KHHHKKSSSSKKHHHK...... + .....KHHHHKGGGGGGKHHHHK..... + .....KHHHKRRRRRRRRKHHHK..... + .....KHHKRRGGRRGGRRKHHK..... + ....KHHKRRKAAKKAAKRRKHHK.... + ....KHHKRRKAAAAAAKRRKHHK.... + ....KHHKRRRKAAAAKRRRKHHK.... + ....KHHKRRRRKAAKRRRRKHHK.... + ....KHHKGGGGGGGGGGGGKHHK.... + ...KHHKRRRRRRRRRRRRRRKHHK... + ...KHKRRRRRRRRRRRRRRRRKHK... + ...KKRRRDRRRRRRRRRRDRRRKK... + ..KRRRRRDRRRRRRRRRRDRRRRRK.. + ..KRRRRDRRRRRRRRRRRRDRRRRK.. + ..KRRRGRRGRRGRRGRRGRRGRRRK.. + ..KRRGRRGRRGRRGRRGRRGRRGRK.. + ..KRRRRRRRRRRRRRRRRRRRRRRK.. + ..KRDDRRRRRRRRRRRRRRRRDDRK.. + ..KDDDDRRRRRRRRRRRRRRDDDDK.. + ..KDDDDDDRRRRRRRRRRDDDDDDK.. + ..KDDDDDDDDDDDDDDDDDDDDDDK.. + ..KKKKKKKKKKKKKKKKKKKKKKKK.. + """) + + static let jack = PixelSprite(""" + ....................GAA..... + ...............KKK..GAA..... + ............KKKBBBKKGAA..... + ..........KBBBBBBBBBGGA..... + .........KBBBBBBBBBBBGK..... + ........KBBBBBBBBBBBBBBK.... + ........KKKKKKKKKKKKKKKK.... + .......KHHHHHHHHHHHHHHK..... + ......KHHSSSSSSSSSSSSHHK.... + ......KHHSSSSSSSSSSSSHHK.... + ......KHHSKSSSSSSSSKSHHK.... + ......KHHSSSSSssSSSSSHHK.... + ......KHHSSSSSAASSSSSHHK.... + ......KHHHSSSSSSSSSSHHHK.... + .......KHHKSSSSSSSSKHHK..... + .......KKKKKSSSSSSKKKKK..... + ..........KKWWWWWWKK........ + .....KKRRKWWWWWWWWWWKRRKK... + ....KRRRKWWKWWWWWWKWWKRRRK.. + ...KRRRKRRRKKWWWWKKRRRKRRRK. + ...KRRKRRGRRKKKKKKRRRRKRRRK. + ..KRRRKRRRGRRRRRRRRRRRKRRRK. + ..KRRRKRRRRGRRRRRRRRRRKRRRK. + ..KRRSKRRRRRGRRRRRRRRRKSRRK. + ..KRSSKRRRRRRGRRRRRRRRKSSRK. + ..KRSSKRRRRRRRGRRRRRRRKSSRK. + ..KRRKRRRRRRRRRGRRRRRRRKRRK. + ..KRRKRRRRRRRRRRGRRRRRRKRRK. + ..KKKKRRRRRRRRRRRGRRRRRKKKK. + .....KRRRRRRRRRRRRGRRRK..... + .....KRRRDRRRRRRRRRGRRK..... + .....KRRRDRRRRRRRRRRGRK..... + .....KRRRDDRRRRRRRRDDRK..... + .....KRRRRRRRRRRRRRRRRK..... + .....KKKKKKKKKKKKKKKKKK..... + """) + + static func portrait(for rank: Rank) -> PixelSprite? { + switch rank { + case .jack: return jack + case .queen: return queen + case .king: return king + default: return nil + } + } +} + +// MARK: - Painter + +enum PixelCardArt { + /// Virtual grid width; unit = cardWidth / gridWidth. At the 1.45 card + /// aspect ratio this yields an exact 40x58 pixel grid. + static let gridWidth: CGFloat = 40 + + /// Draws a sprite whose origin is given in grid units. `scale` is an + /// integer multiplier that keeps the art on the same pixel grid. + static func draw( + _ sprite: PixelSprite, + in context: GraphicsContext, + x: CGFloat, + y: CGFloat, + unit: CGFloat, + scale: CGFloat = 1, + flipped: Bool = false, + color: (PixelInk) -> Color? + ) { + for row in 0.. Color? = { pixelInk in + switch pixelInk { + case .ink, .inkHi: return ink + default: return nil + } + } + + // Corner indices: rank top-left / suit top-right, mirrored below. + draw(rankSprite, in: context, x: 3, y: 3, unit: unit, color: solid) + draw(suitSprite, in: context, x: gridWidth - 3 - 7, y: 3, unit: unit, color: solid) + draw( + rankSprite, in: context, + x: gridWidth - 3 - CGFloat(rankSprite.width), y: gridH - 3 - 7, + unit: unit, flipped: true, color: solid + ) + draw(suitSprite, in: context, x: 3, y: gridH - 3 - 7, unit: unit, flipped: true, color: solid) + + if let portrait = PixelSprites.portrait(for: card.rank) { + drawPortrait(portrait, card: card, in: context, centerY: centerY, unit: unit) + } else if card.rank == .ace { + let shaded: (PixelInk) -> Color? = { pixelInk in + switch pixelInk { + case .ink: return ink + case .inkHi: return PixelPalette.suitHighlight(for: card.suit) + default: return nil + } + } + draw( + suitSprite, in: context, + x: (gridWidth - 14) / 2, y: centerY - 7, + unit: unit, scale: 2, color: shaded + ) + } else { + let pipSprite = PixelSprites.pipSuit(card.suit) + for pip in pipPlacements(count: card.rank.rawValue) { + draw( + pipSprite, in: context, + x: pip.x, y: centerY + pip.dy - 2.5, + unit: unit, flipped: pip.dy > 0, color: solid + ) + } + } + } + + private static func drawPortrait( + _ portrait: PixelSprite, + card: Card, + in context: GraphicsContext, + centerY: CGFloat, + unit: CGFloat + ) { + let isRed = card.suit.isRed + let originX = (gridWidth - CGFloat(portrait.width)) / 2 + let originY = centerY - CGFloat(portrait.height) / 2 + + draw(portrait, in: context, x: originX, y: originY, unit: unit) { pixelInk in + switch pixelInk { + case .outlineDark: return PixelPalette.outline + case .skin: return PixelPalette.skinTone + case .skinShade: return PixelPalette.skinShadow + case .gold: return PixelPalette.gold + case .robe: return isRed ? PixelPalette.robeRed : PixelPalette.robeBlue + case .robeDark: return isRed ? PixelPalette.robeRedDark : PixelPalette.robeBlueDark + case .hair: return PixelPalette.hair + case .white: return PixelPalette.ermine + case .accent: return PixelPalette.accent + case .altRobe: return isRed ? PixelPalette.robeBlue : PixelPalette.robeRed + case .ink, .inkHi, .none: return nil + } + } + } + + // MARK: Pips + + struct PipPlacement { + let x: CGFloat // sprite origin column + let dy: CGFloat // sprite center offset from card center + } + + private static let leftCol: CGFloat = 10.5 + private static let midCol: CGFloat = 17.5 + private static let rightCol: CGFloat = 24.5 + + static func pipPlacements(count: Int) -> [PipPlacement] { + func cols(_ dys: [CGFloat]) -> [PipPlacement] { + dys.flatMap { dy in + [PipPlacement(x: leftCol, dy: dy), PipPlacement(x: rightCol, dy: dy)] + } + } + func mid(_ dys: [CGFloat]) -> [PipPlacement] { + dys.map { PipPlacement(x: midCol, dy: $0) } + } + + switch count { + case 2: return mid([-10, 10]) + case 3: return mid([-12, 0, 12]) + case 4: return cols([-11, 11]) + case 5: return cols([-11, 11]) + mid([0]) + case 6: return cols([-11, 0, 11]) + case 7: return cols([-11, 0, 11]) + mid([-5.5]) + case 8: return cols([-12, -4, 4, 12]) + case 9: return cols([-12, -4, 4, 12]) + mid([0]) + case 10: return cols([-12, -4, 4, 12]) + mid([-8, 8]) + default: return [] + } + } + + // MARK: Back + + static func drawBack(in context: GraphicsContext, size: CGSize, unit: CGFloat) { + let gridH = size.height / unit + let lastRow = Int(gridH.rounded(.down)) - 3 + + // Inner bright frame, one unit thick, inset 2 from the edge. + let frame = PixelPalette.backBright + fillCells(context, x: 2, y: 2, w: gridWidth - 4, h: 1, unit: unit, color: frame) + fillCells(context, x: 2, y: gridH - 3, w: gridWidth - 4, h: 1, unit: unit, color: frame) + fillCells(context, x: 2, y: 3, w: 1, h: gridH - 6, unit: unit, color: frame) + fillCells(context, x: gridWidth - 3, y: 3, w: 1, h: gridH - 6, unit: unit, color: frame) + + // Diamond trellis field, phase-locked to the card center. + let centerX = Int(gridWidth) / 2 + let centerYCell = Int((gridH / 2).rounded(.down)) + for cy in 4...(lastRow - 1) { + for cx in 4...Int(gridWidth) - 5 { + let sum = (cx - centerX) + (cy - centerYCell) + let diff = (cx - centerX) - (cy - centerYCell) + let onSum = sum % 6 == 0 + let onDiff = diff % 6 == 0 + if onSum && onDiff { + fillCells( + context, x: CGFloat(cx), y: CGFloat(cy), w: 1, h: 1, + unit: unit, color: PixelPalette.backGold + ) + } else if onSum || onDiff { + fillCells( + context, x: CGFloat(cx), y: CGFloat(cy), w: 1, h: 1, + unit: unit, color: PixelPalette.backMid + ) + } + } + } + } +} + +// MARK: - Card Front + +struct PixelCardFrontView: View { + let card: Card + let cardSize: CGSize + let isSelected: Bool + + var body: some View { + let unit = cardSize.width / PixelCardArt.gridWidth + let shape = PixelCardShape(px: unit) + let borderColor = isSelected ? Color.yellow.opacity(0.92) : PixelPalette.outline + let borderWidth = isSelected ? max(2, unit * 1.6) : max(0.8, unit) + + ZStack { + shape.fill(PixelPalette.cardFace, style: FillStyle(antialiased: false)) + Canvas { context, size in + PixelCardArt.drawFront(card: card, in: context, size: size, unit: unit) + } + } + .frame(width: cardSize.width, height: cardSize.height) + .clipShape(shape, style: FillStyle(antialiased: false)) + .overlay( + shape.strokeBorder(borderColor, lineWidth: borderWidth, antialiased: false) + ) + .shadow( + color: Color.black.opacity(isSelected ? 0.24 : 0.10), + radius: isSelected ? 7 : 2, + x: 0, + y: isSelected ? 4 : 1 + ) + } +} + +// MARK: - Card Back + +struct PixelCardBackView: View { + let cardSize: CGSize + let isSelected: Bool + + init(cardSize: CGSize, isSelected: Bool = false) { + self.cardSize = cardSize + self.isSelected = isSelected + } + + var body: some View { + let unit = cardSize.width / PixelCardArt.gridWidth + let shape = PixelCardShape(px: unit) + let borderColor = isSelected ? Color.yellow.opacity(0.88) : PixelPalette.outline + let borderWidth = isSelected ? max(2, unit * 1.6) : max(0.8, unit) + + ZStack { + shape.fill(PixelPalette.backDeep, style: FillStyle(antialiased: false)) + Canvas { context, size in + PixelCardArt.drawBack(in: context, size: size, unit: unit) + } + } + .frame(width: cardSize.width, height: cardSize.height) + .clipShape(shape, style: FillStyle(antialiased: false)) + .overlay( + shape.strokeBorder(borderColor, lineWidth: borderWidth, antialiased: false) + ) + .shadow( + color: Color.black.opacity(isSelected ? 0.24 : 0.12), + radius: isSelected ? 7 : 2, + x: 0, + y: isSelected ? 4 : 1 + ) + } +} + +// MARK: - Standalone Pixel Card Back + +struct PixelStandaloneCardBackView: View { + let cardSize: CGSize + + var body: some View { + PixelCardBackView(cardSize: cardSize) + } +} diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 503daa9..43a14eb 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -6,6 +6,7 @@ struct StatisticsView: View { let viewModel: SolitaireViewModel? @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss + @State private var selectedScope: Scope @State private var stats = GameStatistics() @State private var barHoverState: (label: String, x: CGFloat)? @State private var isShowingCleanWinsInfo = false @@ -18,9 +19,43 @@ struct StatisticsView: View { return formatter }() + private enum Scope: String, CaseIterable, Identifiable { + case klondike + case freecell + case all + + var id: String { rawValue } + + var title: String { + switch self { + case .klondike: + return GameVariant.klondike.title + case .freecell: + return GameVariant.freecell.title + case .all: + return "All" + } + } + } + + init(viewModel: SolitaireViewModel?, initialVariant: GameVariant = .klondike) { + self.viewModel = viewModel + _selectedScope = State(initialValue: initialVariant == .freecell ? .freecell : .klondike) + } + var body: some View { TimelineView(.periodic(from: .now, by: 1)) { context in Form { + Section { + Picker("Statistics Scope", selection: $selectedScope) { + ForEach(Scope.allCases) { scope in + Text(scope.title).tag(scope) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + Section { HStack(spacing: 0) { highlightCard( @@ -31,9 +66,9 @@ struct StatisticsView: View { Divider() .frame(height: 32) highlightCard( - icon: "timer", - label: "Best Time", - value: bestTimeLabel + icon: secondaryHighlightIcon, + label: secondaryHighlightLabel, + value: secondaryHighlightValue ) Divider() .frame(height: 32) @@ -62,14 +97,16 @@ struct StatisticsView: View { Text("Games") } - Section { - keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) - keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) - keyValueRow("Best Time", bestTimeLabel) - keyValueRow("High Score (3-card)", stats.highScoreDrawThree.map { "\($0)" } ?? "-") - keyValueRow("High Score (1-card)", stats.highScoreDrawOne.map { "\($0)" } ?? "-") - } header: { - Text("Performance") + if selectedScope != .all { + Section { + keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) + keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) + keyValueRow("Best Time", bestTimeLabel) + keyValueRow("High Score (3-card)", stats.highScoreDrawThree.map { "\($0)" } ?? "-") + keyValueRow("High Score (1-card)", stats.highScoreDrawOne.map { "\($0)" } ?? "-") + } header: { + Text("Performance") + } } Text("Tracked since \(trackedSinceLabel)") @@ -110,19 +147,23 @@ struct StatisticsView: View { } } .onAppear { - stats = GameStatisticsStore.load() + loadStats() + } + .onChange(of: selectedScope) { _, _ in + loadStats() + barHoverState = nil } .confirmationDialog( - "Reset statistics?", + resetDialogTitle, isPresented: $isShowingResetConfirmation, titleVisibility: .visible ) { - Button("Reset Statistics", role: .destructive) { + Button(resetActionTitle, role: .destructive) { resetStatistics() } Button("Cancel", role: .cancel) {} } message: { - Text("All games, times, win rates, and high scores will be reset.") + Text(resetMessage) } } @@ -180,6 +221,27 @@ struct StatisticsView: View { return durationLabel(bestTimeSeconds) } + private var secondaryHighlightIcon: String { + if selectedScope == .all { + return "number" + } + return "timer" + } + + private var secondaryHighlightLabel: String { + if selectedScope == .all { + return "Games Played" + } + return "Best Time" + } + + private var secondaryHighlightValue: String { + if selectedScope == .all { + return "\(stats.gamesPlayed)" + } + return bestTimeLabel + } + private var cleanWinRateLabel: String { return String(format: "%.1f%%", stats.cleanWinRate * 100) } @@ -190,7 +252,23 @@ struct StatisticsView: View { } private func displayTotalTimeSeconds(at date: Date) -> Int { - let liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 + let liveElapsed: Int + switch selectedScope { + case .all: + liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 + case .klondike: + if viewModel?.gameVariant == .klondike { + liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 + } else { + liveElapsed = 0 + } + case .freecell: + if viewModel?.gameVariant == .freecell { + liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 + } else { + liveElapsed = 0 + } + } let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) return overflow ? Int.max : max(0, sum) } @@ -259,13 +337,81 @@ struct StatisticsView: View { } private func resetStatistics() { - GameStatisticsStore.reset() - viewModel?.resetStatisticsTracking() - persistTrackingResetIfNeeded() - stats = GameStatisticsStore.load() + switch selectedScope { + case .klondike: + GameStatisticsStore.reset(for: .klondike) + case .freecell: + GameStatisticsStore.reset(for: .freecell) + case .all: + GameStatisticsStore.reset(for: .klondike) + GameStatisticsStore.reset(for: .freecell) + } + + if selectedScope == .all || activeVariantMatchesSelectedScope { + viewModel?.resetStatisticsTracking() + persistTrackingResetIfNeeded() + } + loadStats() barHoverState = nil } + private var activeVariantMatchesSelectedScope: Bool { + switch selectedScope { + case .klondike: + return viewModel?.gameVariant == .klondike + case .freecell: + return viewModel?.gameVariant == .freecell + case .all: + return true + } + } + + private var resetDialogTitle: String { + switch selectedScope { + case .klondike: + return "Reset Klondike statistics?" + case .freecell: + return "Reset FreeCell statistics?" + case .all: + return "Reset all statistics?" + } + } + + private var resetActionTitle: String { + switch selectedScope { + case .klondike: + return "Reset Klondike Statistics" + case .freecell: + return "Reset FreeCell Statistics" + case .all: + return "Reset All Statistics" + } + } + + private var resetMessage: String { + switch selectedScope { + case .klondike: + return "This will reset only Klondike games, times, win rates, and high scores." + case .freecell: + return "This will reset only FreeCell games, times, win rates, and high scores." + case .all: + return "This will reset both Klondike and FreeCell statistics." + } + } + + private func loadStats() { + switch selectedScope { + case .klondike: + stats = GameStatisticsStore.load(for: .klondike) + case .freecell: + stats = GameStatisticsStore.load(for: .freecell) + case .all: + let klondikeStats = GameStatisticsStore.load(for: .klondike) + let freeCellStats = GameStatisticsStore.load(for: .freecell) + stats = GameStatistics.aggregated([klondikeStats, freeCellStats]) + } + } + private func persistTrackingResetIfNeeded() { guard let viewModel else { return } do { diff --git a/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift b/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift new file mode 100644 index 0000000..30cb628 --- /dev/null +++ b/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FreeCellAutoFinishTests: XCTestCase { + func testCanAutoFinishWhenRemainingBoardIsAPureFoundationRun() { + let state = freeCellEndgame( + freeCells: [TestCards.make(.clubs, .king), nil, nil, nil], + highestFoundationRank: .queen, + tableau: [ + [TestCards.make(.spades, .king)], + [TestCards.make(.hearts, .king)], + [TestCards.make(.diamonds, .king)], + [], [], [], [], [] + ] + ) + XCTAssertTrue(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testCanAutoFinishDrainsCardsInDescendingOrderWithinPiles() { + // Piles are ordered top-to-bottom correctly for foundation play (queen under king + // would block; king under queen plays out). + let state = freeCellEndgame( + freeCells: [nil, nil, nil, nil], + highestFoundationRank: .jack, + tableau: [ + [TestCards.make(.spades, .king), TestCards.make(.spades, .queen)], + [TestCards.make(.hearts, .king), TestCards.make(.hearts, .queen)], + [TestCards.make(.diamonds, .king), TestCards.make(.diamonds, .queen)], + [TestCards.make(.clubs, .king), TestCards.make(.clubs, .queen)], + [], [], [], [] + ] + ) + XCTAssertTrue(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testCannotAutoFinishWhenACardIsBuriedOutOfOrder() { + // Queen buried UNDER the king: the queen can never play before the king, + // and the king needs the queen's spot first → not a pure foundation run. + let state = freeCellEndgame( + freeCells: [nil, nil, nil, nil], + highestFoundationRank: .jack, + tableau: [ + [TestCards.make(.spades, .queen), TestCards.make(.spades, .king)], + [TestCards.make(.hearts, .king), TestCards.make(.hearts, .queen)], + [TestCards.make(.diamonds, .king), TestCards.make(.diamonds, .queen)], + [TestCards.make(.clubs, .king), TestCards.make(.clubs, .queen)], + [], [], [], [] + ] + ) + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testNextAutoFinishMovePullsFromFreeCellWhenNeeded() { + // Spades foundation stops at jack; every other suit is at queen. The only + // queen-rank play is the Q♠ waiting in a free cell. + let queenSpades = TestCards.make(.spades, .queen) + var foundations = Array(repeating: [Card](), count: 4) + for (index, suit) in Suit.allCases.enumerated() { + let topRank: Rank = suit == .spades ? .jack : .queen + foundations[index] = Rank.allCases + .filter { $0.rawValue <= topRank.rawValue } + .map { TestCards.make(suit, $0) } + } + let state = GameState( + variant: .freecell, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: [nil, queenSpades, nil, nil], + foundations: foundations, + tableau: [ + [TestCards.make(.spades, .king)], + [TestCards.make(.hearts, .king)], + [TestCards.make(.diamonds, .king)], + [TestCards.make(.clubs, .king)], + [], [], [], [] + ] + ) + + XCTAssertTrue(AutoFinishPlanner.canAutoFinish(in: state)) + let move = AutoFinishPlanner.nextAutoFinishMove(in: state) + XCTAssertEqual(move?.selection.cards.first?.id, queenSpades.id) + if case .freeCell(let slot) = move?.selection.source { + XCTAssertEqual(slot, 1) + } else { + XCTFail("Expected the free-cell queen to be the next auto-finish move") + } + } + + /// Builds a FreeCell endgame where every suit's foundation is filled up to + /// `highestFoundationRank` and the remaining cards sit in the given layout. + private func freeCellEndgame( + freeCells: [Card?], + highestFoundationRank: Rank, + tableau: [[Card]] + ) -> GameState { + var foundations = Array(repeating: [Card](), count: 4) + for (index, suit) in Suit.allCases.enumerated() { + foundations[index] = Rank.allCases + .filter { $0.rawValue <= highestFoundationRank.rawValue } + .map { TestCards.make(suit, $0) } + } + return GameState( + variant: .freecell, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: freeCells, + foundations: foundations, + tableau: tableau + ) + } +} diff --git a/ComputerSolitaireTests/FreeCell/FreeCellRulesTests.swift b/ComputerSolitaireTests/FreeCell/FreeCellRulesTests.swift new file mode 100644 index 0000000..e5f751a --- /dev/null +++ b/ComputerSolitaireTests/FreeCell/FreeCellRulesTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FreeCellRulesTests: XCTestCase { + func testFreeCellNewGameLayout() { + let state = GameState.newGame(variant: .freecell) + + XCTAssertEqual(state.variant, .freecell) + XCTAssertEqual(state.stock.count, 0) + XCTAssertEqual(state.waste.count, 0) + XCTAssertEqual(state.wasteDrawCount, 0) + XCTAssertEqual(state.freeCells.count, 4) + XCTAssertTrue(state.freeCells.allSatisfy { $0 == nil }) + XCTAssertEqual(state.foundations.count, 4) + XCTAssertEqual(state.tableau.count, 8) + XCTAssertEqual(state.tableau.prefix(4).map(\.count), [7, 7, 7, 7]) + XCTAssertEqual(state.tableau.suffix(4).map(\.count), [6, 6, 6, 6]) + XCTAssertTrue(state.tableau.joined().allSatisfy(\.isFaceUp)) + } + + func testCanMoveToEmptyTableauDiffersByVariant() { + let queen = TestCards.make(.hearts, .queen, isFaceUp: true) + + XCTAssertFalse( + GameRules.canMoveToTableau( + card: queen, + destinationPile: [], + variant: .klondike + ) + ) + XCTAssertTrue( + GameRules.canMoveToTableau( + card: queen, + destinationPile: [], + variant: .freecell + ) + ) + } + + func testFreeCellTransferCountUsesFreeCellsAndEmptyCascades() { + let occupiedCellCard = TestCards.make(.spades, .ace, isFaceUp: true) + let freeCells: [Card?] = [nil, nil, occupiedCellCard, occupiedCellCard] + let tableau: [[Card]] = [ + [TestCards.make(.clubs, .king, isFaceUp: true)], + [], + [], + [TestCards.make(.diamonds, .queen, isFaceUp: true)], + [TestCards.make(.hearts, .jack, isFaceUp: true)], + [TestCards.make(.spades, .ten, isFaceUp: true)], + [TestCards.make(.clubs, .nine, isFaceUp: true)], + [TestCards.make(.diamonds, .eight, isFaceUp: true)] + ] + + let transferCount = GameRules.maxFreeCellTransferCount( + freeCellSlots: freeCells, + tableau: tableau, + destination: .tableau(0) + ) + + XCTAssertEqual(transferCount, 12) + } + + func testFreeCellMoveFromCascadeToFreeCell() { + var state = GameState.newGame(variant: .freecell) + let pileIndex = 0 + let cardIndex = state.tableau[pileIndex].count - 1 + let card = state.tableau[pileIndex][cardIndex] + + let selection = Selection( + source: .tableau(pile: pileIndex, index: cardIndex), + cards: [card] + ) + let legalDestinations = AutoMoveAdvisor.legalDestinations(for: selection, in: state) + + XCTAssertTrue(legalDestinations.contains(.freeCell(0))) + + state.tableau[pileIndex].removeLast() + state.freeCells[0] = card + XCTAssertEqual(state.freeCells[0]?.id, card.id) + } +} diff --git a/ComputerSolitaireTests/FreeCell/FreeCellSolverTests.swift b/ComputerSolitaireTests/FreeCell/FreeCellSolverTests.swift new file mode 100644 index 0000000..49adfb1 --- /dev/null +++ b/ComputerSolitaireTests/FreeCell/FreeCellSolverTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FreeCellSolverTests: XCTestCase { + /// Statistical coverage (solve rate across random deals) was established with the + /// release-build probe study; these fixed deals are regression guards, so a failure + /// is reproducible instead of depending on whatever CI happens to shuffle. + private static let solvableDealSeeds: [UInt64] = Array(1...20) + + /// Every returned solution must replay legally through the app's own move engine + /// to a won board. + func testSolvesFixedDealsAndSolutionsReplayLegally() { + for seed in Self.solvableDealSeeds { + let state = GameStateFixtures.seededFreeCellDeal(seed: seed) + guard let solution = FreeCellSolver.solve( + state, + limits: FreeCellSolver.Limits(deadline: Date().addingTimeInterval(10.0)) + ) else { + return XCTFail("Seeded deal \(seed) should solve within budget") + } + + var replay = state + for move in solution.moves { + guard let (selection, destination) = FreeCellSolver.materialize(move, in: replay), + let next = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: replay, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Seed \(seed): solution contained a move that is illegal in the app model") + } + replay = next + } + XCTAssertTrue( + replay.foundations.allSatisfy { $0.count == Rank.allCases.count }, + "Seed \(seed): replaying the solution must win the game" + ) + } + } + + func testKeyedMovesFollowTheSolutionLine() throws { + let state = GameStateFixtures.seededFreeCellDeal(seed: 42) + guard let solution = FreeCellSolver.solve( + state, + limits: FreeCellSolver.Limits(deadline: Date().addingTimeInterval(10.0)) + ) else { + return XCTFail("Seeded deal should solve within budget") + } + + let plan = FreeCellSolver.keyedMoves(along: solution, from: state) + XCTAssertEqual(plan.count, solution.moves.count) + + var replay = state + for expectedMove in solution.moves { + let key = FreeCellSolver.stateKey(for: replay) + XCTAssertEqual(plan[key], expectedMove) + guard let (selection, destination) = FreeCellSolver.materialize(expectedMove, in: replay), + let next = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: replay, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Plan replay broke") + } + replay = next + } + } + + func testHintPlannerWinsAFreshDealEndToEnd() { + let planner = HintPlanner() + var state = GameStateFixtures.seededFreeCellDeal(seed: 7) + var steps = 0 + while steps < 400 { + steps += 1 + if state.foundations.allSatisfy({ $0.count == Rank.allCases.count }) { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue), + case .move(let move) = hint, + let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Hint chain broke after \(steps) steps") + } + state = next + } + XCTFail("Did not win within 400 hint-followed moves") + } + + func testMaterializeRoutesFoundationMovesToMatchingSuitPile() { + let aceHearts = TestCards.make(.hearts, .ace) + let state = GameState( + variant: .freecell, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: [nil, nil, nil, nil], + foundations: [[TestCards.make(.spades, .ace)], [], [], []], + tableau: [[aceHearts], [], [], [], [], [], [], []] + ) + + let move = FreeCellSolver.Move(source: .cascade(pile: 0, count: 1), target: .foundation) + let materialized = FreeCellSolver.materialize(move, in: state) + XCTAssertEqual(materialized?.destination, .foundation(1), "Ace must open a fresh foundation pile") + XCTAssertEqual(materialized?.selection.cards.first?.id, aceHearts.id) + } +} diff --git a/ComputerSolitaireTests/GameStatisticsStoreTests.swift b/ComputerSolitaireTests/GameStatisticsStoreTests.swift deleted file mode 100644 index 25ea457..0000000 --- a/ComputerSolitaireTests/GameStatisticsStoreTests.swift +++ /dev/null @@ -1,108 +0,0 @@ -import XCTest -@testable import Computer_Solitaire - -@MainActor -final class GameStatisticsStoreTests: XCTestCase { - func testRecordCompletedGameUpdatesBestTimeAndHighScoreByDrawMode() { - var stats = GameStatistics() - - stats.recordCompletedGame( - didWin: true, - elapsedSeconds: 200, - finalScore: 300, - drawCount: DrawMode.three.rawValue, - hintsUsedInGame: 0, - undosUsedInGame: 0, - usedRedealInGame: false - ) - stats.recordCompletedGame( - didWin: true, - elapsedSeconds: 150, - finalScore: 250, - drawCount: DrawMode.one.rawValue, - hintsUsedInGame: 1, - undosUsedInGame: 0, - usedRedealInGame: false - ) - - XCTAssertEqual(stats.gamesPlayed, 2) - XCTAssertEqual(stats.gamesWon, 2) - XCTAssertEqual(stats.bestTimeSeconds, 150) - XCTAssertEqual(stats.highScoreDrawThree, 300) - XCTAssertEqual(stats.highScoreDrawOne, 250) - XCTAssertEqual(stats.cleanWins, 1) - } - - func testRecordCompletedGameUsesOverflowSafeCounters() { - var stats = GameStatistics( - gamesPlayed: Int.max, - gamesWon: Int.max, - totalTimeSeconds: Int.max, - cleanWins: Int.max - ) - - stats.recordCompletedGame( - didWin: true, - elapsedSeconds: Int.max, - finalScore: 100, - drawCount: DrawMode.three.rawValue, - hintsUsedInGame: 0, - undosUsedInGame: 0, - usedRedealInGame: false - ) - - XCTAssertEqual(stats.gamesPlayed, Int.max) - XCTAssertEqual(stats.gamesWon, Int.max) - XCTAssertEqual(stats.totalTimeSeconds, Int.max) - XCTAssertEqual(stats.cleanWins, Int.max) - } - - func testStatisticsStoreMarkTrackingStartedAndReset() { - let defaults = makeIsolatedDefaults() - defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } - - GameStatisticsStore.markTrackingStarted(userDefaults: defaults, at: DateFixtures.reference) - let marked = GameStatisticsStore.load(userDefaults: defaults) - XCTAssertEqual(marked.trackedSince, DateFixtures.reference) - - GameStatisticsStore.markTrackingStarted(userDefaults: defaults, at: DateFixtures.plus(60)) - let notOverwritten = GameStatisticsStore.load(userDefaults: defaults) - XCTAssertEqual(notOverwritten.trackedSince, DateFixtures.reference) - - GameStatisticsStore.reset(userDefaults: defaults, at: DateFixtures.plus(120)) - let reset = GameStatisticsStore.load(userDefaults: defaults) - XCTAssertEqual(reset.trackedSince, DateFixtures.plus(120)) - XCTAssertEqual(reset.gamesPlayed, 0) - XCTAssertEqual(reset.gamesWon, 0) - } - - func testStatisticsStoreUpdatePersistsMutation() { - let defaults = makeIsolatedDefaults() - defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } - - GameStatisticsStore.update(userDefaults: defaults) { stats in - stats.recordCompletedGame( - didWin: true, - elapsedSeconds: 123, - finalScore: 456, - drawCount: DrawMode.three.rawValue, - hintsUsedInGame: 0, - undosUsedInGame: 0, - usedRedealInGame: false - ) - } - - let loaded = GameStatisticsStore.load(userDefaults: defaults) - XCTAssertEqual(loaded.gamesPlayed, 1) - XCTAssertEqual(loaded.gamesWon, 1) - XCTAssertEqual(loaded.bestTimeSeconds, 123) - } - - private let defaultsSuiteName = "ComputerSolitaire.GameStatisticsStoreTests" - - private func makeIsolatedDefaults() -> UserDefaults { - let defaults = UserDefaults(suiteName: defaultsSuiteName)! - defaults.removePersistentDomain(forName: defaultsSuiteName) - return defaults - } -} diff --git a/ComputerSolitaireTests/HintAdvisabilityTests.swift b/ComputerSolitaireTests/HintAdvisabilityTests.swift deleted file mode 100644 index 08fd098..0000000 --- a/ComputerSolitaireTests/HintAdvisabilityTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -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) - } -} diff --git a/ComputerSolitaireTests/AutoFinishPlannerTests.swift b/ComputerSolitaireTests/Klondike/AutoFinishPlannerTests.swift similarity index 100% rename from ComputerSolitaireTests/AutoFinishPlannerTests.swift rename to ComputerSolitaireTests/Klondike/AutoFinishPlannerTests.swift diff --git a/ComputerSolitaireTests/GameRulesTests.swift b/ComputerSolitaireTests/Klondike/GameRulesTests.swift similarity index 100% rename from ComputerSolitaireTests/GameRulesTests.swift rename to ComputerSolitaireTests/Klondike/GameRulesTests.swift diff --git a/ComputerSolitaireTests/HintAdvisorCoverageTests.swift b/ComputerSolitaireTests/Klondike/HintAdvisorCoverageTests.swift similarity index 100% rename from ComputerSolitaireTests/HintAdvisorCoverageTests.swift rename to ComputerSolitaireTests/Klondike/HintAdvisorCoverageTests.swift diff --git a/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift b/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift new file mode 100644 index 0000000..fefa208 --- /dev/null +++ b/ComputerSolitaireTests/Klondike/KlondikePlannerTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class KlondikePlannerTests: XCTestCase { + func testHintIsDeterministicAcrossCalls() { + let fiveHearts = TestCards.make(.hearts, .five) + let sixClubs = TestCards.make(.clubs, .six) + let sixSpades = TestCards.make(.spades, .six) + let state = GameState( + stock: [], + waste: [fiveHearts], + wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: [[sixClubs], [sixSpades], [], [], [], [], []] + ) + + let first = KlondikePlanner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual( + KlondikePlanner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue), + first + ) + } + } + + func testFreshDealsAlwaysHaveAHint() { + for seed in 1...10 { + let state = GameStateFixtures.seededKlondikeDeal(seed: UInt64(seed)) + XCTAssertNotNil( + KlondikePlanner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue), + "Seed \(seed): a fresh Klondike deal should have a suggestible line" + ) + } + } + + func testFollowingHintsNeverRevisitsAState() { + // The planner must never recommend a cycle: following hints, the exact layout + // should never repeat (stock taps cycle by design, so only moves are keyed). + // Loop-freedom comes from deterministic search + shallow-line tie-breaking, not + // budget size, so a small budget keeps this fast without weakening the property. + let limits = KlondikePlanner.Limits(maxNodes: 400) + for seed in [11, 12] as [UInt64] { + var state = GameStateFixtures.seededKlondikeDeal(seed: seed) + var seen = Set() + for _ in 0..<120 { + guard let hint = KlondikePlanner.bestHint( + in: state, + stockDrawCount: DrawMode.three.rawValue, + limits: limits + ) else { + break + } + switch hint { + case .move(let move): + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Hinted move was not legal") + } + state = next + XCTAssertTrue( + seen.insert(stateFingerprint(state)).inserted, + "Hint sequence revisited an earlier position" + ) + case .stockTap: + guard let next = stockTap(state, drawCount: DrawMode.three.rawValue) else { + return XCTFail("Stock tap hinted with nothing to tap") + } + state = next + } + } + } + } + + func testHintPrefersRevealingLineOverPlainReshuffle() { + // Moving the 9♣ onto the red 10 reveals a face-down card; moving the free 10♦ + // onto the black jack accomplishes nothing. The hint should pick the reveal. + let hiddenKing = TestCards.make(.clubs, .king, isFaceUp: false) + let nineClubs = TestCards.make(.clubs, .nine) + let tenHearts = TestCards.make(.hearts, .ten) + let tenDiamonds = TestCards.make(.diamonds, .ten) + let jackSpades = TestCards.make(.spades, .jack) + let state = GameState( + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenKing, nineClubs], [tenHearts], [tenDiamonds], [jackSpades], [], [], []] + ) + + guard case .move(let move)? = KlondikePlanner.bestHint( + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, nineClubs.id) + XCTAssertEqual(move.destination, .tableau(1)) + } + + // MARK: - Helpers + + private func stockTap(_ state: GameState, drawCount: Int) -> GameState? { + var next = state + if !next.stock.isEmpty { + let n = min(drawCount, next.stock.count) + for _ in 0.. UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { hash = (hash ^ UInt64(value)) &* 0x100000001b3 } + func mix(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + mix(UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0))) + } + for card in state.stock { mix(card: card) } + mix(0xFF) + for card in state.waste { mix(card: card) } + mix(UInt8(min(255, max(0, state.wasteDrawCount)))) + for pile in state.foundations { + mix(0xFE) + for card in pile { mix(card: card) } + } + for pile in state.tableau { + mix(0xFD) + for card in pile { mix(card: card) } + } + return hash + } +} diff --git a/ComputerSolitaireTests/MoveEvaluationRankingTests.swift b/ComputerSolitaireTests/MoveEvaluationRankingTests.swift deleted file mode 100644 index 2692ff6..0000000 --- a/ComputerSolitaireTests/MoveEvaluationRankingTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import XCTest -@testable import Computer_Solitaire - -@MainActor -final class MoveEvaluationRankingTests: XCTestCase { - func testRankingPrefersRevealBeforeOtherSignals() { - let reveal = evaluation(destination: .tableau(0), revealsFaceDownCard: true) - let noReveal = evaluation( - destination: .foundation(0), - revealsFaceDownCard: false, - foundationProgressDelta: 1, - mobilityDelta: 10 - ) - - XCTAssertTrue(MoveEvaluationRanking.isBetter(reveal, than: noReveal)) - XCTAssertFalse(MoveEvaluationRanking.isBetter(noReveal, than: reveal)) - } - - func testRankingThenPrefersFoundationProgressAndMobility() { - let betterFoundation = evaluation(destination: .foundation(0), foundationProgressDelta: 2) - let weakerFoundation = evaluation(destination: .foundation(1), foundationProgressDelta: 1) - XCTAssertTrue(MoveEvaluationRanking.isBetter(betterFoundation, than: weakerFoundation)) - - let betterMobility = evaluation(destination: .tableau(0), mobilityDelta: 2) - let weakerMobility = evaluation(destination: .tableau(1), mobilityDelta: 1) - XCTAssertTrue(MoveEvaluationRanking.isBetter(betterMobility, than: weakerMobility)) - } - - func testRankingFallsBackToDeterministicDestinationOrder() { - let foundation0 = evaluation(destination: .foundation(0)) - let foundation1 = evaluation(destination: .foundation(1)) - let tableau0 = evaluation(destination: .tableau(0)) - - XCTAssertTrue(MoveEvaluationRanking.isBetter(foundation0, than: foundation1)) - XCTAssertTrue(MoveEvaluationRanking.isBetter(foundation1, than: tableau0)) - XCTAssertFalse(MoveEvaluationRanking.isBetter(tableau0, than: foundation0)) - } - - private func evaluation( - destination: Destination, - revealsFaceDownCard: Bool = false, - clearsSourcePile: Bool = false, - emptyTableauDelta: Int = 0, - foundationProgressDelta: Int = 0, - mobilityDelta: Int = 0, - resultingMobility: Int = 0, - destinationPriority: Int = 0 - ) -> MoveEvaluation { - MoveEvaluation( - destination: destination, - revealsFaceDownCard: revealsFaceDownCard, - clearsSourcePile: clearsSourcePile, - emptyTableauDelta: emptyTableauDelta, - foundationProgressDelta: foundationProgressDelta, - mobilityDelta: mobilityDelta, - resultingMobility: resultingMobility, - destinationPriority: destinationPriority - ) - } -} diff --git a/ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift b/ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift similarity index 67% rename from ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift rename to ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift index 7a4ea26..c7e4518 100644 --- a/ComputerSolitaireTests/AutoMoveAdvisorCoverageTests.swift +++ b/ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift @@ -49,28 +49,7 @@ final class AutoMoveAdvisorCoverageTests: XCTestCase { XCTAssertFalse(destinations.contains(.tableau(1))) } - func testBestDestinationMovesWasteAceToFoundation() { - let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) - let state = GameState( - stock: [], - waste: [aceSpades], - wasteDrawCount: 1, - foundations: Array(repeating: [], count: 4), - tableau: Array(repeating: [], count: 7) - ) - let selection = Selection(source: .waste, cards: [aceSpades]) - - XCTAssertEqual( - AutoMoveAdvisor.bestDestination( - for: selection, - in: state, - stockDrawCount: DrawMode.three.rawValue - ), - .foundation(0) - ) - } - - func testBestAdvisableDestinationRejectsFoundationToFoundationAndNonMatchingSelections() { + func testSelectionMatchingRejectsStaleSelections() { let aceSpades = TestCards.make(.spades, .ace, isFaceUp: true) let twoSpades = TestCards.make(.spades, .two, isFaceUp: true) let state = GameState( @@ -80,18 +59,13 @@ final class AutoMoveAdvisorCoverageTests: XCTestCase { foundations: [[aceSpades], [twoSpades], [], []], tableau: Array(repeating: [], count: 7) ) - let badSelection = Selection(source: .foundation(pile: 0), cards: [twoSpades]) + let staleSelection = Selection(source: .foundation(pile: 0), cards: [twoSpades]) - XCTAssertNil( - AutoMoveAdvisor.bestAdvisableDestination( - for: badSelection, - in: state, - stockDrawCount: DrawMode.three.rawValue - ) - ) + XCTAssertFalse(AutoMoveAdvisor.selectionMatchesState(staleSelection, in: state)) + XCTAssertTrue(AutoMoveAdvisor.legalDestinations(for: staleSelection, in: state).isEmpty) } - func testBestMoveEvaluationProvidesPositiveMobilityForUsefulMove() { + func testSimulatedStateMovesWasteCardAndUpdatesWasteDrawCount() { let sixClubs = TestCards.make(.clubs, .six, isFaceUp: true) let fiveHearts = TestCards.make(.hearts, .five, isFaceUp: true) let state = GameState( @@ -103,13 +77,15 @@ final class AutoMoveAdvisorCoverageTests: XCTestCase { ) let selection = Selection(source: .waste, cards: [fiveHearts]) - let evaluation = AutoMoveAdvisor.bestMoveEvaluation( - for: selection, + let nextState = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: .tableau(0), in: state, stockDrawCount: DrawMode.three.rawValue ) - XCTAssertNotNil(evaluation) - XCTAssertEqual(evaluation?.destination, .tableau(0)) - XCTAssertGreaterThanOrEqual(evaluation?.resultingMobility ?? -1, 0) + XCTAssertNotNil(nextState) + XCTAssertEqual(nextState?.waste.count, 0) + XCTAssertEqual(nextState?.wasteDrawCount, 0) + XCTAssertEqual(nextState?.tableau[0].last?.id, fiveHearts.id) } } diff --git a/ComputerSolitaireTests/GamePersistenceStoreTests.swift b/ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift similarity index 100% rename from ComputerSolitaireTests/GamePersistenceStoreTests.swift rename to ComputerSolitaireTests/Shared/GamePersistenceStoreTests.swift diff --git a/ComputerSolitaireTests/GameSessionTrackingTests.swift b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift similarity index 75% rename from ComputerSolitaireTests/GameSessionTrackingTests.swift rename to ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift index a9961d4..1d21687 100644 --- a/ComputerSolitaireTests/GameSessionTrackingTests.swift +++ b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift @@ -3,7 +3,6 @@ import XCTest @MainActor final class GameSessionTrackingTests: XCTestCase { - private let statsKey = GameStatisticsStore.defaultsKey private static var retainedViewModels: [SolitaireViewModel] = [] // Verifies app startup initializes tracking metadata without starting a trackable game. @@ -11,7 +10,7 @@ final class GameSessionTrackingTests: XCTestCase { withIsolatedStatsStore { let viewModel = makeViewModel() - let stats = GameStatisticsStore.load() + let stats = GameStatisticsStore.load(for: .klondike) XCTAssertNotNil(stats.trackedSince) XCTAssertEqual(stats.gamesPlayed, 0) @@ -27,7 +26,7 @@ final class GameSessionTrackingTests: XCTestCase { viewModel.newGame(drawMode: .three) - let stats = GameStatisticsStore.load() + let stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 0) let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -43,7 +42,7 @@ final class GameSessionTrackingTests: XCTestCase { viewModel.newGame(drawMode: .three) viewModel.newGame(drawMode: .three) - let stats = GameStatisticsStore.load() + let stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 1) } } @@ -56,7 +55,7 @@ final class GameSessionTrackingTests: XCTestCase { viewModel.newGame(drawMode: .three) viewModel.redeal() - let stats = GameStatisticsStore.load() + let stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 1) let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -92,7 +91,7 @@ final class GameSessionTrackingTests: XCTestCase { viewModel.newGame(drawMode: .three) - let stats = GameStatisticsStore.load() + let stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 0) } } @@ -111,7 +110,7 @@ final class GameSessionTrackingTests: XCTestCase { viewModel.newGame(drawMode: .three) - let stats = GameStatisticsStore.load() + let stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 0) let trackedProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -128,7 +127,7 @@ final class GameSessionTrackingTests: XCTestCase { let activeProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) XCTAssertGreaterThan(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) - GameStatisticsStore.reset() + GameStatisticsStore.reset(for: .klondike) viewModel.resetStatisticsTracking() XCTAssertEqual(viewModel.unfinalizedElapsedSecondsForStats(at: activeProbeDate), 0) let resetPayload = viewModel.persistencePayload() @@ -136,24 +135,55 @@ final class GameSessionTrackingTests: XCTestCase { XCTAssertTrue(resetPayload.isCurrentGameFinalized) viewModel.newGame(drawMode: .three) - var stats = GameStatisticsStore.load() + var stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 0) viewModel.newGame(drawMode: .three) - stats = GameStatisticsStore.load() + stats = GameStatisticsStore.load(for: .klondike) XCTAssertEqual(stats.gamesPlayed, 1) } } + // Verifies switching variants finalizes the prior variant into its own stats bucket. + func testVariantSwitchFinalizesIntoPriorVariantBucket() { + withIsolatedStatsStore { + let viewModel = makeViewModel() + + viewModel.newGame(variant: .klondike, drawMode: .three) + viewModel.newGame(variant: .freecell, drawMode: .three) + + var klondikeStats = GameStatisticsStore.load(for: .klondike) + var freeCellStats = GameStatisticsStore.load(for: .freecell) + XCTAssertEqual(klondikeStats.gamesPlayed, 1) + XCTAssertEqual(freeCellStats.gamesPlayed, 0) + + viewModel.newGame(variant: .klondike, drawMode: .three) + + klondikeStats = GameStatisticsStore.load(for: .klondike) + freeCellStats = GameStatisticsStore.load(for: .freecell) + XCTAssertEqual(klondikeStats.gamesPlayed, 1) + XCTAssertEqual(freeCellStats.gamesPlayed, 1) + } + } + private func withIsolatedStatsStore(_ body: () -> Void) { let defaults = UserDefaults.standard - let previousStatsData = defaults.data(forKey: statsKey) - defaults.removeObject(forKey: statsKey) + let statsKeys = GameVariant.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } + let previousStatsData = statsKeys.reduce(into: [String: Data]()) { result, key in + if let data = defaults.data(forKey: key) { + result[key] = data + } + } + for key in statsKeys { + defaults.removeObject(forKey: key) + } defer { - if let previousStatsData { - defaults.set(previousStatsData, forKey: statsKey) - } else { - defaults.removeObject(forKey: statsKey) + for key in statsKeys { + if let value = previousStatsData[key] { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } } } body() diff --git a/ComputerSolitaireTests/GameStatisticsCleanWinTests.swift b/ComputerSolitaireTests/Shared/GameStatisticsCleanWinTests.swift similarity index 100% rename from ComputerSolitaireTests/GameStatisticsCleanWinTests.swift rename to ComputerSolitaireTests/Shared/GameStatisticsCleanWinTests.swift diff --git a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift new file mode 100644 index 0000000..f57469c --- /dev/null +++ b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GameStatisticsStoreTests: XCTestCase { + func testRecordCompletedGameUpdatesBestTimeAndHighScoreByDrawMode() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 200, + finalScore: 300, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 150, + finalScore: 250, + drawCount: DrawMode.one.rawValue, + hintsUsedInGame: 1, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, 2) + XCTAssertEqual(stats.gamesWon, 2) + XCTAssertEqual(stats.bestTimeSeconds, 150) + XCTAssertEqual(stats.highScoreDrawThree, 300) + XCTAssertEqual(stats.highScoreDrawOne, 250) + XCTAssertEqual(stats.cleanWins, 1) + } + + func testRecordCompletedGameUsesOverflowSafeCounters() { + var stats = GameStatistics( + gamesPlayed: Int.max, + gamesWon: Int.max, + totalTimeSeconds: Int.max, + cleanWins: Int.max + ) + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: Int.max, + finalScore: 100, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.gamesPlayed, Int.max) + XCTAssertEqual(stats.gamesWon, Int.max) + XCTAssertEqual(stats.totalTimeSeconds, Int.max) + XCTAssertEqual(stats.cleanWins, Int.max) + } + + func testStatisticsStoreMarkTrackingStartedAndReset() { + let defaults = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + GameStatisticsStore.markTrackingStarted( + for: .klondike, + userDefaults: defaults, + at: DateFixtures.reference + ) + let marked = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + XCTAssertEqual(marked.trackedSince, DateFixtures.reference) + + GameStatisticsStore.markTrackingStarted( + for: .klondike, + userDefaults: defaults, + at: DateFixtures.plus(60) + ) + let notOverwritten = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + XCTAssertEqual(notOverwritten.trackedSince, DateFixtures.reference) + + GameStatisticsStore.reset( + for: .klondike, + userDefaults: defaults, + at: DateFixtures.plus(120) + ) + let reset = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + XCTAssertEqual(reset.trackedSince, DateFixtures.plus(120)) + XCTAssertEqual(reset.gamesPlayed, 0) + XCTAssertEqual(reset.gamesWon, 0) + } + + func testStatisticsStoreUpdatePersistsMutation() { + let defaults = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + GameStatisticsStore.update(for: .klondike, userDefaults: defaults) { stats in + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 123, + finalScore: 456, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + } + + let loaded = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + XCTAssertEqual(loaded.gamesPlayed, 1) + XCTAssertEqual(loaded.gamesWon, 1) + XCTAssertEqual(loaded.bestTimeSeconds, 123) + } + + func testVariantStoresRemainIsolated() { + let defaults = makeIsolatedDefaults() + defer { defaults.removePersistentDomain(forName: defaultsSuiteName) } + + GameStatisticsStore.update(for: .klondike, userDefaults: defaults) { stats in + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 100, + finalScore: 200, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + } + + let klondikeStats = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + let freeCellStats = GameStatisticsStore.load(for: .freecell, userDefaults: defaults) + + XCTAssertEqual(klondikeStats.gamesPlayed, 1) + XCTAssertEqual(klondikeStats.gamesWon, 1) + XCTAssertEqual(freeCellStats.gamesPlayed, 0) + XCTAssertEqual(freeCellStats.gamesWon, 0) + } + + func testAggregatedStatisticsCombinesCoreMetricsAcrossVariants() { + let klondikeStats = GameStatistics( + trackedSince: DateFixtures.plus(300), + gamesPlayed: 4, + gamesWon: 3, + totalTimeSeconds: 800, + bestTimeSeconds: 120, + highScoreDrawThree: 500, + highScoreDrawOne: 300, + cleanWins: 2 + ) + let freeCellStats = GameStatistics( + trackedSince: DateFixtures.reference, + gamesPlayed: 6, + gamesWon: 4, + totalTimeSeconds: 1200, + bestTimeSeconds: 150, + highScoreDrawThree: nil, + highScoreDrawOne: nil, + cleanWins: 3 + ) + + let aggregate = GameStatistics.aggregated([klondikeStats, freeCellStats]) + + XCTAssertEqual(aggregate.trackedSince, DateFixtures.reference) + XCTAssertEqual(aggregate.gamesPlayed, 10) + XCTAssertEqual(aggregate.gamesWon, 7) + XCTAssertEqual(aggregate.totalTimeSeconds, 2000) + XCTAssertEqual(aggregate.bestTimeSeconds, 120) + XCTAssertEqual(aggregate.cleanWins, 5) + XCTAssertEqual(aggregate.highScoreDrawThree, 500) + XCTAssertEqual(aggregate.highScoreDrawOne, 300) + } + + func testAggregatedStatisticsUsesOverflowSafeCounters() { + let largeA = GameStatistics( + gamesPlayed: Int.max, + gamesWon: Int.max, + totalTimeSeconds: Int.max, + cleanWins: Int.max + ) + let largeB = GameStatistics( + gamesPlayed: 100, + gamesWon: 100, + totalTimeSeconds: 100, + cleanWins: 100 + ) + + let aggregate = GameStatistics.aggregated([largeA, largeB]) + + XCTAssertEqual(aggregate.gamesPlayed, Int.max) + XCTAssertEqual(aggregate.gamesWon, Int.max) + XCTAssertEqual(aggregate.totalTimeSeconds, Int.max) + XCTAssertEqual(aggregate.cleanWins, Int.max) + } + + private let defaultsSuiteName = "ComputerSolitaire.GameStatisticsStoreTests" + + private func makeIsolatedDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: defaultsSuiteName)! + defaults.removePersistentDomain(forName: defaultsSuiteName) + return defaults + } +} diff --git a/ComputerSolitaireTests/SavedGamePayloadSanitizationTests.swift b/ComputerSolitaireTests/Shared/SavedGamePayloadSanitizationTests.swift similarity index 100% rename from ComputerSolitaireTests/SavedGamePayloadSanitizationTests.swift rename to ComputerSolitaireTests/Shared/SavedGamePayloadSanitizationTests.swift diff --git a/ComputerSolitaireTests/ScoringTests.swift b/ComputerSolitaireTests/Shared/ScoringTests.swift similarity index 100% rename from ComputerSolitaireTests/ScoringTests.swift rename to ComputerSolitaireTests/Shared/ScoringTests.swift diff --git a/ComputerSolitaireTests/SolitaireViewModelCoreTests.swift b/ComputerSolitaireTests/Shared/SolitaireViewModelCoreTests.swift similarity index 100% rename from ComputerSolitaireTests/SolitaireViewModelCoreTests.swift rename to ComputerSolitaireTests/Shared/SolitaireViewModelCoreTests.swift diff --git a/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift b/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift new file mode 100644 index 0000000..558e86e --- /dev/null +++ b/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift @@ -0,0 +1,200 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class TapMovePolicyTests: XCTestCase { + // MARK: - Taps never dead-end + + func testTapAlwaysResolvesWhenLegalDestinationExistsAcrossRandomFreeCellPlay() { + var generator = SeededRandomNumberGenerator(seed: 99) + for seed in 1...10 { + var state = GameStateFixtures.seededFreeCellDeal(seed: UInt64(seed)) + for _ in 0..<40 { + for pile in state.tableau.indices { + guard let top = state.tableau[pile].last else { continue } + let selection = Selection( + source: .tableau(pile: pile, index: state.tableau[pile].count - 1), + cards: [top] + ) + let legal = AutoMoveAdvisor.legalDestinations(for: selection, in: state) + guard !legal.isEmpty else { continue } + XCTAssertNotNil( + TapMovePolicy.bestDestination(for: selection, in: state), + "Tap must resolve while a legal destination exists" + ) + } + guard let next = randomAdvance(state, using: &generator) else { break } + state = next + } + } + } + + // MARK: - FreeCell destination preferences + + func testFreeCellSafeFoundationMoveBeatsTableauBuild() { + // 2♠ can go to foundation (safe: rank <= 2) or onto the red 3. + let twoSpades = TestCards.make(.spades, .two) + let threeHearts = TestCards.make(.hearts, .three) + let state = freeCellState( + freeCells: [nil, nil, nil, nil], + foundations: [[TestCards.make(.spades, .ace)], [], [], []], + tableau: [[twoSpades], [threeHearts], [], [], [], [], [], []] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: [twoSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .foundation(0)) + } + + func testFreeCellUnsafeFoundationMoveLosesToTableauBuild() { + // 5♠ is foundation-eligible but unsafe (red foundations far behind); prefer the red 6. + let fiveSpades = TestCards.make(.spades, .five) + let sixHearts = TestCards.make(.hearts, .six) + let state = freeCellState( + freeCells: [nil, nil, nil, nil], + foundations: [ + [TestCards.make(.spades, .ace), TestCards.make(.spades, .two), + TestCards.make(.spades, .three), TestCards.make(.spades, .four)], + [], [], [] + ], + tableau: [[fiveSpades], [sixHearts], [], [], [], [], [], []] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: [fiveSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .tableau(1)) + } + + func testFreeCellFreeCellIsLastResort() { + // King with no tableau fit: only free cells remain, and the tap should use one. + let kingSpades = TestCards.make(.spades, .king) + let sevenHearts = TestCards.make(.hearts, .seven) + let state = freeCellState( + freeCells: [nil, nil, nil, nil], + foundations: [[], [], [], []], + tableau: [ + [sevenHearts, kingSpades], + [TestCards.make(.clubs, .four)], + [TestCards.make(.diamonds, .nine)], + [TestCards.make(.spades, .six)], + [TestCards.make(.hearts, .queen)], + [TestCards.make(.clubs, .ten)], + [TestCards.make(.diamonds, .two)], + [TestCards.make(.clubs, .ace)] + ] + ) + let selection = Selection(source: .tableau(pile: 0, index: 1), cards: [kingSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .freeCell(0)) + } + + func testFoundationSourceTapsNeverAutoMove() { + // The 2♠ on the foundation could legally return to the red 3, but taps must not do that. + let twoSpades = TestCards.make(.spades, .two) + let threeHearts = TestCards.make(.hearts, .three) + let state = freeCellState( + freeCells: [nil, nil, nil, nil], + foundations: [[TestCards.make(.spades, .ace), twoSpades], [], [], []], + tableau: [[threeHearts], [], [], [], [], [], [], []] + ) + let selection = Selection(source: .foundation(pile: 0), cards: [twoSpades]) + + XCTAssertNil(TapMovePolicy.bestDestination(for: selection, in: state)) + } + + // MARK: - Klondike destination preferences + + func testKlondikeFoundationBeatsTableauBuild() { + let aceSpades = TestCards.make(.spades, .ace) + let twoHearts = TestCards.make(.hearts, .two) + let state = GameState( + stock: [], + waste: [aceSpades], + wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: [[twoHearts], [], [], [], [], [], []] + ) + let selection = Selection(source: .waste, cards: [aceSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .foundation(0)) + } + + func testKlondikePrefersLongerBuildBetweenTableauOptions() { + // 5♠ fits on either red 6; prefer the 6 sitting on a longer ordered run. + let fiveSpades = TestCards.make(.spades, .five) + let sixHearts = TestCards.make(.hearts, .six) + let sixDiamonds = TestCards.make(.diamonds, .six) + let sevenClubs = TestCards.make(.clubs, .seven) + let state = GameState( + stock: [], + waste: [fiveSpades], + wasteDrawCount: 1, + foundations: Array(repeating: [], count: 4), + tableau: [[sixHearts], [sevenClubs, sixDiamonds], [], [], [], [], []] + ) + let selection = Selection(source: .waste, cards: [fiveSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .tableau(1)) + } + + // MARK: - Safe foundation rule + + func testSafeFoundationRule() { + var state = freeCellState( + freeCells: [nil, nil, nil, nil], + foundations: [ + [TestCards.make(.spades, .ace), TestCards.make(.spades, .two), TestCards.make(.spades, .three)], + [TestCards.make(.clubs, .ace), TestCards.make(.clubs, .two)], + [TestCards.make(.hearts, .ace), TestCards.make(.hearts, .two), TestCards.make(.hearts, .three)], + [TestCards.make(.diamonds, .ace), TestCards.make(.diamonds, .two), TestCards.make(.diamonds, .three)] + ], + tableau: Array(repeating: [], count: 8) + ) + + // 4♠: both red foundations at 3 (>= 3) and other black at 2 (>= 2) → safe. + XCTAssertTrue(TapMovePolicy.isSafeFoundationMove(card: TestCards.make(.spades, .four), in: state)) + // 4♥: opposite (black) minimum is 2 < 3 → unsafe. + XCTAssertFalse(TapMovePolicy.isSafeFoundationMove(card: TestCards.make(.hearts, .four), in: state)) + // Aces and twos are always safe. + XCTAssertTrue(TapMovePolicy.isSafeFoundationMove(card: TestCards.make(.clubs, .two), in: state)) + + state.foundations[1] = [] + XCTAssertTrue(TapMovePolicy.isSafeFoundationMove(card: TestCards.make(.hearts, .ace), in: state)) + } + + // MARK: - Helpers + + private func freeCellState( + freeCells: [Card?], + foundations: [[Card]], + tableau: [[Card]] + ) -> GameState { + GameState( + variant: .freecell, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: freeCells, + foundations: foundations, + tableau: tableau + ) + } + + private func randomAdvance( + _ state: GameState, + using generator: inout SeededRandomNumberGenerator + ) -> GameState? { + var moves: [(Selection, Destination)] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source { continue } + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + moves.append((selection, destination)) + } + } + guard let (selection, destination) = moves.randomElement(using: &generator) else { return nil } + return AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + } +} diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index fa905a5..37c280c 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -22,8 +22,73 @@ enum TestCards { } } +/// Deterministic RNG (SplitMix64) so test deals are reproducible across runs and machines. +struct SeededRandomNumberGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var mixed = state + mixed = (mixed ^ (mixed >> 30)) &* 0xBF58476D1CE4E5B9 + mixed = (mixed ^ (mixed >> 27)) &* 0x94D049BB133111EB + return mixed ^ (mixed >> 31) + } +} + @MainActor enum GameStateFixtures { + /// A reproducible FreeCell deal. Uses a hand-rolled Fisher–Yates so the layout for a + /// given seed never shifts underneath the tests. + static func seededFreeCellDeal(seed: UInt64) -> GameState { + let deck = seededDeck(seed: seed, faceUp: true) + var tableau = Array(repeating: [Card](), count: 8) + for index in 0.. GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = Array(repeating: [], count: 7) + for pileIndex in 0..<7 { + for cardIndex in 0...pileIndex { + var card = deck.removeLast() + card.isFaceUp = cardIndex == pileIndex + tableau[pileIndex].append(card) + } + } + return GameState( + stock: deck, + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } + + private static func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { + var generator = SeededRandomNumberGenerator(seed: seed) + var deck = TestCards.fullDeck(faceUp: faceUp) + for index in stride(from: deck.count - 1, through: 1, by: -1) { + let swapIndex = Int(generator.next() % UInt64(index + 1)) + deck.swapAt(index, swapIndex) + } + return deck + } static func emptyBoard() -> GameState { GameState( stock: [], diff --git a/README.md b/README.md index 93a65c0..f4c1a07 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,14 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS +- Multiple game variants: **Klondike** (both 1-card and 3-card draw) and **FreeCell** - Automatic game persistence and resume -- Support for 1-card and 3-card Klondike variants - Customizable table appearance - Other things you enjoy + +## Game Variants + +| Variant | Description | Rules | +|---------|-------------|-------| +| **Klondike** | Classic Solitaire, with 1-card and 3-card draw modes | [Rules](docs/solitaire-rules-klondike.md) | +| **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/solitaire-rules-freecell.md) | diff --git a/docs/images/computer-solitaire-app-icon.png b/docs/images/computer-solitaire-app-icon.png index 402298e..d4d3bdd 100644 Binary files a/docs/images/computer-solitaire-app-icon.png and b/docs/images/computer-solitaire-app-icon.png differ diff --git a/docs/solitaire-rules-freecell.md b/docs/solitaire-rules-freecell.md new file mode 100644 index 0000000..9c0bed3 --- /dev/null +++ b/docs/solitaire-rules-freecell.md @@ -0,0 +1,60 @@ +# FreeCell Rules + +These rules describe standard FreeCell setup and play. Unlike Klondike, all cards are dealt face up at the start, there is no stock or waste, and nearly every deal is winnable with correct play. + +## Objective +Move all 52 cards to the four foundations, building each suit from Ace to King. + +## Terminology +- **Cascades:** Eight tableau columns where cards are played and rearranged. +- **Free cells:** Four slots that each hold one card temporarily. +- **Foundations:** Four suit piles built from Ace to King. +- **Supermove:** Moving an ordered run of cards at once, as a shortcut for a series of single-card moves through free cells and empty cascades. + +## Layout +- Eight cascades (tableau columns), all cards face up. +- Four free cells (temporary one-card storage slots). +- Four foundations (built by suit). +- There is no stock or waste — every card is visible and in play from the start. + +## Setup +- Use a standard 52-card deck (no jokers). +- Deal all 52 cards face up across the eight cascades, left to right. +- The first four cascades receive **7 cards** each; the last four receive **6 cards** each. +- The four free cells and four foundations start empty. + +## Cascade Play +- Build cascades **down in rank** while **alternating colors** (e.g., red 6 on black 7). +- You may move the **bottom (exposed) card** of a cascade, or a properly ordered run of cards ending with it (see Multi-Card Moves below). +- An exposed card may move to: + - another cascade, onto a card one rank higher of the opposite color; + - an empty free cell; + - its foundation, if it is the next card in that suit's sequence. +- **Any card** may be placed on an empty cascade — unlike Klondike, empty spaces are not restricted to Kings. + +## Free Cells +- Each free cell holds **exactly one card**. +- Any exposed card may be moved to an empty free cell at any time. +- A card in a free cell may return to a cascade (following the normal build rule), move to an empty cascade, or move to its foundation. +- Free cells are the game's main maneuvering space; keeping them open preserves mobility. + +## Foundations +- Foundations are built **by suit** from **Ace to King**. +- Aces start each foundation pile. +- Cards may move to foundations from cascades or free cells. + +## Multi-Card Moves (Supermoves) +Formally, FreeCell only allows moving one card at a time. Moving a run of cards is a shortcut for a series of single-card moves through free cells and empty cascades, so the length of a movable run is limited by the available space: + +- Maximum run length = `(empty free cells + 1) × 2^(empty cascades)` +- Example: 2 empty free cells and 1 empty cascade allow a run of up to (2 + 1) × 2 = **6 cards**. +- With no free cells or empty cascades available, only **one card** may be moved at a time. +- The run itself must already be properly ordered (descending rank, alternating colors), and its destination must follow the normal build rule. +- **When moving a run onto an empty cascade**, that destination cascade does not count as an available empty cascade, since it cannot be used as an intermediate stop for its own move. + +## Winning +You win when all 52 cards are moved to the foundations in ascending order by suit. Because all cards are visible from the deal, FreeCell is a game of near-complete information — of the original 32,000 Microsoft deals, only one (#11982) is unwinnable. + +## Sources +- https://en.wikipedia.org/wiki/FreeCell +- https://www.247freecell.com/news/mastering-the-freecell-rules-a-beginners-guide/