diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index 5163198..e60ce07 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -324,7 +324,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.8.0; + MARKETING_VERSION = 0.8.1; PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; @@ -376,7 +376,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.8.0; + MARKETING_VERSION = 0.8.1; PRODUCT_BUNDLE_IDENTIFIER = com.crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; diff --git a/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift b/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift index 7a5c371..8f0bc42 100644 --- a/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift +++ b/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift @@ -1,6 +1,6 @@ import Foundation -enum CanfieldAutoMoveAdvisor { +nonisolated enum CanfieldAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { // Canfield's defining transfer rule: a pile moves between tableau // piles only in its entirety, and its exposed top card plays to a diff --git a/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift b/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift index a33ca52..820be79 100644 --- a/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift +++ b/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift @@ -31,7 +31,7 @@ import Foundation /// hint wins 25.0% of games versus the 1.2% random control, with zero /// stalemate loops, zero exact-position revisits, and every loss an honest /// deadlock proven by an exhaustive search. -enum CanfieldPlanner { +nonisolated enum CanfieldPlanner { struct Limits { var maxNodes: Int var maxDepth: Int @@ -198,7 +198,7 @@ enum CanfieldPlanner { // MARK: - Search internals -private extension CanfieldPlanner { +nonisolated private extension CanfieldPlanner { static func search(in state: GameState, limits: Limits) -> SearchOutcome { let rootScore = score(state) var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] diff --git a/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift b/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift index 3f2e5b2..3c75de1 100644 --- a/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift +++ b/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift @@ -1,6 +1,6 @@ import Foundation -enum CanfieldPersistenceRules { +nonisolated enum CanfieldPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == CanfieldGameRules.tableauPileCount else { return false } // Every tableau card deals (and stays) face up, and every pile stays a diff --git a/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift b/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift index 0b939f4..5551333 100644 --- a/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift +++ b/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift @@ -1,6 +1,6 @@ import Foundation -enum CanfieldGameRules { +nonisolated enum CanfieldGameRules { static let tableauPileCount = 4 static let reserveCardCount = 13 /// The 52-card deal minus the reserve, the base card, and four tableau cards. @@ -137,7 +137,7 @@ enum CanfieldGameRules { } } -private extension CanfieldGameRules { +nonisolated private extension CanfieldGameRules { /// The raw rank one step above `rank`, turning the corner from King to Ace. static func wrappedRankAbove(_ rank: Rank) -> Int { rank.rawValue % Rank.allCases.count + 1 diff --git a/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift b/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift index 280f84c..c33156d 100644 --- a/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift +++ b/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { /// The Canfield deal: thirteen cards face down into the reserve with its /// top card turned face up, one face-up base card onto the first /// foundation (its rank is where all four foundations start), one face-up diff --git a/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift index a6d050e..f534c2e 100644 --- a/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift +++ b/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift @@ -1,6 +1,6 @@ import Foundation -enum FortyThievesAutoMoveAdvisor { +nonisolated enum FortyThievesAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { // Forty Thieves' defining rule: only the exposed top card of a column // moves — never a sequence, however well ordered. diff --git a/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift b/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift index aa91bb5..a30d86f 100644 --- a/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift +++ b/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift @@ -21,7 +21,7 @@ import Foundation /// tap-crossing lines only win when the plays they enable pay for them. /// Foundations are locked and `candidateSelections` offers no foundation /// sources for rollback-free variants, so there is no rollback stage. -enum FortyThievesPlanner { +nonisolated enum FortyThievesPlanner { struct Limits { var maxNodes: Int var maxDepth: Int @@ -164,7 +164,7 @@ enum FortyThievesPlanner { // MARK: - Search internals -private extension FortyThievesPlanner { +nonisolated private extension FortyThievesPlanner { static func search(in state: GameState, limits: Limits) -> SearchOutcome { let rootScore = score(state) var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] diff --git a/ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift index 633fe49..680bb15 100644 --- a/ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift +++ b/ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift @@ -1,6 +1,6 @@ import Foundation -enum FortyThievesPersistenceRules { +nonisolated enum FortyThievesPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == FortyThievesGameRules.columnCount else { return false } // Every board card is dealt (and stays) face up. No depth cap: columns diff --git a/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift index 8eac06c..7a2f022 100644 --- a/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift +++ b/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift @@ -1,6 +1,6 @@ import Foundation -enum FortyThievesGameRules { +nonisolated enum FortyThievesGameRules { static let columnCount = 10 static let dealColumnDepth = 4 static let dealTableauCardCount = 40 diff --git a/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift index 6f95faa..0e39668 100644 --- a/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift +++ b/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { /// The Forty Thieves deal: two full decks shuffled together, ten columns /// of four face-up cards dealt column-major (column 0 bottom-to-top first, /// then column 1, and so on), and the remaining 64 cards face down in the diff --git a/ComputerSolitaire/Game/FreeCell/AutoFinishPlannerFreeCell.swift b/ComputerSolitaire/Game/FreeCell/AutoFinishPlannerFreeCell.swift new file mode 100644 index 0000000..9ff4982 --- /dev/null +++ b/ComputerSolitaire/Game/FreeCell/AutoFinishPlannerFreeCell.swift @@ -0,0 +1,29 @@ +import Foundation + +nonisolated extension AutoFinishPlanner { + /// A necessary condition for a FreeCell foundation run, so the win + /// simulation only runs on positions that could plausibly pass it. + /// Auto-finish plays nothing but cascade tops (and free cells) onto the + /// foundations, so a buried card can only reach its foundation after every + /// same-suit card above it — and the foundation ascends, so each of those + /// must outrank it. A cascade holding a same-suit pair whose deeper card + /// is the lower rank can therefore never drain, and the position can never + /// auto-finish. The check is monotone under the run's own moves (removing + /// tops cannot create such a pair), so a run that starts available stays + /// available step to step exactly as before. + static func freeCellCascadesAllowFoundationRun(_ state: GameState) -> Bool { + for pile in state.tableau { + // Bottom to top, each suit's ranks must strictly descend; track + // the lowest rank seen so far per suit and reject any card that + // sits above a lower-ranked card of its own suit. + var lowestRankBySuit: [Suit: Rank] = [:] + for card in pile { + if let lowest = lowestRankBySuit[card.suit], card.rank > lowest { + return false + } + lowestRankBySuit[card.suit] = card.rank + } + } + return true + } +} diff --git a/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift b/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift index 09c2119..5e78977 100644 --- a/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift @@ -1,6 +1,6 @@ import Foundation -enum FreeCellAutoMoveAdvisor { +nonisolated enum FreeCellAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { AutoMoveAdvisor.isValidTableauSequence(cards) } diff --git a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift index 2e0be72..3d15a2e 100644 --- a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift +++ b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift @@ -7,7 +7,7 @@ import Foundation /// `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 { +nonisolated enum FreeCellSolver { /// A card is `suitIndex << 4 | rank` (rank 1...13); suit order follows `Suit.allCases`. typealias Code = UInt8 @@ -170,7 +170,7 @@ enum FreeCellSolver { // MARK: - Board model -extension FreeCellSolver { +nonisolated extension FreeCellSolver { struct Board: Hashable { var cascades: [[Code]] var cells: [Code] // 0 = empty @@ -225,7 +225,7 @@ extension FreeCellSolver { // MARK: - Search internals -private extension FreeCellSolver { +nonisolated 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). diff --git a/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift index afbde3b..36610ff 100644 --- a/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift @@ -1,6 +1,6 @@ import Foundation -enum FreeCellPersistenceRules { +nonisolated 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 } diff --git a/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift index abe1cb6..5741776 100644 --- a/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift @@ -1,4 +1,4 @@ -enum FreeCellGameRules { +nonisolated enum FreeCellGameRules { static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { if destinationPile.isEmpty { return true diff --git a/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift index 5a4d871..d324dff 100644 --- a/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { static func newFreeCellGame() -> GameState { var deck = Card.fullDeck().shuffled() var tableau = Array(repeating: [Card](), count: 8) diff --git a/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift b/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift index d96267f..889e493 100644 --- a/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift +++ b/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift @@ -4,7 +4,7 @@ import Foundation /// variants — its only move is exposed column card onto the waste — so /// `AutoMoveAdvisor` dispatches to it wholesale instead of threading its moves /// through the pile-oriented hooks. -enum GolfAutoMoveAdvisor { +nonisolated enum GolfAutoMoveAdvisor { /// The exposed (last) card of every non-empty column, each as a /// single-card selection. The waste top is never a selection: in Golf it /// is the match target, not a mover. diff --git a/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift b/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift index 3922a56..6acf5f9 100644 --- a/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift +++ b/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift @@ -1,6 +1,6 @@ import Foundation -enum GolfPersistenceRules { +nonisolated enum GolfPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == GolfGameRules.columnCount else { return false } // Columns deal five cards and only ever shrink; every board card is diff --git a/ComputerSolitaire/Game/Golf/GameRulesGolf.swift b/ComputerSolitaire/Game/Golf/GameRulesGolf.swift index 099aca2..2fcdc91 100644 --- a/ComputerSolitaire/Game/Golf/GameRulesGolf.swift +++ b/ComputerSolitaire/Game/Golf/GameRulesGolf.swift @@ -1,6 +1,6 @@ import Foundation -enum GolfGameRules { +nonisolated enum GolfGameRules { static let columnCount = 7 static let columnDepth = 5 static let dealTableauCardCount = 35 diff --git a/ComputerSolitaire/Game/Golf/GameStateGolf.swift b/ComputerSolitaire/Game/Golf/GameStateGolf.swift index 76396d3..aedac68 100644 --- a/ComputerSolitaire/Game/Golf/GameStateGolf.swift +++ b/ComputerSolitaire/Game/Golf/GameStateGolf.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { /// The Golf deal: seven columns of five face-up cards dealt column-major /// (column 0 bottom-to-top first, then column 1, and so on), one face-up /// card starting the waste, and the remaining 16 cards face down in the diff --git a/ComputerSolitaire/Game/Golf/GolfMatchState.swift b/ComputerSolitaire/Game/Golf/GolfMatchState.swift index aaf892b..6afa6c2 100644 --- a/ComputerSolitaire/Game/Golf/GolfMatchState.swift +++ b/ComputerSolitaire/Game/Golf/GolfMatchState.swift @@ -6,7 +6,7 @@ import Foundation /// match total is a plain sum. Lower is better throughout, and negative /// scores are legal results (a cleared board banks one bonus stroke per /// leftover stock card). -struct GolfMatchState: Codable, Equatable { +nonisolated struct GolfMatchState: Codable, Equatable { static let holeCount = 9 /// Traditional par framing: a nine-hole total of 45 or under is par. static let parTotal = 45 diff --git a/ComputerSolitaire/Game/Golf/GolfPlanner.swift b/ComputerSolitaire/Game/Golf/GolfPlanner.swift index 1570e82..11d73ce 100644 --- a/ComputerSolitaire/Game/Golf/GolfPlanner.swift +++ b/ComputerSolitaire/Game/Golf/GolfPlanner.swift @@ -27,7 +27,7 @@ import Foundation /// /// Hint-quality baselines live in the `tools/hint-probe` ledger; the measured /// verdict split is recorded below `Limits`. -enum GolfPlanner { +nonisolated enum GolfPlanner { struct Limits { var maxNodes: Int var deadline: Date? @@ -175,7 +175,7 @@ enum GolfPlanner { // MARK: - Session move mapping -private extension GolfPlanner { +nonisolated private extension GolfPlanner { static func sessionMove( for move: Move, in state: GameState @@ -197,7 +197,7 @@ private extension GolfPlanner { // MARK: - Compact position -private extension GolfPlanner { +nonisolated private extension GolfPlanner { /// The deal's immutable rank tables plus the packed dynamic board. Stock cards /// are indexed in draw order relative to the root; cards already in the waste /// below its top are simply absent — the search never needs them. @@ -283,7 +283,7 @@ private extension GolfPlanner { // MARK: - Move generation and transitions -private extension GolfPlanner { +nonisolated private extension GolfPlanner { /// Legal moves in a fixed, deterministic order: playable columns ascending, /// then draw — so equal-depth ties favor clearing over flipping and lines /// read sensibly. Legality is `GolfGameRules.canPlayRank`, so the strict @@ -327,7 +327,7 @@ private extension GolfPlanner { // MARK: - Search -private extension GolfPlanner { +nonisolated private extension GolfPlanner { /// One explored position, packed to 12 bytes so the million-node budget /// costs ~12 MB of nodes instead of ~50: the 30-bit board code stands in /// for the whole board (it round-trips through `Board(code:)`), the move diff --git a/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift b/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift index 7e8a288..4672b08 100644 --- a/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift @@ -1,6 +1,6 @@ import Foundation -enum KlondikeAutoMoveAdvisor { +nonisolated enum KlondikeAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { AutoMoveAdvisor.isValidTableauSequence(cards) } diff --git a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift index df65439..162ab22 100644 --- a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift @@ -1,6 +1,6 @@ import Foundation -enum KlondikePersistenceRules { +nonisolated enum KlondikePersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == 7 else { return false } // Klondike renders no free-cell slots, so a card stranded there would be diff --git a/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift b/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift index 44114f2..3fa02cf 100644 --- a/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift @@ -1,4 +1,4 @@ -enum KlondikeGameRules { +nonisolated enum KlondikeGameRules { static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { SharedGameRules.canMoveToKingAnchoredTableau(card: card, destinationPile: destinationPile) } diff --git a/ComputerSolitaire/Game/Klondike/GameStateKlondike.swift b/ComputerSolitaire/Game/Klondike/GameStateKlondike.swift index af1127d..11cda5f 100644 --- a/ComputerSolitaire/Game/Klondike/GameStateKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameStateKlondike.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { static func newKlondikeGame() -> GameState { var deck = Card.fullDeck().shuffled() var tableau = Array(repeating: [Card](), count: 7) diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index 8f8e872..7e59b27 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -9,7 +9,7 @@ import Foundation /// /// 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 { +nonisolated enum KlondikePlanner { struct Limits { var maxNodes: Int var maxDepth: Int @@ -99,7 +99,7 @@ enum KlondikePlanner { // MARK: - Search internals -private extension KlondikePlanner { +nonisolated private extension KlondikePlanner { enum Action { case move(Selection, Destination) case stockTap diff --git a/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift b/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift index 2650f2d..6081379 100644 --- a/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift @@ -3,7 +3,7 @@ import Foundation /// Pyramid shares no foundation/tableau/free-cell move algebra with the other /// variants, so `AutoMoveAdvisor` dispatches to it wholesale instead of threading /// its moves through the pile-oriented hooks. -enum PyramidAutoMoveAdvisor { +nonisolated enum PyramidAutoMoveAdvisor { /// The top waste card (if any) plus every selectable pyramid card, each as a /// single-card selection. static func candidateSelections(in state: GameState) -> [Selection] { diff --git a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift index 879fecc..7e73311 100644 --- a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift @@ -1,6 +1,6 @@ import Foundation -enum PyramidPersistenceRules { +nonisolated enum PyramidPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.pyramid.count == PyramidGeometry.cardCount else { return false } guard state.tableau.isEmpty else { return false } diff --git a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift index 879d1d4..2d9774d 100644 --- a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift @@ -1,6 +1,6 @@ import Foundation -enum PyramidGameRules { +nonisolated enum PyramidGameRules { /// Two cards pair when their ranks sum to this; a King reaches it alone. static let pairSum = 13 /// The waste may be recycled into the stock this many times (three total passes). diff --git a/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift b/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift index fc6a403..6af9039 100644 --- a/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { static func newPyramidGame() -> GameState { var deck = Card.fullDeck().shuffled() var pyramid: [Card?] = [] diff --git a/ComputerSolitaire/Game/Pyramid/PyramidGeometry.swift b/ComputerSolitaire/Game/Pyramid/PyramidGeometry.swift index e8ef067..9033101 100644 --- a/ComputerSolitaire/Game/Pyramid/PyramidGeometry.swift +++ b/ComputerSolitaire/Game/Pyramid/PyramidGeometry.swift @@ -5,7 +5,7 @@ import Foundation /// Slots are row-major: row `r` (0-based, apex first) occupies indices /// `r(r+1)/2 ..< (r+1)(r+2)/2`, so row 0 is slot 0 and row 6 is slots 21...27. /// Each slot except the bottom row is covered by two slots in the row below. -enum PyramidGeometry { +nonisolated enum PyramidGeometry { static let rowCount = 7 static let cardCount = 28 diff --git a/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift b/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift index 621f46e..68a1027 100644 --- a/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift +++ b/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift @@ -25,7 +25,7 @@ import Foundation /// best-effort lines); `bestLine` median 0.5ms. Hint-quality baselines live in /// the `tools/hint-probe` ledger: 80.2% of 500 deals won by following every /// hint against a 15.2% random-control floor, zero loops. -enum PyramidPlanner { +nonisolated enum PyramidPlanner { struct Limits { var maxNodes: Int var deadline: Date? @@ -196,7 +196,7 @@ enum PyramidPlanner { // MARK: - Session move mapping -private extension PyramidPlanner { +nonisolated private extension PyramidPlanner { static func sessionMove( for move: Move, in state: GameState @@ -228,7 +228,7 @@ private extension PyramidPlanner { // MARK: - Compact position -private extension PyramidPlanner { +nonisolated private extension PyramidPlanner { /// The deal's immutable rank tables plus the packed dynamic board. Stock cards /// are indexed in draw order: the root's waste bottom-to-top (already drawn), /// then the remaining stock in draw order. Cards already discarded at the root @@ -342,7 +342,7 @@ private extension PyramidPlanner { // MARK: - Move generation and transitions -private extension PyramidPlanner { +nonisolated private extension PyramidPlanner { /// Legal moves in a fixed, deterministic order: pyramid pairs by ascending /// slots (cover-pairs included), waste pairs by ascending slot, Kings, then /// draw and reset — removals first so equal-priority ties favor action. @@ -442,7 +442,7 @@ private extension PyramidPlanner { // MARK: - Heuristic and dead-position proof -private extension PyramidPlanner { +nonisolated private extension PyramidPlanner { /// Admissible lower bound on moves left to clear the pyramid: every King costs /// one removal, and each pair move lowers exactly one `max(count(r), /// count(13−r))` term by at most 1; draws and resets clear nothing. @@ -489,7 +489,7 @@ private extension PyramidPlanner { // MARK: - Search -private extension PyramidPlanner { +nonisolated private extension PyramidPlanner { struct Node { let board: Board let parent: Int diff --git a/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift b/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift index fea13e2..322adb0 100644 --- a/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift +++ b/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift @@ -1,6 +1,6 @@ import Foundation -enum ScorpionAutoMoveAdvisor { +nonisolated enum ScorpionAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { // Scorpion moves groups Yukon-style: any face-up card can be picked up // together with every card above it, regardless of whether they form a diff --git a/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift b/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift index e7299fb..2f03a8e 100644 --- a/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift +++ b/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift @@ -1,6 +1,6 @@ import Foundation -enum ScorpionPersistenceRules { +nonisolated enum ScorpionPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == 7 else { return false } // The stock deals exactly once, wholesale: three cards or none. diff --git a/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift b/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift index de57632..7c2b4d1 100644 --- a/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift +++ b/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift @@ -1,6 +1,6 @@ import Foundation -enum ScorpionGameRules { +nonisolated enum ScorpionGameRules { /// Scorpion's landing rule: an empty pile takes only a king, and a face-up /// top takes the card one rank lower of the same suit. static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { diff --git a/ComputerSolitaire/Game/Scorpion/GameStateScorpion.swift b/ComputerSolitaire/Game/Scorpion/GameStateScorpion.swift index 1e736e8..36ca160 100644 --- a/ComputerSolitaire/Game/Scorpion/GameStateScorpion.swift +++ b/ComputerSolitaire/Game/Scorpion/GameStateScorpion.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { static func newScorpionGame() -> GameState { var deck = Card.fullDeck().shuffled() var tableau = Array(repeating: [Card](), count: 7) diff --git a/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift b/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift index 11fb7c9..a9eb3d2 100644 --- a/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift +++ b/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift @@ -28,7 +28,7 @@ import Foundation /// Measured in the `tools/hint-probe` ledger: following every hint wins 14.8% /// of 500 seeded deals versus the random control's 2.8%, with zero revisit /// events — at the level of published practical win rates for Scorpion. -enum ScorpionPlanner { +nonisolated enum ScorpionPlanner { struct Limits { var maxNodes: Int var maxDepth: Int @@ -120,7 +120,7 @@ enum ScorpionPlanner { // MARK: - Search internals -private extension ScorpionPlanner { +nonisolated private extension ScorpionPlanner { static func search(in state: GameState, limits: Limits) -> SearchOutcome { let rootScore = score(state) var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift similarity index 96% rename from ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift rename to ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift index 02c9be9..f87f897 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift @@ -9,7 +9,7 @@ import Foundation /// waste top. Canfield qualifies once its stock and waste are both spent, playing /// tableau tops and the reserve top (with the compulsory reserve fill mirrored in /// simulation). -enum AutoFinishPlanner { +nonisolated enum AutoFinishPlanner { struct AutoFinishMove { let selection: Selection let destination: Destination @@ -42,7 +42,7 @@ enum AutoFinishPlanner { } } -private extension AutoFinishPlanner { +nonisolated private extension AutoFinishPlanner { static func isAutoFinishCandidateState(_ state: GameState) -> Bool { guard !state.isWon else { return false } switch state.variant { @@ -50,7 +50,10 @@ private extension AutoFinishPlanner { guard state.stock.isEmpty, state.waste.isEmpty else { return false } return !state.tableau.joined().contains(where: { !$0.isFaceUp }) case .freecell: - return true + // Every FreeCell card is face up, so eligibility rests entirely on + // whether the cascades can drain; the ordering check below rejects + // nearly every mid-game position without running the simulation. + return freeCellCascadesAllowFoundationRun(state) case .yukon: return !state.tableau.joined().contains(where: { !$0.isFaceUp }) case .spider, .scorpion: diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index 58267a0..c3e333e 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -3,7 +3,7 @@ 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 { +nonisolated enum AutoMoveAdvisor { static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { // Pyramid, TriPeaks, and Golf remove cards instead of building piles, // so their move sets are generated wholesale rather than through the @@ -287,7 +287,7 @@ enum AutoMoveAdvisor { } } -private extension AutoMoveAdvisor { +nonisolated private extension AutoMoveAdvisor { static func variantAllowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { switch state.variant { case .klondike: diff --git a/ComputerSolitaire/Game/Shared/BinaryHeap.swift b/ComputerSolitaire/Game/Shared/BinaryHeap.swift index 27f2398..30c2373 100644 --- a/ComputerSolitaire/Game/Shared/BinaryHeap.swift +++ b/ComputerSolitaire/Game/Shared/BinaryHeap.swift @@ -1,6 +1,6 @@ import Foundation -protocol HeapPrioritizable { +nonisolated protocol HeapPrioritizable { /// Whether this element should be popped before `other`. Implementations decide /// the ordering (max- or min-first) and must break ties deterministically when /// the search relies on reproducible expansion order. @@ -9,7 +9,7 @@ protocol HeapPrioritizable { /// Array-backed binary heap shared by the search planners; pops the element that /// `takesPriority(over:)` every other element. -struct BinaryHeap { +nonisolated struct BinaryHeap { private var entries: [Element] = [] mutating func push(_ entry: Element) { diff --git a/ComputerSolitaire/Game/Shared/Card.swift b/ComputerSolitaire/Game/Shared/Card.swift index 73d2e30..74c110f 100644 --- a/ComputerSolitaire/Game/Shared/Card.swift +++ b/ComputerSolitaire/Game/Shared/Card.swift @@ -1,6 +1,6 @@ import Foundation -enum Suit: CaseIterable, Codable { +nonisolated enum Suit: CaseIterable, Codable { case spades case hearts case diamonds @@ -38,7 +38,7 @@ enum Suit: CaseIterable, Codable { } } -enum Rank: Int, CaseIterable, Comparable, Codable { +nonisolated enum Rank: Int, CaseIterable, Comparable, Codable { case ace = 1 case two = 2 case three = 3 @@ -91,7 +91,7 @@ enum Rank: Int, CaseIterable, Comparable, Codable { } } -struct Card: Identifiable, Equatable, Codable { +nonisolated struct Card: Identifiable, Equatable, Codable { let id: UUID let suit: Suit let rank: Rank @@ -107,12 +107,12 @@ struct Card: Identifiable, Equatable, Codable { /// A card's face — suit and rank without instance identity. Deck-composition /// checks count these; Spider's two decks carry each identity more than once. -struct CardIdentity: Hashable { +nonisolated struct CardIdentity: Hashable { let suit: Suit let rank: Rank } -extension Card { +nonisolated extension Card { var accessibilityName: String { guard isFaceUp else { return "Face-down card" } return "\(rank.accessibilityName) of \(suit.accessibilityName)" diff --git a/ComputerSolitaire/Game/Shared/GameMode.swift b/ComputerSolitaire/Game/Shared/GameMode.swift index 0fe0a3c..ebb0489 100644 --- a/ComputerSolitaire/Game/Shared/GameMode.swift +++ b/ComputerSolitaire/Game/Shared/GameMode.swift @@ -7,7 +7,7 @@ import Foundation /// statistics carry over. /// Cases are declared in presentation order, mirroring `GameVariant`; modes /// within a variant run easiest to hardest. -enum GameMode: String, CaseIterable, Codable { +nonisolated enum GameMode: String, CaseIterable, Codable { case klondikeDrawOne = "klondike.draw1" case klondikeDrawThree = "klondike.draw3" case spiderOneSuit = "spider.suits1" diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 445a108..7f58c7f 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -6,7 +6,7 @@ final class SavedGameRecord { /// Single-slot key used before saved games became per-mode. static let legacyRecordKey = "current" - static func key(for mode: GameMode) -> String { + nonisolated static func key(for mode: GameMode) -> String { mode.rawValue } @@ -25,7 +25,7 @@ final class SavedGameRecord { } } -struct SavedGamePayload: Codable { +nonisolated struct SavedGamePayload: Codable { static let currentSchemaVersion = 1 let schemaVersion: Int @@ -352,17 +352,39 @@ enum GamePersistence { } static func save(_ payload: SavedGamePayload, in modelContext: ModelContext, now: Date = .now) throws { + let encoded = try encodeForSave(payload, now: now) + try write(encoded, in: modelContext, now: now) + } + + /// The CPU-heavy half of a save — sanitizing and JSON-encoding the payload + /// (undo history included) — with no SwiftData dependency, so the debounced + /// autosave can run it off the main thread. + nonisolated static func encodeForSave( + _ payload: SavedGamePayload, + now: Date = .now + ) throws -> EncodedSave { guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { throw GamePersistenceError.invalidPayload } + return EncodedSave( + key: SavedGameRecord.key(for: sanitizedPayload.gameMode), + data: try JSONEncoder().encode(sanitizedPayload) + ) + } + + nonisolated struct EncodedSave { + let key: String + let data: Data + } - let data = try JSONEncoder().encode(sanitizedPayload) - let key = SavedGameRecord.key(for: sanitizedPayload.gameMode) - if let record = try fetchRecord(forKey: key, in: modelContext) { - record.snapshotData = data + /// The store half of a save: hands an already-encoded payload to SwiftData + /// on the context's actor. + static func write(_ encoded: EncodedSave, in modelContext: ModelContext, now: Date = .now) throws { + if let record = try fetchRecord(forKey: encoded.key, in: modelContext) { + record.snapshotData = encoded.data record.updatedAt = now } else { - modelContext.insert(SavedGameRecord(key: key, snapshotData: data, updatedAt: now)) + modelContext.insert(SavedGameRecord(key: encoded.key, snapshotData: encoded.data, updatedAt: now)) } try modelContext.save() } @@ -885,7 +907,7 @@ enum GameStatisticsStore { } } -private extension GameState { +nonisolated private extension GameState { var allCards: [Card] { stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + tableau.flatMap { $0 } + pyramid.compactMap { $0 } + discard diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 6e2a120..98d70a7 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -1,4 +1,4 @@ -enum GameRules { +nonisolated enum GameRules { /// The Ace-anchored foundation rule shared by every variant except /// Canfield, whose foundations start at a dealt base rank; state-aware /// callers should prefer `canMoveToFoundation(card:foundation:in:)`. @@ -86,7 +86,7 @@ enum GameRules { } } -enum SharedGameRules { +nonisolated enum SharedGameRules { /// Tableau landing rule shared by Klondike and Yukon: empty piles take Kings /// only; otherwise the moving card goes on a face-up top of the opposite color, /// one rank higher. diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 1b2f65a..59b6185 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -13,7 +13,7 @@ struct SystemDateProvider: DateProviding { @Observable final class SolitaireViewModel { - static let maxUndoHistoryCount = 200 + nonisolated static let maxUndoHistoryCount = 200 private static let hintVisibilityDuration: TimeInterval = 1.5 var state: GameState diff --git a/ComputerSolitaire/Game/Shared/GameSnapshot.swift b/ComputerSolitaire/Game/Shared/GameSnapshot.swift index fd8790f..5e93e6f 100644 --- a/ComputerSolitaire/Game/Shared/GameSnapshot.swift +++ b/ComputerSolitaire/Game/Shared/GameSnapshot.swift @@ -1,6 +1,6 @@ import Foundation -struct GameSnapshot: Codable { +nonisolated struct GameSnapshot: Codable { let state: GameState let movesCount: Int let score: Int @@ -39,7 +39,7 @@ struct GameSnapshot: Codable { } } -struct UndoAnimationContext: Codable { +nonisolated struct UndoAnimationContext: Codable { enum Action: String, Codable { case moveSelection case drawFromStock diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index 2fd2f92..f0f0b94 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -1,6 +1,6 @@ import Foundation -struct GameState: Equatable, Codable { +nonisolated struct GameState: Equatable, Codable { var variant: GameVariant var stock: [Card] var waste: [Card] diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index f466c72..6a8349f 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -3,7 +3,7 @@ import Foundation /// Cases are declared in presentation order — most-played game types first — /// and every list in the app (picker, menus, statistics) follows it. Slot new /// variants by how widely played they are, not at the end. -enum GameVariant: String, CaseIterable, Codable { +nonisolated enum GameVariant: String, CaseIterable, Codable { case klondike case spider case freecell @@ -163,7 +163,7 @@ enum GameVariant: String, CaseIterable, Codable { } } -enum DrawMode: Int, CaseIterable, Codable { +nonisolated enum DrawMode: Int, CaseIterable, Codable { case one = 1 case three = 3 @@ -179,7 +179,7 @@ enum DrawMode: Int, CaseIterable, Codable { /// Spider difficulty: how many distinct suits the two-deck (104-card) deal /// is composed of. -enum SpiderSuitCount: Int, CaseIterable, Codable { +nonisolated enum SpiderSuitCount: Int, CaseIterable, Codable { case one = 1 case two = 2 case four = 4 diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index 21cceb8..85d6df7 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -1,6 +1,6 @@ import Foundation -enum HintAdvisor { +nonisolated enum HintAdvisor { enum Hint: Equatable { case move(HintMove) case stockTap @@ -126,7 +126,7 @@ enum HintAdvisor { /// position can never progress — the hint is silence, not a tap that would /// churn a dead game forever. A truncated no-progress still falls back to the /// tap, mirroring Forty Thieves' measured rationale. -final class HintPlanner { +nonisolated 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 @@ -185,7 +185,7 @@ final class HintPlanner { } } -private extension HintPlanner { +nonisolated private extension HintPlanner { func freeCellHint(in state: GameState) -> HintAdvisor.Hint? { let key = FreeCellSolver.stateKey(for: state) if let hint = materializedHint(for: key, in: state) { diff --git a/ComputerSolitaire/Game/Shared/MoveTypes.swift b/ComputerSolitaire/Game/Shared/MoveTypes.swift index a45a2ee..0a66e7b 100644 --- a/ComputerSolitaire/Game/Shared/MoveTypes.swift +++ b/ComputerSolitaire/Game/Shared/MoveTypes.swift @@ -1,6 +1,6 @@ import Foundation -struct Selection: Equatable { +nonisolated struct Selection: Equatable { enum Source: Equatable { case waste case freeCell(slot: Int) @@ -19,7 +19,7 @@ struct Selection: Equatable { let cards: [Card] } -enum Destination: Equatable { +nonisolated enum Destination: Equatable { case foundation(Int) case tableau(Int) case freeCell(Int) diff --git a/ComputerSolitaire/Game/Shared/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift index eca491a..fc8592b 100644 --- a/ComputerSolitaire/Game/Shared/Scoring.swift +++ b/ComputerSolitaire/Game/Shared/Scoring.swift @@ -1,6 +1,6 @@ import Foundation -enum ScoringAction { +nonisolated enum ScoringAction { case wasteToTableau case wasteToFoundation case tableauToFoundation @@ -30,7 +30,7 @@ enum ScoringAction { case golfBoardClear(remainingStockCount: Int) } -enum Scoring { +nonisolated enum Scoring { static let minimumScore = 0 static let timedPointsLostPerSecond = 1 static let timedMaxBonusDrawOne = 600 diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index a675dcc..14e559d 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -5,7 +5,7 @@ import Foundation /// 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 { +nonisolated 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. @@ -67,7 +67,7 @@ enum TapMovePolicy { } } -private extension TapMovePolicy { +nonisolated private extension TapMovePolicy { struct Priority { let tier: Int let buildLength: Int diff --git a/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift b/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift index 71109ea..8b1dc0f 100644 --- a/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift +++ b/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift @@ -1,6 +1,6 @@ import Foundation -enum SpiderAutoMoveAdvisor { +nonisolated enum SpiderAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { // Spider's defining rule: a group moves only as a face-up // single-suit descending run. diff --git a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift index 023db08..09dd969 100644 --- a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift +++ b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift @@ -1,6 +1,6 @@ import Foundation -enum SpiderPersistenceRules { +nonisolated enum SpiderPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == 10 else { return false } // The stock only ever shrinks by full ten-card rows. diff --git a/ComputerSolitaire/Game/Spider/GameRulesSpider.swift b/ComputerSolitaire/Game/Spider/GameRulesSpider.swift index 6dfe4b9..2723761 100644 --- a/ComputerSolitaire/Game/Spider/GameRulesSpider.swift +++ b/ComputerSolitaire/Game/Spider/GameRulesSpider.swift @@ -1,6 +1,6 @@ import Foundation -enum SpiderGameRules { +nonisolated enum SpiderGameRules { /// Spider's landing rule: an empty pile takes any card, and a face-up top /// takes a card one rank lower regardless of suit. static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { diff --git a/ComputerSolitaire/Game/Spider/GameStateSpider.swift b/ComputerSolitaire/Game/Spider/GameStateSpider.swift index 10688f2..fa3fe9a 100644 --- a/ComputerSolitaire/Game/Spider/GameStateSpider.swift +++ b/ComputerSolitaire/Game/Spider/GameStateSpider.swift @@ -2,7 +2,7 @@ import Foundation /// Spider always plays 104 cards from two decks; the difficulty decides how /// many distinct suits compose them. -enum SpiderDeck { +nonisolated enum SpiderDeck { static func suits(for suitCount: SpiderSuitCount) -> [Suit] { switch suitCount { case .one: @@ -41,7 +41,7 @@ enum SpiderDeck { } } -extension GameState { +nonisolated extension GameState { static func newSpiderGame(suitCount: SpiderSuitCount) -> GameState { var deck = SpiderDeck.deck(suitCount: suitCount).shuffled() var tableau = Array(repeating: [Card](), count: 10) diff --git a/ComputerSolitaire/Game/Spider/SpiderPlanner.swift b/ComputerSolitaire/Game/Spider/SpiderPlanner.swift index d10fe64..07259cd 100644 --- a/ComputerSolitaire/Game/Spider/SpiderPlanner.swift +++ b/ComputerSolitaire/Game/Spider/SpiderPlanner.swift @@ -19,7 +19,7 @@ import Foundation /// stock term), so deal-crossing lines only win when the flips and joins they /// enable pay for them. Spider banks completed runs automatically and they /// never return, so there is no rollback stage. -enum SpiderPlanner { +nonisolated enum SpiderPlanner { struct Limits { var maxNodes: Int var maxDepth: Int @@ -141,7 +141,7 @@ enum SpiderPlanner { // MARK: - Search internals -private extension SpiderPlanner { +nonisolated private extension SpiderPlanner { static func search(in state: GameState, limits: Limits) -> SearchOutcome { let rootScore = score(state) var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] diff --git a/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift index c049d6c..5c83ccd 100644 --- a/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift +++ b/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift @@ -3,7 +3,7 @@ import Foundation /// TriPeaks shares no foundation/tableau/free-cell move algebra with the other /// variants, so `AutoMoveAdvisor` dispatches to it wholesale instead of threading /// its moves through the pile-oriented hooks. -enum TriPeaksAutoMoveAdvisor { +nonisolated enum TriPeaksAutoMoveAdvisor { /// Every uncovered peak card (uncovered cards are always face up), each as a /// single-card selection. The waste top is never a selection: in TriPeaks it /// is the match target, not a mover. diff --git a/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift index deca3ca..85950a1 100644 --- a/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift +++ b/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift @@ -1,6 +1,6 @@ import Foundation -enum TriPeaksPersistenceRules { +nonisolated enum TriPeaksPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.triPeaks.count == TriPeaksGeometry.cardCount else { return false } guard state.tableau.isEmpty else { return false } diff --git a/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift index 0884082..bcf9318 100644 --- a/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift +++ b/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift @@ -1,6 +1,6 @@ import Foundation -enum TriPeaksGameRules { +nonisolated enum TriPeaksGameRules { /// One rank above or below, suit ignored; ranks wrap, so K↔A and A↔2 both /// connect (difference 1 or 12 around the 13-rank cycle). static func ranksAdjacentWithWrap(_ first: Rank, _ second: Rank) -> Bool { diff --git a/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift index 2e7ee3a..054cfbc 100644 --- a/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift +++ b/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { /// The TriPeaks deal: 28 peak cards (rows of 3/6/9 face down, the 10-card /// base face up), one face-up card starting the waste, and the remaining /// 23 cards face down in the stock. The hint probe and test fixtures copy diff --git a/ComputerSolitaire/Game/TriPeaks/TriPeaksGeometry.swift b/ComputerSolitaire/Game/TriPeaks/TriPeaksGeometry.swift index 75be8d6..9305b2b 100644 --- a/ComputerSolitaire/Game/TriPeaks/TriPeaksGeometry.swift +++ b/ComputerSolitaire/Game/TriPeaks/TriPeaksGeometry.swift @@ -11,7 +11,7 @@ import Foundation /// covered by two slots in the row below; the upper rows have horizontal gaps /// between peaks, which is why covering indices are computed per row rather /// than by a single triangular formula. -enum TriPeaksGeometry { +nonisolated enum TriPeaksGeometry { static let rowCount = 4 static let cardCount = 28 static let peakCount = 3 diff --git a/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift b/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift index e47f5bb..28a05f5 100644 --- a/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift +++ b/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift @@ -38,7 +38,7 @@ import Foundation /// best-effort lines); `bestLine` median 0.07ms. Hint-quality baselines live /// in the `tools/hint-probe` ledger: 95.4% of 500 deals won by following every /// hint against a 0.0% random-control floor, zero loops. -enum TriPeaksPlanner { +nonisolated enum TriPeaksPlanner { struct Limits { var maxNodes: Int var deadline: Date? @@ -187,7 +187,7 @@ enum TriPeaksPlanner { // MARK: - Session move mapping -private extension TriPeaksPlanner { +nonisolated private extension TriPeaksPlanner { static func sessionMove( for move: Move, in state: GameState @@ -202,7 +202,7 @@ private extension TriPeaksPlanner { // MARK: - Compact position -private extension TriPeaksPlanner { +nonisolated private extension TriPeaksPlanner { /// The deal's immutable rank tables plus the packed dynamic board. Stock cards /// are indexed in draw order relative to the root; cards already in the waste /// below its top are simply absent — the search never needs them. @@ -276,7 +276,7 @@ private extension TriPeaksPlanner { // MARK: - Move generation and transitions -private extension TriPeaksPlanner { +nonisolated private extension TriPeaksPlanner { /// One rank above or below with wrap, mirroring /// `TriPeaksGameRules.ranksAdjacentWithWrap` on raw rank values. static func ranksAreAdjacent(_ first: Int, _ second: Int) -> Bool { @@ -322,7 +322,7 @@ private extension TriPeaksPlanner { // MARK: - Search -private extension TriPeaksPlanner { +nonisolated private extension TriPeaksPlanner { struct Node { let board: Board let parent: Int diff --git a/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift b/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift index 17ea25b..a3491dc 100644 --- a/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift +++ b/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift @@ -1,6 +1,6 @@ import Foundation -enum YukonAutoMoveAdvisor { +nonisolated enum YukonAutoMoveAdvisor { static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { // Yukon's defining rule: any face-up card can be picked up together with // every card above it, regardless of whether they form a sequence. diff --git a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift index 439c1d5..fff6c62 100644 --- a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift +++ b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift @@ -1,6 +1,6 @@ import Foundation -enum YukonPersistenceRules { +nonisolated enum YukonPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == 7 else { return false } guard state.stock.isEmpty, state.waste.isEmpty else { return false } diff --git a/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift b/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift index e3f6538..79cd0a7 100644 --- a/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift +++ b/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift @@ -3,7 +3,7 @@ /// piles. Yukon differs from Klondike in what may be *picked up* (any face-up /// card with everything above it, regardless of order), not where it may *land* — /// see `YukonAutoMoveAdvisor.allowsTableauPickup`. -enum YukonGameRules { +nonisolated enum YukonGameRules { static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { SharedGameRules.canMoveToKingAnchoredTableau(card: card, destinationPile: destinationPile) } diff --git a/ComputerSolitaire/Game/Yukon/GameStateYukon.swift b/ComputerSolitaire/Game/Yukon/GameStateYukon.swift index c7fe449..ddf32e8 100644 --- a/ComputerSolitaire/Game/Yukon/GameStateYukon.swift +++ b/ComputerSolitaire/Game/Yukon/GameStateYukon.swift @@ -1,6 +1,6 @@ import Foundation -extension GameState { +nonisolated extension GameState { static func newYukonGame() -> GameState { var deck = Card.fullDeck().shuffled() var tableau = Array(repeating: [Card](), count: 7) diff --git a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift index e3714bf..b49b940 100644 --- a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift +++ b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift @@ -22,7 +22,7 @@ import Foundation /// search the full move set including rollbacks — a rollback can be the only way to /// unbury a card whose landing spots were banked prematurely, so only a search over /// every legal move may declare the position provably stuck. -enum YukonPlanner { +nonisolated enum YukonPlanner { struct Limits { var maxNodes: Int var maxDepth: Int @@ -115,7 +115,7 @@ enum YukonPlanner { // MARK: - Search internals -private extension YukonPlanner { +nonisolated private extension YukonPlanner { static func search( in state: GameState, limits: Limits, diff --git a/ComputerSolitaire/Views/Cards/CardView.swift b/ComputerSolitaire/Views/Cards/CardView.swift index 443b2aa..4235de4 100644 --- a/ComputerSolitaire/Views/Cards/CardView.swift +++ b/ComputerSolitaire/Views/Cards/CardView.swift @@ -167,6 +167,9 @@ struct CardView: View { let flipOnAppear: Bool let flipDelay: Double let isAccessibilityElement: Bool + /// This card's tilt captured at init, so the `Equatable` check below can + /// compare it without touching the binding (the binding stays for writes). + private let currentTilt: Double? @State private var flipRotation: Double @State private var tiltAngle: Double = 0 @Environment(\.cardStyle) private var cardStyle @@ -191,6 +194,7 @@ struct CardView: View { self.flipOnAppear = flipOnAppear self.flipDelay = flipDelay self.isAccessibilityElement = isAccessibilityElement + self.currentTilt = cardTilts.wrappedValue[card.id] let startFaceDown = flipOnAppear && card.isFaceUp _flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180)) } @@ -287,6 +291,25 @@ struct CardView: View { } } +/// SwiftUI can't diff CardView automatically (the tilt binding defeats the +/// memberwise check), so without this every card on the board re-evaluates on +/// every move. The comparison covers everything the rendered card depends on; +/// the binding is deliberately omitted — this card's tilt participates as the +/// `currentTilt` value captured at init. +extension CardView: Equatable { + nonisolated static func == (lhs: CardView, rhs: CardView) -> Bool { + lhs.card == rhs.card + && lhs.isSelected == rhs.isSelected + && lhs.cardSize == rhs.cardSize + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hintWiggleToken == rhs.hintWiggleToken + && lhs.flipOnAppear == rhs.flipOnAppear + && lhs.flipDelay == rhs.flipDelay + && lhs.isAccessibilityElement == rhs.isAccessibilityElement + && lhs.currentTilt == rhs.currentTilt + } +} + /// Standalone card back (stock pile, deck art) rendered per the active style. struct CardBackView: View { let cardSize: CGSize diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 46b9931..7e44045 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -150,6 +150,9 @@ struct ContentView: View { @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue + /// The one move spring, applied per board region in `boardRoot`. + private static let boardSpring = Animation.spring(response: 0.35, dampingFraction: 0.86) + private var gameVariant: GameVariant { GameVariant(rawValue: gameVariantRawValue) ?? .klondike } @@ -596,6 +599,7 @@ struct ContentView: View { guard abs(newHeight - headerHeight) >= 0.5 else { return } headerHeight = newHeight } + .animation(Self.boardSpring, value: viewModel.state) } TopRowView( viewModel: viewModel, @@ -618,6 +622,12 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + // The move spring is scoped per board region and keyed on + // the state slice that region renders, so one region's + // change never opens an animation transaction over the + // whole board. The top row (stock, waste, foundations, + // free cells) is small enough to key on the whole state. + .animation(Self.boardSpring, value: viewModel.state) if viewModel.gameVariant == .pyramid { PyramidBoardView( viewModel: viewModel, @@ -635,6 +645,7 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + .animation(Self.boardSpring, value: viewModel.state.pyramid) } else if viewModel.gameVariant == .tripeaks { TriPeaksBoardView( viewModel: viewModel, @@ -649,6 +660,7 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + .animation(Self.boardSpring, value: viewModel.state.triPeaks) } else if viewModel.gameVariant == .canfield { CanfieldBoardRowView( viewModel: viewModel, @@ -668,6 +680,9 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + // Canfield's row renders the tableau and the reserve, + // so it keys on the whole state like the top row. + .animation(Self.boardSpring, value: viewModel.state) } else { TableauRowView( viewModel: viewModel, @@ -687,6 +702,7 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + .animation(Self.boardSpring, value: viewModel.state.tableau) } Spacer(minLength: 0) } @@ -809,7 +825,6 @@ struct ContentView: View { guard !dealingCardIDs.isEmpty || !dealAnimationCards.isEmpty else { return } cancelDealAnimation() } - .animation(.spring(response: 0.35, dampingFraction: 0.86), value: viewModel.state) .animation(.easeInOut(duration: 0.12), value: activeTarget) .overlay { GeometryReader { _ in @@ -1900,7 +1915,31 @@ struct ContentView: View { autosaveTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 400_000_000) guard !Task.isCancelled else { return } - persistGameNow() + await autosaveOffMainThread() + } + } + + /// The debounced autosave's save path. Sanitizing and JSON-encoding the + /// payload scales with the undo history (up to 200 board snapshots), so it + /// runs off the main thread instead of stalling gameplay; only the final + /// SwiftData write comes back to the main actor. Runs inside + /// `autosaveTask`, so `persistGameNow()` and newer autosaves cancel any + /// in-flight encode before their own write — a stale payload can never + /// overwrite a newer save. + private func autosaveOffMainThread() async { + guard hasLoadedGame, !isScreenshotSession else { return } + let payload = viewModel.persistencePayload() + let now = Date.now + guard let encoded = try? await Task.detached(priority: .utility, operation: { + try GamePersistence.encodeForSave(payload, now: now) + }).value else { return } + guard !Task.isCancelled else { return } + do { + try GamePersistence.write(encoded, in: modelContext, now: now) + } catch { +#if DEBUG + print("Failed to persist game state: \(error)") +#endif } } diff --git a/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift b/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift index 30cb628..ebf4b49 100644 --- a/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift +++ b/ComputerSolitaireTests/FreeCell/FreeCellAutoFinishTests.swift @@ -88,6 +88,40 @@ final class FreeCellAutoFinishTests: XCTestCase { } } + func testCascadeGateRejectsSameSuitPairBuriedLowestFirst() { + // The 5♠ under the 9♠ must reach the foundation before the 9♠ can, + // but only the 9♠'s removal exposes it — impossible, so the position + // must be rejected (and cheaply, without the win simulation). + let state = freeCellEndgame( + freeCells: [nil, nil, nil, nil], + highestFoundationRank: .two, + tableau: [ + [TestCards.make(.spades, .five), TestCards.make(.spades, .nine)], + [TestCards.make(.hearts, .four), TestCards.make(.hearts, .three)], + [], [], [], [], [], [] + ] + ) + XCTAssertFalse(AutoFinishPlanner.freeCellCascadesAllowFoundationRun(state)) + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testCascadeGatePassesCrossSuitBurialsForTheSimulationToJudge() { + // A♠ under K♥ violates no same-suit ordering, so the cheap gate must + // pass it — and the simulation must still reject it, because the K♥ + // cannot reach the hearts foundation before the buried ace plays. + let state = freeCellEndgame( + freeCells: [nil, nil, nil, nil], + highestFoundationRank: .ace, + tableau: [ + [TestCards.make(.spades, .two), TestCards.make(.hearts, .king)], + [TestCards.make(.hearts, .two)], + [], [], [], [], [], [] + ] + ) + XCTAssertTrue(AutoFinishPlanner.freeCellCascadesAllowFoundationRun(state)) + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + } + /// 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(