From bf4116c03ef4e226302a5d2ff867b6ae7d814222 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 16:43:20 -0700 Subject: [PATCH 1/4] reduce per-move rendering and autosave work scope the board move spring per region (header, top row, tableau, tripeaks, pyramid, canfield) keyed on the state slice each renders, so one move no longer opens an animation transaction over the whole board. make cardview equatable so cards whose rendered inputs are unchanged skip re-evaluation; the card's tilt participates as a value captured at init since the binding defeats swiftui's memberwise diff. move the debounced autosave's sanitize and json encode (up to 200 undo snapshots) off the main thread, leaving only the swiftdata write on the main actor; the pure game-model layer is marked nonisolated to make the background encode legal under the project's mainactor-by-default isolation. --- .../Canfield/AutoMoveAdvisorCanfield.swift | 2 +- .../Game/Canfield/CanfieldPlanner.swift | 4 +- .../Canfield/GamePersistenceCanfield.swift | 2 +- .../Game/Canfield/GameRulesCanfield.swift | 4 +- .../Game/Canfield/GameStateCanfield.swift | 2 +- .../AutoMoveAdvisorFortyThieves.swift | 2 +- .../FortyThieves/FortyThievesPlanner.swift | 4 +- .../GamePersistenceFortyThieves.swift | 2 +- .../FortyThieves/GameRulesFortyThieves.swift | 2 +- .../FortyThieves/GameStateFortyThieves.swift | 2 +- .../FreeCell/AutoMoveAdvisorFreeCell.swift | 2 +- .../Game/FreeCell/FreeCellSolver.swift | 6 +-- .../FreeCell/GamePersistenceFreeCell.swift | 2 +- .../Game/FreeCell/GameRulesFreeCell.swift | 2 +- .../Game/FreeCell/GameStateFreeCell.swift | 2 +- .../Game/Golf/AutoMoveAdvisorGolf.swift | 2 +- .../Game/Golf/GamePersistenceGolf.swift | 2 +- .../Game/Golf/GameRulesGolf.swift | 2 +- .../Game/Golf/GameStateGolf.swift | 2 +- .../Game/Golf/GolfMatchState.swift | 2 +- ComputerSolitaire/Game/Golf/GolfPlanner.swift | 10 ++--- .../Game/Klondike/AutoFinishPlanner.swift | 4 +- .../Klondike/AutoMoveAdvisorKlondike.swift | 2 +- .../Klondike/GamePersistenceKlondike.swift | 2 +- .../Game/Klondike/GameRulesKlondike.swift | 2 +- .../Game/Klondike/GameStateKlondike.swift | 2 +- .../Game/Klondike/KlondikePlanner.swift | 4 +- .../Game/Pyramid/AutoMoveAdvisorPyramid.swift | 2 +- .../Game/Pyramid/GamePersistencePyramid.swift | 2 +- .../Game/Pyramid/GameRulesPyramid.swift | 2 +- .../Game/Pyramid/GameStatePyramid.swift | 2 +- .../Game/Pyramid/PyramidGeometry.swift | 2 +- .../Game/Pyramid/PyramidPlanner.swift | 12 +++--- .../Scorpion/AutoMoveAdvisorScorpion.swift | 2 +- .../Scorpion/GamePersistenceScorpion.swift | 2 +- .../Game/Scorpion/GameRulesScorpion.swift | 2 +- .../Game/Scorpion/GameStateScorpion.swift | 2 +- .../Game/Scorpion/ScorpionPlanner.swift | 4 +- .../Game/Shared/AutoMoveAdvisor.swift | 4 +- .../Game/Shared/BinaryHeap.swift | 4 +- ComputerSolitaire/Game/Shared/Card.swift | 10 ++--- ComputerSolitaire/Game/Shared/GameMode.swift | 2 +- .../Game/Shared/GamePersistence.swift | 38 ++++++++++++---- .../Game/Shared/GameRulesShared.swift | 4 +- .../Game/Shared/GameSession.swift | 2 +- .../Game/Shared/GameSnapshot.swift | 4 +- ComputerSolitaire/Game/Shared/GameState.swift | 2 +- .../Game/Shared/GameVariant.swift | 6 +-- .../Game/Shared/HintAdvisor.swift | 6 +-- ComputerSolitaire/Game/Shared/MoveTypes.swift | 4 +- ComputerSolitaire/Game/Shared/Scoring.swift | 4 +- .../Game/Shared/TapMovePolicy.swift | 4 +- .../Game/Spider/AutoMoveAdvisorSpider.swift | 2 +- .../Game/Spider/GamePersistenceSpider.swift | 2 +- .../Game/Spider/GameRulesSpider.swift | 2 +- .../Game/Spider/GameStateSpider.swift | 4 +- .../Game/Spider/SpiderPlanner.swift | 4 +- .../TriPeaks/AutoMoveAdvisorTriPeaks.swift | 2 +- .../TriPeaks/GamePersistenceTriPeaks.swift | 2 +- .../Game/TriPeaks/GameRulesTriPeaks.swift | 2 +- .../Game/TriPeaks/GameStateTriPeaks.swift | 2 +- .../Game/TriPeaks/TriPeaksGeometry.swift | 2 +- .../Game/TriPeaks/TriPeaksPlanner.swift | 10 ++--- .../Game/Yukon/AutoMoveAdvisorYukon.swift | 2 +- .../Game/Yukon/GamePersistenceYukon.swift | 2 +- .../Game/Yukon/GameRulesYukon.swift | 2 +- .../Game/Yukon/GameStateYukon.swift | 2 +- .../Game/Yukon/YukonPlanner.swift | 4 +- ComputerSolitaire/Views/Cards/CardView.swift | 23 ++++++++++ .../Views/Shared/ContentView.swift | 43 ++++++++++++++++++- 70 files changed, 200 insertions(+), 116 deletions(-) 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/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/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 02c9be9..5833dd4 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/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 { 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/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 } } From 3d01e5ad9aa380b65e9df4da460d9602c070936b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 16:43:20 -0700 Subject: [PATCH 2/4] bump marketing version to 0.8.1 --- ComputerSolitaire.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; From 10303b26c404c4735d637590680353d39a782d91 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 17:35:04 -0700 Subject: [PATCH 3/4] move auto-finish planner to shared the planner serves klondike, yukon, freecell, forty thieves, and canfield; it never belonged in the klondike folder. --- .../Game/{Klondike => Shared}/AutoFinishPlanner.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ComputerSolitaire/Game/{Klondike => Shared}/AutoFinishPlanner.swift (100%) diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift similarity index 100% rename from ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift rename to ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift From dfcd207836338e5713f06f82df0ea05e62de31e3 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 17:35:04 -0700 Subject: [PATCH 4/4] gate freecell auto-finish availability on cascade ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freecell's candidate check was unconditionally true, so every move ran a full greedy win simulation on the main thread just to decide whether the auto-finish button should enable. require same-suit cards within each cascade to sit in foundation order first — a necessary condition for the foundation run that rejects nearly every mid-game position in a single pass, and one the run's own moves can never invalidate, so availability behaves exactly as before. --- .../FreeCell/AutoFinishPlannerFreeCell.swift | 29 ++++++++++++++++ .../Game/Shared/AutoFinishPlanner.swift | 5 ++- .../FreeCell/FreeCellAutoFinishTests.swift | 34 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 ComputerSolitaire/Game/FreeCell/AutoFinishPlannerFreeCell.swift 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/Shared/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift index 5833dd4..f87f897 100644 --- a/ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Shared/AutoFinishPlanner.swift @@ -50,7 +50,10 @@ nonisolated 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/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(