From 0eaee7bdba6111f01e5a7eca3ab235c65befa49d Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 14:29:43 -0700 Subject: [PATCH 1/3] add pyramid game variant with solver-backed hints --- .../Animation/WinCelebrationController.swift | 94 +-- .../Fixtures/ScreenshotFixtures.swift | 3 +- ComputerSolitaire/Fixtures/pyramid.json | 575 +++++++++++++++ .../FreeCell/GamePersistenceFreeCell.swift | 5 + .../Game/Klondike/AutoFinishPlanner.swift | 6 +- .../Klondike/GamePersistenceKlondike.swift | 5 + .../Game/Klondike/GameSessionKlondike.swift | 60 +- .../Game/Klondike/KlondikePlanner.swift | 6 + .../Game/Pyramid/AutoMoveAdvisorPyramid.swift | 67 ++ .../Game/Pyramid/GamePersistencePyramid.swift | 26 + .../Game/Pyramid/GameRulesPyramid.swift | 136 ++++ .../Game/Pyramid/GameSessionPyramid.swift | 159 +++++ .../Game/Pyramid/GameStatePyramid.swift | 26 + .../Game/Pyramid/PyramidGeometry.swift | 41 ++ .../Game/Pyramid/PyramidPlanner.swift | 652 ++++++++++++++++++ .../Game/Shared/AutoMoveAdvisor.swift | 45 ++ .../Game/Shared/GamePersistence.swift | 59 +- .../Game/Shared/GameRulesShared.swift | 3 + .../Game/Shared/GameSession.swift | 121 +++- .../Game/Shared/GameSessionInteraction.swift | 20 + ComputerSolitaire/Game/Shared/GameState.swift | 34 +- .../Game/Shared/GameVariant.swift | 19 +- .../Game/Shared/HintAdvisor.swift | 47 ++ ComputerSolitaire/Game/Shared/MoveTypes.swift | 8 + ComputerSolitaire/Game/Shared/Scoring.swift | 6 + .../Game/Shared/TapMovePolicy.swift | 15 + .../Game/Yukon/GamePersistenceYukon.swift | 5 + .../Game/Yukon/YukonPlanner.swift | 6 +- .../Interaction/BoardInteractionTypes.swift | 10 + .../Interaction/DragDropCoordinator.swift | 6 + .../Klondike/KlondikeStockWasteViews.swift | 31 +- .../Views/Pyramid/PyramidBoardView.swift | 126 ++++ .../Views/Pyramid/PyramidDiscardView.swift | 76 ++ .../Views/Pyramid/PyramidTopRowView.swift | 91 +++ .../Views/RulesAndScoringView.swift | 34 + .../Views/Shared/BoardViews.swift | 19 + .../Views/Shared/ContentView.swift | 108 ++- ComputerSolitaire/Views/StatisticsView.swift | 17 +- .../Pyramid/PyramidGeometryTests.swift | 58 ++ .../Pyramid/PyramidPersistenceTests.swift | 174 +++++ .../Pyramid/PyramidPlannerTests.swift | 348 ++++++++++ .../Pyramid/PyramidRulesTests.swift | 281 ++++++++ .../Pyramid/PyramidSessionTests.swift | 285 ++++++++ .../Shared/ScreenshotFixtureTests.swift | 73 ++ .../Shared/TapMovePolicyTests.swift | 41 ++ ComputerSolitaireTests/TestSupport.swift | 70 ++ README.md | 3 +- docs/solitaire-rules-pyramid.md | 64 ++ tools/hint-probe/README.md | 10 + tools/hint-probe/main.swift | 149 +++- tools/hint-probe/run.sh | 7 +- 51 files changed, 4151 insertions(+), 179 deletions(-) create mode 100644 ComputerSolitaire/Fixtures/pyramid.json create mode 100644 ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift create mode 100644 ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift create mode 100644 ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift create mode 100644 ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift create mode 100644 ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift create mode 100644 ComputerSolitaire/Game/Pyramid/PyramidGeometry.swift create mode 100644 ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift create mode 100644 ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift create mode 100644 ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift create mode 100644 ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift create mode 100644 ComputerSolitaireTests/Pyramid/PyramidGeometryTests.swift create mode 100644 ComputerSolitaireTests/Pyramid/PyramidPersistenceTests.swift create mode 100644 ComputerSolitaireTests/Pyramid/PyramidPlannerTests.swift create mode 100644 ComputerSolitaireTests/Pyramid/PyramidRulesTests.swift create mode 100644 ComputerSolitaireTests/Pyramid/PyramidSessionTests.swift create mode 100644 docs/solitaire-rules-pyramid.md diff --git a/ComputerSolitaire/Animation/WinCelebrationController.swift b/ComputerSolitaire/Animation/WinCelebrationController.swift index 742893d..db49a0e 100644 --- a/ComputerSolitaire/Animation/WinCelebrationController.swift +++ b/ComputerSolitaire/Animation/WinCelebrationController.swift @@ -21,15 +21,20 @@ final class WinCelebrationController { phase == .animating } + /// `launchPiles` are the piles the cascade erupts from, with `launchTargets` + /// naming each pile's on-board drop target (aligned by index): the four + /// foundations for the build-up variants, the discard for Pyramid. func beginIfNeededForWin( - foundations: [[Card]], + launchPiles: [[Card]], + launchTargets: [DropTarget], dropFrames: [DropTarget: DropTargetGeometry], boardViewportSize: CGSize ) { guard phase == .idle else { return } begin( - foundations: foundations, - hiddenFoundationCardIDs: Self.foundationCardIDs(from: foundations), + launchPiles: launchPiles, + launchTargets: launchTargets, + hiddenLaunchCardIDs: Self.launchCardIDs(from: launchPiles), dropFrames: dropFrames, boardViewportSize: boardViewportSize ) @@ -44,7 +49,8 @@ final class WinCelebrationController { } func syncForLoadedGame( - foundations: [[Card]], + launchPiles: [[Card]], + launchTargets: [DropTarget], isWin: Bool, dropFrames: [DropTarget: DropTargetGeometry], boardViewportSize: CGSize @@ -53,14 +59,15 @@ final class WinCelebrationController { cascadeTask = nil if isWin { let completedCards = completedStatesForLoadedWin( - foundations: foundations, + launchPiles: launchPiles, + launchTargets: launchTargets, dropFrames: dropFrames, boardViewportSize: boardViewportSize ) cards = completedCards hiddenFoundationCardIDs = completedCards.isEmpty ? [] - : Self.foundationCardIDs(from: foundations) + : Self.launchCardIDs(from: launchPiles) phase = .completed } else { cards = [] @@ -75,36 +82,27 @@ final class WinCelebrationController { } private func begin( - foundations: [[Card]], - hiddenFoundationCardIDs: Set, + launchPiles: [[Card]], + launchTargets: [DropTarget], + hiddenLaunchCardIDs: Set, dropFrames: [DropTarget: DropTargetGeometry], boardViewportSize: CGSize ) { - self.hiddenFoundationCardIDs = hiddenFoundationCardIDs + self.hiddenFoundationCardIDs = hiddenLaunchCardIDs let boardBounds = CGRect(origin: .zero, size: boardViewportSize) guard boardBounds.width > 0, boardBounds.height > 0 else { phase = .completed return } - var launchFrames: [Int: CGRect] = [:] - for index in 0..<4 { - if let frame = dropFrames[.foundation(index)]?.snapFrame, frame != .zero { - launchFrames[index] = frame - } - } - - let fallbackLaunchFrame = launchFrames[0] - ?? launchFrames.values.first - ?? CGRect( - x: boardBounds.midX - 50, - y: max(0, boardBounds.height * 0.22 - 72), - width: 100, - height: 145 - ) + let launchFrames = Self.launchFrames(for: launchTargets, dropFrames: dropFrames) + let fallbackLaunchFrame = Self.fallbackLaunchFrame( + launchFrames: launchFrames, + boardBounds: boardBounds + ) let initialStates = WinCascadeCoordinator.makeInitialStates( - foundations: foundations, + foundations: launchPiles, foundationFrames: launchFrames, fallbackLaunchFrame: fallbackLaunchFrame ) @@ -146,26 +144,28 @@ final class WinCelebrationController { phase = .completed } - private static func foundationCardIDs(from foundations: [[Card]]) -> Set { - Set(foundations.flatMap { pile in pile.map(\.id) }) + private static func launchCardIDs(from launchPiles: [[Card]]) -> Set { + Set(launchPiles.flatMap { pile in pile.map(\.id) }) } - private func completedStatesForLoadedWin( - foundations: [[Card]], - dropFrames: [DropTarget: DropTargetGeometry], - boardViewportSize: CGSize - ) -> [WinCascadeCardState] { - let boardBounds = CGRect(origin: .zero, size: boardViewportSize) - guard boardBounds.width > 0, boardBounds.height > 0 else { return [] } - + private static func launchFrames( + for launchTargets: [DropTarget], + dropFrames: [DropTarget: DropTargetGeometry] + ) -> [Int: CGRect] { var launchFrames: [Int: CGRect] = [:] - for index in 0..<4 { - if let frame = dropFrames[.foundation(index)]?.snapFrame, frame != .zero { + for (index, target) in launchTargets.enumerated() { + if let frame = dropFrames[target]?.snapFrame, frame != .zero { launchFrames[index] = frame } } + return launchFrames + } - let fallbackLaunchFrame = launchFrames[0] + private static func fallbackLaunchFrame( + launchFrames: [Int: CGRect], + boardBounds: CGRect + ) -> CGRect { + launchFrames[0] ?? launchFrames.values.first ?? CGRect( x: boardBounds.midX - 50, @@ -173,9 +173,25 @@ final class WinCelebrationController { width: 100, height: 145 ) + } + + private func completedStatesForLoadedWin( + launchPiles: [[Card]], + launchTargets: [DropTarget], + dropFrames: [DropTarget: DropTargetGeometry], + boardViewportSize: CGSize + ) -> [WinCascadeCardState] { + let boardBounds = CGRect(origin: .zero, size: boardViewportSize) + guard boardBounds.width > 0, boardBounds.height > 0 else { return [] } + + let launchFrames = Self.launchFrames(for: launchTargets, dropFrames: dropFrames) + let fallbackLaunchFrame = Self.fallbackLaunchFrame( + launchFrames: launchFrames, + boardBounds: boardBounds + ) return WinCascadeCoordinator.makeCompletedStates( - foundations: foundations, + foundations: launchPiles, foundationFrames: launchFrames, fallbackLaunchFrame: fallbackLaunchFrame, boardBounds: boardBounds diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index e342fd9..f911ee8 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -31,7 +31,8 @@ enum ScreenshotFixtures { static let bundled: [ScreenshotFixture] = [ ScreenshotFixture(name: "klondike-draw3", title: "Klondike – Draw 3"), ScreenshotFixture(name: "freecell", title: "FreeCell – fresh deal"), - ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal") + ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal"), + ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/pyramid.json b/ComputerSolitaire/Fixtures/pyramid.json new file mode 100644 index 0000000..50b1859 --- /dev/null +++ b/ComputerSolitaire/Fixtures/pyramid.json @@ -0,0 +1,575 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 1, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 1, + "state" : { + "discard" : [ + + ], + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "pyramid" : [ + { + "id" : "FF4EEE6F-312B-451F-B2BF-A9D2B66B40AA", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E1AECB77-504A-48C5-9EA1-036C8DD0BCFD", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "FA40BC1B-608D-470E-A42C-9984968D5CB3", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "F185A46E-8402-4F45-8C7B-AA7C16025E95", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "EF8972F3-27A8-403B-81AC-1BC4D1531E69", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "50A62583-3792-41A7-BA13-92409E21F0A7", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9B929200-A8EB-4C72-8283-815B0D19C715", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "19A1010F-0A72-400E-9ECE-F1F7F25098F6", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "3E645A23-8871-41A5-8509-FB54636B3CCE", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "ED1FE163-23D5-4409-8401-B5F545521672", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C3EB87A4-4E8A-46B6-891B-95AC67D797C6", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "6E0EFFA4-1446-4E53-84DD-C7486123AF5B", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BDB3DB50-142B-4A7E-9000-C212003974D1", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "41F2B6A7-8B25-49C0-94EF-2F68EC4D2D35", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "DFF459D2-3E03-4002-B151-E1531B304C80", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "596EFD35-4DBE-4CCB-97DA-735333AB5B2A", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "FDAFE9C3-3E69-4F49-AEFA-5CCE92342515", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "10041797-43C7-4711-9047-EA547C64AE42", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "C27C9187-6EAC-4D9E-9C17-6D4ADE68495E", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "69A2E984-8AEE-4730-908C-BA11DE9315B0", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "2A40880E-B4F7-4ECF-9F29-AF6D1BE9BCDB", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "9EA7D464-93AD-4EB9-B331-865BF9A1F6A4", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "88AEFCBA-B9D1-4AB7-B9F8-585FFC73A7C3", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C6181776-6333-4FFB-895B-4F4FE3F85C2B", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "7BD07E25-B06A-461E-A485-0D43FD61FC43", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "9C2758B5-67F6-4480-9133-E2C880E2AFCC", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "D3BC250F-EF44-49C1-83B7-953B71019938", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "20F2F68D-9146-401F-A03B-4990D56ADDF3", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + } + ], + "stock" : [ + { + "id" : "ACC850E9-19DE-4704-A64B-A5184D5133A9", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "CF4A9750-8C87-4A18-AC0F-70BCA329732D", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "46B333EC-D5A9-4627-BF66-5B7C1C842D1D", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "091814DB-5B51-4E4B-B78B-1F1434484470", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "76FD40B0-1BCE-4BF4-9609-777058E8230B", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "9910C49B-FF07-4FAF-A6FC-AC957038E88A", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "E7AD9F94-3AAB-47CB-A80C-DD754E976194", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "AD525692-CB79-4332-8FA2-E32FC9C3175B", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "10848E1E-B92E-4FF3-B255-3253BFC16144", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "4A839196-C657-425F-BC63-594C4FE70514", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E6BA12EF-EEBB-4EAB-99AD-03AB6BA7BC98", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "72BB2353-5F30-4413-B998-B475A801516C", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "DEC4CF99-3F40-4841-BCD2-81623A9FFC43", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "9E8BFE3C-3710-4A40-A42E-3ECFCD885024", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "B8995EE6-AE04-4362-956F-A1A5F33A7789", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "17EB171D-5644-470B-9E0E-ACDA2DBEBCB7", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "554227F8-6C19-4B54-9101-4A7D5C84C5AC", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "436BF7BA-931C-446D-BE00-7F47AA6AF96F", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D982FBDA-1DEF-48B9-856F-0159A8E67EA3", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "639AC4F0-7F6A-4058-9706-05BF20CE3B66", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "40442F50-B940-4A1A-990C-08092C9B703B", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "B3F6AA9E-B887-453B-9426-E4ED5C7BE00A", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "C13F3CAB-40A6-411B-AC28-B7868D2355E2", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + } + ], + "tableau" : [ + + ], + "variant" : "pyramid", + "waste" : [ + { + "id" : "A6D9BD2E-D7E3-448F-89CF-C58FE0A54332", + "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 e2b3f99..b836a4c 100644 --- a/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift +++ b/ComputerSolitaire/Game/FreeCell/GamePersistenceFreeCell.swift @@ -4,6 +4,11 @@ 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. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { + return false + } return state.wasteDrawCount == 0 } } diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 28b52f4..80d154d 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -48,6 +48,10 @@ private extension AutoFinishPlanner { return true case .yukon: return !state.tableau.joined().contains(where: { !$0.isFaceUp }) + case .pyramid: + // Pyramid has no deterministic mop-up phase: which pair to remove + // matters to the last move, so the game never auto-finishes. + return false } } @@ -144,7 +148,7 @@ private extension AutoFinishPlanner { } state.freeCells[slot] = nil - case .waste, .foundation: + case .waste, .foundation, .pyramid: return false } diff --git a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift index bd457a5..d67153b 100644 --- a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift @@ -6,6 +6,11 @@ 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. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { + return false + } return state.wasteDrawCount >= 0 && state.wasteDrawCount <= state.waste.count } } diff --git a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift index 9b9ebd7..171e35a 100644 --- a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift @@ -41,14 +41,7 @@ extension SolitaireViewModel { refreshAutoFinishAvailability() } - func visibleWasteCards() -> [Card] { - guard state.variant == .klondike else { return [] } - let count = min(state.wasteDrawCount, stockDrawCount) - return Array(state.waste.suffix(count)) - } - - func handleStockTap() { - guard state.variant == .klondike else { return } + func handleKlondikeStockTap() { clearHint() selection = nil isDragging = false @@ -60,57 +53,6 @@ extension SolitaireViewModel { } } - func handleWasteTap() { - guard state.variant == .klondike else { return } - guard let top = state.waste.last, state.wasteDrawCount > 0 else { return } - HapticManager.shared.play(.cardPickUp) - let wasteSelection = Selection(source: .waste, cards: [top]) - if queueBestAutoMove(for: wasteSelection) { - return - } - if selection?.source == .waste { - selection = nil - return - } - isDragging = false - selection = wasteSelection - } - - @discardableResult - func startDragFromWaste() -> Bool { - guard state.variant == .klondike else { return false } - guard let top = state.waste.last, state.wasteDrawCount > 0 else { return false } - clearHint() - selection = Selection(source: .waste, cards: [top]) - isDragging = true - return true - } - - func drawFromStock() { - guard !state.stock.isEmpty else { return } - clearHint() - let drawCount = min(stockDrawCount, state.stock.count) - let drawnCardIDs = (0.. [Selection] { + var selections: [Selection] = [] + + if let wasteTop = state.waste.last { + selections.append(Selection(source: .waste, cards: [wasteTop])) + } + + for index in state.pyramid.indices { + guard let card = state.pyramid[index], + PyramidGameRules.isSelectable(index: index, in: state.pyramid) else { continue } + selections.append(Selection(source: .pyramid(index: index), cards: [card])) + } + + return selections + } + + static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state) else { return [] } + + var destinations: [Destination] = [] + + switch selection.source { + case .pyramid(let sourceIndex): + for partnerIndex in state.pyramid.indices + where PyramidGameRules.canRemovePair(sourceIndex, partnerIndex, in: state.pyramid) { + destinations.append(.pyramid(partnerIndex)) + } + if PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: sourceIndex, in: state) { + destinations.append(.waste) + } + case .waste: + for partnerIndex in state.pyramid.indices + where PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: partnerIndex, in: state) { + destinations.append(.pyramid(partnerIndex)) + } + case .foundation, .freeCell, .tableau: + return [] + } + + if PyramidGameRules.canRemoveKing(selection: selection, in: state) { + destinations.append(.discard) + } + + return destinations + } + + static func simulatedState( + afterMoving selection: Selection, + to destination: Destination, + in state: GameState + ) -> GameState? { + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state) else { return nil } + return PyramidGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + } +} diff --git a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift new file mode 100644 index 0000000..21b6701 --- /dev/null +++ b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift @@ -0,0 +1,26 @@ +import Foundation + +enum PyramidPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.pyramid.count == PyramidGeometry.cardCount else { return false } + guard state.tableau.isEmpty else { return false } + // Pyramid 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 } + guard (0...PyramidGameRules.maxWasteRecycles).contains(state.wasteRecyclesUsed) 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 + // empty slot must have empty covering slots. + for index in state.pyramid.indices where state.pyramid[index] == nil { + if let covering = PyramidGeometry.coveringIndices(of: index), + state.pyramid[covering.left] != nil || state.pyramid[covering.right] != nil { + return false + } + } + return true + } +} diff --git a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift new file mode 100644 index 0000000..77d05a2 --- /dev/null +++ b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift @@ -0,0 +1,136 @@ +import Foundation + +enum PyramidGameRules { + /// Two cards pair when their ranks sum to this; a King reaches it alone. + static let pairSum = 13 + /// The waste may be recycled into the stock this many times (three total passes). + static let maxWasteRecycles = 2 + + static func isPair(_ first: Card, _ second: Card) -> Bool { + first.rank.rawValue + second.rank.rawValue == pairSum + } + + static func isKing(_ card: Card) -> Bool { + card.rank == .king + } + + /// The cover-pair rule: `child` directly covers `parent`, is `parent`'s only + /// remaining cover, is itself exposed, and the two ranks sum to 13 — so both + /// may be removed together in one move. + static func isCoverPair(parent: Int, child: Int, in pyramid: [Card?]) -> Bool { + guard let parentCard = pyramid[parent], let childCard = pyramid[child] else { return false } + guard isPair(parentCard, childCard) else { return false } + guard let covering = PyramidGeometry.coveringIndices(of: parent) else { return false } + guard child == covering.left || child == covering.right else { return false } + let otherCover = child == covering.left ? covering.right : covering.left + guard pyramid[otherCover] == nil else { return false } + return PyramidGeometry.isExposed(child, in: pyramid) + } + + /// A slot the player may pick up: exposed, or a cover-pair parent (its sole + /// remaining cover is its exposed rank-13 partner). + static func isSelectable(index: Int, in pyramid: [Card?]) -> Bool { + guard pyramid.indices.contains(index), pyramid[index] != nil else { return false } + if PyramidGeometry.isExposed(index, in: pyramid) { return true } + guard let covering = PyramidGeometry.coveringIndices(of: index) else { return false } + return isCoverPair(parent: index, child: covering.left, in: pyramid) + || isCoverPair(parent: index, child: covering.right, in: pyramid) + } + + static func canRemovePair(_ first: Int, _ second: Int, in pyramid: [Card?]) -> Bool { + guard first != second else { return false } + guard pyramid.indices.contains(first), pyramid.indices.contains(second) else { return false } + guard let firstCard = pyramid[first], let secondCard = pyramid[second] else { return false } + guard isPair(firstCard, secondCard) else { return false } + if PyramidGeometry.isExposed(first, in: pyramid), + PyramidGeometry.isExposed(second, in: pyramid) { + return true + } + return isCoverPair(parent: first, child: second, in: pyramid) + || isCoverPair(parent: second, child: first, in: pyramid) + } + + static func canRemovePairWithWasteTop(pyramidIndex: Int, in state: GameState) -> Bool { + guard state.pyramid.indices.contains(pyramidIndex), + let pyramidCard = state.pyramid[pyramidIndex], + let wasteTop = state.waste.last else { return false } + guard PyramidGeometry.isExposed(pyramidIndex, in: state.pyramid) else { return false } + return isPair(pyramidCard, wasteTop) + } + + static func canRemoveKing(selection: Selection, in state: GameState) -> Bool { + guard selection.cards.count == 1, let card = selection.cards.first else { return false } + guard isKing(card) else { return false } + switch selection.source { + case .pyramid(let index): + return PyramidGeometry.isExposed(index, in: state.pyramid) + case .waste: + return state.waste.last?.id == card.id + case .foundation, .freeCell, .tableau: + return false + } + } + + static func canRecycleWaste(in state: GameState) -> Bool { + state.stock.isEmpty + && !state.waste.isEmpty + && state.wasteRecyclesUsed < maxWasteRecycles + } + + /// Single source of truth for applying a Pyramid move; used by the session and + /// the advisor so their outcomes can never drift. Removed cards land on the + /// discard in selection-first order. Returns nil for illegal moves. + static func stateByApplying( + selection: Selection, + destination: Destination, + to state: GameState + ) -> GameState? { + guard state.variant == .pyramid else { return nil } + guard selection.cards.count == 1, let selectedCard = selection.cards.first else { return nil } + + var nextState = state + + switch (selection.source, destination) { + case (.pyramid(let sourceIndex), .pyramid(let partnerIndex)): + guard canRemovePair(sourceIndex, partnerIndex, in: state.pyramid) else { return nil } + guard state.pyramid[sourceIndex]?.id == selectedCard.id else { return nil } + guard let partnerCard = state.pyramid[partnerIndex] else { return nil } + nextState.pyramid[sourceIndex] = nil + nextState.pyramid[partnerIndex] = nil + nextState.discard.append(contentsOf: [selectedCard, partnerCard]) + + case (.pyramid(let sourceIndex), .waste): + guard canRemovePairWithWasteTop(pyramidIndex: sourceIndex, in: state) else { return nil } + guard state.pyramid[sourceIndex]?.id == selectedCard.id else { return nil } + guard let wasteTop = nextState.waste.popLast() else { return nil } + nextState.pyramid[sourceIndex] = nil + nextState.discard.append(contentsOf: [selectedCard, wasteTop]) + + case (.waste, .pyramid(let partnerIndex)): + guard canRemovePairWithWasteTop(pyramidIndex: partnerIndex, in: state) else { return nil } + guard state.waste.last?.id == selectedCard.id else { return nil } + guard let partnerCard = state.pyramid[partnerIndex] else { return nil } + _ = nextState.waste.popLast() + nextState.pyramid[partnerIndex] = nil + nextState.discard.append(contentsOf: [selectedCard, partnerCard]) + + case (.pyramid(let sourceIndex), .discard): + guard canRemoveKing(selection: selection, in: state) else { return nil } + guard state.pyramid[sourceIndex]?.id == selectedCard.id else { return nil } + nextState.pyramid[sourceIndex] = nil + nextState.discard.append(selectedCard) + + case (.waste, .discard): + guard canRemoveKing(selection: selection, in: state) else { return nil } + _ = nextState.waste.popLast() + nextState.discard.append(selectedCard) + + default: + return nil + } + + // The single visible waste card follows the new top. + nextState.wasteDrawCount = min(1, nextState.waste.count) + return nextState + } +} diff --git a/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift b/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift new file mode 100644 index 0000000..717fd05 --- /dev/null +++ b/ComputerSolitaire/Game/Pyramid/GameSessionPyramid.swift @@ -0,0 +1,159 @@ +import Foundation + +extension SolitaireViewModel { + // MARK: Configuration + + /// Pyramid draws a single card to the waste; time-bonus scoring keeps the + /// draw-three basis the other stockless-choice variants use. + func configurePyramidNewGame() { + setStockDrawCount(DrawMode.one.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(0) + } + + func configurePyramidRedeal() { + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(min(1, state.waste.count)) + } + + func sanitizePyramidRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = min(1, sanitizedState.waste.count) + sanitizedState.wasteRecyclesUsed = min( + max(0, sanitizedState.wasteRecyclesUsed), + PyramidGameRules.maxWasteRecycles + ) + return sanitizedState + } + + // MARK: Moves + + /// Executes the Pyramid destinations (`.pyramid`, `.waste`, `.discard`): removes + /// a rank-13 pair or a lone King to the discard as one scored, undoable move. + @discardableResult + func performPyramidMove(selection: Selection, to destination: Destination) -> Bool { + guard let nextState = PyramidGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) else { return false } + + clearHint() + let removedCardIDs = nextState.discard.suffix( + nextState.discard.count - state.discard.count + ).map(\.id) + pushHistory( + undoContext: UndoAnimationContext( + action: .moveSelection, + cardIDs: removedCardIDs + ) + ) + state = nextState + incrementMovesCount() + applyPyramidMoveScore(for: destination) + applyTimeBonusIfWon() + self.selection = nil + SoundManager.shared.play(.cardPlaced) + refreshAutoFinishAvailability() + return true + } + + func applyPyramidMoveScore(for destination: Destination) { + switch destination { + case .pyramid, .waste: + applyScore(.removePyramidPair) + case .discard: + applyScore(.removePyramidKing) + case .foundation, .tableau, .freeCell: + break + } + } + + // MARK: Interaction + + func handlePyramidTap(index: Int) { + guard state.pyramid.indices.contains(index), let card = state.pyramid[index] else { return } + HapticManager.shared.play(.cardPickUp) + + // An active selection pairing with the tapped card wins over auto-moving + // it, so tap-select-then-tap-partner behaves as expected. + if selection != nil, tryMoveSelection(to: .pyramid(index)) { + return + } + + let tappedSelection = Selection(source: .pyramid(index: index), cards: [card]) + if selection?.source == tappedSelection.source { + selection = nil + return + } + + guard PyramidGameRules.isSelectable(index: index, in: state.pyramid) else { + selection = nil + HapticManager.shared.play(.invalidDrop) + return + } + + if queueBestAutoMove(for: tappedSelection) { + return + } + + isDragging = false + selection = tappedSelection + } + + @discardableResult + func startDragFromPyramid(index: Int) -> Bool { + guard state.pyramid.indices.contains(index), + let card = state.pyramid[index], + PyramidGameRules.isSelectable(index: index, in: state.pyramid) else { return false } + clearHint() + selection = Selection(source: .pyramid(index: index), cards: [card]) + isDragging = true + return true + } + + // MARK: Stock + + func handlePyramidStockTap() { + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + if state.stock.isEmpty { + recyclePyramidWaste() + } else { + drawFromStock() + } + } + + /// Turns the waste back into the stock, consuming one of the limited recycles. + /// The pass limit is the cost, so no score penalty applies. + func recyclePyramidWaste() { + guard PyramidGameRules.canRecycleWaste(in: state) else { return } + clearHint() + let animatedWasteIDs = [state.waste.last?.id].compactMap { $0 } + pushHistory( + undoContext: UndoAnimationContext( + action: .recycleWaste, + cardIDs: animatedWasteIDs + ) + ) + let recycledStock = state.waste.reversed().map { card in + var newCard = card + newCard.isFaceUp = false + return newCard + } + state.stock = recycledStock + state.waste.removeAll() + state.wasteRecyclesUsed += 1 + setWasteDrawCount(0) + incrementMovesCount() + SoundManager.shared.play(.wasteRecycleToStock) + HapticManager.shared.play(.wasteRecycle) + refreshAutoFinishAvailability() + } + + var pyramidWasteRecyclesRemaining: Int { + max(0, PyramidGameRules.maxWasteRecycles - state.wasteRecyclesUsed) + } +} diff --git a/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift b/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift new file mode 100644 index 0000000..fc6a403 --- /dev/null +++ b/ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift @@ -0,0 +1,26 @@ +import Foundation + +extension GameState { + static func newPyramidGame() -> GameState { + var deck = Card.fullDeck().shuffled() + var pyramid: [Card?] = [] + + for _ in 0..] = (0.. 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 bottom row. + static func coveringIndices(of index: Int) -> (left: Int, right: Int)? { + let row = Self.row(of: index) + guard row < rowCount - 1 else { return nil } + let left = Self.index(row: row + 1, column: Self.column(of: index)) + return (left: left, right: left + 1) + } + + /// A slot is exposed when neither covering slot holds a card. + static func isExposed(_ index: Int, in pyramid: [Card?]) -> Bool { + guard let covering = coveringIndices(of: index) else { return true } + return pyramid[covering.left] == nil && pyramid[covering.right] == nil + } +} diff --git a/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift b/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift new file mode 100644 index 0000000..621f46e --- /dev/null +++ b/ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift @@ -0,0 +1,652 @@ +import Foundation + +/// Exact two-stage solver behind Pyramid hints. +/// +/// Pyramid is a perfect-information game with a tiny exact state: which pyramid +/// slots remain, which stock cards were consumed by pairing, how far the current +/// pass has drawn, and how many recycles are spent. That position packs into one +/// collision-free 59-bit code (see `Board`), so the transposition table stores +/// exact keys — no hashing judgment calls — and the game graph is a DAG (removals +/// shrink masks, draws advance the cut, resets spend a bounded counter), so depth +/// is structurally bounded and followed lines can never revisit a position. +/// +/// Stage one runs weighted A* for a full winning line (`f = g + 10·h` with an +/// admissible `h`, so misses are budget misses, not blind spots), pruning +/// positions the partner-count check proves unwinnable; emptying that pruned +/// graph is a proof the deal cannot be won. Unlike the other variants, lost deals +/// are common under the three-pass rule, so stage two then finds the line +/// clearing the most pyramid cards and hints follow it — silence is reserved for +/// positions where not one more pyramid card is clearable, where any nudge would +/// be provably futile stock-churning. +/// +/// Measured at the default budget over 10,000 seeded release-build deals: +/// 79.5% proved winnable, 0.8% proved unwinnable, 19.8% undecided at budget +/// (hard deals whose reachable graphs exceed 150k nodes; they still get +/// best-effort lines); `bestLine` median 0.5ms. Hint-quality baselines live in +/// the `tools/hint-probe` ledger: 80.2% of 500 deals won by following every +/// hint against a 15.2% random-control floor, zero loops. +enum PyramidPlanner { + 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 (≤ 16 removals + 72 draws + 2 resets). + init(maxNodes: Int = 150_000, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.deadline = deadline + } + } + + enum PairTarget: Equatable { + case pyramid(slot: Int) + case wasteTop + } + + enum Move: Equatable { + /// Canonical order: pyramid slots ascending; `.wasteTop` second. + case removePair(PairTarget, PairTarget) + case removeKing(PairTarget) + case draw + case resetStock + } + + enum SearchOutcome { + /// Replaying this line clears the pyramid; the deal is won. + case winningLine([Move]) + /// No winning line exists (or fit the budget); this line clears the most + /// pyramid cards found. The flag is a proof when stage one exhausted its + /// pruned graph rather than running out of budget. + case bestEffortLine([Move], dealIsProvedUnwinnable: Bool) + /// Not even one more pyramid 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 == .pyramid, let position = Position(state: state) else { + return .noProgress(searchWasExhaustive: false) + } + + let win = winSearch(from: position, limits: limits) + if let line = win.line { + return .winningLine(line) + } + + let clear = maxClearSearch(from: position, limits: limits) + guard let line = clear.line else { + return .noProgress(searchWasExhaustive: clear.exhaustive) + } + if clear.bestRemaining == 0 { + // Stage one ran out of budget before reaching this win. + return .winningLine(line) + } + return .bestEffortLine(line, dealIsProvedUnwinnable: win.exhaustive) + } + + /// Exact position key, stable across `Card` identities; used to look up the + /// cached line as the player follows it. Ranks only: suits never matter in + /// Pyramid, so suit-equivalent positions 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.pyramid { + if let card = slot { + append(card: card) + } else { + key.append("-") + } + } + key.append("|") + for card in state.stock { append(card: card) } + key.append("|") + for card in state.waste { append(card: card) } + key.append("|") + key.append(String(state.wasteRecyclesUsed)) + 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 .removePair, .removeKing: + 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 + case .resetStock: + guard PyramidGameRules.canRecycleWaste(in: state) 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 .removePair, .removeKing: + guard let (selection, destination) = sessionMove(for: move, in: state) else { return nil } + return PyramidGameRules.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 + return nextState + case .resetStock: + guard PyramidGameRules.canRecycleWaste(in: state) else { return nil } + var nextState = state + nextState.stock = nextState.waste.reversed().map { card in + var recycledCard = card + recycledCard.isFaceUp = false + return recycledCard + } + nextState.waste.removeAll() + nextState.wasteDrawCount = 0 + nextState.wasteRecyclesUsed += 1 + return nextState + } + } +} + +// MARK: - Session move mapping + +private extension PyramidPlanner { + static func sessionMove( + for move: Move, + in state: GameState + ) -> (selection: Selection, destination: Destination)? { + switch move { + case .removePair(.pyramid(let first), .pyramid(let second)): + guard let card = pyramidCard(at: first, in: state) else { return nil } + return (Selection(source: .pyramid(index: first), cards: [card]), .pyramid(second)) + case .removePair(.pyramid(let slot), .wasteTop), + .removePair(.wasteTop, .pyramid(let slot)): + guard let card = pyramidCard(at: slot, in: state) else { return nil } + return (Selection(source: .pyramid(index: slot), cards: [card]), .waste) + case .removeKing(.pyramid(let slot)): + guard let card = pyramidCard(at: slot, in: state) else { return nil } + return (Selection(source: .pyramid(index: slot), cards: [card]), .discard) + case .removeKing(.wasteTop): + guard let card = state.waste.last else { return nil } + return (Selection(source: .waste, cards: [card]), .discard) + case .removePair(.wasteTop, .wasteTop), .draw, .resetStock: + return nil + } + } + + static func pyramidCard(at slot: Int, in state: GameState) -> Card? { + guard state.pyramid.indices.contains(slot) else { return nil } + return state.pyramid[slot] + } +} + +// MARK: - Compact position + +private extension PyramidPlanner { + /// The deal's immutable rank tables plus the packed dynamic board. Stock cards + /// are indexed in draw order: the root's waste bottom-to-top (already drawn), + /// then the remaining stock in draw order. Cards already discarded at the root + /// are simply absent — the search never needs them. + struct Position { + /// Rank per pyramid slot; 0 for slots already empty at the root. + let slotRanks: [Int] + /// Rank per stock index, in draw order. + let stockRanks: [Int] + let root: Board + + init?(state: GameState) { + guard state.pyramid.count == PyramidGeometry.cardCount else { return nil } + guard state.stock.count + state.waste.count <= Board.maxStockCount else { return nil } + guard (0...PyramidGameRules.maxWasteRecycles).contains(state.wasteRecyclesUsed) else { + return nil + } + + slotRanks = state.pyramid.map { $0?.rank.rawValue ?? 0 } + // Draw order: waste bottom→top was drawn first; the next draw is the + // stock's last element. + stockRanks = state.waste.map(\.rank.rawValue) + + state.stock.reversed().map(\.rank.rawValue) + + var pyramidMask: UInt32 = 0 + for index in state.pyramid.indices where state.pyramid[index] != nil { + pyramidMask |= 1 << UInt32(index) + } + root = Board( + pyramidMask: pyramidMask, + stockRemovedMask: 0, + cut: state.waste.count, + passes: state.wasteRecyclesUsed + ) + } + } + + /// One Pyramid position in 59 bits: which pyramid slots hold cards, which + /// stock cards were consumed by pairing, the draw cut (stock indices below it + /// have been drawn this pass), and recycles spent. The waste needs no storage: + /// it is a stack with top-only pops, so its contents are exactly the + /// non-removed indices below the cut, in index order — making `code` an exact, + /// collision-free transposition key. + struct Board: Equatable { + static let maxStockCount = 24 + + var pyramidMask: UInt32 + var stockRemovedMask: UInt32 + var cut: Int + var passes: Int + + var code: UInt64 { + UInt64(pyramidMask) + | (UInt64(stockRemovedMask) << 28) + | (UInt64(cut) << 52) + | (UInt64(passes) << 57) + } + + var remainingCount: Int { + pyramidMask.nonzeroBitCount + } + + /// The top of the waste; cut normalization keeps this exactly `cut - 1`. + var wasteTopIndex: Int? { + cut > 0 ? cut - 1 : nil + } + + func isRemoved(_ stockIndex: Int) -> Bool { + stockRemovedMask & (1 << UInt32(stockIndex)) != 0 + } + + func nextDrawIndex(stockCount: Int) -> Int? { + var index = cut + while index < stockCount { + if !isRemoved(index) { return index } + index += 1 + } + return nil + } + + func canResetStock(stockCount: Int) -> Bool { + passes < PyramidGameRules.maxWasteRecycles + && cut > 0 + && nextDrawIndex(stockCount: stockCount) == nil + } + + func isExposed(_ slot: Int) -> Bool { + PyramidPlanner.childrenMasks[slot] & pyramidMask == 0 + } + + func holdsCard(at slot: Int) -> Bool { + pyramidMask & (1 << UInt32(slot)) != 0 + } + + /// Removed indices at the cut boundary belong to neither pile, so cuts + /// differing only across them are the same position; normalizing keeps the + /// transposition key canonical and the waste top at `cut - 1`. + mutating func normalizeCut() { + while cut > 0, isRemoved(cut - 1) { + cut -= 1 + } + } + } + + /// Bits of the two slots covering each slot; 0 for the bottom row. + static let childrenMasks: [UInt32] = (0.. [Move] { + var moves: [Move] = [] + + for first in 0.. Bool { + guard let covering = PyramidGeometry.coveringIndices(of: parent) else { return false } + guard child == covering.left || child == covering.right else { return false } + let otherCover = child == covering.left ? covering.right : covering.left + guard !board.holdsCard(at: otherCover) else { return false } + return board.isExposed(child) + } + + /// Applies a generated move without re-validating legality (the search only + /// feeds in moves it just generated). + static func apply(_ move: Move, to board: Board, position: Position) -> Board { + var next = board + switch move { + case .removePair(let first, let second): + removeTarget(first, from: &next) + removeTarget(second, from: &next) + case .removeKing(let target): + removeTarget(target, from: &next) + case .draw: + if let drawIndex = next.nextDrawIndex(stockCount: position.stockRanks.count) { + next.cut = drawIndex + 1 + } + case .resetStock: + next.cut = 0 + next.passes += 1 + } + return next + } + + static func removeTarget(_ target: PairTarget, from board: inout Board) { + switch target { + case .pyramid(let slot): + board.pyramidMask &= ~(1 << UInt32(slot)) + case .wasteTop: + if let wasteTop = board.wasteTopIndex { + board.stockRemovedMask |= 1 << UInt32(wasteTop) + board.normalizeCut() + } + } + } +} + +// MARK: - Heuristic and dead-position proof + +private extension PyramidPlanner { + /// Admissible lower bound on moves left to clear the pyramid: every King costs + /// one removal, and each pair move lowers exactly one `max(count(r), + /// count(13−r))` term by at most 1; draws and resets clear nothing. + static func heuristic(for board: Board, position: Position) -> Int { + let counts = rankCounts(inPyramidOf: board, position: position) + var bound = counts[Rank.king.rawValue] + for rank in 1...6 { + bound += max(counts[rank], counts[PyramidGameRules.pairSum - rank]) + } + return bound + } + + /// Proof of unwinnability: each removal of a rank-`r` pyramid card consumes one + /// rank-`13−r` partner, and partners can never exceed the pyramid's own + /// `13−r` cards plus the stock survivors of that rank. The count is optimistic + /// about reachability (every survivor is treated as playable), so it never + /// over-prunes — an emptied stage-one graph is a proof. + static func isProvablyUnwinnable(_ board: Board, position: Position) -> Bool { + let pyramidCounts = rankCounts(inPyramidOf: board, position: position) + var survivorCounts = [Int](repeating: 0, count: 14) + for index in position.stockRanks.indices where !board.isRemoved(index) { + survivorCounts[position.stockRanks[index]] += 1 + } + for rank in 1...12 where pyramidCounts[rank] > 0 { + let partnerRank = PyramidGameRules.pairSum - rank + if pyramidCounts[rank] > pyramidCounts[partnerRank] + survivorCounts[partnerRank] { + return true + } + } + return false + } + + static func rankCounts(inPyramidOf board: Board, position: Position) -> [Int] { + var counts = [Int](repeating: 0, count: 14) + var mask = board.pyramidMask + while mask != 0 { + let slot = mask.trailingZeroBitCount + counts[position.slotRanks[slot]] += 1 + mask &= mask - 1 + } + return counts + } +} + +// MARK: - Search + +private extension PyramidPlanner { + struct Node { + let board: Board + let parent: Int + let move: Move? + let depth: Int + } + + struct HeapEntry: HeapPrioritizable { + let priority: Int + let order: Int + let index: Int + + func takesPriority(over other: HeapEntry) -> Bool { + priority != other.priority ? priority > other.priority : order < other.order + } + } + + static let heuristicWeight = 10 + + /// Stage one: weighted A* for a full winning line over the dead-pruned graph. + /// `exhaustive` is true when the heap emptied within budget — with the exact + /// prune, that is a proof the deal cannot be won. + static func winSearch( + from position: Position, + limits: Limits + ) -> (line: [Move]?, exhaustive: Bool) { + if position.root.pyramidMask == 0 { + return (line: [], exhaustive: true) + } + if isProvablyUnwinnable(position.root, position: position) { + return (line: nil, exhaustive: true) + } + + var nodes: [Node] = [Node(board: position.root, parent: -1, move: nil, depth: 0)] + var visited: Set = [position.root.code] + var heap = BinaryHeap() + heap.push( + HeapEntry( + priority: -heuristicWeight * heuristic(for: position.root, position: position), + order: 0, + index: 0 + ) + ) + var order = 0 + var expansions = 0 + var wasTruncated = false + + while let entry = heap.pop() { + let nodeIndex = entry.index + let node = nodes[nodeIndex] + + if node.board.pyramidMask == 0 { + return (line: line(to: nodeIndex, nodes: nodes), exhaustive: false) + } + + 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) { + let nextBoard = apply(move, to: node.board, position: position) + guard visited.insert(nextBoard.code).inserted else { continue } + guard !isProvablyUnwinnable(nextBoard, position: position) else { continue } + + nodes.append( + Node(board: nextBoard, parent: nodeIndex, move: move, depth: node.depth + 1) + ) + order += 1 + // Min-first on f = g + W·h, expressed as a negated max priority. + let f = node.depth + 1 + + heuristicWeight * heuristic(for: nextBoard, position: position) + heap.push(HeapEntry(priority: -f, order: order, index: nodes.count - 1)) + } + } + + return (line: nil, exhaustive: !wasTruncated) + } + + /// Stage two: best-first for the line clearing the most pyramid cards, over + /// the unpruned graph — positions dead for winning can still hold the deepest + /// clears. + static func maxClearSearch( + from position: Position, + limits: Limits + ) -> (line: [Move]?, exhaustive: Bool, bestRemaining: Int) { + var nodes: [Node] = [Node(board: position.root, parent: -1, move: nil, depth: 0)] + var visited: Set = [position.root.code] + var heap = BinaryHeap() + heap.push(HeapEntry(priority: 0, order: 0, index: 0)) + var order = 0 + var expansions = 0 + var wasTruncated = false + let rootRemaining = position.root.remainingCount + var best: (index: Int, remaining: Int, depth: Int)? + + while let entry = heap.pop() { + let nodeIndex = entry.index + let node = nodes[nodeIndex] + let remaining = node.board.remainingCount + + if remaining < rootRemaining { + let improvesBest = best.map { + remaining < $0.remaining + || (remaining == $0.remaining && node.depth < $0.depth) + } ?? true + if improvesBest { + best = (nodeIndex, remaining, node.depth) + } + if remaining == 0 { break } + } + + 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) { + 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) + ) + order += 1 + // Best-first on cards cleared, shallow bias so equal clears prefer + // short lines. + let priority = (rootRemaining - nextBoard.remainingCount) * 256 - (node.depth + 1) + heap.push(HeapEntry(priority: priority, order: order, index: nodes.count - 1)) + } + } + + guard let best, let moves = line(to: best.index, nodes: nodes) else { + return (line: nil, exhaustive: !wasTruncated, bestRemaining: rootRemaining) + } + return (line: moves, exhaustive: !wasTruncated, bestRemaining: best.remaining) + } + + 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/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index 648966f..62753eb 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -5,6 +5,12 @@ 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. + if state.variant == .pyramid { + return PyramidAutoMoveAdvisor.legalDestinations(for: selection, in: state) + } + guard selectionMatchesState(selection, in: state) else { return [] } guard let movingCard = selection.cards.first else { return [] } @@ -53,6 +59,10 @@ enum AutoMoveAdvisor { } static func candidateSelections(in state: GameState) -> [Selection] { + if state.variant == .pyramid { + return PyramidAutoMoveAdvisor.candidateSelections(in: state) + } + var selections: [Selection] = [] if let topWasteCard = state.waste.last, state.wasteDrawCount > 0 { @@ -93,6 +103,14 @@ enum AutoMoveAdvisor { in state: GameState, stockDrawCount: Int ) -> GameState? { + if state.variant == .pyramid { + return PyramidAutoMoveAdvisor.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 } @@ -113,6 +131,9 @@ 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: + return min(max(0, state.wasteDrawCount), min(1, state.waste.count)) + case .freecell, .yukon: + return 0 + } + } } enum GamePersistenceError: Error { @@ -280,8 +291,8 @@ struct GameStatistics: Codable, Equatable { var bestTimeSeconds: Int? var highScoreDrawThree: Int? var highScoreDrawOne: Int? - /// High score for variants without a draw mode (FreeCell, Yukon). Klondike wins - /// record into the per-draw-mode fields above instead. + /// High score for variants without a draw mode (FreeCell, Yukon, Pyramid). + /// Klondike wins record into the per-draw-mode fields above instead. var highScore: Int? var cleanWins: Int @@ -462,7 +473,8 @@ struct GameStatistics: Codable, Equatable { } else if drawCount == DrawMode.three.rawValue { highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) } else { - // Variants without a draw mode (FreeCell, Yukon) keep a single high score. + // Variants without a draw mode (FreeCell, Yukon, Pyramid) keep a single + // high score. highScore = max(highScore ?? 0, sanitizedScore) } @@ -557,7 +569,8 @@ private struct CardIdentity: Hashable { private extension GameState { var allCards: [Card] { - stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + tableau.flatMap { $0 } + stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + + tableau.flatMap { $0 } + pyramid.compactMap { $0 } + discard } var isValidForPersistence: Bool { @@ -580,6 +593,8 @@ private extension GameState { return FreeCellPersistenceRules.hasValidLayout(state: self) case .yukon: return YukonPersistenceRules.hasValidLayout(state: self) + case .pyramid: + return PyramidPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index e988042..48bd8de 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -19,6 +19,9 @@ enum GameRules { return FreeCellGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) case .yukon: return YukonGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + case .pyramid: + // Pyramid has no tableau piles; its pair moves flow through PyramidGameRules. + return false } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 82fac5c..e9bbe79 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -470,6 +470,8 @@ final class SolitaireViewModel { configureKlondikeNewGame(drawMode: drawMode) case .freecell, .yukon: configureStocklessNewGame() + case .pyramid: + configurePyramidNewGame() } } @@ -479,6 +481,8 @@ final class SolitaireViewModel { configureKlondikeRedeal() case .freecell, .yukon: configureStocklessRedeal() + case .pyramid: + configurePyramidRedeal() } } @@ -491,6 +495,8 @@ final class SolitaireViewModel { return sanitizeKlondikeRedealState(state, stockDrawCount: stockDrawCount) case .freecell, .yukon: return sanitizeStocklessRedealState(state) + case .pyramid: + return sanitizePyramidRedealState(state) } } @@ -528,7 +534,7 @@ final class SolitaireViewModel { cardIndex: cardIndex, card: card ) - case .freecell: + case .freecell, .pyramid: return false } } @@ -573,6 +579,9 @@ final class SolitaireViewModel { return true case .freecell: return canSelectFreeCellTableauCards(cards) + case .pyramid: + // Pyramid has no tableau piles. + return false } } @@ -580,7 +589,7 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return scoringDrawCount - case .freecell, .yukon: + case .freecell, .yukon, .pyramid: return 0 } } @@ -591,6 +600,105 @@ final class SolitaireViewModel { } } +// MARK: - Stock & waste (variants that deal from a stock) + +extension SolitaireViewModel { + func handleStockTap() { + switch state.variant { + case .klondike: + handleKlondikeStockTap() + case .pyramid: + handlePyramidStockTap() + case .freecell, .yukon: + break + } + } + + /// Whether tapping the stock slot can still do anything: draw, or recycle the + /// waste (Pyramid stops recycling once its passes are spent). + var canInteractWithStock: Bool { + switch state.variant { + case .klondike: + return !(state.stock.isEmpty && state.waste.isEmpty) + case .pyramid: + return !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) + case .freecell, .yukon: + return false + } + } + + func visibleWasteCards() -> [Card] { + switch state.variant { + case .klondike: + let count = min(state.wasteDrawCount, stockDrawCount) + return Array(state.waste.suffix(count)) + case .pyramid: + return Array(state.waste.suffix(min(1, state.wasteDrawCount))) + case .freecell, .yukon: + return [] + } + } + + func handleWasteTap() { + guard state.variant.dealsFromStock else { return } + guard let top = state.waste.last, state.wasteDrawCount > 0 else { return } + HapticManager.shared.play(.cardPickUp) + + // An active selection pairing with the waste top wins over auto-moving + // the waste card, so tap-select-then-tap-waste removes the pair the + // player chose (Pyramid; no selection can land on the waste elsewhere). + if selection != nil, tryMoveSelection(to: .waste) { + return + } + + let wasteSelection = Selection(source: .waste, cards: [top]) + if queueBestAutoMove(for: wasteSelection) { + return + } + if selection?.source == .waste { + selection = nil + return + } + isDragging = false + selection = wasteSelection + } + + @discardableResult + func startDragFromWaste() -> Bool { + guard state.variant.dealsFromStock else { return false } + guard let top = state.waste.last, state.wasteDrawCount > 0 else { return false } + clearHint() + selection = Selection(source: .waste, cards: [top]) + isDragging = true + return true + } + + func drawFromStock() { + guard !state.stock.isEmpty else { return } + clearHint() + let drawCount = min(stockDrawCount, state.stock.count) + let drawnCardIDs = (0.. GameState { @@ -66,6 +92,8 @@ struct GameState: Equatable, Codable { return newFreeCellGame() case .yukon: return newYukonGame() + case .pyramid: + return newPyramidGame() } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index 82a49bf..74ccfb9 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -4,6 +4,7 @@ enum GameVariant: String, CaseIterable, Codable { case klondike case freecell case yukon + case pyramid var title: String { switch self { @@ -13,6 +14,8 @@ enum GameVariant: String, CaseIterable, Codable { return "FreeCell" case .yukon: return "Yukon" + case .pyramid: + return "Pyramid" } } @@ -24,12 +27,14 @@ enum GameVariant: String, CaseIterable, Codable { return "Strategic open layout" case .yukon: return "Move any face-up stack" + case .pyramid: + return "Pair cards that total 13" } } var boardColumnCount: Int { switch self { - case .klondike, .yukon: + case .klondike, .yukon, .pyramid: return 7 case .freecell: return 8 @@ -42,7 +47,17 @@ enum GameVariant: String, CaseIterable, Codable { switch self { case .klondike, .yukon: return true - case .freecell: + case .freecell, .pyramid: + return false + } + } + + /// Whether the variant deals from a stock into a waste pile. + var dealsFromStock: Bool { + switch self { + case .klondike, .pyramid: + return true + case .freecell, .yukon: return false } } diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index 66defcc..59c2d98 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -17,6 +17,11 @@ enum HintAdvisor { if state.variant == .klondike, !state.stock.isEmpty || !state.waste.isEmpty { return true } + if state.variant == .pyramid { + if !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) { + return true + } + } for selection in AutoMoveAdvisor.candidateSelections(in: state) { // Foundation rollbacks only count as available moves where the hint // stack can actually turn one into a hint: Yukon's planner searches @@ -57,14 +62,24 @@ enum HintAdvisor { /// searched region — a nudge there has never been observed to rescue a game and a /// deterministic one shuttles a card back and forth, so a Yukon hint is always the /// first move of a verified improving line, or silence (like Klondike's planner). +/// Pyramid hints come from `PyramidPlanner`'s exact search and are cached like +/// Yukon's; on unwinnable deals (common in Pyramid) they follow the max-clear line +/// rather than going silent, because players still play lost deals for cards +/// cleared and the solver knows the best continuation. Pyramid's nil is reserved +/// for positions where not one more pyramid card is clearable. The ratchet is +/// 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. final class HintPlanner { /// How long a single interactive hint request may spend searching. private static let freeCellSearchBudget: TimeInterval = 0.3 private static let klondikeSearchBudget: TimeInterval = 0.15 private static let yukonSearchBudget: TimeInterval = 0.25 + private static let pyramidSearchBudget: TimeInterval = 0.3 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] + private var pyramidPlan: [String: PyramidPlanner.Move] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -80,6 +95,8 @@ final class HintPlanner { return freeCellHint(in: state) case .yukon: return yukonHint(in: state) + case .pyramid: + return pyramidHint(in: state) } } } @@ -110,6 +127,36 @@ private extension HintPlanner { ) } + func pyramidHint(in state: GameState) -> HintAdvisor.Hint? { + let key = PyramidPlanner.stateKey(for: state) + if let hint = plannedPyramidHint(for: key, in: state) { + return hint + } + + pyramidPlan.removeAll() + let limits = PyramidPlanner.Limits( + deadline: Date().addingTimeInterval(Self.pyramidSearchBudget) + ) + switch PyramidPlanner.bestLine(in: state, limits: limits) { + case .winningLine(let line), .bestEffortLine(let line, _): + pyramidPlan = PyramidPlanner.keyedMoves(along: line, from: state) + return plannedPyramidHint(for: key, in: state) + + case .noProgress: + // Exhaustion proves not one more pyramid card is clearable; truncation + // means a large searched region held none. Either way every remaining + // action is provably futile stock-churning, so silence is the honest + // answer. The hint button re-enables after the player's next move. + return nil + } + } + + func plannedPyramidHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + // materialize re-validates the cached move against the live state. + guard let move = pyramidPlan[key] else { return nil } + return PyramidPlanner.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 87f9d69..f89af39 100644 --- a/ComputerSolitaire/Game/Shared/MoveTypes.swift +++ b/ComputerSolitaire/Game/Shared/MoveTypes.swift @@ -6,6 +6,8 @@ struct Selection: Equatable { case freeCell(slot: Int) case foundation(pile: Int) case tableau(pile: Int, index: Int) + /// A single card at a pyramid slot (Pyramid only). + case pyramid(index: Int) } let source: Source @@ -16,4 +18,10 @@ enum Destination: Equatable { case foundation(Int) case tableau(Int) 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). + 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 049a08f..fd0ea0a 100644 --- a/ComputerSolitaire/Game/Shared/Scoring.swift +++ b/ComputerSolitaire/Game/Shared/Scoring.swift @@ -7,6 +7,8 @@ enum ScoringAction { case turnOverTableauCard case foundationToTableau case recycleWasteInDrawOne + case removePyramidPair + case removePyramidKing } enum Scoring { @@ -29,6 +31,10 @@ enum Scoring { return -15 case .recycleWasteInDrawOne: return -100 + case .removePyramidPair: + return 10 + case .removePyramidKing: + return 5 } } diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 634ea51..1fcf233 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -98,6 +98,9 @@ private extension TapMovePolicy { // No stock to refill the board: an eager unsafe foundation move can // strand a card another pile still needs as a landing spot. tier = isSafeFoundationMove(card: card, in: state) ? 100 : 60 + case .pyramid: + // Unreachable: Pyramid moves never target a foundation. + tier = 0 } return Priority(tier: tier, buildLength: 0, pileOrder: -index) @@ -112,6 +115,18 @@ private extension TapMovePolicy { case .freeCell(let index): return Priority(tier: 20, buildLength: 0, pileOrder: -index) + + case .discard: + // Removing a King is always pure progress. + return Priority(tier: 100, buildLength: 0, pileOrder: 0) + + case .pyramid(let index): + // A pyramid pair removes two board cards, a waste pair only one; ties + // break on the lowest partner slot for determinism. + return Priority(tier: 80, buildLength: 0, pileOrder: -index) + + case .waste: + return Priority(tier: 60, buildLength: 0, pileOrder: 0) } } diff --git a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift index 47b023b..d15f7ca 100644 --- a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift +++ b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift @@ -7,6 +7,11 @@ 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. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { + return false + } return state.wasteDrawCount == 0 } } diff --git a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift index d581795..50859fb 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: - // Yukon has no waste or free cells. + case .waste, .freeCell, .pyramid: + // Yukon has no waste, free cells, or pyramid. return nil } switch move.destination { @@ -333,7 +333,7 @@ private extension YukonPlanner { nextState.foundations[index].append(card) case .tableau(let index): nextState.tableau[index].append(contentsOf: move.selection.cards) - case .freeCell: + case .freeCell, .pyramid, .waste, .discard: return nil } return nextState diff --git a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift index 068ec54..84e39fa 100644 --- a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift +++ b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift @@ -4,6 +4,9 @@ enum DropTarget: Hashable { case foundation(Int) case tableau(Int) case freeCell(Int) + case pyramid(Int) + case waste + case discard } enum DragOrigin: Hashable { @@ -11,6 +14,7 @@ enum DragOrigin: Hashable { case foundation(Int) case freeCell(Int) case tableau(pile: Int, index: Int) + case pyramid(Int) } struct DropTargetGeometry: Equatable { @@ -30,6 +34,12 @@ enum DropTargetHitArea { static let tableauHorizontalGrace: CGFloat = 24 static let tableauTopGrace: CGFloat = 20 static let tableauBottomGrace: CGFloat = 24 + + // Pyramid slots overlap their neighbors, so their grace stays small to keep + // adjacent cards distinguishable as targets. + static let pyramidHorizontalGrace: CGFloat = 8 + static let pyramidTopGrace: CGFloat = 8 + static let pyramidBottomGrace: CGFloat = 8 } extension CGRect { diff --git a/ComputerSolitaire/Interaction/DragDropCoordinator.swift b/ComputerSolitaire/Interaction/DragDropCoordinator.swift index fd9e931..16f4ff2 100644 --- a/ComputerSolitaire/Interaction/DragDropCoordinator.swift +++ b/ComputerSolitaire/Interaction/DragDropCoordinator.swift @@ -48,6 +48,12 @@ enum DragDropCoordinator { return 100 + index case .tableau(let index): return 200 + index + case .pyramid(let index): + return 300 + index + case .waste: + return 400 + case .discard: + return 500 } } } diff --git a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift index 04ae953..20fe7ee 100644 --- a/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift +++ b/ComputerSolitaire/Views/Klondike/KlondikeStockWasteViews.swift @@ -16,10 +16,12 @@ struct StockView: View { PilePlaceholderView(cardSize: cardSize) .allowsHitTesting(false) if viewModel.state.stock.isEmpty { - Image(systemName: "arrow.counterclockwise") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) - .accessibilityHidden(true) + if viewModel.canInteractWithStock { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + .accessibilityHidden(true) + } } else { CardBackView(cardSize: cardSize) } @@ -46,20 +48,20 @@ struct StockView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isStockActionUnavailable) + .disabled(!viewModel.canInteractWithStock) .accessibilityLabel("Stock") .accessibilityValue(stockAccessibilityValue) } - private var isStockActionUnavailable: Bool { - viewModel.state.stock.isEmpty && viewModel.state.waste.isEmpty - } - private var stockAccessibilityValue: String { if !viewModel.state.stock.isEmpty { + if viewModel.gameVariant == .pyramid { + let recycles = viewModel.pyramidWasteRecyclesRemaining + return "\(viewModel.state.stock.count) cards. \(recycles) recycles left" + } return "\(viewModel.state.stock.count) cards" } - if !viewModel.state.waste.isEmpty { + if viewModel.canInteractWithStock { return "Empty. Activate to recycle the waste pile" } return "Empty" @@ -70,6 +72,7 @@ struct WasteView: View { @Bindable var viewModel: SolitaireViewModel let cardSize: CGSize let fanSpacing: CGFloat + var isTargeted: Bool = false let isHintTargeted: Bool let isCardTiltEnabled: Bool @Binding var cardTilts: [UUID: Double] @@ -103,6 +106,14 @@ struct WasteView: View { ZStack(alignment: .topLeading) { PilePlaceholderView(cardSize: cardSize) .hintWiggle(token: isHintTargeted ? hintWiggleToken : nil) + DropHighlightView( + cardSize: cardSize, + isTargeted: isTargeted, + isHintTargeted: false, + hintOpacity: 0 + ) + .zIndex(3) + .allowsHitTesting(false) ForEach(Array(visibleWaste.enumerated()), id: \.element.id) { index, card in let isTopCard = index == visibleWaste.count - 1 let isDragged = isTopCard && viewModel.isDragging && viewModel.isSelected(card: card) diff --git a/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift b/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift new file mode 100644 index 0000000..bd40e7f --- /dev/null +++ b/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift @@ -0,0 +1,126 @@ +import SwiftUI +import Observation + +/// The 28-slot pyramid replaces the shared tableau row for the Pyramid variant: +/// seven centered rows where each card half-overlaps the two cards above it. +struct PyramidBoardView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let maxBoardHeight: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + let rowOverlap = rowOverlap + let boardWidth = (cardSize.width * CGFloat(PyramidGeometry.rowCount)) + + (columnSpacing * CGFloat(PyramidGeometry.rowCount - 1)) + let boardHeight = cardSize.height + rowOverlap * CGFloat(PyramidGeometry.rowCount - 1) + + ZStack(alignment: .topLeading) { + ForEach(0.. CGSize { + let row = PyramidGeometry.row(of: index) + let column = PyramidGeometry.column(of: index) + let columnUnits = CGFloat(PyramidGeometry.rowCount - 1 - row) / 2 + CGFloat(column) + return CGSize( + width: columnUnits * (cardSize.width + columnSpacing), + height: CGFloat(row) * rowOverlap + ) + } + + @ViewBuilder + private func pyramidCard(_ card: Card, at index: Int, rowOverlap: CGFloat) -> some View { + let row = PyramidGeometry.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 isSelectable = PyramidGameRules.isSelectable(index: index, in: viewModel.state.pyramid) + let isAccessibilityElement = isSelectable && !isDragged && !isHidden + let isTargeted = activeTarget == .pyramid(index) + let isHintTargeted = hintedTarget == .pyramid(index) + let accessibilityHint = card.rank == .king + ? "Removes the King" + : "Selects this card" + + ZStack { + CardView( + card: card, + isSelected: isSelected, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: isAccessibilityElement + ) + DropHighlightView( + cardSize: cardSize, + isTargeted: isTargeted, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + .allowsHitTesting(false) + } + .opacity(isDragged || isHidden ? 0 : 1) + .offset(x: offset.width, y: offset.height) + .zIndex(isDragged ? 40 + Double(row) : Double(row)) + .allowsHitTesting(!isHidden) + .onTapGesture { + viewModel.handlePyramidTap(index: index) + } + .gesture(dragGesture(.pyramid(index))) + .accessibilityHidden(!isAccessibilityElement) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityHint(accessibilityHint) + .cardFramePreference(card.id, xOffset: offset.width, yOffset: offset.height) + .background( + GeometryReader { proxy in + let frame = proxy.frame(in: .named("board")) + let snapFrame = frame.offsetBy(dx: offset.width, dy: offset.height) + let hitFrame = snapFrame.expanded( + horizontal: DropTargetHitArea.pyramidHorizontalGrace, + top: DropTargetHitArea.pyramidTopGrace, + bottom: DropTargetHitArea.pyramidBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .pyramid(index): DropTargetGeometry( + snapFrame: snapFrame, + hitFrame: hitFrame + ) + ] + ) + } + ) + } +} diff --git a/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift b/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift new file mode 100644 index 0000000..29ea49e --- /dev/null +++ b/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import Observation + +/// Where removed pairs and Kings land. Inert by design: cards here are out of +/// play, so the pile takes drops (via the shared drop targeting) but offers no +/// taps or drags of its own. +struct PyramidDiscardView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let isTargeted: Bool + let isHintTargeted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + + var body: some View { + let discard = viewModel.state.discard + let visibleDepth = min(discard.count, 4) + let startIndex = discard.count - visibleDepth + + ZStack { + PilePlaceholderView(cardSize: cardSize) + if discard.isEmpty { + Image(systemName: "xmark") + .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.28)) + .allowsHitTesting(false) + } + DropHighlightView( + cardSize: cardSize, + isTargeted: isTargeted, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + .zIndex(1) + ForEach(Array(discard.enumerated().dropFirst(startIndex)), id: \.element.id) { _, card in + CardView( + card: card, + isSelected: false, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: nil, + isAccessibilityElement: false + ) + .opacity(hiddenCardIDs.contains(card.id) ? 0 : 1) + .allowsHitTesting(false) + .cardFramePreference(card.id) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Discard pile") + .accessibilityValue("\(discard.count) cards removed") + .background( + GeometryReader { proxy in + let boardFrame = proxy.frame(in: .named("board")) + let hitFrame = boardFrame.expanded( + horizontal: DropTargetHitArea.foundationHorizontalGrace, + top: DropTargetHitArea.foundationTopGrace, + bottom: DropTargetHitArea.foundationBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .discard: DropTargetGeometry( + snapFrame: boardFrame, + hitFrame: hitFrame + ) + ] + ) + } + ) + } +} diff --git a/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift b/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift new file mode 100644 index 0000000..a8dac4d --- /dev/null +++ b/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import Observation + +struct PyramidTopRowView: 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, + 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) + ] + ) + } + ) + + // Keep the discard aligned over the last tableau column, mirroring + // where the other variants park their rightmost foundation. + ForEach(0..<4, id: \.self) { _ in + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + } + + PyramidDiscardView( + viewModel: viewModel, + cardSize: cardSize, + isTargeted: activeTarget == .discard, + isHintTargeted: hintedTarget == .discard, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs + ) + .frame(width: cardSize.width, alignment: .leading) + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index afdc60a..b7c0766 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -241,6 +241,20 @@ struct RulesAndScoringView: View { definition: "Any face-up card together with every card stacked on top of it, moved as one, even out of order." ) ] + case .pyramid: + return [ + TermRow( + term: "Pyramid", + definition: "Twenty-eight face-up cards in seven overlapping rows; a card is exposed once both cards covering it are gone." + ), + TermRow(term: "Stock", definition: "The face-down draw pile."), + TermRow(term: "Waste", definition: "Face-up cards drawn from the stock; only the top card is playable."), + TermRow(term: "Discard", definition: "Where removed pairs and Kings go; cards there are out of play."), + TermRow( + term: "Recycle", + definition: "Turning the waste back into the stock. Pyramid allows two recycles (three passes)." + ) + ] } } @@ -274,6 +288,16 @@ struct RulesAndScoringView: View { "Face-down cards turn face up when they become the top of a pile.", "You win by moving all 52 cards to foundations." ] + case .pyramid: + return [ + "Deal 28 cards face up into a seven-row pyramid; the remaining 24 form the stock.", + "Remove exposed pairs whose ranks total 13: Ace is 1, Jack 11, Queen 12.", + "Kings count 13 alone and are removed singly.", + "A card is exposed once neither card covering it remains. A card whose only cover is its matching partner may be removed together with it.", + "Tap the stock to draw one card to the waste; the top waste card can pair with exposed pyramid cards.", + "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." + ] } } @@ -301,6 +325,16 @@ struct RulesAndScoringView: View { note: "Reduced by elapsed time." ) ] + case .pyramid: + return [ + ScoringRow(move: "Remove a pair", points: Scoring.delta(for: .removePyramidPair), note: nil), + ScoringRow(move: "Remove a King", points: Scoring.delta(for: .removePyramidKing), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) + ] } } } diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 02f3911..75d0eed 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -357,6 +357,25 @@ struct TopRowView: View { hintWiggleToken: hintWiggleToken, dragGesture: dragGesture ) + case .pyramid: + PyramidTopRowView( + 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 7801146..3e29c1a 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -341,6 +341,10 @@ struct ContentView: View { .onChange(of: gameVariantRawValue) { _, newValue in guard hasLoadedGame, !isHydratingGame else { return } let variant = GameVariant(rawValue: newValue) ?? .klondike + // Restoring a game whose variant differs from the stored setting + // syncs the setting to the game; that write lands here after + // hydration ends and must not re-deal over the restored board. + guard variant != viewModel.gameVariant else { return } stopAutoFinish() winCelebration.reset(to: .idle) isScreenshotSession = false @@ -486,24 +490,43 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) - TableauRowView( - viewModel: viewModel, - cardSize: cardSize, - columnSpacing: metrics.columnSpacing, - faceDownOffset: metrics.tableauFaceDownOffset, - faceUpOffset: metrics.tableauFaceUpOffset, - maxPileHeight: metrics.tableauMaxHeight, - activeTarget: activeTarget, - hintedTarget: hintedTarget, - hintHighlightOpacity: hintHighlightOpacity, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hiddenCardIDs: effectiveHiddenCardIDs, - hintedCardIDs: viewModel.hintedCardIDs, - hintWiggleToken: viewModel.hintWiggleToken, - dragGesture: dragGesture(for:) - ) - .frame(width: boardContentWidth, alignment: .leading) + if viewModel.gameVariant == .pyramid { + PyramidBoardView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: metrics.columnSpacing, + maxBoardHeight: metrics.tableauMaxHeight, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: effectiveHiddenCardIDs, + hintedCardIDs: viewModel.hintedCardIDs, + hintWiggleToken: viewModel.hintWiggleToken, + dragGesture: dragGesture(for:) + ) + .frame(width: boardContentWidth, alignment: .leading) + } else { + TableauRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: metrics.columnSpacing, + faceDownOffset: metrics.tableauFaceDownOffset, + faceUpOffset: metrics.tableauFaceUpOffset, + maxPileHeight: metrics.tableauMaxHeight, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: effectiveHiddenCardIDs, + hintedCardIDs: viewModel.hintedCardIDs, + hintWiggleToken: viewModel.hintWiggleToken, + dragGesture: dragGesture(for:) + ) + .frame(width: boardContentWidth, alignment: .leading) + } Spacer(minLength: 0) } .allowsHitTesting(!isWinCascadeAnimating) @@ -563,7 +586,8 @@ struct ContentView: View { guard !isHydratingGame else { return } if isWin { winCelebration.beginIfNeededForWin( - foundations: viewModel.state.foundations, + launchPiles: winCascadeLaunchPiles, + launchTargets: winCascadeLaunchTargets, dropFrames: dropFrames, boardViewportSize: boardViewportSize ) @@ -863,6 +887,8 @@ struct ContentView: View { started = viewModel.startDragFromFreeCell(index: index) case .tableau(let pile, let index): started = viewModel.startDragFromTableau(pileIndex: pile, cardIndex: index) + case .pyramid(let index): + started = viewModel.startDragFromPyramid(index: index) } if started, let firstCard = viewModel.selection?.cards.first { @@ -1053,6 +1079,12 @@ struct ContentView: View { return .foundation(index) case .tableau(let index): return .tableau(index) + case .pyramid(let index): + return .pyramid(index) + case .waste: + return .waste + case .discard: + return .discard } } @@ -1064,6 +1096,12 @@ struct ContentView: View { return .foundation(index) case .tableau(let index): return .tableau(index) + case .pyramid(let index): + return .pyramid(index) + case .waste: + return .waste + case .discard: + return .discard } } @@ -1236,6 +1274,8 @@ struct ContentView: View { for pile in state.tableau { for card in pile { lookup[card.id] = card } } + for card in state.pyramid.compactMap({ $0 }) { lookup[card.id] = card } + for card in state.discard { lookup[card.id] = card } return lookup } @@ -1245,6 +1285,8 @@ struct ContentView: View { case freeCell(Int) case foundation(pile: Int, index: Int) case tableau(pile: Int, index: Int) + case pyramid(Int) + case discard(Int) } private func cardLocations(in state: GameState) -> [UUID: CardLocation] { @@ -1271,6 +1313,14 @@ struct ContentView: View { locations[card.id] = .tableau(pile: pile, index: index) } } + for (index, card) in state.pyramid.enumerated() { + if let card { + locations[card.id] = .pyramid(index) + } + } + for (index, card) in state.discard.enumerated() { + locations[card.id] = .discard(index) + } return locations } @@ -1370,7 +1420,8 @@ struct ContentView: View { persistGameNow() } winCelebration.syncForLoadedGame( - foundations: viewModel.state.foundations, + launchPiles: winCascadeLaunchPiles, + launchTargets: winCascadeLaunchTargets, isWin: viewModel.isWin, dropFrames: dropFrames, boardViewportSize: boardViewportSize @@ -1407,13 +1458,28 @@ struct ContentView: View { guard winCelebration.phase == .completed else { return } guard winCelebration.cards.isEmpty else { return } winCelebration.syncForLoadedGame( - foundations: viewModel.state.foundations, + launchPiles: winCascadeLaunchPiles, + launchTargets: winCascadeLaunchTargets, isWin: true, dropFrames: dropFrames, boardViewportSize: boardViewportSize ) } + /// The cascade erupts from the foundations, except in Pyramid where every + /// removed card lives on the discard. + private var winCascadeLaunchPiles: [[Card]] { + viewModel.gameVariant == .pyramid + ? [viewModel.state.discard] + : viewModel.state.foundations + } + + private var winCascadeLaunchTargets: [DropTarget] { + viewModel.gameVariant == .pyramid + ? [.discard] + : (0..<4).map(DropTarget.foundation) + } + private func syncLifecyclePauseState() { updatePauseReason(.lifecycle, shouldPause: shouldPauseForLifecycle) } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 386da91..93c24e8 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -23,6 +23,7 @@ struct StatisticsView: View { case klondike case freecell case yukon + case pyramid case all var id: String { rawValue } @@ -35,6 +36,8 @@ struct StatisticsView: View { self = .freecell case .yukon: self = .yukon + case .pyramid: + self = .pyramid } } @@ -47,6 +50,8 @@ struct StatisticsView: View { return .freecell case .yukon: return .yukon + case .pyramid: + return .pyramid case .all: return nil } @@ -54,7 +59,7 @@ struct StatisticsView: View { var title: String { switch self { - case .klondike, .freecell, .yukon: + case .klondike, .freecell, .yukon, .pyramid: return variant?.title ?? "" case .all: return "All" @@ -262,7 +267,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: + case .freecell, .yukon, .pyramid: return [HighScoreRow(label: "High Score", score: stats.highScore)] case .all: return [] @@ -409,6 +414,8 @@ struct StatisticsView: View { return "Reset FreeCell statistics?" case .yukon: return "Reset Yukon statistics?" + case .pyramid: + return "Reset Pyramid statistics?" case .all: return "Reset all statistics?" } @@ -422,6 +429,8 @@ struct StatisticsView: View { return "Reset FreeCell Statistics" case .yukon: return "Reset Yukon Statistics" + case .pyramid: + return "Reset Pyramid Statistics" case .all: return "Reset All Statistics" } @@ -435,8 +444,10 @@ struct StatisticsView: View { return "This will reset only FreeCell games, times, win rates, and high scores." case .yukon: return "This will reset only Yukon games, times, win rates, and high scores." + case .pyramid: + return "This will reset only Pyramid games, times, win rates, and high scores." case .all: - return "This will reset Klondike, FreeCell, and Yukon statistics." + return "This will reset Klondike, FreeCell, Yukon, and Pyramid statistics." } } diff --git a/ComputerSolitaireTests/Pyramid/PyramidGeometryTests.swift b/ComputerSolitaireTests/Pyramid/PyramidGeometryTests.swift new file mode 100644 index 0000000..3ce2750 --- /dev/null +++ b/ComputerSolitaireTests/Pyramid/PyramidGeometryTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class PyramidGeometryTests: XCTestCase { + func testRowRangesTileTheTwentyEightSlots() { + XCTAssertEqual(PyramidGeometry.rowRanges.count, 7) + for (row, range) in PyramidGeometry.rowRanges.enumerated() { + XCTAssertEqual(range.count, row + 1, "Row \(row) should hold \(row + 1) slots") + } + XCTAssertEqual(PyramidGeometry.rowRanges.first?.lowerBound, 0) + XCTAssertEqual(PyramidGeometry.rowRanges.last?.upperBound, PyramidGeometry.cardCount) + } + + func testRowColumnIndexRoundTrip() { + for index in 0.. SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + stockDrawCount: stockDrawCount, + history: [] + ) + } + + func testFreshDealRoundTripsThroughSanitization() throws { + let state = GameState.newPyramidGame() + 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 { + // A legally reachable mid-game shape: two bottom-row cards removed to the + // discard, one card drawn to the waste, one recycle spent. + var state = GameStateFixtures.seededPyramidDeal(seed: 2) + state.discard.append(state.pyramid[27]!) + state.pyramid[27] = nil + state.discard.append(state.pyramid[26]!) + state.pyramid[26] = nil + var drawn = state.stock.removeLast() + drawn.isFaceUp = true + state.waste.append(drawn) + state.wasteDrawCount = 1 + state.wasteRecyclesUsed = 1 + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertEqual(decoded, state) + + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + XCTAssertEqual(try XCTUnwrap(sanitized).state, state) + } + + func testDecodingLegacySaveWithoutPyramidFields() throws { + // Saves written before the Pyramid variant carry no pyramid keys; they must + // decode to empty pyramid fields. + let legacy = GameStateFixtures.seededKlondikeDeal(seed: 1) + var json = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(legacy) + ) as? [String: Any] ?? [:] + json.removeValue(forKey: "pyramid") + json.removeValue(forKey: "discard") + json.removeValue(forKey: "wasteRecyclesUsed") + let data = try JSONSerialization.data(withJSONObject: json) + + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertTrue(decoded.pyramid.isEmpty) + XCTAssertTrue(decoded.discard.isEmpty) + XCTAssertEqual(decoded.wasteRecyclesUsed, 0) + XCTAssertEqual(decoded.tableau, legacy.tableau) + } + + func testLayoutValidationAcceptsLegalStatesOnly() { + XCTAssertTrue(PyramidPersistenceRules.hasValidLayout(state: GameState.newPyramidGame())) + + // Wrong slot count. + var truncated = GameState.newPyramidGame() + truncated.pyramid.removeLast() + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: truncated)) + + // A removed slot beneath an occupied cover breaks the removal invariant. + var brokenCover = GameState.newPyramidGame() + brokenCover.discard.append(brokenCover.pyramid[15]!) + brokenCover.pyramid[15] = nil + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: brokenCover)) + + // Recycle counter out of range. + var overRecycled = GameState.newPyramidGame() + overRecycled.wasteRecyclesUsed = PyramidGameRules.maxWasteRecycles + 1 + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: overRecycled)) + + // Pyramid renders no tableau, free cells, or foundations. + var strandedTableau = GameState.newPyramidGame() + strandedTableau.tableau = [[strandedTableau.stock.removeLast()]] + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: strandedTableau)) + + var strandedFreeCell = GameState.newPyramidGame() + strandedFreeCell.freeCells[0] = strandedFreeCell.stock.removeLast() + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: strandedFreeCell)) + + var strandedFoundation = GameState.newPyramidGame() + strandedFoundation.foundations[0] = [strandedFoundation.stock.removeLast()] + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: strandedFoundation)) + + // The single visible waste card must track the waste. + var badWasteCount = GameState.newPyramidGame() + var drawn = badWasteCount.stock.removeLast() + drawn.isFaceUp = true + badWasteCount.waste = [drawn] + badWasteCount.wasteDrawCount = 0 + XCTAssertFalse(PyramidPersistenceRules.hasValidLayout(state: badWasteCount)) + } + + func testSanitizationRejectsCorruptPyramidStates() { + // A duplicated card violates the 52-unique-card invariant. + var duplicated = GameState.newPyramidGame() + duplicated.pyramid[0] = duplicated.pyramid[1] + XCTAssertNil(payload(for: duplicated).sanitizedForRestore(at: DateFixtures.reference)) + + // A missing card violates the 52-card count. + var missing = GameState.newPyramidGame() + missing.stock.removeLast() + XCTAssertNil(payload(for: missing).sanitizedForRestore(at: DateFixtures.reference)) + } + + func testOtherVariantsRejectStrandedPyramidCards() { + var klondike = GameStateFixtures.validPersistenceState() + let strayCard = klondike.stock.removeLast() + klondike.pyramid = [strayCard] + XCTAssertNil(payload(for: klondike, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference)) + + var withDiscard = GameStateFixtures.validPersistenceState() + let discarded = withDiscard.stock.removeLast() + withDiscard.discard = [discarded] + XCTAssertNil(payload(for: withDiscard, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference)) + + var withRecycles = GameStateFixtures.validPersistenceState() + withRecycles.wasteRecyclesUsed = 1 + XCTAssertNil(payload(for: withRecycles, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference)) + } + + func testSanitizationForcesPyramidDrawCounts() throws { + let state = GameState.newPyramidGame() + let restored = try XCTUnwrap( + payload(for: state, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference) + ) + XCTAssertEqual(restored.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testStatisticsStoreKeepsPyramidIsolated() { + let defaults = UserDefaults(suiteName: "PyramidPersistenceTests-\(UUID().uuidString)")! + defer { defaults.removePersistentDomain(forName: "PyramidPersistenceTests") } + + GameStatisticsStore.update(for: .pyramid, userDefaults: defaults) { stats in + stats.recordCompletedGame( + didWin: true, + elapsedSeconds: 120, + finalScore: 250, + drawCount: 0, + hintsUsedInGame: 0, + undosUsedInGame: 0, + usedRedealInGame: false + ) + } + + let pyramidStats = GameStatisticsStore.load(for: .pyramid, userDefaults: defaults) + XCTAssertEqual(pyramidStats.gamesWon, 1) + // Pyramid has no draw mode, so wins land in the variant-neutral high score. + XCTAssertEqual(pyramidStats.highScore, 250) + XCTAssertNil(pyramidStats.highScoreDrawOne) + XCTAssertNil(pyramidStats.highScoreDrawThree) + + let klondikeStats = GameStatisticsStore.load(for: .klondike, userDefaults: defaults) + XCTAssertEqual(klondikeStats.gamesPlayed, 0) + } +} diff --git a/ComputerSolitaireTests/Pyramid/PyramidPlannerTests.swift b/ComputerSolitaireTests/Pyramid/PyramidPlannerTests.swift new file mode 100644 index 0000000..5908f44 --- /dev/null +++ b/ComputerSolitaireTests/Pyramid/PyramidPlannerTests.swift @@ -0,0 +1,348 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class PyramidPlannerTests: XCTestCase { + // Probe-verified seeds (release-build probe study 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 stage-one search at the default budget. + private static let winningSeed: UInt64 = 1 + private static let unwinnableSeed: UInt64 = 42 + + func testHintIsDeterministicAcrossCalls() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .seven) + slots[23] = TestCards.make(.clubs, .king) + let state = GameStateFixtures.pyramidState(slots: slots) + + let first = PyramidPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(PyramidPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal always has clearable cards within easy reach, so a small + // budget keeps the suite fast; production searches are capped by the + // interactive deadline. + let limits = PyramidPlanner.Limits(maxNodes: 20_000) + for seed in 1...10 { + let state = GameStateFixtures.seededPyramidDeal(seed: UInt64(seed)) + XCTAssertNotNil( + PyramidPlanner.bestHint(in: state, limits: limits), + "Seed \(seed): a fresh Pyramid deal should have a suggestible line" + ) + } + } + + func testWinningLineReplaysLegallyToAClearedPyramid() { + let state = GameStateFixtures.seededPyramidDeal(seed: Self.winningSeed) + guard case .winningLine(let line) = PyramidPlanner.bestLine(in: state) else { + return XCTFail("Probe-verified winning seed should produce a winning line") + } + + var current = state + for move in line { + if case .removePair = move { + replayThroughAdvisor(move, on: ¤t) + } else if case .removeKing = move { + replayThroughAdvisor(move, on: ¤t) + } else { + guard let next = PyramidPlanner.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 pyramid") + } + + func testKeyedMovesFollowTheSolutionLine() { + let state = GameStateFixtures.seededPyramidDeal(seed: Self.winningSeed) + guard case .winningLine(let line) = PyramidPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line") + } + + let keyed = PyramidPlanner.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[PyramidPlanner.stateKey(for: current)], move) + guard let next = PyramidPlanner.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. + let planner = HintPlanner() + var state = GameStateFixtures.seededPyramidDeal(seed: Self.winningSeed) + var steps = 0 + + while steps < 200 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: 1) else { + return XCTFail("Hint stack gave up after \(steps) steps") + } + switch hint { + case .move(let move): + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: 1 + ) else { + return XCTFail("Hinted move was not legal after \(steps) steps") + } + state = next + case .stockTap: + let stockMove: PyramidPlanner.Move = state.stock.isEmpty ? .resetStock : .draw + guard let next = PyramidPlanner.apply(stockMove, to: state) else { + return XCTFail("Hinted stock tap was not legal after \(steps) steps") + } + state = next + } + steps += 1 + } + XCTFail("Did not win within 200 steps") + } + + func testUnwinnableDealIsProvedAndStillYieldsBestEffortHints() { + var state = GameStateFixtures.seededPyramidDeal(seed: Self.unwinnableSeed) + guard case .bestEffortLine(let line, let dealIsProvedUnwinnable) = + PyramidPlanner.bestLine(in: state) else { + return XCTFail("Probe-verified lost seed should produce a best-effort line") + } + XCTAssertTrue(dealIsProvedUnwinnable, "Stage one 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.pyramid.filter { $0 == nil }.count + var steps = 0 + while steps < 200, let hint = planner.bestHint(in: state, stockDrawCount: 1) { + switch hint { + case .move(let move): + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: 1 + ) else { + return XCTFail("Hinted move was not legal after \(steps) steps") + } + state = next + case .stockTap: + let stockMove: PyramidPlanner.Move = state.stock.isEmpty ? .resetStock : .draw + guard let next = PyramidPlanner.apply(stockMove, to: state) else { + return XCTFail("Hinted stock tap was not legal after \(steps) steps") + } + state = next + } + steps += 1 + } + XCTAssertLessThan(steps, 200, "Hints on a lost deal must eventually go silent") + XCTAssertFalse(state.isWon) + XCTAssertGreaterThan( + state.pyramid.filter { $0 == nil }.count, + clearedAtStart, + "Best-effort hints should still clear pyramid cards" + ) + } + + func testFollowingHintsNeverRepeatsAPosition() { + // Every Pyramid move advances a monotone quantity, 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.seededPyramidDeal(seed: seed) + var seen: Set = [PyramidPlanner.stateKey(for: state)] + var steps = 0 + while steps < 200, !state.isWon, + let hint = planner.bestHint(in: state, stockDrawCount: 1) { + switch hint { + case .move(let move): + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: 1 + ) else { + return XCTFail("Hinted move was not legal") + } + state = next + case .stockTap: + let stockMove: PyramidPlanner.Move = state.stock.isEmpty ? .resetStock : .draw + guard let next = PyramidPlanner.apply(stockMove, to: state) else { + return XCTFail("Hinted stock tap was not legal") + } + state = next + } + steps += 1 + XCTAssertTrue( + seen.insert(PyramidPlanner.stateKey(for: state)).inserted, + "Seed \(seed): following hints revisited a position" + ) + } + } + } + + func testCoverPairIsSuggestedWhenItIsTheOnlyClearingMove() { + // The 6's only remaining cover is the exposed 7: removing both together is + // the only move that clears cards. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .six) + slots[21] = TestCards.make(.spades, .seven) + let state = GameStateFixtures.pyramidState(slots: slots, passesUsed: 2) + + guard case .move(let move)? = PyramidPlanner.bestHint(in: state) else { + return XCTFail("Expected the cover-pair move hint") + } + XCTAssertEqual(move.selection.source, .pyramid(index: 15)) + XCTAssertEqual(move.destination, .pyramid(21)) + } + + func testResetHintWhenTheWinNeedsAnotherPass() { + // The 6's partner sits at the bottom of the spent waste; the only winning + // line is reset → draw → pair, so the hint is a stock tap. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + let state = GameStateFixtures.pyramidState( + slots: slots, + waste: [TestCards.make(.hearts, .seven), TestCards.make(.clubs, .nine)], + passesUsed: 1 + ) + + guard case .winningLine(let line) = PyramidPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line through the reset") + } + XCTAssertEqual(line.first, .resetStock) + XCTAssertEqual(PyramidPlanner.bestHint(in: state), .stockTap) + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testNoMovesWhenPassesAreExhausted() { + // Same position with no recycles left: nothing is legal at all. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + let state = GameStateFixtures.pyramidState( + slots: slots, + waste: [TestCards.make(.hearts, .seven), TestCards.make(.clubs, .nine)], + passesUsed: 2 + ) + + guard case .noProgress(searchWasExhaustive: true) = PyramidPlanner.bestLine(in: state) else { + return XCTFail("Expected an exhaustive no-progress outcome") + } + XCTAssertNil(PyramidPlanner.bestHint(in: state)) + XCTAssertFalse(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 = PyramidPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededPyramidDeal(seed: 5) + + guard case .noProgress(searchWasExhaustive: false) = PyramidPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(PyramidPlanner.bestHint(in: state, limits: limits)) + } + + func testProvablyFutilePositionGetsNoHintButKeepsButtonAlive() { + // A draw is legal, but the lone 6 has no 7 anywhere: churning the stock is + // provably futile, so the hint goes silent while the button stays alive. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + let state = GameStateFixtures.pyramidState( + slots: slots, + stock: [TestCards.make(.clubs, .nine), TestCards.make(.diamonds, .two)], + passesUsed: 2 + ) + + guard case .noProgress(searchWasExhaustive: true) = PyramidPlanner.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 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.seededPyramidDeal(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 = PyramidPlanner.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 = PyramidPlanner.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: + XCTAssertTrue( + !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state), + "Seed \(seed): stock tap hinted with a dead stock" + ) + } + } + } + + // MARK: - Helpers + + private func replayThroughAdvisor(_ move: PyramidPlanner.Move, on state: inout GameState) { + guard case .move(let hintMove)? = PyramidPlanner.materialize(move, in: state) else { + return XCTFail("Removal 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/Pyramid/PyramidRulesTests.swift b/ComputerSolitaireTests/Pyramid/PyramidRulesTests.swift new file mode 100644 index 0000000..0919d33 --- /dev/null +++ b/ComputerSolitaireTests/Pyramid/PyramidRulesTests.swift @@ -0,0 +1,281 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class PyramidRulesTests: XCTestCase { + func testPairAndKingClassification() { + XCTAssertTrue(PyramidGameRules.isPair(TestCards.make(.spades, .six), TestCards.make(.hearts, .seven))) + XCTAssertTrue(PyramidGameRules.isPair(TestCards.make(.clubs, .ace), TestCards.make(.clubs, .queen))) + XCTAssertFalse(PyramidGameRules.isPair(TestCards.make(.spades, .six), TestCards.make(.hearts, .six))) + XCTAssertFalse(PyramidGameRules.isPair(TestCards.make(.spades, .king), TestCards.make(.hearts, .ace))) + XCTAssertTrue(PyramidGameRules.isKing(TestCards.make(.spades, .king))) + XCTAssertFalse(PyramidGameRules.isKing(TestCards.make(.spades, .queen))) + } + + func testExposedPairIsRemovableAndCoveredPairIsNot() { + // Bottom row holds a 6 and a 7 (exposed); slot 15 holds a covered 8. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .eight) + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .seven) + slots[23] = TestCards.make(.diamonds, .five) + let state = GameStateFixtures.pyramidState(slots: slots) + + XCTAssertTrue(PyramidGameRules.canRemovePair(21, 22, in: state.pyramid)) + XCTAssertTrue(PyramidGameRules.canRemovePair(22, 21, in: state.pyramid)) + // The covered 8 cannot pair with the exposed 5: two cards cover it. + XCTAssertFalse(PyramidGameRules.canRemovePair(15, 23, in: state.pyramid)) + // A slot never pairs with itself and empty slots never pair. + XCTAssertFalse(PyramidGameRules.canRemovePair(21, 21, in: state.pyramid)) + XCTAssertFalse(PyramidGameRules.canRemovePair(0, 21, in: state.pyramid)) + } + + func testCoverPairIsRemovableTogether() { + // Slot 15's only remaining cover is slot 21, and the two sum to 13 — the + // cover-pair rule removes both in one move. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .six) + slots[21] = TestCards.make(.spades, .seven) + let state = GameStateFixtures.pyramidState(slots: slots) + + XCTAssertTrue(PyramidGameRules.isCoverPair(parent: 15, child: 21, in: state.pyramid)) + XCTAssertTrue(PyramidGameRules.canRemovePair(15, 21, in: state.pyramid)) + XCTAssertTrue(PyramidGameRules.isSelectable(index: 15, in: state.pyramid)) + + // With the second cover still present, the parent stays locked. + var covered = slots + covered[22] = TestCards.make(.diamonds, .two) + let coveredState = GameStateFixtures.pyramidState(slots: covered) + XCTAssertFalse(PyramidGameRules.isCoverPair(parent: 15, child: 21, in: coveredState.pyramid)) + XCTAssertFalse(PyramidGameRules.canRemovePair(15, 21, in: coveredState.pyramid)) + XCTAssertFalse(PyramidGameRules.isSelectable(index: 15, in: coveredState.pyramid)) + } + + func testWasteTopPairing() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .eight) + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .nine) + let state = GameStateFixtures.pyramidState( + slots: slots, + waste: [TestCards.make(.diamonds, .four), TestCards.make(.diamonds, .seven)] + ) + + // Waste top is the 7: it pairs with the exposed 6, not the buried 4 or the + // covered 8. + XCTAssertTrue(PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: 21, in: state)) + XCTAssertFalse(PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: 15, in: state)) + XCTAssertFalse(PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: 22, in: state)) + } + + func testKingRemoval() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .king) + slots[21] = TestCards.make(.spades, .king) + slots[22] = TestCards.make(.hearts, .two) + let wasteKing = TestCards.make(.diamonds, .king) + let state = GameStateFixtures.pyramidState(slots: slots, waste: [wasteKing]) + + let exposedKing = Selection(source: .pyramid(index: 21), cards: [state.pyramid[21]!]) + XCTAssertTrue(PyramidGameRules.canRemoveKing(selection: exposedKing, in: state)) + + let coveredKing = Selection(source: .pyramid(index: 15), cards: [state.pyramid[15]!]) + XCTAssertFalse(PyramidGameRules.canRemoveKing(selection: coveredKing, in: state)) + + let wasteSelection = Selection(source: .waste, cards: [wasteKing]) + XCTAssertTrue(PyramidGameRules.canRemoveKing(selection: wasteSelection, in: state)) + + let notAKing = Selection(source: .pyramid(index: 22), cards: [state.pyramid[22]!]) + XCTAssertFalse(PyramidGameRules.canRemoveKing(selection: notAKing, in: state)) + } + + func testRecycleRequiresEmptyStockAndRemainingPasses() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + let waste = [TestCards.make(.hearts, .nine)] + + XCTAssertTrue( + PyramidGameRules.canRecycleWaste( + in: GameStateFixtures.pyramidState(slots: slots, waste: waste, passesUsed: 0) + ) + ) + XCTAssertTrue( + PyramidGameRules.canRecycleWaste( + in: GameStateFixtures.pyramidState(slots: slots, waste: waste, passesUsed: 1) + ) + ) + XCTAssertFalse( + PyramidGameRules.canRecycleWaste( + in: GameStateFixtures.pyramidState(slots: slots, waste: waste, passesUsed: 2) + ) + ) + XCTAssertFalse( + PyramidGameRules.canRecycleWaste( + in: GameStateFixtures.pyramidState( + slots: slots, + stock: [TestCards.make(.clubs, .three)], + waste: waste + ) + ) + ) + XCTAssertFalse( + PyramidGameRules.canRecycleWaste( + in: GameStateFixtures.pyramidState(slots: slots) + ) + ) + } + + func testStateByApplyingRemovesAPyramidPairToTheDiscard() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.spades, .six) + let seven = TestCards.make(.hearts, .seven) + slots[21] = six + slots[22] = seven + let state = GameStateFixtures.pyramidState(slots: slots) + + let selection = Selection(source: .pyramid(index: 21), cards: [state.pyramid[21]!]) + guard let next = PyramidGameRules.stateByApplying( + selection: selection, + destination: .pyramid(22), + to: state + ) else { + return XCTFail("Expected a legal pair removal") + } + + XCTAssertNil(next.pyramid[21]) + XCTAssertNil(next.pyramid[22]) + XCTAssertEqual(next.discard.map(\.id), [six.id, seven.id]) + XCTAssertEqual(next.waste, state.waste) + } + + func testStateByApplyingRemovesAWastePairAndReclampsTheVisibleWaste() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.spades, .six) + slots[21] = six + let buried = TestCards.make(.diamonds, .four) + let seven = TestCards.make(.diamonds, .seven) + let state = GameStateFixtures.pyramidState(slots: slots, waste: [buried, seven]) + + let selection = Selection(source: .waste, cards: [seven]) + guard let next = PyramidGameRules.stateByApplying( + selection: selection, + destination: .pyramid(21), + to: state + ) else { + return XCTFail("Expected a legal waste pair removal") + } + + XCTAssertNil(next.pyramid[21]) + XCTAssertEqual(next.waste.map(\.id), [buried.id]) + XCTAssertEqual(next.wasteDrawCount, 1, "The uncovered waste card becomes playable") + XCTAssertEqual(next.discard.map(\.id), [seven.id, six.id]) + } + + func testStateByApplyingRemovesACoverPairInOneMove() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.clubs, .six) + let seven = TestCards.make(.spades, .seven) + slots[15] = six + slots[21] = seven + let state = GameStateFixtures.pyramidState(slots: slots) + + let selection = Selection(source: .pyramid(index: 15), cards: [state.pyramid[15]!]) + guard let next = PyramidGameRules.stateByApplying( + selection: selection, + destination: .pyramid(21), + to: state + ) else { + return XCTFail("Expected the cover-pair to be one legal move") + } + XCTAssertNil(next.pyramid[15]) + XCTAssertNil(next.pyramid[21]) + XCTAssertEqual(next.discard.count, 2) + } + + func testStateByApplyingRejectsIllegalMoves() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .eight) + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .five) + let state = GameStateFixtures.pyramidState(slots: slots) + + // 6 + 5 is not 13. + let six = Selection(source: .pyramid(index: 21), cards: [state.pyramid[21]!]) + XCTAssertNil(PyramidGameRules.stateByApplying(selection: six, destination: .pyramid(22), to: state)) + // The covered 8 cannot pair even though 8 + 5 is 13. + let eight = Selection(source: .pyramid(index: 15), cards: [state.pyramid[15]!]) + XCTAssertNil(PyramidGameRules.stateByApplying(selection: eight, destination: .pyramid(22), to: state)) + // A non-King cannot go to the discard alone. + XCTAssertNil(PyramidGameRules.stateByApplying(selection: six, destination: .discard, to: state)) + // Pyramid moves never apply to other variants' states. + let klondike = GameStateFixtures.seededKlondikeDeal(seed: 1) + XCTAssertNil( + PyramidGameRules.stateByApplying(selection: six, destination: .pyramid(22), to: klondike) + ) + } + + func testAdvisorGeneratesExactlyTheLegalPyramidMoves() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .eight) // covered by 21/22 + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .seven) + slots[23] = TestCards.make(.diamonds, .king) + let state = GameStateFixtures.pyramidState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + + let selections = AutoMoveAdvisor.candidateSelections(in: state) + // Waste top + the three exposed bottom-row cards; the covered 8 is not + // selectable (its covers do not form its pair). + XCTAssertEqual(selections.count, 4) + XCTAssertFalse(selections.contains { $0.source == .pyramid(index: 15) }) + + let sixSelection = Selection(source: .pyramid(index: 21), cards: [state.pyramid[21]!]) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: sixSelection, in: state), + [.pyramid(22), .waste] + ) + + let kingSelection = Selection(source: .pyramid(index: 23), cards: [state.pyramid[23]!]) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: kingSelection, in: state), + [.discard] + ) + + let wasteSelection = Selection(source: .waste, cards: [state.waste.last!]) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: wasteSelection, in: state), + [.pyramid(21)] + ) + + // Foundations never appear as destinations in Pyramid. + for selection in selections { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + if case .foundation = destination { + XCTFail("Pyramid moves must never target a foundation") + } + } + } + } + + func testSimulatedStateMatchesStateByApplying() { + let state = GameStateFixtures.seededPyramidDeal(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 = PyramidGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + XCTAssertEqual(simulated, applied, "Advisor and rules must agree on move effects") + } + } + } +} diff --git a/ComputerSolitaireTests/Pyramid/PyramidSessionTests.swift b/ComputerSolitaireTests/Pyramid/PyramidSessionTests.swift new file mode 100644 index 0000000..04086ed --- /dev/null +++ b/ComputerSolitaireTests/Pyramid/PyramidSessionTests.swift @@ -0,0 +1,285 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class PyramidSessionTests: XCTestCase { + private func makePyramidSession() -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .pyramid) + viewModel.newGame(variant: .pyramid) + return viewModel + } + + func testNewPyramidGameLayout() { + let state = GameState.newPyramidGame() + XCTAssertEqual(state.variant, .pyramid) + XCTAssertEqual(state.pyramid.count, PyramidGeometry.cardCount) + XCTAssertTrue(state.pyramid.allSatisfy { $0?.isFaceUp == true }) + XCTAssertEqual(state.stock.count, 24) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertTrue(state.waste.isEmpty) + XCTAssertTrue(state.tableau.isEmpty) + XCTAssertTrue(state.discard.isEmpty) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertEqual(state.wasteRecyclesUsed, 0) + XCTAssertFalse(state.isWon) + } + + func testSeededDealMatchesRealDealShape() { + let real = GameState.newPyramidGame() + let seeded = GameStateFixtures.seededPyramidDeal(seed: 1) + XCTAssertEqual(seeded.pyramid.count, real.pyramid.count) + XCTAssertEqual(seeded.stock.count, real.stock.count) + XCTAssertEqual(seeded.pyramid.compactMap { $0 }.allSatisfy(\.isFaceUp), true) + XCTAssertEqual(seeded.tableau, real.tableau) + } + + func testNewGameConfiguresDrawCounts() { + let viewModel = makePyramidSession() + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(viewModel.scoringDrawCount, DrawMode.three.rawValue) + XCTAssertFalse(viewModel.supportsDrawMode) + } + + func testStockTapDrawsOneCard() { + let viewModel = makePyramidSession() + let expectedCard = viewModel.state.stock.last + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.stock.count, 23) + XCTAssertEqual(viewModel.state.waste.count, 1) + XCTAssertEqual(viewModel.state.waste.last?.id, expectedCard?.id) + XCTAssertEqual(viewModel.state.waste.last?.isFaceUp, true) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1) + XCTAssertEqual(viewModel.visibleWasteCards().count, 1) + XCTAssertEqual(viewModel.movesCount, 1) + } + + func testStockRecyclesTwiceThenExhausts() { + let viewModel = makePyramidSession() + + for pass in 0...2 { + XCTAssertEqual(viewModel.state.wasteRecyclesUsed, pass) + while !viewModel.state.stock.isEmpty { + viewModel.handleStockTap() + } + XCTAssertEqual(viewModel.state.waste.count, 24) + viewModel.handleStockTap() + } + + // The third pass has been drawn through and no recycles remain: the last + // tap must not have recycled, and the stock is dead. + XCTAssertEqual(viewModel.state.wasteRecyclesUsed, 2) + XCTAssertTrue(viewModel.state.stock.isEmpty) + XCTAssertEqual(viewModel.state.waste.count, 24) + XCTAssertFalse(viewModel.canInteractWithStock) + XCTAssertEqual(viewModel.pyramidWasteRecyclesRemaining, 0) + } + + func testRecyclePreservesDrawOrder() { + let viewModel = makePyramidSession() + var firstPassOrder: [UUID] = [] + while !viewModel.state.stock.isEmpty { + viewModel.handleStockTap() + if let top = viewModel.state.waste.last { + firstPassOrder.append(top.id) + } + } + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state.wasteRecyclesUsed, 1) + + var secondPassOrder: [UUID] = [] + while !viewModel.state.stock.isEmpty { + viewModel.handleStockTap() + if let top = viewModel.state.waste.last { + secondPassOrder.append(top.id) + } + } + XCTAssertEqual(secondPassOrder, firstPassOrder, "Recycling must preserve draw order") + } + + func testPairMoveThroughTheSessionScoresAndIsUndoable() { + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.spades, .six) + let seven = TestCards.make(.hearts, .seven) + slots[21] = six + slots[22] = seven + slots[23] = TestCards.make(.clubs, .two) + viewModel.state = GameStateFixtures.pyramidState(slots: slots) + let stateBefore = viewModel.state + let scoreBefore = viewModel.score + + viewModel.selection = Selection(source: .pyramid(index: 21), cards: [six]) + XCTAssertTrue(viewModel.canDrop(to: .pyramid(22))) + XCTAssertTrue(viewModel.tryMoveSelection(to: .pyramid(22))) + + XCTAssertNil(viewModel.state.pyramid[21]) + XCTAssertNil(viewModel.state.pyramid[22]) + XCTAssertEqual(viewModel.state.discard.map(\.id), [six.id, seven.id]) + XCTAssertEqual(viewModel.score, scoreBefore + Scoring.delta(for: .removePyramidPair)) + XCTAssertEqual(viewModel.movesCount, 1) + XCTAssertNil(viewModel.selection) + + viewModel.undo() + XCTAssertEqual(viewModel.state, stateBefore) + XCTAssertEqual(viewModel.score, scoreBefore) + } + + func testKingTapAutoMovesToDiscard() { + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let king = TestCards.make(.diamonds, .king) + slots[21] = king + slots[22] = TestCards.make(.clubs, .two) + viewModel.state = GameStateFixtures.pyramidState(slots: slots) + + viewModel.handlePyramidTap(index: 21) + + guard let pending = viewModel.pendingAutoMove else { + return XCTFail("Tapping an exposed King should queue its removal") + } + XCTAssertEqual(pending.selection.source, .pyramid(index: 21)) + XCTAssertEqual(pending.destination, .discard) + } + + func testTapSelectThenTapPartnerRemovesThePair() { + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let five = TestCards.make(.spades, .five) + let eight = TestCards.make(.hearts, .eight) + let nine = TestCards.make(.clubs, .nine) + let four = TestCards.make(.diamonds, .four) + slots[21] = five + slots[22] = eight + slots[23] = nine + slots[24] = four + viewModel.state = GameStateFixtures.pyramidState(slots: slots) + + // With the 5 selected, tapping the 8 must complete that pair immediately + // rather than re-resolving the tapped card's own best move. + viewModel.selection = Selection(source: .pyramid(index: 21), cards: [five]) + viewModel.handlePyramidTap(index: 22) + + XCTAssertNil(viewModel.state.pyramid[21]) + XCTAssertNil(viewModel.state.pyramid[22]) + XCTAssertEqual(Set(viewModel.state.discard.map(\.id)), Set([five.id, eight.id])) + XCTAssertNotNil(viewModel.state.pyramid[23]) + XCTAssertNotNil(viewModel.state.pyramid[24]) + } + + func testTapSelectThenTapWasteRemovesTheChosenPair() { + // The waste 7 has two exposed partners; with the higher-slot 6 selected, + // tapping the waste must remove that chosen pair — not auto-move the + // waste card onto the other 6 the tap policy would prefer. + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let otherSix = TestCards.make(.spades, .six) + let chosenSix = TestCards.make(.clubs, .six) + let wasteSeven = TestCards.make(.diamonds, .seven) + slots[21] = otherSix + slots[23] = chosenSix + viewModel.state = GameStateFixtures.pyramidState(slots: slots, waste: [wasteSeven]) + + viewModel.selection = Selection(source: .pyramid(index: 23), cards: [chosenSix]) + viewModel.handleWasteTap() + + XCTAssertNil(viewModel.state.pyramid[23]) + XCTAssertNotNil(viewModel.state.pyramid[21]) + XCTAssertTrue(viewModel.state.waste.isEmpty) + XCTAssertEqual(Set(viewModel.state.discard.map(\.id)), Set([chosenSix.id, wasteSeven.id])) + XCTAssertNil(viewModel.pendingAutoMove) + } + + func testTappingCoveredCardClearsSelection() { + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[15] = TestCards.make(.clubs, .eight) + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .nine) + viewModel.state = GameStateFixtures.pyramidState(slots: slots) + + viewModel.selection = Selection(source: .pyramid(index: 21), cards: [slots[21]!]) + viewModel.handlePyramidTap(index: 15) + + XCTAssertNil(viewModel.selection) + XCTAssertNotNil(viewModel.state.pyramid[15]) + } + + func testWasteKingIsRemovableThroughTheSession() { + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + let king = TestCards.make(.diamonds, .king) + viewModel.state = GameStateFixtures.pyramidState(slots: slots, waste: [king]) + let scoreBefore = viewModel.score + + viewModel.selection = Selection(source: .waste, cards: [king]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .discard)) + + XCTAssertTrue(viewModel.state.waste.isEmpty) + XCTAssertEqual(viewModel.state.wasteDrawCount, 0) + XCTAssertEqual(viewModel.state.discard.last?.id, king.id) + XCTAssertEqual(viewModel.score, scoreBefore + Scoring.delta(for: .removePyramidKing)) + } + + func testWinRequiresOnlyTheClearedPyramid() { + let viewModel = makePyramidSession() + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.spades, .six) + let seven = TestCards.make(.hearts, .seven) + slots[21] = six + slots[22] = seven + viewModel.state = GameStateFixtures.pyramidState( + slots: slots, + stock: [TestCards.make(.clubs, .two), TestCards.make(.clubs, .three)], + fillDiscardFromRemainder: true + ) + XCTAssertFalse(viewModel.isWin) + + viewModel.selection = Selection(source: .pyramid(index: 21), cards: [six]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .pyramid(22))) + + XCTAssertTrue(viewModel.isWin, "Clearing the pyramid wins even with stock cards left") + XCTAssertFalse(viewModel.state.stock.isEmpty) + } + + func testUndoRestoresDrawAndRecycle() { + let viewModel = makePyramidSession() + + let freshState = viewModel.state + viewModel.handleStockTap() + viewModel.undo() + XCTAssertEqual(viewModel.state, freshState) + + while !viewModel.state.stock.isEmpty { + viewModel.handleStockTap() + } + let drawnOutState = viewModel.state + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state.wasteRecyclesUsed, 1) + viewModel.undo() + XCTAssertEqual(viewModel.state, drawnOutState) + XCTAssertEqual(viewModel.state.wasteRecyclesUsed, 0) + } + + func testHintAvailabilityTracksStockAndRecycles() { + let viewModel = makePyramidSession() + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: viewModel.state)) + + // A dead board: no pairs, no stock, no waste, no recycles left. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + slots[21] = TestCards.make(.spades, .six) + slots[22] = TestCards.make(.hearts, .nine) + let deadState = GameStateFixtures.pyramidState(slots: slots, passesUsed: 2) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: deadState)) + + // The same board with a recycle left keeps the button alive. + let liveState = GameStateFixtures.pyramidState( + slots: slots, + waste: [TestCards.make(.clubs, .two)], + passesUsed: 1 + ) + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: liveState)) + } +} diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index 2684d0a..40f1b30 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -204,6 +204,57 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("Yukon fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Pyramid board is a fresh deal with the first card drawn to the + /// waste. All 28 pyramid cards are visible, but the eye lands on the bottom + /// two rows, so seeds are scanned for the most photogenic spread there. + func testGeneratePyramidFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededPyramidDeal(seed: seed) + let score = pyramidDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededPyramidDeal(seed: seed) + viewModel.configurePyramidNewGame() + viewModel.handleStockTap() + + let savedAt = DateFixtures.reference + let payload = SavedGamePayload( + savedAt: savedAt, + state: viewModel.state, + movesCount: viewModel.movesCount, + score: viewModel.score, + gameStartedAt: savedAt.addingTimeInterval(-Self.stagedElapsedSeconds), + stockDrawCount: DrawMode.one.rawValue, + history: [], + hasStartedTrackedGame: false + ) + + XCTAssertNotNil(payload.sanitizedForRestore(), "Generated fixture failed the validity gate") + let restoredViewModel = SolitaireViewModel() + XCTAssertTrue(restoredViewModel.restore(from: payload), "Generated fixture failed to restore") + XCTAssertEqual(restoredViewModel.gameVariant, .pyramid, "Fixture did not restore as Pyramid") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("pyramid.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Pyramid fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -244,6 +295,28 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Pyramid deal by the bottom two pyramid rows (the fully and + /// nearly exposed cards the eye lands on): rank variety, red/black balance, + /// all four suits, a few face cards, and an exposed pair or King to suggest a + /// first move. + private func pyramidDealScore(of deal: GameState) -> Int { + let visible = deal.pyramid.suffix(13).compactMap { $0 } + let exposed = deal.pyramid.indices + .filter { PyramidGeometry.isExposed($0, in: deal.pyramid) } + .compactMap { deal.pyramid[$0] } + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + let exposedRanks = Set(exposed.map(\.rank.rawValue)) + let hasExposedPair = exposedRanks.contains { exposedRanks.contains(PyramidGameRules.pairSum - $0) } + score += hasExposedPair ? 8 : 0 + score += exposed.contains(where: { $0.rank == .king }) ? 4 : 0 + return score + } + /// Scores a fresh deal by the ten cards a first draw makes visible: the /// seven tableau tops plus the three stock cards that land in the waste. /// Rank variety, red/black balance, all four suits, and a couple of face diff --git a/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift b/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift index f386dcd..b5fa476 100644 --- a/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift +++ b/ComputerSolitaireTests/Shared/TapMovePolicyTests.swift @@ -201,6 +201,47 @@ final class TapMovePolicyTests: XCTestCase { XCTAssertTrue(TapMovePolicy.isSafeFoundationMove(card: TestCards.make(.hearts, .ace), in: state)) } + // MARK: - Pyramid destination preferences + + func testPyramidPairOnTheBoardBeatsAWastePair() { + // The 6 can pair with the exposed 7 on the board or the 7 on the waste; + // the board pair removes two pyramid cards, so it wins. + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.spades, .six) + slots[21] = six + slots[22] = TestCards.make(.hearts, .seven) + let state = GameStateFixtures.pyramidState( + slots: slots, + waste: [TestCards.make(.diamonds, .seven)] + ) + let selection = Selection(source: .pyramid(index: 21), cards: [six]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .pyramid(22)) + } + + func testPyramidEqualPartnersPreferTheLowestSlot() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let six = TestCards.make(.spades, .six) + slots[21] = six + slots[22] = TestCards.make(.hearts, .seven) + slots[23] = TestCards.make(.clubs, .seven) + let state = GameStateFixtures.pyramidState(slots: slots) + let selection = Selection(source: .pyramid(index: 21), cards: [six]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .pyramid(22)) + } + + func testPyramidKingResolvesToTheDiscard() { + var slots = [Card?](repeating: nil, count: PyramidGeometry.cardCount) + let king = TestCards.make(.diamonds, .king) + slots[21] = king + slots[22] = TestCards.make(.hearts, .seven) + let state = GameStateFixtures.pyramidState(slots: slots) + let selection = Selection(source: .pyramid(index: 21), cards: [king]) + + XCTAssertEqual(TapMovePolicy.bestDestination(for: selection, in: state), .discard) + } + // MARK: - Helpers private func freeCellState( diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index b7599ed..48f68d5 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -104,6 +104,76 @@ enum GameStateFixtures { ) } + /// A reproducible Pyramid deal matching the shape of `GameState.newPyramidGame`. + static func seededPyramidDeal(seed: UInt64) -> GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var pyramid: [Card?] = [] + for _ in 0.. GameState { + var pyramid = slots + if pyramid.count < PyramidGeometry.cardCount { + pyramid.append( + contentsOf: [Card?](repeating: nil, count: PyramidGeometry.cardCount - pyramid.count) + ) + } + pyramid = pyramid.map { card in + card.map { placed in + var faceUp = placed + faceUp.isFaceUp = true + return faceUp + } + } + var discard: [Card] = [] + if fillDiscardFromRemainder { + func identity(_ card: Card) -> Int { + (Suit.allCases.firstIndex(of: card.suit) ?? 0) * 16 + card.rank.rawValue + } + let usedIdentities = Set((pyramid.compactMap { $0 } + stock + waste).map(identity)) + discard = TestCards.fullDeck(faceUp: true).filter { card in + !usedIdentities.contains(identity(card)) + } + } + return GameState( + variant: .pyramid, + stock: stock, + waste: waste, + wasteDrawCount: min(1, waste.count), + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: [], + pyramid: pyramid, + discard: discard, + wasteRecyclesUsed: passesUsed + ) + } + private static func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { var generator = SeededRandomNumberGenerator(seed: seed) var deck = TestCards.fullDeck(faceUp: faceUp) diff --git a/README.md b/README.md index 3d104fc..d2a014a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS -- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, and **Yukon** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **FreeCell**, **Yukon**, and **Pyramid** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -25,3 +25,4 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | **Klondike** | Classic Solitaire, with 1-card and 3-card draw modes | [Rules](docs/solitaire-rules-klondike.md) | | **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/solitaire-rules-freecell.md) | | **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/solitaire-rules-yukon.md) | +| **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/solitaire-rules-pyramid.md) | diff --git a/docs/solitaire-rules-pyramid.md b/docs/solitaire-rules-pyramid.md new file mode 100644 index 0000000..0354a29 --- /dev/null +++ b/docs/solitaire-rules-pyramid.md @@ -0,0 +1,64 @@ +# Pyramid Rules + +These rules describe Pyramid as implemented in the app: pairs of exposed cards totaling 13 are removed from a 28-card pyramid, with a draw-one stock and up to three passes. The published sources disagree on several points; the choices made here (and why) are called out below. + +## Objective +Remove all 28 pyramid cards by discarding exposed pairs whose ranks total 13, and Kings alone. The stock and waste do **not** need to be emptied. + +## Terminology +- **Pyramid:** Twenty-eight face-up cards in seven overlapping rows; each card except the bottom row is covered by two cards below it. +- **Exposed:** A card with neither covering card remaining. Only exposed cards can be played. +- **Stock:** The face-down draw pile (24 cards after the deal). +- **Waste:** Face-up cards drawn from the stock; only the top card is playable. +- **Discard:** Where removed pairs and Kings go; cards there are out of play permanently. +- **Recycle:** Turning the exhausted stock's waste back into the stock for another pass. + +## Card Values +Ace = 1, number cards = face value, Jack = 11, Queen = 12, King = 13. + +## Setup +- Use a standard 52-card deck (no jokers). +- **Pyramid:** Deal 28 cards face up in seven rows — one card in the first row, two in the second, and so on to seven — each row overlapping the row above. +- **Stock:** The remaining 24 cards, face down. + +## Play +- Remove any two **exposed** cards whose ranks total 13: two pyramid cards, or a pyramid card and the top waste card. +- **Kings** total 13 alone and are removed singly. +- **Cover pair:** a pyramid card whose only remaining cover is its rank-13 partner (itself exposed) may be removed together with it in one move. +- Tap the stock to draw **one** card to the waste. +- When the stock is empty, the waste may be recycled into the stock — at most **twice** (three passes total). Recycling preserves draw order. +- Gaps in the pyramid are never refilled, and there is no building. + +## Scoring +- Removing a pair: +10. +- Removing a King: +5. +- No recycle penalty — the pass limit is the cost. +- On a win, a time bonus is added (same basis as the other stockless-choice variants). + +## Winning +You win the moment the last pyramid card is removed, regardless of the stock and waste. + +## Rule choices +The linked sources disagree; this implementation uses: +- **Win = pyramid cleared** (cardgames.io, solitaired.com, and most digital implementations), not Wikipedia's strict all-52-cards variant (~1 in 50 winnable). +- **Three passes** through the stock (solitaired.com; Wikipedia's "Par Pyramid"), not one pass (strict) or unlimited (cardgames.io). +- **Cover pairs allowed** (cardgames.io and most digital implementations). + +## Solver-backed hints +Pyramid is a perfect-information game once dealt, so `PyramidPlanner` searches the exact position graph: stage one runs weighted A* for a full winning line, with a partner-count prune that can prove a deal unwinnable; stage two finds the line clearing the most pyramid cards when no win exists, and hints follow it — unlike the other variants, lost Pyramid deals are common and still played for cards cleared. Hints go silent only when not one more pyramid 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 **80.2%** against a **15.2%** random-control floor, with zero loops and a +median winning game of 67 moves. The solver's own verdict sweep at its default +budget proves **79.5%** of deals winnable and **0.8%** unwinnable, with 19.8% +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/Pyramid_(solitaire) +- https://cardgames.io/pyramidsolitaire/ +- https://solitaired.com/pyramid-solitaire diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index 5cc530e..a6a3adc 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -22,6 +22,7 @@ tools/hint-probe/run.sh yukon 500 tools/hint-probe/run.sh klondike 500 1 # third arg is the draw count tools/hint-probe/run.sh klondike 500 3 tools/hint-probe/run.sh freecell 500 +tools/hint-probe/run.sh pyramid 500 ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -58,6 +59,7 @@ consecutive runs, serial and parallel. | `klondike` draw-1 | **44.4%** | 39.4% | | `klondike` draw-3 | **24.0%** | 6.0% | | `freecell` | **99.8%** | 0.2% | +| `pyramid` | **80.2%** | 15.2% | Reading the table honestly: @@ -76,6 +78,14 @@ Reading the table honestly: within its node budget; the follower classifies it as a deadlock because the nudge fallback only circles there (a solved line is finite and cannot loop, so any plan-line revisit would be a real bug and trips the gate). +- **Pyramid (80.2% vs 15.2%)**: the solver's own verdict sweep proves 79.5% of + deals winnable at its default budget (0.8% proved unwinnable, 19.8% undecided + — hard deals whose reachable graphs exceed the budget), so the follower + converts essentially every deal the search can prove. Losses record pyramid + cards cleared instead of foundation cards (Pyramid banks no foundations; + 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. - 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 fa5dbc2..41c8a75 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -93,6 +93,25 @@ func seededDeal(variant: GameVariant, seed: UInt64) -> GameState { foundations: Array(repeating: [], count: 4), tableau: tableau ) + + case .pyramid: + var deck = seededDeck(seed: seed, faceUp: false) + var pyramid: [Card?] = [] + for _ in 0.. UInt64 { mix(0xFD) for card in pile { mix(card: card) } } + for slot in state.pyramid { + mix(0xFB) + if let card = slot { mix(card: card) } + } + // Section separator: without it, clearing the last pyramid slot to the + // discard leaves the byte stream unchanged and reads as a false revisit. + mix(0xFA) + for card in state.discard { mix(card: card) } + mix(UInt8(min(255, max(0, state.wasteRecyclesUsed)))) return hash } @@ -140,6 +168,16 @@ func apply( ) } +/// Mirrors handlePyramidStockTap / recyclePyramidWaste in the session: draw one, +/// or recycle within the pass limit. The planner's apply is the same pure logic. +func pyramidStockTap(_ state: GameState) -> GameState? { + PyramidPlanner.apply(state.stock.isEmpty ? .resetStock : .draw, to: state) +} + +func pyramidCleared(_ state: GameState) -> Int { + state.pyramid.count(where: { $0 == nil }) +} + /// Mirrors drawFromStock / recycleWaste in the session. func stockTap(_ state: GameState, drawCount: Int) -> GameState? { var next = state @@ -172,7 +210,8 @@ enum Outcome { } /// Yukon/FreeCell games finish or die well under this; Klondike needs headroom -/// for stock cycling. +/// for stock cycling. (Pyramid is structurally bounded near 100 actions: three +/// 24-card passes, two resets, and at most 26 removal moves.) func actionCap(for variant: GameVariant) -> Int { variant == .klondike ? 1_200 : 600 } @@ -305,6 +344,56 @@ func playYukonFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: return (.actionCap(foundation: foundationCount(state)), revisitEvents) } +func playPyramidFollowingHints(seed: UInt64) -> Outcome { + // Replicates HintPlanner's Pyramid path without its wall-clock deadline: + // follow each planned line — winning or max-clear — to its end, then replan; + // noProgress means not one more pyramid card is clearable. Every Pyramid move + // advances a monotone quantity, so for this deterministic follower any + // revisit is a proven infinite loop. The loss column records pyramid cards + // cleared (Pyramid banks no foundations). + var state = seededDeal(variant: .pyramid, seed: seed) + var plan: [String: PyramidPlanner.Move] = [:] + var seen: Set = [fingerprint(state)] + var actions = 0 + while actions < actionCap(for: .pyramid) { + if state.isWon { return .win(moves: actions) } + + let key = PyramidPlanner.stateKey(for: state) + var hint = plan[key].flatMap { PyramidPlanner.materialize($0, in: state) } + if hint == nil { + plan.removeAll() + switch PyramidPlanner.bestLine(in: state) { + case .winningLine(let line), .bestEffortLine(let line, _): + plan = PyramidPlanner.keyedMoves(along: line, from: state) + case .noProgress: + return .deadlock(foundation: pyramidCleared(state)) + } + hint = plan[key].flatMap { PyramidPlanner.materialize($0, in: state) } + } + guard let hint else { + return .deadlock(foundation: pyramidCleared(state)) + } + + switch hint { + case .move(let move): + guard let next = apply(move.selection, move.destination, to: state, stockDrawCount: 1) else { + fatalError("Seed \(seed): illegal Pyramid hint") + } + state = next + case .stockTap: + guard let next = pyramidStockTap(state) else { + fatalError("Seed \(seed): pyramid stock tap with nothing to tap") + } + state = next + } + actions += 1 + if !seen.insert(fingerprint(state)).inserted { + return .stalemateLoop(foundation: pyramidCleared(state)) + } + } + return .actionCap(foundation: pyramidCleared(state)) +} + // MARK: - Control player // The random-moves floor calibrates each variant's deal universe. Deliberately @@ -320,6 +409,9 @@ func playRandom(variant: GameVariant, seed: UInt64, drawCount: Int) -> Outcome { // for them a revisit is a proven infinite loop.) var state = seededDeal(variant: variant, seed: seed) var generator = SeededRandomNumberGenerator(seed: seed ^ 0xDEADBEEF) + let lossProgress: (GameState) -> Int = variant == .pyramid + ? pyramidCleared + : foundationCount var actions = 0 while actions < actionCap(for: variant) { if state.isWon { return .win(moves: actions) } @@ -331,13 +423,24 @@ func playRandom(variant: GameVariant, seed: UInt64, drawCount: Int) -> Outcome { legal.append((selection, destination)) } } - let canTapStock = variant == .klondike && (!state.stock.isEmpty || !state.waste.isEmpty) + let canTapStock: Bool + switch variant { + case .klondike: + canTapStock = !state.stock.isEmpty || !state.waste.isEmpty + case .pyramid: + canTapStock = !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) + case .freecell, .yukon: + canTapStock = false + } let choices = legal.count + (canTapStock ? 1 : 0) - guard choices > 0 else { return .deadlock(foundation: foundationCount(state)) } + guard choices > 0 else { return .deadlock(foundation: lossProgress(state)) } let pick = Int(generator.next() % UInt64(choices)) if pick == legal.count { - guard let next = stockTap(state, drawCount: drawCount) else { + let tapped = variant == .pyramid + ? pyramidStockTap(state) + : stockTap(state, drawCount: drawCount) + guard let next = tapped else { fatalError("Seed \(seed): random stock tap with nothing to tap") } state = next @@ -349,12 +452,17 @@ func playRandom(variant: GameVariant, seed: UInt64, drawCount: Int) -> Outcome { } actions += 1 } - return .actionCap(foundation: foundationCount(state)) + return .actionCap(foundation: lossProgress(state)) } // MARK: - Reporting -func summarize(_ name: String, outcomes: [(UInt64, Outcome)]) { +func summarize( + _ name: String, + outcomes: [(UInt64, Outcome)], + lossProgressLabel: String = "foundation-at-loss", + tracksOverBanking: Bool = true +) { var wins = 0 var deadlocks = 0 var loops = 0 @@ -391,7 +499,11 @@ func summarize(_ name: String, outcomes: [(UInt64, Outcome)]) { } if !lossFoundations.isEmpty { let sorted = lossFoundations.sorted() - print("foundation-at-loss: median=\(sorted[sorted.count / 2]), losses with >=40 banked: \(highBankLosses)") + var line = "\(lossProgressLabel): median=\(sorted[sorted.count / 2])" + if tracksOverBanking { + line += ", losses with >=40 banked: \(highBankLosses)" + } + print(line) } } @@ -441,7 +553,12 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { label = "freecell" case .yukon: label = "yukon" + case .pyramid: + label = "pyramid" } + // 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 start = DispatchTime.now() let followerResults = mapInParallel( @@ -455,6 +572,8 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { return (playFreeCellFollowingHints(seed: seed), 0) case .yukon: return playYukonFollowingHints(seed: seed) + case .pyramid: + return (playPyramidFollowingHints(seed: seed), 0) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -466,7 +585,12 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { return false } - summarize("\(label) — following every hint", outcomes: followerOutcomes) + summarize( + "\(label) — following every hint", + outcomes: followerOutcomes, + lossProgressLabel: lossProgressLabel, + tracksOverBanking: tracksOverBanking + ) print(String(format: "elapsed: %.1fs", seconds)) if variant == .yukon { print("hint revisit events: \(revisitEvents)") @@ -488,7 +612,9 @@ func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { } summarize( "\(label) — random legal moves (control)", - outcomes: zip(1...seeds, randomResults).map { ($0, $1) } + outcomes: zip(1...seeds, randomResults).map { ($0, $1) }, + lossProgressLabel: lossProgressLabel, + tracksOverBanking: tracksOverBanking ) } @@ -506,7 +632,7 @@ var gateViolations = 0 setvbuf(stdout, nil, _IOLBF, 0) func exitWithUsage() -> Never { - print("usage: run.sh [deals >= 1] [klondike draw count: 1 or 3]") + print("usage: run.sh [deals >= 1] [klondike draw count: 1 or 3]") exit(1) } @@ -527,11 +653,14 @@ case "klondike": run(variant: .klondike, seeds: seeds, drawCount: draw) case "freecell": run(variant: .freecell, seeds: seeds, drawCount: 3) +case "pyramid": + run(variant: .pyramid, seeds: seeds, drawCount: 1) case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) run(variant: .klondike, seeds: seeds, drawCount: 3) run(variant: .freecell, seeds: seeds, drawCount: 3) + run(variant: .pyramid, seeds: seeds, drawCount: 1) default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index 3142b9a..4bbf7cf 100755 --- a/tools/hint-probe/run.sh +++ b/tools/hint-probe/run.sh @@ -1,6 +1,6 @@ #!/bin/bash # Compiles the hint-quality probe against the UI-free Game sources and runs it. -# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count] +# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count] set -euo pipefail cd "$(dirname "$0")/../.." @@ -27,6 +27,11 @@ SOURCES=( ComputerSolitaire/Game/Yukon/GameRulesYukon.swift ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift ComputerSolitaire/Game/Yukon/YukonPlanner.swift + ComputerSolitaire/Game/Pyramid/PyramidGeometry.swift + ComputerSolitaire/Game/Pyramid/GameStatePyramid.swift + ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift + ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift + ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift ) for source in "${SOURCES[@]}"; do From ad54077add3447b639ce5878b8e23b9f98e36dd3 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 15:14:17 -0700 Subject: [PATCH 2/3] reject stranded pyramid cards in spider persistence validation --- ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift index bdaa30b..772f36c 100644 --- a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift +++ b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift @@ -10,6 +10,11 @@ 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. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { + return false + } guard state.wasteDrawCount == 0 else { return false } return state.foundations.allSatisfy(isValidFoundationPile) } From 9d2883d95360504d56f95947bd20b456fcd2ee9a Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 15:21:16 -0700 Subject: [PATCH 3/3] restore spider's win time bonus scoring row --- ComputerSolitaire/Views/RulesAndScoringView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index 546558a..685b271 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -359,6 +359,11 @@ struct RulesAndScoringView: View { ), ScoringRow(move: "Any move or stock deal", points: Scoring.delta(for: .spiderMove), note: nil), ScoringRow(move: "Complete a run", points: Scoring.delta(for: .spiderCompletedRun), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) ] case .pyramid: return [