From d8475dc9743ed159229ec87ff03a91642436ac57 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 20:52:24 -0700 Subject: [PATCH 1/2] add tripeaks game variant three-peak layout with rank-adjacent wrap matching, chain scoring, single-pass stock, and exact solver-backed hints validated by the hint probe --- .../Fixtures/ScreenshotFixtures.swift | 3 +- ComputerSolitaire/Fixtures/tripeaks.json | 579 ++++++++++++++++++ .../FreeCell/GamePersistenceFreeCell.swift | 5 +- .../Game/Klondike/AutoFinishPlanner.swift | 6 +- .../Klondike/GamePersistenceKlondike.swift | 5 +- .../Game/Klondike/KlondikePlanner.swift | 2 +- .../Game/Pyramid/AutoMoveAdvisorPyramid.swift | 2 +- .../Game/Pyramid/GamePersistencePyramid.swift | 3 + .../Game/Pyramid/GameRulesPyramid.swift | 2 +- .../Game/Shared/AutoMoveAdvisor.swift | 57 +- .../Game/Shared/GamePersistence.swift | 15 +- .../Game/Shared/GameRulesShared.swift | 5 +- .../Game/Shared/GameSession.swift | 42 +- .../Game/Shared/GameSessionInteraction.swift | 18 +- ComputerSolitaire/Game/Shared/GameState.swift | 22 +- .../Game/Shared/GameVariant.swift | 25 +- .../Game/Shared/HintAdvisor.swift | 47 ++ ComputerSolitaire/Game/Shared/MoveTypes.swift | 6 +- ComputerSolitaire/Game/Shared/Scoring.swift | 15 + .../Game/Shared/TapMovePolicy.swift | 4 +- .../Game/Spider/GamePersistenceSpider.swift | 5 +- .../TriPeaks/AutoMoveAdvisorTriPeaks.swift | 43 ++ .../TriPeaks/GamePersistenceTriPeaks.swift | 44 ++ .../Game/TriPeaks/GameRulesTriPeaks.swift | 65 ++ .../Game/TriPeaks/GameSessionTriPeaks.swift | 119 ++++ .../Game/TriPeaks/GameStateTriPeaks.swift | 32 + .../Game/TriPeaks/TriPeaksGeometry.swift | 86 +++ .../Game/TriPeaks/TriPeaksPlanner.swift | 409 +++++++++++++ .../Game/Yukon/GamePersistenceYukon.swift | 5 +- .../Game/Yukon/YukonPlanner.swift | 4 +- .../Interaction/BoardInteractionTypes.swift | 1 + .../Views/RulesAndScoringView.swift | 49 ++ .../Views/Shared/BoardViews.swift | 19 + .../Views/Shared/ContentView.swift | 48 +- .../StockWasteViews.swift} | 15 +- ComputerSolitaire/Views/StatisticsView.swift | 17 +- .../Views/TriPeaks/TriPeaksBoardView.swift | 88 +++ .../Views/TriPeaks/TriPeaksTopRowView.swift | 80 +++ .../Shared/ScreenshotFixtureTests.swift | 71 +++ ComputerSolitaireTests/TestSupport.swift | 75 +++ .../TriPeaks/TriPeaksGeometryTests.swift | 124 ++++ .../TriPeaks/TriPeaksPersistenceTests.swift | 182 ++++++ .../TriPeaks/TriPeaksPlannerTests.swift | 446 ++++++++++++++ .../TriPeaks/TriPeaksRulesTests.swift | 227 +++++++ .../TriPeaks/TriPeaksSessionTests.swift | 360 +++++++++++ tools/hint-probe/README.md | 11 + tools/hint-probe/main.swift | 130 +++- tools/hint-probe/run.sh | 7 +- 48 files changed, 3542 insertions(+), 83 deletions(-) create mode 100644 ComputerSolitaire/Fixtures/tripeaks.json create mode 100644 ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift create mode 100644 ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift create mode 100644 ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift create mode 100644 ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift create mode 100644 ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift create mode 100644 ComputerSolitaire/Game/TriPeaks/TriPeaksGeometry.swift create mode 100644 ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift rename ComputerSolitaire/Views/{Klondike/KlondikeStockWasteViews.swift => Shared/StockWasteViews.swift} (87%) create mode 100644 ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift create mode 100644 ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift create mode 100644 ComputerSolitaireTests/TriPeaks/TriPeaksGeometryTests.swift create mode 100644 ComputerSolitaireTests/TriPeaks/TriPeaksPersistenceTests.swift create mode 100644 ComputerSolitaireTests/TriPeaks/TriPeaksPlannerTests.swift create mode 100644 ComputerSolitaireTests/TriPeaks/TriPeaksRulesTests.swift create mode 100644 ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index cc3686d..9c3b2d9 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -33,7 +33,8 @@ enum ScreenshotFixtures { ScreenshotFixture(name: "freecell", title: "FreeCell – fresh deal"), ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal"), ScreenshotFixture(name: "spider", title: "Spider – 2 suits"), - ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal") + ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal"), + ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/tripeaks.json b/ComputerSolitaire/Fixtures/tripeaks.json new file mode 100644 index 0000000..618d890 --- /dev/null +++ b/ComputerSolitaire/Fixtures/tripeaks.json @@ -0,0 +1,579 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 0, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 1, + "state" : { + "discard" : [ + + ], + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "pyramid" : [ + + ], + "stock" : [ + { + "id" : "46444998-0C37-4CE8-9046-5319CA381712", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "CFC157DB-6F2A-4622-B2C9-609654910960", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "43331753-7583-4EB0-97B6-1D1E3AF5B5E5", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "86872539-C441-45F0-8F63-B96CC5CA1738", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "3878DB9B-5D6B-4D08-B3A2-C6B7C220C474", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "678A1691-D6B2-4C05-AC34-2F728AD84E78", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "ABA9229B-5ED5-4E9C-A64D-21D82060D953", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "F7402D0D-B944-40D3-81C5-04249216F544", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "85959F63-3DDD-4950-B0E9-37F8F756FB22", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "824C7BE0-FC4A-4B9E-8C7B-AA9EB59EAA4C", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "F3862E56-7C39-4F19-BB10-C90129EE7753", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "11BAAEB7-BD59-4575-B037-3F3A4EAF0B52", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "A9D5AE1E-CAB0-4AB3-9941-33C9DE1A142B", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "FAC9261C-4724-4681-92A6-F299B1364B44", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "D6157CE6-5FB7-43D1-AF68-5638FCFC10D6", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "64EDE57D-6E90-472E-93FB-D81CD40D2D9A", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2E2CAFC7-5632-4C92-9B01-28F741A01504", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "0CA80C04-9BC7-4D2D-A1F1-47D9D0ABF58E", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2E21C973-467A-4B22-8B3C-DB4F115F008D", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "E7BF27DF-C127-498D-9C6E-E07C29CBDF05", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "A8EA7196-6361-42BF-924A-FBC58DFCEB6F", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "970D822D-7465-4599-99B1-194C1DFB7BF7", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C9B85FC3-2695-4891-A7F9-0422E041BD89", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + } + ], + "tableau" : [ + + ], + "triPeaks" : [ + { + "id" : "20A0891C-EB9A-44E5-8A4F-D2B8528D776A", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "B4C983DB-99EA-493E-B375-BD6B6B4CA059", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BEF4C95A-BAE1-4A26-A246-217AFD1688C2", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "3F876D9E-6AA2-497F-AB4F-D616CEBE1205", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B388CED6-E0CF-4EEE-97A2-E96890108C3B", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "EB46D4EA-1440-4F2D-919A-5813AA4AFFA0", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BE145FCF-3B05-48D9-B27F-6CCDEFC28FD0", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1BCF8505-7747-4EBE-870C-7D5BF8269529", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "6D243E91-481C-42B2-AB08-6477C09F206A", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C4B648AA-2E4B-44B1-81B1-C95B5C10F556", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "361C6779-404A-4CE4-BB71-299864BEBDAE", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "3EB55592-C543-4340-BE1D-7754601F6FC9", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "C5AE5569-0EE9-429A-A902-7DF2B099BFA2", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "1AB05C2D-5A45-40B0-AFC8-12BF511D455C", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1575141F-7C5B-4E26-92B9-5933675A0A31", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BE8F881F-8CDF-44F1-A973-DE0931FC701A", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "9852BA0D-E28E-4AC6-98A7-25767FFEF177", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BFD78635-2BBB-407C-9EA1-DA2036FB99F2", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "2515004F-400E-41B9-A6A8-C8C84A52DA66", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "88464E44-A96D-4FCA-AB4B-0938E148D786", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "33D74A72-C368-410F-9543-3D26ED995CC3", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "400BC501-5F0A-44FE-96E4-B60D893D1B95", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "95C345E7-C5EE-44C8-9A5B-F3C36C8A8B80", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C8A4341D-585B-4517-815A-F24336487D48", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "2AF79E5C-433F-47D0-8781-0BEE52FC61AC", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "9EE6FB8F-0D62-40F3-B6F1-8DF5146C8965", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "CB78112D-C325-4B68-AB0C-2FB577D07AAD", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "C6046CC0-5A73-4308-B90C-8DDEC3531371", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + } + ], + "triPeaksChainLength" : 0, + "variant" : "tripeaks", + "waste" : [ + { + "id" : "D30DF373-B29B-443F-BD43-F4A73A9F85F4", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + } + ], + "wasteDrawCount" : 1, + "wasteRecyclesUsed" : 0 + }, + "stockDrawCount" : 1, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift index b836a4c..eac2681 100644 --- a/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift @@ -4,11 +4,12 @@ enum FreeCellPersistenceRules { static func hasValidLayout(state: GameState) -> Bool { guard state.tableau.count == 8 else { return false } guard state.stock.isEmpty, state.waste.isEmpty else { return false } - // The pyramid fields belong to the Pyramid variant alone; a card stranded - // there would be invisible here. + // 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.wasteRecyclesUsed == 0 else { return false } + guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } return state.wasteDrawCount == 0 } } diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 185fd6a..7e22290 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -56,6 +56,10 @@ private extension AutoFinishPlanner { // Pyramid has no deterministic mop-up phase: which pair to remove // matters to the last move, so the game never auto-finishes. return false + case .tripeaks: + // TriPeaks has no deterministic mop-up phase either: play order + // matters to the last card, so the game never auto-finishes. + return false } } @@ -152,7 +156,7 @@ private extension AutoFinishPlanner { } state.freeCells[slot] = nil - case .waste, .foundation, .pyramid: + case .waste, .foundation, .pyramid, .triPeaks: return false } diff --git a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift index d67153b..240b4c5 100644 --- a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift @@ -6,11 +6,12 @@ enum KlondikePersistenceRules { // Klondike renders no free-cell slots, so a card stranded there would be // invisible and the game unwinnable. guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } - // The pyramid fields belong to the Pyramid variant alone; a card stranded - // there would be invisible here. + // 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.wasteRecyclesUsed == 0 else { return false } + guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } return state.wasteDrawCount >= 0 && state.wasteDrawCount <= state.waste.count } } diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index 5c8fc72..ad9ddad 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -177,7 +177,7 @@ private extension KlondikePlanner { !nextState.tableau[pile][topIndex].isFaceUp { nextState.tableau[pile][topIndex].isFaceUp = true } - case .pyramid: + case .pyramid, .triPeaks: // Unreachable: this planner only searches Klondike states. return nil } diff --git a/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift b/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift index 2411250..37441d7 100644 --- a/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift @@ -41,7 +41,7 @@ enum PyramidAutoMoveAdvisor { where PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: partnerIndex, in: state) { destinations.append(.pyramid(partnerIndex)) } - case .foundation, .freeCell, .tableau: + case .foundation, .freeCell, .tableau, .triPeaks: return [] } diff --git a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift index 21b6701..b8fda11 100644 --- a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift @@ -11,6 +11,9 @@ enum PyramidPersistenceRules { guard (0...PyramidGameRules.maxWasteRecycles).contains(state.wasteRecyclesUsed) else { return false } + // The TriPeaks fields belong to the TriPeaks variant alone; a card + // stranded there would be invisible here. + guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } guard state.wasteDrawCount == min(1, state.waste.count) else { return false } // Legal play can never remove a card while a covering card remains, so an diff --git a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift index 77d05a2..6e483ff 100644 --- a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift @@ -66,7 +66,7 @@ enum PyramidGameRules { return PyramidGeometry.isExposed(index, in: state.pyramid) case .waste: return state.waste.last?.id == card.id - case .foundation, .freeCell, .tableau: + case .foundation, .freeCell, .tableau, .triPeaks: return false } } diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index 7bd6f8d..dc08594 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -5,11 +5,15 @@ import Foundation /// state looks like after a move. enum AutoMoveAdvisor { static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { - // Pyramid removes pairs instead of building piles, so its move set is - // generated wholesale rather than through the pile-oriented flow below. + // Pyramid and TriPeaks remove cards instead of building piles, so their + // move sets are generated wholesale rather than through the + // pile-oriented flow below. if state.variant == .pyramid { return PyramidAutoMoveAdvisor.legalDestinations(for: selection, in: state) } + if state.variant == .tripeaks { + return TriPeaksAutoMoveAdvisor.legalDestinations(for: selection, in: state) + } guard selectionMatchesState(selection, in: state) else { return [] } guard let movingCard = selection.cards.first else { return [] } @@ -62,6 +66,9 @@ enum AutoMoveAdvisor { if state.variant == .pyramid { return PyramidAutoMoveAdvisor.candidateSelections(in: state) } + if state.variant == .tripeaks { + return TriPeaksAutoMoveAdvisor.candidateSelections(in: state) + } var selections: [Selection] = [] @@ -112,6 +119,13 @@ enum AutoMoveAdvisor { in: state ) } + if state.variant == .tripeaks { + return TriPeaksAutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state + ) + } guard selectionMatchesState(selection, in: state) else { return nil } guard legalDestinations(for: selection, in: state).contains(destination) else { return nil } @@ -133,8 +147,8 @@ enum AutoMoveAdvisor { case .tableau(let pile, let index): nextState.tableau[pile].removeSubrange(index.. Int { switch state.variant { case .klondike: return min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) - case .pyramid: + case .pyramid, .tripeaks: return min(max(0, state.wasteDrawCount), min(1, state.waste.count)) case .freecell, .yukon, .spider: return 0 @@ -609,6 +609,7 @@ private extension GameState { var allCards: [Card] { stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + tableau.flatMap { $0 } + pyramid.compactMap { $0 } + discard + + triPeaks.compactMap { $0 } } var isValidForPersistence: Bool { @@ -636,7 +637,7 @@ private extension GameState { private var expectedIdentityCounts: [CardIdentity: Int] { switch variant { - case .klondike, .freecell, .yukon, .pyramid: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks: var counts: [CardIdentity: Int] = [:] for suit in Suit.allCases { for rank in Rank.allCases { @@ -662,6 +663,8 @@ private extension GameState { return SpiderPersistenceRules.hasValidLayout(state: self) case .pyramid: return PyramidPersistenceRules.hasValidLayout(state: self) + case .tripeaks: + return TriPeaksPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 0e6a506..0945d23 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -21,8 +21,9 @@ enum GameRules { return YukonGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) case .spider: return SpiderGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) - case .pyramid: - // Pyramid has no tableau piles; its pair moves flow through PyramidGameRules. + case .pyramid, .tripeaks: + // Neither has tableau piles; their moves flow through + // PyramidGameRules and TriPeaksGameRules. return false } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 0684322..ddc4514 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -484,6 +484,8 @@ final class SolitaireViewModel { configureSpiderNewGame() case .pyramid: configurePyramidNewGame() + case .tripeaks: + configureTriPeaksNewGame() } } @@ -497,6 +499,8 @@ final class SolitaireViewModel { configureSpiderRedeal() case .pyramid: configurePyramidRedeal() + case .tripeaks: + configureTriPeaksRedeal() } } @@ -511,6 +515,8 @@ final class SolitaireViewModel { return sanitizeWastelessRedealState(state) case .pyramid: return sanitizePyramidRedealState(state) + case .tripeaks: + return sanitizeTriPeaksRedealState(state) } } @@ -548,7 +554,7 @@ final class SolitaireViewModel { cardIndex: cardIndex, card: card ) - case .freecell, .pyramid: + case .freecell, .pyramid, .tripeaks: return false } } @@ -595,8 +601,8 @@ final class SolitaireViewModel { return canSelectFreeCellTableauCards(cards) case .spider: return SharedGameRules.isDescendingSameSuitRun(cards) - case .pyramid: - // Pyramid has no tableau piles. + case .pyramid, .tripeaks: + // Pyramid and TriPeaks have no tableau piles. return false } } @@ -605,7 +611,7 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return scoringDrawCount - case .freecell, .yukon, .spider, .pyramid: + case .freecell, .yukon, .spider, .pyramid, .tripeaks: return 0 } } @@ -627,6 +633,8 @@ extension SolitaireViewModel { handleSpiderStockTap() case .pyramid: handlePyramidStockTap() + case .tripeaks: + handleTriPeaksStockTap() case .freecell, .yukon: break } @@ -640,6 +648,9 @@ extension SolitaireViewModel { return !(state.stock.isEmpty && state.waste.isEmpty) case .pyramid: return !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) + case .tripeaks: + // Single pass with no recycles: an empty stock is dead. + return !state.stock.isEmpty case .spider: // Spider's stock renders through its own view; recorded for honesty. return !state.stock.isEmpty @@ -653,7 +664,7 @@ extension SolitaireViewModel { case .klondike: let count = min(state.wasteDrawCount, stockDrawCount) return Array(state.waste.suffix(count)) - case .pyramid: + case .pyramid, .tripeaks: return Array(state.waste.suffix(min(1, state.wasteDrawCount))) case .freecell, .yukon, .spider: return [] @@ -662,6 +673,8 @@ extension SolitaireViewModel { func handleWasteTap() { guard state.variant.dealsFromStock else { return } + // The TriPeaks waste top is the match target, never a mover. + guard state.variant != .tripeaks else { return } guard let top = state.waste.last, state.wasteDrawCount > 0 else { return } HapticManager.shared.play(.cardPickUp) @@ -687,6 +700,8 @@ extension SolitaireViewModel { @discardableResult func startDragFromWaste() -> Bool { guard state.variant.dealsFromStock else { return false } + // The TriPeaks waste top is the match target, never a mover. + guard state.variant != .tripeaks else { return false } guard let top = state.waste.last, state.wasteDrawCount > 0 else { return false } clearHint() selection = Selection(source: .waste, cards: [top]) @@ -712,6 +727,12 @@ extension SolitaireViewModel { card.isFaceUp = true state.waste.append(card) } + if state.variant == .tripeaks { + // A stock flip breaks the scoring chain. This lives here — not in + // the TriPeaks stock handler — so any draw path preserves the + // invariant; `TriPeaksPlanner.apply(.draw)` mirrors it. + state.triPeaksChainLength = 0 + } setWasteDrawCount(drawCount) incrementMovesCount() SoundManager.shared.play(.cardDrawFromStock) @@ -810,6 +831,9 @@ extension SolitaireViewModel { return true case .pyramid, .waste, .discard: + if state.variant == .tripeaks { + return performTriPeaksMove(selection: selection, to: destination) + } return performPyramidMove(selection: selection, to: destination) } } @@ -834,6 +858,8 @@ extension SolitaireViewModel { flipTopCardIfNeeded(in: pile) case .pyramid(let index): state.pyramid[index] = nil + case .triPeaks(let index): + state.triPeaks[index] = nil } } @@ -841,7 +867,7 @@ extension SolitaireViewModel { switch state.variant { case .klondike, .yukon, .spider: flipFaceDownTopCardIfNeeded(in: pileIndex) - case .freecell, .pyramid: + case .freecell, .pyramid, .tripeaks: break } } @@ -891,6 +917,10 @@ extension SolitaireViewModel { applySpiderMoveScore(for: source, destination: destination) case .pyramid: applyPyramidMoveScore(for: destination) + case .tripeaks: + // TriPeaks chain scoring reads the before/after states, so + // `performTriPeaksMove` applies it directly. + break } } diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift index 6d19964..a45855d 100644 --- a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -62,14 +62,24 @@ extension SolitaireViewModel { return PyramidGameRules.canRemovePair(sourceIndex, index, in: state.pyramid) case .waste: return PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: index, in: state) - case .foundation, .freeCell, .tableau: + case .foundation, .freeCell, .tableau, .triPeaks: return false } case .waste: - guard state.variant == .pyramid else { return false } - guard case .pyramid(let sourceIndex) = selection.source else { return false } - return PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: sourceIndex, in: state) + switch state.variant { + case .pyramid: + guard case .pyramid(let sourceIndex) = selection.source else { return false } + return PyramidGameRules.canRemovePairWithWasteTop( + pyramidIndex: sourceIndex, + in: state + ) + case .tripeaks: + guard case .triPeaks(let sourceIndex) = selection.source else { return false } + return TriPeaksGameRules.canPlay(index: sourceIndex, in: state) + case .klondike, .freecell, .yukon, .spider: + return false + } case .discard: guard state.variant == .pyramid else { return false } diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index 783677a..ed91350 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -16,6 +16,13 @@ struct GameState: Equatable, Codable { /// Completed waste-to-stock recycles; Pyramid allows /// `PyramidGameRules.maxWasteRecycles`. Zero for the other variants. var wasteRecyclesUsed: Int + /// TriPeaks layout: 28 row-major slots (three apexes, then rows of 6, 9, + /// and the 10-card base — see `TriPeaksGeometry.rowRanges`); nil means + /// played onto the waste. Empty for the other variants. + var triPeaks: [Card?] + /// Consecutive TriPeaks tableau discards since the last stock flip; the + /// n-th discard in a chain scores n. Zero for the other variants. + var triPeaksChainLength: Int enum CodingKeys: String, CodingKey { case variant @@ -28,6 +35,8 @@ struct GameState: Equatable, Codable { case pyramid case discard case wasteRecyclesUsed + case triPeaks + case triPeaksChainLength } init( @@ -40,7 +49,9 @@ struct GameState: Equatable, Codable { tableau: [[Card]], pyramid: [Card?] = [], discard: [Card] = [], - wasteRecyclesUsed: Int = 0 + wasteRecyclesUsed: Int = 0, + triPeaks: [Card?] = [], + triPeaksChainLength: Int = 0 ) { self.variant = variant self.stock = stock @@ -52,6 +63,8 @@ struct GameState: Equatable, Codable { self.pyramid = pyramid self.discard = discard self.wasteRecyclesUsed = wasteRecyclesUsed + self.triPeaks = triPeaks + self.triPeaksChainLength = triPeaksChainLength } init(from decoder: Decoder) throws { @@ -67,6 +80,8 @@ struct GameState: Equatable, Codable { pyramid = try container.decodeIfPresent([Card?].self, forKey: .pyramid) ?? [] discard = try container.decodeIfPresent([Card].self, forKey: .discard) ?? [] wasteRecyclesUsed = try container.decodeIfPresent(Int.self, forKey: .wasteRecyclesUsed) ?? 0 + triPeaks = try container.decodeIfPresent([Card?].self, forKey: .triPeaks) ?? [] + triPeaksChainLength = try container.decodeIfPresent(Int.self, forKey: .triPeaksChainLength) ?? 0 } var isWon: Bool { @@ -78,6 +93,9 @@ struct GameState: Equatable, Codable { case .pyramid: // Won once every pyramid slot is cleared; stock and waste may keep cards. return !pyramid.isEmpty && pyramid.allSatisfy { $0 == nil } + case .tripeaks: + // Won once every peak slot is cleared; stock and waste may keep cards. + return !triPeaks.isEmpty && triPeaks.allSatisfy { $0 == nil } } } @@ -97,6 +115,8 @@ struct GameState: Equatable, Codable { return newSpiderGame(suitCount: spiderSuitCount) case .pyramid: return newPyramidGame() + case .tripeaks: + return newTriPeaksGame() } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index feb5dfb..b7d5d54 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -6,6 +6,7 @@ enum GameVariant: String, CaseIterable, Codable { case yukon case spider case pyramid + case tripeaks var title: String { switch self { @@ -19,6 +20,8 @@ enum GameVariant: String, CaseIterable, Codable { return "Spider" case .pyramid: return "Pyramid" + case .tripeaks: + return "TriPeaks" } } @@ -34,6 +37,8 @@ enum GameVariant: String, CaseIterable, Codable { return "Build full suit runs" case .pyramid: return "Pair cards that total 13" + case .tripeaks: + return "Chain up or down the ranks" } } @@ -43,18 +48,19 @@ enum GameVariant: String, CaseIterable, Codable { return 7 case .freecell: return 8 - case .spider: + case .spider, .tripeaks: return 10 } } /// Whether deals place face-down cards in the tableau (tapping an exposed - /// face-down top flips it). + /// face-down top flips it). TriPeaks deals face-down peak cards, but they + /// flip automatically once uncovered — never by tapping. var dealsFaceDownTableauCards: Bool { switch self { case .klondike, .yukon, .spider: return true - case .freecell, .pyramid: + case .freecell, .pyramid, .tripeaks: return false } } @@ -63,7 +69,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: + case .klondike, .pyramid, .tripeaks: return true case .freecell, .yukon, .spider: return false @@ -75,7 +81,7 @@ enum GameVariant: String, CaseIterable, Codable { /// build one foundation per suit. var foundationPileCount: Int { switch self { - case .klondike, .freecell, .yukon, .pyramid: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks: return 4 case .spider: return 8 @@ -85,7 +91,7 @@ enum GameVariant: String, CaseIterable, Codable { /// How many cards a deal uses. Spider plays with two decks. var deckCardCount: Int { switch self { - case .klondike, .freecell, .yukon, .pyramid: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks: return 52 case .spider: return 104 @@ -94,13 +100,14 @@ enum GameVariant: String, CaseIterable, Codable { /// Whether the player builds foundations by moving cards onto them. /// Spider's completed runs move to a foundation automatically, and - /// Pyramid's foundations stay empty (removed pairs go to its discard), - /// so neither treats foundations as a drag, drop, or tap target. + /// Pyramid's and TriPeaks' foundations stay empty (their removed cards go + /// to the discard and waste respectively), so none of the three treats + /// foundations as a drag, drop, or tap target. var playerBuildsFoundations: Bool { switch self { case .klondike, .freecell, .yukon: return true - case .spider, .pyramid: + case .spider, .pyramid, .tripeaks: return false } } diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index a1d0e04..1c7d8e4 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -25,6 +25,9 @@ enum HintAdvisor { if state.variant == .spider, SpiderGameRules.canDealFromStock(state: state) { return true } + if state.variant == .tripeaks, !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 @@ -77,6 +80,12 @@ enum HintAdvisor { /// loop-free by construction: every Pyramid move advances a monotone quantity /// (removals shrink the board, draws advance the stock, resets spend passes), so a /// followed line can never revisit a position. +/// TriPeaks hints come from `TriPeaksPlanner`'s exact search and behave exactly +/// like Pyramid's: winning lines when the deal is winnable, the max-clear line on +/// unwinnable deals, nil only when not one more peak card is clearable. Its +/// ratchet is the strongest of any variant — every TriPeaks move consumes a card +/// (plays shrink the board, draws shrink the stock), so a followed line can never +/// revisit a position. final class HintPlanner { /// How long a single interactive hint request may spend searching. private static let freeCellSearchBudget: TimeInterval = 0.3 @@ -84,11 +93,13 @@ final class HintPlanner { private static let yukonSearchBudget: TimeInterval = 0.25 private static let spiderSearchBudget: TimeInterval = 0.3 private static let pyramidSearchBudget: TimeInterval = 0.3 + private static let triPeaksSearchBudget: TimeInterval = 0.3 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] private var spiderPlan: [String: SpiderPlanner.PlannedAction] = [:] private var pyramidPlan: [String: PyramidPlanner.Move] = [:] + private var triPeaksPlan: [String: TriPeaksPlanner.Move] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -108,6 +119,8 @@ final class HintPlanner { return spiderHint(in: state) case .pyramid: return pyramidHint(in: state) + case .tripeaks: + return triPeaksHint(in: state) } } } @@ -168,6 +181,40 @@ private extension HintPlanner { return PyramidPlanner.materialize(move, in: state) } + func triPeaksHint(in state: GameState) -> HintAdvisor.Hint? { + let key = TriPeaksPlanner.stateKey(for: state) + if let hint = plannedTriPeaksHint(for: key, in: state) { + return hint + } + + triPeaksPlan.removeAll() + let limits = TriPeaksPlanner.Limits( + deadline: Date().addingTimeInterval(Self.triPeaksSearchBudget) + ) + switch TriPeaksPlanner.bestLine(in: state, limits: limits) { + case .winningLine(let line), .bestEffortLine(let line, _): + triPeaksPlan = TriPeaksPlanner.keyedMoves(along: line, from: state) + return plannedTriPeaksHint(for: key, in: state) + + case .noProgress: + // A proof, not a budget artifact: any clearable line registers + // within the search's first ~two dozen expansions (a root play + // pops immediately; otherwise only draws are legal, a chain of at + // most 23, and a draw-enabled play pops right after its draw), so + // no-progress is only ever reached by exhausting that region — + // far under the interactive budget. Every remaining action is + // provably futile stock-churning and silence is the honest + // answer. The hint button re-enables after the player's next move. + return nil + } + } + + func plannedTriPeaksHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + // materialize re-validates the cached move against the live state. + guard let move = triPeaksPlan[key] else { return nil } + return TriPeaksPlanner.materialize(move, in: state) + } + func yukonHint(in state: GameState) -> HintAdvisor.Hint? { let key = YukonPlanner.stateKey(for: state) if let hint = plannedYukonHint(for: key, in: state) { diff --git a/ComputerSolitaire/Game/Shared/MoveTypes.swift b/ComputerSolitaire/Game/Shared/MoveTypes.swift index f89af39..e185f75 100644 --- a/ComputerSolitaire/Game/Shared/MoveTypes.swift +++ b/ComputerSolitaire/Game/Shared/MoveTypes.swift @@ -8,6 +8,8 @@ struct Selection: Equatable { case tableau(pile: Int, index: Int) /// A single card at a pyramid slot (Pyramid only). case pyramid(index: Int) + /// A single uncovered card at a TriPeaks slot (TriPeaks only). + case triPeaks(index: Int) } let source: Source @@ -20,7 +22,9 @@ enum Destination: Equatable { case freeCell(Int) /// Remove the selection together with the card at this pyramid slot (Pyramid only). case pyramid(Int) - /// Remove the selection together with the top waste card (Pyramid only). + /// The waste pile. Pyramid removes the selection together with the top + /// waste card; TriPeaks plays the selection onto the waste, making it the + /// new match target. case waste /// Remove a lone King from play (Pyramid only). case discard diff --git a/ComputerSolitaire/Game/Shared/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift index ebd8553..c63ea42 100644 --- a/ComputerSolitaire/Game/Shared/Scoring.swift +++ b/ComputerSolitaire/Game/Shared/Scoring.swift @@ -11,6 +11,13 @@ enum ScoringAction { case spiderCompletedRun case removePyramidPair case removePyramidKing + /// The n-th consecutive TriPeaks discard in a chain scores n points. + case triPeaksChainDiscard(chainLength: Int) + case triPeaksPeakClear + /// Clearing the third peak always clears the whole board, so this bonus + /// replaces (not joins) the third `triPeaksPeakClear`. + case triPeaksBoardClear + case triPeaksStockFlip } enum Scoring { @@ -43,6 +50,14 @@ enum Scoring { return 10 case .removePyramidKing: return 5 + case .triPeaksChainDiscard(let chainLength): + return max(0, chainLength) + case .triPeaksPeakClear: + return 15 + case .triPeaksBoardClear: + return 30 + case .triPeaksStockFlip: + return -5 } } diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index d7d43a2..e4a7fda 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -101,8 +101,8 @@ private extension TapMovePolicy { case .spider: // Unreachable: Spider foundations are never player destinations. tier = 100 - case .pyramid: - // Unreachable: Pyramid moves never target a foundation. + case .pyramid, .tripeaks: + // Unreachable: Pyramid and TriPeaks moves never target a foundation. tier = 0 } return Priority(tier: tier, buildLength: 0, pileOrder: -index) diff --git a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift index 772f36c..933ce0a 100644 --- a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift +++ b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift @@ -10,11 +10,12 @@ enum SpiderPersistenceRules { // Spider renders no free-cell slots, so a card stranded there would be // invisible and the game unwinnable. guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } - // The pyramid fields belong to the Pyramid variant alone; a card stranded - // there would be invisible here. + // 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.wasteRecyclesUsed == 0 else { return false } + guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } guard state.wasteDrawCount == 0 else { return false } return state.foundations.allSatisfy(isValidFoundationPile) } diff --git a/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift new file mode 100644 index 0000000..c049d6c --- /dev/null +++ b/ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift @@ -0,0 +1,43 @@ +import Foundation + +/// TriPeaks shares no foundation/tableau/free-cell move algebra with the other +/// variants, so `AutoMoveAdvisor` dispatches to it wholesale instead of threading +/// its moves through the pile-oriented hooks. +enum TriPeaksAutoMoveAdvisor { + /// Every uncovered peak card (uncovered cards are always face up), each as a + /// single-card selection. The waste top is never a selection: in TriPeaks it + /// is the match target, not a mover. + static func candidateSelections(in state: GameState) -> [Selection] { + var selections: [Selection] = [] + + for index in state.triPeaks.indices { + guard let card = state.triPeaks[index], + TriPeaksGeometry.isUncovered(index, in: state.triPeaks) else { continue } + selections.append(Selection(source: .triPeaks(index: index), cards: [card])) + } + + return selections + } + + /// `[.waste]` when the selection is rank-adjacent to the waste top; the + /// waste is TriPeaks' only destination. + static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state) else { return [] } + guard case .triPeaks(let index) = selection.source else { return [] } + guard TriPeaksGameRules.canPlay(index: index, in: state) else { return [] } + return [.waste] + } + + static func simulatedState( + afterMoving selection: Selection, + to destination: Destination, + in state: GameState + ) -> GameState? { + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state) else { return nil } + return TriPeaksGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + } +} diff --git a/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift new file mode 100644 index 0000000..2a92d55 --- /dev/null +++ b/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift @@ -0,0 +1,44 @@ +import Foundation + +enum TriPeaksPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.triPeaks.count == TriPeaksGeometry.cardCount else { return false } + guard state.tableau.isEmpty else { return false } + // TriPeaks renders no free-cell slots or foundations, so a card stranded + // there would be invisible and the game unwinnable. + guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } + guard state.foundations.allSatisfy(\.isEmpty) else { return false } + // The pyramid fields belong to the Pyramid variant alone; a card stranded + // there would be invisible here. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { + return false + } + // The deal starts the waste with one card and the waste only grows, so + // an empty waste is corrupt (there would be no match target). + guard !state.waste.isEmpty else { return false } + guard state.wasteDrawCount == 1 else { return false } + + // Legal play can never remove a card while a covering card remains, so an + // empty slot must have empty covering slots. + for index in state.triPeaks.indices where state.triPeaks[index] == nil { + if let covering = TriPeaksGeometry.coveringIndices(of: index), + state.triPeaks[covering.left] != nil || state.triPeaks[covering.right] != nil { + return false + } + } + + // The rules flip a card the instant it is uncovered, so every present + // card must be face up exactly when uncovered. + for index in state.triPeaks.indices { + guard let card = state.triPeaks[index] else { continue } + guard card.isFaceUp == TriPeaksGeometry.isUncovered(index, in: state.triPeaks) else { + return false + } + } + + // Chain cards are consecutive discards: all in the waste beyond the + // deal's starter, and never more than the cards removed from the board. + let removedCount = state.triPeaks.count { $0 == nil } + return (0...min(state.waste.count - 1, removedCount)).contains(state.triPeaksChainLength) + } +} diff --git a/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift new file mode 100644 index 0000000..0884082 --- /dev/null +++ b/ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift @@ -0,0 +1,65 @@ +import Foundation + +enum TriPeaksGameRules { + /// One rank above or below, suit ignored; ranks wrap, so K↔A and A↔2 both + /// connect (difference 1 or 12 around the 13-rank cycle). + static func ranksAdjacentWithWrap(_ first: Rank, _ second: Rank) -> Bool { + let difference = abs(first.rawValue - second.rawValue) + return difference == 1 || difference == Rank.allCases.count - 1 + } + + /// Whether the card at `index` may be played onto the waste right now: + /// present, face up, uncovered, and rank-adjacent to the waste top. + static func canPlay(index: Int, in state: GameState) -> Bool { + guard state.variant == .tripeaks, + state.triPeaks.indices.contains(index), + let card = state.triPeaks[index], + card.isFaceUp, + TriPeaksGeometry.isUncovered(index, in: state.triPeaks), + let wasteTop = state.waste.last else { return false } + return ranksAdjacentWithWrap(card.rank, wasteTop.rank) + } + + /// Single source of truth for applying a TriPeaks move; used by the session + /// and the advisor so their outcomes can never drift. The only legal move + /// shape is playing an uncovered peak card onto the waste. Returns nil for + /// illegal moves. + static func stateByApplying( + selection: Selection, + destination: Destination, + to state: GameState + ) -> GameState? { + guard state.variant == .tripeaks else { return nil } + guard selection.cards.count == 1, let selectedCard = selection.cards.first else { return nil } + guard case .triPeaks(let sourceIndex) = selection.source, + case .waste = destination else { return nil } + guard canPlay(index: sourceIndex, in: state), + state.triPeaks[sourceIndex]?.id == selectedCard.id else { return nil } + + var nextState = state + nextState.triPeaks[sourceIndex] = nil + nextState.waste.append(selectedCard) + flipNewlyUncoveredCards(in: &nextState) + nextState.triPeaksChainLength += 1 + // The single visible waste card follows the new top. + nextState.wasteDrawCount = 1 + return nextState + } + + /// A face-down card flips face up once both cards covering it are removed. + static func flipNewlyUncoveredCards(in state: inout GameState) { + for index in state.triPeaks.indices { + guard var card = state.triPeaks[index], + !card.isFaceUp, + TriPeaksGeometry.isUncovered(index, in: state.triPeaks) else { continue } + card.isFaceUp = true + state.triPeaks[index] = card + } + } + + /// Apex slots cleared so far (0...3). A single play removes one card, so + /// at most one apex clears per move; the third clear is the board clear. + static func clearedPeakCount(in triPeaks: [Card?]) -> Int { + TriPeaksGeometry.apexIndices.count { triPeaks[$0] == nil } + } +} diff --git a/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift new file mode 100644 index 0000000..74f5b1d --- /dev/null +++ b/ComputerSolitaire/Game/TriPeaks/GameSessionTriPeaks.swift @@ -0,0 +1,119 @@ +import Foundation + +extension SolitaireViewModel { + // MARK: Configuration + + /// TriPeaks draws a single card to the waste; time-bonus scoring keeps the + /// draw-three basis the other stockless-choice variants use. + func configureTriPeaksNewGame() { + setStockDrawCount(DrawMode.one.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(min(1, state.waste.count)) + } + + func configureTriPeaksRedeal() { + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(min(1, state.waste.count)) + } + + func sanitizeTriPeaksRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = min(1, sanitizedState.waste.count) + // Chain cards are all in the waste beyond the deal's starter card. + sanitizedState.triPeaksChainLength = min( + max(0, sanitizedState.triPeaksChainLength), + max(0, sanitizedState.waste.count - 1) + ) + return sanitizedState + } + + // MARK: Moves + + /// Executes the TriPeaks move (`.waste`): plays an uncovered peak card onto + /// the waste as one scored, undoable move. + @discardableResult + func performTriPeaksMove(selection: Selection, to destination: Destination) -> Bool { + guard let nextState = TriPeaksGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) else { return false } + + clearHint() + pushHistory( + undoContext: UndoAnimationContext( + action: .moveSelection, + cardIDs: selection.cards.map(\.id) + ) + ) + let previousState = state + state = nextState + incrementMovesCount() + applyTriPeaksMoveScore(before: previousState, after: nextState) + applyTimeBonusIfWon() + self.selection = nil + SoundManager.shared.play(.cardPlaced) + refreshAutoFinishAvailability() + return true + } + + /// The n-th consecutive discard in a chain scores n; clearing a peak adds + /// its bonus (15 for the first two, 30 for the third, which clears the + /// board). One play removes one card, so at most one peak clears per move. + func applyTriPeaksMoveScore(before: GameState, after: GameState) { + applyScore(.triPeaksChainDiscard(chainLength: after.triPeaksChainLength)) + let clearedPeaks = TriPeaksGameRules.clearedPeakCount(in: after.triPeaks) + if clearedPeaks > TriPeaksGameRules.clearedPeakCount(in: before.triPeaks) { + applyScore( + clearedPeaks == TriPeaksGeometry.peakCount ? .triPeaksBoardClear : .triPeaksPeakClear + ) + } + } + + // MARK: Interaction + + /// A TriPeaks card either plays onto the waste or it doesn't, so a tap + /// auto-moves the card and an unplayable tap just gives failure feedback — + /// there is no two-step select-then-tap flow. + func handleTriPeaksTap(index: Int) { + guard state.triPeaks.indices.contains(index), + let card = state.triPeaks[index] else { return } + HapticManager.shared.play(.cardPickUp) + + guard card.isFaceUp, TriPeaksGeometry.isUncovered(index, in: state.triPeaks) else { + selection = nil + HapticManager.shared.play(.invalidDrop) + return + } + + let tappedSelection = Selection(source: .triPeaks(index: index), cards: [card]) + _ = queueBestAutoMove(for: tappedSelection) + selection = nil + } + + @discardableResult + func startDragFromTriPeaks(index: Int) -> Bool { + guard state.triPeaks.indices.contains(index), + let card = state.triPeaks[index], + card.isFaceUp, + TriPeaksGeometry.isUncovered(index, in: state.triPeaks) else { return false } + clearHint() + selection = Selection(source: .triPeaks(index: index), cards: [card]) + isDragging = true + return true + } + + // MARK: Stock + + /// Flips one stock card onto the waste. Single pass: once the stock is + /// empty the slot goes dead — TriPeaks never recycles. + func handleTriPeaksStockTap() { + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + guard !state.stock.isEmpty else { return } + drawFromStock() + applyScore(.triPeaksStockFlip) + } +} diff --git a/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift new file mode 100644 index 0000000..2e7ee3a --- /dev/null +++ b/ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift @@ -0,0 +1,32 @@ +import Foundation + +extension GameState { + /// The TriPeaks deal: 28 peak cards (rows of 3/6/9 face down, the 10-card + /// base face up), one face-up card starting the waste, and the remaining + /// 23 cards face down in the stock. The hint probe and test fixtures copy + /// this dealing order verbatim — change them together. + static func newTriPeaksGame() -> GameState { + var deck = Card.fullDeck().shuffled() + var triPeaks: [Card?] = [] + + for index in 0..] = [0..<3, 3..<9, 9..<18, 18..<28] + + /// The apex slot of each peak; clearing all three clears the board (each + /// apex is only removable once its whole subtree below is gone, and the + /// three subtrees cover every slot). + static let apexIndices = [0, 1, 2] + + static func row(of index: Int) -> Int { + rowRanges.firstIndex { $0.contains(index) } ?? 0 + } + + static func column(of index: Int) -> Int { + index - rowRanges[row(of: index)].lowerBound + } + + static func index(row: Int, column: Int) -> Int { + rowRanges[row].lowerBound + column + } + + /// The two slots covering `index` in the row below; nil for the base row. + static func coveringIndices(of index: Int) -> (left: Int, right: Int)? { + let row = Self.row(of: index) + let column = Self.column(of: index) + switch row { + case 0: + // Apex p sits over its peak's two row-1 cards. + let left = Self.index(row: 1, column: 2 * column) + return (left: left, right: left + 1) + case 1: + // Row-1 card m of peak g sits over row-2 cards 3g+m and 3g+m+1; + // the row-2 cards at peak boundaries cover only one row-1 card. + let peak = column / 2 + let left = Self.index(row: 2, column: 3 * peak + column % 2) + return (left: left, right: left + 1) + case 2: + // Rows 2 and 3 are contiguous: card j straddles base j and j+1. + let left = Self.index(row: 3, column: column) + return (left: left, right: left + 1) + default: + return nil + } + } + + /// A slot is uncovered when neither covering slot holds a card. + static func isUncovered(_ index: Int, in triPeaks: [Card?]) -> Bool { + guard let covering = coveringIndices(of: index) else { return true } + return triPeaks[covering.left] == nil && triPeaks[covering.right] == nil + } + + /// Horizontal slot position in half-card layout units, where one unit is + /// half of (card width + column spacing). Base card b sits at 2b; each + /// upper card is centered over the two cards it is covered by. + static func columnOffsetUnits(of index: Int) -> Double { + let column = column(of: index) + switch row(of: index) { + case 0: + return Double(6 * column + 3) + case 1: + return Double(6 * (column / 2) + 2 * (column % 2) + 2) + case 2: + return Double(2 * column + 1) + default: + return Double(2 * column) + } + } +} diff --git a/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift b/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift new file mode 100644 index 0000000..e47f5bb --- /dev/null +++ b/ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift @@ -0,0 +1,409 @@ +import Foundation + +/// Exact solver behind TriPeaks hints. +/// +/// TriPeaks has the tiniest exact state of any variant: which peak slots remain, +/// how many stock cards were drawn, and the waste's top rank (the only waste fact +/// that gates legality — buried waste history and suits are strategically inert, +/// so merging them is exact state merging, not a collision). That position packs +/// into one collision-free 37-bit code (see `Board`). Every move consumes a card +/// (plays shrink the board, draws shrink the stock), so the game graph is a DAG +/// of depth ≤ 51 with small branching, and reachable spaces per deal are small +/// enough to exhaust outright. +/// +/// The search is a single depth-first pass with plays explored before draws — +/// no heuristic, no `BinaryHeap`, no pruning. Reachable spaces run to millions +/// of positions, so frontier order is the whole ballgame: breadth-first (and +/// even draw-bucketed) orders must sweep the graph's width before reaching +/// depth-45 wins and blow any budget, while the plays-first dive reaches a win +/// in near-linear time on most winnable deals and backtracks through the +/// transposition set otherwise. The dive's preference for playing over flipping +/// at every step is also what makes its lines read naturally (long chains, no +/// idle draws); strictly draw-minimal wins were measured and rejected — they +/// force near-exhaustive sweeps of every low-draw region first. Because nothing +/// is pruned, one exhausted pass is simultaneously a proof the deal cannot be +/// won and the exact max-clear answer, so unwinnable deals (the solver plays +/// the actual deal, reading face-down ranks like every planner in this app — +/// hints are verified lines, not guesses) still get the best continuation +/// found. Silence is reserved for positions where not one more peak card is +/// clearable. If unwinnability proofs ever need to land inside the interactive +/// budget, the ready lever is the exact rank-connectivity prune (a remaining +/// card with no wrap-adjacent rank anywhere in the waste top + remaining stock +/// + remaining peaks can never be played), which must then be confined to a +/// separate win search to keep max-clear exact. +/// +/// Measured at the default budget over 10,000 seeded release-build deals: +/// 95.6% proved winnable, 0.2% proved unwinnable, 4.1% undecided at budget +/// (hard deals whose reachable graphs exceed 200k nodes; they still get +/// best-effort lines); `bestLine` median 0.07ms. Hint-quality baselines live +/// in the `tools/hint-probe` ledger: 95.4% of 500 deals won by following every +/// hint against a 0.0% random-control floor, zero loops. +enum TriPeaksPlanner { + struct Limits { + var maxNodes: Int + var deadline: Date? + + // Expansions are bit operations on a packed board, and reachable spaces + // per deal run well under this cap, so the budget exists for pathological + // deals rather than typical ones. No maxDepth: the game graph is a DAG + // whose depth is structurally bounded (≤ 28 plays + 23 draws). + init(maxNodes: Int = 200_000, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.deadline = deadline + } + } + + enum Move: Equatable { + /// Play the uncovered card at `slot` onto the waste, making it the new + /// match target. + case play(slot: Int) + /// Flip the next stock card onto the waste (single pass, no redeals). + case draw + } + + enum SearchOutcome { + /// Replaying this line clears the peaks; the deal is won. + case winningLine([Move]) + /// No winning line exists (or fit the budget); this line clears the most + /// peak cards found. The flag is a proof when the unpruned graph was + /// exhausted rather than the budget running out. + case bestEffortLine([Move], dealIsProvedUnwinnable: Bool) + /// Not even one more peak card is clearable within the horizon. + /// Exhaustive means proof (the full move graph was emptied). + case noProgress(searchWasExhaustive: Bool) + } + + static func bestHint(in state: GameState, limits: Limits = Limits()) -> HintAdvisor.Hint? { + let line: [Move] + switch bestLine(in: state, limits: limits) { + case .winningLine(let moves): + line = moves + case .bestEffortLine(let moves, _): + line = moves + case .noProgress: + return nil + } + guard let move = line.first else { return nil } + return materialize(move, in: state) + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .tripeaks, let position = Position(state: state) else { + return .noProgress(searchWasExhaustive: false) + } + + let result = search(from: position, limits: limits) + if let line = result.winLine { + return .winningLine(line) + } + guard let line = result.bestLine else { + return .noProgress(searchWasExhaustive: result.exhaustive) + } + return .bestEffortLine(line, dealIsProvedUnwinnable: result.exhaustive) + } + + /// Exact position key, stable across `Card` identities; used to look up the + /// cached line as the player follows it. Ranks only, and only the waste's + /// top card: suits and buried waste history never matter in TriPeaks, so + /// positions with identical hint futures intentionally share a key. + static func stateKey(for state: GameState) -> String { + var key = String() + key.reserveCapacity(64) + func append(card: Card) { + key.append(String(UnicodeScalar(UInt8(96 + card.rank.rawValue)))) + } + for slot in state.triPeaks { + if let card = slot { + append(card: card) + } else { + key.append("-") + } + } + key.append("|") + for card in state.stock { append(card: card) } + key.append("|") + if let wasteTop = state.waste.last { + append(card: wasteTop) + } + return key + } + + /// Maps each position along the line to the move to play there, so consecutive + /// hints are instant while the player follows (or plays ahead along) the line. + static func keyedMoves(along line: [Move], from state: GameState) -> [String: Move] { + var keyed: [String: Move] = [:] + var current = state + for move in line { + keyed[stateKey(for: current)] = move + guard let next = apply(move, to: current) else { break } + current = next + } + return keyed + } + + /// Converts a planner move into the executable hint, re-validating against the + /// live state so a stale cached move can never surface. + static func materialize(_ move: Move, in state: GameState) -> HintAdvisor.Hint? { + switch move { + case .play: + guard let (selection, destination) = sessionMove(for: move, in: state), + 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 .draw: + guard !state.stock.isEmpty else { return nil } + return .stockTap + } + } + + /// Applies a planner move to a real game state, mirroring the session's move + /// effects; used to walk `keyedMoves` and to replay lines in tests. + static func apply(_ move: Move, to state: GameState) -> GameState? { + switch move { + case .play: + guard let (selection, destination) = sessionMove(for: move, in: state) else { return nil } + return TriPeaksGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + case .draw: + guard !state.stock.isEmpty else { return nil } + var nextState = state + var card = nextState.stock.removeLast() + card.isFaceUp = true + nextState.waste.append(card) + nextState.wasteDrawCount = 1 + // A stock flip breaks the scoring chain, exactly as the session's + // draw path does. + nextState.triPeaksChainLength = 0 + return nextState + } + } +} + +// MARK: - Session move mapping + +private extension TriPeaksPlanner { + static func sessionMove( + for move: Move, + in state: GameState + ) -> (selection: Selection, destination: Destination)? { + guard case .play(let slot) = move else { return nil } + guard state.triPeaks.indices.contains(slot), let card = state.triPeaks[slot] else { + return nil + } + return (Selection(source: .triPeaks(index: slot), cards: [card]), .waste) + } +} + +// MARK: - Compact position + +private extension TriPeaksPlanner { + /// The deal's immutable rank tables plus the packed dynamic board. Stock cards + /// are indexed in draw order relative to the root; cards already in the waste + /// below its top are simply absent — the search never needs them. + struct Position { + /// Rank per peak slot (face-down ranks included — the solver plays the + /// actual deal); 0 for slots already cleared at the root. + let slotRanks: [Int] + /// Rank per undrawn stock card, in draw order (index 0 draws next). + let stockRanks: [Int] + let root: Board + + init?(state: GameState) { + guard state.triPeaks.count == TriPeaksGeometry.cardCount else { return nil } + guard state.stock.count <= Board.maxStockCount else { return nil } + // The deal starts the waste with one card and nothing ever leaves + // it, so an empty waste marks a malformed state. + guard let wasteTop = state.waste.last else { return nil } + + slotRanks = state.triPeaks.map { $0?.rank.rawValue ?? 0 } + stockRanks = state.stock.reversed().map(\.rank.rawValue) + + var tableauMask: UInt32 = 0 + for index in state.triPeaks.indices where state.triPeaks[index] != nil { + tableauMask |= 1 << UInt32(index) + } + root = Board( + tableauMask: tableauMask, + drawsUsed: 0, + wasteTopRank: wasteTop.rank.rawValue + ) + } + } + + /// One TriPeaks position in 37 bits: which peak slots hold cards (28), draws + /// made since the root (5 bits, ≤ 23), and the waste's top rank (4 bits, + /// 1–13). Draws are strictly sequential and nothing ever leaves the waste, so + /// these three fields determine the position's entire future — making `code` + /// an exact, collision-free transposition key. + struct Board: Equatable { + static let maxStockCount = 31 + + var tableauMask: UInt32 + var drawsUsed: Int + var wasteTopRank: Int + + var code: UInt64 { + UInt64(tableauMask) + | (UInt64(drawsUsed) << 28) + | (UInt64(wasteTopRank) << 33) + } + + var remainingCount: Int { + tableauMask.nonzeroBitCount + } + + func holdsCard(at slot: Int) -> Bool { + tableauMask & (1 << UInt32(slot)) != 0 + } + + func isUncovered(_ slot: Int) -> Bool { + TriPeaksPlanner.coveringMasks[slot] & tableauMask == 0 + } + } + + /// Bits of the two slots covering each slot; 0 for the base row. + static let coveringMasks: [UInt32] = (0.. Bool { + let difference = abs(first - second) + return difference == 1 || difference == Rank.allCases.count - 1 + } + + /// Legal moves in a fixed, deterministic order: playable slots ascending, + /// then draw — so equal-depth ties favor clearing over flipping and lines + /// read sensibly. + static func moves(from board: Board, position: Position) -> [Move] { + var moves: [Move] = [] + + for slot in 0.. Board { + var next = board + switch move { + case .play(let slot): + next.tableauMask &= ~(1 << UInt32(slot)) + next.wasteTopRank = position.slotRanks[slot] + case .draw: + next.wasteTopRank = position.stockRanks[next.drawsUsed] + next.drawsUsed += 1 + } + return next + } +} + +// MARK: - Search + +private extension TriPeaksPlanner { + struct Node { + let board: Board + let parent: Int + let move: Move? + let depth: Int + } + + /// One depth-first pass over the transposition-deduplicated game graph. + /// Children push in reverse generation order so the dive pops plays + /// (lowest slot first) before the draw — the search plays whenever it can + /// and flips only when a branch is spent. Because nothing is pruned, + /// draining the stack proves unwinnability and makes the best-effort line + /// the exact max-clear answer (ties prefer the shallower line). + static func search( + from position: Position, + limits: Limits + ) -> (winLine: [Move]?, bestLine: [Move]?, exhaustive: Bool) { + if position.root.tableauMask == 0 { + return (winLine: [], bestLine: nil, exhaustive: true) + } + + var nodes: [Node] = [Node(board: position.root, parent: -1, move: nil, depth: 0)] + var visited: Set = [position.root.code] + var pending: [Int] = [0] + var expansions = 0 + var wasTruncated = false + var best: (index: Int, remaining: Int, depth: Int)? + + while let nodeIndex = pending.popLast() { + let node = nodes[nodeIndex] + let remaining = node.board.remainingCount + + if remaining == 0 { + return ( + winLine: line(to: nodeIndex, nodes: nodes), + bestLine: nil, + exhaustive: false + ) + } + let improvesBest = best.map { + remaining < $0.remaining || (remaining == $0.remaining && node.depth < $0.depth) + } ?? (remaining < position.root.remainingCount) + if improvesBest { + best = (nodeIndex, remaining, node.depth) + } + + expansions += 1 + if nodes.count >= limits.maxNodes { + wasTruncated = true + break + } + if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { + wasTruncated = true + break + } + + for move in moves(from: node.board, position: position).reversed() { + let nextBoard = apply(move, to: node.board, position: position) + guard visited.insert(nextBoard.code).inserted else { continue } + nodes.append( + Node(board: nextBoard, parent: nodeIndex, move: move, depth: node.depth + 1) + ) + pending.append(nodes.count - 1) + } + } + + guard let best, let moves = line(to: best.index, nodes: nodes) else { + return (winLine: nil, bestLine: nil, exhaustive: !wasTruncated) + } + return (winLine: nil, bestLine: moves, exhaustive: !wasTruncated) + } + + static func line(to index: Int, nodes: [Node]) -> [Move]? { + var moves: [Move] = [] + var current = index + while current > 0 { + let node = nodes[current] + guard let move = node.move else { return nil } + moves.append(move) + current = node.parent + } + return moves.reversed() + } +} diff --git a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift index d15f7ca..696796d 100644 --- a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift +++ b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift @@ -7,11 +7,12 @@ enum YukonPersistenceRules { // Yukon renders no free-cell slots, so a card stranded there would be // invisible and the game unwinnable. guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } - // The pyramid fields belong to the Pyramid variant alone; a card stranded - // there would be invisible here. + // 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.wasteRecyclesUsed == 0 else { return false } + guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } return state.wasteDrawCount == 0 } } diff --git a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift index 50859fb..220a74c 100644 --- a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift +++ b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift @@ -321,8 +321,8 @@ private extension YukonPlanner { } case .foundation(let pile): _ = nextState.foundations[pile].popLast() - case .waste, .freeCell, .pyramid: - // Yukon has no waste, free cells, or pyramid. + case .waste, .freeCell, .pyramid, .triPeaks: + // Yukon has no waste, free cells, pyramid, or peaks. return nil } switch move.destination { diff --git a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift index 84e39fa..26e9eb0 100644 --- a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift +++ b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift @@ -15,6 +15,7 @@ enum DragOrigin: Hashable { case freeCell(Int) case tableau(pile: Int, index: Int) case pyramid(Int) + case triPeaks(Int) } struct DropTargetGeometry: Equatable { diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index 685b271..b2eb30f 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -269,6 +269,22 @@ struct RulesAndScoringView: View { definition: "Turning the waste back into the stock. Pyramid allows two recycles (three passes)." ) ] + case .tripeaks: + return [ + TermRow( + term: "Peaks", + definition: "Twenty-eight cards in three overlapping peaks; a card is uncovered once both cards covering it are gone, and flips face up." + ), + TermRow(term: "Stock", definition: "The face-down draw pile. One pass only — there are no recycles."), + TermRow( + term: "Waste", + definition: "The growing face-up pile; play any uncovered card one rank above or below its top." + ), + TermRow( + term: "Chain", + definition: "Consecutive discards without flipping the stock; each discard in a chain is worth one more point than the last." + ) + ] } } @@ -323,6 +339,15 @@ struct RulesAndScoringView: View { "When the stock runs out, recycle the waste back into it — at most twice.", "You win by removing all 28 pyramid cards; stock and waste may keep cards." ] + case .tripeaks: + return [ + "Deal 28 cards into three overlapping peaks — three face-down rows topped by a face-up base row of ten. One card starts the waste; the remaining 23 form the stock.", + "Play any uncovered card that is one rank above or below the top waste card, regardless of suit. It becomes the new target.", + "Ranks wrap around: a King plays on an Ace and an Ace plays on a King or a Two.", + "A face-down card flips face up once both cards covering it are removed.", + "Tap the stock to flip one card onto the waste. The stock allows a single pass — there are no recycles.", + "You win by clearing all 28 peak cards; stock and waste may keep cards." + ] } } @@ -375,6 +400,30 @@ struct RulesAndScoringView: View { note: "Reduced by elapsed time." ) ] + case .tripeaks: + return [ + ScoringRow( + move: "Discard onto the waste", + points: Scoring.delta(for: .triPeaksChainDiscard(chainLength: 1)), + note: "Each consecutive discard is worth one more: 1, 2, 3…" + ), + ScoringRow( + move: "Flip a stock card", + points: Scoring.delta(for: .triPeaksStockFlip), + note: "Also resets the chain." + ), + ScoringRow(move: "Clear a peak", points: Scoring.delta(for: .triPeaksPeakClear), note: nil), + ScoringRow( + move: "Clear the board", + points: Scoring.delta(for: .triPeaksBoardClear), + note: "Replaces the third peak's bonus." + ), + 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 a343807..e5df31d 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -388,6 +388,25 @@ struct TopRowView: View { fanProgress: fanProgress, dragGesture: dragGesture ) + case .tripeaks: + TriPeaksTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + 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 8a04b0c..d7cc1e2 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -529,6 +529,20 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + } else if viewModel.gameVariant == .tripeaks { + TriPeaksBoardView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: metrics.columnSpacing, + maxBoardHeight: metrics.tableauMaxHeight, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: effectiveHiddenCardIDs, + hintedCardIDs: viewModel.hintedCardIDs, + hintWiggleToken: viewModel.hintWiggleToken, + dragGesture: dragGesture(for:) + ) + .frame(width: boardContentWidth, alignment: .leading) } else { TableauRowView( viewModel: viewModel, @@ -911,6 +925,8 @@ struct ContentView: View { started = viewModel.startDragFromTableau(pileIndex: pile, cardIndex: index) case .pyramid(let index): started = viewModel.startDragFromPyramid(index: index) + case .triPeaks(let index): + started = viewModel.startDragFromTriPeaks(index: index) } if started, let firstCard = viewModel.selection?.cards.first { @@ -1298,6 +1314,7 @@ struct ContentView: View { } for card in state.pyramid.compactMap({ $0 }) { lookup[card.id] = card } for card in state.discard { lookup[card.id] = card } + for card in state.triPeaks.compactMap({ $0 }) { lookup[card.id] = card } return lookup } @@ -1309,6 +1326,7 @@ struct ContentView: View { case tableau(pile: Int, index: Int) case pyramid(Int) case discard(Int) + case triPeaks(Int) } private func cardLocations(in state: GameState) -> [UUID: CardLocation] { @@ -1343,6 +1361,11 @@ struct ContentView: View { for (index, card) in state.discard.enumerated() { locations[card.id] = .discard(index) } + for (index, card) in state.triPeaks.enumerated() { + if let card { + locations[card.id] = .triPeaks(index) + } + } return locations } @@ -1493,17 +1516,28 @@ struct ContentView: View { } /// The cascade erupts from the foundations, except in Pyramid where every - /// removed card lives on the discard. + /// removed card lives on the discard, and in TriPeaks where every played + /// card lives on the waste. private var winCascadeLaunchPiles: [[Card]] { - viewModel.gameVariant == .pyramid - ? [viewModel.state.discard] - : viewModel.state.foundations + switch viewModel.gameVariant { + case .pyramid: + return [viewModel.state.discard] + case .tripeaks: + return [viewModel.state.waste] + case .klondike, .freecell, .yukon, .spider: + return viewModel.state.foundations + } } private var winCascadeLaunchTargets: [DropTarget] { - viewModel.gameVariant == .pyramid - ? [.discard] - : viewModel.state.foundations.indices.map(DropTarget.foundation) + switch viewModel.gameVariant { + case .pyramid: + return [.discard] + case .tripeaks: + return [.waste] + case .klondike, .freecell, .yukon, .spider: + return viewModel.state.foundations.indices.map(DropTarget.foundation) + } } private func syncLifecyclePauseState() { diff --git a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift b/ComputerSolitaire/Views/Shared/StockWasteViews.swift similarity index 87% rename from ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift rename to ComputerSolitaire/Views/Shared/StockWasteViews.swift index 20fe7ee..2e62263 100644 --- a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift +++ b/ComputerSolitaire/Views/Shared/StockWasteViews.swift @@ -1,6 +1,11 @@ import SwiftUI import Observation +/// The stock and waste piles shared by every variant that deals from a stock: +/// Klondike, Spider (stock only), Pyramid, and TriPeaks compose these into +/// their top rows. Variant behavior stays in the view model (`handleStockTap`, +/// `handleWasteTap`, `canInteractWithStock`, `visibleWasteCards`); these views +/// only render and forward interaction. struct StockView: View { @Bindable var viewModel: SolitaireViewModel let cardSize: CGSize @@ -73,6 +78,10 @@ struct WasteView: View { let cardSize: CGSize let fanSpacing: CGFloat var isTargeted: Bool = false + /// Whether tapping the waste does anything. TriPeaks turns this off — its + /// waste top is the match target, never a mover — so the pile neither + /// handles taps nor advertises itself to VoiceOver as a button. + var isTapEnabled: Bool = true let isHintTargeted: Bool let isCardTiltEnabled: Bool @Binding var cardTilts: [UUID: Double] @@ -151,14 +160,18 @@ struct WasteView: View { } ) .onTapGesture { + guard isTapEnabled else { return } viewModel.handleWasteTap() } .zIndex(isDragSource || isSelected ? 10 : 0) .accessibilityElement(children: .ignore) .accessibilityLabel("Waste") .accessibilityValue(accessibleTopCard?.accessibilityName ?? "Empty") - .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isTapEnabled ? .isButton : []) .accessibilityAddTraits(isAccessibleTopCardSelected ? .isSelected : []) + // Declared explicitly because the installed tap gesture would otherwise + // let assistive technologies infer interactivity even when disabled. + .accessibilityRespondsToUserInteraction(isTapEnabled) .accessibilityHidden(accessibleTopCard == nil) } } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 015ef0b..71643f9 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -25,6 +25,7 @@ struct StatisticsView: View { case yukon case spider case pyramid + case tripeaks case all var id: String { rawValue } @@ -41,6 +42,8 @@ struct StatisticsView: View { self = .spider case .pyramid: self = .pyramid + case .tripeaks: + self = .tripeaks } } @@ -57,6 +60,8 @@ struct StatisticsView: View { return .spider case .pyramid: return .pyramid + case .tripeaks: + return .tripeaks case .all: return nil } @@ -64,7 +69,7 @@ struct StatisticsView: View { var title: String { switch self { - case .klondike, .freecell, .yukon, .spider, .pyramid: + case .klondike, .freecell, .yukon, .spider, .pyramid, .tripeaks: return variant?.title ?? "" case .all: return "All" @@ -273,7 +278,7 @@ struct StatisticsView: View { HighScoreRow(label: "High Score (3-card)", score: stats.highScoreDrawThree), HighScoreRow(label: "High Score (1-card)", score: stats.highScoreDrawOne) ] - case .freecell, .yukon, .pyramid: + case .freecell, .yukon, .pyramid, .tripeaks: return [HighScoreRow(label: "High Score", score: stats.highScore)] case .spider: return [ @@ -430,6 +435,8 @@ struct StatisticsView: View { return "Reset Spider statistics?" case .pyramid: return "Reset Pyramid statistics?" + case .tripeaks: + return "Reset TriPeaks statistics?" case .all: return "Reset all statistics?" } @@ -447,6 +454,8 @@ struct StatisticsView: View { return "Reset Spider Statistics" case .pyramid: return "Reset Pyramid Statistics" + case .tripeaks: + return "Reset TriPeaks Statistics" case .all: return "Reset All Statistics" } @@ -464,8 +473,10 @@ struct StatisticsView: View { return "This will reset only Spider games, times, win rates, and high scores." case .pyramid: return "This will reset only Pyramid games, times, win rates, and high scores." + case .tripeaks: + return "This will reset only TriPeaks games, times, win rates, and high scores." case .all: - return "This will reset Klondike, FreeCell, Yukon, Spider, and Pyramid statistics." + return "This will reset Klondike, FreeCell, Yukon, Spider, Pyramid, and TriPeaks statistics." } } diff --git a/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift b/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift new file mode 100644 index 0000000..0f487fd --- /dev/null +++ b/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift @@ -0,0 +1,88 @@ +import SwiftUI +import Observation + +/// The 28-slot three-peak layout replaces the shared tableau row for the +/// TriPeaks variant: three face-down rows overlapping down to the face-up +/// ten-card base row. Slots are never drop targets — cards play from here onto +/// the waste — so the board registers no drop frames. +struct TriPeaksBoardView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let maxBoardHeight: CGFloat + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + let rowOverlap = rowOverlap + let boardWidth = (cardSize.width * CGFloat(TriPeaksGeometry.baseRowLength)) + + (columnSpacing * CGFloat(TriPeaksGeometry.baseRowLength - 1)) + let boardHeight = cardSize.height + rowOverlap * CGFloat(TriPeaksGeometry.rowCount - 1) + + ZStack(alignment: .topLeading) { + ForEach(0.. CGSize { + CGSize( + width: TriPeaksGeometry.columnOffsetUnits(of: index) + * (cardSize.width + columnSpacing) / 2, + height: CGFloat(TriPeaksGeometry.row(of: index)) * rowOverlap + ) + } + + @ViewBuilder + private func peakCard(_ card: Card, at index: Int, rowOverlap: CGFloat) -> some View { + let row = TriPeaksGeometry.row(of: index) + let offset = slotOffset(for: index, rowOverlap: rowOverlap) + let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isHidden = hiddenCardIDs.contains(card.id) + let isSelected = viewModel.isSelected(card: card) + let isUncovered = TriPeaksGeometry.isUncovered(index, in: viewModel.state.triPeaks) + let isAccessibilityElement = card.isFaceUp && isUncovered && !isDragged && !isHidden + + CardView( + card: card, + isSelected: isSelected, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: isAccessibilityElement + ) + .opacity(isDragged || isHidden ? 0 : 1) + .offset(x: offset.width, y: offset.height) + .zIndex(isDragged ? 40 + Double(row) : Double(row)) + .allowsHitTesting(!isHidden) + .onTapGesture { + viewModel.handleTriPeaksTap(index: index) + } + .gesture(dragGesture(.triPeaks(index))) + .accessibilityHidden(!isAccessibilityElement) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityHint("Plays onto the waste") + .cardFramePreference(card.id, xOffset: offset.width, yOffset: offset.height) + } +} diff --git a/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift b/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift new file mode 100644 index 0000000..8e2a114 --- /dev/null +++ b/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Observation + +struct TriPeaksTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: 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) { + StockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken + ) + .frame(width: cardSize.width, alignment: .leading) + + WasteView( + viewModel: viewModel, + cardSize: cardSize, + fanSpacing: 0, + isTargeted: activeTarget == .waste, + isTapEnabled: false, + isHintTargeted: hintedTarget == .waste || isWasteHinted, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + .background( + GeometryReader { proxy in + let frame = proxy.frame(in: .named("board")) + let hitFrame = frame.expanded( + horizontal: DropTargetHitArea.foundationHorizontalGrace, + top: DropTargetHitArea.foundationTopGrace, + bottom: DropTargetHitArea.foundationBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .waste: DropTargetGeometry(snapFrame: frame, hitFrame: hitFrame) + ] + ) + } + ) + + // Fill the ten-column board width so the stock and waste align + // with the leftmost peak columns. + ForEach(0..<(TriPeaksGeometry.baseRowLength - 2), id: \.self) { _ in + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index 96e3fd3..3d1e0f2 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -305,6 +305,56 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("Pyramid fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged TriPeaks board is a fresh deal — face-up base row, one waste + /// starter. Seeds are scanned for the most photogenic base row with a first + /// play available off the waste top. + func testGenerateTriPeaksFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededTriPeaksDeal(seed: seed) + let score = triPeaksDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededTriPeaksDeal(seed: seed) + viewModel.configureTriPeaksNewGame() + + 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, .tripeaks, "Fixture did not restore as TriPeaks") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tripeaks.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("TriPeaks fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -367,6 +417,27 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh TriPeaks deal by its face-up cards (the ten-card base row + /// plus the waste starter): rank variety, red/black balance, all four + /// suits, a few face cards, and several playable base cards to suggest an + /// opening chain. + private func triPeaksDealScore(of deal: GameState) -> Int { + let visible = deal.triPeaks.compactMap { $0 }.filter(\.isFaceUp) + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + if let wasteTop = deal.waste.last { + let playableCount = visible.count { card in + TriPeaksGameRules.ranksAdjacentWithWrap(card.rank, wasteTop.rank) + } + score += min(playableCount, 3) * 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 eb8238e..c96f627 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -154,6 +154,81 @@ enum GameStateFixtures { /// the pyramid top-down (nil = removed) and is padded with removed slots; any /// cards not on the board, in the stock, or in the waste land on the discard so /// persistence-facing tests still see 52 cards. + /// A reproducible TriPeaks deal matching the shape of `GameState.newTriPeaksGame`. + /// Mirrored by the hint probe's `seededTriPeaksDeal` so seeds are comparable. + static func seededTriPeaksDeal(seed: UInt64) -> GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var triPeaks: [Card?] = [] + for index in 0.. GameState { + var triPeaks = slots + if triPeaks.count < TriPeaksGeometry.cardCount { + triPeaks.append( + contentsOf: [Card?](repeating: nil, count: TriPeaksGeometry.cardCount - triPeaks.count) + ) + } + for index in triPeaks.indices { + guard var card = triPeaks[index] else { continue } + card.isFaceUp = TriPeaksGeometry.isUncovered(index, in: triPeaks) + triPeaks[index] = card + } + var fullWaste = waste.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + if fillWasteFromRemainder { + func identity(_ card: Card) -> Int { + (Suit.allCases.firstIndex(of: card.suit) ?? 0) * 16 + card.rank.rawValue + } + let usedIdentities = Set((triPeaks.compactMap { $0 } + stock + fullWaste).map(identity)) + fullWaste = TestCards.fullDeck(faceUp: true).filter { card in + !usedIdentities.contains(identity(card)) + } + fullWaste + } + return GameState( + variant: .tripeaks, + stock: stock, + waste: fullWaste, + wasteDrawCount: min(1, fullWaste.count), + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: [], + triPeaks: triPeaks, + triPeaksChainLength: chainLength + ) + } + static func pyramidState( slots: [Card?], stock: [Card] = [], diff --git a/ComputerSolitaireTests/TriPeaks/TriPeaksGeometryTests.swift b/ComputerSolitaireTests/TriPeaks/TriPeaksGeometryTests.swift new file mode 100644 index 0000000..a6cf744 --- /dev/null +++ b/ComputerSolitaireTests/TriPeaks/TriPeaksGeometryTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class TriPeaksGeometryTests: XCTestCase { + func testRowRangesTileTheTwentyEightSlots() { + XCTAssertEqual(TriPeaksGeometry.rowRanges.count, TriPeaksGeometry.rowCount) + XCTAssertEqual(TriPeaksGeometry.rowRanges.map(\.count), [3, 6, 9, 10]) + var covered: [Int] = [] + for range in TriPeaksGeometry.rowRanges { + covered.append(contentsOf: range) + } + XCTAssertEqual(covered, Array(0.. [Int] { + TriPeaksGeometry.rowRanges[1].filter { index in + guard let covering = TriPeaksGeometry.coveringIndices(of: index) else { return false } + return covering.left == rowTwoIndex || covering.right == rowTwoIndex + } + } + XCTAssertEqual(rowOneSlotsCovered(by: 11), [4]) + XCTAssertEqual(rowOneSlotsCovered(by: 12), [5]) + // Peak-interior row-2 cards cover two row-1 cards. + XCTAssertEqual(rowOneSlotsCovered(by: 10), [3, 4]) + } + + func testEveryBaseCardCoversSomeRowTwoSlot() { + for baseIndex in TriPeaksGeometry.rowRanges[3] { + let coversSomething = TriPeaksGeometry.rowRanges[2].contains { index in + guard let covering = TriPeaksGeometry.coveringIndices(of: index) else { return false } + return covering.left == baseIndex || covering.right == baseIndex + } + XCTAssertTrue(coversSomething, "Base card \(baseIndex) covers nothing") + } + } + + func testUncoveredOnFullAndPartialBoards() { + let full = GameStateFixtures.seededTriPeaksDeal(seed: 1).triPeaks + for index in 0.. SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + stockDrawCount: stockDrawCount, + history: [] + ) + } + + /// A legally reachable mid-game shape: two base cards played onto the waste + /// (a two-card chain), one stock card flipped beneath them. + private func midGameState() -> GameState { + var state = GameStateFixtures.seededTriPeaksDeal(seed: 2) + var drawn = state.stock.removeLast() + drawn.isFaceUp = true + state.waste.append(drawn) + for slot in [27, 26] { + var played = state.triPeaks[slot]! + played.isFaceUp = true + state.triPeaks[slot] = nil + state.waste.append(played) + } + TriPeaksGameRules.flipNewlyUncoveredCards(in: &state) + state.triPeaksChainLength = 2 + state.wasteDrawCount = 1 + return state + } + + func testFreshDealRoundTripsThroughSanitization() throws { + let state = GameState.newTriPeaksGame() + 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) + XCTAssertEqual(decoded.triPeaksChainLength, 2) + + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + XCTAssertEqual(try XCTUnwrap(sanitized).state, state) + } + + func testDecodingLegacySaveWithoutTriPeaksFields() throws { + // Saves written before the TriPeaks variant carry no triPeaks keys; they + // must decode to empty TriPeaks fields and keep validating. + let legacy = GameStateFixtures.seededKlondikeDeal(seed: 1) + var json = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(legacy) + ) as? [String: Any] ?? [:] + json.removeValue(forKey: "triPeaks") + json.removeValue(forKey: "triPeaksChainLength") + let data = try JSONSerialization.data(withJSONObject: json) + + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertTrue(decoded.triPeaks.isEmpty) + XCTAssertEqual(decoded.triPeaksChainLength, 0) + XCTAssertNotNil(payload(for: decoded, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference)) + } + + func testSanitizationAcceptsLegalMidGameStates() { + XCTAssertNotNil(payload(for: midGameState()).sanitizedForRestore(at: DateFixtures.reference)) + } + + func testSanitizationRejectsCorruptTriPeaksStates() { + func assertRejected( + _ message: String, + mutate: (inout GameState) -> Void + ) { + var state = midGameState() + mutate(&state) + XCTAssertNil( + payload(for: state).sanitizedForRestore(at: DateFixtures.reference), + message + ) + } + + assertRejected("Wrong slot count") { state in + state.triPeaks.removeLast() + } + assertRejected("A removed card under an intact coverer is unreachable") { state in + // Slot 9 is covered while base 18/19 remain on a near-fresh board. + let card = state.triPeaks[9]! + state.triPeaks[9] = nil + state.waste.insert(card, at: 0) + } + assertRejected("A covered face-up card breaks the flip invariant") { state in + state.triPeaks[9]?.isFaceUp = true + } + assertRejected("An uncovered face-down card breaks the flip invariant") { state in + state.triPeaks[20]?.isFaceUp = false + } + assertRejected("Cards stranded in the pyramid field are invisible") { state in + state.pyramid = [state.waste.removeLast()] + } + assertRejected("Cards stranded in the discard are invisible") { state in + state.discard = [state.waste.removeLast()] + } + assertRejected("Cards stranded in a tableau pile are invisible") { state in + state.tableau = [[state.waste.removeLast()]] + } + assertRejected("Cards stranded in a foundation are invisible") { state in + state.foundations[0] = [state.waste.removeLast()] + } + assertRejected("Cards stranded in a free cell are invisible") { state in + state.freeCells[0] = state.waste.removeLast() + } + assertRejected("TriPeaks never recycles the waste") { state in + state.wasteRecyclesUsed = 1 + } + assertRejected("An empty waste has no match target") { state in + state.stock.append(contentsOf: state.waste) + state.waste = [] + state.triPeaksChainLength = 0 + } + assertRejected("The chain cannot exceed the waste beyond its starter") { state in + state.triPeaksChainLength = state.waste.count + } + assertRejected("The chain cannot exceed the cards removed from the board") { state in + state.triPeaksChainLength = 3 + } + assertRejected("A negative chain is corrupt") { state in + state.triPeaksChainLength = -1 + } + assertRejected("Duplicate cards break deck composition") { state in + state.waste[0] = state.waste[1] + } + } + + func testOtherVariantsRejectStrandedTriPeaksState() { + var klondike = GameStateFixtures.seededKlondikeDeal(seed: 1) + klondike.triPeaks = [klondike.stock.removeLast()] + XCTAssertNil( + payload(for: klondike, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference), + "A card stranded in triPeaks would be invisible in Klondike" + ) + + var pyramid = GameStateFixtures.seededPyramidDeal(seed: 1) + pyramid.triPeaksChainLength = 2 + XCTAssertNil( + payload(for: pyramid).sanitizedForRestore(at: DateFixtures.reference), + "A nonzero chain outside TriPeaks is corrupt" + ) + } + + func testSanitizationForcesTriPeaksDrawCounts() throws { + let state = GameState.newTriPeaksGame() + let sanitized = payload(for: state, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual( + restored.stockDrawCount, + DrawMode.one.rawValue, + "TriPeaks always draws a single card" + ) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testViewModelRestoresTriPeaksPayload() throws { + let state = midGameState() + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: payload(for: state))) + XCTAssertEqual(viewModel.gameVariant, .tripeaks) + XCTAssertEqual(viewModel.state, state) + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + } +} diff --git a/ComputerSolitaireTests/TriPeaks/TriPeaksPlannerTests.swift b/ComputerSolitaireTests/TriPeaks/TriPeaksPlannerTests.swift new file mode 100644 index 0000000..3ee0162 --- /dev/null +++ b/ComputerSolitaireTests/TriPeaks/TriPeaksPlannerTests.swift @@ -0,0 +1,446 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class TriPeaksPlannerTests: XCTestCase { + // Probe-verified seeds (release-build sweep over seeded deals): the winning + // seed's deal is cleared by following hints end-to-end; the unwinnable seed + // is proved lost by the exhaustive search at the default budget. + private static let winningSeed: UInt64 = 1 + private static let unwinnableSeed: UInt64 = 282 + + func testHintIsDeterministicAcrossCalls() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .eight) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + + let first = TriPeaksPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(TriPeaksPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal always has a suggestible line within easy reach, so a + // small budget keeps the suite fast; production searches are capped by + // the interactive deadline. + let limits = TriPeaksPlanner.Limits(maxNodes: 20_000) + for seed in 1...10 { + let state = GameStateFixtures.seededTriPeaksDeal(seed: UInt64(seed)) + XCTAssertNotNil( + TriPeaksPlanner.bestHint(in: state, limits: limits), + "Seed \(seed): a fresh TriPeaks deal should have a suggestible line" + ) + } + } + + func testWinningLineReplaysLegallyToAClearedBoard() { + let state = GameStateFixtures.seededTriPeaksDeal(seed: Self.winningSeed) + guard case .winningLine(let line) = TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Probe-verified winning seed should produce a winning line") + } + + var current = state + for move in line { + if case .play = move { + replayThroughAdvisor(move, on: ¤t) + } else { + guard let next = TriPeaksPlanner.apply(move, to: current) else { + return XCTFail("Stock move in the winning line was not legal") + } + current = next + } + } + XCTAssertTrue(current.isWon, "Replaying the winning line must clear the peaks") + } + + func testKeyedMovesFollowTheSolutionLine() { + let state = GameStateFixtures.seededTriPeaksDeal(seed: Self.winningSeed) + guard case .winningLine(let line) = TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line") + } + + let keyed = TriPeaksPlanner.keyedMoves(along: line, from: state) + XCTAssertEqual(keyed.count, line.count, "Every position along the line gets its move") + + var current = state + for move in line { + XCTAssertEqual(keyed[TriPeaksPlanner.stateKey(for: current)], move) + guard let next = TriPeaksPlanner.apply(move, to: current) else { + return XCTFail("Line move was not legal") + } + current = next + } + } + + func testHintPlannerWinsAKnownDealEndToEnd() { + // Probe-verified winning seed: following the HintPlanner's cached lines + // (including stock taps) plays this deal to a win. Guards the whole + // stack. The game is structurally bounded at 51 actions. + let planner = HintPlanner() + var state = GameStateFixtures.seededTriPeaksDeal(seed: Self.winningSeed) + var steps = 0 + + while steps < 60 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: 1) else { + return XCTFail("Hint stack gave up after \(steps) steps") + } + guard let next = applied(hint, to: state) else { + return XCTFail("Hinted action was not legal after \(steps) steps") + } + state = next + steps += 1 + } + XCTFail("Did not win within 60 steps") + } + + func testUnwinnableDealIsProvedAndStillYieldsBestEffortHints() { + var state = GameStateFixtures.seededTriPeaksDeal(seed: Self.unwinnableSeed) + guard case .bestEffortLine(let line, let dealIsProvedUnwinnable) = + TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Probe-verified lost seed should produce a best-effort line") + } + XCTAssertTrue(dealIsProvedUnwinnable, "The exhausted search must prove this deal lost") + XCTAssertFalse(line.isEmpty) + + // Following hints clears strictly more cards, never plays an illegal + // move, and ends in silence rather than churn. + let planner = HintPlanner() + let clearedAtStart = state.triPeaks.count { $0 == nil } + var steps = 0 + while steps < 60, let hint = planner.bestHint(in: state, stockDrawCount: 1) { + guard let next = applied(hint, to: state) else { + return XCTFail("Hinted action was not legal after \(steps) steps") + } + state = next + steps += 1 + } + XCTAssertLessThan(steps, 60, "Hints on a lost deal must eventually go silent") + XCTAssertFalse(state.isWon) + XCTAssertGreaterThan( + state.triPeaks.count { $0 == nil }, + clearedAtStart, + "Best-effort hints should still clear peak cards" + ) + } + + func testFollowingHintsNeverRepeatsAPosition() { + // Every TriPeaks move consumes a card, so followed lines can never + // revisit a position; this guards the state mapping and cache. + for seed in [Self.winningSeed, Self.unwinnableSeed] { + let planner = HintPlanner() + var state = GameStateFixtures.seededTriPeaksDeal(seed: seed) + var seen: Set = [TriPeaksPlanner.stateKey(for: state)] + var steps = 0 + while steps < 60, !state.isWon, + let hint = planner.bestHint(in: state, stockDrawCount: 1) { + guard let next = applied(hint, to: state) else { + return XCTFail("Seed \(seed): hinted action was not legal") + } + state = next + steps += 1 + XCTAssertTrue( + seen.insert(TriPeaksPlanner.stateKey(for: state)).inserted, + "Seed \(seed): following hints revisited a position" + ) + } + } + } + + func testDrawHintWhenNoPlayExists() { + // The 9 cannot play on the 6, but drawing the 8 makes it playable: the + // only winning line starts with a flip, so the hint is a stock tap. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .nine) + let state = GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .eight, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .six)] + ) + + guard case .winningLine(let line) = TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line through the flip") + } + XCTAssertEqual(line.first, .draw) + XCTAssertEqual(TriPeaksPlanner.bestHint(in: state), .stockTap) + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testFlipTimingIsSearchedNotGreedy() { + // The search dives plays-first, so wherever a winning line flips the + // stock while plays were legal, every play-first alternative from that + // position was explored and lost — the flip's timing is a verdict. + // Verify the property holds at such a position on the winning seed. + let deal = GameStateFixtures.seededTriPeaksDeal(seed: Self.winningSeed) + guard case .winningLine(let line) = TriPeaksPlanner.bestLine(in: deal) else { + return XCTFail("Expected a winning line") + } + + var state = deal + for move in line { + let playsAvailable = AutoMoveAdvisor.candidateSelections(in: state).contains { + !AutoMoveAdvisor.legalDestinations(for: $0, in: state).isEmpty + } + if move == .draw, playsAvailable { + guard case .winningLine(let replanned) = TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Position on a winning line must stay winnable") + } + XCTAssertEqual( + replanned.first, + .draw, + "With plays available, a flip-first winning line means every play loses" + ) + return + } + guard let next = TriPeaksPlanner.apply(move, to: state) else { + return XCTFail("Line move was not legal") + } + state = next + } + // Seed 1's line does flip while plays are available, so the walk above + // returns before reaching here. If a future deal or search change picks + // a line that never does, this test would silently become a no-op — + // fail loudly instead so the seed gets replaced. + XCTFail("Winning line never flipped while plays were available — pick another seed") + } + + func testWrapAdjacencyIsPlayable() { + // Waste King: both the Queen and the Ace play (ranks wrap). + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .queen) + slots[19] = TestCards.make(.hearts, .ace) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .king)] + ) + + for index in [18, 19] { + let selection = Selection( + source: .triPeaks(index: index), + cards: [state.triPeaks[index]!] + ) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: selection, in: state), + [.waste], + "Slot \(index) should play on the King" + ) + } + XCTAssertNotNil(TriPeaksPlanner.bestHint(in: state)) + } + + func testStateKeyMergesOnlyStrategicallyIdenticalStates() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + let base = GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .nine, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)] + ) + + // Buried waste history and suits are strategically inert: same key. + var differentHistory = base + differentHistory.waste.insert(TestCards.make(.hearts, .two), at: 0) + XCTAssertEqual( + TriPeaksPlanner.stateKey(for: base), + TriPeaksPlanner.stateKey(for: differentHistory) + ) + var differentSuit = base + differentSuit.waste[0] = TestCards.make(.clubs, .seven) + XCTAssertEqual( + TriPeaksPlanner.stateKey(for: base), + TriPeaksPlanner.stateKey(for: differentSuit) + ) + + // The waste top rank, the board, and the stock all gate the future: + // each changes the key. + var differentTop = base + differentTop.waste[0] = TestCards.make(.diamonds, .eight) + XCTAssertNotEqual( + TriPeaksPlanner.stateKey(for: base), + TriPeaksPlanner.stateKey(for: differentTop) + ) + var differentBoard = base + differentBoard.triPeaks[18] = TestCards.make(.spades, .five, isFaceUp: true) + XCTAssertNotEqual( + TriPeaksPlanner.stateKey(for: base), + TriPeaksPlanner.stateKey(for: differentBoard) + ) + var differentStock = base + differentStock.stock = [TestCards.make(.clubs, .ten, isFaceUp: false)] + XCTAssertNotEqual( + TriPeaksPlanner.stateKey(for: base), + TriPeaksPlanner.stateKey(for: differentStock) + ) + } + + func testMaterializeRejectsStaleMoves() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + let move = TriPeaksPlanner.Move.play(slot: 18) + XCTAssertNotNil(TriPeaksPlanner.materialize(move, in: state)) + + var clearedSlot = state + clearedSlot.triPeaks[18] = nil + XCTAssertNil( + TriPeaksPlanner.materialize(move, in: clearedSlot), + "A cached move for an emptied slot must not surface" + ) + + var changedTop = state + changedTop.waste[0] = TestCards.make(.hearts, .ten) + XCTAssertNil( + TriPeaksPlanner.materialize(move, in: changedTop), + "A cached move that is no longer adjacent must not surface" + ) + + XCTAssertNil( + TriPeaksPlanner.materialize(.draw, in: state), + "A draw hint with an empty stock must not surface" + ) + } + + func testNoMovesWhenStockEmptyAndNoPlay() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .nine) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .six)] + ) + + guard case .noProgress(searchWasExhaustive: true) = TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Expected an exhaustive no-progress outcome") + } + XCTAssertNil(TriPeaksPlanner.bestHint(in: state)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testProvablyFutileDrawsGetNoHintButKeepButtonAlive() { + // Draws are legal, but the lone 7 has no 6 or 8 anywhere: churning the + // stock is provably futile, so the hint goes silent while the button + // stays alive. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .seven) + let state = GameStateFixtures.triPeaksState( + slots: slots, + stock: [ + TestCards.make(.clubs, .two, isFaceUp: false), + TestCards.make(.hearts, .jack, isFaceUp: false) + ], + waste: [TestCards.make(.diamonds, .four)] + ) + + guard case .noProgress(searchWasExhaustive: true) = TriPeaksPlanner.bestLine(in: state) else { + return XCTFail("Expected an exhaustive no-progress outcome") + } + XCTAssertNil(HintPlanner().bestHint(in: state, stockDrawCount: 1)) + // The position still has legal draws — the hint's nil is a verdict, not a bug. + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(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 + // deal is dead. + let limits = TriPeaksPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededTriPeaksDeal(seed: 5) + + guard case .noProgress(searchWasExhaustive: false) = TriPeaksPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(TriPeaksPlanner.bestHint(in: state, limits: limits)) + } + + func testHintsAreAlwaysLegalFromArbitraryMidGamePositions() { + // Planner moves must materialize into advisor-legal moves from any + // reachable position, not just fresh deals. + for seed in 1...5 { + var state = GameStateFixtures.seededTriPeaksDeal(seed: UInt64(seed)) + var generator = SeededRandomNumberGenerator(seed: UInt64(seed) &* 977) + for _ in 0..<8 { + var options: [(Selection, Destination)] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + options.append((selection, destination)) + } + } + if !state.stock.isEmpty, generator.next() % 2 == 0 { + state = TriPeaksPlanner.apply(.draw, to: state) ?? state + } else if let choice = options.isEmpty + ? nil + : options[Int(generator.next() % UInt64(options.count))] { + state = AutoMoveAdvisor.simulatedState( + afterMoving: choice.0, + to: choice.1, + in: state, + stockDrawCount: 1 + ) ?? state + } + } + + guard let hint = TriPeaksPlanner.bestHint(in: state) else { continue } + switch hint { + case .move(let move): + XCTAssertTrue( + AutoMoveAdvisor.selectionMatchesState(move.selection, in: state), + "Seed \(seed): hinted selection did not match the state" + ) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: move.selection, in: state) + .contains(move.destination), + "Seed \(seed): hinted destination was not legal" + ) + case .stockTap: + XCTAssertFalse( + state.stock.isEmpty, + "Seed \(seed): stock tap hinted with a dead stock" + ) + } + } + } + + // MARK: - Helpers + + 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: 1 + ) + case .stockTap: + return TriPeaksPlanner.apply(.draw, to: state) + } + } + + private func replayThroughAdvisor(_ move: TriPeaksPlanner.Move, on state: inout GameState) { + guard case .move(let hintMove)? = TriPeaksPlanner.materialize(move, in: state) else { + return XCTFail("Play move failed to materialize") + } + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: hintMove.selection, + to: hintMove.destination, + in: state, + stockDrawCount: 1 + ) else { + return XCTFail("Materialized move was not advisor-legal") + } + state = next + } +} diff --git a/ComputerSolitaireTests/TriPeaks/TriPeaksRulesTests.swift b/ComputerSolitaireTests/TriPeaks/TriPeaksRulesTests.swift new file mode 100644 index 0000000..3ed3d14 --- /dev/null +++ b/ComputerSolitaireTests/TriPeaks/TriPeaksRulesTests.swift @@ -0,0 +1,227 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class TriPeaksRulesTests: XCTestCase { + func testRankAdjacencyWrapsAtAceAndKing() { + XCTAssertTrue(TriPeaksGameRules.ranksAdjacentWithWrap(.ace, .two)) + XCTAssertTrue(TriPeaksGameRules.ranksAdjacentWithWrap(.ace, .king)) + XCTAssertTrue(TriPeaksGameRules.ranksAdjacentWithWrap(.king, .queen)) + XCTAssertTrue(TriPeaksGameRules.ranksAdjacentWithWrap(.seven, .eight)) + XCTAssertTrue(TriPeaksGameRules.ranksAdjacentWithWrap(.seven, .six)) + + XCTAssertFalse(TriPeaksGameRules.ranksAdjacentWithWrap(.ace, .three)) + XCTAssertFalse(TriPeaksGameRules.ranksAdjacentWithWrap(.king, .two)) + XCTAssertFalse(TriPeaksGameRules.ranksAdjacentWithWrap(.seven, .seven)) + XCTAssertFalse(TriPeaksGameRules.ranksAdjacentWithWrap(.queen, .ace)) + } + + func testCanPlayRequiresUncoveredAndAdjacency() { + // Base 18/19 present and covering row-2 slot 9; waste top is a 7. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[9] = TestCards.make(.clubs, .eight) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .ten) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + + XCTAssertTrue( + TriPeaksGameRules.canPlay(index: 18, in: state), + "An uncovered adjacent card plays (suit ignored)" + ) + XCTAssertFalse( + TriPeaksGameRules.canPlay(index: 19, in: state), + "Ten is not adjacent to seven" + ) + XCTAssertFalse( + TriPeaksGameRules.canPlay(index: 9, in: state), + "A covered card never plays, adjacent rank or not" + ) + XCTAssertFalse( + TriPeaksGameRules.canPlay(index: 27, in: state), + "An empty slot never plays" + ) + } + + func testStateByApplyingMovesCardToWasteAndIncrementsChain() throws { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + let played = TestCards.make(.spades, .six) + slots[18] = played + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)], + chainLength: 2 + ) + + let next = try XCTUnwrap( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 18), cards: [played]), + destination: .waste, + to: state + ) + ) + XCTAssertNil(next.triPeaks[18]) + XCTAssertEqual(next.waste.last?.id, played.id) + XCTAssertEqual(next.waste.count, state.waste.count + 1) + XCTAssertEqual(next.triPeaksChainLength, 3) + XCTAssertEqual(next.wasteDrawCount, 1) + } + + func testStateByApplyingFlipsNewlyUncoveredCards() throws { + // Slot 9 is covered by base 18 and 19; removing the second coverer + // flips it, removing only the first does not. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[9] = TestCards.make(.clubs, .four) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .seven) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .eight)] + ) + XCTAssertEqual(state.triPeaks[9]?.isFaceUp, false) + + let afterFirst = try XCTUnwrap( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 19), cards: [state.triPeaks[19]!]), + destination: .waste, + to: state + ) + ) + XCTAssertEqual(afterFirst.triPeaks[9]?.isFaceUp, false, "One coverer remains") + + let afterSecond = try XCTUnwrap( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 18), cards: [afterFirst.triPeaks[18]!]), + destination: .waste, + to: afterFirst + ) + ) + XCTAssertEqual(afterSecond.triPeaks[9]?.isFaceUp, true, "Both coverers gone: flips") + } + + func testStateByApplyingRejectsIllegalMoves() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[9] = TestCards.make(.clubs, .eight) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .ten) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + + // Covered card. + XCTAssertNil( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 9), cards: [state.triPeaks[9]!]), + destination: .waste, + to: state + ) + ) + // Non-adjacent rank. + XCTAssertNil( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 19), cards: [state.triPeaks[19]!]), + destination: .waste, + to: state + ) + ) + // Stale selection: right slot, wrong card identity. + XCTAssertNil( + TriPeaksGameRules.stateByApplying( + selection: Selection( + source: .triPeaks(index: 18), + cards: [TestCards.make(.spades, .six)] + ), + destination: .waste, + to: state + ) + ) + // Wrong destination. + XCTAssertNil( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 18), cards: [state.triPeaks[18]!]), + destination: .discard, + to: state + ) + ) + // Wrong variant. + var pyramidState = state + pyramidState.variant = .pyramid + XCTAssertNil( + TriPeaksGameRules.stateByApplying( + selection: Selection(source: .triPeaks(index: 18), cards: [state.triPeaks[18]!]), + destination: .waste, + to: pyramidState + ) + ) + } + + func testClearedPeakCountReadsApexSlots() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + XCTAssertEqual(TriPeaksGameRules.clearedPeakCount(in: GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ).triPeaks), 3) + + slots[0] = TestCards.make(.clubs, .two) + slots[2] = TestCards.make(.spades, .nine) + let partial = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + XCTAssertEqual(TriPeaksGameRules.clearedPeakCount(in: partial.triPeaks), 1) + } + + func testAdvisorGeneratesExactlyTheLegalTriPeaksMoves() { + // Uncovered: 0 (apex, subtree clear) and the two base cards. Playable + // onto the 7: the 6 and the 8 only. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[0] = TestCards.make(.clubs, .jack) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .eight) + let state = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + + let sources = AutoMoveAdvisor.candidateSelections(in: state).map(\.source) + XCTAssertEqual( + sources, + [.triPeaks(index: 0), .triPeaks(index: 18), .triPeaks(index: 19)], + "Every uncovered card is a candidate; covered and empty slots are not" + ) + + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + let destinations = AutoMoveAdvisor.legalDestinations(for: selection, in: state) + switch selection.source { + case .triPeaks(let index) where index == 0: + XCTAssertTrue(destinations.isEmpty, "Jack is not adjacent to seven") + default: + XCTAssertEqual(destinations, [.waste]) + } + } + } + + func testSimulatedStateMatchesStateByApplying() throws { + let state = GameStateFixtures.seededTriPeaksDeal(seed: 3) + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + let simulated = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: 1 + ) + let applied = TriPeaksGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + XCTAssertEqual(simulated, applied) + XCTAssertNotNil(applied, "Advisor-legal moves must apply") + } + } + } +} diff --git a/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift b/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift new file mode 100644 index 0000000..ead5048 --- /dev/null +++ b/ComputerSolitaireTests/TriPeaks/TriPeaksSessionTests.swift @@ -0,0 +1,360 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class TriPeaksSessionTests: XCTestCase { + private func makeTriPeaksSession() -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .tripeaks) + viewModel.newGame(variant: .tripeaks) + return viewModel + } + + /// A session staged on a hand-constructed board; draw counts configured as + /// a real TriPeaks game would be. + private func makeStagedSession(state: GameState) -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .tripeaks) + viewModel.state = state + viewModel.configureTriPeaksNewGame() + return viewModel + } + + private func playedSelection(at index: Int, in viewModel: SolitaireViewModel) -> Selection { + Selection(source: .triPeaks(index: index), cards: [viewModel.state.triPeaks[index]!]) + } + + func testNewTriPeaksGameLayout() { + let state = GameState.newTriPeaksGame() + XCTAssertEqual(state.variant, .tripeaks) + XCTAssertEqual(state.triPeaks.count, TriPeaksGeometry.cardCount) + for index in state.triPeaks.indices { + let expectedFaceUp = TriPeaksGeometry.row(of: index) == TriPeaksGeometry.rowCount - 1 + XCTAssertEqual( + state.triPeaks[index]?.isFaceUp, + expectedFaceUp, + "Rows above the base deal face down; the base row deals face up" + ) + } + XCTAssertEqual(state.stock.count, 23) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertEqual(state.waste.count, 1) + XCTAssertEqual(state.waste.last?.isFaceUp, true) + XCTAssertEqual(state.wasteDrawCount, 1) + XCTAssertTrue(state.tableau.isEmpty) + XCTAssertTrue(state.pyramid.isEmpty) + XCTAssertTrue(state.discard.isEmpty) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertEqual(state.triPeaksChainLength, 0) + XCTAssertFalse(state.isWon) + } + + func testSeededDealMatchesRealDealShape() { + let real = GameState.newTriPeaksGame() + let seeded = GameStateFixtures.seededTriPeaksDeal(seed: 1) + XCTAssertEqual(seeded.triPeaks.count, real.triPeaks.count) + XCTAssertEqual(seeded.stock.count, real.stock.count) + XCTAssertEqual(seeded.waste.count, real.waste.count) + XCTAssertEqual(seeded.wasteDrawCount, real.wasteDrawCount) + for index in seeded.triPeaks.indices { + XCTAssertEqual(seeded.triPeaks[index]?.isFaceUp, real.triPeaks[index]?.isFaceUp) + } + } + + func testNewGameConfiguresDrawCounts() { + let viewModel = makeTriPeaksSession() + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(viewModel.scoringDrawCount, DrawMode.three.rawValue) + XCTAssertFalse(viewModel.supportsDrawMode) + } + + func testStockTapDrawsOneCardResetsChainAndScoresPenalty() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .nine, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)], + chainLength: 4 + ) + ) + viewModel.setInitialScore(20) + + viewModel.handleStockTap() + + XCTAssertTrue(viewModel.state.stock.isEmpty) + XCTAssertEqual(viewModel.state.waste.last?.rank, .nine) + XCTAssertEqual(viewModel.state.waste.last?.isFaceUp, true) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1) + XCTAssertEqual(viewModel.state.triPeaksChainLength, 0, "A stock flip breaks the chain") + XCTAssertEqual(viewModel.score, 15, "A stock flip costs five points") + XCTAssertEqual(viewModel.movesCount, 1) + + viewModel.undo() + XCTAssertEqual(viewModel.state.stock.count, 1) + XCTAssertEqual(viewModel.state.triPeaksChainLength, 4) + XCTAssertEqual(viewModel.score, 20) + } + + func testStockFlipScoreClampsAtZero() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .nine, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + viewModel.setInitialScore(3) + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.score, 0, "Score never goes below zero") + } + + func testPlayMovesScoreChainEscalationAndFlipResetsIt() { + // Base 6, 5, 4 chain off the waste 7; the stock flip in between breaks + // the chain so the final play scores 1 again. The unplayable Jack keeps + // the board from clearing so no win bonus muddies the arithmetic. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .five) + slots[20] = TestCards.make(.clubs, .four) + slots[27] = TestCards.make(.diamonds, .jack) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .five, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 18, in: viewModel), + to: .waste + )) + XCTAssertEqual(viewModel.score, 1, "First discard in a chain scores one") + + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 19, in: viewModel), + to: .waste + )) + XCTAssertEqual(viewModel.score, 3, "Second discard scores two") + + viewModel.handleStockTap() + XCTAssertEqual(viewModel.score, 0, "Flip costs five, clamped at zero") + XCTAssertEqual(viewModel.state.triPeaksChainLength, 0) + + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 20, in: viewModel), + to: .waste + )) + XCTAssertEqual(viewModel.score, 1, "The chain restarts at one after a flip") + } + + func testPeakClearAwardsBonus() { + // Apex 0 is the only card of its peak; apex 1 remains, so clearing apex + // 0 pays the peak bonus, not the board-clear bonus. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[0] = TestCards.make(.spades, .six) + slots[1] = TestCards.make(.hearts, .jack) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .nine, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 0, in: viewModel), + to: .waste + )) + XCTAssertEqual(viewModel.score, 16, "Chain point plus the 15-point peak bonus") + XCTAssertFalse(viewModel.isWin) + } + + func testBoardClearAwardsThirtyAndWinsWithStockRemaining() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[0] = TestCards.make(.spades, .six) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .nine, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 0, in: viewModel), + to: .waste + )) + + XCTAssertTrue(viewModel.isWin, "Clearing the last peak card wins with stock remaining") + let timeBonus = Scoring.timeBonus( + elapsedSeconds: viewModel.finalElapsedSeconds ?? 0, + maxBonus: Scoring.timedMaxBonusDrawThree + ) + XCTAssertEqual( + viewModel.score, + 1 + 30 + timeBonus, + "Chain point, board-clear bonus, and the draw-three-basis time bonus" + ) + } + + func testTapQueuesAutoMoveToWaste() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + + viewModel.handleTriPeaksTap(index: 18) + + XCTAssertEqual(viewModel.pendingAutoMove?.destination, .waste) + XCTAssertEqual(viewModel.pendingAutoMove?.selection.source, .triPeaks(index: 18)) + } + + func testTappingCoveredOrUnplayableCardsDoesNothing() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[9] = TestCards.make(.clubs, .eight) + slots[18] = TestCards.make(.spades, .ten) + slots[19] = TestCards.make(.hearts, .three) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + let before = viewModel.state + + viewModel.handleTriPeaksTap(index: 9) + XCTAssertEqual(viewModel.state, before, "A covered card ignores taps") + XCTAssertNil(viewModel.pendingAutoMove) + XCTAssertNil(viewModel.selection) + + viewModel.handleTriPeaksTap(index: 18) + XCTAssertEqual(viewModel.state, before, "An unplayable card gives feedback only") + XCTAssertNil(viewModel.pendingAutoMove) + XCTAssertNil(viewModel.selection, "TriPeaks has no two-step selection flow") + } + + func testWasteTapAndDragAreInert() { + let viewModel = makeTriPeaksSession() + let before = viewModel.state + + viewModel.handleWasteTap() + XCTAssertEqual(viewModel.state, before) + XCTAssertNil(viewModel.selection, "The waste top is a target, never a mover") + + XCTAssertFalse(viewModel.startDragFromWaste()) + XCTAssertFalse(viewModel.isDragging) + } + + func testDragFromPeaksRequiresUncoveredFaceUpCard() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[9] = TestCards.make(.clubs, .eight) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .ten) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + + XCTAssertFalse(viewModel.startDragFromTriPeaks(index: 9), "Covered cards cannot drag") + XCTAssertTrue( + viewModel.startDragFromTriPeaks(index: 19), + "Any uncovered card can drag; playability is checked at the drop" + ) + XCTAssertEqual(viewModel.selection?.source, .triPeaks(index: 19)) + XCTAssertTrue(viewModel.isDragging) + XCTAssertFalse(viewModel.canDrop(to: .waste), "Ten is not adjacent to seven") + + viewModel.selection = Selection( + source: .triPeaks(index: 18), + cards: [viewModel.state.triPeaks[18]!] + ) + XCTAssertTrue(viewModel.canDrop(to: .waste)) + } + + func testStockExhaustsWithNoRecycle() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .six) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + XCTAssertFalse(viewModel.canInteractWithStock, "An empty TriPeaks stock is dead") + + let movesBefore = viewModel.movesCount + let before = viewModel.state + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state, before, "An empty stock never recycles") + XCTAssertEqual(viewModel.movesCount, movesBefore) + XCTAssertFalse(viewModel.canUndo, "A dead stock tap must not push history") + } + + func testUndoRestoresFlippedCardsFaceDown() { + // Playing the 6 then the 5 (a chain) removes both coverers of slot 9. + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[9] = TestCards.make(.clubs, .queen) + slots[18] = TestCards.make(.spades, .six) + slots[19] = TestCards.make(.hearts, .five) + let viewModel = makeStagedSession( + state: GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + ) + + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 18, in: viewModel), + to: .waste + )) + XCTAssertTrue(viewModel.performTriPeaksMove( + selection: playedSelection(at: 19, in: viewModel), + to: .waste + )) + XCTAssertEqual(viewModel.state.triPeaks[9]?.isFaceUp, true) + + viewModel.undo() + XCTAssertEqual( + viewModel.state.triPeaks[9]?.isFaceUp, + false, + "Undo restores the auto-flipped card face down" + ) + XCTAssertNotNil(viewModel.state.triPeaks[19]) + XCTAssertEqual(viewModel.state.triPeaksChainLength, 1) + } + + func testHintAvailabilityTracksStock() { + var slots = [Card?](repeating: nil, count: TriPeaksGeometry.cardCount) + slots[18] = TestCards.make(.spades, .ten) + + let withStock = GameStateFixtures.triPeaksState( + slots: slots, + stock: [TestCards.make(.clubs, .nine, isFaceUp: false)], + waste: [TestCards.make(.diamonds, .seven)] + ) + XCTAssertTrue( + HintAdvisor.anyPlayerMoveExists(in: withStock), + "A flip is a legal action while stock remains" + ) + + let deadBoard = GameStateFixtures.triPeaksState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + XCTAssertFalse( + HintAdvisor.anyPlayerMoveExists(in: deadBoard), + "Empty stock and no adjacent play: nothing is legal" + ) + } +} diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index a5e3b08..945b758 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -25,6 +25,7 @@ tools/hint-probe/run.sh freecell 500 tools/hint-probe/run.sh spider 500 # all three suit counts tools/hint-probe/run.sh spider 500 4 # third arg narrows to one suit count tools/hint-probe/run.sh pyramid 500 +tools/hint-probe/run.sh tripeaks 500 ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -65,6 +66,7 @@ consecutive runs, serial and parallel. | `spider` 2-suit | **49.2%** | 0.0% | | `spider` 4-suit | **2.8%** | 0.0% | | `pyramid` | **80.2%** | 15.2% | +| `tripeaks` | **95.4%** | 0.0% | Reading the table honestly: @@ -109,6 +111,15 @@ Reading the table honestly: median 22 of 28 cleared on lost deals), and the over-banking detector does not apply. Wins are efficient by structure — the whole game is bounded near 100 actions — so the hint value is the win-rate gap, not move count. +- **TriPeaks (95.4% vs 0.0%)**: the solver's own verdict sweep proves 95.6% of + deals winnable at its default budget (0.2% proved unwinnable, 4.1% undecided + over 10,000 deals), so the follower converts essentially every deal the + search can prove — and the random control winning zero says single-pass + TriPeaks wins are never stumbled into; the entire hint column is solver + skill. Losses record peak cards cleared (TriPeaks banks no foundations; + median 27 of 28 cleared on lost deals — best-effort lines leave almost + nothing behind), and the over-banking detector does not apply. The game is + structurally bounded at 51 actions, so the hint value is the win-rate gap. - 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 diff --git a/tools/hint-probe/main.swift b/tools/hint-probe/main.swift index 909f6a2..29ca89c 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -144,6 +144,27 @@ func seededDeal(variant: GameVariant, seed: UInt64, spiderSuitCount: SpiderSuitC pyramid: pyramid, discard: [] ) + + case .tripeaks: + // Mirrors GameState.newTriPeaksGame (and GameStateFixtures.seededTriPeaksDeal). + var deck = seededDeck(seed: seed, faceUp: false) + var triPeaks: [Card?] = [] + for index in 0.. UInt64 { mix(0xFA) for card in state.discard { mix(card: card) } mix(UInt8(min(255, max(0, state.wasteRecyclesUsed)))) + for slot in state.triPeaks { + mix(0xF9) + if let card = slot { mix(card: card) } + } return hash } @@ -218,6 +243,16 @@ func pyramidCleared(_ state: GameState) -> Int { state.pyramid.count(where: { $0 == nil }) } +/// Mirrors handleTriPeaksStockTap in the session: draw one, no recycles ever. +/// The planner's apply is the same pure logic. +func triPeaksStockTap(_ state: GameState) -> GameState? { + TriPeaksPlanner.apply(.draw, to: state) +} + +func triPeaksCleared(_ state: GameState) -> Int { + state.triPeaks.count(where: { $0 == nil }) +} + /// Mirrors drawFromStock / recycleWaste in the session. func stockTap(_ state: GameState, drawCount: Int) -> GameState? { var next = state @@ -252,14 +287,15 @@ enum Outcome { /// Yukon/FreeCell games finish or die well under this; Klondike needs headroom /// for stock cycling, and Spider for grooming 104 cards across five deals. /// (Pyramid is structurally bounded near 100 actions: three 24-card passes, -/// two resets, and at most 26 removal moves.) +/// two resets, and at most 26 removal moves. TriPeaks is bounded at 51: every +/// action consumes a peak card or a stock card.) func actionCap(for variant: GameVariant) -> Int { switch variant { case .klondike: return 1_200 case .spider: return 1_000 - case .freecell, .yukon, .pyramid: + case .freecell, .yukon, .pyramid, .tripeaks: return 600 } } @@ -515,6 +551,56 @@ func playPyramidFollowingHints(seed: UInt64) -> Outcome { return .actionCap(foundation: pyramidCleared(state)) } +func playTriPeaksFollowingHints(seed: UInt64) -> Outcome { + // Replicates HintPlanner's TriPeaks path without its wall-clock deadline: + // follow each planned line — winning or max-clear — to its end, then replan; + // noProgress means not one more peak card is clearable. Every TriPeaks move + // consumes a card, so for this deterministic follower any revisit is a proven + // infinite loop. The loss column records peak cards cleared (TriPeaks banks + // no foundations). + var state = seededDeal(variant: .tripeaks, seed: seed) + var plan: [String: TriPeaksPlanner.Move] = [:] + var seen: Set = [fingerprint(state)] + var actions = 0 + while actions < actionCap(for: .tripeaks) { + if state.isWon { return .win(moves: actions) } + + let key = TriPeaksPlanner.stateKey(for: state) + var hint = plan[key].flatMap { TriPeaksPlanner.materialize($0, in: state) } + if hint == nil { + plan.removeAll() + switch TriPeaksPlanner.bestLine(in: state) { + case .winningLine(let line), .bestEffortLine(let line, _): + plan = TriPeaksPlanner.keyedMoves(along: line, from: state) + case .noProgress: + return .deadlock(foundation: triPeaksCleared(state)) + } + hint = plan[key].flatMap { TriPeaksPlanner.materialize($0, in: state) } + } + guard let hint else { + return .deadlock(foundation: triPeaksCleared(state)) + } + + switch hint { + case .move(let move): + guard let next = apply(move.selection, move.destination, to: state, stockDrawCount: 1) else { + fatalError("Seed \(seed): illegal TriPeaks hint") + } + state = next + case .stockTap: + guard let next = triPeaksStockTap(state) else { + fatalError("Seed \(seed): TriPeaks stock tap with nothing to tap") + } + state = next + } + actions += 1 + if !seen.insert(fingerprint(state)).inserted { + return .stalemateLoop(foundation: triPeaksCleared(state)) + } + } + return .actionCap(foundation: triPeaksCleared(state)) +} + // MARK: - Control player // The random-moves floor calibrates each variant's deal universe. Deliberately @@ -535,9 +621,15 @@ func playRandom( // for them a revisit is a proven infinite loop.) var state = seededDeal(variant: variant, seed: seed, spiderSuitCount: spiderSuitCount) var generator = SeededRandomNumberGenerator(seed: seed ^ 0xDEADBEEF) - let lossProgress: (GameState) -> Int = variant == .pyramid - ? pyramidCleared - : foundationCount + let lossProgress: (GameState) -> Int + switch variant { + case .pyramid: + lossProgress = pyramidCleared + case .tripeaks: + lossProgress = triPeaksCleared + case .klondike, .freecell, .yukon, .spider: + lossProgress = foundationCount + } var actions = 0 while actions < actionCap(for: variant) { if state.isWon { return .win(moves: actions) } @@ -557,6 +649,8 @@ func playRandom( canTapStock = SpiderGameRules.canDealFromStock(state: state) case .pyramid: canTapStock = !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) + case .tripeaks: + canTapStock = !state.stock.isEmpty case .freecell, .yukon: canTapStock = false } @@ -571,6 +665,8 @@ func playRandom( tapped = spiderStockDeal(state) case .pyramid: tapped = pyramidStockTap(state) + case .tripeaks: + tapped = triPeaksStockTap(state) case .klondike, .freecell, .yukon: tapped = stockTap(state, drawCount: drawCount) } @@ -696,10 +792,21 @@ func run( label = "spider \(spiderSuitCount.rawValue)-suit" case .pyramid: label = "pyramid" + case .tripeaks: + label = "tripeaks" + } + // Pyramid and TriPeaks bank no foundations; their loss columns record + // board cards cleared. + let lossProgressLabel: String + switch variant { + case .pyramid: + lossProgressLabel = "pyramid-cleared-at-loss" + case .tripeaks: + lossProgressLabel = "tripeaks-cleared-at-loss" + case .klondike, .freecell, .yukon, .spider: + lossProgressLabel = "foundation-at-loss" } - // Pyramid banks no foundations; its loss column records pyramid cards cleared. - let lossProgressLabel = variant == .pyramid ? "pyramid-cleared-at-loss" : "foundation-at-loss" - let tracksOverBanking = variant != .pyramid + let tracksOverBanking = variant != .pyramid && variant != .tripeaks let start = DispatchTime.now() let followerResults = mapInParallel( @@ -717,6 +824,8 @@ func run( return playSpiderFollowingHints(seed: seed, suitCount: spiderSuitCount) case .pyramid: return (playPyramidFollowingHints(seed: seed), 0) + case .tripeaks: + return (playTriPeaksFollowingHints(seed: seed), 0) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -780,7 +889,7 @@ setvbuf(stdout, nil, _IOLBF, 0) func exitWithUsage() -> Never { print( - "usage: run.sh [deals >= 1] " + "usage: run.sh [deals >= 1] " + "[klondike draw count: 1 or 3 | spider suit count: 1, 2, or 4]" ) exit(1) @@ -816,6 +925,8 @@ case "spider": } case "pyramid": run(variant: .pyramid, seeds: seeds, drawCount: 1) +case "tripeaks": + run(variant: .tripeaks, seeds: seeds, drawCount: 1) case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) @@ -825,6 +936,7 @@ case "all": run(variant: .spider, seeds: seeds, drawCount: 3, spiderSuitCount: suitCount) } run(variant: .pyramid, seeds: seeds, drawCount: 1) + run(variant: .tripeaks, seeds: seeds, drawCount: 1) default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index 13daa3e..decd2db 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")/../.." @@ -36,6 +36,11 @@ SOURCES=( ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift + ComputerSolitaire/Game/TriPeaks/TriPeaksGeometry.swift + ComputerSolitaire/Game/TriPeaks/GameStateTriPeaks.swift + ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift + ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift + ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift ) for source in "${SOURCES[@]}"; do From f28a7702bf1c734c9bab98de8ae08b807fadf17b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 21:06:27 -0700 Subject: [PATCH 2/2] add tripeaks rules doc --- README.md | 3 +- docs/solitaire-rules-tripeaks.md | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 docs/solitaire-rules-tripeaks.md diff --git a/README.md b/README.md index abe032c..4b477cb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS -- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, **Yukon**, **Spider** (1, 2, or 4 suits), and **Pyramid** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, **Yukon**, **Spider** (1, 2, or 4 suits), **Pyramid**, and **TriPeaks** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -27,3 +27,4 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/solitaire-rules-yukon.md) | | **Spider** | Two decks, ten piles — build full suit runs from King to Ace, with 1/2/4-suit difficulty | [Rules](docs/solitaire-rules-spider.md) | | **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/solitaire-rules-pyramid.md) | +| **TriPeaks** | Chain uncovered cards one rank up or down to level three peaks | [Rules](docs/solitaire-rules-tripeaks.md) | diff --git a/docs/solitaire-rules-tripeaks.md b/docs/solitaire-rules-tripeaks.md new file mode 100644 index 0000000..35b6639 --- /dev/null +++ b/docs/solitaire-rules-tripeaks.md @@ -0,0 +1,65 @@ +# TriPeaks Rules + +These rules describe TriPeaks as implemented in the app: uncovered peak cards one rank above or below the waste top are played onto it, with a draw-one stock and a single pass. The published sources disagree on several points; the choices made here (and why) are called out below. + +## Objective +Clear all 28 peak cards by playing them onto the waste, one rank up or down at a time. The stock and waste do **not** need to be emptied. + +## Terminology +- **Peaks:** Twenty-eight cards in three overlapping peaks — face-down rows of three, six, and nine over a face-up base row of ten; each card except the base row is covered by two cards below it. +- **Uncovered:** A card with neither covering card remaining. Only uncovered cards can be played. +- **Stock:** The face-down draw pile (23 cards after the deal). +- **Waste:** The face-up pile every played and drawn card lands on; its top card is the match target. +- **Chain:** Consecutive discards without flipping the stock; each discard in a chain is worth one more point than the last. + +## Card Values +Rank order runs Ace, 2 … 10, Jack, Queen, King, and wraps around: King and Ace are adjacent, as are Ace and 2. Suits never matter. + +## Setup +- Use a standard 52-card deck (no jokers). +- **Peaks:** Deal 28 cards into the three-peak layout — three face-down rows (three peak cards, then six, then nine), topped by the face-up ten-card base row that the peaks share. +- **Waste:** Flip one card face up to start the waste. +- **Stock:** The remaining 23 cards, face down. + +## Play +- Play any **uncovered** card that is one rank above or below the top waste card, regardless of suit. It becomes the new match target. +- Ranks **wrap**: a King plays on an Ace, and an Ace plays on a King or a Two. +- A face-down card flips face up the moment both cards covering it are removed. +- Tap the stock to flip **one** card onto the waste. The stock allows a **single pass** — there are no recycles. +- Cards never leave the waste, and there is no building. + +## Scoring +- The n-th consecutive discard in a chain: +n (1, 2, 3, …). +- Flipping a stock card: −5, and the chain resets. +- Clearing a peak: +15 for each of the first two; the third — which always clears the board — pays +30 instead. +- On a win, a time bonus is added (same basis as the other stockless-choice variants). +- The score never drops below zero. + +## Winning +You win the moment the last peak card is played, regardless of the stock and waste. + +## Rule choices +The linked sources disagree; this implementation uses: +- **Wrapping allowed** (cardgames.io/semicolon.com and most digital implementations, including the original 1989 game), not the no-wrap variant some sites reserve for their "hard" mode. +- **Single pass** through the stock with no recycles (universal across sources — the pass limit is the game). +- **Chain scoring with peak bonuses** (the conventional scheme from the original game: escalating discards, −5 flips, 15/15/30 peak bonuses), not plain per-card scoring. + +## Solver-backed hints +TriPeaks is a perfect-information game once dealt, so `TriPeaksPlanner` searches the exact position graph: a plays-first depth-first pass over a collision-free 37-bit packed position that returns a winning line, or — because nothing is pruned — an exhausted pass that both proves the deal unwinnable and yields the exact max-clear line, which hints then follow. Timing the stock flip is most of the strategy, so "flip the stock" is itself a hint verdict: it appears only when every available play was searched and lost. Hints go silent only when not one more peak card is clearable. + +### Measured baselines +The canonical hint-quality figures live in the hint-probe ledger +(`tools/hint-probe/README.md`): over 500 seeded deals, following every hint +wins **95.4%** against a **0.0%** random-control floor, with zero loops and a +median winning game of 49 moves. The solver's own verdict sweep at its default +budget proves **95.6%** of deals winnable and **0.2%** unwinnable, with 4.1% +undecided at budget (hard deals whose reachable graphs exceed it — they still +get best-effort hints); interactive searches resolve in well under a +millisecond on the median deal. Validate planner changes against the ledger +before shipping. + +## Sources +- https://en.wikipedia.org/wiki/Tri_Peaks_(game) +- https://cardgames.io/tripeakssolitaire/ +- https://solitaired.com/tripeaks +- https://www.semicolon.com/Solitaire/Rules/TriPeaks.html