From 1ede8198008c6404166bf74cf4d2a88e1bdd61ba Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 13 Jul 2026 14:23:34 -0700 Subject: [PATCH 1/2] add forty thieves game variant --- .../Fixtures/ScreenshotFixtures.swift | 3 +- ComputerSolitaire/Fixtures/fortythieves.json | 1131 +++++++++++++++++ .../AutoMoveAdvisorFortyThieves.swift | 49 + .../FortyThieves/FortyThievesPlanner.swift | 420 ++++++ .../GamePersistenceFortyThieves.swift | 35 + .../FortyThieves/GameRulesFortyThieves.swift | 33 + .../GameSessionFortyThieves.swift | 54 + .../FortyThieves/GameStateFortyThieves.swift | 34 + .../Game/Klondike/AutoFinishPlanner.swift | 35 +- .../Game/Shared/AutoMoveAdvisor.swift | 26 +- ComputerSolitaire/Game/Shared/GameMode.swift | 10 +- .../Game/Shared/GamePersistence.swift | 33 +- .../Game/Shared/GameRulesShared.swift | 5 + .../Game/Shared/GameSession.swift | 24 +- .../Game/Shared/GameSessionInteraction.swift | 9 +- ComputerSolitaire/Game/Shared/GameState.swift | 4 +- .../Game/Shared/GameVariant.swift | 37 +- .../Game/Shared/HintAdvisor.swift | 51 + .../Game/Shared/TapMovePolicy.swift | 15 + .../FortyThieves/FortyThievesTopRowView.swift | 78 ++ .../Views/RulesAndScoringView.swift | 36 + .../Views/Shared/BoardViews.swift | 20 + .../Views/Shared/ContentView.swift | 4 +- .../Views/Shared/GameModePickerView.swift | 8 +- ComputerSolitaire/Views/StatisticsView.swift | 2 +- .../FortyThievesPersistenceTests.swift | 196 +++ .../FortyThievesPlannerTests.swift | 448 +++++++ .../FortyThieves/FortyThievesRulesTests.swift | 290 +++++ .../FortyThievesSessionTests.swift | 325 +++++ .../Shared/ScreenshotFixtureTests.swift | 78 ++ ComputerSolitaireTests/TestSupport.swift | 106 ++ README.md | 3 +- docs/rules/fortythieves.md | 56 + tools/hint-probe/README.md | 39 +- tools/hint-probe/main.swift | 131 +- tools/hint-probe/run.sh | 6 +- 36 files changed, 3770 insertions(+), 64 deletions(-) create mode 100644 ComputerSolitaire/Fixtures/fortythieves.json create mode 100644 ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift create mode 100644 ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift create mode 100644 ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift create mode 100644 ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift create mode 100644 ComputerSolitaire/Game/FortyThieves/GameSessionFortyThieves.swift create mode 100644 ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift create mode 100644 ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift create mode 100644 ComputerSolitaireTests/FortyThieves/FortyThievesPersistenceTests.swift create mode 100644 ComputerSolitaireTests/FortyThieves/FortyThievesPlannerTests.swift create mode 100644 ComputerSolitaireTests/FortyThieves/FortyThievesRulesTests.swift create mode 100644 ComputerSolitaireTests/FortyThieves/FortyThievesSessionTests.swift create mode 100644 docs/rules/fortythieves.md diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index f741f5e..1ef862e 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -35,7 +35,8 @@ enum ScreenshotFixtures { ScreenshotFixture(name: "spider", title: "Spider – 2 suits"), ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal"), ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal"), - ScreenshotFixture(name: "golf", title: "Golf – fresh deal") + ScreenshotFixture(name: "golf", title: "Golf – fresh deal"), + ScreenshotFixture(name: "fortythieves", title: "Forty Thieves – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/fortythieves.json b/ComputerSolitaire/Fixtures/fortythieves.json new file mode 100644 index 0000000..aeb2adf --- /dev/null +++ b/ComputerSolitaire/Fixtures/fortythieves.json @@ -0,0 +1,1131 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 1, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 1, + "state" : { + "discard" : [ + + ], + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "pyramid" : [ + + ], + "stock" : [ + { + "id" : "E48DEC8F-ED76-4F9F-A999-55D0008DE590", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "844641A2-D14F-4913-B139-CD78036C4B1C", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "6755B981-7E80-435E-912F-693CA175464B", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "B218B2D4-0C90-42F0-9F6E-2701B0856751", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "AB67D910-EB54-4BED-B43C-4F9DAF80678C", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "9213F7CF-EFFD-4A20-8FBE-6232B720B814", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "5A931357-5B27-4CE4-800C-EC24BE43AFF9", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2FAA084E-0AF9-4B1C-8223-9DF4F8502BAD", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "16AB9A6C-04EF-4A48-9923-C5E9319F0E49", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "E2DE6AFF-2393-4F96-8EE5-7CE3DB5C3480", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "042CDBD9-D488-4044-8321-C56A312443E0", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "EA70700D-B61B-49E9-ACBD-AC6539DD4893", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "9A53FFD0-B030-4DFF-8C76-9725BAD0DF77", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "631F875F-79FF-4A11-8577-76D5E93C67B3", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9E96E026-16F3-4C29-8E37-AF7438119256", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2AF87CFE-1519-4F5E-AC98-1076A17687EC", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "63164560-0304-4FD9-89AF-7517EB72E8E5", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "699268F1-DC87-4746-8FA6-9F7491CC0FC1", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "E3792C62-D0B7-468F-9203-0F8DEF0C1DBE", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0E992843-766B-4135-ACAB-B276C3BF785F", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "B1FD3ED6-E25E-4F9E-AB26-2839D6FA8F03", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "CAC2B423-14FE-4F48-AD1A-B03ADA96185A", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "8461C6D3-BBD1-477E-A140-69D4656FCDE0", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "3C35312F-6178-4CE2-BE37-DFB8F58FEC67", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "694B227A-E7F9-47CA-B5C7-E7B9B5FE760D", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "9E874F6A-9CBA-4DDC-8D6F-AD97CA317C7C", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "152A6D95-A92F-4024-BE01-DB847112EECC", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9450C37C-A497-4EAA-95CC-C13978EC54DD", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "8BB3C74C-A0A2-453E-B7AD-87E37675EAE2", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E4021F79-DA1D-41C7-9A2C-A601A61119BC", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "C0C3A542-414C-427F-BBF0-B9F10C71B0DA", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F9B6362D-463F-4C5C-818E-94AD15F64C69", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "9FC64341-497F-439F-B89A-E159161B8FCF", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C0ACA699-85CD-45C1-867F-B30D9A0116C6", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "0A9F6090-9DB7-43FC-B98B-1E2F78219016", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "7D52AD80-2C62-4192-997E-2AC63B6DA776", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "086AEE9F-D08A-44EB-90D8-34FD91000EC9", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "962FC3C4-378D-458D-B451-3EA22393934E", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "CBF7D7E8-2C5A-429F-9E4B-405AE2336F3B", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "197F0719-81FE-4514-BF6C-66A36E21E2BD", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "1F63C901-D4D5-4396-B291-1A3B73051EFA", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "CADA5D6D-CF5E-4B0D-8A0D-49A00E16D4C8", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "AE92221F-0745-415F-96DE-A57100D585B9", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "A02D4E59-BD18-4321-93D3-B99B1F18C600", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "84CA87ED-869F-4824-8063-0FDB27EBF259", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "F6F28942-C618-459F-80D6-F6265A4CBB91", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "D6F8B638-E59A-4624-94C8-A347D316C883", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "633AE3BB-0B87-4612-ADA9-69A240F1A0C5", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "A570D12D-897C-4AAF-9C4B-2B53CBE2D553", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "4720C033-3892-40D7-B3F5-17082F687E2A", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "6521250A-A613-4698-B06D-E967F57D0789", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "11403A97-3525-4F58-9566-403A3E2E4382", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "140A8314-28A9-4778-9037-A34C84C0ED22", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "E38C2D02-52AC-4076-AF16-A823464E8287", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "DF48764E-6A18-42E2-B90D-75F706E89288", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "562CC03D-0C60-4227-BB85-A07A6C6B6147", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "0B69AFE0-1807-4CED-BDE9-E041C1199841", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "4F2DC27A-9FA0-4542-A600-CFAA6D077DC7", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "272B5ADD-690D-4058-B549-65DD28875672", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "B911633F-0C1C-4120-AA2B-87074CA779A1", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "EABF3684-CBDB-4723-8D3D-4AC4E437EE84", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "A8921E36-12BB-4974-BC7D-2847FFE326F4", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0F23AA98-87B8-4FA9-BCB4-E64C817BE0FA", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + } + ], + "tableau" : [ + [ + { + "id" : "C5E6BC5B-3911-4A71-850B-E17DD48FF545", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "0B18705C-22BE-41FD-9DEC-4004A726FEAE", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "CE67BA0C-D870-4723-88B1-A1223CF1F185", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0557C63B-98C0-479A-A44B-DC9B481D7A5E", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "F293CE4F-5820-4A12-B27A-B65B3A5D2286", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "27FDEAFE-670D-44EF-BFB1-55EAF5E45308", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "F25FC3C2-76CB-4301-9335-12E1A31714B6", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "CFE5716F-8486-4E0D-B476-45753D23ED14", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "3E390885-2C2D-4507-9ABE-69CC1025FBD6", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "47891598-92BC-4EE0-834F-DB18FE5C4A80", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "734DF875-953A-4E68-8758-971892B8E084", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F0AAC0D5-13F5-48DF-A178-19B67C96C416", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "E1056188-5A16-47BF-92FF-43AA7AD26B4F", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "28569C10-8E18-4C45-9C20-670D3DAC4574", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "AE8BC8C6-4D3B-4DC4-BABB-2F7E8170C0B9", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "D7BF5C8C-665D-4F0A-B311-9833F1A72A88", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "E602D62F-62AD-4262-A7AD-BA5B2E270047", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "53FF237D-2109-4E85-818E-DB113B203AFF", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "FE98C04E-B2EC-42F5-981E-AFC563D98617", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "1454FF89-041B-406F-8B3A-663816B53B33", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "F45B5FB7-A796-4F06-8A47-81605E62923C", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "A2A16714-32CC-4501-B68D-4FA28E46A6B2", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "FB326599-26DF-4549-988F-0BF54879D5CF", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "9AA8BD18-27BA-475A-9226-39940AC21022", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "292929E6-7198-4D9D-B794-91020EF358FE", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "130F1B53-0255-4232-9D6C-49CC3E4B904C", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C9179BDF-E04F-4224-8E12-D1DA1E66399C", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "DE333907-9FC1-493B-B360-C3A4634E0DF7", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "202B013B-FD32-4AAA-9AE5-C138532EF00A", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "5B141DB1-F4F4-497E-8EE0-4E74D01F3EB8", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "DF55C98E-A914-4759-AE9C-4B033FC4253F", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "6DF7F79D-6BD9-49D4-AACA-0860CB57A45F", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "536B0B32-5C9B-44EF-BBB5-815408B52FEB", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "17EF3C66-0A8C-49C7-A268-8DF9E0469504", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "FE2A2B6A-B5BD-4096-A8B5-B2A6C51E1332", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "83196A8F-8FDC-4186-B2A2-95894C50941F", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "78EEA46F-9979-40C3-94BC-0EC1714B2C72", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "1FFA04C1-5FE5-4E4F-9C99-C4C6D39D13CB", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "B4F37318-F37C-4B53-AB4A-DE671A3B029B", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "17E0E834-91AB-491A-8416-EB443E07E55F", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + } + ] + ], + "triPeaks" : [ + + ], + "triPeaksChainLength" : 0, + "variant" : "fortythieves", + "waste" : [ + { + "id" : "A6F0E1E4-8495-4CEC-8E63-7232224D1493", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + } + ], + "wasteDrawCount" : 1, + "wasteRecyclesUsed" : 0 + }, + "stockDrawCount" : 1, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift new file mode 100644 index 0000000..a6d050e --- /dev/null +++ b/ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift @@ -0,0 +1,49 @@ +import Foundation + +enum FortyThievesAutoMoveAdvisor { + static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + // Forty Thieves' defining rule: only the exposed top card of a column + // moves — never a sequence, however well ordered. + cards.count == 1 + } + + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + // Foundations are locked: a card placed there never returns to the + // tableau. Belt and braces — `candidateSelections` never offers a + // foundation source for a rollback-free variant. + if case .foundation = selection.source { + return false + } + return true + } + + static func isRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + // Empty columns accept any card, so relocating a lone card between + // empty columns is a no-op no matter what it is. + AutoMoveAdvisor.isRedundantWholePileTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + // Forty Thieves has no auxiliary destination type beyond tableau/foundation. + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + // Every card deals (and stays) face up; nothing to flip. + } +} diff --git a/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift b/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift new file mode 100644 index 0000000..e7e205b --- /dev/null +++ b/ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift @@ -0,0 +1,420 @@ +import Foundation + +/// Bounded best-first hint planner for Forty Thieves. +/// +/// Searches sequences of real actions — single-card tableau and waste moves, +/// foundation banks, and stock taps — up to a node/time budget, scoring +/// positions by banked cards, open columns, suited descending order, developed +/// stock/waste cards, and how deeply the next foundation-needed cards are +/// buried. `bestLine` returns the whole action sequence to the best position +/// found that strictly improves on the current one; `HintPlanner` follows the +/// cached line action by action: like Spider and Yukon, every Forty Thieves +/// tableau move is reversible until a card banks or the stock turns, so +/// re-search after each move can oscillate between equally attractive lines, +/// while following one improving line ratchets the position strictly forward. +/// +/// The search reads the true state, including the face-down stock order, but +/// it only ever recommends actions that are legal right now. Searching through +/// stock taps is what lets the planner line up the plays a buried stock card +/// enables *before* recommending the tap; the tap itself is score-neutral +/// (each undeveloped stock or waste card carries the same penalty), so +/// tap-crossing lines only win when the plays they enable pay for them. +/// Foundations are locked and `candidateSelections` offers no foundation +/// sources for rollback-free variants, so there is no rollback stage. +enum FortyThievesPlanner { + struct Limits { + var maxNodes: Int + var maxDepth: Int + var deadline: Date? + + // Forty Thieves branches in Spider's class (eleven sources, few legal + // landings each, plus the tap), so it shares Spider's budget. + // Affordable because lines are cached: the search only runs when a + // followed line runs out, not on every hint. + init(maxNodes: Int = 30_000, maxDepth: Int = 64, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.maxDepth = maxDepth + self.deadline = deadline + } + } + + enum PlannedAction { + case move(selection: Selection, destination: Destination) + case stockTap + } + + enum SearchOutcome { + /// Actions leading to the best strictly-improving position found. + case line([PlannedAction]) + /// Nothing within the horizon improves on the current position. When the + /// search ran out of reachable states — rather than nodes, depth, or time — + /// that is proof the position cannot progress without a stock tap. + case noProgress(searchWasExhaustive: Bool) + } + + static func bestHint(in state: GameState, limits: Limits = Limits()) -> HintAdvisor.Hint? { + guard case .line(let actions) = bestLine(in: state, limits: limits), + let action = actions.first else { + return nil + } + return materialize(action, in: state) + } + + /// 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 single-pass stock only ever shrinks off a fixed + // order, so its count identifies its exact contents. The waste is spelled + // out in full: plays remove waste cards, so its contents are not + // derivable from the stock count, and its buried order shapes the future. + key.append("#\(state.stock.count)~") + for card in state.waste { append(card: card) } + 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 + } + + /// Re-validates a planned action against the live state; a stale cached + /// action surfaces as nil (and triggers a fresh search) rather than as an + /// illegal hint. + static func materialize(_ action: PlannedAction, in state: GameState) -> HintAdvisor.Hint? { + switch action { + 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 .stockTap: + guard !state.stock.isEmpty else { return nil } + return .stockTap + } + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .fortyThieves else { return .noProgress(searchWasExhaustive: false) } + return search(in: state, limits: limits) + } + + /// Applies an action without re-validating legality: the planner only feeds + /// in actions it just generated (the hint probe reuses this as the same + /// pure logic the session performs), and revalidating each one there + /// dominates search cost. Mirrors the session's move effects. + static func apply(_ action: PlannedAction, to state: GameState) -> GameState? { + var nextState = state + switch action { + case .move(let selection, let destination): + switch selection.source { + case .tableau(let pile, let index): + nextState.tableau[pile].removeSubrange(index.. 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 banks a card or opens a column is a solid hint; once + // one is in hand, cap how long we keep hunting for something better. + // Spider's floor, for the same wide-branching economics. + 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 emptyColumns = 0 + var suitedPairs = 0 + for pile in state.tableau { + if pile.isEmpty { + emptyColumns += 1 + continue + } + for index in 1.. Int { + var total = 0 + for suit in Suit.allCases { + var heights = state.foundations + .filter { $0.first?.suit == suit } + .map(\.count) + while heights.count < 2 { + heights.append(0) + } + for height in heights { + let neededRank = height + 1 + guard neededRank <= Rank.king.rawValue else { continue } + var shallowestBurial: Int? + for pile in state.tableau { + for index in pile.indices + where pile[index].suit == suit && pile[index].rank.rawValue == neededRank { + let burial = pile.count - 1 - index + shallowestBurial = min(shallowestBurial ?? burial, burial) + } + } + total += shallowestBurial ?? 0 + } + } + return total + } + + static func actions(from state: GameState) -> [PlannedAction] { + let firstEmptyColumn = state.tableau.firstIndex(where: \.isEmpty) + var actions: [PlannedAction] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + // Exact-equivalence canonicalizations, all invisible to the player: + // the two decks make twin destinations common, and searching both + // of a twin pair only multiplies permuted duplicates. + var tookFoundation = false + var seenTableauTops: [(suit: Suit, rank: Rank)] = [] + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + switch destination { + case .foundation: + // Two foundations legal for the same card hold identical + // runs by construction (same suit, same height), so keep + // the first. + if tookFoundation { continue } + tookFoundation = true + case .tableau(let index): + if state.tableau[index].isEmpty { + // Empty columns are interchangeable: canonicalize to the + // first. (Players can still drop on any empty column.) + if index != firstEmptyColumn { continue } + } else if let top = state.tableau[index].last { + // Twin tops are interchangeable landings: keep the + // lower-indexed column. + if seenTableauTops.contains(where: { $0.suit == top.suit && $0.rank == top.rank }) { + continue + } + seenTableauTops.append((top.suit, top.rank)) + } + case .freeCell, .pyramid, .waste, .discard: + continue + } + actions.append(.move(selection: selection, destination: destination)) + } + } + if !state.stock.isEmpty { + actions.append(.stockTap) + } + return actions + } + + /// FNV-1a over a canonical layout: tableau piles are sorted before mixing + /// because Forty Thieves columns are strategically interchangeable, each + /// suit's two foundations collapse to their sorted height pair (which twin + /// pile holds which run carries nothing), and the stock contributes only + /// its count (its order never changes within one game). The waste is mixed + /// in full — its buried order matters. Cards hash by content, so the twin + /// cards of the two decks collapse identical positions to one visited entry. + static func stateHash(_ state: GameState) -> 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)) + mix(0xFC) + for card in state.waste { mix(encode(card: card)) } + for suit in Suit.allCases { + mix(0xFE) + let heights = state.foundations + .filter { $0.first?.suit == suit } + .map(\.count) + .sorted() + for height in heights { mix(UInt8(height)) } + } + 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/Game/FortyThieves/GamePersistenceFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift new file mode 100644 index 0000000..14de6ea --- /dev/null +++ b/ComputerSolitaire/Game/FortyThieves/GamePersistenceFortyThieves.swift @@ -0,0 +1,35 @@ +import Foundation + +enum FortyThievesPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == FortyThievesGameRules.columnCount else { return false } + // Every board card is dealt (and stays) face up. No depth cap: columns + // grow past their dealt four when built on. + guard state.tableau.allSatisfy({ $0.allSatisfy(\.isFaceUp) }) else { return false } + // Forty Thieves 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 } + // The pyramid and TriPeaks fields belong to those variants alone; a + // card stranded there would be invisible here. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.triPeaks.isEmpty, + state.triPeaksChainLength == 0, state.wasteRecyclesUsed == 0 else { + return false + } + // The stock deals 64 cards and only ever shrinks, face down. + guard state.stock.count <= FortyThievesGameRules.dealStockCardCount else { return false } + guard state.stock.allSatisfy({ !$0.isFaceUp }) else { return false } + guard state.waste.allSatisfy(\.isFaceUp) else { return false } + // Draws and waste plays both leave exactly one card fanned while the + // waste holds any; the planner's state keys rely on this invariant. + guard state.wasteDrawCount == min(1, state.waste.count) else { return false } + return state.foundations.allSatisfy(isValidFoundationPile) + } + + /// A Forty Thieves foundation grows one suit from the Ace up; two + /// foundations per suit share the two decks. + private static func isValidFoundationPile(_ pile: [Card]) -> Bool { + guard !pile.isEmpty else { return true } + return SharedGameRules.isDescendingSameSuitRun(Array(pile.reversed())) + && pile.first?.rank == .ace + } +} diff --git a/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift new file mode 100644 index 0000000..8eac06c --- /dev/null +++ b/ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift @@ -0,0 +1,33 @@ +import Foundation + +enum FortyThievesGameRules { + static let columnCount = 10 + static let dealColumnDepth = 4 + static let dealTableauCardCount = 40 + /// The 104-card two-deck deal minus the 40-card board. + static let dealStockCardCount = 64 + + /// Tableau landing rule: an empty column takes any single card; otherwise + /// the moving card goes on the top card of the same suit, one rank higher. + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + guard let top = destinationPile.last else { return true } + return top.suit == card.suit && card.rank.rawValue == top.rank.rawValue - 1 + } + + /// Whether sending `card` to a foundation can never cost the game. Forty + /// Thieves foundations are locked, so an eager send is irrevocable — but + /// with two decks and same-suit building, only same-suit cards ever need + /// `card` as a tableau landing spot. Aces and twos are always safe; rank r + /// is safe once both foundations of its suit have reached r − 2, because + /// from then on every same-suit card that could want to land on `card` is + /// directly foundation-playable instead. + static func isSafeFoundationMove(card: Card, in state: GameState) -> Bool { + let rank = card.rank.rawValue + if rank <= 2 { return true } + let sameSuitTopRanks = state.foundations + .filter { $0.first?.suit == card.suit } + .map { $0.last?.rank.rawValue ?? 0 } + guard sameSuitTopRanks.count == 2 else { return false } + return sameSuitTopRanks.allSatisfy { $0 >= rank - 2 } + } +} diff --git a/ComputerSolitaire/Game/FortyThieves/GameSessionFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GameSessionFortyThieves.swift new file mode 100644 index 0000000..2d2e7e1 --- /dev/null +++ b/ComputerSolitaire/Game/FortyThieves/GameSessionFortyThieves.swift @@ -0,0 +1,54 @@ +import Foundation + +extension SolitaireViewModel { + // MARK: Configuration + + /// Forty Thieves draws a single card to the waste. The scoring draw count + /// keeps the draw-three basis the other stockless-choice variants use, so + /// the shared invariant that every variant defines a time-bonus basis + /// still holds. + func configureFortyThievesNewGame() { + setStockDrawCount(DrawMode.one.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(0) + } + + func configureFortyThievesRedeal() { + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(min(1, state.waste.count)) + } + + func sanitizeFortyThievesRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = min(1, sanitizedState.waste.count) + return sanitizedState + } + + // MARK: Scoring + + func applyFortyThievesMoveScore(for source: Selection.Source, destination: Destination) { + switch (source, destination) { + case (.waste, .tableau): + applyScore(.wasteToTableau) + case (.waste, .foundation): + applyScore(.wasteToFoundation) + case (.tableau, .foundation): + applyScore(.tableauToFoundation) + default: + break + } + } + + // MARK: Stock + + /// Flips one stock card onto the waste. Single pass: once the stock is + /// empty the slot goes dead — Forty Thieves never recycles. + func handleFortyThievesStockTap() { + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + guard !state.stock.isEmpty else { return } + drawFromStock() + } +} diff --git a/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift b/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift new file mode 100644 index 0000000..6f95faa --- /dev/null +++ b/ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift @@ -0,0 +1,34 @@ +import Foundation + +extension GameState { + /// The Forty Thieves deal: two full decks shuffled together, ten columns + /// of four face-up cards dealt column-major (column 0 bottom-to-top first, + /// then column 1, and so on), and the remaining 64 cards face down in the + /// stock. The waste starts empty and the eight foundations start empty. + /// The hint probe and test fixtures copy this dealing order verbatim — + /// change them together. + static func newFortyThievesGame() -> GameState { + var deck = (Card.fullDeck() + Card.fullDeck()).shuffled() + var tableau: [[Card]] = [] + + for _ in 0.. Bool { var identityCounts: [CardIdentity: Int] = [:] for card in allCards { @@ -908,19 +909,25 @@ private extension GameState { private var expectedIdentityCounts: [CardIdentity: Int] { switch variant { case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: - var counts: [CardIdentity: Int] = [:] - for suit in Suit.allCases { - for rank in Rank.allCases { - counts[CardIdentity(suit: suit, rank: rank)] = 1 - } - } - return counts + return Self.uniformIdentityCounts(copies: 1) + case .fortyThieves: + return Self.uniformIdentityCounts(copies: 2) case .spider: guard let suitCount = spiderSuitCount else { return [:] } return SpiderDeck.expectedIdentityCounts(suitCount: suitCount) } } + private static func uniformIdentityCounts(copies: Int) -> [CardIdentity: Int] { + var counts: [CardIdentity: Int] = [:] + for suit in Suit.allCases { + for rank in Rank.allCases { + counts[CardIdentity(suit: suit, rank: rank)] = copies + } + } + return counts + } + private var hasValidVariantPersistenceLayout: Bool { switch variant { case .klondike: @@ -937,6 +944,8 @@ private extension GameState { return TriPeaksPersistenceRules.hasValidLayout(state: self) case .golf: return GolfPersistenceRules.hasValidLayout(state: self) + case .fortyThieves: + return FortyThievesPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 46fe214..8fbcad5 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -29,6 +29,11 @@ enum GameRules { // Golf columns are never a destination; its one move flows // through GolfGameRules. return false + case .fortyThieves: + return FortyThievesGameRules.canMoveToTableau( + card: card, + destinationPile: destinationPile + ) } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 3e0a0d2..b6ed187 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -493,7 +493,7 @@ final class SolitaireViewModel { @discardableResult func startDragFromFoundation(index: Int) -> Bool { - guard state.variant.playerBuildsFoundations else { return false } + guard state.variant.allowsFoundationRollback else { return false } guard let top = state.foundations[index].last else { return false } clearHint() selection = Selection(source: .foundation(pile: index), cards: [top]) @@ -554,6 +554,8 @@ final class SolitaireViewModel { configureTriPeaksNewGame() case .golf: configureGolfNewGame() + case .fortyThieves: + configureFortyThievesNewGame() } } @@ -571,6 +573,8 @@ final class SolitaireViewModel { configureTriPeaksRedeal() case .golf: configureGolfRedeal() + case .fortyThieves: + configureFortyThievesRedeal() } } @@ -589,6 +593,8 @@ final class SolitaireViewModel { return sanitizeTriPeaksRedealState(state) case .golf: return sanitizeGolfRedealState(state) + case .fortyThieves: + return sanitizeFortyThievesRedealState(state) } } @@ -633,7 +639,7 @@ final class SolitaireViewModel { cardIndex: cardIndex, card: card ) - case .freecell, .pyramid, .tripeaks: + case .freecell, .pyramid, .tripeaks, .fortyThieves: return false } } @@ -680,7 +686,7 @@ final class SolitaireViewModel { return canSelectFreeCellTableauCards(cards) case .spider: return SharedGameRules.isDescendingSameSuitRun(cards) - case .golf: + case .golf, .fortyThieves: // Only the exposed card of a column can ever move. return cards.count == 1 case .pyramid, .tripeaks: @@ -718,6 +724,8 @@ extension SolitaireViewModel { handleTriPeaksStockTap() case .golf: handleGolfStockTap() + case .fortyThieves: + handleFortyThievesStockTap() case .freecell, .yukon: break } @@ -731,7 +739,7 @@ extension SolitaireViewModel { return !(state.stock.isEmpty && state.waste.isEmpty) case .pyramid: return !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) - case .tripeaks, .golf: + case .tripeaks, .golf, .fortyThieves: // Single pass with no recycles: an empty stock is dead. return !state.stock.isEmpty case .spider: @@ -747,7 +755,7 @@ extension SolitaireViewModel { case .klondike: let count = min(state.wasteDrawCount, stockDrawCount) return Array(state.waste.suffix(count)) - case .pyramid, .tripeaks, .golf: + case .pyramid, .tripeaks, .golf, .fortyThieves: return Array(state.waste.suffix(min(1, state.wasteDrawCount))) case .freecell, .yukon, .spider: return [] @@ -835,7 +843,7 @@ extension SolitaireViewModel { } func selectFromFoundation(index: Int) { - guard state.variant.playerBuildsFoundations else { return } + guard state.variant.allowsFoundationRollback else { return } guard let top = state.foundations[index].last else { return } selection = Selection(source: .foundation(pile: index), cards: [top]) } @@ -953,7 +961,7 @@ extension SolitaireViewModel { switch state.variant { case .klondike, .yukon, .spider: flipFaceDownTopCardIfNeeded(in: pileIndex) - case .freecell, .pyramid, .tripeaks, .golf: + case .freecell, .pyramid, .tripeaks, .golf, .fortyThieves: break } } @@ -1011,6 +1019,8 @@ extension SolitaireViewModel { // Golf stroke scoring reads the after state, so `performGolfMove` // applies it directly. break + case .fortyThieves: + applyFortyThievesMoveScore(for: source, destination: destination) } } diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift index ead95fa..5f908de 100644 --- a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -16,8 +16,11 @@ extension SolitaireViewModel { if state.variant == .spider, !canSelectTableauCards(cards) { return false } - if state.variant == .golf, cardIndex != pile.count - 1 { - // Only the exposed card of a Golf column can move. + if state.variant == .golf || state.variant == .fortyThieves, + cardIndex != pile.count - 1 { + // Only the exposed card of a Golf or Forty Thieves column can + // move; without this guard a buried-card drag would build a + // multi-card selection whose legality checks only see its first card. return false } selection = Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) @@ -86,7 +89,7 @@ extension SolitaireViewModel { state.tableau.indices.contains(pile), index == state.tableau[pile].count - 1 else { return false } return GolfGameRules.canPlay(column: pile, in: state) - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .fortyThieves: return false } diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index 139534f..570a230 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -86,7 +86,7 @@ struct GameState: Equatable, Codable { var isWon: Bool { switch variant { - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .fortyThieves: // Won once every foundation holds a full run (Ace-to-King on the // build-up variants, a banked King-to-Ace run per Spider foundation). return foundations.allSatisfy { $0.count == Rank.allCases.count } @@ -122,6 +122,8 @@ struct GameState: Equatable, Codable { return newTriPeaksGame() case .golf: return newGolfGame() + case .fortyThieves: + return newFortyThievesGame() } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index a6e5b22..3b1632c 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -10,6 +10,7 @@ enum GameVariant: String, CaseIterable, Codable { case tripeaks case pyramid case golf + case fortyThieves = "fortythieves" case yukon var title: String { @@ -28,6 +29,8 @@ enum GameVariant: String, CaseIterable, Codable { return "TriPeaks" case .golf: return "Golf" + case .fortyThieves: + return "Forty Thieves" } } @@ -47,6 +50,8 @@ enum GameVariant: String, CaseIterable, Codable { return "Chain up or down the ranks" case .golf: return "Play one rank up or down" + case .fortyThieves: + return "Two decks, build down by suit" } } @@ -56,7 +61,7 @@ enum GameVariant: String, CaseIterable, Codable { return 7 case .freecell: return 8 - case .spider, .tripeaks: + case .spider, .tripeaks, .fortyThieves: return 10 } } @@ -68,7 +73,7 @@ enum GameVariant: String, CaseIterable, Codable { switch self { case .klondike, .yukon, .spider: return true - case .freecell, .pyramid, .tripeaks, .golf: + case .freecell, .pyramid, .tripeaks, .golf, .fortyThieves: return false } } @@ -77,7 +82,7 @@ enum GameVariant: String, CaseIterable, Codable { /// stock but deals it onto the tableau, never into a waste. var dealsFromStock: Bool { switch self { - case .klondike, .pyramid, .tripeaks, .golf: + case .klondike, .pyramid, .tripeaks, .golf, .fortyThieves: return true case .freecell, .yukon, .spider: return false @@ -85,23 +90,24 @@ enum GameVariant: String, CaseIterable, Codable { } /// 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. + /// eight completed King-to-Ace runs in foundations, Forty Thieves builds + /// two foundations per suit from its two decks; the other variants build + /// one foundation per suit. var foundationPileCount: Int { switch self { case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: return 4 - case .spider: + case .spider, .fortyThieves: return 8 } } - /// How many cards a deal uses. Spider plays with two decks. + /// How many cards a deal uses. Spider and Forty Thieves play with two decks. var deckCardCount: Int { switch self { case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: return 52 - case .spider: + case .spider, .fortyThieves: return 104 } } @@ -113,13 +119,26 @@ enum GameVariant: String, CaseIterable, Codable { /// foundations as a drag, drop, or tap target. var playerBuildsFoundations: Bool { switch self { - case .klondike, .freecell, .yukon: + case .klondike, .freecell, .yukon, .fortyThieves: return true case .spider, .pyramid, .tripeaks, .golf: return false } } + /// Whether a card already on a foundation may be picked back up (a scored + /// rollback in Klondike, FreeCell, and Yukon). Forty Thieves builds its + /// foundations but locks them: a placed card never returns to play. The + /// variants that never build foundations have nothing to roll back. + var allowsFoundationRollback: Bool { + switch self { + case .klondike, .freecell, .yukon: + return true + case .spider, .pyramid, .tripeaks, .golf, .fortyThieves: + return false + } + } + /// Golf keeps a stroke-style score: lower is better, the win adds no time /// bonus, the floor-0 clamp does not apply (clearing the board subtracts /// one point per leftover stock card, so negative finals are the best diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index af54a71..78de3a0 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -31,6 +31,9 @@ enum HintAdvisor { if state.variant == .golf, !state.stock.isEmpty { return true } + if state.variant == .fortyThieves, !state.stock.isEmpty { + 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 @@ -95,6 +98,14 @@ enum HintAdvisor { /// not one more column card is clearable. Its ratchet matches TriPeaks' — /// every Golf move consumes a card — so a followed line can never revisit a /// position. +/// Forty Thieves hints work like Spider's: cached `FortyThievesPlanner` +/// improving lines that may include stock taps, followed to their end before +/// re-planning (its single-card tableau moves are just as reversible until a +/// card banks or the stock turns). When no improving line exists but stock +/// remains, the fallback is a single stock tap — unlike Spider's deal +/// preparation it costs nothing and strictly shrinks the stock, so it can +/// never cycle — and silence comes only when the stock is out and nothing +/// searched improves. final class HintPlanner { /// How long a single interactive hint request may spend searching. private static let freeCellSearchBudget: TimeInterval = 0.3 @@ -108,6 +119,7 @@ final class HintPlanner { /// the rare hard deal beats truncating a provably winnable position into /// a best-effort line. private static let golfSearchBudget: TimeInterval = 0.5 + private static let fortyThievesSearchBudget: TimeInterval = 0.3 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] @@ -115,6 +127,7 @@ final class HintPlanner { private var pyramidPlan: [String: PyramidPlanner.Move] = [:] private var triPeaksPlan: [String: TriPeaksPlanner.Move] = [:] private var golfPlan: [String: GolfPlanner.Move] = [:] + private var fortyThievesPlan: [String: FortyThievesPlanner.PlannedAction] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -138,6 +151,8 @@ final class HintPlanner { return triPeaksHint(in: state) case .golf: return golfHint(in: state) + case .fortyThieves: + return fortyThievesHint(in: state) } } } @@ -340,6 +355,42 @@ private extension HintPlanner { } } + func fortyThievesHint(in state: GameState) -> HintAdvisor.Hint? { + let key = FortyThievesPlanner.stateKey(for: state) + if let hint = plannedFortyThievesHint(for: key, in: state) { + return hint + } + + fortyThievesPlan.removeAll() + let limits = FortyThievesPlanner.Limits( + deadline: Date().addingTimeInterval(Self.fortyThievesSearchBudget) + ) + switch FortyThievesPlanner.bestLine(in: state, limits: limits) { + case .line(let line): + fortyThievesPlan = FortyThievesPlanner.keyedActions(along: line, from: state) + return plannedFortyThievesHint(for: key, in: state) + + case .noProgress: + // The searched region holds no improvement, so the way forward is + // the next stock card — what a strong player does with a + // groomed-but-stuck board. Deliberately offered for truncated + // no-progress too, not just exhaustive proof (Spider's measured + // lesson: hints withheld are games stalled), and deliberately not + // cached: the fallback tap is one action, strictly monotone, and + // the fresh waste top may unlock an improving line worth a fresh + // search. Silence only when the stock is out too — then the shared + // candidate scan in `anyPlayerMoveExists` is the exact loss test. + guard !state.stock.isEmpty else { return nil } + return .stockTap + } + } + + func plannedFortyThievesHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + // materialize re-validates the cached action against the live state. + guard let action = fortyThievesPlan[key] else { return nil } + return FortyThievesPlanner.materialize(action, in: state) + } + func plannedSpiderHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { switch spiderPlan[key] { case .move(let selection, let destination): diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 7c873e1..1568c81 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -98,6 +98,11 @@ 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 .fortyThieves: + // Foundations are locked on top of the strand risk, so the + // safety gate matters even more; the shared rule assumes one + // foundation per suit, so Forty Thieves brings its own. + tier = FortyThievesGameRules.isSafeFoundationMove(card: card, in: state) ? 100 : 60 case .spider: // Unreachable: Spider foundations are never player destinations. tier = 100 @@ -127,6 +132,16 @@ private extension TapMovePolicy { pileOrder: -index ) } + if state.variant == .fortyThieves { + // Every legal landing builds by suit, so ties break on the + // longer suited run; empty columns are Forty Thieves' scarcest + // resource and stay the last resort. + return Priority( + tier: pile.isEmpty ? 40 : 80, + buildLength: topSameSuitRunLength(of: pile) + selection.cards.count, + pileOrder: -index + ) + } let tier = pile.isEmpty ? 40 : 80 return Priority( tier: tier, diff --git a/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift b/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift new file mode 100644 index 0000000..7b5d1ca --- /dev/null +++ b/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import Observation + +struct FortyThievesTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let wasteFanSpacing: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let isStockHinted: Bool + let isWasteHinted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let drawingCardIDs: Set + let fanProgress: [UUID: Double] + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + // Stock and waste on the left like Klondike's, then the eight + // foundations — two per suit — aligned over tableau columns 3-10. + StockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken + ) + .frame(width: cardSize.width, alignment: .leading) + + WasteView( + viewModel: viewModel, + cardSize: cardSize, + fanSpacing: wasteFanSpacing, + isHintTargeted: isWasteHinted, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + + // Iterate the piles the state actually holds, not a fixed 0..<8: + // during a game switch this row can re-evaluate against the + // incoming variant's four-foundation state before the board + // replaces it. + ForEach(viewModel.state.foundations.indices, id: \.self) { index in + FoundationView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isTargeted: activeTarget == .foundation(index), + isHintTargeted: hintedTarget == .foundation(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index a40e6f5..9846fc7 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -311,6 +311,22 @@ struct RulesAndScoringView: View { definition: "45 strokes for a nine-hole match. Like golf, lower is better." ) ] + case .fortyThieves: + return [ + TermRow( + term: "Tableau", + definition: "Ten columns of four face-up cards; build down by suit, one card at a time." + ), + TermRow( + term: "Foundations", + definition: "Eight suit piles built up from Ace to King — two per suit; cards placed here never return." + ), + TermRow(term: "Stock", definition: "The face-down draw pile. One pass only — there are no recycles."), + TermRow( + term: "Waste", + definition: "Face-up cards drawn from the stock; only the top card is playable." + ) + ] } } @@ -384,6 +400,15 @@ struct RulesAndScoringView: View { "The hole ends when you clear all 35 column cards, or when the stock is spent and nothing plays.", "A match is nine holes; the lowest total wins. Switching games keeps the match — it resumes with your Golf session." ] + case .fortyThieves: + return [ + "Two decks (104 cards) are dealt into ten tableau columns of four face-up cards. The remaining 64 cards form the stock.", + "Build tableau columns down by suit, one rank at a time. Only the exposed top card of a column may move — sequences never move together.", + "Any single available card — an exposed tableau card or the top waste card — may fill an empty column.", + "Build the eight foundations up by suit from Ace to King, two per suit. Cards placed on a foundation never return to play.", + "Tap the stock to flip one card onto the waste, whenever you like. The stock allows a single pass — there are no recycles.", + "You win by moving all 104 cards to foundations. The game is lost when the stock is spent and no legal move remains." + ] } } @@ -474,6 +499,17 @@ struct RulesAndScoringView: View { note: "Per stock card left — scores below zero are the best results." ) ] + case .fortyThieves: + return [ + ScoringRow(move: "Waste to Tableau", points: Scoring.delta(for: .wasteToTableau), note: nil), + ScoringRow(move: "Waste to Foundation", points: Scoring.delta(for: .wasteToFoundation), note: nil), + ScoringRow(move: "Tableau to Foundation", points: Scoring.delta(for: .tableauToFoundation), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) + ] } } } diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 21d737e..a7a64a8 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -505,6 +505,26 @@ struct TopRowView: View { fanProgress: fanProgress, dragGesture: dragGesture ) + case .fortyThieves: + FortyThievesTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + wasteFanSpacing: wasteFanSpacing, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + isStockHinted: isStockHinted, + isWasteHinted: isWasteHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) } } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index cbdc63a..d4d480e 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -1719,7 +1719,7 @@ struct ContentView: View { return [viewModel.state.discard] case .tripeaks, .golf: return [viewModel.state.waste] - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .fortyThieves: return viewModel.state.foundations } } @@ -1730,7 +1730,7 @@ struct ContentView: View { return [.discard] case .tripeaks, .golf: return [.waste] - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .fortyThieves: return viewModel.state.foundations.indices.map(DropTarget.foundation) } } diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index fc6dd67..e847c48 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -363,7 +363,7 @@ private struct MiniBoardView: View { pyramidRows case .tripeaks: triPeaksRows - case .klondike, .spider, .freecell, .yukon, .golf: + case .klondike, .spider, .freecell, .yukon, .golf, .fortyThieves: tableauRow } } @@ -403,6 +403,10 @@ private struct MiniBoardView: View { miniCard(.faceDown) Spacer(minLength: 0) foundationSlots(count: 8) + case .fortyThieves: + miniCard(.faceDown) + miniCard(.slot) + foundationSlots(count: 8) case .pyramid: miniCard(.faceDown) miniCard(.slot) @@ -509,6 +513,8 @@ private struct MiniBoardView: View { } case .golf: return Array(repeating: Array(repeating: .faceUp, count: 5), count: 7) + case .fortyThieves: + return Array(repeating: Array(repeating: .faceUp, count: 4), count: 10) case .pyramid, .tripeaks: return [] } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 7adaff1..d50d5fb 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -403,7 +403,7 @@ private struct GameStatisticsDetailView: View { return stats.highScoreTwoSuits case .spiderFourSuits: return stats.highScoreFourSuits - case .freecell, .pyramid, .tripeaks, .yukon: + case .freecell, .pyramid, .tripeaks, .yukon, .fortyThieves: return stats.highScore case .golf: return nil diff --git a/ComputerSolitaireTests/FortyThieves/FortyThievesPersistenceTests.swift b/ComputerSolitaireTests/FortyThieves/FortyThievesPersistenceTests.swift new file mode 100644 index 0000000..18c18a6 --- /dev/null +++ b/ComputerSolitaireTests/FortyThieves/FortyThievesPersistenceTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FortyThievesPersistenceTests: XCTestCase { + private func payload( + for state: GameState, + score: Int = 0, + stockDrawCount: Int = DrawMode.one.rawValue + ) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + score: score, + stockDrawCount: stockDrawCount, + history: [] + ) + } + + /// A mid-game shape: two stock cards flipped, the first played onto a + /// column, and one of the deal's aces banked onto a foundation — pulled + /// from wherever the deal put it so the 104-card census stays intact. + private func midGameState() -> GameState { + var state = GameStateFixtures.seededFortyThievesDeal(seed: 2) + for _ in 0..<2 { + var drawn = state.stock.removeLast() + drawn.isFaceUp = true + state.waste.append(drawn) + } + let played = state.waste.removeFirst() + state.tableau[0].append(played) + if let stockIndex = state.stock.lastIndex(where: { $0.rank == .ace }) { + var ace = state.stock.remove(at: stockIndex) + ace.isFaceUp = true + state.foundations[0] = [ace] + } else if let pileIndex = state.tableau.firstIndex(where: { $0.last?.rank == .ace }), + let ace = state.tableau[pileIndex].popLast() { + state.foundations[0] = [ace] + } + state.wasteDrawCount = 1 + return state + } + + func testFreshDealRoundTripsThroughSanitization() throws { + let state = GameState.newFortyThievesGame() + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual(restored.state, state) + XCTAssertEqual(restored.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testMidGameStateSurvivesEncodeDecode() throws { + let state = midGameState() + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertEqual(decoded, state) + + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + XCTAssertEqual(try XCTUnwrap(sanitized).state, state) + } + + func testSanitizationForcesFortyThievesDrawCounts() throws { + let state = GameState.newFortyThievesGame() + let sanitized = payload(for: state, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual( + restored.stockDrawCount, + DrawMode.one.rawValue, + "Forty Thieves always draws a single card" + ) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testSanitizationRejectsCorruptFortyThievesStates() { + func assertRejected( + _ message: String, + mutate: (inout GameState) -> Void + ) { + var state = midGameState() + mutate(&state) + XCTAssertNil( + payload(for: state).sanitizedForRestore(at: DateFixtures.reference), + message + ) + } + + assertRejected("Wrong column count") { state in + state.tableau.removeLast() + } + assertRejected("Wrong foundation count") { state in + state.foundations.removeLast() + } + assertRejected("A face-down board card breaks the all-face-up invariant") { state in + state.tableau[1][0].isFaceUp = false + } + assertRejected("A face-up stock card breaks the stock invariant") { state in + state.stock[0].isFaceUp = true + } + assertRejected("A stock beyond 64 cards exceeds the deal") { state in + var card = state.tableau[0].removeLast() + card.isFaceUp = false + state.stock.append(card) + while state.stock.count <= FortyThievesGameRules.dealStockCardCount { + var filler = state.tableau[1].removeLast() + filler.isFaceUp = false + state.stock.append(filler) + } + } + assertRejected("A missing card breaks deck composition") { state in + state.tableau[2].removeLast() + } + assertRejected("A third copy of one identity breaks deck composition") { state in + let copied = state.tableau[6][0] + state.tableau[7][0] = TestCards.make(copied.suit, copied.rank) + } + assertRejected("Cards stranded in the pyramid field are invisible") { state in + state.pyramid = [state.waste.removeLast()] + state.wasteDrawCount = 0 + } + assertRejected("Cards stranded in the triPeaks field are invisible") { state in + state.triPeaks = [state.waste.removeLast()] + state.wasteDrawCount = 0 + } + assertRejected("Cards stranded in the discard are invisible") { state in + state.discard = [state.waste.removeLast()] + state.wasteDrawCount = 0 + } + assertRejected("Cards stranded in a free cell are invisible") { state in + state.freeCells[0] = state.waste.removeLast() + state.wasteDrawCount = 0 + } + assertRejected("Forty Thieves never recycles the waste") { state in + state.wasteRecyclesUsed = 1 + } + assertRejected("A fanned card the waste does not hold") { state in + state.tableau[0].append(contentsOf: state.waste) + state.waste = [] + state.wasteDrawCount = 1 + } + } + + func testLayoutRuleRejectsCorruptFoundationPiles() { + // Census-independent checks against the layout rule directly: a + // foundation grows one suit from the Ace up, or it is corrupt. + let nonAceBase = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [[TestCards.make(.spades, .two)]] + ) + XCTAssertFalse(FortyThievesPersistenceRules.hasValidLayout(state: nonAceBase)) + + let offSuitRun = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [[TestCards.make(.spades, .ace), TestCards.make(.hearts, .two)]] + ) + XCTAssertFalse(FortyThievesPersistenceRules.hasValidLayout(state: offSuitRun)) + + let skippedRank = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [[TestCards.make(.spades, .ace), TestCards.make(.spades, .three)]] + ) + XCTAssertFalse(FortyThievesPersistenceRules.hasValidLayout(state: skippedRank)) + + let validRun = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [[TestCards.make(.spades, .ace), TestCards.make(.spades, .two)]] + ) + XCTAssertTrue(FortyThievesPersistenceRules.hasValidLayout(state: validRun)) + } + + func testLayoutRuleRejectsWrongWasteDrawCount() { + // The waste always fans exactly one card while it holds any; both a + // hidden waste top and a Klondike-style multi-card fan are corrupt. + var state = midGameState() + state.wasteDrawCount = 0 + XCTAssertFalse(FortyThievesPersistenceRules.hasValidLayout(state: state)) + + state = midGameState() + state.wasteDrawCount = 2 + XCTAssertFalse(FortyThievesPersistenceRules.hasValidLayout(state: state)) + } + + func testViewModelRestoresFortyThievesPayload() throws { + let state = midGameState() + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: payload(for: state))) + XCTAssertEqual(viewModel.gameVariant, .fortyThieves) + XCTAssertEqual(viewModel.gameMode, .fortyThieves) + XCTAssertEqual(viewModel.state, state) + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + } +} diff --git a/ComputerSolitaireTests/FortyThieves/FortyThievesPlannerTests.swift b/ComputerSolitaireTests/FortyThieves/FortyThievesPlannerTests.swift new file mode 100644 index 0000000..aabbd9f --- /dev/null +++ b/ComputerSolitaireTests/FortyThieves/FortyThievesPlannerTests.swift @@ -0,0 +1,448 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FortyThievesPlannerTests: XCTestCase { + func testHintIsDeterministicAcrossCalls() { + let state = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .nine), TestCards.make(.spades, .seven)], + [TestCards.make(.spades, .eight)], + [TestCards.make(.clubs, .four)] + ] + ) + + let first = FortyThievesPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(FortyThievesPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal always has a hint through the stack: an improving line + // when the search finds one, the stock-tap fallback otherwise (the + // 64-card stock guarantees a legal draw). + for seed in 1...10 { + let state = GameStateFixtures.seededFortyThievesDeal(seed: UInt64(seed)) + XCTAssertNotNil( + HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.one.rawValue), + "Seed \(seed): a fresh Forty Thieves deal should always yield a hint" + ) + } + } + + func testHintsAreAlwaysLegalFromArbitraryMidGamePositions() { + let limits = FortyThievesPlanner.Limits(maxNodes: 2_000) + var generator = SeededRandomNumberGenerator(seed: 99) + + for seed in 1...5 { + var state = GameStateFixtures.seededFortyThievesDeal(seed: UInt64(seed)) + for _ in 0..<8 { + guard let next = randomLegalSuccessor(of: state, using: &generator) else { break } + state = next + } + + guard let hint = FortyThievesPlanner.bestHint(in: state, limits: limits) else { continue } + switch hint { + case .move(let move): + XCTAssertTrue(AutoMoveAdvisor.selectionMatchesState(move.selection, in: state)) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: move.selection, in: state) + .contains(move.destination) + ) + XCTAssertEqual(move.selection.cards.count, 1, "Sequences never move in Forty Thieves") + if case .foundation = move.selection.source { + XCTFail("A hint must never move a foundation card") + } + case .stockTap: + XCTAssertFalse(state.stock.isEmpty) + } + } + } + + 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. When no + // line exists the fallback is a single stock tap, mirroring + // `HintPlanner`; it strictly shrinks the stock so it can never cycle. + let limits = FortyThievesPlanner.Limits(maxNodes: 4_000) + for seed in [11, 12] as [UInt64] { + var state = GameStateFixtures.seededFortyThievesDeal(seed: seed) + var visitCounts: [UInt64: Int] = [stateFingerprint(state): 1] + var actions = 0 + func record(_ key: UInt64) -> Bool { + let count = (visitCounts[key] ?? 0) + 1 + visitCounts[key] = count + if count >= 3 { + XCTFail("Following planned lines revisited the same position twice") + return false + } + return true + } + while actions < 400 { + switch FortyThievesPlanner.bestLine(in: state, limits: limits) { + case .line(let line): + 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" + ) + guard record(key) else { return } + } + case .noProgress: + guard !state.stock.isEmpty, + let next = applied(FortyThievesPlanner.PlannedAction.stockTap, to: state) else { + return + } + state = next + actions += 1 + guard record(stateFingerprint(state)) else { return } + } + } + } + } + + func testKeyedActionsFollowTheLineInLockstepWithTheSharedSimulation() { + // Replaying the line through `AutoMoveAdvisor.simulatedState` must land + // on exactly the keys `keyedActions` mapped: this pins the planner's + // internal transition to the session's shared move algebra. + let state = GameStateFixtures.seededFortyThievesDeal(seed: 3) + guard case .line(let line) = FortyThievesPlanner.bestLine( + in: state, + limits: FortyThievesPlanner.Limits(maxNodes: 4_000) + ) else { + return XCTFail("Expected an improving line on a fresh deal") + } + + let keyed = FortyThievesPlanner.keyedActions(along: line, from: state) + XCTAssertEqual(keyed.count, line.count, "Every step along the line keys one action") + + var current = state + for action in line { + let key = FortyThievesPlanner.stateKey(for: current) + guard let planned = keyed[key] else { + return XCTFail("A followed position lost its cached action") + } + XCTAssertNotNil( + FortyThievesPlanner.materialize(planned, in: current), + "The cached action must re-validate against the live position" + ) + guard let next = applied(action, to: current) else { + return XCTFail("Planned action was not legal") + } + current = next + } + } + + func testMaterializeRejectsStaleActions() { + let sevenSpades = TestCards.make(.spades, .seven) + let state = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .nine), sevenSpades], + [TestCards.make(.spades, .eight)] + ] + ) + guard let selection = AutoMoveAdvisor.candidateSelections(in: state).first(where: { + $0.cards.first?.id == state.tableau[0][1].id + }) else { + return XCTFail("Expected the exposed 7♠ selection") + } + let action = FortyThievesPlanner.PlannedAction.move( + selection: selection, + destination: .tableau(1) + ) + XCTAssertNotNil(FortyThievesPlanner.materialize(action, in: state)) + + // The same action goes stale once the card has moved away… + var moved = state + let card = moved.tableau[0].removeLast() + moved.tableau[1].append(card) + XCTAssertNil(FortyThievesPlanner.materialize(action, in: moved)) + + // …or once the destination stopped accepting it. + var blocked = state + blocked.tableau[1].append(TestCards.make(.clubs, .two)) + XCTAssertNil(FortyThievesPlanner.materialize(action, in: blocked)) + + // A stock tap is stale once the stock is out. + XCTAssertNil(FortyThievesPlanner.materialize(.stockTap, in: state)) + } + + func testTruncatedSearchReportsNoProgressWithoutClaimingProof() { + // A one-node budget cannot explore a fresh deal, so the search must + // report truncation — not exhaustion, which would wrongly claim the + // position is stuck — and the planner yields no unverified nudge. + let limits = FortyThievesPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededFortyThievesDeal(seed: 5) + + guard case .noProgress(searchWasExhaustive: false) = FortyThievesPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(FortyThievesPlanner.bestHint(in: state, limits: limits)) + } + + func testNoHintEverMovesAFoundationCard() { + // A rollback would join the 6♠ onto the 7♠, but foundations are + // locked; whatever the planner suggests, it must not be the 7♠ coming + // back down. + let state = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.spades, .six)], + [TestCards.make(.spades, .eight)], + [TestCards.make(.hearts, .four)] + ], + foundations: [ + Rank.allCases.filter { $0.rawValue <= 7 }.map { TestCards.make(.spades, $0) } + ] + ) + + if case .move(let move)? = FortyThievesPlanner.bestHint(in: state) { + if case .foundation = move.selection.source { + XCTFail("A hint must never move a foundation card") + } + } + } + + func testStockTapHintWhenStockNonemptyAndNothingBetter() { + // No same-suit adjacency, no empty column, no waste play, and only + // unplayable kings left in the stock: no improving line exists, so the + // hint stack must point at the stock — the only way forward. + let state = stuckBoard( + stock: [TestCards.make(.diamonds, .king), TestCards.make(.hearts, .king)] + ) + + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + XCTAssertEqual( + HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.one.rawValue), + .stockTap + ) + } + + func testDeadlockedStateReturnsNilAndReportsNoMoves() { + let state = stuckBoard(stock: []) + + XCTAssertNil(FortyThievesPlanner.bestHint(in: state)) + XCTAssertNil(HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.one.rawValue)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testHintPrefersUnburyingNeededCardOverNeutralShuffle() { + // Moving the 9♥ onto the 10♥ uncovers the 5♠ the spade foundation + // needs next; hopping the 7♦ onto the 8♦ forms a pair but digs out + // nothing (base cards keep either move from opening a column). The + // hint should start the unburying line. + let nineHearts = TestCards.make(.hearts, .nine) + let state = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.spades, .five), nineHearts], + [TestCards.make(.diamonds, .queen), TestCards.make(.hearts, .ten)], + [TestCards.make(.clubs, .queen), TestCards.make(.diamonds, .seven)], + [TestCards.make(.spades, .jack), TestCards.make(.diamonds, .eight)] + ], + foundations: [ + Rank.allCases.filter { $0.rawValue <= 4 }.map { TestCards.make(.spades, $0) } + ] + ) + + guard case .move(let move)? = FortyThievesPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, nineHearts.id) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testHintTargetsTheFirstEmptyColumnAndTheFirstTwinDestination() { + // Emptying onto interchangeable landings is canonicalized: with two + // empty columns the hint points at the first, and with twin 8♠ tops it + // points at the lower-indexed column. + let nineHearts = TestCards.make(.hearts, .nine) + let emptyColumnBoard = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.spades, .ace), nineHearts], + [TestCards.make(.clubs, .five), TestCards.make(.diamonds, .two)], + [TestCards.make(.clubs, .jack), TestCards.make(.diamonds, .queen)] + ] + ) + guard case .move(let move)? = FortyThievesPlanner.bestHint(in: emptyColumnBoard) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, nineHearts.id) + XCTAssertEqual(move.destination, .tableau(3), "Drops canonicalize to the first empty column") + + let sevenSpades = TestCards.make(.spades, .seven) + let twinBoard = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .four), sevenSpades], + [TestCards.make(.diamonds, .nine), TestCards.make(.spades, .eight)], + [TestCards.make(.clubs, .nine), TestCards.make(.spades, .eight)], + [TestCards.make(.hearts, .jack), TestCards.make(.hearts, .queen)], + [TestCards.make(.clubs, .jack), TestCards.make(.clubs, .queen)], + [TestCards.make(.diamonds, .jack), TestCards.make(.diamonds, .queen)], + [TestCards.make(.spades, .jack), TestCards.make(.spades, .queen)], + [TestCards.make(.hearts, .two), TestCards.make(.clubs, .four)], + [TestCards.make(.diamonds, .three), TestCards.make(.hearts, .six)], + [TestCards.make(.clubs, .three), TestCards.make(.diamonds, .six)] + ] + ) + guard case .move(let twinMove)? = FortyThievesPlanner.bestHint(in: twinBoard) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(twinMove.selection.cards.first?.id, sevenSpades.id) + XCTAssertEqual(twinMove.destination, .tableau(1), "Twin tops canonicalize to the lower column") + } + + func testBankingLineIsFoundAndCachedEndToEnd() { + // Every foundation is one Queen-and-King away from completion; the + // HintPlanner's cached line must walk the whole mop-up to the win + // without a single nil hint. + let foundations = Suit.allCases.flatMap { suit in + (0..<2).map { _ in + Rank.allCases.filter { $0.rawValue <= 11 }.map { TestCards.make(suit, $0) } + } + } + let columns = Suit.allCases.flatMap { suit in + (0..<2).map { _ in + [TestCards.make(suit, .king), TestCards.make(suit, .queen)] + } + } + var state = GameStateFixtures.fortyThievesState( + columns: columns, + foundations: foundations + ) + XCTAssertNotNil( + SavedGamePayload(state: state, movesCount: 0, score: 0, stockDrawCount: DrawMode.one.rawValue, history: []) + .sanitizedForRestore(at: DateFixtures.reference), + "The mop-up must be a legal 104-card position" + ) + + let planner = HintPlanner() + var actions = 0 + while actions < 40 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: DrawMode.one.rawValue) else { + return XCTFail("Hint stack gave up after \(actions) actions") + } + guard let next = applied(hint, to: state) else { + return XCTFail("Hinted action was not legal after \(actions) actions") + } + state = next + actions += 1 + } + XCTFail("Did not win within 40 actions") + } + + // MARK: - Helpers + + /// Ten columns with no same-suit adjacency, no empty column, no waste, + /// and empty foundations: no tableau or waste action is legal. + private func stuckBoard(stock: [Card]) -> GameState { + var columns = Suit.allCases.flatMap { suit in + [ + [TestCards.make(.hearts, .queen), TestCards.make(suit, .three)], + [TestCards.make(.clubs, .queen), TestCards.make(suit, .three)] + ] + } + columns.append([TestCards.make(.hearts, .queen), TestCards.make(.spades, .eight)]) + columns.append([TestCards.make(.clubs, .queen), TestCards.make(.spades, .eight)]) + return GameStateFixtures.fortyThievesState(columns: columns, stock: stock) + } + + private func applied(_ hint: HintAdvisor.Hint, to state: GameState) -> GameState? { + switch hint { + case .move(let move): + return AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.one.rawValue + ) + case .stockTap: + return applied(FortyThievesPlanner.PlannedAction.stockTap, to: state) + } + } + + private func applied( + _ action: FortyThievesPlanner.PlannedAction, + to state: GameState + ) -> GameState? { + switch action { + case .move(let selection, let destination): + return AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: DrawMode.one.rawValue + ) + case .stockTap: + guard !state.stock.isEmpty else { return nil } + var next = state + var card = next.stock.removeLast() + card.isFaceUp = true + next.waste.append(card) + next.wasteDrawCount = 1 + return next + } + } + + private func randomLegalSuccessor( + of state: GameState, + using generator: inout SeededRandomNumberGenerator + ) -> GameState? { + var moves: [(Selection, Destination)] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + moves.append((selection, destination)) + } + } + let actionCount = moves.count + (state.stock.isEmpty ? 0 : 1) + guard actionCount > 0 else { return nil } + let pick = Int(generator.next() % UInt64(actionCount)) + if pick == moves.count { + return applied(FortyThievesPlanner.PlannedAction.stockTap, to: state) + } + let (selection, destination) = moves[pick] + return AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: DrawMode.one.rawValue + ) + } + + 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)) + mix(0xFC) + for card in state.waste { mix(card: card) } + 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/FortyThieves/FortyThievesRulesTests.swift b/ComputerSolitaireTests/FortyThieves/FortyThievesRulesTests.swift new file mode 100644 index 0000000..8b3a441 --- /dev/null +++ b/ComputerSolitaireTests/FortyThieves/FortyThievesRulesTests.swift @@ -0,0 +1,290 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FortyThievesRulesTests: XCTestCase { + // MARK: - Tableau landing + + func testTableauLandingRequiresSameSuitOneRankLower() { + let eightSpades = [TestCards.make(.spades, .eight)] + + XCTAssertTrue( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(.spades, .seven), + destinationPile: eightSpades + ) + ) + XCTAssertFalse( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .seven), + destinationPile: eightSpades + ), + "An off-suit card must not land, whatever its rank" + ) + XCTAssertFalse( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(.spades, .six), + destinationPile: eightSpades + ), + "Building skips no ranks" + ) + XCTAssertFalse( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(.spades, .nine), + destinationPile: eightSpades + ), + "Building runs downward only" + ) + } + + func testNothingLandsOnAnAce() { + let aceClubs = [TestCards.make(.clubs, .ace)] + for suit in Suit.allCases { + for rank in Rank.allCases { + XCTAssertFalse( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(suit, rank), + destinationPile: aceClubs + ) + ) + } + } + } + + func testEmptyColumnAcceptsAnyAvailableCard() { + XCTAssertTrue( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .king), + destinationPile: [] + ) + ) + XCTAssertTrue( + FortyThievesGameRules.canMoveToTableau( + card: TestCards.make(.clubs, .five), + destinationPile: [] + ), + "Empty columns take any card, not just Kings" + ) + + // Both an exposed tableau card and the waste top reach the empty + // column through the shared move generation. + let fiveDiamonds = TestCards.make(.diamonds, .five) + let nineClubs = TestCards.make(.clubs, .nine) + let state = GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.hearts, .queen), fiveDiamonds]], + waste: [nineClubs] + ) + let boardSelection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [state.tableau[0][1]] + ) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: boardSelection, in: state) + .contains(.tableau(1)) + ) + let wasteSelection = Selection(source: .waste, cards: [state.waste[0]]) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: wasteSelection, in: state) + .contains(.tableau(1)) + ) + } + + // MARK: - Single-card movement + + func testOnlyASingleCardMayBePickedUp() { + // The defining strictness: even a perfect suited descending run never + // moves as a unit. + let viewModel = SolitaireViewModel(variant: .fortyThieves) + let run = [TestCards.make(.spades, .eight), TestCards.make(.spades, .seven)] + + XCTAssertTrue(viewModel.canSelectTableauCards([run[1]])) + XCTAssertFalse(viewModel.canSelectTableauCards(run)) + } + + func testBuriedCardDragIsRefused() { + let viewModel = SolitaireViewModel(variant: .fortyThieves) + viewModel.state = GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.spades, .eight), TestCards.make(.spades, .seven)]] + ) + + XCTAssertFalse(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 0)) + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 1)) + } + + func testCandidateSelectionsOfferOnlyExposedTopCards() { + let state = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.spades, .eight), TestCards.make(.spades, .seven)], + [TestCards.make(.hearts, .four)] + ], + waste: [TestCards.make(.clubs, .two)] + ) + + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + XCTAssertEqual(selection.cards.count, 1) + if case .tableau(let pile, let index) = selection.source { + XCTAssertEqual(index, state.tableau[pile].count - 1) + } + } + } + + // MARK: - Foundations + + func testBothTwinFoundationsProgressIndependently() { + let aceSpades = TestCards.make(.spades, .ace) + let twoSpades = TestCards.make(.spades, .two) + + XCTAssertTrue(GameRules.canMoveToFoundation(card: aceSpades, foundation: [])) + // Two decks carry two aces per suit; each starts its own foundation, + // and a deuce continues either one. + XCTAssertTrue(GameRules.canMoveToFoundation(card: twoSpades, foundation: [aceSpades])) + + let state = GameStateFixtures.fortyThievesState( + columns: [[twoSpades]], + foundations: [[aceSpades], [TestCards.make(.spades, .ace)]] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: [state.tableau[0][0]]) + let destinations = AutoMoveAdvisor.legalDestinations(for: selection, in: state) + XCTAssertTrue(destinations.contains(.foundation(0))) + XCTAssertTrue(destinations.contains(.foundation(1))) + } + + func testSafeFoundationMoveRequiresBothTwinFoundationsCaughtUp() { + let fiveSpades = TestCards.make(.spades, .five) + func spadeFoundation(through rank: Int) -> [Card] { + Rank.allCases.filter { $0.rawValue <= rank }.map { TestCards.make(.spades, $0) } + } + + // Aces and twos can never be needed as a tableau landing spot. + XCTAssertTrue( + FortyThievesGameRules.isSafeFoundationMove( + card: TestCards.make(.hearts, .ace), + in: GameStateFixtures.fortyThievesState(columns: []) + ) + ) + XCTAssertTrue( + FortyThievesGameRules.isSafeFoundationMove( + card: TestCards.make(.hearts, .two), + in: GameStateFixtures.fortyThievesState(columns: []) + ) + ) + + // Rank five is safe only once both spade foundations reach three — + // then every four of spades is directly foundation-playable and never + // needs the five as a landing spot. + let bothCaughtUp = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [spadeFoundation(through: 3), spadeFoundation(through: 3)] + ) + XCTAssertTrue(FortyThievesGameRules.isSafeFoundationMove(card: fiveSpades, in: bothCaughtUp)) + + let oneLagging = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [spadeFoundation(through: 3), spadeFoundation(through: 2)] + ) + XCTAssertFalse(FortyThievesGameRules.isSafeFoundationMove(card: fiveSpades, in: oneLagging)) + + let oneUnstarted = GameStateFixtures.fortyThievesState( + columns: [], + foundations: [spadeFoundation(through: 13)] + ) + XCTAssertFalse( + FortyThievesGameRules.isSafeFoundationMove(card: fiveSpades, in: oneUnstarted), + "An unstarted twin foundation still owes its whole run" + ) + } + + // MARK: - Foundation lock + + func testFoundationCardsNeverReturnToPlay() { + let viewModel = SolitaireViewModel(variant: .fortyThieves) + viewModel.state = GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.spades, .three)]], + foundations: [[TestCards.make(.spades, .ace), TestCards.make(.spades, .two)]] + ) + + XCTAssertFalse(viewModel.startDragFromFoundation(index: 0)) + viewModel.selectFromFoundation(index: 0) + XCTAssertNil(viewModel.selection) + + // The advisor never offers a foundation source, and even a + // hand-constructed one is refused every tableau landing — the 2♠ may + // not come back down onto the 3♠. + let selections = AutoMoveAdvisor.candidateSelections(in: viewModel.state) + XCTAssertFalse(selections.contains { selection in + if case .foundation = selection.source { return true } + return false + }) + + guard let top = viewModel.state.foundations[0].last else { + return XCTFail("Expected a foundation top") + } + let rollback = Selection(source: .foundation(pile: 0), cards: [top]) + let destinations = AutoMoveAdvisor.legalDestinations(for: rollback, in: viewModel.state) + XCTAssertFalse(destinations.contains { destination in + if case .tableau = destination { return true } + return false + }) + } + + func testTapPolicyOrdersSafeFoundationBuildUnsafeFoundationEmptyColumn() { + func spadeFoundation(through rank: Int) -> [Card] { + Rank.allCases.filter { $0.rawValue <= rank }.map { TestCards.make(.spades, $0) } + } + + // 5♠ with both spade foundations at four: banking is safe and wins + // over the tableau build onto the 6♠. A base card keeps the empty + // column a genuine (non-redundant) alternative throughout. + let safeState = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .nine), TestCards.make(.spades, .five)], + [TestCards.make(.spades, .six)] + ], + foundations: [spadeFoundation(through: 4), spadeFoundation(through: 4)] + ) + let safeSelection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [safeState.tableau[0][1]] + ) + XCTAssertEqual( + TapMovePolicy.bestDestination(for: safeSelection, in: safeState), + .foundation(0) + ) + + // With the twin foundation lagging the bank is unsafe, so the tableau + // build wins; with no build available the unsafe bank still beats the + // empty column. + let unsafeState = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .nine), TestCards.make(.spades, .five)], + [TestCards.make(.spades, .six)] + ], + foundations: [spadeFoundation(through: 4), spadeFoundation(through: 1)] + ) + let unsafeSelection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [unsafeState.tableau[0][1]] + ) + XCTAssertEqual( + TapMovePolicy.bestDestination(for: unsafeSelection, in: unsafeState), + .tableau(1) + ) + + let noBuildState = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .nine), TestCards.make(.spades, .five)], + [TestCards.make(.hearts, .queen)] + ], + foundations: [spadeFoundation(through: 4), spadeFoundation(through: 1)] + ) + let noBuildSelection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [noBuildState.tableau[0][1]] + ) + XCTAssertEqual( + TapMovePolicy.bestDestination(for: noBuildSelection, in: noBuildState), + .foundation(0), + "An unsafe bank still beats spending an empty column" + ) + } +} diff --git a/ComputerSolitaireTests/FortyThieves/FortyThievesSessionTests.swift b/ComputerSolitaireTests/FortyThieves/FortyThievesSessionTests.swift new file mode 100644 index 0000000..96b07c4 --- /dev/null +++ b/ComputerSolitaireTests/FortyThieves/FortyThievesSessionTests.swift @@ -0,0 +1,325 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class FortyThievesSessionTests: XCTestCase { + private func makeSession() -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .fortyThieves) + viewModel.newGame(mode: .fortyThieves) + return viewModel + } + + /// A session staged on a hand-constructed board; draw counts configured + /// as a real Forty Thieves game would be. + private func makeStagedSession(state: GameState) -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .fortyThieves) + viewModel.state = state + viewModel.configureFortyThievesNewGame() + viewModel.setWasteDrawCount(min(1, state.waste.count)) + return viewModel + } + + func testNewGameDealsFortyFaceUpCardsAndConfiguresDrawCounts() { + let viewModel = makeSession() + let state = viewModel.state + + XCTAssertEqual(state.tableau.count, FortyThievesGameRules.columnCount) + XCTAssertTrue(state.tableau.allSatisfy { $0.count == FortyThievesGameRules.dealColumnDepth }) + XCTAssertTrue(state.tableau.allSatisfy { $0.allSatisfy(\.isFaceUp) }) + XCTAssertEqual(state.stock.count, FortyThievesGameRules.dealStockCardCount) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertTrue(state.waste.isEmpty) + XCTAssertEqual(state.wasteDrawCount, 0) + XCTAssertEqual(state.foundations.count, 8) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertNotNil( + SavedGamePayload(state: state, movesCount: 0, score: 0, stockDrawCount: DrawMode.one.rawValue, history: []) + .sanitizedForRestore(at: DateFixtures.reference) + ) + + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(viewModel.scoringDrawCount, DrawMode.three.rawValue) + XCTAssertFalse(viewModel.supportsDrawMode) + } + + func testSeededDealMatchesRealDealShape() { + let real = GameState.newFortyThievesGame() + let seeded = GameStateFixtures.seededFortyThievesDeal(seed: 1) + XCTAssertEqual(seeded.tableau.count, real.tableau.count) + XCTAssertEqual(seeded.tableau.map(\.count), real.tableau.map(\.count)) + XCTAssertEqual(seeded.stock.count, real.stock.count) + XCTAssertEqual(seeded.waste.count, real.waste.count) + XCTAssertEqual(seeded.foundations.count, real.foundations.count) + XCTAssertNotNil( + SavedGamePayload(state: seeded, movesCount: 0, score: 0, stockDrawCount: DrawMode.one.rawValue, history: []) + .sanitizedForRestore(at: DateFixtures.reference) + ) + } + + func testStockTapTurnsExactlyOneCardOntoTheWaste() { + let viewModel = makeSession() + let expectedCard = viewModel.state.stock.last + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.stock.count, 63) + XCTAssertEqual(viewModel.state.waste.count, 1) + XCTAssertEqual(viewModel.state.waste.last?.id, expectedCard?.id) + XCTAssertEqual(viewModel.state.waste.last?.isFaceUp, true) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1) + XCTAssertEqual(viewModel.movesCount, 1) + } + + func testEmptyStockTapIsANoOpWithNoRecycle() { + let viewModel = makeStagedSession( + state: GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.spades, .eight)]], + waste: [TestCards.make(.hearts, .three), TestCards.make(.clubs, .ten)] + ) + ) + let before = viewModel.state + + XCTAssertFalse(viewModel.canInteractWithStock) + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state, before, "The single pass never recycles the waste") + XCTAssertEqual(viewModel.movesCount, 0) + XCTAssertNil(viewModel.peekUndoSnapshot()) + } + + func testPlayingTheWasteTopExposesTheNextWasteCard() { + let buried = TestCards.make(.hearts, .three) + let top = TestCards.make(.spades, .seven) + let viewModel = makeStagedSession( + state: GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.spades, .eight)]], + waste: [buried, top] + ) + ) + + viewModel.selection = Selection(source: .waste, cards: [viewModel.state.waste[1]]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertEqual(viewModel.state.tableau[0].last?.id, top.id) + XCTAssertEqual(viewModel.state.waste.map(\.id), [buried.id]) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1) + TestAssertions.assertSingleVisibleWasteCard(viewModel, expected: buried) + } + + func testMoveScoringMatchesTheClassicSchedule() { + func score( + afterMoving source: Selection.Source, + to destination: Destination, + in state: GameState + ) -> Int { + let viewModel = makeStagedSession(state: state) + let cards: [Card] + switch source { + case .waste: + cards = [state.waste[state.waste.count - 1]] + case .tableau(let pile, let index): + cards = [state.tableau[pile][index]] + default: + XCTFail("Unsupported source") + return 0 + } + viewModel.selection = Selection(source: source, cards: cards) + XCTAssertTrue(viewModel.tryMoveSelection(to: destination)) + return viewModel.score + } + + let wastePlayState = GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.spades, .eight)]], + waste: [TestCards.make(.spades, .seven)] + ) + XCTAssertEqual(score(afterMoving: .waste, to: .tableau(0), in: wastePlayState), 5) + + let wasteBankState = GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.spades, .eight)]], + waste: [TestCards.make(.hearts, .ace)] + ) + XCTAssertEqual(score(afterMoving: .waste, to: .foundation(0), in: wasteBankState), 10) + + let tableauBankState = GameStateFixtures.fortyThievesState( + columns: [[TestCards.make(.hearts, .ace)]] + ) + XCTAssertEqual( + score( + afterMoving: .tableau(pile: 0, index: 0), + to: .foundation(0), + in: tableauBankState + ), + 10 + ) + + let tableauBuildState = GameStateFixtures.fortyThievesState( + columns: [ + [TestCards.make(.hearts, .four), TestCards.make(.spades, .seven)], + [TestCards.make(.spades, .eight)] + ] + ) + XCTAssertEqual( + score( + afterMoving: .tableau(pile: 0, index: 1), + to: .tableau(1), + in: tableauBuildState + ), + 0, + "Tableau-to-tableau moves score nothing" + ) + } + + // MARK: - Auto-finish + + /// All eight foundations built through Queen; the eight Kings split + /// between tableau tops and the waste. + private func nearWonState(kingsInWaste: Int) -> GameState { + let foundations = Suit.allCases.flatMap { suit in + (0..<2).map { _ in + Rank.allCases.filter { $0 != .king }.map { TestCards.make(suit, $0) } + } + } + var kings = Suit.allCases.flatMap { suit in + [TestCards.make(suit, .king), TestCards.make(suit, .king)] + } + var waste: [Card] = [] + for _ in 0.. [[Card]] { + var columns = Suit.allCases.flatMap { suit in + [ + [TestCards.make(.hearts, .queen), TestCards.make(suit, .three)], + [TestCards.make(.clubs, .queen), TestCards.make(suit, .three)] + ] + } + columns.append([TestCards.make(.hearts, .queen), TestCards.make(.spades, .eight)]) + columns.append([TestCards.make(.clubs, .queen), TestCards.make(.spades, .eight)]) + return columns + } + + func testAnyPlayerMoveExistsWhileStockRemains() { + let state = GameStateFixtures.fortyThievesState( + columns: deadBoardColumns(), + stock: [TestCards.make(.diamonds, .king)] + ) + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state), "A draw is always a legal action") + } + + func testDeadPositionReportsNoMoves() { + let state = GameStateFixtures.fortyThievesState(columns: deadBoardColumns()) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + // MARK: - Redeal + + func testRedealRestoresTheIdenticalDeal() { + let viewModel = makeSession() + let initialState = viewModel.state + + viewModel.handleStockTap() + viewModel.handleStockTap() + XCTAssertNotEqual(viewModel.state, initialState) + + viewModel.redeal() + + XCTAssertEqual(viewModel.state, initialState) + XCTAssertEqual(viewModel.movesCount, 0) + XCTAssertEqual(viewModel.score, 0) + XCTAssertEqual(viewModel.state.wasteDrawCount, 0) + } +} diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index e6a0813..00e8480 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -407,6 +407,62 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("Golf fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Forty Thieves board is a fresh deal with the first card + /// drawn to the waste. All forty board cards are visible, but the eye + /// lands on the ten exposed column ends, so seeds are scanned for the + /// most photogenic spread there with a first build available. + func testGenerateFortyThievesFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededFortyThievesDeal(seed: seed) + let score = fortyThievesDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededFortyThievesDeal(seed: seed) + viewModel.configureFortyThievesNewGame() + viewModel.handleStockTap() + + 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.one.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, + .fortyThieves, + "Fixture did not restore as Forty Thieves" + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("fortythieves.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Forty Thieves fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -512,6 +568,28 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Forty Thieves deal by the eleven cards the eye lands on + /// (the ten exposed column ends plus the first drawn stock card): rank + /// variety, red/black balance, all four suits, a few face cards, and a + /// couple of same-suit builds among the tops to suggest a first move. + private func fortyThievesDealScore(of deal: GameState) -> Int { + let exposed = deal.tableau.compactMap { $0.last } + let visible = exposed + Array(deal.stock.suffix(1)) + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + let buildCount = exposed.count { card in + exposed.contains { top in + FortyThievesGameRules.canMoveToTableau(card: card, destinationPile: [top]) + } + } + score += min(buildCount, 2) * 4 + 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. diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index 8dce88d..08e4df2 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -354,6 +354,112 @@ enum GameStateFixtures { ) } + /// A reproducible Forty Thieves deal matching the shape of + /// `GameState.newFortyThievesGame`. Mirrored by the hint probe's + /// `seededFortyThievesDeal` so seeds are comparable. + static func seededFortyThievesDeal(seed: UInt64) -> GameState { + var deck = seededShuffle(TestCards.fullDeck() + TestCards.fullDeck(), seed: seed) + var tableau: [[Card]] = [] + for _ in 0.. GameState { + var tableau = columns.map { column in + column.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + } + if tableau.count < FortyThievesGameRules.columnCount { + tableau.append( + contentsOf: [[Card]]( + repeating: [], + count: FortyThievesGameRules.columnCount - tableau.count + ) + ) + } + var faceDownStock = stock.map { card in + var faceDown = card + faceDown.isFaceUp = false + return faceDown + } + let fullWaste = waste.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + var fullFoundations = foundations.map { pile in + pile.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + } + if fullFoundations.count < 8 { + fullFoundations.append( + contentsOf: [[Card]](repeating: [], count: 8 - fullFoundations.count) + ) + } + if fillStockFromRemainder { + var usedCounts: [CardIdentity: Int] = [:] + let placed = tableau.flatMap { $0 } + faceDownStock + fullWaste + + fullFoundations.flatMap { $0 } + for card in placed { + usedCounts[CardIdentity(suit: card.suit, rank: card.rank), default: 0] += 1 + } + var remainder: [Card] = [] + for card in TestCards.fullDeck() + TestCards.fullDeck() { + let identity = CardIdentity(suit: card.suit, rank: card.rank) + if let count = usedCounts[identity], count > 0 { + usedCounts[identity] = count - 1 + } else { + remainder.append(card) + } + } + faceDownStock = remainder + faceDownStock + } + return GameState( + variant: .fortyThieves, + stock: faceDownStock, + waste: fullWaste, + wasteDrawCount: min(1, fullWaste.count), + freeCells: Array(repeating: nil, count: 4), + foundations: fullFoundations, + tableau: tableau + ) + } + private static func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { seededShuffle(TestCards.fullDeck(faceUp: faceUp), seed: seed) } diff --git a/README.md b/README.md index 21d1386..2047044 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), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, and **Yukon** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, **Forty Thieves**, and **Yukon** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -28,4 +28,5 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | **TriPeaks** | Chain uncovered cards one rank up or down to level three peaks | [Rules](docs/rules/tripeaks.md) | | **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/rules/pyramid.md) | | **Golf** | Play columns down to nothing, one rank up or down, scored like golf across a nine-hole match | [Rules](docs/rules/golf.md) | +| **Forty Thieves** | Two decks, ten columns — build down by suit one card at a time, managing a single-pass stock | [Rules](docs/rules/fortythieves.md) | | **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/rules/yukon.md) | diff --git a/docs/rules/fortythieves.md b/docs/rules/fortythieves.md new file mode 100644 index 0000000..ec2674a --- /dev/null +++ b/docs/rules/fortythieves.md @@ -0,0 +1,56 @@ +# Forty Thieves Rules + +These rules describe Forty Thieves as implemented in the app: the strict classic two-deck game — tableau built down by suit, single-card moves only, locked foundations, and a draw-one stock with a single pass. The published sources offer several relaxations; the choices made here (and why) are called out below. + +## Objective +Move all 104 cards onto the eight foundations, building each up by suit from Ace to King. Because two decks are in play, every suit completes two foundations. + +## Terminology +- **Tableau:** Ten columns of four face-up cards; build down by suit, one card at a time. +- **Foundations:** Eight suit piles built up from Ace to King — two per suit. Cards placed here never return to play. +- **Stock:** The face-down draw pile (64 cards after the deal). One pass only — there are no recycles. +- **Waste:** Face-up cards turned from the stock; only the top card is playable. + +## Setup +- Use two standard 52-card decks shuffled together (104 cards, no jokers). +- **Tableau:** Deal 40 cards face up into ten columns of four. +- **Stock:** The remaining 64 cards, face down. +- **Waste** and all eight **foundations** start empty. + +## Play +### Tableau +- Build columns **down by suit**, one rank at a time — the 7♠ plays onto the 8♠ and nothing else. +- Only the **exposed top card** of a column may move. Sequences never move as a unit, however perfectly ordered. +- Any single available card — an exposed tableau card or the top waste card — may fill an **empty column**. + +### Foundations +- An available Ace starts any empty foundation; each foundation then builds up in its suit to the King. +- A card placed on a foundation is **locked** — it never returns to the tableau. + +### The stock +- Tap the stock to turn **one** card face up onto the waste, whenever you like. +- Only the top waste card is playable, to the tableau or a foundation. +- The stock allows a **single pass** — once it is spent, the cards left in the waste stay in play only through the waste top. + +## Scoring +- Waste to tableau: +5. +- Waste to foundation: +10. +- Tableau to foundation: +10. +- Winning adds a time bonus that starts at 900 and drops one point per second. +- The score never goes below zero. + +## Winning +You win by moving all 104 cards to the foundations. The game is lost when the stock is spent and no legal move remains. Strict Forty Thieves is a famously difficult game — expert play wins perhaps one deal in five — so most deals end as well-fought losses; empty columns and a carefully rationed stock are what winning deals have in common. + +## Rule choices +The linked sources offer relaxations; this implementation uses: +- **Single-card movement only** — sequences never move together (Wikipedia's standard rules). Some software offers a "supermove" shortcut that relocates a suited run when enough empty columns exist to have done it card by card; the app keeps the by-the-book rule, so every multi-card relocation is played — and paid for — one move at a time. +- **Build down by suit**, not by alternating colors — the strict classic rule; color building is a different, much easier game. +- **Any card fills an empty column** (universal across sources), and columns have no depth limit. +- **Locked foundations** — a banked card never returns to play. Klondike-style rollbacks would soften the game's defining irreversibility. +- **Single pass** through the stock with no recycles (universal across sources — the pass limit is the game). The in-app **Redeal** command replays the same deal from the start; it is a fresh attempt at the layout, not a recycle of the stock. + +## Sources +- https://en.wikipedia.org/wiki/Napoleon_at_St_Helena +- https://www.esolutions.se/Solitaire/forty-thieves +- https://www.bvssolitaire.com/rules/forty-thieves.htm diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index 15b1396..d8f95a8 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -27,6 +27,7 @@ tools/hint-probe/run.sh spider 500 4 # third arg narrows to one suit count tools/hint-probe/run.sh pyramid 500 tools/hint-probe/run.sh tripeaks 500 tools/hint-probe/run.sh golf 500 +tools/hint-probe/run.sh fortythieves 500 ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -69,6 +70,7 @@ consecutive runs, serial and parallel. | `pyramid` | **80.2%** | 15.2% | | `tripeaks` | **95.4%** | 0.0% | | `golf` | **22.6%** | 0.0% | +| `fortythieves` | **3.4%** | 0.0% | Reading the table honestly: @@ -137,6 +139,25 @@ Reading the table honestly: structurally bounded at 51 actions. Budget history: the TriPeaks-sized 200k node cap measured 13.6% (61% of deals undecided); the shipped 1M cap with 12-byte packed search nodes decides 92% of deals and is the baseline above. +- **Forty Thieves (3.4% vs 0.0%)**: the low absolute rate is the variant's + class, not a broken planner — expert human play wins roughly 10–30% of + deals, and a greedy bounded best-first search is far below expert; treat + 3.4% as the regression floor, not an achievement (the Spider 4-suit + framing). The random control winning zero says strict Forty Thieves wins + (same-suit building, single cards, one stock pass) are never stumbled into; + the entire hint column is planner skill, and the banked-at-loss gap (median + 33 vs 13) is the per-deal quality signal on the lost majority. Every + follower loss is an honest deadlock — no action caps, no loops — and + revisit events measure zero (the no-progress fallback is a bare stock tap, + strictly monotone, unlike Spider's score-losing deal preparation), so + Forty Thieves revisits are gated to zero like Yukon's. `losses with >=40 + banked: 167` is legitimately high — Forty Thieves losses strand well-banked + boards by nature — and is the recorded baseline; treat increases as + regressions. Tuning directions already measured flat or negative: + empty-column weight 15 (3.6%, within noise), burial weight 3 (2.4%), + node budget 60k (flat — the early-exit floor binds first), a twin-lag + banking penalty targeting the over-banking count (flat at 166, +40% + wall-clock). - 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 @@ -150,16 +171,18 @@ add its sources to `run.sh`, then run 500 deals. Acceptance gates: - The hint column must **decisively beat the random control**. - **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.) + are additionally gated to zero for Yukon and Forty Thieves (their planners + measure 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, and Spider records - 6/7/1 by suit count (its losses can strand nearly-done boards); treat any - increase as a regression. + Klondike draw-1 baseline records a single such loss, Spider records + 6/7/1 by suit count (its losses can strand nearly-done boards), and Forty + Thieves records 167 — legitimately high, its losses strand well-banked + boards by nature; 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 54fb233..47b8335 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -189,6 +189,36 @@ func seededDeal(variant: GameVariant, seed: UInt64, spiderSuitCount: SpiderSuitC foundations: Array(repeating: [], count: 4), tableau: tableau ) + + case .fortyThieves: + // Mirrors GameState.newFortyThievesGame (and + // GameStateFixtures.seededFortyThievesDeal): two full decks. + var deck = seededShuffle( + (0..<2).flatMap { _ in + Suit.allCases.flatMap { suit in + Rank.allCases.map { rank in Card(suit: suit, rank: rank, isFaceUp: false) } + } + }, + seed: seed + ) + var tableau: [[Card]] = [] + for _ in 0.. GameState? { GolfPlanner.apply(.draw, to: state) } +/// Mirrors handleFortyThievesStockTap in the session: draw one, no recycles +/// ever. The planner's apply is the same pure logic. +func fortyThievesStockTap(_ state: GameState) -> GameState? { + FortyThievesPlanner.apply(.stockTap, to: state) +} + func golfCleared(_ state: GameState) -> Int { GolfGameRules.dealTableauCardCount - state.tableau.reduce(0) { $0 + $1.count } } @@ -325,7 +361,8 @@ enum Outcome { /// Golf at 51: every action consumes a board card or a stock card.) func actionCap(for variant: GameVariant) -> Int { switch variant { - case .klondike: + case .klondike, .fortyThieves: + // Forty Thieves needs 104 banks plus 64 draws plus tableau grooming. return 1_200 case .spider: return 1_000 @@ -685,6 +722,69 @@ func playGolfFollowingHints(seed: UInt64) -> Outcome { return .actionCap(foundation: golfCleared(state)) } +func playFortyThievesFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: Int) { + // Replicates HintPlanner's Forty Thieves path without its wall-clock + // deadline: follow each improving line (which may include stock taps) to + // its end, then replan; on no-progress, tap the stock once — the real hint + // stack's fallback, uncached because the fresh waste top may unlock an + // improving line — and declare a deadlock only when the stock is out too. + var state = seededDeal(variant: .fortyThieves, seed: seed) + 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: .fortyThieves) { + return .actionCap(foundation: foundationCount(state)) + } + if state.isWon { return .win(moves: actions) } + return nil + } + + func applied(_ action: FortyThievesPlanner.PlannedAction) -> GameState? { + switch action { + case .move(let selection, let destination): + return apply(selection, destination, to: state, stockDrawCount: 1) + case .stockTap: + return fortyThievesStockTap(state) + } + } + + while actions < actionCap(for: .fortyThieves) { + if state.isWon { return (.win(moves: actions), revisitEvents) } + switch FortyThievesPlanner.bestLine(in: state) { + case .line(let line): + for action in line { + guard let next = applied(action) else { + fatalError("Seed \(seed): illegal Forty Thieves hint") + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + + case .noProgress: + guard let next = fortyThievesStockTap(state) else { + return (.deadlock(foundation: foundationCount(state)), revisitEvents) + } + 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 @@ -713,7 +813,7 @@ func playRandom( lossProgress = triPeaksCleared case .golf: lossProgress = golfCleared - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .fortyThieves: lossProgress = foundationCount } var actions = 0 @@ -735,7 +835,7 @@ func playRandom( canTapStock = SpiderGameRules.canDealFromStock(state: state) case .pyramid: canTapStock = !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) - case .tripeaks, .golf: + case .tripeaks, .golf, .fortyThieves: canTapStock = !state.stock.isEmpty case .freecell, .yukon: canTapStock = false @@ -755,6 +855,8 @@ func playRandom( tapped = triPeaksStockTap(state) case .golf: tapped = golfStockTap(state) + case .fortyThieves: + tapped = fortyThievesStockTap(state) case .klondike, .freecell, .yukon: tapped = stockTap(state, drawCount: drawCount) } @@ -884,6 +986,8 @@ func run( label = "tripeaks" case .golf: label = "golf" + case .fortyThieves: + label = "fortythieves" } // Pyramid, TriPeaks, and Golf bank no foundations; their loss columns // record board cards cleared. @@ -895,7 +999,7 @@ func run( lossProgressLabel = "tripeaks-cleared-at-loss" case .golf: lossProgressLabel = "golf-cleared-at-loss" - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .fortyThieves: lossProgressLabel = "foundation-at-loss" } let tracksOverBanking = variant != .pyramid && variant != .tripeaks && variant != .golf @@ -920,6 +1024,8 @@ func run( return (playTriPeaksFollowingHints(seed: seed), 0) case .golf: return (playGolfFollowingHints(seed: seed), 0) + case .fortyThieves: + return playFortyThievesFollowingHints(seed: seed) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -938,7 +1044,7 @@ func run( tracksOverBanking: tracksOverBanking ) print(String(format: "elapsed: %.1fs", seconds)) - if variant == .yukon || variant == .spider { + if variant == .yukon || variant == .spider || variant == .fortyThieves { print("hint revisit events: \(revisitEvents)") } if followerLoops > 0 { @@ -948,9 +1054,11 @@ func run( // 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)") + // Yukon's and Forty Thieves' planners measure zero (Forty Thieves' bare + // stock-tap fallback is strictly monotone), so for them any revisit is a + // regression. + if variant == .yukon || variant == .fortyThieves, revisitEvents > 0 { + print("GATE VIOLATION: \(label) hint follower revisited positions \(revisitEvents) time(s)") gateViolations += revisitEvents } @@ -983,8 +1091,8 @@ setvbuf(stdout, nil, _IOLBF, 0) func exitWithUsage() -> Never { print( - "usage: run.sh [deals >= 1] " - + "[klondike draw count: 1 or 3 | spider suit count: 1, 2, or 4]" + "usage: run.sh " + + "[deals >= 1] [klondike draw count: 1 or 3 | spider suit count: 1, 2, or 4]" ) exit(1) } @@ -1023,6 +1131,8 @@ case "tripeaks": run(variant: .tripeaks, seeds: seeds, drawCount: 1) case "golf": run(variant: .golf, seeds: seeds, drawCount: 1) +case "fortythieves": + run(variant: .fortyThieves, seeds: seeds, drawCount: 1) case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) @@ -1034,6 +1144,7 @@ case "all": run(variant: .pyramid, seeds: seeds, drawCount: 1) run(variant: .tripeaks, seeds: seeds, drawCount: 1) run(variant: .golf, seeds: seeds, drawCount: 1) + run(variant: .fortyThieves, seeds: seeds, drawCount: 1) default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index b77bd6c..3eab06e 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 | spider suit count] +# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count | spider suit count] set -euo pipefail cd "$(dirname "$0")/../.." @@ -45,6 +45,10 @@ SOURCES=( ComputerSolitaire/Game/Golf/GameRulesGolf.swift ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift ComputerSolitaire/Game/Golf/GolfPlanner.swift + ComputerSolitaire/Game/FortyThieves/GameStateFortyThieves.swift + ComputerSolitaire/Game/FortyThieves/GameRulesFortyThieves.swift + ComputerSolitaire/Game/FortyThieves/AutoMoveAdvisorFortyThieves.swift + ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift ) for source in "${SOURCES[@]}"; do From de4aec987d8f65cbd7c13af0169ce8a83f24f26d Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 13 Jul 2026 15:05:04 -0700 Subject: [PATCH 2/2] fix tableau deal flight stalls and stale-frame landings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a gameplay move landing during a deal flight could relocate a card the overlay was still flying toward (a scorpion group move carries an in-flight card away with the cards beneath it), so the flight now lands the moment any other move arrives, matching the undo path's existing rule. the pre-flight frame wait also stalled 240ms whenever a dealt card completed a run and banked on arrival — run piles publish only their top card's frame — so the wait now covers only cards still on the tableau, the only cards that ever publish a landing frame. --- .../Views/Shared/ContentView.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 090382f..9e2de60 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -109,6 +109,9 @@ struct ContentView: View { @State private var dealAnimationCards: [DrawAnimationCard] = [] @State private var dealingCardIDs: Set = [] @State private var dealAnimationToken = UUID() + /// The move count when the active deal flight took off; a later move means + /// gameplay has mutated the position the flight refers to. + @State private var dealAnimationMovesCount = 0 @State private var undoAnimationItems: [UndoAnimationItem] = [] @State private var undoAnimationTargets: [UUID: UndoAnimationEndTarget] = [:] @State private var undoAnimationProgress: CGFloat = 0 @@ -731,6 +734,17 @@ struct ContentView: View { guard let event else { return } startDealAnimation(for: event.dealtCardIDs) } + .onChange(of: viewModel.movesCount) { _, movesCount in + // The board stays live during a deal flight, and a move that lands + // mid-flight can relocate a card the overlay is still flying toward + // (a Scorpion group move carries an in-flight card away with the + // cards beneath it). Land the flight rather than finish it against + // a stale frame — the same rule the undo path applies. The deal's + // own move is exempt: its count is the baseline taken at takeoff. + guard movesCount != dealAnimationMovesCount else { return } + guard !dealingCardIDs.isEmpty || !dealAnimationCards.isEmpty else { return } + cancelDealAnimation() + } .animation(.spring(response: 0.35, dampingFraction: 0.86), value: viewModel.state) .animation(.easeInOut(duration: 0.12), value: activeTarget) .overlay { @@ -1321,6 +1335,7 @@ struct ContentView: View { guard !dealtCards.isEmpty, stockFrame != .zero else { return } dealingCardIDs = Set(dealtCards.map(\.id)) + dealAnimationMovesCount = viewModel.movesCount let token = UUID() dealAnimationToken = token DispatchQueue.main.async { @@ -1336,7 +1351,14 @@ struct ContentView: View { private func resolveDealAnimation(for dealtCards: [Card], token: UUID, attemptsRemaining: Int) { guard dealAnimationToken == token else { return } - let framesReady = dealtCards.allSatisfy { cardFrames[$0.id] != nil } + // Only cards still on the tableau ever publish a landing frame: a + // dealt card that completed a run banked on arrival, and its run pile + // publishes the run's top card only. Waiting on a banked card would + // burn every retry before the plan — which already skips frame-less + // cards — could run. + let tableauCardIDs = Set(viewModel.state.tableau.joined().map(\.id)) + let awaitedCards = dealtCards.filter { tableauCardIDs.contains($0.id) } + let framesReady = awaitedCards.allSatisfy { cardFrames[$0.id] != nil } if !framesReady, attemptsRemaining > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { resolveDealAnimation(for: dealtCards, token: token, attemptsRemaining: attemptsRemaining - 1)