From 2f043b38d991f294a4acc5321848ff9a0585af2c Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 09:09:11 -0700 Subject: [PATCH 1/3] extract shared BinaryHeap for the search planners KlondikePlanner and FreeCellSolver carried byte-identical private binary heap implementations, and the upcoming Yukon planner would have added a third. Behavior-preserving: identical comparator and algorithm, verified by identical solve/win-rate probe results pre- and post-migration. --- .../Game/FreeCell/FreeCellSolver.swift | 43 +--------------- .../Game/Klondike/KlondikePlanner.swift | 43 +--------------- .../Game/Shared/BinaryHeap.swift | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+), 82 deletions(-) create mode 100644 ComputerSolitaire/Game/Shared/BinaryHeap.swift diff --git a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift index 27b9bae..2e0be72 100644 --- a/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift +++ b/ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift @@ -47,7 +47,7 @@ enum FreeCellSolver { var nodes: [Node] = [Node(board: rootBoard, parent: -1, movesFromParent: rootAutoplay, g: rootAutoplay.count)] var visited: Set = [rootBoard.canonical()] - var heap = Heap() + var heap = BinaryHeap() heap.push(HeapEntry(f: heuristic(rootBoard), order: 0, index: 0)) var order = 0 var expansions = 0 @@ -238,7 +238,7 @@ private extension FreeCellSolver { let g: Int } - struct HeapEntry { + struct HeapEntry: HeapPrioritizable { let f: Int let order: Int let index: Int @@ -248,45 +248,6 @@ private extension FreeCellSolver { } } - struct Heap { - private var entries: [HeapEntry] = [] - - mutating func push(_ entry: HeapEntry) { - entries.append(entry) - var child = entries.count - 1 - while child > 0 { - let parent = (child - 1) / 2 - guard entries[child].takesPriority(over: entries[parent]) else { break } - entries.swapAt(child, parent) - child = parent - } - } - - mutating func pop() -> HeapEntry? { - guard let top = entries.first else { return nil } - let last = entries.removeLast() - if !entries.isEmpty { - entries[0] = last - var parent = 0 - while true { - let left = parent * 2 + 1 - let right = left + 1 - var candidate = parent - if left < entries.count, entries[left].takesPriority(over: entries[candidate]) { - candidate = left - } - if right < entries.count, entries[right].takesPriority(over: entries[candidate]) { - candidate = right - } - guard candidate != parent else { break } - entries.swapAt(parent, candidate) - parent = candidate - } - } - return top - } - } - static func heuristic(_ board: Board) -> Int { var estimate = 0 for suitValue in 0..<4 { diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index 0f630c4..e719686 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -32,7 +32,7 @@ enum KlondikePlanner { let rootScore = score(state) var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] var visited: Set = [stateHash(state)] - var heap = Heap() + var heap = BinaryHeap() heap.push(HeapEntry(priority: rootScore, order: 0, index: 0)) var order = 0 var expansions = 0 @@ -113,7 +113,7 @@ private extension KlondikePlanner { let score: Int } - struct HeapEntry { + struct HeapEntry: HeapPrioritizable { let priority: Int let order: Int let index: Int @@ -123,45 +123,6 @@ private extension KlondikePlanner { } } - struct Heap { - private var entries: [HeapEntry] = [] - - mutating func push(_ entry: HeapEntry) { - entries.append(entry) - var child = entries.count - 1 - while child > 0 { - let parent = (child - 1) / 2 - guard entries[child].takesPriority(over: entries[parent]) else { break } - entries.swapAt(child, parent) - child = parent - } - } - - mutating func pop() -> HeapEntry? { - guard let top = entries.first else { return nil } - let last = entries.removeLast() - if !entries.isEmpty { - entries[0] = last - var parent = 0 - while true { - let left = parent * 2 + 1 - let right = left + 1 - var candidate = parent - if left < entries.count, entries[left].takesPriority(over: entries[candidate]) { - candidate = left - } - if right < entries.count, entries[right].takesPriority(over: entries[candidate]) { - candidate = right - } - guard candidate != parent else { break } - entries.swapAt(parent, candidate) - parent = candidate - } - } - return top - } - } - static func isWon(_ state: GameState) -> Bool { state.foundations.allSatisfy { $0.count == Rank.allCases.count } } diff --git a/ComputerSolitaire/Game/Shared/BinaryHeap.swift b/ComputerSolitaire/Game/Shared/BinaryHeap.swift new file mode 100644 index 0000000..27f2398 --- /dev/null +++ b/ComputerSolitaire/Game/Shared/BinaryHeap.swift @@ -0,0 +1,49 @@ +import Foundation + +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. + func takesPriority(over other: Self) -> Bool +} + +/// Array-backed binary heap shared by the search planners; pops the element that +/// `takesPriority(over:)` every other element. +struct BinaryHeap { + private var entries: [Element] = [] + + mutating func push(_ entry: Element) { + entries.append(entry) + var child = entries.count - 1 + while child > 0 { + let parent = (child - 1) / 2 + guard entries[child].takesPriority(over: entries[parent]) else { break } + entries.swapAt(child, parent) + child = parent + } + } + + mutating func pop() -> Element? { + guard let top = entries.first else { return nil } + let last = entries.removeLast() + if !entries.isEmpty { + entries[0] = last + var parent = 0 + while true { + let left = parent * 2 + 1 + let right = left + 1 + var candidate = parent + if left < entries.count, entries[left].takesPriority(over: entries[candidate]) { + candidate = left + } + if right < entries.count, entries[right].takesPriority(over: entries[candidate]) { + candidate = right + } + guard candidate != parent else { break } + entries.swapAt(parent, candidate) + parent = candidate + } + } + return top + } +} From e09203803555d89c55a4142574fb15aed593a008 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 09:09:11 -0700 Subject: [PATCH 2/3] add yukon game variant with solver-backed hints Implements issue #23. Yukon: all 52 cards dealt across seven piles (no stock/waste), Klondike landing rules, and the defining rule that any face-up card moves together with every card stacked on it regardless of order. Rules verified against the three sources linked in the issue. - Game/Yukon/ mirrors the existing per-variant structure: deal, rules, advisor hooks, session scoring (Klondike-style), persistence rules. - YukonPlanner: bounded best-first search over cached improving lines (FreeCell-style keyed lines; per-move re-search oscillates because Yukon moves are reversible). Staged move generation: rollback-free first, full move set including foundation rollbacks only when the first stage exhausts, so a nil hint is an honest verdict and rollback-only rescues are still found. Hints are strict: first move of a verified improving line, or silence. - Probe baselines (500 seeds, release build): 61.8% hint-following win rate vs 0% for greedy-tap and random baselines, zero hint loops, zero exact-state revisits; p95 hint latency ~0ms via cached lines. - Shared code consolidated where variants converged: king-anchored landing rule, face-down tap/flip handlers, stockless configuration, GameState.isWon, advisor king-transfer/flip helpers. - Statistics gain a variant-neutral high score for stockless variants; Klondike keeps its per-draw-mode fields. Layout validation now rejects saves with cards stranded in unrendered free cells. - VoiceOver: tableau accessibility now follows each variant's real pickup rule; exposed face-down tops are elements in Yukon too. - Marketing screenshot fixture, rules document, and README entry. --- .../Fixtures/ScreenshotFixtures.swift | 3 +- ComputerSolitaire/Fixtures/yukon.json | 584 ++++++++++++++++++ .../FreeCell/AutoMoveAdvisorFreeCell.swift | 4 + .../Game/FreeCell/GameSessionFreeCell.swift | 17 - .../Game/Klondike/AutoFinishPlanner.swift | 16 +- .../Klondike/AutoMoveAdvisorKlondike.swift | 29 +- .../Klondike/GamePersistenceKlondike.swift | 3 + .../Game/Klondike/GameRulesKlondike.swift | 8 +- .../Game/Klondike/GameSessionKlondike.swift | 38 -- .../Game/Klondike/KlondikePlanner.swift | 6 +- .../Game/Shared/AutoMoveAdvisor.swift | 64 +- .../Game/Shared/GamePersistence.swift | 17 + .../Game/Shared/GameRulesShared.swift | 15 + .../Game/Shared/GameSession.swift | 95 ++- .../Game/Shared/GameSessionInteraction.swift | 7 + ComputerSolitaire/Game/Shared/GameState.swift | 7 + .../Game/Shared/GameVariant.swift | 25 + .../Game/Shared/HintAdvisor.swift | 69 ++- .../Game/Shared/TapMovePolicy.swift | 4 +- .../Game/Yukon/AutoMoveAdvisorYukon.swift | 41 ++ .../Game/Yukon/GamePersistenceYukon.swift | 12 + .../Game/Yukon/GameRulesYukon.swift | 10 + .../Game/Yukon/GameSessionYukon.swift | 14 + .../Game/Yukon/GameStateYukon.swift | 28 + .../Game/Yukon/YukonPlanner.swift | 381 ++++++++++++ .../Views/RulesAndScoringView.swift | 30 + .../Views/Shared/BoardViews.swift | 22 +- .../Views/Shared/ContentView.swift | 2 +- ComputerSolitaire/Views/StatisticsView.swift | 128 ++-- .../Views/Yukon/YukonTopRowView.swift | 50 ++ .../Shared/AutoMoveAdvisorCoverageTests.swift | 36 ++ .../Shared/GameSessionTrackingTests.swift | 11 +- .../Shared/GameStatisticsStoreTests.swift | 82 ++- .../SavedGamePayloadSanitizationTests.swift | 8 + .../Shared/ScreenshotFixtureTests.swift | 67 +- .../Shared/TapMovePolicyTests.swift | 41 ++ ComputerSolitaireTests/TestSupport.swift | 24 + .../Yukon/YukonAutoFinishTests.swift | 73 +++ .../Yukon/YukonPersistenceTests.swift | 65 ++ .../Yukon/YukonPlannerTests.swift | 385 ++++++++++++ .../Yukon/YukonRulesTests.swift | 225 +++++++ .../ScreenshotCaptureUITests.swift | 3 +- README.md | 3 +- docs/solitaire-rules-yukon.md | 49 ++ 44 files changed, 2626 insertions(+), 175 deletions(-) create mode 100644 ComputerSolitaire/Fixtures/yukon.json create mode 100644 ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift create mode 100644 ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift create mode 100644 ComputerSolitaire/Game/Yukon/GameRulesYukon.swift create mode 100644 ComputerSolitaire/Game/Yukon/GameSessionYukon.swift create mode 100644 ComputerSolitaire/Game/Yukon/GameStateYukon.swift create mode 100644 ComputerSolitaire/Game/Yukon/YukonPlanner.swift create mode 100644 ComputerSolitaire/Views/Yukon/YukonTopRowView.swift create mode 100644 ComputerSolitaireTests/Yukon/YukonAutoFinishTests.swift create mode 100644 ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift create mode 100644 ComputerSolitaireTests/Yukon/YukonPlannerTests.swift create mode 100644 ComputerSolitaireTests/Yukon/YukonRulesTests.swift create mode 100644 docs/solitaire-rules-yukon.md diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index 86735af..e342fd9 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -30,7 +30,8 @@ enum ScreenshotFixtures { /// One entry per App Store screenshot, in store order. static let bundled: [ScreenshotFixture] = [ ScreenshotFixture(name: "klondike-draw3", title: "Klondike – Draw 3"), - ScreenshotFixture(name: "freecell", title: "FreeCell – fresh deal") + ScreenshotFixture(name: "freecell", title: "FreeCell – fresh deal"), + ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/yukon.json b/ComputerSolitaire/Fixtures/yukon.json new file mode 100644 index 0000000..d4a7e1e --- /dev/null +++ b/ComputerSolitaire/Fixtures/yukon.json @@ -0,0 +1,584 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 0, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 3, + "state" : { + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "stock" : [ + + ], + "tableau" : [ + [ + { + "id" : "4335BA8B-BAFA-4891-925C-57FE950CD76C", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "BD5295F6-1896-4993-99E4-29102A154627", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "F90F5DFF-C85D-49FC-8CF7-AF6E8CC2BF32", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "077166F5-068B-4E85-B368-A36DEB9C0520", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "FD4C81E6-63CB-484B-B4D9-B249EB6E9F8A", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "B155B142-324A-4B30-8927-35849A57B79A", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "6B2DE9F0-8C0F-49B2-9C54-C955FCE438C3", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "F8C0D79A-AD81-4E24-8395-0C9FD843709C", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "EAF22A31-FF17-4B25-9E61-C1CDBE8E403A", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "7AE68BE9-3E65-4254-8930-1160871518B2", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "0371067C-A5E1-47E9-93F2-DF819D60A14B", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "3071279A-C248-4C50-94B9-8766739A0B04", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "87360145-FF09-4C35-AF6E-756A94C4A145", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "967AAAE4-3169-410E-B2AA-79592D955A2F", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "652BAD88-1249-43F6-B35E-4CA58359EC82", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "0B122986-03EE-4398-898F-15D5EDFD17E3", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E736CB30-73BD-4BEB-A820-9E56BB38B9FA", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D9BD93A8-3CCC-498B-8ADF-B4E23EE47412", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C829F559-2C56-40AB-9A7C-95746E320D47", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "2CF8F956-0E70-423C-A4F5-54FE85C455C8", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "821A7237-E739-4A28-ACD9-CF85647D6FEB", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2106D7EE-0BDF-468B-9FFE-EBC29735585D", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "76460AA1-FE10-4AB9-8131-8A7622906865", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "AEFC7312-D830-40C3-B34C-294EF2BC2221", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "6EA666DF-98FA-4C75-B5E6-B020E22AEBAF", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2E488AC0-AC7B-4306-994F-3C64D759A6F6", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "A48B3BC6-DF5D-40FE-B20D-B787FE101C51", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "289B2FBE-9513-4D50-8D30-A6620A7BD549", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C93321A5-9AE5-4842-A9D5-A443463D801A", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "9130E134-20F9-4BB2-8364-02CB6F94FD80", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "CE8F80A0-1FCF-4ED9-A202-92DD59793EA3", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "B197B4DB-732E-412B-8F80-C104D1211165", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "21B0ED4B-17EB-4773-9F1E-4A3EC4F47151", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "6A64E912-6BC1-4819-8CEF-FF0EC5A2B222", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E7E3BB49-06ED-4D1C-8FAF-FB5036CFB894", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "AECB0238-3345-40A8-8D62-A64DB742DF81", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "7B95913F-7FEB-4B5D-9E8B-E423FE2664C2", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "EB9F4D15-D200-41EE-84CE-5F3B475F4ABE", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "E75AA468-5D48-4C26-8F46-DB5821CB70A0", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2B092DEB-F7C4-4228-B8BF-D5EABA5D707D", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "76AD9F21-C6DB-47CA-B97E-22F8E1942EF0", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "8D8F1D1A-C3B2-4ED9-A2DD-65EFC7356F0D", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0375939E-F56A-4CAD-B696-EBAF9DD59243", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "4E582436-0739-4E11-970B-1F759AFF3472", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "DDF6E29C-26B2-44FD-B490-6F70ADB43AC4", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "8FD532E9-8B6F-4B9C-A88C-9BE187EBA6AE", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "ADBA4DEE-FB2F-42FB-BCFD-2EC9382EF4F9", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "E938CAA0-7CF1-47DA-9FF1-E2C15F624835", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2411A0C5-BBF7-4658-B3EA-B0FB5965437F", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "15842C70-A73F-418A-B7AC-3AADC75344EA", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "CF1CF7AC-6035-4E36-A825-153359D469D5", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "1665D678-9E8C-43E8-AE5E-8055A6D32E72", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + } + ] + ], + "variant" : "yukon", + "waste" : [ + + ], + "wasteDrawCount" : 0 + }, + "stockDrawCount" : 3, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift b/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift index f1f1708..09c2119 100644 --- a/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift @@ -1,6 +1,10 @@ import Foundation enum FreeCellAutoMoveAdvisor { + static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + AutoMoveAdvisor.isValidTableauSequence(cards) + } + static func allowsTableauTransfer( selection: Selection, destinationTableauIndex: Int, diff --git a/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift index 11379d7..64d97a7 100644 --- a/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/GameSessionFreeCell.swift @@ -1,23 +1,6 @@ import Foundation extension SolitaireViewModel { - func configureFreeCellNewGame() { - setStockDrawCount(DrawMode.three.rawValue) - setScoringDrawCount(DrawMode.three.rawValue) - setWasteDrawCount(0) - } - - func configureFreeCellRedeal() { - setScoringDrawCount(stockDrawCount) - setWasteDrawCount(0) - } - - func sanitizeFreeCellRedealState(_ baseState: GameState) -> GameState { - var sanitizedState = baseState - sanitizedState.wasteDrawCount = 0 - return sanitizedState - } - func canSelectFreeCellTableauCards(_ cards: [Card]) -> Bool { GameRules.isValidDescendingAlternatingSequence(cards) } diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 5131f8a..28b52f4 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -3,8 +3,8 @@ import Foundation /// Detects when the remaining game is a pure foundation run and produces the moves. /// /// Klondike qualifies once the stock/waste are empty and every tableau card is face up; -/// FreeCell qualifies whenever repeatedly playing eligible cards (from cascade tops and -/// free cells) reaches a win in simulation. +/// Yukon once every tableau card is face up; FreeCell qualifies whenever repeatedly +/// playing eligible cards (from cascade tops and free cells) reaches a win in simulation. enum AutoFinishPlanner { struct AutoFinishMove { let selection: Selection @@ -19,7 +19,7 @@ enum AutoFinishPlanner { } + simulatedState.freeCells.count for _ in 0.. AutoFinishMove? { @@ -39,20 +39,18 @@ enum AutoFinishPlanner { private extension AutoFinishPlanner { static func isAutoFinishCandidateState(_ state: GameState) -> Bool { - guard !isWin(state) else { return false } + guard !state.isWon else { return false } switch state.variant { case .klondike: guard state.stock.isEmpty, state.waste.isEmpty else { return false } return !state.tableau.joined().contains(where: { !$0.isFaceUp }) case .freecell: return true + case .yukon: + return !state.tableau.joined().contains(where: { !$0.isFaceUp }) } } - static func isWin(_ state: GameState) -> Bool { - state.foundations.allSatisfy { $0.count == Rank.allCases.count } - } - static func nextAutoFinishMoveInternal(in state: GameState) -> AutoFinishMove? { var candidates: [(move: AutoFinishMove, rankValue: Int, sourceOrder: Int, foundationPile: Int)] = [] diff --git a/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift b/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift index 6a1d986..7e8a288 100644 --- a/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift @@ -1,6 +1,10 @@ import Foundation enum KlondikeAutoMoveAdvisor { + static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + AutoMoveAdvisor.isValidTableauSequence(cards) + } + static func allowsTableauTransfer( selection: Selection, destinationTableauIndex: Int, @@ -14,20 +18,11 @@ enum KlondikeAutoMoveAdvisor { destinationTableauIndex: Int, in state: GameState ) -> Bool { - guard case .tableau(let sourcePile, let sourceIndex) = selection.source else { return false } - guard sourcePile != destinationTableauIndex else { return false } - guard state.tableau.indices.contains(sourcePile), - state.tableau.indices.contains(destinationTableauIndex) else { return false } - guard state.tableau[destinationTableauIndex].isEmpty else { return false } - guard sourceIndex == 0 else { return false } - - let sourceCards = state.tableau[sourcePile] - guard selection.cards.count == sourceCards.count else { return false } - guard let movingCard = selection.cards.first else { return false } - - // Moving an entire king-led tableau stack to another empty column is a no-op - // for advisor quality purposes (manual play can still do this). - return movingCard.rank == .king + AutoMoveAdvisor.isRedundantWholePileKingTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) } static func appendAuxiliaryDestinations( @@ -39,11 +34,7 @@ enum KlondikeAutoMoveAdvisor { } static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { - guard let topIndex = state.tableau[pileIndex].indices.last, - !state.tableau[pileIndex][topIndex].isFaceUp else { - return - } - state.tableau[pileIndex][topIndex].isFaceUp = true + AutoMoveAdvisor.flipExposedFaceDownTop(on: &state, pileIndex: pileIndex) } } diff --git a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift index c7c61df..bd457a5 100644 --- a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift @@ -3,6 +3,9 @@ import Foundation 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 + // invisible and the game unwinnable. + guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } return state.wasteDrawCount >= 0 && state.wasteDrawCount <= state.waste.count } } diff --git a/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift b/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift index a160132..44114f2 100644 --- a/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift @@ -1,11 +1,5 @@ enum KlondikeGameRules { static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { - if destinationPile.isEmpty { - return card.rank == .king - } - guard let top = destinationPile.last else { return false } - return top.isFaceUp - && top.suit.isRed != card.suit.isRed - && card.rank.rawValue == top.rank.rawValue - 1 + SharedGameRules.canMoveToKingAnchoredTableau(card: card, destinationPile: destinationPile) } } diff --git a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift index cf5e6ba..9b9ebd7 100644 --- a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift @@ -22,35 +22,6 @@ extension SolitaireViewModel { return sanitizedState } - @discardableResult - func handleKlondikeTableauFaceDownTap( - pile: [Card], - pileIndex: Int, - cardIndex: Int, - card: Card - ) -> Bool { - guard !card.isFaceUp else { return false } - guard cardIndex == pile.count - 1 else { - selection = nil - return true - } - clearHint() - pushHistory( - undoContext: UndoAnimationContext( - action: .flipTableauTop, - cardIDs: [card.id] - ) - ) - state.tableau[pileIndex][cardIndex].isFaceUp = true - incrementMovesCount() - applyScore(.turnOverTableauCard) - SoundManager.shared.play(.cardFlipFaceUp) - HapticManager.shared.play(.cardFlipFaceUp) - refreshAutoFinishAvailability() - selection = nil - return true - } - var supportsDrawMode: Bool { state.variant == .klondike } @@ -170,15 +141,6 @@ extension SolitaireViewModel { refreshAutoFinishAvailability() } - func flipKlondikeTopCardIfNeeded(in pileIndex: Int) { - guard let lastIndex = state.tableau[pileIndex].indices.last else { return } - guard !state.tableau[pileIndex][lastIndex].isFaceUp else { return } - state.tableau[pileIndex][lastIndex].isFaceUp = true - applyScore(.turnOverTableauCard) - SoundManager.shared.play(.cardFlipFaceUp) - HapticManager.shared.play(.cardFlipFaceUp) - } - func applyKlondikeMoveScore(for source: Selection.Source, destination: Destination) { switch (source, destination) { case (.waste, .tableau): diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index e719686..9540e7e 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -49,7 +49,7 @@ enum KlondikePlanner { if improvesBest { best = (nodeIndex, node.score, node.depth) } - if isWon(node.state) { break } + if node.state.isWon { break } } guard node.depth < limits.maxDepth else { continue } @@ -123,10 +123,6 @@ private extension KlondikePlanner { } } - static func isWon(_ state: GameState) -> Bool { - state.foundations.allSatisfy { $0.count == Rank.allCases.count } - } - static func score(_ state: GameState) -> Int { var hiddenCount = 0 var emptyPiles = 0 diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index 616cdb1..648966f 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -77,7 +77,7 @@ enum AutoMoveAdvisor { let pile = state.tableau[pileIndex] for cardIndex in pile.indices where pile[cardIndex].isFaceUp { let cards = Array(pile[cardIndex...]) - guard isValidTableauSequence(cards) else { continue } + guard variantAllowsTableauPickup(of: cards, in: state) else { continue } selections.append( Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) ) @@ -161,9 +161,51 @@ enum AutoMoveAdvisor { static func isValidTableauSequence(_ cards: [Card]) -> Bool { GameRules.isValidDescendingAlternatingSequence(cards) } + + /// Moving an entire king-led tableau stack to another empty column is a no-op + /// for advisor quality purposes (manual play can still do this). Shared by the + /// variants whose empty columns accept Kings only. + static func isRedundantWholePileKingTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + guard case .tableau(let sourcePile, let sourceIndex) = selection.source else { return false } + guard sourcePile != destinationTableauIndex else { return false } + guard state.tableau.indices.contains(sourcePile), + state.tableau.indices.contains(destinationTableauIndex) else { return false } + guard state.tableau[destinationTableauIndex].isEmpty else { return false } + guard sourceIndex == 0 else { return false } + + let sourceCards = state.tableau[sourcePile] + guard selection.cards.count == sourceCards.count else { return false } + guard let movingCard = selection.cards.first else { return false } + return movingCard.rank == .king + } + + /// Flips a face-down card exposed at the top of the pile a selection left, + /// shared by the variants that deal face-down tableau cards. + static func flipExposedFaceDownTop(on state: inout GameState, pileIndex: Int) { + guard let topIndex = state.tableau[pileIndex].indices.last, + !state.tableau[pileIndex][topIndex].isFaceUp else { + return + } + state.tableau[pileIndex][topIndex].isFaceUp = true + } } private extension AutoMoveAdvisor { + static func variantAllowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + switch state.variant { + case .klondike: + return KlondikeAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) + case .freecell: + return FreeCellAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) + case .yukon: + return YukonAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) + } + } + static func variantAllowsTableauTransfer( selection: Selection, destinationTableauIndex: Int, @@ -182,6 +224,12 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) + case .yukon: + return YukonAutoMoveAdvisor.allowsTableauTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) } } @@ -199,6 +247,12 @@ private extension AutoMoveAdvisor { ) case .freecell: return false + case .yukon: + return YukonAutoMoveAdvisor.isRedundantEmptyColumnTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) } } @@ -220,6 +274,12 @@ private extension AutoMoveAdvisor { in: state, destinations: &destinations ) + case .yukon: + YukonAutoMoveAdvisor.appendAuxiliaryDestinations( + for: selection, + in: state, + destinations: &destinations + ) } } @@ -229,6 +289,8 @@ private extension AutoMoveAdvisor { KlondikeAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) case .freecell: FreeCellAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) + case .yukon: + YukonAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) } } } diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 24e332e..ba2ae4d 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -280,6 +280,9 @@ struct GameStatistics: Codable, Equatable { var bestTimeSeconds: Int? var highScoreDrawThree: Int? var highScoreDrawOne: Int? + /// High score for variants without a draw mode (FreeCell, Yukon). Klondike wins + /// record into the per-draw-mode fields above instead. + var highScore: Int? var cleanWins: Int enum CodingKeys: String, CodingKey { @@ -291,6 +294,7 @@ struct GameStatistics: Codable, Equatable { case bestTimeSeconds case highScoreDrawThree case highScoreDrawOne + case highScore case cleanWins } @@ -303,6 +307,7 @@ struct GameStatistics: Codable, Equatable { bestTimeSeconds: Int? = nil, highScoreDrawThree: Int? = nil, highScoreDrawOne: Int? = nil, + highScore: Int? = nil, cleanWins: Int = 0 ) { self.schemaVersion = schemaVersion @@ -313,6 +318,7 @@ struct GameStatistics: Codable, Equatable { self.bestTimeSeconds = bestTimeSeconds.map { max(0, $0) } self.highScoreDrawThree = highScoreDrawThree.map { max(0, $0) } self.highScoreDrawOne = highScoreDrawOne.map { max(0, $0) } + self.highScore = highScore.map { max(0, $0) } self.cleanWins = max(0, min(cleanWins, self.gamesWon)) } @@ -340,6 +346,7 @@ struct GameStatistics: Codable, Equatable { bestTimeSeconds = try container.decodeIfPresent(Int.self, forKey: .bestTimeSeconds).map { max(0, $0) } highScoreDrawThree = try container.decodeIfPresent(Int.self, forKey: .highScoreDrawThree).map { max(0, $0) } highScoreDrawOne = try container.decodeIfPresent(Int.self, forKey: .highScoreDrawOne).map { max(0, $0) } + highScore = try container.decodeIfPresent(Int.self, forKey: .highScore).map { max(0, $0) } cleanWins = max( 0, min( @@ -373,6 +380,7 @@ struct GameStatistics: Codable, Equatable { var bestTimeSeconds: Int? var highScoreDrawThree: Int? var highScoreDrawOne: Int? + var highScore: Int? for stats in statsByVariant { gamesPlayed = addingSafely(gamesPlayed, stats.gamesPlayed) @@ -402,6 +410,9 @@ struct GameStatistics: Codable, Equatable { if let candidate = stats.highScoreDrawOne { highScoreDrawOne = max(highScoreDrawOne ?? 0, candidate) } + if let candidate = stats.highScore { + highScore = max(highScore ?? 0, candidate) + } } gamesWon = min(gamesWon, gamesPlayed) @@ -415,6 +426,7 @@ struct GameStatistics: Codable, Equatable { bestTimeSeconds: bestTimeSeconds, highScoreDrawThree: highScoreDrawThree, highScoreDrawOne: highScoreDrawOne, + highScore: highScore, cleanWins: cleanWins ) } @@ -449,6 +461,9 @@ struct GameStatistics: Codable, Equatable { highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) } else if drawCount == DrawMode.three.rawValue { highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) + } else { + // Variants without a draw mode (FreeCell, Yukon) keep a single high score. + highScore = max(highScore ?? 0, sanitizedScore) } let isCleanWin = sanitizedHintsUsedInGame == 0 @@ -563,6 +578,8 @@ private extension GameState { return KlondikePersistenceRules.hasValidLayout(state: self) case .freecell: return FreeCellPersistenceRules.hasValidLayout(state: self) + case .yukon: + return YukonPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 031532f..e988042 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -17,6 +17,8 @@ enum GameRules { return KlondikeGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) case .freecell: return FreeCellGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + case .yukon: + return YukonGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) } } @@ -46,6 +48,19 @@ enum GameRules { } 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. + static func canMoveToKingAnchoredTableau(card: Card, destinationPile: [Card]) -> Bool { + if destinationPile.isEmpty { + return card.rank == .king + } + guard let top = destinationPile.last else { return false } + return top.isFaceUp + && top.suit.isRed != card.suit.isRed + && card.rank.rawValue == top.rank.rawValue - 1 + } + static func isValidDescendingAlternatingSequence(_ cards: [Card]) -> Bool { guard cards.count > 1 else { return true } for index in 0..<(cards.count - 1) { diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 42424dc..82fac5c 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -78,7 +78,7 @@ final class SolitaireViewModel { } var isWin: Bool { - state.foundations.allSatisfy { $0.count == Rank.allCases.count } + state.isWon } var canUndo: Bool { @@ -468,8 +468,8 @@ final class SolitaireViewModel { switch variant { case .klondike: configureKlondikeNewGame(drawMode: drawMode) - case .freecell: - configureFreeCellNewGame() + case .freecell, .yukon: + configureStocklessNewGame() } } @@ -477,8 +477,8 @@ final class SolitaireViewModel { switch state.variant { case .klondike: configureKlondikeRedeal() - case .freecell: - configureFreeCellRedeal() + case .freecell, .yukon: + configureStocklessRedeal() } } @@ -489,11 +489,31 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return sanitizeKlondikeRedealState(state, stockDrawCount: stockDrawCount) - case .freecell: - return sanitizeFreeCellRedealState(state) + case .freecell, .yukon: + return sanitizeStocklessRedealState(state) } } + /// New-game configuration shared by the variants without a stock: draw counts + /// stay at the draw-three defaults so time-bonus scoring has a defined basis, + /// and no waste cards are ever fanned. + func configureStocklessNewGame() { + setStockDrawCount(DrawMode.three.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(0) + } + + func configureStocklessRedeal() { + setScoringDrawCount(stockDrawCount) + setWasteDrawCount(0) + } + + func sanitizeStocklessRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = 0 + return sanitizedState + } + private func handleVariantTableauTapIfNeeded( pile: [Card], pileIndex: Int, @@ -501,8 +521,8 @@ final class SolitaireViewModel { card: Card ) -> Bool { switch state.variant { - case .klondike: - return handleKlondikeTableauFaceDownTap( + case .klondike, .yukon: + return handleFaceDownTableauTap( pile: pile, pileIndex: pileIndex, cardIndex: cardIndex, @@ -513,9 +533,43 @@ final class SolitaireViewModel { } } - private func canSelectTableauCards(_ cards: [Card]) -> Bool { + /// Tap handling shared by the variants that deal face-down tableau cards: + /// tapping an exposed face-down top flips it (a scored move); tapping a buried + /// face-down card just clears the selection. + @discardableResult + private func handleFaceDownTableauTap( + pile: [Card], + pileIndex: Int, + cardIndex: Int, + card: Card + ) -> Bool { + guard !card.isFaceUp else { return false } + guard cardIndex == pile.count - 1 else { + selection = nil + return true + } + clearHint() + pushHistory( + undoContext: UndoAnimationContext( + action: .flipTableauTop, + cardIDs: [card.id] + ) + ) + state.tableau[pileIndex][cardIndex].isFaceUp = true + incrementMovesCount() + applyScore(.turnOverTableauCard) + SoundManager.shared.play(.cardFlipFaceUp) + HapticManager.shared.play(.cardFlipFaceUp) + refreshAutoFinishAvailability() + selection = nil + return true + } + + /// Whether the given face-up run/group may be picked up under the current + /// variant's rules. Also drives which tableau cards are accessibility elements. + func canSelectTableauCards(_ cards: [Card]) -> Bool { switch state.variant { - case .klondike: + case .klondike, .yukon: return true case .freecell: return canSelectFreeCellTableauCards(cards) @@ -526,7 +580,7 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return scoringDrawCount - case .freecell: + case .freecell, .yukon: return 0 } } @@ -647,13 +701,24 @@ extension SolitaireViewModel { func flipTopCardIfNeeded(in pileIndex: Int) { switch state.variant { - case .klondike: - flipKlondikeTopCardIfNeeded(in: pileIndex) + case .klondike, .yukon: + flipFaceDownTopCardIfNeeded(in: pileIndex) case .freecell: break } } + /// Flips a face-down card exposed at the top of a pile (a scored reveal), + /// shared by the variants that deal face-down tableau cards. + private func flipFaceDownTopCardIfNeeded(in pileIndex: Int) { + guard let lastIndex = state.tableau[pileIndex].indices.last else { return } + guard !state.tableau[pileIndex][lastIndex].isFaceUp else { return } + state.tableau[pileIndex][lastIndex].isFaceUp = true + applyScore(.turnOverTableauCard) + SoundManager.shared.play(.cardFlipFaceUp) + HapticManager.shared.play(.cardFlipFaceUp) + } + func pushHistory(undoContext: UndoAnimationContext? = nil) { history.append( GameSnapshot( @@ -675,6 +740,8 @@ extension SolitaireViewModel { applyKlondikeMoveScore(for: source, destination: destination) case .freecell: break + case .yukon: + applyYukonMoveScore(for: source, destination: destination) } } diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift index 3c2ee39..ad5d93f 100644 --- a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -29,6 +29,13 @@ extension SolitaireViewModel { foundation: state.foundations[index] ) case .tableau(let index): + // Dropping a stack back onto its own pile is a cancel, not a move. The + // destination pile still contains the lifted cards here, so in Yukon an + // unordered group could otherwise "land" on itself and flip the exposed + // face-down card without any real move being made. + if case .tableau(let sourcePile, _) = selection.source, sourcePile == index { + return false + } guard GameRules.canMoveToTableau( card: movingCard, destinationPile: state.tableau[index], diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index f9d4f0a..c159edf 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -49,6 +49,11 @@ struct GameState: Equatable, Codable { tableau = try container.decode([[Card]].self, forKey: .tableau) } + /// The game is won once every foundation holds a full Ace-to-King run. + var isWon: Bool { + foundations.allSatisfy { $0.count == Rank.allCases.count } + } + static func newGame() -> GameState { newGame(variant: .klondike) } @@ -59,6 +64,8 @@ struct GameState: Equatable, Codable { return newKlondikeGame() case .freecell: return newFreeCellGame() + case .yukon: + return newYukonGame() } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index 1a6adda..82a49bf 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -3,6 +3,7 @@ import Foundation enum GameVariant: String, CaseIterable, Codable { case klondike case freecell + case yukon var title: String { switch self { @@ -10,6 +11,8 @@ enum GameVariant: String, CaseIterable, Codable { return "Klondike" case .freecell: return "FreeCell" + case .yukon: + return "Yukon" } } @@ -19,6 +22,28 @@ enum GameVariant: String, CaseIterable, Codable { return "Classic Solitaire" case .freecell: return "Strategic open layout" + case .yukon: + return "Move any face-up stack" + } + } + + var boardColumnCount: Int { + switch self { + case .klondike, .yukon: + return 7 + case .freecell: + return 8 + } + } + + /// Whether deals place face-down cards in the tableau (tapping an exposed + /// face-down top flips it). + var dealsFaceDownTableauCards: Bool { + switch self { + case .klondike, .yukon: + return true + case .freecell: + return false } } } diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index e557392..66defcc 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -18,7 +18,13 @@ enum HintAdvisor { return true } for selection in AutoMoveAdvisor.candidateSelections(in: state) { - if case .foundation = selection.source { continue } + // Foundation rollbacks only count as available moves where the hint + // stack can actually turn one into a hint: Yukon's planner searches + // rollbacks (one can be the only rescue), while the Klondike planner + // and FreeCell solver never suggest them. + if case .foundation = selection.source, state.variant != .yukon { + continue + } if !AutoMoveAdvisor.legalDestinations(for: selection, in: state).isEmpty { return true } @@ -33,18 +39,32 @@ enum HintAdvisor { } } -/// Produces hints for both variants. +/// Produces hints for every variant. /// /// FreeCell hints come from the solver: the hint is the first move of an actual winning -/// line. The full line is cached keyed by position, so as long as the player follows it -/// (or plays ahead along it), subsequent hints are instant. Klondike hints come from the -/// bounded `KlondikePlanner` search. +/// line. Klondike hints come from the bounded `KlondikePlanner` search, re-run per +/// request. Yukon hints come from `YukonPlanner`'s best improving line, cached like +/// FreeCell's: every Yukon tableau move is reversible until a card flips, so re-search +/// after each move can oscillate between equally attractive lines, while following one +/// cached line ratchets the position strictly forward. Cached lines are keyed by +/// position, so as long as the player follows one (or plays ahead along it), +/// subsequent hints are instant. +/// +/// When no line is found, the variants deliberately differ. FreeCell falls back to +/// `TapMovePolicy`: its solver misses are usually winnable positions (~99% of deals +/// are), so a constructive nudge keeps a rescuable game moving. Yukon returns nil: +/// its planner misses are positions with no measurable progress anywhere in a large +/// searched region — a nudge there has never been observed to rescue a game and a +/// deterministic one shuttles a card back and forth, so a Yukon hint is always the +/// first move of a verified improving line, or silence (like Klondike's planner). final class HintPlanner { /// How long a single interactive hint request may spend searching. private static let freeCellSearchBudget: TimeInterval = 0.3 private static let klondikeSearchBudget: TimeInterval = 0.15 + private static let yukonSearchBudget: TimeInterval = 0.25 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] + private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -58,6 +78,8 @@ final class HintPlanner { ) case .freecell: return freeCellHint(in: state) + case .yukon: + return yukonHint(in: state) } } } @@ -88,6 +110,43 @@ private extension HintPlanner { ) } + func yukonHint(in state: GameState) -> HintAdvisor.Hint? { + let key = YukonPlanner.stateKey(for: state) + if let hint = plannedYukonHint(for: key, in: state) { + return hint + } + + yukonPlan.removeAll() + let limits = YukonPlanner.Limits( + deadline: Date().addingTimeInterval(Self.yukonSearchBudget) + ) + switch YukonPlanner.bestLine(in: state, limits: limits) { + case .line(let line): + yukonPlan = YukonPlanner.keyedMoves(along: line, from: state) + return plannedYukonHint(for: key, in: state) + + case .noProgress: + // Exhaustion proves the position is stuck; truncation means a large + // searched region held no measurable progress, which is empirically just + // as dead. Either way there is no move worth pointing at (see the class + // comment for why Yukon does not fall back to a nudge). The hint button + // re-enables after the player's next move. + return nil + } + } + + func plannedYukonHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + guard let planned = yukonPlan[key], + AutoMoveAdvisor.selectionMatchesState(planned.selection, in: state), + AutoMoveAdvisor.legalDestinations(for: planned.selection, in: state) + .contains(planned.destination) else { + return nil + } + return .move( + HintAdvisor.HintMove(selection: planned.selection, destination: planned.destination) + ) + } + func materializedHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { guard let planned = freeCellPlan[key], let move = FreeCellSolver.materialize(planned, in: state) else { diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 63d2560..634ea51 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -94,7 +94,9 @@ private extension TapMovePolicy { switch state.variant { case .klondike: tier = 100 - case .freecell: + case .freecell, .yukon: + // No stock to refill the board: an eager unsafe foundation move can + // strand a card another pile still needs as a landing spot. tier = isSafeFoundationMove(card: card, in: state) ? 100 : 60 } return Priority(tier: tier, buildLength: 0, pileOrder: -index) diff --git a/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift b/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift new file mode 100644 index 0000000..17ea25b --- /dev/null +++ b/ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift @@ -0,0 +1,41 @@ +import Foundation + +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. + true + } + + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + true + } + + static func isRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + AutoMoveAdvisor.isRedundantWholePileKingTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + // Yukon has no auxiliary destination type beyond tableau/foundation. + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + AutoMoveAdvisor.flipExposedFaceDownTop(on: &state, pileIndex: pileIndex) + } +} diff --git a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift new file mode 100644 index 0000000..47b023b --- /dev/null +++ b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift @@ -0,0 +1,12 @@ +import Foundation + +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 } + // Yukon renders no free-cell slots, so a card stranded there would be + // invisible and the game unwinnable. + guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } + return state.wasteDrawCount == 0 + } +} diff --git a/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift b/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift new file mode 100644 index 0000000..e3f6538 --- /dev/null +++ b/ComputerSolitaire/Game/Yukon/GameRulesYukon.swift @@ -0,0 +1,10 @@ +/// Yukon's landing rule intentionally matches Klondike's: a group's bottom card +/// lands on an opposite-color card one rank higher, and only Kings fill empty +/// 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 { + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + SharedGameRules.canMoveToKingAnchoredTableau(card: card, destinationPile: destinationPile) + } +} diff --git a/ComputerSolitaire/Game/Yukon/GameSessionYukon.swift b/ComputerSolitaire/Game/Yukon/GameSessionYukon.swift new file mode 100644 index 0000000..f1dabff --- /dev/null +++ b/ComputerSolitaire/Game/Yukon/GameSessionYukon.swift @@ -0,0 +1,14 @@ +import Foundation + +extension SolitaireViewModel { + func applyYukonMoveScore(for source: Selection.Source, destination: Destination) { + switch (source, destination) { + case (.tableau, .foundation): + applyScore(.tableauToFoundation) + case (.foundation, .tableau): + applyScore(.foundationToTableau) + default: + break + } + } +} diff --git a/ComputerSolitaire/Game/Yukon/GameStateYukon.swift b/ComputerSolitaire/Game/Yukon/GameStateYukon.swift new file mode 100644 index 0000000..c7fe449 --- /dev/null +++ b/ComputerSolitaire/Game/Yukon/GameStateYukon.swift @@ -0,0 +1,28 @@ +import Foundation + +extension GameState { + static func newYukonGame() -> GameState { + var deck = Card.fullDeck().shuffled() + var tableau = Array(repeating: [Card](), count: 7) + + for pileIndex in 0..<7 { + let faceDownCount = pileIndex == 0 ? 0 : pileIndex + let faceUpCount = pileIndex == 0 ? 1 : 5 + for cardIndex in 0..<(faceDownCount + faceUpCount) { + var card = deck.removeLast() + card.isFaceUp = cardIndex >= faceDownCount + tableau[pileIndex].append(card) + } + } + + return GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } +} diff --git a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift new file mode 100644 index 0000000..d581795 --- /dev/null +++ b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift @@ -0,0 +1,381 @@ +import Foundation + +/// Bounded best-first hint planner for Yukon. +/// +/// Searches sequences of real moves up to a node/time budget, scoring positions by +/// foundation progress, revealed cards, open columns, and how untangled the face-up +/// stacks are. `bestLine` returns the whole move sequence to the best position found +/// that strictly improves on the current one; nil means nothing within the horizon +/// makes progress (the game is stuck or lost). `HintPlanner` follows the cached line +/// move by move: because every Yukon tableau move is reversible until a card flips, +/// re-searching after each move can oscillate between equally attractive lines, while +/// following one improving line to its end ratchets the position strictly forward. +/// +/// 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. Yukon has no stock, so this +/// never returns `.stockTap`. +/// +/// The search runs in two stages. The primary stage excludes foundation-to-tableau +/// rollbacks: including them measurably degrades line quality (bank/unbank churn +/// wanders lines through repeated territory and dilutes the node budget). Only when +/// the primary stage exhausts its graph without finding progress does a second stage +/// 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 { + struct Limits { + var maxNodes: Int + var maxDepth: Int + var deadline: Date? + + // Yukon branches 3-5x wider than Klondike (any face-up card is pickable), + // so a deeper node budget buys back comparable effective search depth. The + // budget is affordable because lines are cached: the search only runs when a + // followed line runs out, not on every hint. + init(maxNodes: Int = 40_000, maxDepth: Int = 64, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.maxDepth = maxDepth + self.deadline = deadline + } + } + + struct PlannedMove { + let selection: Selection + let destination: Destination + } + + enum SearchOutcome { + /// Moves leading to the best strictly-improving position found. + case line([PlannedMove]) + /// Nothing within the horizon improves on the current position. When the + /// search ran out of reachable states — rather than nodes, depth, or time — + /// that is proof the position cannot progress, and the hint should say so + /// instead of nudging in circles. + case noProgress(searchWasExhaustive: Bool) + } + + static func bestHint(in state: GameState, limits: Limits = Limits()) -> HintAdvisor.Hint? { + guard case .line(let moves) = bestLine(in: state, limits: limits), + let move = moves.first else { + return nil + } + return .move(HintAdvisor.HintMove(selection: move.selection, destination: move.destination)) + } + + /// Exact (non-canonical) position key, stable across `Card` identities; used to + /// look up the cached line as the player follows it. + static func stateKey(for state: GameState) -> String { + var key = String() + key.reserveCapacity(128) + func append(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + key.append(String(UnicodeScalar(UInt8(65 + suitValue * 2 + (card.isFaceUp ? 1 : 0))))) + key.append(String(UnicodeScalar(UInt8(97 + card.rank.rawValue)))) + } + for pile in state.foundations { + key.append("|") + for card in pile { append(card: card) } + } + for pile in state.tableau { + key.append("/") + for card in pile { append(card: card) } + } + return key + } + + /// Maps each position along the line to the move to play there, so consecutive + /// hints are instant while the player follows (or plays ahead along) the line. + static func keyedMoves(along line: [PlannedMove], from state: GameState) -> [String: PlannedMove] { + var keyed: [String: PlannedMove] = [:] + var current = state + for move in line { + keyed[stateKey(for: current)] = move + guard let next = apply(move, to: current) else { break } + current = next + } + return keyed + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .yukon else { return .noProgress(searchWasExhaustive: false) } + + switch search(in: state, limits: limits, includesFoundationRollbacks: false) { + case .line(let moves): + return .line(moves) + case .noProgress(searchWasExhaustive: false): + return .noProgress(searchWasExhaustive: false) + case .noProgress(searchWasExhaustive: true): + // The rollback-free graph holds no progress. Before declaring the + // position stuck, search the full move set: a foundation rollback can + // be the only rescue when a needed landing card was banked early. + return search(in: state, limits: limits, includesFoundationRollbacks: true) + } + } +} + +// MARK: - Search internals + +private extension YukonPlanner { + static func search( + in state: GameState, + limits: Limits, + includesFoundationRollbacks: Bool + ) -> SearchOutcome { + let rootScore = score(state) + var nodes: [Node] = [Node(state: state, parent: -1, move: nil, depth: 0, score: rootScore)] + var visited: Set = [stateHash(state)] + var heap = BinaryHeap() + heap.push(HeapEntry(priority: rootScore, order: 0, index: 0)) + var order = 0 + var expansions = 0 + var wasTruncated = false + var best: (index: Int, score: Int, depth: Int)? + + while let entry = heap.pop() { + let nodeIndex = entry.index + let node = nodes[nodeIndex] + + if node.score > rootScore { + let improvesBest = best.map { + node.score > $0.score || (node.score == $0.score && node.depth < $0.depth) + } ?? true + if improvesBest { + best = (nodeIndex, node.score, node.depth) + } + if node.state.isWon { break } + } + + guard node.depth < limits.maxDepth else { + wasTruncated = true + continue + } + expansions += 1 + if nodes.count >= limits.maxNodes { + wasTruncated = true + break + } + if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { + wasTruncated = true + break + } + // A line that reveals a card or banks a foundation card is a solid hint; + // once one is in hand, cap how long we keep hunting for something better. + // The floor is higher than Klondike's 768: lines are cached, so the search + // runs once per followed line and can afford to pick lines more carefully. + if let best, best.score - rootScore >= 20, expansions >= 16384 { + break + } + + for move in moves( + from: node.state, + includesFoundationRollbacks: includesFoundationRollbacks + ) { + guard let nextState = apply(move, to: node.state) else { continue } + guard visited.insert(stateHash(nextState)).inserted else { continue } + + let nextScore = score(nextState) + nodes.append( + Node( + state: nextState, + parent: nodeIndex, + move: move, + depth: node.depth + 1, + score: nextScore + ) + ) + order += 1 + // Best-first on score, shallow bias so equal outcomes prefer short lines. + heap.push( + HeapEntry( + priority: nextScore * 4 - (node.depth + 1), + order: order, + index: nodes.count - 1 + ) + ) + } + } + + guard let best, let moves = line(to: best.index, nodes: nodes) else { + return .noProgress(searchWasExhaustive: !wasTruncated) + } + return .line(moves) + } + + struct Node { + let state: GameState + let parent: Int + let move: PlannedMove? + let depth: Int + let score: Int + } + + struct HeapEntry: HeapPrioritizable { + let priority: Int + let order: Int + let index: Int + + func takesPriority(over other: HeapEntry) -> Bool { + priority != other.priority ? priority > other.priority : order < other.order + } + } + + static func score(_ state: GameState) -> Int { + var hiddenCount = 0 + var emptyPiles = 0 + var sequencedPairs = 0 + var sameSuitInversions = 0 + for pile in state.tableau { + if pile.isEmpty { emptyPiles += 1 } + for index in pile.indices { + let card = pile[index] + if card.isFaceUp { + if index + 1 < pile.count { + let upper = pile[index + 1] + if upper.suit.isRed != card.suit.isRed, + upper.rank.rawValue == card.rank.rawValue - 1 { + sequencedPairs += 1 + } + } + } else { + hiddenCount += 1 + } + for upperIndex in (index + 1).. card.rank { + sameSuitInversions += 1 + } + } + } + let foundationCount = state.foundations.reduce(0) { $0 + $1.count } + // Between reveals, progress in Yukon is untangling: reward in-sequence pairs + // (a full tidy-up still scores below one reveal), penalize burying a card + // under a higher card of its own suit (that card must move again before the + // suit can finish), and penalize cards stacked above the next rank each + // foundation needs, so the search digs with purpose. + return foundationCount * 20 + - hiddenCount * 25 + + emptyPiles * 4 + + sequencedPairs + - sameSuitInversions * 3 + - nextNeededBurial(in: state) * 2 + } + + /// Total number of cards stacked above each card that some foundation needs next. + static func nextNeededBurial(in state: GameState) -> Int { + var topRankBySuit: [Suit: Int] = [:] + for foundation in state.foundations { + if let top = foundation.last { + topRankBySuit[top.suit] = top.rank.rawValue + } + } + + var burial = 0 + for suit in Suit.allCases { + let neededRank = (topRankBySuit[suit] ?? 0) + 1 + guard neededRank <= Rank.king.rawValue else { continue } + for pile in state.tableau { + if let index = pile.firstIndex(where: { $0.suit == suit && $0.rank.rawValue == neededRank }) { + burial += pile.count - 1 - index + break + } + } + } + return burial + } + + static func moves( + from state: GameState, + includesFoundationRollbacks: Bool + ) -> [PlannedMove] { + let firstEmptyColumn = state.tableau.firstIndex(where: \.isEmpty) + var moves: [PlannedMove] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source, !includesFoundationRollbacks { + continue + } + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + // Empty columns are interchangeable: searching a drop into every one + // only multiplies column-permuted twins, so canonicalize to the first. + // (Players can still drop on any empty column.) + if case .tableau(let index) = destination, + state.tableau[index].isEmpty, + index != firstEmptyColumn { + continue + } + moves.append(PlannedMove(selection: selection, destination: destination)) + } + } + return moves + } + + /// Applies a move without re-validating legality: the planner only feeds in + /// moves it just generated from `legalDestinations`, and revalidating each one + /// there dominates search cost. Mirrors the session's move effects. + static func apply(_ move: PlannedMove, to state: GameState) -> GameState? { + var nextState = state + switch move.selection.source { + case .tableau(let pile, let index): + nextState.tableau[pile].removeSubrange(index.. UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { + hash = (hash ^ UInt64(value)) &* 0x100000001b3 + } + func encode(card: Card) -> UInt8 { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + return UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0)) + } + for pile in state.foundations { + mix(0xFE) + for card in pile { mix(encode(card: card)) } + } + let encodedPiles = state.tableau + .map { pile in pile.map { encode(card: $0) } } + .sorted { $0.lexicographicallyPrecedes($1) } + for pile in encodedPiles { + mix(0xFD) + for value in pile { mix(value) } + } + return hash + } + + static func line(to index: Int, nodes: [Node]) -> [PlannedMove]? { + var moves: [PlannedMove] = [] + var cursor = index + while cursor >= 0, nodes[cursor].parent >= 0 { + if let move = nodes[cursor].move { + moves.append(move) + } + cursor = nodes[cursor].parent + } + guard !moves.isEmpty else { return nil } + return moves.reversed() + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index b44b8d5..afdc60a 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -232,6 +232,15 @@ struct RulesAndScoringView: View { definition: "A multi-card move enabled by available free cells and empty cascades." ) ] + case .yukon: + return [ + TermRow(term: "Tableau", definition: "The seven play piles where you build down in alternating colors."), + TermRow(term: "Foundations", definition: "Four suit piles built up from Ace to King."), + TermRow( + term: "Group move", + definition: "Any face-up card together with every card stacked on top of it, moved as one, even out of order." + ) + ] } } @@ -255,6 +264,16 @@ struct RulesAndScoringView: View { "Any card may move to an empty cascade.", "You win by moving all 52 cards to foundations." ] + case .yukon: + return [ + "Deal seven tableau piles: the first holds one face-up card; each pile after adds one more face-down card beneath five face-up cards. All 52 cards are dealt — there is no stock.", + "Move any face-up card along with all cards on top of it, even if they are not in sequence.", + "The moving group's bottom card must land on a card of the opposite color, one rank higher.", + "Build foundations by suit from Ace to King.", + "Only Kings (with any cards stacked on them) can fill an empty pile.", + "Face-down cards turn face up when they become the top of a pile.", + "You win by moving all 52 cards to foundations." + ] } } @@ -271,6 +290,17 @@ struct RulesAndScoringView: View { note: "Reduced by elapsed time." ) ] + case .yukon: + return [ + ScoringRow(move: "Tableau to Foundation", points: Scoring.delta(for: .tableauToFoundation), note: nil), + ScoringRow(move: "Turn over Tableau card", points: Scoring.delta(for: .turnOverTableauCard), note: nil), + ScoringRow(move: "Foundation to Tableau", points: Scoring.delta(for: .foundationToTableau), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) + ] } } } diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 65fb301..02f3911 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -342,6 +342,21 @@ struct TopRowView: View { hintWiggleToken: hintWiggleToken, dragGesture: dragGesture ) + case .yukon: + YukonTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) } } } @@ -572,17 +587,18 @@ struct TableauPileView: View { let isSelected = viewModel.isSelected(card: card) let selectableCards = Array(pile[index...]) let isValidRunOrigin = card.isFaceUp - && GameRules.isValidDescendingAlternatingSequence(selectableCards) - let isExposedFaceDownCard = viewModel.state.variant == .klondike + && viewModel.canSelectTableauCards(selectableCards) + let isExposedFaceDownCard = viewModel.state.variant.dealsFaceDownTableauCards && !card.isFaceUp && index == pile.indices.last let isAccessibilityElement = (isValidRunOrigin || isExposedFaceDownCard) && !isDragged && !isHidden + let multiCardNoun = viewModel.state.variant == .yukon ? "group" : "run" let accessibilityHint = isExposedFaceDownCard ? "Flip card" : selectableCards.count > 1 - ? "Selects a \(selectableCards.count)-card run" + ? "Selects a \(selectableCards.count)-card \(multiCardNoun)" : "Selects this card" let yOffset = yOffsets[index] let cardView = CardView( diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 91c81b3..7801146 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -421,7 +421,7 @@ struct ContentView: View { @ViewBuilder private func boardRoot(for geometry: GeometryProxy) -> some View { - let boardColumnCount = max(viewModel.state.tableau.count, viewModel.gameVariant == .freecell ? 8 : 7) + let boardColumnCount = max(viewModel.state.tableau.count, viewModel.gameVariant.boardColumnCount) #if os(iOS) let metrics = Layout.metrics( for: geometry.size, diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index adee71d..386da91 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -22,25 +22,56 @@ struct StatisticsView: View { private enum Scope: String, CaseIterable, Identifiable { case klondike case freecell + case yukon case all var id: String { rawValue } - var title: String { + init(variant: GameVariant) { + switch variant { + case .klondike: + self = .klondike + case .freecell: + self = .freecell + case .yukon: + self = .yukon + } + } + + /// The variant this scope covers; nil for the aggregate scope. + var variant: GameVariant? { switch self { case .klondike: - return GameVariant.klondike.title + return .klondike case .freecell: - return GameVariant.freecell.title + return .freecell + case .yukon: + return .yukon + case .all: + return nil + } + } + + var title: String { + switch self { + case .klondike, .freecell, .yukon: + return variant?.title ?? "" case .all: return "All" } } } + private struct HighScoreRow: Identifiable { + let label: String + let score: Int? + + var id: String { label } + } + init(viewModel: SolitaireViewModel?, initialVariant: GameVariant = .klondike) { self.viewModel = viewModel - _selectedScope = State(initialValue: initialVariant == .freecell ? .freecell : .klondike) + _selectedScope = State(initialValue: Scope(variant: initialVariant)) } var body: some View { @@ -102,8 +133,9 @@ struct StatisticsView: View { keyValueRow("Total Time", durationLabel(displayTotalTimeSeconds(at: context.date))) keyValueRow("Avg Time", durationLabel(stats.averageTimeSeconds)) keyValueRow("Best Time", bestTimeLabel) - keyValueRow("High Score (3-card)", stats.highScoreDrawThree.map { "\($0)" } ?? "-") - keyValueRow("High Score (1-card)", stats.highScoreDrawOne.map { "\($0)" } ?? "-") + ForEach(highScoreRowsForSelectedScope) { row in + keyValueRow(row.label, scoreLabel(row.score)) + } } header: { Text("Performance") } @@ -221,6 +253,26 @@ struct StatisticsView: View { return durationLabel(bestTimeSeconds) } + /// Draw modes are a Klondike concept, so only Klondike splits its high score + /// by draw mode; the other variants keep a single high score. + private var highScoreRowsForSelectedScope: [HighScoreRow] { + switch selectedScope { + case .klondike: + return [ + HighScoreRow(label: "High Score (3-card)", score: stats.highScoreDrawThree), + HighScoreRow(label: "High Score (1-card)", score: stats.highScoreDrawOne) + ] + case .freecell, .yukon: + return [HighScoreRow(label: "High Score", score: stats.highScore)] + case .all: + return [] + } + } + + private func scoreLabel(_ score: Int?) -> String { + score.map { "\($0)" } ?? "-" + } + private var secondaryHighlightIcon: String { if selectedScope == .all { return "number" @@ -253,21 +305,10 @@ struct StatisticsView: View { private func displayTotalTimeSeconds(at date: Date) -> Int { let liveElapsed: Int - switch selectedScope { - case .all: + if activeVariantMatchesSelectedScope { liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 - case .klondike: - if viewModel?.gameVariant == .klondike { - liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 - } else { - liveElapsed = 0 - } - case .freecell: - if viewModel?.gameVariant == .freecell { - liveElapsed = viewModel?.unfinalizedElapsedSecondsForStats(at: date) ?? 0 - } else { - liveElapsed = 0 - } + } else { + liveElapsed = 0 } let (sum, overflow) = stats.totalTimeSeconds.addingReportingOverflow(liveElapsed) return overflow ? Int.max : max(0, sum) @@ -339,14 +380,12 @@ struct StatisticsView: View { } private func resetStatistics() { - switch selectedScope { - case .klondike: - GameStatisticsStore.reset(for: .klondike) - case .freecell: - GameStatisticsStore.reset(for: .freecell) - case .all: - GameStatisticsStore.reset(for: .klondike) - GameStatisticsStore.reset(for: .freecell) + if let variant = selectedScope.variant { + GameStatisticsStore.reset(for: variant) + } else { + for variant in GameVariant.allCases { + GameStatisticsStore.reset(for: variant) + } } if selectedScope == .all || activeVariantMatchesSelectedScope { @@ -358,14 +397,8 @@ struct StatisticsView: View { } private var activeVariantMatchesSelectedScope: Bool { - switch selectedScope { - case .klondike: - return viewModel?.gameVariant == .klondike - case .freecell: - return viewModel?.gameVariant == .freecell - case .all: - return true - } + guard let variant = selectedScope.variant else { return true } + return viewModel?.gameVariant == variant } private var resetDialogTitle: String { @@ -374,6 +407,8 @@ struct StatisticsView: View { return "Reset Klondike statistics?" case .freecell: return "Reset FreeCell statistics?" + case .yukon: + return "Reset Yukon statistics?" case .all: return "Reset all statistics?" } @@ -385,6 +420,8 @@ struct StatisticsView: View { return "Reset Klondike Statistics" case .freecell: return "Reset FreeCell Statistics" + case .yukon: + return "Reset Yukon Statistics" case .all: return "Reset All Statistics" } @@ -396,21 +433,20 @@ struct StatisticsView: View { return "This will reset only Klondike games, times, win rates, and high scores." case .freecell: return "This will reset only FreeCell games, times, win rates, and high scores." + case .yukon: + return "This will reset only Yukon games, times, win rates, and high scores." case .all: - return "This will reset both Klondike and FreeCell statistics." + return "This will reset Klondike, FreeCell, and Yukon statistics." } } private func loadStats() { - switch selectedScope { - case .klondike: - stats = GameStatisticsStore.load(for: .klondike) - case .freecell: - stats = GameStatisticsStore.load(for: .freecell) - case .all: - let klondikeStats = GameStatisticsStore.load(for: .klondike) - let freeCellStats = GameStatisticsStore.load(for: .freecell) - stats = GameStatistics.aggregated([klondikeStats, freeCellStats]) + if let variant = selectedScope.variant { + stats = GameStatisticsStore.load(for: variant) + } else { + stats = GameStatistics.aggregated( + GameVariant.allCases.map { GameStatisticsStore.load(for: $0) } + ) } } diff --git a/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift b/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift new file mode 100644 index 0000000..2896340 --- /dev/null +++ b/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import Observation + +struct YukonTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + // Yukon has no stock, waste, or free cells; keep the foundations aligned + // over tableau columns 4-7, matching their Klondike positions. + ForEach(0..<3, id: \.self) { _ in + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + } + + ForEach(0..<4, id: \.self) { index in + FoundationView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isTargeted: activeTarget == .foundation(index), + isHintTargeted: hintedTarget == .foundation(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift b/ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift index c7e4518..0d443ae 100644 --- a/ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift +++ b/ComputerSolitaireTests/Shared/AutoMoveAdvisorCoverageTests.swift @@ -34,6 +34,42 @@ final class AutoMoveAdvisorCoverageTests: XCTestCase { ) } + func testTableauPickupRuleIsVariantSpecific() { + // The same unordered face-up group: pickable in Yukon, never in Klondike or + // FreeCell (their grabs must be descending alternating sequences). + let unorderedGroup = [ + TestCards.make(.hearts, .seven, isFaceUp: true), + TestCards.make(.spades, .two, isFaceUp: true) + ] + + func selections(for variant: GameVariant) -> [Selection] { + var tableau: [[Card]] = [unorderedGroup, [], [], [], [], [], []] + if variant == .freecell { + tableau.append([]) + } + let state = GameState( + variant: variant, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + return AutoMoveAdvisor.candidateSelections(in: state) + } + + func containsUnorderedGrab(_ selections: [Selection]) -> Bool { + selections.contains(where: { + if case .tableau(pile: 0, index: 0) = $0.source { return $0.cards.count == 2 } + return false + }) + } + + XCTAssertTrue(containsUnorderedGrab(selections(for: .yukon))) + XCTAssertFalse(containsUnorderedGrab(selections(for: .klondike))) + XCTAssertFalse(containsUnorderedGrab(selections(for: .freecell))) + } + func testLegalDestinationsRejectsRedundantKingTransferBetweenEmptyColumns() { let kingSpades = TestCards.make(.spades, .king, isFaceUp: true) let state = GameState( diff --git a/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift index 1d21687..9af4ef9 100644 --- a/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift +++ b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift @@ -157,12 +157,21 @@ final class GameSessionTrackingTests: XCTestCase { XCTAssertEqual(klondikeStats.gamesPlayed, 1) XCTAssertEqual(freeCellStats.gamesPlayed, 0) - viewModel.newGame(variant: .klondike, drawMode: .three) + viewModel.newGame(variant: .yukon, drawMode: .three) klondikeStats = GameStatisticsStore.load(for: .klondike) freeCellStats = GameStatisticsStore.load(for: .freecell) + var yukonStats = GameStatisticsStore.load(for: .yukon) XCTAssertEqual(klondikeStats.gamesPlayed, 1) XCTAssertEqual(freeCellStats.gamesPlayed, 1) + XCTAssertEqual(yukonStats.gamesPlayed, 0) + + viewModel.newGame(variant: .klondike, drawMode: .three) + + klondikeStats = GameStatisticsStore.load(for: .klondike) + yukonStats = GameStatisticsStore.load(for: .yukon) + XCTAssertEqual(klondikeStats.gamesPlayed, 1) + XCTAssertEqual(yukonStats.gamesPlayed, 1) } } diff --git a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift index c16ef2c..e6faa87 100644 --- a/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift +++ b/ComputerSolitaireTests/Shared/GameStatisticsStoreTests.swift @@ -33,6 +33,69 @@ final class GameStatisticsStoreTests: XCTestCase { XCTAssertEqual(stats.cleanWins, 1) } + func testRecordCompletedGameWithoutDrawModeUpdatesVariantNeutralHighScore() { + // FreeCell and Yukon have no stock, so they report draw count 0: their wins + // must land in the single variant-neutral high score, never in Klondike's + // per-draw-mode fields. + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 180, + finalScore: 420, + drawCount: 0, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 240, + finalScore: 350, + drawCount: 0, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.highScore, 420) + XCTAssertNil(stats.highScoreDrawThree) + XCTAssertNil(stats.highScoreDrawOne) + } + + func testKlondikeDrawModeWinsDoNotTouchVariantNeutralHighScore() { + var stats = GameStatistics() + + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 100, + finalScore: 500, + drawCount: DrawMode.three.rawValue, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + + XCTAssertEqual(stats.highScoreDrawThree, 500) + XCTAssertNil(stats.highScore) + } + + func testStatisticsDecodingToleratesPayloadWithoutHighScoreField() throws { + // Statistics saved before the variant-neutral high score existed must load + // with the field simply absent. + let legacyJSON = """ + {"schemaVersion": 1, "gamesPlayed": 3, "gamesWon": 2, "totalTimeSeconds": 600, "cleanWins": 1} + """ + let stats = try JSONDecoder().decode( + GameStatistics.self, + from: try XCTUnwrap(legacyJSON.data(using: .utf8)) + ) + + XCTAssertNil(stats.highScore) + XCTAssertEqual(stats.gamesPlayed, 3) + XCTAssertEqual(stats.gamesWon, 2) + } + func testRecordCompletedGameUsesOverflowSafeCounters() { var stats = GameStatistics( gamesPlayed: Int.max, @@ -154,19 +217,30 @@ final class GameStatisticsStoreTests: XCTestCase { bestTimeSeconds: 150, highScoreDrawThree: nil, highScoreDrawOne: nil, + highScore: 260, cleanWins: 3 ) + let yukonStats = GameStatistics( + trackedSince: DateFixtures.plus(600), + gamesPlayed: 2, + gamesWon: 1, + totalTimeSeconds: 400, + bestTimeSeconds: 180, + highScore: 610, + cleanWins: 0 + ) - let aggregate = GameStatistics.aggregated([klondikeStats, freeCellStats]) + let aggregate = GameStatistics.aggregated([klondikeStats, freeCellStats, yukonStats]) XCTAssertEqual(aggregate.trackedSince, DateFixtures.reference) - XCTAssertEqual(aggregate.gamesPlayed, 10) - XCTAssertEqual(aggregate.gamesWon, 7) - XCTAssertEqual(aggregate.totalTimeSeconds, 2000) + XCTAssertEqual(aggregate.gamesPlayed, 12) + XCTAssertEqual(aggregate.gamesWon, 8) + XCTAssertEqual(aggregate.totalTimeSeconds, 2400) XCTAssertEqual(aggregate.bestTimeSeconds, 120) XCTAssertEqual(aggregate.cleanWins, 5) XCTAssertEqual(aggregate.highScoreDrawThree, 500) XCTAssertEqual(aggregate.highScoreDrawOne, 300) + XCTAssertEqual(aggregate.highScore, 610) } func testAggregatedStatisticsUsesOverflowSafeCounters() { diff --git a/ComputerSolitaireTests/Shared/SavedGamePayloadSanitizationTests.swift b/ComputerSolitaireTests/Shared/SavedGamePayloadSanitizationTests.swift index b0ad578..26a9c49 100644 --- a/ComputerSolitaireTests/Shared/SavedGamePayloadSanitizationTests.swift +++ b/ComputerSolitaireTests/Shared/SavedGamePayloadSanitizationTests.swift @@ -13,6 +13,14 @@ final class SavedGamePayloadSanitizationTests: XCTestCase { XCTAssertNil(payload.sanitizedForRestore()) } + func testSanitizedForRestoreRejectsKlondikeStateWithStrandedFreeCellCard() { + // Klondike renders no free-cell slots, so a card stranded there would be + // invisible and the game unwinnable. + var state = GameStateFixtures.validPersistenceState() + state.freeCells[0] = state.stock.removeLast() + XCTAssertNil(makePayload(state: state).sanitizedForRestore()) + } + func testSanitizedForRestoreClampsDrawModesCountsAndHistory() { let validState = GameStateFixtures.validPersistenceState() let validSnapshot = GameSnapshot( diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index 41cdac8..2684d0a 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -124,7 +124,7 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { let viewModel = SolitaireViewModel() viewModel.state = GameStateFixtures.seededFreeCellDeal(seed: seed) - viewModel.configureFreeCellNewGame() + viewModel.configureStocklessNewGame() let savedAt = DateFixtures.reference let payload = SavedGamePayload( @@ -154,6 +154,56 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("FreeCell fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Yukon board is a fresh deal — face-down ramps under five-card + /// face-up fans. Seeds are scanned for the most photogenic spread across the + /// fan tails (the cards the eye lands on). + func testGenerateYukonFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededYukonDeal(seed: seed) + let score = yukonDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededYukonDeal(seed: seed) + viewModel.configureStocklessNewGame() + + let savedAt = DateFixtures.reference + let payload = SavedGamePayload( + savedAt: savedAt, + state: viewModel.state, + movesCount: viewModel.movesCount, + score: viewModel.score, + gameStartedAt: savedAt.addingTimeInterval(-Self.stagedElapsedSeconds), + stockDrawCount: DrawMode.three.rawValue, + history: [], + hasStartedTrackedGame: false + ) + + XCTAssertNotNil(payload.sanitizedForRestore(), "Generated fixture failed the validity gate") + let restoredViewModel = SolitaireViewModel() + XCTAssertTrue(restoredViewModel.restore(from: payload), "Generated fixture failed to restore") + XCTAssertEqual(restoredViewModel.gameVariant, .yukon, "Fixture did not restore as Yukon") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("yukon.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Yukon fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -179,6 +229,21 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Yukon deal by the cards on the fan tails (the last two + /// face-up cards of each pile): rank variety, red/black balance, all four + /// suits, a few face cards, and an ace on a tail read well. + private func yukonDealScore(of deal: GameState) -> Int { + let visible = deal.tableau.flatMap { $0.suffix(2).filter(\.isFaceUp) } + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + score += deal.tableau.compactMap { $0.last }.contains(where: { $0.rank == .ace }) ? 6 : 0 + return score + } + /// Scores a fresh deal by the ten cards a first draw makes visible: the /// seven tableau tops plus the three stock cards that land in the waste. /// Rank variety, red/black balance, all four suits, and a couple of face diff --git a/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift b/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift index 558e86e..f386dcd 100644 --- a/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift +++ b/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift @@ -63,6 +63,47 @@ final class TapMovePolicyTests: XCTestCase { XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .tableau(1)) } + // MARK: - Yukon destination preferences + + func testYukonSafeFoundationMoveBeatsTableauBuild() { + // 2♠ can go to foundation (safe: rank <= 2) or onto the red 3. + let twoSpades = TestCards.make(.spades, .two) + let threeHearts = TestCards.make(.hearts, .three) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[TestCards.make(.spades, .ace)], [], [], []], + tableau: [[twoSpades], [threeHearts], [], [], [], [], []] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: [twoSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .foundation(0)) + } + + func testYukonUnsafeFoundationMoveLosesToTableauBuild() { + // 5♠ is foundation-eligible but unsafe (red foundations far behind); with no + // stock to refill the board, the tap should prefer keeping it on the red 6. + let fiveSpades = TestCards.make(.spades, .five) + let sixHearts = TestCards.make(.hearts, .six) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [ + [TestCards.make(.spades, .ace), TestCards.make(.spades, .two), + TestCards.make(.spades, .three), TestCards.make(.spades, .four)], + [], [], [] + ], + tableau: [[fiveSpades], [sixHearts], [], [], [], [], []] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: [fiveSpades]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .tableau(1)) + } + func testFreeCellFreeCellIsLastResort() { // King with no tableau fit: only free cells remain, and the tap should use one. let kingSpades = TestCards.make(.spades, .king) diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index 37c280c..b7599ed 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -80,6 +80,30 @@ enum GameStateFixtures { ) } + /// A reproducible Yukon deal matching the shape of `GameState.newYukonGame`. + static func seededYukonDeal(seed: UInt64) -> GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = Array(repeating: [], count: 7) + for pileIndex in 0..<7 { + let faceDownCount = pileIndex == 0 ? 0 : pileIndex + let faceUpCount = pileIndex == 0 ? 1 : 5 + for cardIndex in 0..<(faceDownCount + faceUpCount) { + var card = deck.removeLast() + card.isFaceUp = cardIndex >= faceDownCount + tableau[pileIndex].append(card) + } + } + return GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } + private static func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { var generator = SeededRandomNumberGenerator(seed: seed) var deck = TestCards.fullDeck(faceUp: faceUp) diff --git a/ComputerSolitaireTests/Yukon/YukonAutoFinishTests.swift b/ComputerSolitaireTests/Yukon/YukonAutoFinishTests.swift new file mode 100644 index 0000000..025fe4e --- /dev/null +++ b/ComputerSolitaireTests/Yukon/YukonAutoFinishTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class YukonAutoFinishTests: XCTestCase { + func testCandidateRequiresEveryTableauCardFaceUp() { + var state = almostWonYukonBoard() + XCTAssertTrue(AutoFinishPlanner.canAutoFinish(in: state)) + + state.tableau[0][0].isFaceUp = false + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testScrambledAllFaceUpBoardIsNotAutoFinishable() { + // Zero face-down cards qualifies the board as a candidate, but the greedy + // foundation simulation must reject it: the A♠ is buried under its own king, + // so pure foundation play stalls immediately. + var foundations = Array(repeating: [Card](), count: 4) + for (index, suit) in [Suit.hearts, .diamonds, .clubs].enumerated() { + foundations[index] = Rank.allCases.map { TestCards.make(suit, $0) } + } + let spadesRunTopDown = Rank.allCases + .filter { $0 != .ace && $0 != .king } + .sorted { $0.rawValue > $1.rawValue } + .map { TestCards.make(.spades, $0) } + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: foundations, + tableau: [ + [TestCards.make(.spades, .ace), TestCards.make(.spades, .king)], + spadesRunTopDown, + [], [], [], [], [] + ] + ) + + XCTAssertNil(AutoFinishPlanner.nextAutoFinishMove(in: state)) + XCTAssertFalse(AutoFinishPlanner.canAutoFinish(in: state)) + } + + func testAutoFinishPlaysAFinishableBoardToWin() { + var state = almostWonYukonBoard() + var steps = 0 + + while !state.isWon { + guard let move = AutoFinishPlanner.nextAutoFinishMove(in: state) else { + return XCTFail("Auto-finish stalled after \(steps) steps") + } + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Auto-finish produced an illegal move") + } + state = next + steps += 1 + if steps > 60 { + return XCTFail("Auto-finish did not converge") + } + } + } + + /// Foundations built through the queens, four kings left on the tableau. + private func almostWonYukonBoard() -> GameState { + var state = GameStateFixtures.almostWonForAutoFinish() + state.variant = .yukon + return state + } +} diff --git a/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift b/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift new file mode 100644 index 0000000..4d102f9 --- /dev/null +++ b/ComputerSolitaireTests/Yukon/YukonPersistenceTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class YukonPersistenceTests: XCTestCase { + func testValidYukonPayloadSurvivesSanitization() { + let state = GameStateFixtures.seededYukonDeal(seed: 1) + let payload = makePayload(state: state) + + let sanitized = payload.sanitizedForRestore() + XCTAssertNotNil(sanitized) + XCTAssertEqual(sanitized?.state.variant, .yukon) + XCTAssertEqual(sanitized?.state.wasteDrawCount, 0) + XCTAssertEqual(sanitized?.stockDrawCount, DrawMode.three.rawValue) + } + + func testInvalidYukonLayoutsAreRejected() { + var eightPiles = GameStateFixtures.seededYukonDeal(seed: 2) + eightPiles.tableau.append([]) + XCTAssertNil(makePayload(state: eightPiles).sanitizedForRestore()) + + var nonEmptyStock = GameStateFixtures.seededYukonDeal(seed: 3) + nonEmptyStock.stock = [nonEmptyStock.tableau[6].removeLast()] + XCTAssertNil(makePayload(state: nonEmptyStock).sanitizedForRestore()) + + // Yukon renders no free-cell slots: a card stranded there would be invisible + // and the game unwinnable, so the layout gate must reject it. + var strandedFreeCell = GameStateFixtures.seededYukonDeal(seed: 4) + strandedFreeCell.freeCells[0] = strandedFreeCell.tableau[6].removeLast() + XCTAssertNil(makePayload(state: strandedFreeCell).sanitizedForRestore()) + } + + func testViewModelRoundTripPreservesYukonGame() { + let viewModel = SolitaireViewModel() + viewModel.newGame(variant: .yukon) + let payload = viewModel.persistencePayload() + + let restored = SolitaireViewModel() + XCTAssertTrue(restored.restore(from: payload)) + XCTAssertEqual(restored.state.variant, .yukon) + XCTAssertEqual(restored.state, viewModel.state) + } + + private func makePayload(state: GameState) -> SavedGamePayload { + SavedGamePayload( + savedAt: DateFixtures.reference, + state: state, + movesCount: 0, + score: 0, + gameStartedAt: DateFixtures.reference, + pauseStartedAt: nil, + hasAppliedTimeBonus: false, + finalElapsedSeconds: nil, + stockDrawCount: DrawMode.three.rawValue, + scoringDrawCount: DrawMode.three.rawValue, + history: [], + redealState: state, + hasStartedTrackedGame: true, + isCurrentGameFinalized: false, + hintRequestsInCurrentGame: 0, + undosUsedInCurrentGame: 0, + usedRedealInCurrentGame: false + ) + } +} diff --git a/ComputerSolitaireTests/Yukon/YukonPlannerTests.swift b/ComputerSolitaireTests/Yukon/YukonPlannerTests.swift new file mode 100644 index 0000000..e9d3f34 --- /dev/null +++ b/ComputerSolitaireTests/Yukon/YukonPlannerTests.swift @@ -0,0 +1,385 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class YukonPlannerTests: XCTestCase { + func testHintIsDeterministicAcrossCalls() { + let sixClubs = TestCards.make(.clubs, .six) + let sixSpades = TestCards.make(.spades, .six) + let fiveHearts = TestCards.make(.hearts, .five) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[sixClubs], [sixSpades], [fiveHearts], [], [], [], []] + ) + + let first = YukonPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(YukonPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal always has a reveal within easy reach, so a small budget keeps + // the suite fast; production searches are capped by the interactive deadline. + let limits = YukonPlanner.Limits(maxNodes: 2_000) + for seed in 1...10 { + let state = GameStateFixtures.seededYukonDeal(seed: UInt64(seed)) + XCTAssertNotNil( + YukonPlanner.bestHint(in: state, limits: limits), + "Seed \(seed): a fresh Yukon deal should have a suggestible line" + ) + } + } + + func testFollowingPlannedLinesNeverLoops() { + // Hints follow one cached improving line to its end before re-planning, and + // every completed line strictly improves the anchor position — that ratchet is + // what makes looping impossible. Within a line, positions never repeat (the + // search graph is acyclic); across lines a transient revisit is survivable, + // but seeing the same exact layout a third time would mean the hints loop. + let limits = YukonPlanner.Limits(maxNodes: 4_000) + for seed in [11, 12] as [UInt64] { + var state = GameStateFixtures.seededYukonDeal(seed: seed) + var visitCounts: [UInt64: Int] = [stateFingerprint(state): 1] + var moves = 0 + while moves < 300 { + guard case .line(let line) = YukonPlanner.bestLine(in: state, limits: limits) else { + break + } + var lineKeys: Set = [stateFingerprint(state)] + for move in line { + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Planned move was not legal") + } + state = next + moves += 1 + let key = stateFingerprint(state) + XCTAssertTrue( + lineKeys.insert(key).inserted, + "A planned line revisited a position" + ) + let count = (visitCounts[key] ?? 0) + 1 + visitCounts[key] = count + if count >= 3 { + return XCTFail("Following planned lines revisited the same position twice") + } + } + } + } + } + + func testHintPrefersRevealingLineOverPlainReshuffle() { + // Moving the 9♣ onto the red 10 reveals a face-down card; moving the free 10♦ + // onto the black jack accomplishes nothing. The hint should pick the reveal. + let hiddenKing = TestCards.make(.clubs, .king, isFaceUp: false) + let nineClubs = TestCards.make(.clubs, .nine) + let tenHearts = TestCards.make(.hearts, .ten) + let tenDiamonds = TestCards.make(.diamonds, .ten) + let jackSpades = TestCards.make(.spades, .jack) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenKing, nineClubs], [tenHearts], [tenDiamonds], [jackSpades], [], [], []] + ) + + guard case .move(let move)? = YukonPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, nineClubs.id) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testHintGrabsUnorderedGroupWhenThatIsTheOnlyRevealingLine() { + // The 7♥ is buried under an out-of-sequence 2♠; the only progress is grabbing + // both together onto the 8♠, which a sequence-only picker could never suggest. + let hiddenFour = TestCards.make(.diamonds, .four, isFaceUp: false) + let sevenHearts = TestCards.make(.hearts, .seven) + let twoSpades = TestCards.make(.spades, .two) + let eightSpades = TestCards.make(.spades, .eight) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenFour, sevenHearts, twoSpades], [eightSpades], [], [], [], [], []] + ) + + guard case .move(let move)? = YukonPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.source, .tableau(pile: 0, index: 1)) + XCTAssertEqual(move.selection.cards.map(\.id), [sevenHearts.id, twoSpades.id]) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testHintTargetsTheFirstEmptyColumn() { + // With two empty columns the planner canonicalizes king drops to the first one; + // the hint should never point at the second interchangeable column. + let hiddenThree = TestCards.make(.diamonds, .three, isFaceUp: false) + let kingSpades = TestCards.make(.spades, .king) + let nineHearts = TestCards.make(.hearts, .nine) + let sevenClubs = TestCards.make(.clubs, .seven) + let nineSpades = TestCards.make(.spades, .nine) + let jackClubs = TestCards.make(.clubs, .jack) + let fiveSpades = TestCards.make(.spades, .five) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [ + [hiddenThree, kingSpades, nineHearts], + [sevenClubs], + [nineSpades], + [jackClubs], + [], + [], + [fiveSpades] + ] + ) + + guard case .move(let move)? = YukonPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.source, .tableau(pile: 0, index: 1)) + XCTAssertEqual(move.destination, .tableau(4)) + } + + func testRedundantKingShuffleYieldsNoHintAndNoAvailableMove() { + // A full king-led pile next to empty columns is a strategic dead end: moving it + // sideways is a no-op, so both the planner and the move-exists check say stop. + let kingSpades = TestCards.make(.spades, .king) + let nineHearts = TestCards.make(.hearts, .nine) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[kingSpades, nineHearts], [], [], [], [], [], []] + ) + + XCTAssertNil(YukonPlanner.bestHint(in: state)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testRollbackOnlyRescueIsFoundInsteadOfDeclaredStuck() { + // The 4♠ hides the only face-down card, and both of its landing spots (the + // red fives) are gone — one banked on the foundation. The only rescue is + // rolling the 5♥ back onto the 6♣, landing the 4♠ on it, and flipping the + // hidden card. If rollbacks were missing from the search, this position + // would be misreported as provably stuck while productive play exists. + let heartsFoundation = [Rank.ace, .two, .three, .four, .five] + .map { TestCards.make(.hearts, $0) } + let hiddenNine = TestCards.make(.diamonds, .nine, isFaceUp: false) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [heartsFoundation, [], [], []], + tableau: [ + [hiddenNine, TestCards.make(.spades, .four)], + [TestCards.make(.clubs, .six)], + [TestCards.make(.diamonds, .king), TestCards.make(.spades, .queen)], + [TestCards.make(.hearts, .king)], + [TestCards.make(.clubs, .three)], + [TestCards.make(.spades, .nine)], + [TestCards.make(.clubs, .jack)] + ] + ) + + // The Q♠ shuttle keeps ordinary moves available, so the position is live. + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + + guard case .line(let line) = YukonPlanner.bestLine(in: state) else { + return XCTFail("Expected the search to find the rollback rescue line") + } + guard let firstMove = line.first else { + return XCTFail("Expected a non-empty line") + } + XCTAssertEqual(firstMove.selection.source, .foundation(pile: 0)) + XCTAssertEqual(firstMove.destination, .tableau(1)) + + guard case .move(let hint)? = HintPlanner().bestHint( + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Expected the hint stack to surface the rollback") + } + XCTAssertEqual(hint.selection.source, .foundation(pile: 0)) + } + + func testTruncatedSearchReportsNoProgressWithoutClaimingProof() { + // A one-node budget cannot explore a fresh deal, so the search must report + // truncation — not exhaustion, which would wrongly claim the deal is dead — + // and the hint contract yields silence rather than an unverified nudge. + let limits = YukonPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededYukonDeal(seed: 5) + + guard case .noProgress(searchWasExhaustive: false) = YukonPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(YukonPlanner.bestHint(in: state, limits: limits)) + } + + func testRollbackOnlyPositionKeepsHintButtonAliveAndHintsTheRollback() { + // Same rescue as above, but the rollback is the ONLY legal move on the + // board: every face-up tableau card is black, so no tableau move exists. + // The availability check must still report a move for Yukon — its planner + // can turn the rollback into a hint — or the button dies on a live game. + let heartsFoundation = [Rank.ace, .two, .three, .four, .five] + .map { TestCards.make(.hearts, $0) } + let hiddenNine = TestCards.make(.diamonds, .nine, isFaceUp: false) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [heartsFoundation, [], [], []], + tableau: [ + [hiddenNine, TestCards.make(.spades, .four)], + [TestCards.make(.clubs, .six)], + [TestCards.make(.spades, .queen)], + [TestCards.make(.clubs, .king)], + [TestCards.make(.clubs, .three)], + [TestCards.make(.spades, .nine)], + [TestCards.make(.clubs, .jack)] + ] + ) + + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + + guard case .move(let hint)? = HintPlanner().bestHint( + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Expected the hint stack to surface the rollback rescue") + } + XCTAssertEqual(hint.selection.source, .foundation(pile: 0)) + XCTAssertEqual(hint.destination, .tableau(1)) + } + + func testProvablyStuckPositionWithLegalMovesGetsNoHint() { + // The 5♠ can shuttle between the two red sixes forever, but no reveal or + // foundation progress is reachable anywhere. The search exhausts the reachable + // positions, and the hint stack must report "no useful move" instead of + // suggesting the shuttle — a deterministic fallback would ping-pong it. + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [ + [TestCards.make(.hearts, .six), TestCards.make(.spades, .five)], + [TestCards.make(.diamonds, .six)], + [TestCards.make(.clubs, .three)], + [TestCards.make(.clubs, .eight)], + [TestCards.make(.clubs, .nine)], + [TestCards.make(.spades, .jack)], + [TestCards.make(.clubs, .king)] + ] + ) + + guard case .noProgress(searchWasExhaustive: true) = YukonPlanner.bestLine(in: state) else { + return XCTFail("Expected an exhaustive no-progress search outcome") + } + XCTAssertNil(HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.three.rawValue)) + // The position still has legal moves — the hint's nil is a verdict, not a bug. + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testDeadlockedStateReturnsNil() { + // Every face-up card is black, so no tableau landing exists; no tops are aces + // and no column is empty, so nothing else is legal either. + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [ + [TestCards.make(.spades, .two, isFaceUp: false), TestCards.make(.spades, .five)], + [TestCards.make(.clubs, .three)], + [TestCards.make(.spades, .seven)], + [TestCards.make(.clubs, .nine)], + [TestCards.make(.spades, .jack)], + [TestCards.make(.clubs, .king)], + [TestCards.make(.clubs, .six)] + ] + ) + + XCTAssertNil(YukonPlanner.bestHint(in: state)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testHintPlannerWinsAKnownDealEndToEnd() { + // Probe-verified winning seed: following the HintPlanner's cached lines plays + // this deal to a win without a single nil hint. Guards the whole hint stack. + let planner = HintPlanner() + var state = GameStateFixtures.seededYukonDeal(seed: 7) + var moves = 0 + + while moves < 400 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) else { + return XCTFail("Hint stack gave up after \(moves) moves") + } + guard case .move(let move) = hint else { + return XCTFail("Yukon hinted a stock tap") + } + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Hinted move was not legal after \(moves) moves") + } + state = next + moves += 1 + } + XCTFail("Did not win within 400 moves") + } + + // MARK: - Helpers + + private func stateFingerprint(_ state: GameState) -> UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { hash = (hash ^ UInt64(value)) &* 0x100000001b3 } + func mix(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + mix(UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0))) + } + for pile in state.foundations { + mix(0xFE) + for card in pile { mix(card: card) } + } + for pile in state.tableau { + mix(0xFD) + for card in pile { mix(card: card) } + } + return hash + } +} diff --git a/ComputerSolitaireTests/Yukon/YukonRulesTests.swift b/ComputerSolitaireTests/Yukon/YukonRulesTests.swift new file mode 100644 index 0000000..bc76aa0 --- /dev/null +++ b/ComputerSolitaireTests/Yukon/YukonRulesTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class YukonRulesTests: XCTestCase { + func testYukonNewGameLayout() { + let state = GameState.newGame(variant: .yukon) + + XCTAssertEqual(state.variant, .yukon) + XCTAssertTrue(state.stock.isEmpty) + XCTAssertTrue(state.waste.isEmpty) + XCTAssertEqual(state.wasteDrawCount, 0) + XCTAssertTrue(state.freeCells.allSatisfy { $0 == nil }) + XCTAssertEqual(state.foundations.count, 4) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertEqual(state.tableau.map(\.count), [1, 6, 7, 8, 9, 10, 11]) + + for (pileIndex, pile) in state.tableau.enumerated() { + let expectedFaceDownCount = pileIndex == 0 ? 0 : pileIndex + let faceDownPrefix = pile.prefix(while: { !$0.isFaceUp }) + XCTAssertEqual(faceDownPrefix.count, expectedFaceDownCount, "Pile \(pileIndex)") + XCTAssertTrue( + pile.dropFirst(expectedFaceDownCount).allSatisfy(\.isFaceUp), + "Pile \(pileIndex): face-up cards must sit above the face-down ones" + ) + } + + let allCards = Array(state.tableau.joined()) + XCTAssertEqual(allCards.count, 52) + XCTAssertEqual(Set(allCards.map(\.id)).count, 52) + } + + func testTableauLandingRuleMatchesKlondikeSemantics() { + let sevenHearts = TestCards.make(.hearts, .seven) + let eightSpades = TestCards.make(.spades, .eight) + let eightDiamonds = TestCards.make(.diamonds, .eight) + let nineSpades = TestCards.make(.spades, .nine) + let queenHearts = TestCards.make(.hearts, .queen) + let kingClubs = TestCards.make(.clubs, .king) + let faceDownEight = TestCards.make(.spades, .eight, isFaceUp: false) + + // Opposite color, one rank higher: legal. + XCTAssertTrue(GameRules.canMoveToTableau(card: sevenHearts, destinationPile: [eightSpades], variant: .yukon)) + // Same color: rejected. + XCTAssertFalse(GameRules.canMoveToTableau(card: sevenHearts, destinationPile: [eightDiamonds], variant: .yukon)) + // Wrong rank: rejected. + XCTAssertFalse(GameRules.canMoveToTableau(card: sevenHearts, destinationPile: [nineSpades], variant: .yukon)) + // Face-down destination top: rejected. + XCTAssertFalse(GameRules.canMoveToTableau(card: sevenHearts, destinationPile: [faceDownEight], variant: .yukon)) + // Empty column: Kings only. + XCTAssertTrue(GameRules.canMoveToTableau(card: kingClubs, destinationPile: [], variant: .yukon)) + XCTAssertFalse(GameRules.canMoveToTableau(card: queenHearts, destinationPile: [], variant: .yukon)) + } + + func testUnorderedGroupMovesThroughTheSession() { + let hiddenFour = TestCards.make(.diamonds, .four, isFaceUp: false) + let sevenHearts = TestCards.make(.hearts, .seven) + let twoSpades = TestCards.make(.spades, .two) + let eightSpades = TestCards.make(.spades, .eight) + + let viewModel = SolitaireViewModel() + viewModel.newGame(variant: .yukon) + viewModel.state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenFour, sevenHearts, twoSpades], [eightSpades], [], [], [], [], []] + ) + + // The 7♥/2♠ group is not a sequence, yet Yukon allows picking it up; only the + // bottom card (7♥) has to fit the destination (8♠). + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 1)) + XCTAssertTrue(viewModel.canDrop(to: .tableau(1))) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + + XCTAssertEqual(viewModel.state.tableau[1].map(\.rank), [.eight, .seven, .two]) + XCTAssertEqual(viewModel.state.tableau[0].count, 1) + XCTAssertTrue( + viewModel.state.tableau[0][0].isFaceUp, + "The exposed face-down card must flip when the group moves away" + ) + } + + func testDroppingAGroupBackOntoItsOwnPileIsARejectedCancel() { + // In Yukon an unordered group's bottom card can "fit" the top card of its own + // pile (8♥ under 9♠ fits on 9♠), so without an explicit same-pile rejection a + // cancelled drag would count as a move and flip the hidden card for free. + let hiddenFour = TestCards.make(.diamonds, .four, isFaceUp: false) + let eightHearts = TestCards.make(.hearts, .eight) + let nineSpades = TestCards.make(.spades, .nine) + + let viewModel = SolitaireViewModel() + viewModel.newGame(variant: .yukon) + viewModel.state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenFour, eightHearts, nineSpades], [], [], [], [], [], []] + ) + let movesBefore = viewModel.movesCount + let scoreBefore = viewModel.score + + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 1)) + XCTAssertFalse(viewModel.canDrop(to: .tableau(0))) + XCTAssertFalse(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertEqual(viewModel.movesCount, movesBefore) + XCTAssertEqual(viewModel.score, scoreBefore) + XCTAssertFalse( + viewModel.state.tableau[0][0].isFaceUp, + "A cancelled same-pile drop must not flip the hidden card" + ) + } + + func testKingLedUnorderedGroupFillsEmptyColumnAndOthersCannot() { + let kingSpades = TestCards.make(.spades, .king) + let fiveDiamonds = TestCards.make(.diamonds, .five) + let hiddenNine = TestCards.make(.clubs, .nine, isFaceUp: false) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenNine, kingSpades, fiveDiamonds], [], [], [], [], [], []] + ) + + let kingGroup = Selection( + source: .tableau(pile: 0, index: 1), + cards: [kingSpades, fiveDiamonds] + ) + XCTAssertTrue(AutoMoveAdvisor.legalDestinations(for: kingGroup, in: state).contains(.tableau(1))) + + let nonKingGroup = Selection(source: .tableau(pile: 0, index: 2), cards: [fiveDiamonds]) + XCTAssertFalse( + AutoMoveAdvisor.legalDestinations(for: nonKingGroup, in: state) + .contains(where: { if case .tableau = $0 { return true } else { return false } }) + ) + } + + func testSimulatedStateFlipsExposedCard() { + let hiddenTen = TestCards.make(.hearts, .ten, isFaceUp: false) + let threeClubs = TestCards.make(.clubs, .three) + let fourHearts = TestCards.make(.hearts, .four) + let state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenTen, threeClubs], [fourHearts], [], [], [], [], []] + ) + let selection = Selection(source: .tableau(pile: 0, index: 1), cards: [threeClubs]) + + let nextState = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: .tableau(1), + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + XCTAssertNotNil(nextState) + XCTAssertTrue(nextState?.tableau[0].last?.isFaceUp ?? false) + } + + func testFoundationTopReturnsToTableau() { + let aceSpades = TestCards.make(.spades, .ace) + let twoSpades = TestCards.make(.spades, .two) + let threeHearts = TestCards.make(.hearts, .three) + + let viewModel = SolitaireViewModel() + viewModel.newGame(variant: .yukon) + viewModel.state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: [[aceSpades, twoSpades], [], [], []], + tableau: [[threeHearts], [], [], [], [], [], []] + ) + + viewModel.selectFromFoundation(index: 0) + XCTAssertTrue(viewModel.canDrop(to: .tableau(0))) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + XCTAssertEqual(viewModel.state.tableau[0].map(\.rank), [.three, .two]) + } + + func testYukonMoveScoring() { + let aceSpades = TestCards.make(.spades, .ace) + let hiddenSix = TestCards.make(.hearts, .six, isFaceUp: false) + + let viewModel = SolitaireViewModel() + viewModel.newGame(variant: .yukon) + viewModel.state = GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: [[hiddenSix, aceSpades], [], [], [], [], [], []] + ) + let baseScore = viewModel.score + + // Tableau -> foundation banks the ace and flips the exposed card underneath. + viewModel.selectFromTableau(pileIndex: 0, cardIndex: 1) + XCTAssertTrue(viewModel.tryMoveSelection(to: .foundation(0))) + XCTAssertEqual( + viewModel.score, + baseScore + + Scoring.delta(for: .tableauToFoundation) + + Scoring.delta(for: .turnOverTableauCard) + ) + + // Foundation -> tableau costs points, exactly like Klondike. + let scoreBeforeRollback = viewModel.score + let twoHearts = TestCards.make(.hearts, .two) + viewModel.state.tableau[1] = [twoHearts] + viewModel.selectFromFoundation(index: 0) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + XCTAssertEqual(viewModel.score, scoreBeforeRollback + Scoring.delta(for: .foundationToTableau)) + } +} diff --git a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift index 77ddaae..2ab1fc6 100644 --- a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift +++ b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift @@ -18,7 +18,8 @@ final class ScreenshotCaptureUITests: XCTestCase { /// Store screenshot, in store order. private static let boards = [ "klondike-draw3", - "freecell" + "freecell", + "yukon" ] /// Appearance for every screenshot, pinned via UserDefaults launch diff --git a/README.md b/README.md index f4c1a07..3d104fc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS -- Multiple game variants: **Klondike** (both 1-card and 3-card draw) and **FreeCell** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, and **Yukon** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -24,3 +24,4 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. |---------|-------------|-------| | **Klondike** | Classic Solitaire, with 1-card and 3-card draw modes | [Rules](docs/solitaire-rules-klondike.md) | | **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/solitaire-rules-freecell.md) | +| **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/solitaire-rules-yukon.md) | diff --git a/docs/solitaire-rules-yukon.md b/docs/solitaire-rules-yukon.md new file mode 100644 index 0000000..ab887e5 --- /dev/null +++ b/docs/solitaire-rules-yukon.md @@ -0,0 +1,49 @@ +# Yukon Rules + +These rules describe standard Yukon setup and play. Yukon resembles Klondike — same tableau building and foundation rules, with face-down cards to uncover — but all 52 cards are dealt at the start (there is no stock or waste), and any face-up card may be moved together with every card stacked on top of it, **even if those cards are not in sequence**. + +## Objective +Move all 52 cards to the four foundations, building each suit from Ace to King. + +## Terminology +- **Tableau:** Seven piles where cards are played and rearranged. +- **Foundations:** Four suit piles built from Ace to King. +- **Group move:** Moving a face-up card together with every card above it as one unit, regardless of order. + +## Layout +- Seven tableau piles of uneven depth, each (except the first) with face-down cards beneath a fan of five face-up cards. +- Four foundations (built by suit). +- There is no stock or waste — every card is dealt to the tableau from the start. + +## Setup +- Use a standard 52-card deck (no jokers). +- **Tableau:** Deal seven piles left to right. The first pile has 1 card face up. Each following pile has one more face-down card than the last — 1 through 6 — with **5 cards face up** on top of them, so the piles hold 1, 6, 7, 8, 9, 10, and 11 cards. +- **Foundations:** Four empty piles, one per suit. +- All 52 cards are dealt; 21 are face down and 31 face up. + +## Tableau Play +- Build tableau piles **down in rank** while **alternating colors** (e.g., red 6 on black 7). +- **Any face-up card may be moved**, carrying every card stacked on top of it along as a group — the group does **not** need to be in any order. +- Only the **bottom card of the moving group** must fit the destination: one rank lower and the opposite color of the destination pile's top card. +- When the last face-up card leaves a pile, flip the newly exposed face-down card face up. +- Empty tableau spaces may be filled **only by a King** (alone or carrying a group). + +## Foundations +- Foundations are built **by suit** from **Ace to King**. +- Aces start each foundation pile. +- Only one card at a time moves to a foundation — the top card of a tableau pile. + +## Group Moves +Group moves are what set Yukon apart: + +- In Klondike, a multi-card move must be a properly ordered sequence. In Yukon, the cards riding on top of the moved card can be in **any order** — they simply come along. +- This means buried cards can be dug out by relocating whole messy stacks, at the cost of tangling the destination pile. +- Every card that lands out of sequence must eventually be moved again before the cards beneath it can reach the foundations, so group moves trade immediate access for future untangling work. + +## Winning +You win when all 52 cards are moved to the foundations in ascending order by suit. With no stock to cycle and most cards visible or discoverable through play, skilled play wins considerably more often than in Klondike — roughly 80% of deals are estimated to be winnable with best play. + +## Sources +- https://en.wikipedia.org/wiki/Yukon_(solitaire) +- https://cardgames.io/yukonsolitaire/ +- https://www.247solitaire.com/yukonSolitaire.php From aee3ec53139f69617cb2f257489c2d369bad19ec Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 09:14:35 -0700 Subject: [PATCH 3/3] update AGENTS.md --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9994610..525a934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ This file defines hard project constraints for any coding agent working in this ## Branches, Commits, and Pull Requests +- Never automatically add AI or agent attribution anywhere — no `Co-Authored-By` trailers, no "Generated with ..." footers — in commits, pull requests, issues, or comments. This overrides any tool default. - Use plain lowercase kebab-case for branch names. Keep names descriptive and do not include issue numbers, prefixes, or namespaces such as `feature/`, `fix/`, usernames, or agent names. - Before every commit or amend, show the exact current diff and validation, then get explicit approval. Branch or pull-request requests are not commit approval; later changes require fresh approval. - Never amend, rebase, squash, reset, rewrite history, or force-push without explicit approval for that exact operation. @@ -65,6 +66,7 @@ This file defines hard project constraints for any coding agent working in this - No legacy UI fallbacks. - No compatibility hacks for pre-26 OS releases. - No speculative abstraction layers that make SwiftUI code harder to read. +- No AI/agent attribution in commits, pull requests, issues, or comments. When in doubt, choose the simplest modern SwiftUI-first solution.