diff --git a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift index 9d35e8b..5b849ee 100644 --- a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift @@ -63,6 +63,18 @@ enum UndoAnimationCoordinator { } return Plan(items: items, targets: targets, needsPostUndoFrames: true) + case .dealTableauRow: + // Spider's dealt row flies from the tableau (or a banked run's pile) + // back onto the stock; unlike a waste draw there is no fan position + // to fall back to when a card's frame is unknown. + for (index, id) in cardIDs.enumerated() { + guard let card = beforeCards[id] ?? afterCards[id], + let startFrame = cardFrames[id] else { continue } + items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) + targets[id] = .stock(index) + } + return Plan(items: items, targets: targets, needsPostUndoFrames: false) + case .flipTableauTop: return Plan(items: [], targets: [:], needsPostUndoFrames: false) } diff --git a/ComputerSolitaire/Animation/WinCelebrationController.swift b/ComputerSolitaire/Animation/WinCelebrationController.swift index 742893d..daae615 100644 --- a/ComputerSolitaire/Animation/WinCelebrationController.swift +++ b/ComputerSolitaire/Animation/WinCelebrationController.swift @@ -88,7 +88,7 @@ final class WinCelebrationController { } var launchFrames: [Int: CGRect] = [:] - for index in 0..<4 { + for index in foundations.indices { if let frame = dropFrames[.foundation(index)]?.snapFrame, frame != .zero { launchFrames[index] = frame } @@ -159,7 +159,7 @@ final class WinCelebrationController { guard boardBounds.width > 0, boardBounds.height > 0 else { return [] } var launchFrames: [Int: CGRect] = [:] - for index in 0..<4 { + for index in foundations.indices { if let frame = dropFrames[.foundation(index)]?.snapFrame, frame != .zero { launchFrames[index] = frame } diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index e342fd9..6a5e0de 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -31,7 +31,8 @@ enum ScreenshotFixtures { static let bundled: [ScreenshotFixture] = [ ScreenshotFixture(name: "klondike-draw3", title: "Klondike – Draw 3"), ScreenshotFixture(name: "freecell", title: "FreeCell – fresh deal"), - ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal") + ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal"), + ScreenshotFixture(name: "spider", title: "Spider – 2 suits") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/spider.json b/ComputerSolitaire/Fixtures/spider.json new file mode 100644 index 0000000..e6b9a08 --- /dev/null +++ b/ComputerSolitaire/Fixtures/spider.json @@ -0,0 +1,1121 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 0, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 500, + "scoringDrawCount" : 3, + "state" : { + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "stock" : [ + { + "id" : "E9BF25DD-38E1-4C7F-AA92-81E9169DDCD0", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "66B3FF11-9FBD-4004-9D76-F8415F20F6A7", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "095456AB-B49F-4E3D-A36E-E4A8F58C2ADA", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "21B7E153-755D-4DD4-BB1B-DB6BF3B06803", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "4A817BDA-4213-4EC2-963B-C2B12F3CD545", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "4989916A-5E57-406A-A1B6-7905EA9A256B", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F752682A-2899-4EBA-8232-A8C32C598081", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "F31DB790-6463-4E67-9867-073CE371572E", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "292E4D36-1D7B-4C17-A53A-1213EAD7FA76", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "68844967-04E4-41F7-83D2-764FD858EC11", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "154FE8B2-1BD9-4A80-9CAB-7B9A74E11D05", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D3E37318-2E7F-4127-98F9-01743BFDDA4F", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2288D6AA-8D57-451B-98D1-491E35C2A39A", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "8A8C69D2-98B0-4DA6-A03A-EE1CD29856A5", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "8C31E560-E373-498B-AD59-9FC881F97D24", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "21089D88-3CE6-40D9-B42F-A3DC522FD541", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BD027465-5439-46B8-911C-2790EB7E93A5", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D8FE8647-F0F7-4434-A033-1C95500E6C8B", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "4C90527E-FE36-49F6-9692-2C09CBB72162", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "A09F366B-5C1C-4F20-846B-7F7FF688C41A", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "60527FC7-D2D0-4223-B488-1E6C294A56A7", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "F379A261-9977-4AAF-A437-424C562C2424", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "70995AAA-D7A6-41B9-9A7A-5185CAADE77A", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "5786DFE2-4A75-40E7-96E6-D7F0E4C8DA27", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "CD512405-B60A-42DB-8396-9D0427BBC02A", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "94DC3877-7991-4408-920B-ED43B6A35766", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "AF737A48-B126-481D-B684-3B98C28B26BD", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BD92F0EE-4CEF-4490-A2C9-57ED8B7BA6AB", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BC1C0A5E-4199-4A79-B7EC-4F8F8C06B146", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C48C744F-99BB-4180-BEDA-E25E638092DD", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BA1CC49C-1651-426E-BF60-14451736406F", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "0EDF906B-4618-4BDB-A560-CF7E27131A8B", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "75E4C866-7DA1-4847-9E59-130B43EC4EFD", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "60B3AF19-5C2E-4EAB-8223-4555BC1CAD3A", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "E3CAD810-16EE-42AF-880B-DC85D0BB0F04", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "FC771AC2-5F40-4AA5-AC10-8DBBB4184228", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "4F30A023-C445-460D-AD08-06A816FCA56C", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "666AC744-5846-419C-B0BD-B4EE297FD619", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2A8A6A2A-2B86-4F83-8265-35730444F4E1", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "5762787F-DF7C-4EE6-8C83-5BCA1241BBD3", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F04D450E-CDF1-43D3-9607-AC1E72B4A107", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "77881F27-0416-4F23-869D-936A5EECCF8B", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "114785D0-C2AC-43BE-B2DE-0CEC64C3A40E", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "59F8E87D-2840-4B2E-81D6-ECBC996F28C0", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "CB7CFC69-7941-42D3-ABED-F387B3DEE6DA", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B8F78C14-9E75-4213-BB9F-2618ADD3895E", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "FF5DB4E0-5BEE-41A9-8052-04448D42803C", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C756DAD9-6B5B-42DC-8A48-B29543885E20", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "630ABC4A-4A5A-46F7-B5FF-19B86C09871D", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "44CBB5F0-3D36-453B-9472-17F845BF6AAE", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + } + ], + "tableau" : [ + [ + { + "id" : "C4042C1E-A507-4077-9B04-C5C8828ACA1D", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BFA65E6C-213E-46C1-8593-322B141C5764", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BFF1AD6E-99CB-4AEA-87CC-B6DB0BA36FF6", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "79023CA3-34B0-4854-AF3F-D4F42CA09C9E", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "86EC7B2A-2169-4250-B0EF-78B8B1B407AB", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9EF3B45E-35DE-4A54-B9FD-9F5BEE621716", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "FF259395-8420-4C77-A476-F22BE8068D49", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2394B558-B85D-4556-93CA-325F8A9B5582", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "08BC1F65-F6B8-463D-8E7C-0C0ACEA7D84F", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "026499CE-0F76-4F17-B831-F7546E4C71A3", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "A8B9CEC5-F47B-4313-AE28-091B42B185C2", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B66870FE-3333-468E-B294-3135C5B3D511", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "419AE571-3B6C-4B59-9783-8B6785D18E4B", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "FB038707-3DC0-490C-920C-EA87E3A5E3A7", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "6F588E14-3553-4136-A06C-69517152D60E", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B8A62BF8-A2FA-4C42-95EF-64732CBAE0B7", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1EDEF8AE-6F0B-45AA-97A5-DDC0DD6770EE", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "4D36DBB2-929B-4336-9CC9-1BBC57481524", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "9F78F03E-9053-4867-8F34-C562326F8C8B", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BE3EBFBD-0FA2-4676-85DB-5E6D9E19DD87", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C2EF2598-1640-420A-A8EF-08F145CB480D", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "E3F9A7ED-661A-43BE-9FEA-345EF3DEA7F2", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "AEDA0811-074C-4A1E-B1E2-A1E16A864552", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "789C8886-B174-467C-9964-2D37CF50BF90", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "02FF7FEC-FB7C-4DDA-A461-CAC3960883C0", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C58E8A01-B05F-43EF-866F-098DCA0C6524", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1D75106F-9BAB-442D-A312-8ABB61AEE0B6", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BE950BFA-6CAE-4357-8AEB-99F6442688D5", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "E005EE9A-D732-435E-9AAC-9FF87B848458", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "A37696A9-52FE-42CF-B1C6-7A16AB4FB2E6", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "6149400D-F872-4ECD-A21A-DC7AF2D3751E", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C3DAB45F-0F01-4E88-9877-D540E9734F0F", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "50ED787C-8306-4F1A-88A6-6899AF5EEF9C", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "6023F836-CAA9-4335-9DB2-FA2E158F64B4", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "12E41E76-EABE-454E-839E-93B55F4906C5", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "DD45E549-8271-4AA6-9D7D-EBC00F0BF153", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "E53B9C48-73B8-435D-8EDB-76F1E85ED733", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "5FF2C900-F0D7-4120-9AB5-9C1B74EBBA10", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "4199DE4B-089E-4228-A13F-9F5A25D6DDE2", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "E23E977F-B79A-46B1-8A17-E1E948F6AE5E", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "DBB59164-670C-497A-BC1A-15F3DED3A724", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9415CA31-D229-4C36-B075-17F202B5989E", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "27DDF58F-0DCF-4721-AFC4-58284B250553", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1513B9E2-C5FA-4078-A5F5-7A63A7E33D1A", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "F0F449CA-1F7C-4346-BE62-D7F886150166", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "45C0D5CB-20F2-4C7B-8D15-67417D697089", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "36A612DA-1856-4C7E-B50D-A90F650C9F04", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "6C39EB4A-3BBF-415C-BBF2-FA6B04356638", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "48AAC4DC-8247-45AE-8705-D2B6CFF16805", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "7B2F1D1F-8F0D-4EBB-885B-888CC749EFC7", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "5BC4CF08-F819-42CC-9B99-6360EC1EC424", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "8F1DB080-BD43-433B-8E6A-2B0A31C86C2A", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "545A6ABD-48AC-481E-A4E7-C4D91FD51024", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "8E12D854-3767-4998-9011-689579FD9D14", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + } + ] + ], + "variant" : "spider", + "waste" : [ + + ], + "wasteDrawCount" : 0 + }, + "stockDrawCount" : 3, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 28b52f4..9ccd63f 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -48,6 +48,10 @@ private extension AutoFinishPlanner { return true case .yukon: return !state.tableau.joined().contains(where: { !$0.isFaceUp }) + case .spider: + // Spider banks completed runs automatically; there is never a + // foundation run left for auto-finish to play. + return false } } diff --git a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift index 9b9ebd7..ba15d1f 100644 --- a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift @@ -47,7 +47,7 @@ extension SolitaireViewModel { return Array(state.waste.suffix(count)) } - func handleStockTap() { + func handleKlondikeStockTap() { guard state.variant == .klondike else { return } clearHint() selection = nil diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index 648966f..b39bfb4 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -10,7 +10,7 @@ enum AutoMoveAdvisor { var destinations: [Destination] = [] - if selection.cards.count == 1 { + if selection.cards.count == 1, state.variant.playerBuildsFoundations { for foundationIndex in state.foundations.indices { let foundation = state.foundations[foundationIndex] if GameRules.canMoveToFoundation(card: movingCard, foundation: foundation) { @@ -59,11 +59,13 @@ enum AutoMoveAdvisor { selections.append(Selection(source: .waste, cards: [topWasteCard])) } - for foundationIndex in state.foundations.indices { - guard let topFoundationCard = state.foundations[foundationIndex].last else { continue } - selections.append( - Selection(source: .foundation(pile: foundationIndex), cards: [topFoundationCard]) - ) + if state.variant.playerBuildsFoundations { + for foundationIndex in state.foundations.indices { + guard let topFoundationCard = state.foundations[foundationIndex].last else { continue } + selections.append( + Selection(source: .foundation(pile: foundationIndex), cards: [topFoundationCard]) + ) + } } for freeCellIndex in state.freeCells.indices { @@ -121,6 +123,7 @@ enum AutoMoveAdvisor { nextState.foundations[index].append(card) case .tableau(let index): nextState.tableau[index].append(contentsOf: selection.cards) + applyVariantTableauDestinationEffects(on: &nextState, pileIndex: index) case .freeCell(let index): guard selection.cards.count == 1, let card = selection.cards.first else { return nil } nextState.freeCells[index] = card @@ -162,10 +165,9 @@ enum AutoMoveAdvisor { 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( + /// Moving an entire tableau pile to another empty column is a no-op for + /// advisor quality purposes (manual play can still do this). + static func isRedundantWholePileTransfer( selection: Selection, destinationTableauIndex: Int, in state: GameState @@ -176,11 +178,22 @@ enum AutoMoveAdvisor { state.tableau.indices.contains(destinationTableauIndex) else { return false } guard state.tableau[destinationTableauIndex].isEmpty else { return false } guard sourceIndex == 0 else { return false } + return selection.cards.count == state.tableau[sourcePile].count + } - 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 + /// The whole-pile no-op restricted to king-led stacks, for the variants + /// whose empty columns accept Kings only. + static func isRedundantWholePileKingTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + guard selection.cards.first?.rank == .king else { return false } + return isRedundantWholePileTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) } /// Flips a face-down card exposed at the top of the pile a selection left, @@ -203,6 +216,8 @@ private extension AutoMoveAdvisor { return FreeCellAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) case .yukon: return YukonAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) + case .spider: + return SpiderAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) } } @@ -230,6 +245,12 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) + case .spider: + return SpiderAutoMoveAdvisor.allowsTableauTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) } } @@ -253,6 +274,12 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) + case .spider: + return SpiderAutoMoveAdvisor.isRedundantEmptyColumnTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) } } @@ -280,6 +307,12 @@ private extension AutoMoveAdvisor { in: state, destinations: &destinations ) + case .spider: + SpiderAutoMoveAdvisor.appendAuxiliaryDestinations( + for: selection, + in: state, + destinations: &destinations + ) } } @@ -291,6 +324,19 @@ private extension AutoMoveAdvisor { FreeCellAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) case .yukon: YukonAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) + case .spider: + SpiderAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) + } + } + + /// Effects a landing triggers on the destination pile. Spider banks any + /// run the landing completed; the other variants have none. + static func applyVariantTableauDestinationEffects(on state: inout GameState, pileIndex: Int) { + switch state.variant { + case .klondike, .freecell, .yukon: + break + case .spider: + SpiderAutoMoveAdvisor.applyTableauDestinationEffects(on: &state, pileIndex: pileIndex) } } } diff --git a/ComputerSolitaire/Game/Shared/Card.swift b/ComputerSolitaire/Game/Shared/Card.swift index 4595106..73d2e30 100644 --- a/ComputerSolitaire/Game/Shared/Card.swift +++ b/ComputerSolitaire/Game/Shared/Card.swift @@ -105,6 +105,13 @@ struct Card: Identifiable, Equatable, Codable { } } +/// A card's face — suit and rank without instance identity. Deck-composition +/// checks count these; Spider's two decks carry each identity more than once. +struct CardIdentity: Hashable { + let suit: Suit + let rank: Rank +} + extension Card { var accessibilityName: String { guard isFaceUp else { return "Face-down card" } diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index ba2ae4d..4ac6c61 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -280,9 +280,13 @@ 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. + /// High score for variants without a game-mode split (FreeCell, Yukon). + /// Klondike wins record into the per-draw-mode fields above and Spider wins + /// into the per-suit-count fields below instead. var highScore: Int? + var highScoreOneSuit: Int? + var highScoreTwoSuits: Int? + var highScoreFourSuits: Int? var cleanWins: Int enum CodingKeys: String, CodingKey { @@ -295,6 +299,9 @@ struct GameStatistics: Codable, Equatable { case highScoreDrawThree case highScoreDrawOne case highScore + case highScoreOneSuit + case highScoreTwoSuits + case highScoreFourSuits case cleanWins } @@ -308,6 +315,9 @@ struct GameStatistics: Codable, Equatable { highScoreDrawThree: Int? = nil, highScoreDrawOne: Int? = nil, highScore: Int? = nil, + highScoreOneSuit: Int? = nil, + highScoreTwoSuits: Int? = nil, + highScoreFourSuits: Int? = nil, cleanWins: Int = 0 ) { self.schemaVersion = schemaVersion @@ -319,6 +329,9 @@ struct GameStatistics: Codable, Equatable { self.highScoreDrawThree = highScoreDrawThree.map { max(0, $0) } self.highScoreDrawOne = highScoreDrawOne.map { max(0, $0) } self.highScore = highScore.map { max(0, $0) } + self.highScoreOneSuit = highScoreOneSuit.map { max(0, $0) } + self.highScoreTwoSuits = highScoreTwoSuits.map { max(0, $0) } + self.highScoreFourSuits = highScoreFourSuits.map { max(0, $0) } self.cleanWins = max(0, min(cleanWins, self.gamesWon)) } @@ -347,6 +360,9 @@ struct GameStatistics: Codable, Equatable { 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) } + highScoreOneSuit = try container.decodeIfPresent(Int.self, forKey: .highScoreOneSuit).map { max(0, $0) } + highScoreTwoSuits = try container.decodeIfPresent(Int.self, forKey: .highScoreTwoSuits).map { max(0, $0) } + highScoreFourSuits = try container.decodeIfPresent(Int.self, forKey: .highScoreFourSuits).map { max(0, $0) } cleanWins = max( 0, min( @@ -381,6 +397,9 @@ struct GameStatistics: Codable, Equatable { var highScoreDrawThree: Int? var highScoreDrawOne: Int? var highScore: Int? + var highScoreOneSuit: Int? + var highScoreTwoSuits: Int? + var highScoreFourSuits: Int? for stats in statsByVariant { gamesPlayed = addingSafely(gamesPlayed, stats.gamesPlayed) @@ -413,6 +432,15 @@ struct GameStatistics: Codable, Equatable { if let candidate = stats.highScore { highScore = max(highScore ?? 0, candidate) } + if let candidate = stats.highScoreOneSuit { + highScoreOneSuit = max(highScoreOneSuit ?? 0, candidate) + } + if let candidate = stats.highScoreTwoSuits { + highScoreTwoSuits = max(highScoreTwoSuits ?? 0, candidate) + } + if let candidate = stats.highScoreFourSuits { + highScoreFourSuits = max(highScoreFourSuits ?? 0, candidate) + } } gamesWon = min(gamesWon, gamesPlayed) @@ -427,6 +455,9 @@ struct GameStatistics: Codable, Equatable { highScoreDrawThree: highScoreDrawThree, highScoreDrawOne: highScoreDrawOne, highScore: highScore, + highScoreOneSuit: highScoreOneSuit, + highScoreTwoSuits: highScoreTwoSuits, + highScoreFourSuits: highScoreFourSuits, cleanWins: cleanWins ) } @@ -436,6 +467,7 @@ struct GameStatistics: Codable, Equatable { elapsedSeconds: Int, finalScore: Int, drawCount: Int, + spiderSuitCount: SpiderSuitCount? = nil, hintsUsedInGame: Int, undosUsedInGame: Int, usedRedealInGame: Bool @@ -457,7 +489,18 @@ struct GameStatistics: Codable, Equatable { bestTimeSeconds = sanitizedElapsed } - if drawCount == DrawMode.one.rawValue { + if let spiderSuitCount { + // Spider difficulties aren't score-comparable, so each suit count + // keeps its own high score (mirroring Klondike's draw-mode split). + switch spiderSuitCount { + case .one: + highScoreOneSuit = max(highScoreOneSuit ?? 0, sanitizedScore) + case .two: + highScoreTwoSuits = max(highScoreTwoSuits ?? 0, sanitizedScore) + case .four: + highScoreFourSuits = max(highScoreFourSuits ?? 0, sanitizedScore) + } + } else if drawCount == DrawMode.one.rawValue { highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) } else if drawCount == DrawMode.three.rawValue { highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) @@ -550,28 +593,50 @@ enum GameStatisticsStore { } } -private struct CardIdentity: Hashable { - let suit: Suit - let rank: Rank -} - private extension GameState { var allCards: [Card] { stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + tableau.flatMap { $0 } } var isValidForPersistence: Bool { - guard foundations.count == 4 else { return false } + guard foundations.count == variant.foundationPileCount else { return false } guard freeCells.count == 4 else { return false } guard hasValidVariantPersistenceLayout else { return false } let allCards = allCards - guard allCards.count == 52 else { return false } - guard Set(allCards.map(\.id)).count == 52 else { return false } - guard Set(allCards.map { CardIdentity(suit: $0.suit, rank: $0.rank) }).count == 52 else { return false } + guard allCards.count == variant.deckCardCount else { return false } + guard Set(allCards.map(\.id)).count == variant.deckCardCount else { return false } + guard hasExpectedDeckComposition(allCards) else { return false } return true } + /// Every card identity must appear exactly as often as the variant's deck + /// composition prescribes: once each for the single-deck variants, and per + /// `SpiderDeck` for Spider's two suit-composed decks. + private func hasExpectedDeckComposition(_ allCards: [Card]) -> Bool { + var identityCounts: [CardIdentity: Int] = [:] + for card in allCards { + identityCounts[CardIdentity(suit: card.suit, rank: card.rank), default: 0] += 1 + } + return identityCounts == expectedIdentityCounts + } + + private var expectedIdentityCounts: [CardIdentity: Int] { + switch variant { + case .klondike, .freecell, .yukon: + var counts: [CardIdentity: Int] = [:] + for suit in Suit.allCases { + for rank in Rank.allCases { + counts[CardIdentity(suit: suit, rank: rank)] = 1 + } + } + return counts + case .spider: + guard let suitCount = spiderSuitCount else { return [:] } + return SpiderDeck.expectedIdentityCounts(suitCount: suitCount) + } + } + private var hasValidVariantPersistenceLayout: Bool { switch variant { case .klondike: @@ -580,6 +645,8 @@ private extension GameState { return FreeCellPersistenceRules.hasValidLayout(state: self) case .yukon: return YukonPersistenceRules.hasValidLayout(state: self) + case .spider: + return SpiderPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index e988042..0f1707a 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -19,6 +19,8 @@ enum GameRules { return FreeCellGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) case .yukon: return YukonGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + case .spider: + return SpiderGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) } } @@ -71,4 +73,18 @@ enum SharedGameRules { } return true } + + /// A face-up single-suit run descending one rank per step — Spider's movable + /// group, and (at thirteen cards led by a King) its completed run. + static func isDescendingSameSuitRun(_ cards: [Card]) -> Bool { + guard !cards.isEmpty else { return false } + guard cards.allSatisfy(\.isFaceUp) else { return false } + for index in 0..<(cards.count - 1) { + let upper = cards[index] + let lower = cards[index + 1] + guard upper.suit == lower.suit else { return false } + guard upper.rank.rawValue == lower.rank.rawValue + 1 else { return false } + } + return true + } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 82fac5c..18fc499 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -213,11 +213,15 @@ final class SolitaireViewModel { return true } - func newGame(variant: GameVariant? = nil, drawMode: DrawMode = .three) { + func newGame( + variant: GameVariant? = nil, + drawMode: DrawMode = .three, + spiderSuitCount: SpiderSuitCount = .two + ) { finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) clearHint() let nextVariant = variant ?? state.variant - let initialState = GameState.newGame(variant: nextVariant) + let initialState = GameState.newGame(variant: nextVariant, spiderSuitCount: spiderSuitCount) state = initialState redealState = initialState selection = nil @@ -345,6 +349,7 @@ final class SolitaireViewModel { } func handleFoundationTap(index: Int) { + guard state.variant.playerBuildsFoundations else { return } if selection != nil || state.foundations[index].last != nil { HapticManager.shared.play(.cardPickUp) } @@ -422,6 +427,7 @@ final class SolitaireViewModel { @discardableResult func startDragFromFoundation(index: Int) -> Bool { + guard state.variant.playerBuildsFoundations else { return false } guard let top = state.foundations[index].last else { return false } clearHint() selection = Selection(source: .foundation(pile: index), cards: [top]) @@ -452,6 +458,21 @@ final class SolitaireViewModel { stockDrawCount = count } + func setInitialScore(_ initialScore: Int) { + score = Scoring.clamped(initialScore) + } + + func handleStockTap() { + switch state.variant { + case .klondike: + handleKlondikeStockTap() + case .freecell, .yukon: + break + case .spider: + handleSpiderStockTap() + } + } + func setScoringDrawCount(_ count: Int) { scoringDrawCount = count } @@ -469,7 +490,9 @@ final class SolitaireViewModel { case .klondike: configureKlondikeNewGame(drawMode: drawMode) case .freecell, .yukon: - configureStocklessNewGame() + configureWastelessNewGame() + case .spider: + configureSpiderNewGame() } } @@ -478,7 +501,9 @@ final class SolitaireViewModel { case .klondike: configureKlondikeRedeal() case .freecell, .yukon: - configureStocklessRedeal() + configureWastelessRedeal() + case .spider: + configureSpiderRedeal() } } @@ -489,26 +514,26 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return sanitizeKlondikeRedealState(state, stockDrawCount: stockDrawCount) - case .freecell, .yukon: - return sanitizeStocklessRedealState(state) + case .freecell, .yukon, .spider: + return sanitizeWastelessRedealState(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() { + /// New-game configuration shared by the variants that never draw through a + /// waste: draw counts stay at the draw-three defaults so time-bonus scoring + /// has a defined basis, and no waste cards are ever fanned. + func configureWastelessNewGame() { setStockDrawCount(DrawMode.three.rawValue) setScoringDrawCount(DrawMode.three.rawValue) setWasteDrawCount(0) } - func configureStocklessRedeal() { + func configureWastelessRedeal() { setScoringDrawCount(stockDrawCount) setWasteDrawCount(0) } - func sanitizeStocklessRedealState(_ baseState: GameState) -> GameState { + func sanitizeWastelessRedealState(_ baseState: GameState) -> GameState { var sanitizedState = baseState sanitizedState.wasteDrawCount = 0 return sanitizedState @@ -521,7 +546,7 @@ final class SolitaireViewModel { card: Card ) -> Bool { switch state.variant { - case .klondike, .yukon: + case .klondike, .yukon, .spider: return handleFaceDownTableauTap( pile: pile, pileIndex: pileIndex, @@ -557,7 +582,7 @@ final class SolitaireViewModel { ) state.tableau[pileIndex][cardIndex].isFaceUp = true incrementMovesCount() - applyScore(.turnOverTableauCard) + applyTableauRevealScoreIfNeeded() SoundManager.shared.play(.cardFlipFaceUp) HapticManager.shared.play(.cardFlipFaceUp) refreshAutoFinishAvailability() @@ -573,6 +598,8 @@ final class SolitaireViewModel { return true case .freecell: return canSelectFreeCellTableauCards(cards) + case .spider: + return SharedGameRules.isDescendingSameSuitRun(cards) } } @@ -580,7 +607,7 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return scoringDrawCount - case .freecell, .yukon: + case .freecell, .yukon, .spider: return 0 } } @@ -602,6 +629,7 @@ extension SolitaireViewModel { } func selectFromFoundation(index: Int) { + guard state.variant.playerBuildsFoundations else { return } guard let top = state.foundations[index].last else { return } selection = Selection(source: .foundation(pile: index), cards: [top]) } @@ -651,6 +679,9 @@ extension SolitaireViewModel { state.tableau[index].append(contentsOf: selection.cards) movesCount += 1 applyScore(for: selection.source, destination: .tableau(index)) + if state.variant == .spider { + resolveCompletedSpiderRuns() + } applyTimeBonusIfWon() self.selection = nil SoundManager.shared.play(.cardPlaced) @@ -701,24 +732,31 @@ extension SolitaireViewModel { func flipTopCardIfNeeded(in pileIndex: Int) { switch state.variant { - case .klondike, .yukon: + case .klondike, .yukon, .spider: 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. + /// Flips a face-down card exposed at the top of a pile, 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) + applyTableauRevealScoreIfNeeded() SoundManager.shared.play(.cardFlipFaceUp) HapticManager.shared.play(.cardFlipFaceUp) } + /// Reveals score in the Klondike-family variants; Spider's classic scheme + /// scores card moves and completed runs only. + private func applyTableauRevealScoreIfNeeded() { + guard state.variant != .spider else { return } + applyScore(.turnOverTableauCard) + } + func pushHistory(undoContext: UndoAnimationContext? = nil) { history.append( GameSnapshot( @@ -742,6 +780,8 @@ extension SolitaireViewModel { break case .yukon: applyYukonMoveScore(for: source, destination: destination) + case .spider: + applySpiderMoveScore(for: source, destination: destination) } } @@ -775,6 +815,7 @@ extension SolitaireViewModel { elapsedSeconds: elapsedSeconds, finalScore: score, drawCount: statisticsDrawCountForCurrentVariant(), + spiderSuitCount: state.spiderSuitCount, hintsUsedInGame: hintRequestsInCurrentGame, undosUsedInGame: undosUsedInCurrentGame, usedRedealInGame: usedRedealInCurrentGame diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift index ad5d93f..078cc33 100644 --- a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -13,6 +13,9 @@ extension SolitaireViewModel { !freeCellCanMoveStack(cards, to: .tableau(pileIndex)) { return false } + if state.variant == .spider, !canSelectTableauCards(cards) { + return false + } selection = Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) isDragging = true return true @@ -23,6 +26,7 @@ extension SolitaireViewModel { switch destination { case .foundation(let index): + guard state.variant.playerBuildsFoundations else { return false } guard selection.cards.count == 1 else { return false } return GameRules.canMoveToFoundation( card: movingCard, diff --git a/ComputerSolitaire/Game/Shared/GameSnapshot.swift b/ComputerSolitaire/Game/Shared/GameSnapshot.swift index c6ae51a..fd8790f 100644 --- a/ComputerSolitaire/Game/Shared/GameSnapshot.swift +++ b/ComputerSolitaire/Game/Shared/GameSnapshot.swift @@ -45,6 +45,7 @@ struct UndoAnimationContext: Codable { case drawFromStock case recycleWaste case flipTableauTop + case dealTableauRow } let action: Action diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index c159edf..96efbcc 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -58,7 +58,7 @@ struct GameState: Equatable, Codable { newGame(variant: .klondike) } - static func newGame(variant: GameVariant) -> GameState { + static func newGame(variant: GameVariant, spiderSuitCount: SpiderSuitCount = .two) -> GameState { switch variant { case .klondike: return newKlondikeGame() @@ -66,6 +66,8 @@ struct GameState: Equatable, Codable { return newFreeCellGame() case .yukon: return newYukonGame() + case .spider: + return newSpiderGame(suitCount: spiderSuitCount) } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index 82a49bf..85ea93f 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -4,6 +4,7 @@ enum GameVariant: String, CaseIterable, Codable { case klondike case freecell case yukon + case spider var title: String { switch self { @@ -13,6 +14,8 @@ enum GameVariant: String, CaseIterable, Codable { return "FreeCell" case .yukon: return "Yukon" + case .spider: + return "Spider" } } @@ -24,6 +27,8 @@ enum GameVariant: String, CaseIterable, Codable { return "Strategic open layout" case .yukon: return "Move any face-up stack" + case .spider: + return "Build full suit runs" } } @@ -33,6 +38,8 @@ enum GameVariant: String, CaseIterable, Codable { return 7 case .freecell: return 8 + case .spider: + return 10 } } @@ -44,6 +51,42 @@ enum GameVariant: String, CaseIterable, Codable { return true case .freecell: return false + case .spider: + return true + } + } + + /// How many foundation piles the variant plays with. Spider banks its + /// eight completed King-to-Ace runs in foundations; the other variants + /// build one foundation per suit. + var foundationPileCount: Int { + switch self { + case .klondike, .freecell, .yukon: + return 4 + case .spider: + return 8 + } + } + + /// How many cards a deal uses. Spider plays with two decks. + var deckCardCount: Int { + switch self { + case .klondike, .freecell, .yukon: + return 52 + case .spider: + return 104 + } + } + + /// Whether the player builds foundations by moving cards onto them. + /// Spider's completed runs move to a foundation automatically, so its + /// foundations are never a drag, drop, or tap target. + var playerBuildsFoundations: Bool { + switch self { + case .klondike, .freecell, .yukon: + return true + case .spider: + return false } } } @@ -61,3 +104,22 @@ enum DrawMode: Int, CaseIterable, Codable { } } } + +/// Spider difficulty: how many distinct suits the two-deck (104-card) deal +/// is composed of. +enum SpiderSuitCount: Int, CaseIterable, Codable { + case one = 1 + case two = 2 + case four = 4 + + var title: String { + switch self { + case .one: + return "1 Suit" + case .two: + return "2 Suits" + case .four: + return "4 Suits" + } + } +} diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index 66defcc..ba8e360 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -17,6 +17,9 @@ enum HintAdvisor { if state.variant == .klondike, !state.stock.isEmpty || !state.waste.isEmpty { return true } + if state.variant == .spider, SpiderGameRules.canDealFromStock(state: state) { + return true + } for selection in AutoMoveAdvisor.candidateSelections(in: state) { // Foundation rollbacks only count as available moves where the hint // stack can actually turn one into a hint: Yukon's planner searches @@ -46,7 +49,9 @@ enum HintAdvisor { /// 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 +/// cached line ratchets the position strictly forward. Spider hints work like Yukon's +/// (its tableau moves are just as reversible until a flip, deal, or completed run), +/// with `SpiderPlanner` lines that may include stock deals. Cached lines are keyed by /// position, so as long as the player follows one (or plays ahead along it), /// subsequent hints are instant. /// @@ -57,14 +62,18 @@ enum HintAdvisor { /// 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). +/// Spider returns a stock-deal hint when its tableau holds no improving line but a +/// deal is legal — dealing is then the only way forward — and silence otherwise. 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 static let spiderSearchBudget: TimeInterval = 0.3 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] + private var spiderPlan: [String: SpiderPlanner.PlannedAction] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -80,6 +89,8 @@ final class HintPlanner { return freeCellHint(in: state) case .yukon: return yukonHint(in: state) + case .spider: + return spiderHint(in: state) } } } @@ -147,6 +158,60 @@ private extension HintPlanner { ) } + func spiderHint(in state: GameState) -> HintAdvisor.Hint? { + let key = SpiderPlanner.stateKey(for: state) + if let hint = plannedSpiderHint(for: key, in: state) { + return hint + } + + spiderPlan.removeAll() + let limits = SpiderPlanner.Limits( + deadline: Date().addingTimeInterval(Self.spiderSearchBudget) + ) + switch SpiderPlanner.bestLine(in: state, limits: limits) { + case .line(let line): + spiderPlan = SpiderPlanner.keyedActions(along: line, from: state) + return plannedSpiderHint(for: key, in: state) + + case .noProgress: + // The searched region holds no tableau progress. Unlike Yukon, + // Spider has a rescue the planner can vouch for: dealing the next + // stock row is what a strong player does with a groomed-but-stuck + // tableau. The preparation line fills any empty columns first (the + // deal is illegal over them) and is cached whole — filling costs + // score, so re-planning from an intermediate position would just + // recommend undoing it. + // + // Deliberately falls back for truncated no-progress too, not just + // exhaustive proof: a Spider midgame's improvement-free region + // usually exceeds any budget, so most stuck verdicts are truncated, + // and gating the deal on exhaustiveness measures 8 points worse at + // 2-suit in the hint probe (deals withheld are games stalled). + guard let preparation = SpiderPlanner.dealPreparationLine(in: state) else { + return nil + } + spiderPlan = SpiderPlanner.keyedActions(along: preparation, from: state) + return plannedSpiderHint(for: key, in: state) + } + } + + func plannedSpiderHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + switch spiderPlan[key] { + case .move(let selection, let destination): + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state), + AutoMoveAdvisor.legalDestinations(for: selection, in: state) + .contains(destination) else { + return nil + } + return .move(HintAdvisor.HintMove(selection: selection, destination: destination)) + case .stockDeal: + guard SpiderGameRules.canDealFromStock(state: state) else { return nil } + return .stockTap + case .none: + return nil + } + } + 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/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift index 049a08f..aca35ec 100644 --- a/ComputerSolitaire/Game/Shared/Scoring.swift +++ b/ComputerSolitaire/Game/Shared/Scoring.swift @@ -7,6 +7,8 @@ enum ScoringAction { case turnOverTableauCard case foundationToTableau case recycleWasteInDrawOne + case spiderMove + case spiderCompletedRun } enum Scoring { @@ -14,6 +16,8 @@ enum Scoring { static let timedPointsLostPerSecond = 1 static let timedMaxBonusDrawOne = 600 static let timedMaxBonusDrawThree = 900 + /// Classic Spider scoring starts every game at 500. + static let spiderInitialScore = 500 static func delta(for action: ScoringAction) -> Int { switch action { @@ -29,6 +33,10 @@ enum Scoring { return -15 case .recycleWasteInDrawOne: return -100 + case .spiderMove: + return -1 + case .spiderCompletedRun: + return 100 } } diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 634ea51..128ec57 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -98,11 +98,31 @@ private extension TapMovePolicy { // 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 + case .spider: + // Unreachable: Spider foundations are never player destinations. + tier = 100 } return Priority(tier: tier, buildLength: 0, pileOrder: -index) case .tableau(let index): let pile = state.tableau[index] + if state.variant == .spider { + // Only suited runs can complete in Spider, so a same-suit + // landing beats an off-suit one, which beats an empty pile. + let tier: Int + if pile.isEmpty { + tier = 40 + } else if pile.last?.suit == selection.cards.first?.suit { + tier = 90 + } else { + tier = 80 + } + return Priority( + tier: tier, + buildLength: topSameSuitRunLength(of: pile) + selection.cards.count, + pileOrder: -index + ) + } let tier = pile.isEmpty ? 40 : 80 return Priority( tier: tier, @@ -132,4 +152,22 @@ private extension TapMovePolicy { } return length } + + /// Length of the descending same-suit run ending at the pile's top card. + static func topSameSuitRunLength(of pile: [Card]) -> Int { + guard var index = pile.indices.last else { return 0 } + var length = 1 + while index > 0 { + let upper = pile[index - 1] + let lower = pile[index] + guard upper.isFaceUp, + upper.suit == lower.suit, + lower.rank.rawValue == upper.rank.rawValue - 1 else { + break + } + length += 1 + index -= 1 + } + return length + } } diff --git a/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift b/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift new file mode 100644 index 0000000..71109ea --- /dev/null +++ b/ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift @@ -0,0 +1,48 @@ +import Foundation + +enum SpiderAutoMoveAdvisor { + static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + // Spider's defining rule: a group moves only as a face-up + // single-suit descending run. + SharedGameRules.isDescendingSameSuitRun(cards) + } + + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + true + } + + static func isRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + // Spider's empty piles accept any leading rank, so relocating a whole + // pile is a no-op no matter what leads it. + AutoMoveAdvisor.isRedundantWholePileTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + // Spider has no auxiliary destination type; completed runs bank + // themselves. + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + AutoMoveAdvisor.flipExposedFaceDownTop(on: &state, pileIndex: pileIndex) + } + + static func applyTableauDestinationEffects(on state: inout GameState, pileIndex: Int) { + SpiderGameRules.resolveCompletedRuns(in: &state) + } +} diff --git a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift new file mode 100644 index 0000000..bdaa30b --- /dev/null +++ b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift @@ -0,0 +1,24 @@ +import Foundation + +enum SpiderPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == 10 else { return false } + // The stock only ever shrinks by full ten-card rows. + guard state.stock.count.isMultiple(of: 10), state.stock.count <= 50 else { return false } + guard state.stock.allSatisfy({ !$0.isFaceUp }) else { return false } + guard state.waste.isEmpty else { return false } + // Spider 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 } + guard state.wasteDrawCount == 0 else { return false } + return state.foundations.allSatisfy(isValidFoundationPile) + } + + /// A Spider foundation is empty until a run completes, then holds exactly + /// one banked run: thirteen same-suit cards, Ace at the bottom. + private static func isValidFoundationPile(_ pile: [Card]) -> Bool { + guard !pile.isEmpty else { return true } + guard pile.count == Rank.allCases.count else { return false } + return SharedGameRules.isDescendingSameSuitRun(Array(pile.reversed())) + } +} diff --git a/ComputerSolitaire/Game/Spider/GameRulesSpider.swift b/ComputerSolitaire/Game/Spider/GameRulesSpider.swift new file mode 100644 index 0000000..6dfe4b9 --- /dev/null +++ b/ComputerSolitaire/Game/Spider/GameRulesSpider.swift @@ -0,0 +1,67 @@ +import Foundation + +enum SpiderGameRules { + /// Spider's landing rule: an empty pile takes any card, and a face-up top + /// takes a card one rank lower regardless of suit. + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + guard let top = destinationPile.last else { return true } + return top.isFaceUp && card.rank.rawValue == top.rank.rawValue - 1 + } + + /// Dealing a stock row requires a card in every pile. + static func canDealFromStock(state: GameState) -> Bool { + !state.stock.isEmpty && state.tableau.allSatisfy { !$0.isEmpty } + } + + /// Index where a complete face-up King-to-Ace same-suit run starts at the + /// top of the pile, or `nil` when the pile holds none. + static func completedRunStartIndex(in pile: [Card]) -> Int? { + let runLength = Rank.allCases.count + guard pile.count >= runLength else { return nil } + let startIndex = pile.count - runLength + let run = Array(pile[startIndex...]) + guard run.first?.rank == .king else { return nil } + guard SharedGameRules.isDescendingSameSuitRun(run) else { return nil } + return startIndex + } + + /// Banks every complete run to the first empty foundation (Ace at the + /// bottom, King on top), flipping the tops the removals expose, until no + /// complete run remains: a removal or a dealt card can complete another. + /// Returns how many runs were banked. + @discardableResult + static func resolveCompletedRuns(in state: inout GameState) -> Int { + var completedRunCount = 0 + var didRemoveRun = true + while didRemoveRun { + didRemoveRun = false + for pileIndex in state.tableau.indices { + guard let startIndex = completedRunStartIndex(in: state.tableau[pileIndex]), + let foundationIndex = state.foundations.firstIndex(where: \.isEmpty) else { + continue + } + let run = Array(state.tableau[pileIndex][startIndex...]) + state.tableau[pileIndex].removeSubrange(startIndex...) + state.foundations[foundationIndex] = run.reversed() + AutoMoveAdvisor.flipExposedFaceDownTop(on: &state, pileIndex: pileIndex) + completedRunCount += 1 + didRemoveRun = true + } + } + return completedRunCount + } + + /// Deals one face-up stock card onto each pile, left to right, then banks + /// any runs the deal completed. Returns how many runs were banked, or + /// `nil` when dealing is illegal. Shared verbatim by the session, the + /// planner, and the hint probe so simulated deals match real ones. + static func dealStockRow(in state: inout GameState) -> Int? { + guard canDealFromStock(state: state) else { return nil } + for pileIndex in state.tableau.indices { + var card = state.stock.removeLast() + card.isFaceUp = true + state.tableau[pileIndex].append(card) + } + return resolveCompletedRuns(in: &state) + } +} diff --git a/ComputerSolitaire/Game/Spider/GameSessionSpider.swift b/ComputerSolitaire/Game/Spider/GameSessionSpider.swift new file mode 100644 index 0000000..a322317 --- /dev/null +++ b/ComputerSolitaire/Game/Spider/GameSessionSpider.swift @@ -0,0 +1,69 @@ +import Foundation + +extension SolitaireViewModel { + func configureSpiderNewGame() { + configureWastelessNewGame() + setInitialScore(Scoring.spiderInitialScore) + } + + func configureSpiderRedeal() { + configureWastelessRedeal() + setInitialScore(Scoring.spiderInitialScore) + } + + func handleSpiderStockTap() { + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + dealSpiderStockRow() + } + + /// Deals the next stock row: one face-up card onto every pile, banking any + /// runs the deal completes. Blocked while a pile is empty, with the + /// standard invalid feedback so the tap explains itself. + func dealSpiderStockRow() { + guard !state.stock.isEmpty else { return } + guard SpiderGameRules.canDealFromStock(state: state) else { + SoundManager.shared.play(.invalidDrop) + HapticManager.shared.play(.invalidDrop) + return + } + let dealtCardIDs = state.stock.suffix(state.tableau.count).map(\.id) + pushHistory( + undoContext: UndoAnimationContext( + action: .dealTableauRow, + cardIDs: dealtCardIDs + ) + ) + let completedRunCount = SpiderGameRules.dealStockRow(in: &state) ?? 0 + incrementMovesCount() + applyScore(.spiderMove) + applyCompletedSpiderRunEffects(count: completedRunCount) + SoundManager.shared.play(.cardDrawFromStock) + HapticManager.shared.play(.stockDraw) + applyTimeBonusIfWon() + selection = nil + refreshAutoFinishAvailability() + } + + func applySpiderMoveScore(for source: Selection.Source, destination: Destination) { + // Classic Spider scoring: every card move costs one point. + applyScore(.spiderMove) + } + + /// Banks completed runs after a tableau landing, scoring each. + func resolveCompletedSpiderRuns() { + let completedRunCount = SpiderGameRules.resolveCompletedRuns(in: &state) + applyCompletedSpiderRunEffects(count: completedRunCount) + } + + private func applyCompletedSpiderRunEffects(count: Int) { + guard count > 0 else { return } + for _ in 0.. [Suit] { + switch suitCount { + case .one: + return [.spades] + case .two: + return [.spades, .hearts] + case .four: + return Suit.allCases + } + } + + static func deck(suitCount: SpiderSuitCount) -> [Card] { + var deck: [Card] = [] + for suit in suits(for: suitCount) { + for _ in 0.. [CardIdentity: Int] { + var counts: [CardIdentity: Int] = [:] + for suit in suits(for: suitCount) { + for rank in Rank.allCases { + counts[CardIdentity(suit: suit, rank: rank)] = copiesPerSuit(for: suitCount) + } + } + return counts + } + + private static func copiesPerSuit(for suitCount: SpiderSuitCount) -> Int { + GameVariant.spider.deckCardCount / (suits(for: suitCount).count * Rank.allCases.count) + } +} + +extension GameState { + static func newSpiderGame(suitCount: SpiderSuitCount) -> GameState { + var deck = SpiderDeck.deck(suitCount: suitCount).shuffled() + var tableau = Array(repeating: [Card](), count: 10) + + for pileIndex in 0..<10 { + let cardCount = pileIndex < 4 ? 6 : 5 + for cardIndex in 0.. HintAdvisor.Hint? { + guard case .line(let actions) = bestLine(in: state, limits: limits), + let action = actions.first else { + return nil + } + switch action { + case .move(let selection, let destination): + return .move(HintAdvisor.HintMove(selection: selection, destination: destination)) + case .stockDeal: + return .stockTap + } + } + + /// 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(256) + 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)))) + } + // Within one game the stock only ever shrinks by fixed ten-card rows, + // so its count identifies its exact contents. + key.append("#\(state.stock.count)") + 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 action to play there, so consecutive + /// hints are instant while the player follows (or plays ahead along) the line. + static func keyedActions( + along line: [PlannedAction], + from state: GameState + ) -> [String: PlannedAction] { + var keyed: [String: PlannedAction] = [:] + var current = state + for action in line { + keyed[stateKey(for: current)] = action + guard let next = apply(action, to: current) else { break } + current = next + } + return keyed + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .spider else { return .noProgress(searchWasExhaustive: false) } + return search(in: state, limits: limits) + } + + /// The way forward when no improving line exists but stock remains is to + /// deal. Dealing first requires filling every empty column, and filling + /// always *costs* score (it spends an open column), so no improving line + /// can contain it — worse, from any intermediate filled position, moving + /// the filler back out "improves" again, so planning move-by-move would + /// cycle. The whole preparation — the best one-ply fill for each empty + /// column, then the deal — is therefore built as one line for `HintPlanner` + /// to cache and follow without re-planning in between. Returns nil when + /// the stock is out or an empty column cannot be filled (the position is + /// genuinely finished). + static func dealPreparationLine(in state: GameState) -> [PlannedAction]? { + guard state.variant == .spider, !state.stock.isEmpty else { return nil } + var current = state + var line: [PlannedAction] = [] + var fills = 0 + while !SpiderGameRules.canDealFromStock(state: current) { + guard fills < current.tableau.count, + let fill = bestFill(in: current), + let next = apply(fill, to: current) else { + return nil + } + line.append(fill) + current = next + fills += 1 + } + line.append(.stockDeal) + return line + } +} + +// MARK: - Search internals + +private extension SpiderPlanner { + static func search(in state: GameState, limits: Limits) -> SearchOutcome { + let rootScore = score(state) + var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] + 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 run is a solid hint; once one + // is in hand, cap how long we keep hunting for something better. The + // floor is lower than Yukon's 16384 because Spider's wider branching + // makes each expansion several times as expensive. + if let best, best.score - rootScore >= 20, expansions >= 8_192 { + break + } + + for action in actions(from: node.state) { + guard let nextState = apply(action, to: node.state) else { continue } + guard visited.insert(stateHash(nextState)).inserted else { continue } + + let nextScore = score(nextState) + nodes.append( + Node( + state: nextState, + parent: nodeIndex, + action: action, + depth: node.depth + 1, + score: nextScore + ) + ) + order += 1 + // Best-first on score, shallow bias so equal outcomes prefer short lines. + heap.push( + HeapEntry( + priority: nextScore * 4 - (node.depth + 1), + order: order, + index: nodes.count - 1 + ) + ) + } + } + + guard let best, let actions = line(to: best.index, nodes: nodes) else { + return .noProgress(searchWasExhaustive: !wasTruncated) + } + return .line(actions) + } + + struct Node { + let state: GameState + let parent: Int + let action: PlannedAction? + 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 descendingPairs = 0 + var breaks = 0 + var suitedRunBonus = 0 + for pile in state.tableau { + if pile.isEmpty { emptyPiles += 1 } + var suitedRunLength = 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.rank.rawValue == card.rank.rawValue - 1 { + descendingPairs += 1 + if upper.suit == card.suit { + suitedRunLength += 1 + continue + } + } else { + // Only deals create these junctions (every landing + // is one-higher or an empty pile); each one blocks + // its pile until the junk above moves off. + breaks += 1 + } + } + } else { + hiddenCount += 1 + } + // The suited run ends here (or the pile did); credit it. + suitedRunBonus += (suitedRunLength - 1) * (suitedRunLength - 1) + suitedRunLength = 1 + } + } + let bankedCards = state.foundations.reduce(0) { $0 + $1.count } + // Between reveals, progress in Spider is ordering: reward any in-order + // pair (it survives deals and keeps piles playable), reward suited runs + // quadratically in their length — only thirteen-long suited runs bank, + // and a linear pair count would never prefer consolidating two short + // runs into one long one — and prize open columns well above Yukon's + // rate (they are Spider's maneuvering space and must be filled before a + // deal) while keeping them below one reveal so the search digs rather + // than hoards. No stock term: rewarding deals would make the planner + // deal eagerly, the classic Spider blunder. + return bankedCards * 20 + - hiddenCount * 25 + + emptyPiles * 10 + + descendingPairs + + suitedRunBonus + - breaks * 3 + } + + static func actions(from state: GameState) -> [PlannedAction] { + let firstEmptyColumn = state.tableau.firstIndex(where: \.isEmpty) + var actions: [PlannedAction] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + 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 + } + if isPointlessSameSuitUnbind(selection, destination: destination, in: state) { + continue + } + actions.append(.move(selection: selection, destination: destination)) + } + } + if SpiderGameRules.canDealFromStock(state: state) { + actions.append(.stockDeal) + } + return actions + } + + /// The legal move into the first empty column whose resulting position + /// scores best; ties keep the first candidate for determinism. + static func bestFill(in state: GameState) -> PlannedAction? { + guard let emptyIndex = state.tableau.firstIndex(where: \.isEmpty) else { return nil } + var best: (action: PlannedAction, score: Int)? + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + let destination = Destination.tableau(emptyIndex) + guard AutoMoveAdvisor.legalDestinations(for: selection, in: state) + .contains(destination) else { + continue + } + let action = PlannedAction.move(selection: selection, destination: destination) + guard let next = apply(action, to: state) else { continue } + let nextScore = score(next) + if best.map({ nextScore > $0.score }) ?? true { + best = (action, nextScore) + } + } + return best?.action + } + + /// Pulling a run off a suited join to re-host it on another top of the same + /// rank can never gain anything: nothing is revealed (the join's upper card + /// still covers the pile) and any dig the re-host enables is available in + /// one move by taking the joined run along — a suited join is itself + /// movable. Empty-column drops stay searchable; parking a run there is how + /// the planner frees a join's upper card when no landing rank exists. + static func isPointlessSameSuitUnbind( + _ selection: Selection, + destination: Destination, + in state: GameState + ) -> Bool { + guard case .tableau(let sourcePile, let sourceIndex) = selection.source, + sourceIndex > 0 else { + return false + } + guard case .tableau(let destinationIndex) = destination, + let destinationTop = state.tableau[destinationIndex].last else { + return false + } + let parent = state.tableau[sourcePile][sourceIndex - 1] + guard parent.isFaceUp, let movingCard = selection.cards.first else { return false } + guard parent.suit == movingCard.suit, + parent.rank.rawValue == movingCard.rank.rawValue + 1 else { + return false + } + return destinationTop.rank == parent.rank + } + + /// Applies an action without re-validating legality: the planner only feeds + /// in actions it just generated, and revalidating each one there dominates + /// search cost. Mirrors the session's move effects, including banking any + /// completed run. + static func apply(_ action: PlannedAction, to state: GameState) -> GameState? { + var nextState = state + switch action { + case .move(let selection, let destination): + guard case .tableau(let pile, let index) = selection.source else { return nil } + 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)) + } + mix(UInt8(state.stock.count)) + for suit in Suit.allCases { + mix(0xFE) + let bankedRuns = state.foundations.count { $0.first?.suit == suit } + mix(UInt8(bankedRuns)) + } + 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]) -> [PlannedAction]? { + var actions: [PlannedAction] = [] + var cursor = index + while cursor >= 0, nodes[cursor].parent >= 0 { + if let action = nodes[cursor].action { + actions.append(action) + } + cursor = nodes[cursor].parent + } + guard !actions.isEmpty else { return nil } + return actions.reversed() + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index afdc60a..3e92d87 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -241,6 +241,20 @@ struct RulesAndScoringView: View { definition: "Any face-up card together with every card stacked on top of it, moved as one, even out of order." ) ] + case .spider: + return [ + TermRow(term: "Tableau", definition: "The ten play piles where you build down, regardless of suit."), + TermRow( + term: "Run", + definition: "Face-up cards of one suit in descending order; only runs move together." + ), + TermRow( + term: "Completed run", + definition: "A full King-to-Ace run of one suit. It leaves the tableau automatically; eight complete the game." + ), + TermRow(term: "Stock", definition: "The face-down pile that deals one card onto every tableau pile at once."), + TermRow(term: "Suits", definition: "The difficulty: 1, 2, or 4 suits composing the two decks (104 cards).") + ] } } @@ -274,6 +288,17 @@ struct RulesAndScoringView: View { "Face-down cards turn face up when they become the top of a pile.", "You win by moving all 52 cards to foundations." ] + case .spider: + return [ + "Two decks (104 cards) are dealt into ten tableau piles: six cards in each of the first four, five in the rest, with only the top card face up. The remaining 50 cards form the stock.", + "A card can move onto any card one rank higher, regardless of suit. Nothing can be placed on an Ace.", + "Several cards move together only as a face-up run of one suit in descending order.", + "Any card or movable run can fill an empty pile.", + "Tapping the stock deals one face-up card onto every pile. Dealing is not allowed while any pile is empty.", + "A completed King-to-Ace run of one suit is removed from the tableau automatically.", + "Face-down cards turn face up when they become the top of a pile.", + "You win by completing all eight runs." + ] } } @@ -301,6 +326,21 @@ struct RulesAndScoringView: View { note: "Reduced by elapsed time." ) ] + case .spider: + return [ + ScoringRow( + move: "Start of game", + points: Scoring.spiderInitialScore, + note: "Classic Spider scoring starts every game with this balance." + ), + ScoringRow(move: "Any move or stock deal", points: Scoring.delta(for: .spiderMove), note: nil), + ScoringRow(move: "Complete a run", points: Scoring.delta(for: .spiderCompletedRun), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) + ] } } } diff --git a/ComputerSolitaire/Views/SettingsView.swift b/ComputerSolitaire/Views/SettingsView.swift index 064893a..62f0b2d 100644 --- a/ComputerSolitaire/Views/SettingsView.swift +++ b/ComputerSolitaire/Views/SettingsView.swift @@ -48,6 +48,7 @@ enum SettingsKey { static let cardTiltEnabled = "settings.cardTiltEnabled" static let gameVariant = "settings.gameVariant" static let drawMode = "settings.drawMode" + static let spiderSuitCount = "settings.spiderSuitCount" static let tableBackgroundColor = "settings.tableBackgroundColor" static let feltEffectEnabled = "settings.feltEffectEnabled" static let soundEffectsEnabled = "settings.soundEffectsEnabled" @@ -62,6 +63,7 @@ struct SettingsView: View { @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue + @AppStorage(SettingsKey.spiderSuitCount) private var spiderSuitCountRawValue = SpiderSuitCount.two.rawValue @AppStorage(SettingsKey.tableBackgroundColor) private var tableBackgroundColorRawValue = TableBackgroundColor.defaultValue.rawValue @AppStorage(SettingsKey.feltEffectEnabled) private var isFeltEffectEnabled = true @@ -127,6 +129,10 @@ struct SettingsView: View { guard oldValue != newValue else { return } HapticManager.shared.play(.settingsSelection) } + .onChange(of: spiderSuitCountRawValue) { oldValue, newValue in + guard oldValue != newValue else { return } + HapticManager.shared.play(.settingsSelection) + } .onChange(of: gameVariantRawValue) { oldValue, newValue in guard oldValue != newValue else { return } HapticManager.shared.play(.settingsSelection) @@ -242,6 +248,16 @@ struct SettingsView: View { .labelsHidden() .pickerStyle(.segmented) } + + if gameVariantRawValue == GameVariant.spider.rawValue { + Picker("Suits", selection: $spiderSuitCountRawValue) { + ForEach(SpiderSuitCount.allCases, id: \.rawValue) { suitCount in + Text(suitCount.title).tag(suitCount.rawValue) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } } header: { Text("Game Type") } @@ -327,6 +343,8 @@ struct SettingsView: View { VStack(spacing: 3) { Text(variant.title) .font(.subheadline.weight(.bold)) + .lineLimit(1) + .minimumScaleFactor(0.75) Text(variant.subtitle) .font(.caption2) diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 02f3911..123bf4f 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -357,6 +357,18 @@ struct TopRowView: View { hintWiggleToken: hintWiggleToken, dragGesture: dragGesture ) + case .spider: + SpiderTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + isStockHinted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintWiggleToken: hintWiggleToken + ) } } } @@ -594,6 +606,8 @@ struct TableauPileView: View { let isAccessibilityElement = (isValidRunOrigin || isExposedFaceDownCard) && !isDragged && !isHidden + // Yukon groups need not be ordered; every other multi-card + // pickup is a run. let multiCardNoun = viewModel.state.variant == .yukon ? "group" : "run" let accessibilityHint = isExposedFaceDownCard ? "Flip card" diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 7801146..2cdec8f 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -132,6 +132,7 @@ struct ContentView: View { @AppStorage(SettingsKey.cardTiltEnabled) private var isCardTiltEnabled = true @AppStorage(SettingsKey.gameVariant) private var gameVariantRawValue = GameVariant.klondike.rawValue @AppStorage(SettingsKey.drawMode) private var drawModeRawValue = DrawMode.three.rawValue + @AppStorage(SettingsKey.spiderSuitCount) private var spiderSuitCountRawValue = SpiderSuitCount.two.rawValue @AppStorage(SettingsKey.showHintButton) private var isHintButtonVisible = true @AppStorage(SettingsKey.cardStyle) private var cardStyleRawValue = CardStyle.defaultValue.rawValue @@ -143,6 +144,10 @@ struct ContentView: View { DrawMode(rawValue: drawModeRawValue) ?? .three } + private var spiderSuitCount: SpiderSuitCount { + SpiderSuitCount(rawValue: spiderSuitCountRawValue) ?? .two + } + private enum TimeScoringPauseReason: Hashable { case lifecycle case menuPresentation @@ -344,7 +349,7 @@ struct ContentView: View { stopAutoFinish() winCelebration.reset(to: .idle) isScreenshotSession = false - viewModel.newGame(variant: variant, drawMode: drawMode) + viewModel.newGame(variant: variant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) persistGameNow() } .onChange(of: drawModeRawValue) { (_, newValue: Int) in @@ -353,6 +358,18 @@ struct ContentView: View { viewModel.updateDrawMode(mode) scheduleAutosave() } + .onChange(of: spiderSuitCountRawValue) { _, newValue in + guard hasLoadedGame, !isHydratingGame else { return } + // Unlike a draw-mode change, a suit-count change recomposes the + // deck, so it always starts a new game (as variant changes do). + guard viewModel.gameVariant == .spider else { return } + let suitCount = SpiderSuitCount(rawValue: newValue) ?? .two + stopAutoFinish() + winCelebration.reset(to: .idle) + isScreenshotSession = false + viewModel.newGame(variant: .spider, drawMode: drawMode, spiderSuitCount: suitCount) + persistGameNow() + } .onChange(of: isAnyMenuPresented) { _, _ in updateMenuPresentationPauseState() } @@ -757,7 +774,7 @@ struct ContentView: View { winCelebration.reset(to: .idle) isScreenshotSession = false let selectedVariant = variant ?? gameVariant - viewModel.newGame(variant: selectedVariant, drawMode: drawMode) + viewModel.newGame(variant: selectedVariant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) persistGameNow() } @@ -1364,9 +1381,13 @@ struct ContentView: View { if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { drawModeRawValue = viewModel.stockDrawCount } + if let restoredSuitCount = viewModel.state.spiderSuitCount, + spiderSuitCountRawValue != restoredSuitCount.rawValue { + spiderSuitCountRawValue = restoredSuitCount.rawValue + } } else { winCelebration.reset(to: .idle) - viewModel.newGame(variant: gameVariant, drawMode: drawMode) + viewModel.newGame(variant: gameVariant, drawMode: drawMode, spiderSuitCount: spiderSuitCount) persistGameNow() } winCelebration.syncForLoadedGame( @@ -1460,6 +1481,10 @@ struct ContentView: View { if viewModel.supportsDrawMode, drawModeRawValue != viewModel.stockDrawCount { drawModeRawValue = viewModel.stockDrawCount } + if let restoredSuitCount = viewModel.state.spiderSuitCount, + spiderSuitCountRawValue != restoredSuitCount.rawValue { + spiderSuitCountRawValue = restoredSuitCount.rawValue + } return true #else return false diff --git a/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift b/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift new file mode 100644 index 0000000..80bc23b --- /dev/null +++ b/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import Observation + +/// One of Spider's eight banked-run piles. Runs arrive here automatically, so +/// unlike `FoundationView` this pile is never a tap, drag, or drop target; it +/// still publishes its frames so the win cascade can launch cards from it. +struct SpiderCompletedRunPileView: View { + @Bindable var viewModel: SolitaireViewModel + let index: Int + let cardSize: CGSize + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + + var body: some View { + let pile = viewModel.state.foundations[index] + let visibleDepth = min(pile.count, 4) + let startIndex = pile.count - visibleDepth + ZStack { + PilePlaceholderView(cardSize: cardSize) + ForEach(Array(pile.enumerated().dropFirst(startIndex)), id: \.element.id) { cardIndex, card in + let isTopCard = cardIndex == pile.count - 1 + let cardView = CardView( + card: card, + isSelected: false, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: nil, + isAccessibilityElement: false + ) + .opacity(hiddenCardIDs.contains(card.id) ? 0 : 1) + .allowsHitTesting(false) + + if isTopCard { + cardView + .cardFramePreference(card.id) + } else { + cardView + } + } + } + .background( + GeometryReader { proxy in + let boardFrame = proxy.frame(in: .named("board")) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .foundation(index): DropTargetGeometry( + snapFrame: boardFrame, + hitFrame: .zero + ) + ] + ) + } + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Completed run \(index + 1)") + .accessibilityValue(accessibilityValue) + } + + private var accessibilityValue: String { + guard let topCard = viewModel.state.foundations[index].last else { return "Empty" } + return "Full \(topCard.suit.accessibilityName) run" + } +} diff --git a/ComputerSolitaire/Views/Spider/SpiderStockView.swift b/ComputerSolitaire/Views/Spider/SpiderStockView.swift new file mode 100644 index 0000000..09907bf --- /dev/null +++ b/ComputerSolitaire/Views/Spider/SpiderStockView.swift @@ -0,0 +1,53 @@ +import SwiftUI +import Observation + +struct SpiderStockView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let isHintTargeted: Bool + let hintHighlightOpacity: Double + let hintWiggleToken: UUID + + var body: some View { + Button { + viewModel.handleStockTap() + } label: { + ZStack { + PilePlaceholderView(cardSize: cardSize) + .allowsHitTesting(false) + if !viewModel.state.stock.isEmpty { + CardBackView(cardSize: cardSize) + Text("\(viewModel.state.stock.count)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) + } + + DropHighlightView( + cardSize: cardSize, + isTargeted: false, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + .allowsHitTesting(false) + } + .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + } + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(viewModel.state.stock.isEmpty) + .accessibilityLabel("Stock") + .accessibilityValue(stockAccessibilityValue) + } + + private var stockAccessibilityValue: String { + guard !viewModel.state.stock.isEmpty else { return "Empty" } + return "\(viewModel.state.stock.count) cards. Deals one card to each pile" + } +} diff --git a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift new file mode 100644 index 0000000..03365c8 --- /dev/null +++ b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Observation + +struct SpiderTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let isStockHinted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintWiggleToken: UUID + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + // Stock on the left like Klondike's, one clear column, then the + // eight banked-run piles aligned over tableau columns 3-10. + SpiderStockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken + ) + .frame(width: cardSize.width, alignment: .leading) + + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + + ForEach(0..<8, id: \.self) { index in + SpiderCompletedRunPileView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs + ) + .frame(width: cardSize.width, alignment: .leading) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 386da91..2c7f44c 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -23,6 +23,7 @@ struct StatisticsView: View { case klondike case freecell case yukon + case spider case all var id: String { rawValue } @@ -35,6 +36,8 @@ struct StatisticsView: View { self = .freecell case .yukon: self = .yukon + case .spider: + self = .spider } } @@ -47,6 +50,8 @@ struct StatisticsView: View { return .freecell case .yukon: return .yukon + case .spider: + return .spider case .all: return nil } @@ -54,7 +59,7 @@ struct StatisticsView: View { var title: String { switch self { - case .klondike, .freecell, .yukon: + case .klondike, .freecell, .yukon, .spider: return variant?.title ?? "" case .all: return "All" @@ -253,8 +258,9 @@ 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. + /// Klondike splits its high score by draw mode and Spider by suit count + /// (scores across game modes aren't comparable); the other variants keep a + /// single high score. private var highScoreRowsForSelectedScope: [HighScoreRow] { switch selectedScope { case .klondike: @@ -264,6 +270,12 @@ struct StatisticsView: View { ] case .freecell, .yukon: return [HighScoreRow(label: "High Score", score: stats.highScore)] + case .spider: + return [ + HighScoreRow(label: "High Score (1 Suit)", score: stats.highScoreOneSuit), + HighScoreRow(label: "High Score (2 Suits)", score: stats.highScoreTwoSuits), + HighScoreRow(label: "High Score (4 Suits)", score: stats.highScoreFourSuits) + ] case .all: return [] } @@ -409,6 +421,8 @@ struct StatisticsView: View { return "Reset FreeCell statistics?" case .yukon: return "Reset Yukon statistics?" + case .spider: + return "Reset Spider statistics?" case .all: return "Reset all statistics?" } @@ -422,6 +436,8 @@ struct StatisticsView: View { return "Reset FreeCell Statistics" case .yukon: return "Reset Yukon Statistics" + case .spider: + return "Reset Spider Statistics" case .all: return "Reset All Statistics" } @@ -435,8 +451,10 @@ struct StatisticsView: View { 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 .spider: + return "This will reset only Spider games, times, win rates, and high scores." case .all: - return "This will reset Klondike, FreeCell, and Yukon statistics." + return "This will reset Klondike, FreeCell, Yukon, and Spider statistics." } } diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index 2684d0a..b59e7f5 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.configureStocklessNewGame() + viewModel.configureWastelessNewGame() let savedAt = DateFixtures.reference let payload = SavedGamePayload( @@ -174,7 +174,7 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { let viewModel = SolitaireViewModel() viewModel.state = GameStateFixtures.seededYukonDeal(seed: seed) - viewModel.configureStocklessNewGame() + viewModel.configureWastelessNewGame() let savedAt = DateFixtures.reference let payload = SavedGamePayload( @@ -204,6 +204,56 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("Yukon fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Spider board is a fresh 2-suit deal — ten piles with a single + /// face-up top each and a full stock. Seeds are scanned for the most + /// photogenic spread across the ten tops (the cards the eye lands on). + func testGenerateSpiderFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededSpiderDeal(seed: seed, suitCount: .two) + let score = spiderDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededSpiderDeal(seed: seed, suitCount: .two) + viewModel.configureSpiderNewGame() + + 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, .spider, "Fixture did not restore as Spider") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("spider.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Spider fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -244,6 +294,21 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Spider deal by its ten face-up tops: rank variety, + /// red/black balance, both composed suits, a few face cards, and an ace + /// on a top read well. + private func spiderDealScore(of deal: GameState) -> Int { + let visible = deal.tableau.compactMap { $0.last } + 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 == 2 ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + score += visible.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/Spider/SpiderPersistenceTests.swift b/ComputerSolitaireTests/Spider/SpiderPersistenceTests.swift new file mode 100644 index 0000000..344678a --- /dev/null +++ b/ComputerSolitaireTests/Spider/SpiderPersistenceTests.swift @@ -0,0 +1,194 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SpiderPersistenceTests: XCTestCase { + private func payload(for state: GameState) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + stockDrawCount: DrawMode.three.rawValue, + history: [] + ) + } + + // MARK: - Valid states + + func testFreshDealsRoundTripForEverySuitCount() throws { + for suitCount in SpiderSuitCount.allCases { + let state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: suitCount) + let sanitized = try XCTUnwrap( + payload(for: state).sanitizedForRestore(), + "\(suitCount): a fresh deal must pass the validity gate" + ) + XCTAssertEqual(sanitized.state, state) + + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: sanitized)) + XCTAssertEqual(viewModel.gameVariant, .spider) + XCTAssertEqual(viewModel.state.spiderSuitCount, suitCount) + } + } + + func testMidGameStateWithBankedRunIsValid() { + // Bank one complete spade run out of a 1-suit deal by hand: the board + // keeps all 104 cards, so the multiset gate still holds. + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .one) + var bankedRun: [Card] = [] + for rank in Rank.allCases { + let location = firstLocation(of: rank, in: state.tableau)! + var card = state.tableau[location.pile].remove(at: location.index) + card.isFaceUp = true + bankedRun.append(card) + } + state.foundations[0] = bankedRun + for pileIndex in state.tableau.indices { + if let topIndex = state.tableau[pileIndex].indices.last { + state.tableau[pileIndex][topIndex].isFaceUp = true + } + } + // Removing scattered cards leaves the stock untouched, so the layout + // rules (stock in ten-card rows, face-down) still hold. + XCTAssertNotNil(payload(for: state).sanitizedForRestore()) + } + + // MARK: - Rejected states + + func testRejectsWrongFoundationCount() { + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + state.foundations = Array(repeating: [], count: 4) + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsSingleDeckCardCount() { + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + state.stock.removeLast(50) + state.tableau[0].removeLast(2) + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsDuplicateCardIDs() { + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + state.tableau[0][0] = state.tableau[1][0] + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsWrongIdentityMultiset() { + // Swap one spade for an extra copy of a heart: still 104 unique cards, + // but no Spider deck composes this way. + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + let spadeLocation = firstLocation(where: { $0.suit == .spades }, in: state.tableau)! + let victim = state.tableau[spadeLocation.pile][spadeLocation.index] + state.tableau[spadeLocation.pile][spadeLocation.index] = Card( + suit: .hearts, + rank: victim.rank, + isFaceUp: victim.isFaceUp + ) + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsThreeSuitComposition() { + // Rebuild the deal with a 3-suit multiset: no SpiderSuitCount matches, + // so the derived difficulty is nil and the state must be rejected. + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .one) + var toRelabel = 26 + for pileIndex in state.tableau.indices { + for cardIndex in state.tableau[pileIndex].indices where toRelabel > 0 { + let card = state.tableau[pileIndex][cardIndex] + let newSuit: Suit = toRelabel > 13 ? .hearts : .clubs + state.tableau[pileIndex][cardIndex] = Card( + suit: newSuit, + rank: card.rank, + isFaceUp: card.isFaceUp + ) + toRelabel -= 1 + } + } + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsNonEmptyWasteAndOccupiedFreeCell() { + var wasteState = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + wasteState.waste.append(wasteState.stock.removeLast()) + XCTAssertNil(payload(for: wasteState).sanitizedForRestore()) + + var freeCellState = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + freeCellState.freeCells[0] = freeCellState.stock.removeLast() + XCTAssertNil(payload(for: freeCellState).sanitizedForRestore()) + } + + func testRejectsWrongPileCount() { + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + let removedPile = state.tableau.removeLast() + state.tableau[0].append(contentsOf: removedPile) + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsPartialStockRow() { + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + var card = state.stock.removeLast() + card.isFaceUp = true + state.tableau[0].append(card) + XCTAssertNil(payload(for: state).sanitizedForRestore(), "The stock only shrinks in ten-card rows") + } + + func testRejectsFaceUpStockCard() { + var state = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + state.stock[0].isFaceUp = true + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsPartialOrMixedFoundationPile() { + // Move five in-suit cards to a foundation: partial banked runs cannot occur. + var partialState = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .one) + var moved: [Card] = [] + for rank in [Rank.ace, .two, .three, .four, .five] { + let location = firstLocation(of: rank, in: partialState.tableau)! + moved.append(partialState.tableau[location.pile].remove(at: location.index)) + } + partialState.foundations[0] = moved + XCTAssertNil(payload(for: partialState).sanitizedForRestore()) + + // A full 13-card foundation pile that mixes suits is equally impossible. + var mixedState = GameStateFixtures.seededSpiderDeal(seed: 4, suitCount: .two) + var mixedRun: [Card] = [] + for rank in Rank.allCases { + let suit: Suit = rank == .king ? .hearts : .spades + let location = firstLocation( + where: { $0.suit == suit && $0.rank == rank }, + in: mixedState.tableau + )! + var card = mixedState.tableau[location.pile].remove(at: location.index) + card.isFaceUp = true + mixedRun.append(card) + } + mixedState.foundations[0] = mixedRun + XCTAssertNil(payload(for: mixedState).sanitizedForRestore()) + } + + func testSanitizationLeavesOtherVariantsUntouched() { + let klondikeState = GameStateFixtures.validPersistenceState() + XCTAssertNotNil(payload(for: klondikeState).sanitizedForRestore()) + } + + // MARK: - Helpers + + private func firstLocation( + of rank: Rank, + in tableau: [[Card]] + ) -> (pile: Int, index: Int)? { + firstLocation(where: { $0.rank == rank }, in: tableau) + } + + private func firstLocation( + where predicate: (Card) -> Bool, + in tableau: [[Card]] + ) -> (pile: Int, index: Int)? { + for pileIndex in tableau.indices { + if let cardIndex = tableau[pileIndex].firstIndex(where: predicate) { + return (pileIndex, cardIndex) + } + } + return nil + } +} diff --git a/ComputerSolitaireTests/Spider/SpiderPlannerTests.swift b/ComputerSolitaireTests/Spider/SpiderPlannerTests.swift new file mode 100644 index 0000000..6a89918 --- /dev/null +++ b/ComputerSolitaireTests/Spider/SpiderPlannerTests.swift @@ -0,0 +1,321 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SpiderPlannerTests: XCTestCase { + func testHintIsDeterministicAcrossCalls() { + let state = SpiderTestStates.board( + tableau: [ + [TestCards.make(.spades, .six)], + [TestCards.make(.hearts, .six)], + [TestCards.make(.hearts, .five)], + [TestCards.make(.clubs, .nine)] + ] + ) + + let first = SpiderPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(SpiderPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal always has a pairing or reveal within easy reach, so a + // small budget keeps the suite fast; production searches are capped by + // the interactive deadline. + let limits = SpiderPlanner.Limits(maxNodes: 2_000) + for suitCount in SpiderSuitCount.allCases { + for seed in 1...10 { + let state = GameStateFixtures.seededSpiderDeal(seed: UInt64(seed), suitCount: suitCount) + XCTAssertNotNil( + SpiderPlanner.bestHint(in: state, limits: limits), + "\(suitCount) seed \(seed): a fresh Spider 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; across lines a transient revisit is survivable, but the + // same exact layout a third time would mean the hints loop. + let limits = SpiderPlanner.Limits(maxNodes: 4_000) + for seed in [11, 12] as [UInt64] { + var state = GameStateFixtures.seededSpiderDeal(seed: seed, suitCount: .two) + var visitCounts: [UInt64: Int] = [stateFingerprint(state): 1] + var actions = 0 + while actions < 400 { + guard case .line(let line) = SpiderPlanner.bestLine(in: state, limits: limits) else { + break + } + var lineKeys: Set = [stateFingerprint(state)] + for action in line { + guard let next = applied(action, to: state) else { + return XCTFail("Planned action was not legal") + } + state = next + actions += 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 a ten reveals a face-down card; moving the free + // 10♦ onto the 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 = SpiderTestStates.board( + tableau: [[hiddenKing, nineClubs], [tenHearts], [tenDiamonds], [jackSpades]] + ) + + guard case .move(let move)? = SpiderPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, nineClubs.id) + } + + func testHintPrefersSameSuitBuildWhenOtherwiseEqual() { + // The 5♥ can land on either six; only the suited build can ever bank a + // run, so the hint should target the 6♥. + let fiveHearts = TestCards.make(.hearts, .five) + let sixHearts = TestCards.make(.hearts, .six) + let sixSpades = TestCards.make(.spades, .six) + let state = SpiderTestStates.board( + tableau: [[fiveHearts], [sixHearts], [sixSpades], [TestCards.make(.clubs, .two)]] + ) + + guard case .move(let move)? = SpiderPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, fiveHearts.id) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testCompletingARunIsFoundAndModeled() { + // One move banks a full spade run; the hint must be that move, and the + // shared simulation must model the banking so cached-line replay, + // tests, and the probe stay in lockstep with real play. + let kingThroughTwoSpades = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.spades, $0) } + let aceSpades = TestCards.make(.spades, .ace) + let state = SpiderTestStates.board( + tableau: [Array(kingThroughTwoSpades), [aceSpades], [TestCards.make(.hearts, .four)]] + ) + + guard case .move(let move)? = SpiderPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.map(\.id), [aceSpades.id]) + XCTAssertEqual(move.destination, .tableau(0)) + + 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") + } + XCTAssertTrue(next.tableau[0].isEmpty, "Simulation must bank the completed run") + XCTAssertEqual(next.foundations[0].count, 13) + XCTAssertEqual(next.foundations[0].first?.rank, .ace) + } + + func testSuffixOfALongerRunIsMovedToCompleteARun() { + // The 2♠A♠ suffix sits on an off-suit three; picking up just that + // suffix onto the spade pile banks the run. + let threeHearts = TestCards.make(.hearts, .three) + let twoSpades = TestCards.make(.spades, .two) + let aceSpades = TestCards.make(.spades, .ace) + let kingThroughThreeSpades = Rank.allCases.reversed().dropLast(2) + .map { TestCards.make(.spades, $0) } + let state = SpiderTestStates.board( + tableau: [ + [threeHearts, twoSpades, aceSpades], + Array(kingThroughThreeSpades), + [TestCards.make(.clubs, .nine)] + ] + ) + + guard case .move(let move)? = SpiderPlanner.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), [twoSpades.id, aceSpades.id]) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testStockDealHintRespectsEmptyPileRule() { + // With an empty pile the deal is illegal, so the line must start by + // filling the space — never with a stock tap. + let hiddenThree = TestCards.make(.diamonds, .three, isFaceUp: false) + let nineHearts = TestCards.make(.hearts, .nine) + var board = SpiderTestStates.fullBoard(topRank: .five) + board.tableau[0] = [] + board.tableau[1] = [hiddenThree, nineHearts] + board.stock = (1...10).map { _ in TestCards.make(.clubs, .two, isFaceUp: false) } + + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: board)) + guard case .move(let move)? = SpiderPlanner.bestHint(in: board) else { + return XCTFail("Expected a move hint, never a stock tap over an empty pile") + } + XCTAssertEqual(move.selection.cards.first?.id, nineHearts.id) + XCTAssertEqual(move.destination, .tableau(0)) + XCTAssertNotEqual( + HintPlanner().bestHint(in: board, stockDrawCount: DrawMode.three.rawValue), + .stockTap, + "The hint stack must not point at the stock while a pile is empty" + ) + } + + func testNoTableauProgressFallsBackToStockDealHint() { + // Ten same-rank tops allow no tableau move at all; with stock in hand + // the deal is the only way forward and the hint stack must say so. + let stock = (1...10).map { _ in TestCards.make(.hearts, .two, isFaceUp: false) } + let state = SpiderTestStates.fullBoard(topRank: .five, stock: stock) + + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + XCTAssertEqual( + HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.three.rawValue), + .stockTap + ) + } + + func testHintTargetsTheFirstEmptyColumn() { + // With two empty columns the planner canonicalizes drops to the first; + // the hint should never point at the second interchangeable column. + let hiddenThree = TestCards.make(.diamonds, .three, isFaceUp: false) + let nineSpades = TestCards.make(.spades, .nine) + var board = SpiderTestStates.board( + tableau: [ + [hiddenThree, nineSpades], + [TestCards.make(.clubs, .five)], + [TestCards.make(.clubs, .jack)] + ] + ) + board.tableau[3] = [] + board.tableau[4] = [] + + guard case .move(let move)? = SpiderPlanner.bestHint(in: board) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, nineSpades.id) + XCTAssertEqual(move.destination, .tableau(3)) + } + + 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 planner yields no unverified nudge. + let limits = SpiderPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededSpiderDeal(seed: 5, suitCount: .two) + + guard case .noProgress(searchWasExhaustive: false) = SpiderPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(SpiderPlanner.bestHint(in: state, limits: limits)) + } + + func testDeadlockedStateReturnsNilAndReportsNoMoves() { + // Ten same-rank tops, no empty pile, and no stock: nothing is legal. + let state = SpiderTestStates.fullBoard(topRank: .five) + + XCTAssertNil(SpiderPlanner.bestHint(in: state)) + XCTAssertNil(HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.three.rawValue)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testHintPlannerWinsAKnownDealEndToEnd() { + // Probe-verified winning seed: following the HintPlanner's cached lines + // (including its stock-deal preparation fallback) plays this 1-suit + // deal to a win without a single nil hint. Guards the whole hint stack. + let planner = HintPlanner() + var state = GameStateFixtures.seededSpiderDeal(seed: 1, suitCount: .one) + var actions = 0 + + while actions < 500 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) else { + return XCTFail("Hint stack gave up after \(actions) actions") + } + let next: GameState? + switch hint { + case .move(let move): + next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + case .stockTap: + var dealt = state + next = SpiderGameRules.dealStockRow(in: &dealt) != nil ? dealt : nil + } + guard let next else { + return XCTFail("Hinted action was not legal after \(actions) actions") + } + state = next + actions += 1 + } + XCTFail("Did not win within 500 actions") + } + + // MARK: - Helpers + + private func applied(_ action: SpiderPlanner.PlannedAction, to state: GameState) -> GameState? { + switch action { + case .move(let selection, let destination): + return AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + case .stockDeal: + var next = state + guard SpiderGameRules.dealStockRow(in: &next) != nil else { return nil } + return next + } + } + + 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))) + } + mix(UInt8(state.stock.count)) + 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/Spider/SpiderRulesTests.swift b/ComputerSolitaireTests/Spider/SpiderRulesTests.swift new file mode 100644 index 0000000..72ba4e6 --- /dev/null +++ b/ComputerSolitaireTests/Spider/SpiderRulesTests.swift @@ -0,0 +1,304 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SpiderRulesTests: XCTestCase { + // MARK: - Deal shape and deck composition + + func testNewGameDealShape() { + for suitCount in SpiderSuitCount.allCases { + let state = GameState.newSpiderGame(suitCount: suitCount) + + XCTAssertEqual(state.variant, .spider) + XCTAssertEqual(state.tableau.count, 10) + XCTAssertEqual(state.tableau.map(\.count), [6, 6, 6, 6, 5, 5, 5, 5, 5, 5]) + for pile in state.tableau { + for (index, card) in pile.enumerated() { + XCTAssertEqual( + card.isFaceUp, + index == pile.count - 1, + "Only each pile's top card should be dealt face up" + ) + } + } + XCTAssertEqual(state.stock.count, 50) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertTrue(state.waste.isEmpty) + XCTAssertEqual(state.wasteDrawCount, 0) + XCTAssertEqual(state.foundations.count, 8) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertTrue(state.freeCells.allSatisfy { $0 == nil }) + } + } + + func testDeckCompositionPerSuitCount() { + let expectedSuits: [SpiderSuitCount: Set] = [ + .one: [.spades], + .two: [.spades, .hearts], + .four: Set(Suit.allCases) + ] + for suitCount in SpiderSuitCount.allCases { + let deck = SpiderDeck.deck(suitCount: suitCount) + XCTAssertEqual(deck.count, 104) + XCTAssertEqual(Set(deck.map(\.id)).count, 104, "Every card needs a unique identity") + XCTAssertEqual(Set(deck.map(\.suit)), expectedSuits[suitCount]) + + let copiesPerIdentity = 104 / (suitCount.rawValue * Rank.allCases.count) + var counts: [CardIdentity: Int] = [:] + for card in deck { + counts[CardIdentity(suit: card.suit, rank: card.rank), default: 0] += 1 + } + XCTAssertTrue( + counts.values.allSatisfy { $0 == copiesPerIdentity }, + "\(suitCount): every rank of every composed suit appears \(copiesPerIdentity) times" + ) + XCTAssertEqual(counts, SpiderDeck.expectedIdentityCounts(suitCount: suitCount)) + } + } + + func testSuitCountIsDerivedFromTheCardsInPlay() { + for suitCount in SpiderSuitCount.allCases { + let state = GameStateFixtures.seededSpiderDeal(seed: 3, suitCount: suitCount) + XCTAssertEqual(state.spiderSuitCount, suitCount) + } + XCTAssertNil(GameStateFixtures.seededYukonDeal(seed: 3).spiderSuitCount) + } + + // MARK: - Landing rules + + func testSingleCardLandsOnAnySuitOneRankHigher() { + let sevenHearts = TestCards.make(.hearts, .seven) + XCTAssertTrue( + SpiderGameRules.canMoveToTableau( + card: sevenHearts, + destinationPile: [TestCards.make(.spades, .eight)] + ), + "Suit must not matter for a landing" + ) + XCTAssertTrue( + SpiderGameRules.canMoveToTableau( + card: sevenHearts, + destinationPile: [TestCards.make(.hearts, .eight)] + ) + ) + XCTAssertFalse( + SpiderGameRules.canMoveToTableau( + card: sevenHearts, + destinationPile: [TestCards.make(.spades, .nine)] + ), + "The landing must be exactly one rank higher" + ) + XCTAssertFalse( + SpiderGameRules.canMoveToTableau( + card: sevenHearts, + destinationPile: [TestCards.make(.spades, .seven)] + ) + ) + } + + func testEmptyPileAcceptsAnyCard() { + for rank in [Rank.ace, .seven, .king] { + XCTAssertTrue( + SpiderGameRules.canMoveToTableau( + card: TestCards.make(.clubs, rank), + destinationPile: [] + ) + ) + } + } + + func testNothingLandsOnAnAce() { + let ace = [TestCards.make(.spades, .ace)] + for rank in Rank.allCases { + XCTAssertFalse( + SpiderGameRules.canMoveToTableau( + card: TestCards.make(.hearts, rank), + destinationPile: ace + ) + ) + } + } + + func testFaceDownTopRejectsLandings() { + XCTAssertFalse( + SpiderGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .seven), + destinationPile: [TestCards.make(.spades, .eight, isFaceUp: false)] + ) + ) + } + + // MARK: - Pickup rules + + func testPickupRequiresDescendingSameSuitRun() { + let sameSuitRun = [ + TestCards.make(.spades, .nine), + TestCards.make(.spades, .eight), + TestCards.make(.spades, .seven) + ] + XCTAssertTrue(SharedGameRules.isDescendingSameSuitRun(sameSuitRun)) + + let mixedSuitRun = [ + TestCards.make(.spades, .nine), + TestCards.make(.hearts, .eight) + ] + XCTAssertFalse( + SharedGameRules.isDescendingSameSuitRun(mixedSuitRun), + "A descending run of mixed suits must not move together" + ) + + let alternatingColorRun = [ + TestCards.make(.spades, .nine), + TestCards.make(.diamonds, .eight) + ] + XCTAssertFalse(SharedGameRules.isDescendingSameSuitRun(alternatingColorRun)) + + let gappedSameSuit = [ + TestCards.make(.spades, .nine), + TestCards.make(.spades, .seven) + ] + XCTAssertFalse(SharedGameRules.isDescendingSameSuitRun(gappedSameSuit)) + + XCTAssertTrue(SharedGameRules.isDescendingSameSuitRun([TestCards.make(.clubs, .four)])) + + let faceDownLead = [ + TestCards.make(.spades, .nine, isFaceUp: false), + TestCards.make(.spades, .eight) + ] + XCTAssertFalse(SharedGameRules.isDescendingSameSuitRun(faceDownLead)) + } + + func testCandidateSelectionsOfferOnlySameSuitRunSuffixes() { + // Pile: 9♠ 8♥ 7♥ — the 8♥7♥ suffix and the 7♥ alone are movable; + // the full mixed-suit stack is not. + let nineSpades = TestCards.make(.spades, .nine) + let eightHearts = TestCards.make(.hearts, .eight) + let sevenHearts = TestCards.make(.hearts, .seven) + let state = SpiderTestStates.board( + tableau: [[nineSpades, eightHearts, sevenHearts]] + ) + + let tableauSources = AutoMoveAdvisor.candidateSelections(in: state) + .compactMap { selection -> Int? in + guard case .tableau(_, let index) = selection.source else { return nil } + return index + } + XCTAssertEqual(tableauSources.sorted(), [1, 2], "Only the same-suit suffixes are pickable") + } + + func testFoundationsAreNeverSourcesOrDestinations() { + // A banked run's King must never be pickable, and a lone Ace must never + // be droppable on an empty foundation pile. + let bankedRun = Rank.allCases.map { TestCards.make(.spades, $0) } + let aceHearts = TestCards.make(.hearts, .ace) + var foundations: [[Card]] = Array(repeating: [], count: 8) + foundations[0] = bankedRun + let state = SpiderTestStates.board( + tableau: [[aceHearts]], + foundations: foundations + ) + + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source { + XCTFail("Spider foundations must not be pickup sources") + } + } + + let aceSelection = Selection(source: .tableau(pile: 0, index: 0), cards: [aceHearts]) + for destination in AutoMoveAdvisor.legalDestinations(for: aceSelection, in: state) { + if case .foundation = destination { + XCTFail("Spider foundations must not be legal destinations") + } + } + } + + func testWholePileMoveToEmptyColumnIsRedundantForAnyLeadRank() { + // A whole pile led by a mid-rank card parked next to empty columns is a + // no-op relocation, so the advisor refuses it (players still can). + let nineSpades = TestCards.make(.spades, .nine) + let eightSpades = TestCards.make(.spades, .eight) + let state = SpiderTestStates.board(tableau: [[nineSpades, eightSpades]]) + + let wholePile = Selection( + source: .tableau(pile: 0, index: 0), + cards: [nineSpades, eightSpades] + ) + XCTAssertTrue(AutoMoveAdvisor.legalDestinations(for: wholePile, in: state).isEmpty) + } + + // MARK: - Session move semantics + + func testSessionMovesSameSuitRunAndFlipsExposedCard() { + let hiddenKing = TestCards.make(.clubs, .king, isFaceUp: false) + let eightSpades = TestCards.make(.spades, .eight) + let sevenSpades = TestCards.make(.spades, .seven) + let nineHearts = TestCards.make(.hearts, .nine) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [[hiddenKing, eightSpades, sevenSpades], [nineHearts]] + ) + viewModel.configureSpiderNewGame() + + viewModel.selection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [eightSpades, sevenSpades] + ) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + + XCTAssertEqual(viewModel.state.tableau[1].map(\.id), [nineHearts.id, eightSpades.id, sevenSpades.id]) + XCTAssertEqual(viewModel.state.tableau[0].count, 1) + XCTAssertTrue( + viewModel.state.tableau[0][0].isFaceUp, + "The exposed face-down card should flip when the run leaves" + ) + XCTAssertEqual(viewModel.movesCount, 1) + } + + func testSessionRefusesMixedSuitGroupSelection() { + let nineSpades = TestCards.make(.spades, .nine) + let eightHearts = TestCards.make(.hearts, .eight) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [[nineSpades, eightHearts], [TestCards.make(.clubs, .ten)]] + ) + viewModel.configureSpiderNewGame() + + XCTAssertFalse(viewModel.canSelectTableauCards([nineSpades, eightHearts])) + XCTAssertFalse(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 0)) + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 1)) + } +} + +/// Constructs Spider board states for tests: piles are padded to Spider's ten +/// columns, foundations to its eight banked-run piles. +@MainActor +enum SpiderTestStates { + static func board( + tableau: [[Card]], + stock: [Card] = [], + foundations: [[Card]] = Array(repeating: [], count: 8) + ) -> GameState { + var paddedTableau = tableau + while paddedTableau.count < 10 { + paddedTableau.append([]) + } + return GameState( + variant: .spider, + stock: stock, + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: foundations, + tableau: paddedTableau + ) + } + + /// A ten-pile board with a single face-up card in every pile, so stock + /// deals are legal and the layout is otherwise inert. + static func fullBoard(topRank: Rank = .five, stock: [Card] = []) -> GameState { + board( + tableau: (0..<10).map { _ in [TestCards.make(.spades, topRank)] }, + stock: stock + ) + } +} diff --git a/ComputerSolitaireTests/Spider/SpiderRunCompletionTests.swift b/ComputerSolitaireTests/Spider/SpiderRunCompletionTests.swift new file mode 100644 index 0000000..567403b --- /dev/null +++ b/ComputerSolitaireTests/Spider/SpiderRunCompletionTests.swift @@ -0,0 +1,180 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SpiderRunCompletionTests: XCTestCase { + /// King through Two of one suit, face up — one Ace short of a full run. + private func kingThroughTwo(_ suit: Suit) -> [Card] { + Rank.allCases.reversed().dropLast().map { TestCards.make(suit, $0) } + } + + func testMoveCompletedRunIsBankedToTheFirstEmptyFoundation() { + let aceSpades = TestCards.make(.spades, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [kingThroughTwo(.spades), [aceSpades], [TestCards.make(.hearts, .four)]] + ) + viewModel.configureSpiderNewGame() + let scoreBeforeMove = viewModel.score + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceSpades]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertTrue(viewModel.state.tableau[0].isEmpty, "The completed run leaves the tableau") + let bankedRun = viewModel.state.foundations[0] + XCTAssertEqual(bankedRun.count, 13) + XCTAssertEqual(bankedRun.first?.rank, .ace, "Banked runs stack Ace at the bottom") + XCTAssertEqual(bankedRun.last?.rank, .king) + XCTAssertTrue(bankedRun.allSatisfy { $0.suit == .spades }) + XCTAssertEqual( + viewModel.score, + scoreBeforeMove + Scoring.delta(for: .spiderMove) + Scoring.delta(for: .spiderCompletedRun) + ) + XCTAssertFalse(viewModel.isWin) + } + + func testDealCompletedRunIsBankedAndExposedCardFlips() { + // Pile 0 holds a hidden card under K→2 of spades; the deal drops A♠ + // onto it (the stock's last card lands on pile 0). + let hiddenCard = TestCards.make(.hearts, .nine, isFaceUp: false) + var board = SpiderTestStates.fullBoard(topRank: .five) + board.tableau[0] = [hiddenCard] + kingThroughTwo(.spades) + var stock = (1...9).map { _ in TestCards.make(.hearts, .two, isFaceUp: false) } + stock.append(TestCards.make(.spades, .ace, isFaceUp: false)) + board.stock = stock + + let viewModel = SolitaireViewModel() + viewModel.state = board + viewModel.configureSpiderNewGame() + let scoreBeforeDeal = viewModel.score + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.foundations[0].count, 13) + XCTAssertEqual(viewModel.state.tableau[0].count, 1) + XCTAssertEqual(viewModel.state.tableau[0][0].id, hiddenCard.id) + XCTAssertTrue( + viewModel.state.tableau[0][0].isFaceUp, + "The card the banked run exposed should flip" + ) + XCTAssertEqual( + viewModel.score, + scoreBeforeDeal + Scoring.delta(for: .spiderMove) + Scoring.delta(for: .spiderCompletedRun) + ) + } + + func testExposedFlipAfterCompletionScoresNothing() { + // Spider's classic scheme has no reveal bonus: banking a run over a + // face-down card changes the score by the move and the run only. + let hiddenCard = TestCards.make(.hearts, .nine, isFaceUp: false) + let aceSpades = TestCards.make(.spades, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [[hiddenCard] + kingThroughTwo(.spades), [aceSpades]] + ) + viewModel.configureSpiderNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceSpades]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertTrue(viewModel.state.tableau[0][0].isFaceUp) + XCTAssertEqual( + viewModel.score, + Scoring.spiderInitialScore + + Scoring.delta(for: .spiderMove) + + Scoring.delta(for: .spiderCompletedRun), + "The exposed flip must not add the Klondike-family reveal bonus" + ) + } + + func testOneRemovalCanExposeAndBankASecondCompleteRun() { + // A full heart run sits under K→2 of spades; landing the A♠ banks the + // spade run, exposing the complete heart run, which banks too. + let heartRun = Rank.allCases.reversed().map { TestCards.make(.hearts, $0) } + let aceSpades = TestCards.make(.spades, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [heartRun + kingThroughTwo(.spades), [aceSpades]] + ) + viewModel.configureSpiderNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceSpades]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertTrue(viewModel.state.tableau[0].isEmpty) + XCTAssertEqual(viewModel.state.foundations[0].count, 13) + XCTAssertEqual(viewModel.state.foundations[1].count, 13) + XCTAssertEqual( + viewModel.score, + Scoring.spiderInitialScore + + Scoring.delta(for: .spiderMove) + + Scoring.delta(for: .spiderCompletedRun) * 2 + ) + } + + func testDealCanCompleteTwoRunsAtOnce() { + var board = SpiderTestStates.fullBoard(topRank: .five) + board.tableau[0] = kingThroughTwo(.spades) + board.tableau[1] = kingThroughTwo(.hearts) + // Pile 0 takes the stock's last card, pile 1 the one before it. + var stock = (1...8).map { _ in TestCards.make(.clubs, .two, isFaceUp: false) } + stock.append(TestCards.make(.hearts, .ace, isFaceUp: false)) + stock.append(TestCards.make(.spades, .ace, isFaceUp: false)) + board.stock = stock + + let viewModel = SolitaireViewModel() + viewModel.state = board + viewModel.configureSpiderNewGame() + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.foundations[0].count, 13) + XCTAssertEqual(viewModel.state.foundations[1].count, 13) + XCTAssertTrue(viewModel.state.tableau[0].isEmpty) + XCTAssertTrue(viewModel.state.tableau[1].isEmpty) + } + + func testEighthCompletedRunWinsTheGame() { + var foundations: [[Card]] = (0..<7).map { _ in + Rank.allCases.map { TestCards.make(.spades, $0) } + } + foundations.append([]) + let aceHearts = TestCards.make(.hearts, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [kingThroughTwo(.hearts), [aceHearts]], + foundations: foundations + ) + viewModel.configureSpiderNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertTrue(viewModel.state.isWon) + XCTAssertTrue(viewModel.isWin) + XCTAssertNotNil(viewModel.finalElapsedSeconds, "A win must finalize the clock") + } + + func testSingleUndoRestoresACompletedRun() { + let aceSpades = TestCards.make(.spades, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board( + tableau: [kingThroughTwo(.spades), [aceSpades], [TestCards.make(.hearts, .four)]] + ) + viewModel.configureSpiderNewGame() + let stateBeforeMove = viewModel.state + let scoreBeforeMove = viewModel.score + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceSpades]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + XCTAssertEqual(viewModel.state.foundations[0].count, 13) + + viewModel.undo() + XCTAssertEqual( + viewModel.state, + stateBeforeMove, + "One undo must restore the run to the tableau and the Ace to its pile" + ) + XCTAssertEqual(viewModel.score, scoreBeforeMove) + } +} diff --git a/ComputerSolitaireTests/Spider/SpiderScoringTests.swift b/ComputerSolitaireTests/Spider/SpiderScoringTests.swift new file mode 100644 index 0000000..c1013e9 --- /dev/null +++ b/ComputerSolitaireTests/Spider/SpiderScoringTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SpiderScoringTests: XCTestCase { + func testNewGameStartsAtTheClassicInitialScore() { + let viewModel = SolitaireViewModel() + viewModel.newGame(variant: .spider) + XCTAssertEqual(viewModel.score, Scoring.spiderInitialScore) + + viewModel.redeal() + XCTAssertEqual(viewModel.score, Scoring.spiderInitialScore, "A redeal restarts the balance") + } + + func testEveryTableauMoveCostsOnePoint() { + let fiveHearts = TestCards.make(.hearts, .five) + let sixSpades = TestCards.make(.spades, .six) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board(tableau: [[fiveHearts], [sixSpades]]) + viewModel.configureSpiderNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 0, index: 0), cards: [fiveHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + + XCTAssertEqual(viewModel.score, Scoring.spiderInitialScore - 1) + } + + func testStockDealCostsOnePoint() { + let stock = (1...10).map { _ in TestCards.make(.hearts, .two, isFaceUp: false) } + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.fullBoard(topRank: .five, stock: stock) + viewModel.configureSpiderNewGame() + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.score, Scoring.spiderInitialScore - 1) + } + + func testScoreIsClampedAtZero() { + let fiveHearts = TestCards.make(.hearts, .five) + let sixSpades = TestCards.make(.spades, .six) + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.board(tableau: [[fiveHearts], [sixSpades]]) + viewModel.configureSpiderNewGame() + viewModel.setInitialScore(0) + + viewModel.selection = Selection(source: .tableau(pile: 0, index: 0), cards: [fiveHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + + XCTAssertEqual(viewModel.score, 0) + } + + func testWinAddsTheStandardTimeBonus() { + // Seven runs banked; the final Ace completes the eighth. The provider + // pins the clock, so the expected bonus is exact. + var foundations: [[Card]] = (0..<7).map { _ in + Rank.allCases.map { TestCards.make(.spades, $0) } + } + foundations.append([]) + let kingThroughTwoHearts = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.hearts, $0) } + let aceHearts = TestCards.make(.hearts, .ace) + + let dateProvider = TestDateProvider(now: DateFixtures.reference) + let viewModel = SolitaireViewModel(dateProvider: dateProvider) + viewModel.state = SpiderTestStates.board( + tableau: [Array(kingThroughTwoHearts), [aceHearts]], + foundations: foundations + ) + viewModel.configureSpiderNewGame() + let scoreBeforeWin = viewModel.score + + dateProvider.now = DateFixtures.plus(60) + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertTrue(viewModel.isWin) + let expectedBonus = Scoring.timeBonus( + elapsedSeconds: 60, + maxBonus: Scoring.timedMaxBonus(for: DrawMode.three.rawValue) + ) + XCTAssertGreaterThan(expectedBonus, 0) + XCTAssertEqual( + viewModel.score, + scoreBeforeWin + + Scoring.delta(for: .spiderMove) + + Scoring.delta(for: .spiderCompletedRun) + + expectedBonus + ) + } +} diff --git a/ComputerSolitaireTests/Spider/SpiderStockDealTests.swift b/ComputerSolitaireTests/Spider/SpiderStockDealTests.swift new file mode 100644 index 0000000..f32b2bb --- /dev/null +++ b/ComputerSolitaireTests/Spider/SpiderStockDealTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class SpiderStockDealTests: XCTestCase { + func testDealPlacesOneFaceUpCardOnEveryPileInOrder() { + let stock = (1...20).map { _ in TestCards.make(.spades, .two, isFaceUp: false) } + let viewModel = SolitaireViewModel() + viewModel.state = SpiderTestStates.fullBoard(topRank: .five, stock: stock) + viewModel.configureSpiderNewGame() + // The last ten stock cards land on piles 0-9, in order. + let expectedDealtIDs = viewModel.state.stock.suffix(10).map(\.id) + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.stock.count, 10) + XCTAssertEqual(viewModel.state.tableau.map(\.count), Array(repeating: 2, count: 10)) + let dealtByPile = viewModel.state.tableau.map { pile in pile[1] } + XCTAssertEqual(dealtByPile.map(\.id), expectedDealtIDs.reversed()) + XCTAssertTrue(dealtByPile.allSatisfy(\.isFaceUp)) + XCTAssertEqual(viewModel.movesCount, 1, "A deal is one move") + } + + func testDealIsBlockedWhileAnyPileIsEmpty() { + let stock = (1...10).map { _ in TestCards.make(.spades, .two, isFaceUp: false) } + let viewModel = SolitaireViewModel() + var board = SpiderTestStates.fullBoard(topRank: .five, stock: stock) + board.tableau[3] = [] + viewModel.state = board + viewModel.configureSpiderNewGame() + let stateBeforeTap = viewModel.state + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state, stateBeforeTap, "A blocked deal must not change the board") + XCTAssertEqual(viewModel.movesCount, 0) + XCTAssertFalse(viewModel.canUndo, "A blocked deal must not push an undo snapshot") + } + + func testFiveDealsExhaustTheStockAndAFurtherTapIsANoOp() { + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededSpiderDeal(seed: 1, suitCount: .two) + viewModel.configureSpiderNewGame() + + for expectedRemaining in [40, 30, 20, 10, 0] { + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state.stock.count, expectedRemaining) + } + XCTAssertEqual(viewModel.movesCount, 5) + XCTAssertEqual( + viewModel.state.tableau.reduce(0) { $0 + $1.count } + + viewModel.state.foundations.reduce(0) { $0 + $1.count }, + 104 + ) + + let stateAfterFiveDeals = viewModel.state + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state, stateAfterFiveDeals, "An empty stock tap is a no-op") + XCTAssertEqual(viewModel.movesCount, 5) + } + + func testSingleUndoRestoresTheWholeDealtRow() { + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededSpiderDeal(seed: 2, suitCount: .four) + viewModel.configureSpiderNewGame() + let stateBeforeDeal = viewModel.state + let scoreBeforeDeal = viewModel.score + + viewModel.handleStockTap() + XCTAssertNotEqual(viewModel.state, stateBeforeDeal) + XCTAssertEqual( + viewModel.peekUndoSnapshot()?.undoContext?.action, + .dealTableauRow + ) + + viewModel.undo() + XCTAssertEqual(viewModel.state, stateBeforeDeal, "One undo must restore all ten dealt cards") + XCTAssertEqual(viewModel.score, scoreBeforeDeal) + XCTAssertEqual(viewModel.movesCount, 0) + } +} diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index b7599ed..44f9c68 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -80,6 +80,30 @@ enum GameStateFixtures { ) } + /// A reproducible Spider deal matching the shape of `GameState.newSpiderGame`. + /// Mirrored by the hint probe's `seededSpiderDeal` so seeds are comparable. + static func seededSpiderDeal(seed: UInt64, suitCount: SpiderSuitCount) -> GameState { + var deck = seededShuffle(SpiderDeck.deck(suitCount: suitCount), seed: seed) + var tableau: [[Card]] = Array(repeating: [], count: 10) + for pileIndex in 0..<10 { + let cardCount = pileIndex < 4 ? 6 : 5 + for cardIndex in 0.. GameState { var deck = seededDeck(seed: seed, faceUp: false) @@ -105,8 +129,12 @@ enum GameStateFixtures { } private static func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { + seededShuffle(TestCards.fullDeck(faceUp: faceUp), seed: seed) + } + + private static func seededShuffle(_ cards: [Card], seed: UInt64) -> [Card] { var generator = SeededRandomNumberGenerator(seed: seed) - var deck = TestCards.fullDeck(faceUp: faceUp) + var deck = cards for index in stride(from: deck.count - 1, through: 1, by: -1) { let swapIndex = Int(generator.next() % UInt64(index + 1)) deck.swapAt(index, swapIndex) diff --git a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift index 2ab1fc6..f150953 100644 --- a/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift +++ b/ComputerSolitaireUITests/ScreenshotCaptureUITests.swift @@ -19,7 +19,8 @@ final class ScreenshotCaptureUITests: XCTestCase { private static let boards = [ "klondike-draw3", "freecell", - "yukon" + "yukon", + "spider" ] /// Appearance for every screenshot, pinned via UserDefaults launch diff --git a/README.md b/README.md index 3d104fc..e496378 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), **FreeCell**, and **Yukon** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, **Yukon**, and **Spider** (1, 2, or 4 suits) - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -25,3 +25,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) | +| **Spider** | Two decks, ten piles — build full suit runs from King to Ace, with 1/2/4-suit difficulty | [Rules](docs/solitaire-rules-spider.md) | diff --git a/docs/solitaire-rules-spider.md b/docs/solitaire-rules-spider.md new file mode 100644 index 0000000..840208b --- /dev/null +++ b/docs/solitaire-rules-spider.md @@ -0,0 +1,52 @@ +# Spider Rules + +These rules describe standard Spider setup and play. Spider is played with **two decks (104 cards)** across **ten tableau piles**. Cards build down regardless of suit, but only same-suit runs move together, and the goal is to assemble — and automatically bank — **eight full King-to-Ace runs**, one suit each. + +## Objective +Complete eight King-to-Ace runs of a single suit. Each completed run is removed from the tableau automatically; the game is won when all eight are done. + +## Terminology +- **Tableau:** Ten piles where cards are played and rearranged. +- **Run:** Face-up cards of one suit in strict descending order; the only multi-card unit that moves together. +- **Completed run:** A full King-to-Ace run of one suit; it leaves the tableau on its own. +- **Stock:** The face-down pile that deals one card onto every tableau pile at once. +- **Suits (difficulty):** The two decks are composed of 1, 2, or 4 distinct suits — always 104 cards. + +## Layout +- Ten tableau piles. +- A stock of 50 face-down cards (five deals of ten). +- Eight spaces where completed runs are banked. + +## Setup +- Use two standard 52-card decks (no jokers). Difficulty changes the composition: **1 suit** (eight sets of Spades), **2 suits** (four sets each of Spades and Hearts), or **4 suits** (two full decks). +- **Tableau:** Deal 54 cards left to right: the first four piles get **6 cards**, the remaining six piles get **5 cards**, only the top card of each pile face up. +- **Stock:** The remaining 50 cards, face down. + +## Tableau Play +- Build tableau piles **down in rank**, **regardless of suit** (e.g., a red 6 may land on any 7). +- Nothing may be placed on an Ace. +- Several cards move together only as a **face-up, same-suit descending run**. Mixed-suit or gapped stacks move one card at a time. +- Any card or movable run may fill an **empty pile**. +- When the last face-up card leaves a pile, flip the newly exposed face-down card face up. + +## The Stock +- Tapping the stock deals **one face-up card onto every pile** (ten cards per deal; five deals in a game). +- Dealing is **not allowed while any pile is empty** — fill every space first. + +## Completed Runs +- The moment a pile's top thirteen cards form a face-up King-to-Ace run of one suit, the run is **removed automatically** and banked. +- A dealt card can complete a run, and one removal can expose another complete run beneath it. + +## Winning +You win when all eight runs are completed. With good play, roughly one in three 4-suit games is winnable; 2-suit games considerably more, and 1-suit games almost always. + +## Scoring (classic) +- Start at **500**. +- **−1** for every move, including each stock deal. +- **+100** for each completed run. +- The score never drops below 0. On a win, the app adds its standard time bonus. + +## Sources +- https://en.wikipedia.org/wiki/Spider_(solitaire) +- https://cardgames.io/spidersolitaire/ +- https://www.247spidersolitaire.com diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index 5cc530e..32a76ff 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -17,11 +17,13 @@ the app target, the test suite, or CI. From the repo root: ```bash -tools/hint-probe/run.sh all # full study, 500 deals per run (~7 min) +tools/hint-probe/run.sh all # full study, 500 deals per run (~10 min) tools/hint-probe/run.sh yukon 500 tools/hint-probe/run.sh klondike 500 1 # third arg is the draw count tools/hint-probe/run.sh klondike 500 3 tools/hint-probe/run.sh freecell 500 +tools/hint-probe/run.sh spider 500 # all three suit counts +tools/hint-probe/run.sh spider 500 4 # third arg narrows to one suit count ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -58,6 +60,9 @@ consecutive runs, serial and parallel. | `klondike` draw-1 | **44.4%** | 39.4% | | `klondike` draw-3 | **24.0%** | 6.0% | | `freecell` | **99.8%** | 0.2% | +| `spider` 1-suit | **95.4%** | 0.0% | +| `spider` 2-suit | **49.2%** | 0.0% | +| `spider` 4-suit | **2.8%** | 0.0% | Reading the table honestly: @@ -76,6 +81,24 @@ Reading the table honestly: within its node budget; the follower classifies it as a deadlock because the nudge fallback only circles there (a solved line is finite and cannot loop, so any plan-line revisit would be a real bug and trips the gate). +- **Spider (95.4% / 49.2% / 2.8% vs 0.0%)**: the random control winning zero + at every suit count says Spider wins are never stumbled into — the entire + hint column is planner skill. 1-suit deals are nearly always winnable and + the planner delivers. 4-suit is honest about its class: expert play wins + roughly a third of deals, and a greedy bounded best-first search is far + below expert — treat 2.8% as the regression floor, not an achievement. + Spider records a handful of *transient* position revisits per 500 deals + (2/6/4 by suit count); they come from the deal-preparation fallback, which + deliberately plays score-losing column fills, so a later line can re-cross + an earlier layout once. They are reported but not gated; a third visit to + the same layout is still a gate-tripping loop, and Spider measures zero. + Tuning directions already measured flat or negative: suited-run bonus x2 + (0% at 4-suit), empty-pile weight 15 (2.0%), break penalty 4 (4.0%), + early-exit floor 16384 (flat, +50% search cost), node budget 50k (flat). + Directions that got the planner here: early-exit floor 2048 → 8192 + (+9 points at 2-suit), quadratic suited-run bonus, break penalty 2 → 3 + (+2 points at 4-suit), and the cached fill-then-deal preparation line + (kills the fill/unfill oscillation the empty-column bonus otherwise causes). - These figures use the planners' full node budgets. The app additionally clips each interactive search at a fraction of a second so the UI never hitches; that clip rarely binds, so in-app quality is at most a hair below @@ -87,15 +110,18 @@ Wire its deal into `seededDeal`, add a hint-following player for its planner, add its sources to `run.sh`, then run 500 deals. Acceptance gates: - The hint column must **decisively beat the random control**. -- **Zero stalemate-loops and zero revisit events** for the hint player. Both - are machine-enforced: the probe exits nonzero if any hint follower loops in - any variant, or if Yukon records a single position revisit. (Revisits are - reported without reclassifying the game, so win rates stay honestly - measured; the exit code is what enforces the gate.) +- **Zero stalemate-loops** for the hint player, machine-enforced: the probe + exits nonzero if any hint follower loops in any variant. **Revisit events** + are additionally gated to zero for Yukon (its planner measures zero, so any + revisit is a regression signal); Spider's are reported but not gated — see + the baseline notes for why a few transients per 500 deals are structural + there. (Revisits are reported without reclassifying the game, so win rates + stay honestly measured; the exit code is what enforces the gates.) - **Watch the over-banking detector** (`losses with >=40 banked`): it should be zero for stockless variants (Yukon and FreeCell measure zero). The - Klondike draw-1 baseline records a single such loss; treat any increase as - a regression. + Klondike draw-1 baseline records a single such loss, and Spider records + 6/7/1 by suit count (its losses can strand nearly-done boards); treat any + increase as a regression. - Record the measured numbers in the table above; they become the variant's regression baseline. Mechanical refactors must reproduce every figure exactly; deliberate quality changes must move the hint column up, never diff --git a/tools/hint-probe/main.swift b/tools/hint-probe/main.swift index fa5dbc2..0913c03 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -26,11 +26,9 @@ struct SeededRandomNumberGenerator: RandomNumberGenerator { } } -func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { +func seededShuffle(_ cards: [Card], seed: UInt64) -> [Card] { var generator = SeededRandomNumberGenerator(seed: seed) - var deck = Suit.allCases.flatMap { suit in - Rank.allCases.map { rank in Card(suit: suit, rank: rank, isFaceUp: faceUp) } - } + var deck = cards for index in stride(from: deck.count - 1, through: 1, by: -1) { let swapIndex = Int(generator.next() % UInt64(index + 1)) deck.swapAt(index, swapIndex) @@ -38,7 +36,38 @@ func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { return deck } -func seededDeal(variant: GameVariant, seed: UInt64) -> GameState { +func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { + seededShuffle( + Suit.allCases.flatMap { suit in + Rank.allCases.map { rank in Card(suit: suit, rank: rank, isFaceUp: faceUp) } + }, + seed: seed + ) +} + +func seededSpiderDeal(seed: UInt64, suitCount: SpiderSuitCount) -> GameState { + var deck = seededShuffle(SpiderDeck.deck(suitCount: suitCount), seed: seed) + var tableau: [[Card]] = Array(repeating: [], count: 10) + for pileIndex in 0..<10 { + let cardCount = pileIndex < 4 ? 6 : 5 + for cardIndex in 0.. GameState { switch variant { case .klondike: var deck = seededDeck(seed: seed, faceUp: false) @@ -93,6 +122,9 @@ func seededDeal(variant: GameVariant, seed: UInt64) -> GameState { foundations: Array(repeating: [], count: 4), tableau: tableau ) + + case .spider: + return seededSpiderDeal(seed: seed, suitCount: spiderSuitCount) } } @@ -140,6 +172,14 @@ func apply( ) } +/// Mirrors dealSpiderStockRow in the session (via the shared rules function, +/// including the completed-run sweep). +func spiderStockDeal(_ state: GameState) -> GameState? { + var next = state + guard SpiderGameRules.dealStockRow(in: &next) != nil else { return nil } + return next +} + /// Mirrors drawFromStock / recycleWaste in the session. func stockTap(_ state: GameState, drawCount: Int) -> GameState? { var next = state @@ -172,9 +212,16 @@ enum Outcome { } /// Yukon/FreeCell games finish or die well under this; Klondike needs headroom -/// for stock cycling. +/// for stock cycling, and Spider for grooming 104 cards across five deals. func actionCap(for variant: GameVariant) -> Int { - variant == .klondike ? 1_200 : 600 + switch variant { + case .klondike: + return 1_200 + case .spider: + return 1_000 + case .freecell, .yukon: + return 600 + } } func foundationCount(_ state: GameState) -> Int { @@ -305,6 +352,79 @@ func playYukonFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: return (.actionCap(foundation: foundationCount(state)), revisitEvents) } +func playSpiderFollowingHints( + seed: UInt64, + suitCount: SpiderSuitCount +) -> (outcome: Outcome, revisitEvents: Int) { + // Replicates HintPlanner's Spider path without its wall-clock deadline: + // follow each improving line (which may include stock deals) to its end, + // then replan; on no-progress, follow the deal-preparation fallback the + // real hint stack uses, and declare a deadlock only when no deal remains. + var state = seededSpiderDeal(seed: seed, suitCount: suitCount) + var visitCounts: [UInt64: Int] = [fingerprint(state): 1] + var revisitEvents = 0 + var actions = 0 + + func record(_ nextState: GameState) -> Outcome? { + state = nextState + actions += 1 + let key = fingerprint(state) + let count = (visitCounts[key] ?? 0) + 1 + visitCounts[key] = count + if count > 1 { revisitEvents += 1 } + // A transient cross-line revisit is survivable (the next plan differs); + // a third visit to the same exact layout means the hints are looping. + if count >= 3 { + return .stalemateLoop(foundation: foundationCount(state)) + } + // Cap before win, matching the other players: their win check only + // runs on the next loop iteration, so a win landed on the final + // permitted action classifies as .actionCap everywhere. + if actions >= actionCap(for: .spider) { + return .actionCap(foundation: foundationCount(state)) + } + if state.isWon { return .win(moves: actions) } + return nil + } + + func applied(_ action: SpiderPlanner.PlannedAction) -> GameState? { + switch action { + case .move(let selection, let destination): + return apply(selection, destination, to: state, stockDrawCount: 3) + case .stockDeal: + return spiderStockDeal(state) + } + } + + while actions < actionCap(for: .spider) { + if state.isWon { return (.win(moves: actions), revisitEvents) } + switch SpiderPlanner.bestLine(in: state) { + case .line(let line): + for action in line { + guard let next = applied(action) else { + fatalError("Seed \(seed): illegal Spider hint") + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + + case .noProgress: + // Mirrors HintPlanner's fallback: follow the whole deal-preparation + // line (fill any empty columns, then deal) without re-planning from + // the intermediate positions. + guard let preparation = SpiderPlanner.dealPreparationLine(in: state) else { + return (.deadlock(foundation: foundationCount(state)), revisitEvents) + } + for action in preparation { + guard let next = applied(action) else { + fatalError("Seed \(seed): illegal Spider deal preparation") + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + } + } + return (.actionCap(foundation: foundationCount(state)), revisitEvents) +} + // MARK: - Control player // The random-moves floor calibrates each variant's deal universe. Deliberately @@ -313,12 +433,17 @@ func playYukonFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: // spend much of the endgame yanking banked cards back down). Two tap-policy // control players were evaluated and retired as uninformative; those findings // are recorded in README.md. -func playRandom(variant: GameVariant, seed: UInt64, drawCount: Int) -> Outcome { +func playRandom( + variant: GameVariant, + seed: UInt64, + drawCount: Int, + spiderSuitCount: SpiderSuitCount = .two +) -> Outcome { // No revisit check: a stochastic player legitimately revisits positions and // diverges by luck afterward, so it runs to a true deadlock or the action cap. // (The hint followers keep their revisit checks — they are deterministic, so // for them a revisit is a proven infinite loop.) - var state = seededDeal(variant: variant, seed: seed) + var state = seededDeal(variant: variant, seed: seed, spiderSuitCount: spiderSuitCount) var generator = SeededRandomNumberGenerator(seed: seed ^ 0xDEADBEEF) var actions = 0 while actions < actionCap(for: variant) { @@ -331,13 +456,17 @@ func playRandom(variant: GameVariant, seed: UInt64, drawCount: Int) -> Outcome { legal.append((selection, destination)) } } - let canTapStock = variant == .klondike && (!state.stock.isEmpty || !state.waste.isEmpty) + let canTapStock = (variant == .klondike && (!state.stock.isEmpty || !state.waste.isEmpty)) + || (variant == .spider && SpiderGameRules.canDealFromStock(state: state)) let choices = legal.count + (canTapStock ? 1 : 0) guard choices > 0 else { return .deadlock(foundation: foundationCount(state)) } let pick = Int(generator.next() % UInt64(choices)) if pick == legal.count { - guard let next = stockTap(state, drawCount: drawCount) else { + let tapped = variant == .spider + ? spiderStockDeal(state) + : stockTap(state, drawCount: drawCount) + guard let next = tapped else { fatalError("Seed \(seed): random stock tap with nothing to tap") } state = next @@ -432,7 +561,12 @@ func mapInParallel( return results.map { $0! } } -func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { +func run( + variant: GameVariant, + seeds: UInt64, + drawCount: Int, + spiderSuitCount: SpiderSuitCount = .two +) { let label: String switch variant { case .klondike: @@ -441,6 +575,8 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { label = "freecell" case .yukon: label = "yukon" + case .spider: + label = "spider \(spiderSuitCount.rawValue)-suit" } let start = DispatchTime.now() @@ -455,6 +591,8 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { return (playFreeCellFollowingHints(seed: seed), 0) case .yukon: return playYukonFollowingHints(seed: seed) + case .spider: + return playSpiderFollowingHints(seed: seed, suitCount: spiderSuitCount) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -468,13 +606,17 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { summarize("\(label) — following every hint", outcomes: followerOutcomes) print(String(format: "elapsed: %.1fs", seconds)) - if variant == .yukon { + if variant == .yukon || variant == .spider { print("hint revisit events: \(revisitEvents)") } if followerLoops > 0 { print("GATE VIOLATION: \(label) hint follower looped in \(followerLoops) game(s)") gateViolations += followerLoops } + // Spider revisit events are reported but not gated: the deal-preparation + // fallback deliberately plays score-losing fills, so a later line can + // transiently re-cross an earlier position (a handful per 500 deals). + // Yukon's planner measures zero, so for it any revisit is a regression. if variant == .yukon, revisitEvents > 0 { print("GATE VIOLATION: yukon hint follower revisited positions \(revisitEvents) time(s)") gateViolations += revisitEvents @@ -484,7 +626,7 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { seeds: seeds, progressLabel: "\(label) — random control" ) { seed in - playRandom(variant: variant, seed: seed, drawCount: drawCount) + playRandom(variant: variant, seed: seed, drawCount: drawCount, spiderSuitCount: spiderSuitCount) } summarize( "\(label) — random legal moves (control)", @@ -506,7 +648,10 @@ var gateViolations = 0 setvbuf(stdout, nil, _IOLBF, 0) func exitWithUsage() -> Never { - print("usage: run.sh [deals >= 1] [klondike draw count: 1 or 3]") + print( + "usage: run.sh [deals >= 1] " + + "[klondike draw count: 1 or 3 | spider suit count: 1, 2, or 4]" + ) exit(1) } @@ -515,8 +660,9 @@ let mode = arguments.count > 1 ? arguments[1] : "all" guard let seeds = arguments.count > 2 ? UInt64(arguments[2]) : 500, seeds >= 1 else { exitWithUsage() } -guard let draw = arguments.count > 3 ? Int(arguments[3]) : 1, - DrawMode(rawValue: draw) != nil else { +// The third argument is mode-specific: a Klondike draw count or a Spider suit count. +let modeOption = arguments.count > 3 ? Int(arguments[3]) : nil +if arguments.count > 3, modeOption == nil { exitWithUsage() } @@ -524,14 +670,27 @@ switch mode { case "yukon": run(variant: .yukon, seeds: seeds, drawCount: 3) case "klondike": - run(variant: .klondike, seeds: seeds, drawCount: draw) + guard let draw = DrawMode(rawValue: modeOption ?? 1) else { exitWithUsage() } + run(variant: .klondike, seeds: seeds, drawCount: draw.rawValue) case "freecell": run(variant: .freecell, seeds: seeds, drawCount: 3) +case "spider": + if let modeOption { + guard let suitCount = SpiderSuitCount(rawValue: modeOption) else { exitWithUsage() } + run(variant: .spider, seeds: seeds, drawCount: 3, spiderSuitCount: suitCount) + } else { + for suitCount in SpiderSuitCount.allCases { + run(variant: .spider, seeds: seeds, drawCount: 3, spiderSuitCount: suitCount) + } + } case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) run(variant: .klondike, seeds: seeds, drawCount: 3) run(variant: .freecell, seeds: seeds, drawCount: 3) + for suitCount in SpiderSuitCount.allCases { + run(variant: .spider, seeds: seeds, drawCount: 3, spiderSuitCount: suitCount) + } default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index 3142b9a..c608478 100755 --- a/tools/hint-probe/run.sh +++ b/tools/hint-probe/run.sh @@ -1,6 +1,6 @@ #!/bin/bash # Compiles the hint-quality probe against the UI-free Game sources and runs it. -# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count] +# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count | spider suit count] set -euo pipefail cd "$(dirname "$0")/../.." @@ -27,6 +27,10 @@ SOURCES=( ComputerSolitaire/Game/Yukon/GameRulesYukon.swift ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift ComputerSolitaire/Game/Yukon/YukonPlanner.swift + ComputerSolitaire/Game/Spider/GameStateSpider.swift + ComputerSolitaire/Game/Spider/GameRulesSpider.swift + ComputerSolitaire/Game/Spider/AutoMoveAdvisorSpider.swift + ComputerSolitaire/Game/Spider/SpiderPlanner.swift ) for source in "${SOURCES[@]}"; do