From 58beacaa1b17978d6733d102c30864fdc8801954 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 13 Jul 2026 18:46:06 -0700 Subject: [PATCH] add canfield game variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strict classic rules: a thirteen-card reserve with compulsory space fills, foundations built up by suit from a dealt base rank with wrap in both directions, whole-pile tableau moves only, and a draw-three stock with unlimited redeals onto locked foundations. hints follow cached improving lines from a bounded best-first planner whose exhausted search — taps and recycles included — proves a dead position; the probe baseline is 25.0% following every hint vs 1.2% random over 500 deals with zero loops and zero revisits. every other variant's persistence rules now reject cards stranded in the new reserve field, and the macos game menu no longer crashes assembling a digit shortcut for a tenth variant. --- .../Fixtures/ScreenshotFixtures.swift | 3 +- ComputerSolitaire/Fixtures/canfield.json | 588 ++++++++++++++++++ .../Canfield/AutoMoveAdvisorCanfield.swift | 55 ++ .../Game/Canfield/CanfieldPlanner.swift | 420 +++++++++++++ .../Canfield/GamePersistenceCanfield.swift | 55 ++ .../Game/Canfield/GameRulesCanfield.swift | 145 +++++ .../Game/Canfield/GameSessionCanfield.swift | 112 ++++ .../Game/Canfield/GameStateCanfield.swift | 43 ++ .../FortyThieves/FortyThievesPlanner.swift | 2 +- .../GamePersistenceFortyThieves.swift | 3 + .../FreeCell/GamePersistenceFreeCell.swift | 3 + .../Game/Golf/GamePersistenceGolf.swift | 3 + .../Game/Klondike/AutoFinishPlanner.swift | 68 +- .../Klondike/GamePersistenceKlondike.swift | 3 + .../Game/Klondike/GameSessionKlondike.swift | 30 - .../Game/Klondike/KlondikePlanner.swift | 2 +- .../Game/Pyramid/AutoMoveAdvisorPyramid.swift | 2 +- .../Game/Pyramid/GamePersistencePyramid.swift | 3 + .../Game/Pyramid/GameRulesPyramid.swift | 2 +- .../Scorpion/GamePersistenceScorpion.swift | 3 + .../Game/Shared/AutoMoveAdvisor.swift | 45 +- ComputerSolitaire/Game/Shared/GameMode.swift | 9 +- .../Game/Shared/GamePersistence.swift | 16 +- .../Game/Shared/GameRulesShared.swift | 22 + .../Game/Shared/GameSession.swift | 74 ++- .../Game/Shared/GameSessionInteraction.swift | 22 +- ComputerSolitaire/Game/Shared/GameState.swift | 16 +- .../Game/Shared/GameVariant.swift | 29 +- .../Game/Shared/HintAdvisor.swift | 49 ++ ComputerSolitaire/Game/Shared/MoveTypes.swift | 3 + ComputerSolitaire/Game/Shared/Scoring.swift | 6 + .../Game/Shared/TapMovePolicy.swift | 15 + .../Game/Spider/GamePersistenceSpider.swift | 3 + .../TriPeaks/GamePersistenceTriPeaks.swift | 3 + .../Game/Yukon/GamePersistenceYukon.swift | 3 + .../Game/Yukon/YukonPlanner.swift | 4 +- ComputerSolitaire/GameMenuCommands.swift | 24 +- .../Interaction/BoardInteractionTypes.swift | 1 + .../Views/Canfield/CanfieldTopRowView.swift | 228 +++++++ .../Views/RulesAndScoringView.swift | 44 ++ .../Views/Shared/BoardViews.swift | 38 +- .../Views/Shared/ContentView.swift | 25 +- .../Views/Shared/GameModePickerView.swift | 25 +- ComputerSolitaire/Views/StatisticsView.swift | 2 +- .../Canfield/CanfieldPersistenceTests.swift | 235 +++++++ .../Canfield/CanfieldPlannerTests.swift | 225 +++++++ .../Canfield/CanfieldRulesTests.swift | 394 ++++++++++++ .../Canfield/CanfieldSessionTests.swift | 270 ++++++++ .../SavedGamePayloadSanitizationTests.swift | 40 ++ .../Shared/ScreenshotFixtureTests.swift | 74 +++ ComputerSolitaireTests/TestSupport.swift | 117 ++++ README.md | 3 +- docs/rules/canfield.md | 66 ++ tools/hint-probe/README.md | 35 +- tools/hint-probe/main.swift | 143 ++++- tools/hint-probe/run.sh | 6 +- 56 files changed, 3753 insertions(+), 106 deletions(-) create mode 100644 ComputerSolitaire/Fixtures/canfield.json create mode 100644 ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift create mode 100644 ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift create mode 100644 ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift create mode 100644 ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift create mode 100644 ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift create mode 100644 ComputerSolitaire/Game/Canfield/GameStateCanfield.swift create mode 100644 ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift create mode 100644 ComputerSolitaireTests/Canfield/CanfieldPersistenceTests.swift create mode 100644 ComputerSolitaireTests/Canfield/CanfieldPlannerTests.swift create mode 100644 ComputerSolitaireTests/Canfield/CanfieldRulesTests.swift create mode 100644 ComputerSolitaireTests/Canfield/CanfieldSessionTests.swift create mode 100644 docs/rules/canfield.md diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index 0dd1ab1..ec73bb8 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -37,7 +37,8 @@ enum ScreenshotFixtures { ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal"), ScreenshotFixture(name: "golf", title: "Golf – fresh deal"), ScreenshotFixture(name: "fortythieves", title: "Forty Thieves – fresh deal"), - ScreenshotFixture(name: "scorpion", title: "Scorpion – fresh deal") + ScreenshotFixture(name: "scorpion", title: "Scorpion – fresh deal"), + ScreenshotFixture(name: "canfield", title: "Canfield – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/canfield.json b/ComputerSolitaire/Fixtures/canfield.json new file mode 100644 index 0000000..db53967 --- /dev/null +++ b/ComputerSolitaire/Fixtures/canfield.json @@ -0,0 +1,588 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 1, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 3, + "state" : { + "discard" : [ + + ], + "foundations" : [ + [ + { + "id" : "0754843C-C5C0-4DD3-8D14-F73227FD40F1", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "pyramid" : [ + + ], + "reserve" : [ + { + "id" : "5D6AB24E-8427-4F1B-A55F-FF8F825811A8", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "C6AF7AF4-88FE-4D94-B81E-3EB89BF164D6", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "7FC07A9E-42FC-4592-B1F3-987024CAF5F9", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "55BBEA22-30A9-4762-90E9-16DFBC066512", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "2F6A1DC0-27C3-4FC8-95E2-9455CC3EDD0B", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "682872E8-87DB-4698-8168-570DD2BEECDC", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "130184F9-8B6E-4701-A6F3-E146BCCFE180", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "B600DCCB-4E75-4E9C-B160-F45C14E90439", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "7A39FC51-1C05-472C-9E74-8E5FBFA1A1D6", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "C9125743-81C5-4AF0-A88D-2B610379AD4C", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0215283C-B33B-4233-B5DD-AA56225C3D19", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "A8A3DEAA-4D51-4795-B6B5-C04D29E69279", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "0A72A850-A3A5-4A4A-B628-FE02374E1D29", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + } + ], + "stock" : [ + { + "id" : "7EE04404-ABEB-4A73-AE45-4808BB0F16E1", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "9C5D2DDB-06FF-4781-91C1-8C6CE2B22C9E", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "52208EF6-C751-44D4-9B99-8EE222CA030A", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "45CDB7B4-3B32-415C-985C-E714CCB42599", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F77CBB2B-44F1-4B82-B00B-B9BB88B7B20B", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "687EF259-5ED1-4EDD-BEEF-F7F3411A7DC8", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "32F6D7F3-0EBA-477F-986D-53EBF57B65B9", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "CDA8225D-9D18-46C4-AC2E-24974A3407DE", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "EFA6ED42-63D3-4CE2-B15F-0294E0AC5D3E", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "71C0B608-D7C8-4F8D-A49E-1FFE5DF8780F", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E151EE5C-6C95-4044-AE53-8919B1D2298A", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BBD55D74-F97F-4CBB-831F-30A1396825B9", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "1FF7A549-4D6E-4400-A94E-C2688B38B865", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "49084F96-7098-491A-920F-41252928E525", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "3775A9CC-2B39-4265-98F7-2AB675F8D11F", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "DE92E42C-7B36-44EA-889A-1BF5E378A74D", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "9D176CF7-E012-422B-91CC-8FBE7C89CB92", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "AE7D00ED-9532-488F-964A-EE8581790C94", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "391C3609-8B7C-4298-B5BA-E1B155A2E164", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "B7E863C1-2E63-42F7-99CB-681797C45D54", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "ECBDD9BE-F335-46F6-8290-AA5D113234DB", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "EF566233-D315-437A-98C4-D0E8726F9C0F", + "isFaceUp" : false, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "37C416C8-651D-4190-8621-B73BA1FBC315", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "A6EBB803-BC0C-43FF-A051-1518FA57C520", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "EF726375-8CD8-446A-9473-212BC6651A99", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "3E4D75C9-151B-4EFE-A4BD-4779CFF14E91", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C536D8B7-F1FA-48BB-887F-94B1FB506F6B", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "80EEB9C2-B834-46CA-8ABD-A3405EE73A3B", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "6F924FDA-6277-4E9D-8FAA-DB2AF6C4AFD4", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "208803A4-5A8E-48D6-A3C6-6F0221EEC8E8", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "211863AE-ACAA-4EF2-BF07-897971724FEE", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + } + ], + "tableau" : [ + [ + { + "id" : "7BDD2F5A-6C64-4E4D-8894-BCD93BF03AB9", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "E19EEF98-3CAA-4D01-9B76-B688957EA84B", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "7C025F46-4B5D-4986-A193-3381E76E8035", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "1E673EE7-C46B-4CBA-B716-BA9BDF44B71A", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + } + ] + ], + "triPeaks" : [ + + ], + "triPeaksChainLength" : 0, + "variant" : "canfield", + "waste" : [ + { + "id" : "C3706E3B-38AE-4133-89DA-7631E8BCC38F", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "D1757803-E03D-4C78-AC33-5AAC35C4A8FF", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "EE6E053B-81C2-48EF-A19D-A20B0E3F4B93", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + } + ], + "wasteDrawCount" : 3, + "wasteRecyclesUsed" : 0 + }, + "stockDrawCount" : 3, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift b/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift new file mode 100644 index 0000000..7a5c371 --- /dev/null +++ b/ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift @@ -0,0 +1,55 @@ +import Foundation + +enum CanfieldAutoMoveAdvisor { + static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + // Canfield's defining transfer rule: a pile moves between tableau + // piles only in its entirety, and its exposed top card plays to a + // foundation. Anything between — a sequence lifted off the middle of + // a pile — never moves, so only those two pickups exist. + guard let first = cards.first else { return false } + if cards.count == 1 { return true } + guard CanfieldGameRules.isPackedSequence(cards) else { return false } + return state.tableau.contains { pile in + pile.first?.id == first.id && pile.count == cards.count + } + } + + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + guard state.tableau.indices.contains(destinationTableauIndex) else { return false } + return CanfieldGameRules.allowsTableauTransfer( + selection: selection, + destinationPile: state.tableau[destinationTableauIndex], + in: state + ) + } + + static func isRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + // The only selection that may take a space is the top waste card, and + // playing it there is real progress; tableau piles never target + // spaces, so the whole-pile shuffle the other variants filter out + // cannot arise. + false + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + // Canfield has no auxiliary destination type beyond tableau/foundation. + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + // The compulsory fill: an emptied pile takes the reserve's exposed + // card at once, so spaces only ever persist after the reserve is out. + CanfieldGameRules.refillEmptyPileFromReserve(on: &state, pileIndex: pileIndex) + } +} diff --git a/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift b/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift new file mode 100644 index 0000000..a33ca52 --- /dev/null +++ b/ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift @@ -0,0 +1,420 @@ +import Foundation + +/// Bounded best-first hint planner for Canfield. +/// +/// Searches sequences of real actions — whole-pile tableau moves, waste and +/// reserve plays, foundation builds, and stock taps — up to a node/time +/// budget, scoring positions by foundation cards, reserve cards freed, +/// developed stock/waste cards, and how deeply the next foundation-needed +/// cards are buried. `bestLine` returns the whole action sequence to the best +/// position found that strictly improves on the current one; `HintPlanner` +/// follows the cached line action by action: like Forty Thieves, every +/// Canfield tableau move is reversible until a card builds or the reserve +/// turns, so re-search after each move can oscillate between equally +/// attractive lines, while following one improving line ratchets the position +/// strictly forward. +/// +/// The search reads the true state, including the face-down stock and reserve +/// order, but it only ever recommends actions that are legal right now. +/// Searching through stock taps is what lets the planner line up the plays a +/// buried stock card enables *before* recommending the tap; the tap itself is +/// score-neutral, so tap-crossing lines only win when the plays they enable +/// pay for them. Unlike Forty Thieves' single pass, a tap on the spent stock +/// recycles the waste, so taps alone can cycle back to an earlier position — +/// the visited set is what keeps searched lines loop-free, and an exhausted +/// search (recycles included) is a proof the position cannot progress at all. +/// Foundations are locked and `candidateSelections` offers no foundation +/// sources for rollback-free variants, so there is no rollback stage. +/// +/// Measured over 500 seeded deals in the hint probe (the ledger in +/// `tools/hint-probe/README.md` is the regression baseline): following every +/// hint wins 25.0% of games versus the 1.2% random control, with zero +/// stalemate loops, zero exact-position revisits, and every loss an honest +/// deadlock proven by an exhaustive search. +enum CanfieldPlanner { + struct Limits { + var maxNodes: Int + var maxDepth: Int + var deadline: Date? + + // Canfield branches modestly (six sources at most, few legal landings + // each, plus the tap), but its lines cross many taps — a full pass + // over the stock is a dozen — so it keeps Forty Thieves' node budget + // with a deeper horizon. + init(maxNodes: Int = 30_000, maxDepth: Int = 96, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.maxDepth = maxDepth + self.deadline = deadline + } + } + + enum PlannedAction { + case move(selection: Selection, destination: Destination) + case stockTap + } + + enum SearchOutcome { + /// Actions leading to the best strictly-improving position found. + case line([PlannedAction]) + /// Nothing within the horizon improves on the current position. When + /// the search ran out of reachable states — rather than nodes, depth, + /// or time — that is proof the position cannot progress at all, since + /// taps and recycles were searched too. + case noProgress(searchWasExhaustive: Bool) + } + + static func bestHint(in state: GameState, limits: Limits = Limits()) -> HintAdvisor.Hint? { + guard case .line(let actions) = bestLine(in: state, limits: limits), + let action = actions.first else { + return nil + } + return materialize(action, in: state) + } + + /// Exact (non-canonical) position key, stable across `Card` identities; used to + /// look up the cached line as the player follows it. + static func stateKey(for state: GameState) -> String { + var key = String() + key.reserveCapacity(256) + func append(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + key.append(String(UnicodeScalar(UInt8(65 + suitValue * 2 + (card.isFaceUp ? 1 : 0))))) + key.append(String(UnicodeScalar(UInt8(97 + card.rank.rawValue)))) + } + // Recycling rebuilds the stock from whatever the waste held, so unlike + // Forty Thieves the stock's count does not identify its contents — it + // is spelled out in full, as is the waste. The reserve only ever + // shrinks off its fixed dealt order, so its count is exact. + for card in state.stock { append(card: card) } + key.append("#\(state.reserve.count)~") + for card in state.waste { append(card: card) } + for pile in state.foundations { + key.append("|") + for card in pile { append(card: card) } + } + for pile in state.tableau { + key.append("/") + for card in pile { append(card: card) } + } + return key + } + + /// Maps each position along the line to the action to play there, so consecutive + /// hints are instant while the player follows (or plays ahead along) the line. + static func keyedActions( + along line: [PlannedAction], + from state: GameState + ) -> [String: PlannedAction] { + var keyed: [String: PlannedAction] = [:] + var current = state + for action in line { + keyed[stateKey(for: current)] = action + guard let next = apply(action, to: current) else { break } + current = next + } + return keyed + } + + /// Re-validates a planned action against the live state; a stale cached + /// action surfaces as nil (and triggers a fresh search) rather than as an + /// illegal hint. + static func materialize(_ action: PlannedAction, in state: GameState) -> HintAdvisor.Hint? { + switch action { + case .move(let selection, let destination): + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state), + AutoMoveAdvisor.legalDestinations(for: selection, in: state) + .contains(destination) else { + return nil + } + return .move(HintAdvisor.HintMove(selection: selection, destination: destination)) + case .stockTap: + guard !state.stock.isEmpty || !state.waste.isEmpty else { return nil } + return .stockTap + } + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .canfield else { return .noProgress(searchWasExhaustive: false) } + return search(in: state, limits: limits) + } + + /// Applies an action without re-validating legality: the planner only feeds + /// in actions it just generated (the hint probe reuses this as the same + /// pure logic the session performs), and revalidating each one there + /// dominates search cost. Mirrors the session's move effects, including + /// the compulsory reserve fill of an emptied pile. + static func apply(_ action: PlannedAction, to state: GameState) -> GameState? { + var nextState = state + switch action { + case .move(let selection, let destination): + switch selection.source { + case .tableau(let pile, let index): + nextState.tableau[pile].removeSubrange(index.. SearchOutcome { + let rootScore = score(state) + var nodes: [Node] = [Node(state: state, parent: -1, action: nil, depth: 0, score: rootScore)] + var visited: Set = [stateHash(state)] + var heap = BinaryHeap() + heap.push(HeapEntry(priority: rootScore, order: 0, index: 0)) + var order = 0 + var expansions = 0 + var wasTruncated = false + var best: (index: Int, score: Int, depth: Int)? + + while let entry = heap.pop() { + let nodeIndex = entry.index + let node = nodes[nodeIndex] + + if node.score > rootScore { + let improvesBest = best.map { + node.score > $0.score || (node.score == $0.score && node.depth < $0.depth) + } ?? true + if improvesBest { + best = (nodeIndex, node.score, node.depth) + } + if node.state.isWon { break } + } + + guard node.depth < limits.maxDepth else { + wasTruncated = true + continue + } + expansions += 1 + if nodes.count >= limits.maxNodes { + wasTruncated = true + break + } + if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { + wasTruncated = true + break + } + // A line that builds a foundation card or frees a reserve card + // and change is a solid hint; once one is in hand, cap how long + // we keep hunting for something better. + if let best, best.score - rootScore >= 20, expansions >= 8_192 { + break + } + + for action in actions(from: node.state) { + guard let nextState = apply(action, to: node.state) else { continue } + guard visited.insert(stateHash(nextState)).inserted else { continue } + + let nextScore = score(nextState) + nodes.append( + Node( + state: nextState, + parent: nodeIndex, + action: action, + depth: node.depth + 1, + score: nextScore + ) + ) + order += 1 + // Best-first on score, shallow bias so equal outcomes prefer short lines. + heap.push( + HeapEntry( + priority: nextScore * 4 - (node.depth + 1), + order: order, + index: nodes.count - 1 + ) + ) + } + } + + guard let best, let actions = line(to: best.index, nodes: nodes) else { + return .noProgress(searchWasExhaustive: !wasTruncated) + } + return .line(actions) + } + + struct Node { + let state: GameState + let parent: Int + let action: PlannedAction? + let depth: Int + let score: Int + } + + struct HeapEntry: HeapPrioritizable { + let priority: Int + let order: Int + let index: Int + + func takesPriority(over other: HeapEntry) -> Bool { + priority != other.priority ? priority > other.priority : order < other.order + } + } + + static func score(_ state: GameState) -> Int { + let foundationCards = state.foundations.reduce(0) { $0 + $1.count } + let undevelopedCount = state.stock.count + state.waste.count + // Foundation cards are the only permanent progress and set the scale. + // Freeing a reserve card is the strategic heart of the game — each one + // is a forced-order dig the deal owes — so it prices high but below a + // foundation card, and the compulsory fill makes emptying a pile worth + // exactly one freed reserve card. Developing cards out of the + // stock/waste counts as progress (so a stock tap is score-neutral and + // playing off the waste beats an equivalent tableau play), and each + // card covering the next foundation-needed card of any suit is a dig + // the line still owes. + return foundationCards * 20 + - state.reserve.count * 8 + - undevelopedCount * 2 + - nextNeededBurialDepth(state) * 2 + } + + /// How many cards sit on top of the tableau copy of each foundation's + /// next needed card, summed over the four foundations. Cards still in the + /// stock, waste, or reserve owe no dig here — the undeveloped and reserve + /// terms already carry them. + static func nextNeededBurialDepth(_ state: GameState) -> Int { + guard let base = CanfieldGameRules.baseRank(in: state) else { return 0 } + var total = 0 + for suit in Suit.allCases { + let height = state.foundations + .first { $0.first?.suit == suit }? + .count ?? 0 + guard height < Rank.allCases.count else { continue } + let neededOffset = height + for pile in state.tableau { + for index in pile.indices + where pile[index].suit == suit + && CanfieldGameRules.foundationOffset(of: pile[index].rank, from: base) == neededOffset { + total += pile.count - 1 - index + } + } + } + return total + } + + static func actions(from state: GameState) -> [PlannedAction] { + let firstEmptyColumn = state.tableau.firstIndex(where: \.isEmpty) + var actions: [PlannedAction] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + // Exact-equivalence canonicalizations, all invisible to the player. + var tookFoundation = false + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + switch destination { + case .foundation: + // Only a base-rank card ever has more than one legal + // foundation — the empty piles are interchangeable — so + // keep the first. + if tookFoundation { continue } + tookFoundation = true + case .tableau(let index): + // Empty columns are interchangeable: canonicalize to the + // first. (Players can still drop on any empty column.) + if state.tableau[index].isEmpty, index != firstEmptyColumn { continue } + case .freeCell, .pyramid, .waste, .discard: + continue + } + actions.append(.move(selection: selection, destination: destination)) + } + } + if !state.stock.isEmpty || !state.waste.isEmpty { + actions.append(.stockTap) + } + return actions + } + + /// FNV-1a over a canonical layout: tableau piles are sorted before mixing + /// because Canfield's four piles are strategically interchangeable, and + /// foundations collapse to their per-suit heights (which pile holds which + /// suit carries nothing; the heights plus the base rank determine their + /// exact contents). The stock and waste are mixed in full — recycling + /// rebuilds the stock from the waste, so neither is derivable from a + /// count — while the reserve only ever shrinks off its fixed dealt order, + /// so its count is exact. + static func stateHash(_ state: GameState) -> UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { + hash = (hash ^ UInt64(value)) &* 0x100000001b3 + } + func encode(card: Card) -> UInt8 { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + return UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0)) + } + for card in state.stock { mix(encode(card: card)) } + mix(0xFC) + for card in state.waste { mix(encode(card: card)) } + mix(0xFB) + mix(UInt8(state.reserve.count)) + for suit in Suit.allCases { + mix(0xFE) + let height = state.foundations + .first { $0.first?.suit == suit }? + .count ?? 0 + mix(UInt8(height)) + } + let encodedPiles = state.tableau + .map { pile in pile.map { encode(card: $0) } } + .sorted { $0.lexicographicallyPrecedes($1) } + for pile in encodedPiles { + mix(0xFD) + for value in pile { mix(value) } + } + return hash + } + + static func line(to index: Int, nodes: [Node]) -> [PlannedAction]? { + var actions: [PlannedAction] = [] + var cursor = index + while cursor >= 0, nodes[cursor].parent >= 0 { + if let action = nodes[cursor].action { + actions.append(action) + } + cursor = nodes[cursor].parent + } + guard !actions.isEmpty else { return nil } + return actions.reversed() + } +} diff --git a/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift b/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift new file mode 100644 index 0000000..3f2e5b2 --- /dev/null +++ b/ComputerSolitaire/Game/Canfield/GamePersistenceCanfield.swift @@ -0,0 +1,55 @@ +import Foundation + +enum CanfieldPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == CanfieldGameRules.tableauPileCount else { return false } + // Every tableau card deals (and stays) face up, and every pile stays a + // packed run — the rules only ever land legal builds and whole piles. + guard state.tableau.allSatisfy({ pile in + pile.isEmpty || CanfieldGameRules.isPackedSequence(pile) + }) else { return false } + // The compulsory fill: a space can only persist once the reserve is out. + guard state.reserve.isEmpty || state.tableau.allSatisfy({ !$0.isEmpty }) else { + return false + } + // The reserve deals thirteen and only ever shrinks; exactly its top + // card is face up. + guard state.reserve.count <= CanfieldGameRules.reserveCardCount else { return false } + guard state.reserve.enumerated().allSatisfy({ index, card in + card.isFaceUp == (index == state.reserve.count - 1) + }) else { return false } + // Canfield renders no free-cell slots, so a card stranded there would + // be invisible and the game unwinnable. + guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } + // The pyramid and TriPeaks fields belong to those variants alone; a + // card stranded there would be invisible here. Recycles are unlimited + // and deliberately untracked. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.triPeaks.isEmpty, + state.triPeaksChainLength == 0, state.wasteRecyclesUsed == 0 else { + return false + } + guard state.stock.count <= CanfieldGameRules.dealStockCardCount else { return false } + guard state.stock.allSatisfy({ !$0.isFaceUp }) else { return false } + guard state.waste.allSatisfy(\.isFaceUp) else { return false } + // The deal seeds the base card before play and foundations are locked, + // so the base rank must be on the board and every foundation must be a + // wrapped same-suit run rising from it. + guard let baseRank = CanfieldGameRules.baseRank(in: state) else { return false } + return state.foundations.allSatisfy { pile in + isValidFoundationPile(pile, baseRank: baseRank) + } + } + + /// A Canfield foundation grows one suit upward from the base rank, turning + /// the corner from King to Ace, to at most thirteen cards. + private static func isValidFoundationPile(_ pile: [Card], baseRank: Rank) -> Bool { + guard !pile.isEmpty else { return true } + guard pile.count <= Rank.allCases.count else { return false } + guard let suit = pile.first?.suit else { return false } + return pile.enumerated().allSatisfy { index, card in + card.isFaceUp + && card.suit == suit + && CanfieldGameRules.foundationOffset(of: card.rank, from: baseRank) == index + } + } +} diff --git a/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift b/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift new file mode 100644 index 0000000..0b939f4 --- /dev/null +++ b/ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift @@ -0,0 +1,145 @@ +import Foundation + +enum CanfieldGameRules { + static let tableauPileCount = 4 + static let reserveCardCount = 13 + /// The 52-card deal minus the reserve, the base card, and four tableau cards. + static let dealStockCardCount = 34 + static let stockDrawCount = 3 + + /// The rank all four foundations start with: the base card the deal turned + /// onto the first foundation. Foundations are locked, so a seeded pile + /// never empties and the base rank can always be read back off the board. + static func baseRank(in state: GameState) -> Rank? { + state.foundations.compactMap(\.first).first?.rank + } + + /// How many steps above the base rank `rank` sits, turning the corner: + /// the base rank is offset zero and the rank just below it is offset + /// twelve, the last card a foundation takes. + static func foundationOffset(of rank: Rank, from base: Rank) -> Int { + (rank.rawValue - base.rawValue + Rank.allCases.count) % Rank.allCases.count + } + + /// Foundation landing rule: an empty pile takes only the base rank; + /// otherwise the moving card goes on the top card of the same suit, one + /// rank higher, turning the corner from King to Ace. + static func canMoveToFoundation(card: Card, foundation: [Card], in state: GameState) -> Bool { + guard let top = foundation.last else { + return card.rank == baseRank(in: state) + } + return top.suit == card.suit && card.rank.rawValue == wrappedRankAbove(top.rank) + } + + /// Tableau landing rule: the moving card goes on a face-up top card of + /// the opposite color, one rank higher, turning the corner from Ace up to + /// King. An empty pile is open on card alone — whether a source may + /// actually take a space is `allowsTableauTransfer`'s source-aware call. + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + guard let top = destinationPile.last else { return true } + return top.isFaceUp + && top.suit.isRed != card.suit.isRed + && top.rank.rawValue == wrappedRankAbove(card.rank) + } + + /// Source-aware transfer gate shared by drags and the advisor. Foundations + /// are locked, so a foundation card never returns to the tableau. Between + /// tableau piles Canfield moves a pile only in its entirety — a selection + /// lifted above the pile bottom is a partial sequence and never transfers. + /// A space refills from the reserve automatically; once the reserve is + /// out, only the top waste card may take a space, at the player's choice. + static func allowsTableauTransfer( + selection: Selection, + destinationPile: [Card], + in state: GameState + ) -> Bool { + if case .foundation = selection.source { return false } + if case .tableau(_, let index) = selection.source, index != 0 { return false } + guard destinationPile.isEmpty else { return true } + guard state.reserve.isEmpty else { return false } + if case .waste = selection.source { return true } + return false + } + + /// A packed run: face-up cards descending one rank per step in + /// alternating colors, turning the corner from Ace down to King. Deals + /// and legal landings only ever build packed piles, so every whole pile + /// satisfies this; it guards hand-built and restored states. + static func isPackedSequence(_ cards: [Card]) -> Bool { + guard !cards.isEmpty else { return false } + guard cards.allSatisfy(\.isFaceUp) else { return false } + for index in 0..<(cards.count - 1) { + let upper = cards[index] + let lower = cards[index + 1] + guard upper.suit.isRed != lower.suit.isRed else { return false } + guard upper.rank.rawValue == wrappedRankAbove(lower.rank) else { return false } + } + return true + } + + /// The fan count after the top waste card is played (call with the card + /// already removed): one fewer fanned card, but never zero while the + /// waste holds any — the exposed top card is always available in + /// Canfield, so a spent fan uncovers the card beneath it rather than + /// burying the pile until the next stock action. + static func wasteDrawCountAfterWastePlay(in state: GameState) -> Int { + max(min(1, state.waste.count), state.wasteDrawCount - 1) + } + + /// The compulsory space fill: as soon as a tableau pile empties, the + /// reserve's exposed card moves in and the next reserve card turns face + /// up. Player choice only enters once the reserve is exhausted. + static func refillEmptyPileFromReserve(on state: inout GameState, pileIndex: Int) { + guard state.tableau.indices.contains(pileIndex), + state.tableau[pileIndex].isEmpty, + !state.reserve.isEmpty else { return } + var card = state.reserve.removeLast() + card.isFaceUp = true + state.tableau[pileIndex].append(card) + if let newTopIndex = state.reserve.indices.last { + state.reserve[newTopIndex].isFaceUp = true + } + } + + /// Whether sending `card` to a foundation can never cost the game. + /// Foundations are locked, so an eager send is irrevocable. The classic + /// two-step rule applies in offset space: a base-plus-one card is safe + /// because the only cards that land on it are base cards, themselves + /// foundation-playable at once; a higher card is safe once both + /// opposite-color foundations reach at least its offset minus one and the + /// other same-color foundation its offset minus two. Base cards are + /// treated as safe outright: each foundation can only ever begin with its + /// suit's base card, so withholding one defers mandatory progress. + /// (Strictly, a promoted base card stops hosting wrapped offset-twelve + /// landings, but ranking a foundation start below any tableau build is + /// the far worse trade.) + static func isSafeFoundationMove(card: Card, in state: GameState) -> Bool { + guard let base = baseRank(in: state) else { return false } + let offset = foundationOffset(of: card.rank, from: base) + if offset <= 1 { return true } + + var topOffsetBySuit: [Suit: Int] = [:] + for foundation in state.foundations { + if let first = foundation.first { + topOffsetBySuit[first.suit] = foundation.count - 1 + } + } + + let oppositeMin = Suit.allCases + .filter { $0.isRed != card.suit.isRed } + .map { topOffsetBySuit[$0] ?? -1 } + .min() ?? -1 + let sameColorOther = Suit.allCases + .first { $0.isRed == card.suit.isRed && $0 != card.suit } + let sameColorOtherOffset = sameColorOther.flatMap { topOffsetBySuit[$0] } ?? -1 + + return oppositeMin >= offset - 1 && sameColorOtherOffset >= offset - 2 + } +} + +private extension CanfieldGameRules { + /// The raw rank one step above `rank`, turning the corner from King to Ace. + static func wrappedRankAbove(_ rank: Rank) -> Int { + rank.rawValue % Rank.allCases.count + 1 + } +} diff --git a/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift b/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift new file mode 100644 index 0000000..6afa4d8 --- /dev/null +++ b/ComputerSolitaire/Game/Canfield/GameSessionCanfield.swift @@ -0,0 +1,112 @@ +import Foundation + +extension SolitaireViewModel { + // MARK: Configuration + + /// Canfield always turns three cards to the waste, with unlimited + /// recycles; there is no draw-mode choice. + func configureCanfieldNewGame() { + setStockDrawCount(DrawMode.three.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(0) + } + + func configureCanfieldRedeal() { + setScoringDrawCount(DrawMode.three.rawValue) + // The exposed waste top is always available, so the fan floors at one + // card while the waste holds any. + let clampedWasteDrawCount = min( + max(min(1, state.waste.count), state.wasteDrawCount), + min(stockDrawCount, state.waste.count) + ) + setWasteDrawCount(clampedWasteDrawCount) + } + + func sanitizeCanfieldRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = min( + max(min(1, sanitizedState.waste.count), sanitizedState.wasteDrawCount), + min(DrawMode.three.rawValue, sanitizedState.waste.count) + ) + return sanitizedState + } + + // MARK: Scoring + + func applyCanfieldMoveScore(for source: Selection.Source, destination: Destination) { + switch (source, destination) { + case (.waste, .tableau): + applyScore(.wasteToTableau) + case (.waste, .foundation): + applyScore(.wasteToFoundation) + case (.tableau, .foundation): + applyScore(.tableauToFoundation) + case (.reserve, .tableau): + applyScore(.reserveToTableau) + case (.reserve, .foundation): + applyScore(.reserveToFoundation) + default: + break + } + } + + // MARK: Stock + + /// Turns three cards onto the waste; with the stock out, a tap turns the + /// waste over to form the new stock. Redeals are unlimited. + func handleCanfieldStockTap() { + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + if state.stock.isEmpty { + recycleWaste() + } else { + drawFromStock() + } + } + + // MARK: Reserve + + func handleReserveTap() { + guard state.variant == .canfield else { return } + guard let top = state.reserve.last, top.isFaceUp else { return } + HapticManager.shared.play(.cardPickUp) + + let reserveSelection = Selection(source: .reserve, cards: [top]) + if queueBestAutoMove(for: reserveSelection) { + return + } + if selection?.source == .reserve { + selection = nil + return + } + isDragging = false + selection = reserveSelection + } + + @discardableResult + func startDragFromReserve() -> Bool { + guard state.variant == .canfield else { return false } + guard let top = state.reserve.last, top.isFaceUp else { return false } + clearHint() + selection = Selection(source: .reserve, cards: [top]) + isDragging = true + return true + } + + /// The compulsory space fill after a tableau source removal, with the + /// session-side score and feedback the advisor's pure version stays + /// silent about. The fill scores like any reserve-to-tableau play — the + /// rules forcing the move does not change what the player gained, and + /// Klondike's compulsory flip scores through its automatic path the same + /// way. + func refillCanfieldSpaceFromReserve(in pileIndex: Int) { + guard state.tableau.indices.contains(pileIndex), + state.tableau[pileIndex].isEmpty, + !state.reserve.isEmpty else { return } + CanfieldGameRules.refillEmptyPileFromReserve(on: &state, pileIndex: pileIndex) + applyScore(.reserveToTableau) + SoundManager.shared.play(.cardPlaced) + } +} diff --git a/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift b/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift new file mode 100644 index 0000000..280f84c --- /dev/null +++ b/ComputerSolitaire/Game/Canfield/GameStateCanfield.swift @@ -0,0 +1,43 @@ +import Foundation + +extension GameState { + /// The Canfield deal: thirteen cards face down into the reserve with its + /// top card turned face up, one face-up base card onto the first + /// foundation (its rank is where all four foundations start), one face-up + /// card onto each of the four tableau piles, and the remaining 34 cards + /// face down in the stock. The waste starts empty. + /// The hint probe and test fixtures copy this dealing order verbatim — + /// change them together. + static func newCanfieldGame() -> GameState { + var deck = Card.fullDeck().shuffled() + + var reserve: [Card] = [] + for _ in 0.. Int { + guard state.variant == .canfield, + let base = CanfieldGameRules.baseRank(in: state) else { + return card.rank.rawValue + } + return CanfieldGameRules.foundationOffset(of: card.rank, from: base) + } + @discardableResult static func applyAutoFinishMove(_ move: AutoFinishMove, in state: inout GameState) -> Bool { guard case .foundation(let foundationIndex) = move.destination, @@ -159,7 +199,11 @@ private extension AutoFinishPlanner { guard move.selection.cards.count == 1, let movingCard = move.selection.cards.first else { return false } - guard GameRules.canMoveToFoundation(card: movingCard, foundation: state.foundations[foundationIndex]) else { + guard GameRules.canMoveToFoundation( + card: movingCard, + foundation: state.foundations[foundationIndex], + in: state + ) else { return false } @@ -176,6 +220,9 @@ private extension AutoFinishPlanner { !state.tableau[pileIndex][newTopIndex].isFaceUp { state.tableau[pileIndex][newTopIndex].isFaceUp = true } + if state.variant == .canfield { + CanfieldGameRules.refillEmptyPileFromReserve(on: &state, pileIndex: pileIndex) + } case .freeCell(let slot): guard state.freeCells.indices.contains(slot), @@ -189,6 +236,13 @@ private extension AutoFinishPlanner { _ = state.waste.popLast() state.wasteDrawCount = min(1, state.waste.count) + case .reserve: + guard state.reserve.last?.id == movingCard.id else { return false } + _ = state.reserve.popLast() + if let newTopIndex = state.reserve.indices.last { + state.reserve[newTopIndex].isFaceUp = true + } + case .foundation, .pyramid, .triPeaks: return false } diff --git a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift index 240b4c5..df65439 100644 --- a/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GamePersistenceKlondike.swift @@ -12,6 +12,9 @@ enum KlondikePersistenceRules { return false } guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } + // Canfield's reserve belongs to that variant alone; a card stranded + // there would be invisible here. + guard state.reserve.isEmpty 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 4bc8091..a036de2 100644 --- a/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift +++ b/ComputerSolitaire/Game/Klondike/GameSessionKlondike.swift @@ -39,36 +39,6 @@ extension SolitaireViewModel { } } - func recycleWaste() { - guard state.stock.isEmpty, !state.waste.isEmpty else { return } - clearHint() - let visibleWasteIDs = visibleWasteCards().map(\.id) - let animatedWasteIDs = visibleWasteIDs.isEmpty - ? [state.waste.last?.id].compactMap { $0 } - : visibleWasteIDs - 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() - setWasteDrawCount(0) - incrementMovesCount() - if scoringDrawCount == DrawMode.one.rawValue { - applyScore(.recycleWasteInDrawOne) - } - SoundManager.shared.play(.wasteRecycleToStock) - HapticManager.shared.play(.wasteRecycle) - refreshAutoFinishAvailability() - } - func applyKlondikeMoveScore(for source: Selection.Source, destination: Destination) { switch (source, destination) { case (.waste, .tableau): diff --git a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift index ad9ddad..8f8e872 100644 --- a/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift +++ b/ComputerSolitaire/Game/Klondike/KlondikePlanner.swift @@ -177,7 +177,7 @@ private extension KlondikePlanner { !nextState.tableau[pile][topIndex].isFaceUp { nextState.tableau[pile][topIndex].isFaceUp = true } - case .pyramid, .triPeaks: + case .pyramid, .triPeaks, .reserve: // Unreachable: this planner only searches Klondike states. return nil } diff --git a/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift b/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift index 37441d7..2650f2d 100644 --- a/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/AutoMoveAdvisorPyramid.swift @@ -41,7 +41,7 @@ enum PyramidAutoMoveAdvisor { where PyramidGameRules.canRemovePairWithWasteTop(pyramidIndex: partnerIndex, in: state) { destinations.append(.pyramid(partnerIndex)) } - case .foundation, .freeCell, .tableau, .triPeaks: + case .foundation, .freeCell, .tableau, .triPeaks, .reserve: return [] } diff --git a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift index b8fda11..879fecc 100644 --- a/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GamePersistencePyramid.swift @@ -14,6 +14,9 @@ enum PyramidPersistenceRules { // The TriPeaks fields belong to the TriPeaks variant alone; a card // stranded there would be invisible here. guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } + // Canfield's reserve belongs to that variant alone; a card stranded + // there would be invisible here. + guard state.reserve.isEmpty else { return false } guard state.wasteDrawCount == min(1, state.waste.count) else { return false } // Legal play can never remove a card while a covering card remains, so an diff --git a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift index 6e483ff..879d1d4 100644 --- a/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift +++ b/ComputerSolitaire/Game/Pyramid/GameRulesPyramid.swift @@ -66,7 +66,7 @@ enum PyramidGameRules { return PyramidGeometry.isExposed(index, in: state.pyramid) case .waste: return state.waste.last?.id == card.id - case .foundation, .freeCell, .tableau, .triPeaks: + case .foundation, .freeCell, .tableau, .triPeaks, .reserve: return false } } diff --git a/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift b/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift index ef5bcf8..e7299fb 100644 --- a/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift +++ b/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift @@ -16,6 +16,9 @@ enum ScorpionPersistenceRules { return false } guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } + // Canfield's reserve belongs to that variant alone; a card stranded + // there would be invisible here. + guard state.reserve.isEmpty else { return false } guard state.wasteDrawCount == 0 else { return false } return state.foundations.allSatisfy(isValidFoundationPile) } diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index a3f7c9c..58267a0 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -26,7 +26,7 @@ enum AutoMoveAdvisor { if selection.cards.count == 1, state.variant.playerBuildsFoundations { for foundationIndex in state.foundations.indices { let foundation = state.foundations[foundationIndex] - if GameRules.canMoveToFoundation(card: movingCard, foundation: foundation) { + if GameRules.canMoveToFoundation(card: movingCard, foundation: foundation, in: state) { destinations.append(.foundation(foundationIndex)) } } @@ -98,6 +98,12 @@ enum AutoMoveAdvisor { ) } + // Canfield's reserve; empty for the other variants. Only its exposed + // top card is ever available. + if let topReserveCard = state.reserve.last, topReserveCard.isFaceUp { + selections.append(Selection(source: .reserve, cards: [topReserveCard])) + } + for pileIndex in state.tableau.indices { let pile = state.tableau[pileIndex] for cardIndex in pile.indices where pile[cardIndex].isFaceUp { @@ -148,7 +154,9 @@ enum AutoMoveAdvisor { switch selection.source { case .waste: _ = nextState.waste.popLast() - if stockDrawCount == DrawMode.one.rawValue { + if state.variant == .canfield { + nextState.wasteDrawCount = CanfieldGameRules.wasteDrawCountAfterWastePlay(in: nextState) + } else if stockDrawCount == DrawMode.one.rawValue { nextState.wasteDrawCount = min(1, nextState.waste.count) } else { nextState.wasteDrawCount = max(0, nextState.wasteDrawCount - 1) @@ -160,6 +168,11 @@ enum AutoMoveAdvisor { case .tableau(let pile, let index): nextState.tableau[pile].removeSubrange(index.. Bool { if foundation.isEmpty { return card.rank == .ace @@ -7,6 +10,20 @@ enum GameRules { return top.suit == card.suit && card.rank.rawValue == top.rank.rawValue + 1 } + static func canMoveToFoundation(card: Card, foundation: [Card], in state: GameState) -> Bool { + switch state.variant { + case .klondike, .freecell, .yukon, .spider, .pyramid, .tripeaks, .golf, .fortyThieves, + .scorpion: + return canMoveToFoundation(card: card, foundation: foundation) + case .canfield: + return CanfieldGameRules.canMoveToFoundation( + card: card, + foundation: foundation, + in: state + ) + } + } + static func canMoveToTableau( card: Card, destinationPile: [Card], @@ -36,6 +53,11 @@ enum GameRules { card: card, destinationPile: destinationPile ) + case .canfield: + return CanfieldGameRules.canMoveToTableau( + card: card, + destinationPile: destinationPile + ) } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 71063f0..1b2f65a 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -574,6 +574,8 @@ final class SolitaireViewModel { configureGolfNewGame() case .fortyThieves: configureFortyThievesNewGame() + case .canfield: + configureCanfieldNewGame() } } @@ -593,6 +595,8 @@ final class SolitaireViewModel { configureGolfRedeal() case .fortyThieves: configureFortyThievesRedeal() + case .canfield: + configureCanfieldRedeal() } } @@ -613,6 +617,8 @@ final class SolitaireViewModel { return sanitizeGolfRedealState(state) case .fortyThieves: return sanitizeFortyThievesRedealState(state) + case .canfield: + return sanitizeCanfieldRedealState(state) } } @@ -657,7 +663,7 @@ final class SolitaireViewModel { cardIndex: cardIndex, card: card ) - case .freecell, .pyramid, .tripeaks, .fortyThieves: + case .freecell, .pyramid, .tripeaks, .fortyThieves, .canfield: return false } } @@ -707,6 +713,9 @@ final class SolitaireViewModel { case .golf, .fortyThieves: // Only the exposed card of a column can ever move. return cards.count == 1 + case .canfield: + // The exposed card (for foundations) or the whole pile. + return CanfieldAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) case .pyramid, .tripeaks: // Pyramid and TriPeaks have no tableau piles. return false @@ -746,6 +755,8 @@ extension SolitaireViewModel { handleGolfStockTap() case .fortyThieves: handleFortyThievesStockTap() + case .canfield: + handleCanfieldStockTap() case .freecell, .yukon: break } @@ -755,7 +766,7 @@ extension SolitaireViewModel { /// waste (Pyramid stops recycling once its passes are spent). var canInteractWithStock: Bool { switch state.variant { - case .klondike: + case .klondike, .canfield: return !(state.stock.isEmpty && state.waste.isEmpty) case .pyramid: return !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) @@ -773,7 +784,7 @@ extension SolitaireViewModel { func visibleWasteCards() -> [Card] { switch state.variant { - case .klondike: + case .klondike, .canfield: let count = min(state.wasteDrawCount, stockDrawCount) return Array(state.waste.suffix(count)) case .pyramid, .tripeaks, .golf, .fortyThieves: @@ -821,6 +832,40 @@ extension SolitaireViewModel { return true } + /// Turns the spent waste over to form the new stock, order preserved. + /// Klondike and Canfield recycle without limit (Klondike's draw-one + /// scoring charges for it); Pyramid recycles through its own pass-capped + /// handler. + func recycleWaste() { + guard state.stock.isEmpty, !state.waste.isEmpty else { return } + clearHint() + let visibleWasteIDs = visibleWasteCards().map(\.id) + let animatedWasteIDs = visibleWasteIDs.isEmpty + ? [state.waste.last?.id].compactMap { $0 } + : visibleWasteIDs + 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() + setWasteDrawCount(0) + incrementMovesCount() + if scoringDrawCount == DrawMode.one.rawValue { + applyScore(.recycleWasteInDrawOne) + } + SoundManager.shared.play(.wasteRecycleToStock) + HapticManager.shared.play(.wasteRecycle) + refreshAutoFinishAvailability() + } + func drawFromStock() { guard !state.stock.isEmpty else { return } clearHint() @@ -882,7 +927,8 @@ extension SolitaireViewModel { guard selection.cards.count == 1 else { return false } guard GameRules.canMoveToFoundation( card: movingCard, - foundation: state.foundations[index] + foundation: state.foundations[index], + in: state ) else { return false } clearHint() pushHistory( @@ -959,7 +1005,9 @@ extension SolitaireViewModel { switch selection.source { case .waste: _ = state.waste.popLast() - if stockDrawCount == DrawMode.one.rawValue { + if state.variant == .canfield { + state.wasteDrawCount = CanfieldGameRules.wasteDrawCountAfterWastePlay(in: state) + } else if stockDrawCount == DrawMode.one.rawValue { state.wasteDrawCount = min(1, state.waste.count) } else { state.wasteDrawCount = max(0, state.wasteDrawCount - 1) @@ -972,18 +1020,28 @@ extension SolitaireViewModel { var cards = state.tableau[pile] cards.removeSubrange(index.. HintAdvisor.Hint? { switch state.variant { @@ -166,6 +179,8 @@ final class HintPlanner { return fortyThievesHint(in: state) case .scorpion: return scorpionHint(in: state) + case .canfield: + return canfieldHint(in: state) } } } @@ -404,6 +419,40 @@ private extension HintPlanner { return FortyThievesPlanner.materialize(action, in: state) } + func canfieldHint(in state: GameState) -> HintAdvisor.Hint? { + let key = CanfieldPlanner.stateKey(for: state) + if let hint = plannedCanfieldHint(for: key, in: state) { + return hint + } + + canfieldPlan.removeAll() + let limits = CanfieldPlanner.Limits( + deadline: Date().addingTimeInterval(Self.canfieldSearchBudget) + ) + switch CanfieldPlanner.bestLine(in: state, limits: limits) { + case .line(let line): + canfieldPlan = CanfieldPlanner.keyedActions(along: line, from: state) + return plannedCanfieldHint(for: key, in: state) + + case .noProgress(let searchWasExhaustive): + // An exhaustive search covered every reachable position — taps and + // recycles included — so exhaustion is a proof the game is over; + // a tap hint would just churn the dead stock in a circle, and + // silence is the honest answer. Only a truncated no-progress falls + // back to the tap (a large searched region held no improvement, + // but the unexplored remainder may), mirroring Forty Thieves. + guard !searchWasExhaustive else { return nil } + guard !state.stock.isEmpty || !state.waste.isEmpty else { return nil } + return .stockTap + } + } + + func plannedCanfieldHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + // materialize re-validates the cached action against the live state. + guard let action = canfieldPlan[key] else { return nil } + return CanfieldPlanner.materialize(action, in: state) + } + func plannedSpiderHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { switch spiderPlan[key] { case .move(let selection, let destination): diff --git a/ComputerSolitaire/Game/Shared/MoveTypes.swift b/ComputerSolitaire/Game/Shared/MoveTypes.swift index e185f75..a45a2ee 100644 --- a/ComputerSolitaire/Game/Shared/MoveTypes.swift +++ b/ComputerSolitaire/Game/Shared/MoveTypes.swift @@ -10,6 +10,9 @@ struct Selection: Equatable { case pyramid(index: Int) /// A single uncovered card at a TriPeaks slot (TriPeaks only). case triPeaks(index: Int) + /// The face-up top card of the reserve (Canfield only). The reserve is + /// never a destination. + case reserve } let source: Source diff --git a/ComputerSolitaire/Game/Shared/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift index 1166ff9..eca491a 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 reserveToTableau + case reserveToFoundation case spiderMove case spiderCompletedRun case scorpionCompletedRun @@ -50,6 +52,10 @@ enum Scoring { return -15 case .recycleWasteInDrawOne: return -100 + case .reserveToTableau: + return 5 + case .reserveToFoundation: + return 10 case .spiderMove: return -1 case .spiderCompletedRun: diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index ae31b96..a675dcc 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -103,6 +103,11 @@ private extension TapMovePolicy { // safety gate matters even more; the shared rule assumes one // foundation per suit, so Forty Thieves brings its own. tier = FortyThievesGameRules.isSafeFoundationMove(card: card, in: state) ? 100 : 60 + case .canfield: + // Foundations are locked here too, and the shared rule + // assumes Ace-anchored foundations; Canfield's own rule works + // in offset space above the dealt base rank. + tier = CanfieldGameRules.isSafeFoundationMove(card: card, in: state) ? 100 : 60 case .spider, .scorpion: // Unreachable: Spider and Scorpion foundations are never // player destinations. @@ -143,6 +148,16 @@ private extension TapMovePolicy { pileOrder: -index ) } + if state.variant == .canfield { + // A Canfield pile is packed in its entirety (its runs turn + // the corner from Ace to King, which `topRunLength` would + // stop at), so the whole pile is the build. + return Priority( + tier: pile.isEmpty ? 40 : 80, + buildLength: pile.count + selection.cards.count, + pileOrder: -index + ) + } let tier = pile.isEmpty ? 40 : 80 return Priority( tier: tier, diff --git a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift index 933ce0a..023db08 100644 --- a/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift +++ b/ComputerSolitaire/Game/Spider/GamePersistenceSpider.swift @@ -16,6 +16,9 @@ enum SpiderPersistenceRules { return false } guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } + // Canfield's reserve belongs to that variant alone; a card stranded + // there would be invisible here. + guard state.reserve.isEmpty else { return false } guard state.wasteDrawCount == 0 else { return false } return state.foundations.allSatisfy(isValidFoundationPile) } diff --git a/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift b/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift index 2a92d55..deca3ca 100644 --- a/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift +++ b/ComputerSolitaire/Game/TriPeaks/GamePersistenceTriPeaks.swift @@ -13,6 +13,9 @@ enum TriPeaksPersistenceRules { guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { return false } + // Canfield's reserve belongs to that variant alone; a card stranded + // there would be invisible here. + guard state.reserve.isEmpty else { return false } // The deal starts the waste with one card and the waste only grows, so // an empty waste is corrupt (there would be no match target). guard !state.waste.isEmpty else { return false } diff --git a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift index 696796d..439c1d5 100644 --- a/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift +++ b/ComputerSolitaire/Game/Yukon/GamePersistenceYukon.swift @@ -13,6 +13,9 @@ enum YukonPersistenceRules { return false } guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } + // Canfield's reserve belongs to that variant alone; a card stranded + // there would be invisible here. + guard state.reserve.isEmpty else { return false } return state.wasteDrawCount == 0 } } diff --git a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift index 220a74c..e3714bf 100644 --- a/ComputerSolitaire/Game/Yukon/YukonPlanner.swift +++ b/ComputerSolitaire/Game/Yukon/YukonPlanner.swift @@ -321,8 +321,8 @@ private extension YukonPlanner { } case .foundation(let pile): _ = nextState.foundations[pile].popLast() - case .waste, .freeCell, .pyramid, .triPeaks: - // Yukon has no waste, free cells, pyramid, or peaks. + case .waste, .freeCell, .pyramid, .triPeaks, .reserve: + // Yukon has no waste, free cells, pyramid, peaks, or reserve. return nil } switch move.destination { diff --git a/ComputerSolitaire/GameMenuCommands.swift b/ComputerSolitaire/GameMenuCommands.swift index eda42cd..a7feb94 100644 --- a/ComputerSolitaire/GameMenuCommands.swift +++ b/ComputerSolitaire/GameMenuCommands.swift @@ -52,9 +52,14 @@ struct GameMenuCommands: Commands { set: { actions?.switchVariant($0) } )) { ForEach(Array(GameVariant.allCases.enumerated()), id: \.element) { index, variant in - Text(variant.title) - .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) - .tag(variant) + if let shortcut = Self.variantShortcut(at: index) { + Text(variant.title) + .keyboardShortcut(shortcut, modifiers: .command) + .tag(variant) + } else { + Text(variant.title) + .tag(variant) + } } } .pickerStyle(.inline) @@ -114,5 +119,18 @@ struct GameMenuCommands: Commands { .disabled(actions == nil) } } + + /// ⌘1 through ⌘9 select the first nine variants and ⌘0 the tenth; any + /// beyond that get no shortcut — there are only ten digit keys. + private static func variantShortcut(at index: Int) -> KeyEquivalent? { + let ordinal = index + 1 + if ordinal <= 9 { + return KeyEquivalent(Character("\(ordinal)")) + } + if ordinal == 10 { + return "0" + } + return nil + } } #endif diff --git a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift index 26e9eb0..f517e3a 100644 --- a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift +++ b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift @@ -16,6 +16,7 @@ enum DragOrigin: Hashable { case tableau(pile: Int, index: Int) case pyramid(Int) case triPeaks(Int) + case reserve } struct DropTargetGeometry: Equatable { diff --git a/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift b/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift new file mode 100644 index 0000000..4b3612d --- /dev/null +++ b/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift @@ -0,0 +1,228 @@ +import SwiftUI +import Observation + +/// Canfield's top row matches Klondike's shape — stock, fanned waste, a +/// spacer column the fan can overflow into, and four foundations. The +/// reserve renders in the tableau band (see `CanfieldBoardRowView`), beside +/// the piles it feeds, as on a physical table. +struct CanfieldTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let wasteFanSpacing: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let isStockHinted: Bool + let isWasteHinted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let drawingCardIDs: Set + let fanProgress: [UUID: Double] + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + StockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken + ) + .frame(width: cardSize.width, alignment: .leading) + + WasteView( + viewModel: viewModel, + cardSize: cardSize, + fanSpacing: wasteFanSpacing, + isHintTargeted: isWasteHinted, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) + // Keep top-row columns aligned with tableau; waste fan can overflow visually + // without changing foundation positions. + .frame(width: cardSize.width, alignment: .leading) + + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + + ForEach(0..<4, id: \.self) { index in + FoundationView( + viewModel: viewModel, + index: index, + cardSize: cardSize, + isTargeted: activeTarget == .foundation(index), + isHintTargeted: hintedTarget == .foundation(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} + +/// Canfield's tableau band: the reserve at the leftmost column — under the +/// stock, as on a physical table — a spacer pair, then the four tableau +/// piles aligned directly beneath the four foundations. +struct CanfieldBoardRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let faceDownOffset: CGFloat + let faceUpOffset: CGFloat + let maxPileHeight: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + CanfieldReserveView( + viewModel: viewModel, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + + ForEach(0..<2, id: \.self) { _ in + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + } + + TableauRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + faceDownOffset: faceDownOffset, + faceUpOffset: faceUpOffset, + maxPileHeight: maxPileHeight, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} + +/// The reserve pile ("the demon"): a face-down packet whose exposed top card +/// is always playable. It is never a drop target — cards only ever leave. +struct CanfieldReserveView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + 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 reserve = viewModel.state.reserve + let topCard = reserve.last + let isDragSource: Bool = { + guard viewModel.isDragging, let selection = viewModel.selection else { return false } + if case .reserve = selection.source { + return true + } + return false + }() + let accessibleTopCard: Card? = topCard.flatMap { card in + guard card.isFaceUp else { return nil } + let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + return isDragged || hiddenCardIDs.contains(card.id) ? nil : card + } + let isAccessibleTopCardSelected = accessibleTopCard.map { + viewModel.isSelected(card: $0) + } ?? false + + VStack(spacing: 4) { + ZStack { + PilePlaceholderView(cardSize: cardSize) + if reserve.count > 1 { + CardBackView(cardSize: cardSize) + } + if let topCard, topCard.isFaceUp { + let isDragged = viewModel.isDragging && viewModel.isSelected(card: topCard) + let isHidden = hiddenCardIDs.contains(topCard.id) + CardView( + card: topCard, + isSelected: viewModel.isSelected(card: topCard), + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(topCard.id) ? hintWiggleToken : nil, + isAccessibilityElement: false + ) + .opacity(isDragged || isHidden ? 0 : 1) + .allowsHitTesting(!isHidden) + .cardFramePreference(topCard.id) + .gesture(dragGesture(.reserve)) + } + } + .frame(width: cardSize.width, height: cardSize.height) + // The count sits on the felt below the pile — the top card is face + // up, so a Stock-style overlaid badge would be unreadable on it. + Text("\(reserve.count)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .allowsHitTesting(false) + } + .onTapGesture { + viewModel.handleReserveTap() + } + .zIndex(isDragSource ? 10 : 0) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Reserve") + .accessibilityValue(reserveAccessibilityValue(topCard: accessibleTopCard, count: reserve.count)) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isAccessibleTopCardSelected ? .isSelected : []) + .accessibilityHidden(reserve.isEmpty) + } + + private func reserveAccessibilityValue(topCard: Card?, count: Int) -> String { + guard count > 0 else { return "Empty" } + guard let topCard else { return "\(count) cards" } + return "\(topCard.accessibilityName). \(count) cards" + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index c479292..153ed3d 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -327,6 +327,27 @@ struct RulesAndScoringView: View { definition: "Face-up cards drawn from the stock; only the top card is playable." ) ] + case .canfield: + return [ + TermRow( + term: "Reserve", + definition: "Thirteen face-down cards with the top one face up and playable; it must fill any empty tableau pile at once." + ), + TermRow( + term: "Base card", + definition: "The card dealt to the first foundation; its rank starts all four foundations." + ), + TermRow( + term: "Tableau", + definition: "Four piles built down in alternating colors, wrapping from Ace to King; a pile moves only in its entirety." + ), + TermRow( + term: "Foundations", + definition: "Four suit piles built up from the base rank, wrapping from King to Ace; cards placed here never return." + ), + TermRow(term: "Stock", definition: "The face-down draw pile; three cards turn at a time, with unlimited redeals."), + TermRow(term: "Waste", definition: "Face-up cards drawn from the stock; only the top card is playable.") + ] case .scorpion: return [ TermRow(term: "Tableau", definition: "The seven play piles where you build down by suit."), @@ -425,6 +446,16 @@ struct RulesAndScoringView: View { "Tap the stock to flip one card onto the waste, whenever you like. The stock allows a single pass — there are no recycles.", "You win by moving all 104 cards to foundations. The game is lost when the stock is spent and no legal move remains." ] + case .canfield: + return [ + "Deal 13 cards into the reserve with the top card face up, one face-up base card onto the first foundation, and one face-up card onto each of four tableau piles. The remaining 34 cards form the stock.", + "All four foundations start at the base card's rank and build up by suit, wrapping from King to Ace. Cards placed on a foundation never return to play.", + "Build tableau piles down in alternating colors, wrapping from Ace to King. A pile moves onto another only in its entirety; the exposed top card may play to a foundation.", + "An empty tableau pile is filled at once from the reserve. Once the reserve is empty, fill it with the top waste card whenever you choose.", + "The reserve's top card is always available to play on foundations or tableau piles.", + "Tap the stock to turn three cards onto the waste; when it runs out, tap again to turn the waste over and keep going — redeals are unlimited.", + "You win by moving all 52 cards to foundations." + ] case .scorpion: return [ "Deal 49 cards into seven tableau piles of seven. The first four piles hide their bottom three cards face down; the last three are fully face up. The remaining three cards form the stock.", @@ -537,6 +568,19 @@ struct RulesAndScoringView: View { note: "Reduced by elapsed time." ) ] + case .canfield: + return [ + ScoringRow(move: "Waste to Tableau", points: Scoring.delta(for: .wasteToTableau), note: nil), + ScoringRow(move: "Waste to Foundation", points: Scoring.delta(for: .wasteToFoundation), note: nil), + ScoringRow(move: "Reserve to Tableau", points: Scoring.delta(for: .reserveToTableau), note: nil), + ScoringRow(move: "Reserve to Foundation", points: Scoring.delta(for: .reserveToFoundation), note: nil), + ScoringRow(move: "Tableau to Foundation", points: Scoring.delta(for: .tableauToFoundation), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) + ] case .scorpion: return [ ScoringRow(move: "Turn over Tableau card", points: Scoring.delta(for: .turnOverTableauCard), note: nil), diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 22a292c..8c10ac4 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -537,6 +537,26 @@ struct TopRowView: View { fanProgress: fanProgress, dragGesture: dragGesture ) + case .canfield: + CanfieldTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + wasteFanSpacing: wasteFanSpacing, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + isStockHinted: isStockHinted, + isWasteHinted: isWasteHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) } } } @@ -623,10 +643,20 @@ struct FoundationView: View { ZStack { PilePlaceholderView(cardSize: cardSize) if foundation.isEmpty { - Image(systemName: "a") - .font(.system(size: cardSize.width * 0.22, weight: .semibold)) - .foregroundStyle(.white.opacity(0.28)) - .allowsHitTesting(false) + // Canfield foundations start at the dealt base rank, not the Ace. + if viewModel.gameVariant == .canfield { + if let baseRank = CanfieldGameRules.baseRank(in: viewModel.state) { + Text(baseRank.label) + .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.28)) + .allowsHitTesting(false) + } + } else { + Image(systemName: "a") + .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.28)) + .allowsHitTesting(false) + } } DropHighlightView( cardSize: cardSize, diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 9df8152..f5fc9c0 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -635,6 +635,25 @@ struct ContentView: View { dragGesture: dragGesture(for:) ) .frame(width: boardContentWidth, alignment: .leading) + } else if viewModel.gameVariant == .canfield { + CanfieldBoardRowView( + 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) } else { TableauRowView( viewModel: viewModel, @@ -1169,6 +1188,8 @@ struct ContentView: View { started = viewModel.startDragFromPyramid(index: index) case .triPeaks(let index): started = viewModel.startDragFromTriPeaks(index: index) + case .reserve: + started = viewModel.startDragFromReserve() } if started, let firstCard = viewModel.selection?.cards.first { @@ -1891,7 +1912,7 @@ struct ContentView: View { return [viewModel.state.discard] case .tripeaks, .golf: return [viewModel.state.waste] - case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion: + case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion, .canfield: return viewModel.state.foundations } } @@ -1902,7 +1923,7 @@ struct ContentView: View { return [.discard] case .tripeaks, .golf: return [.waste] - case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion: + case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion, .canfield: return viewModel.state.foundations.indices.map(DropTarget.foundation) } } diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index 7ef9ee8..2e40325 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -363,6 +363,8 @@ private struct MiniBoardView: View { pyramidRows case .tripeaks: triPeaksRows + case .canfield: + canfieldRow case .klondike, .spider, .freecell, .yukon, .golf, .fortyThieves, .scorpion: tableauRow } @@ -420,6 +422,12 @@ private struct MiniBoardView: View { miniCard(.faceDown) miniCard(.faceUp) Spacer(minLength: 0) + case .canfield: + miniCard(.faceDown) + miniCard(.slot) + Spacer(minLength: 0) + miniCard(.faceUp) + foundationSlots(count: 3) } } } @@ -484,6 +492,19 @@ private struct MiniBoardView: View { .frame(maxWidth: .infinity) } + /// Canfield's tableau band: the face-down reserve at the left, then the + /// four single-card piles under the foundations — the base card renders + /// face up in `topRow`'s first foundation slot. + private var canfieldRow: some View { + HStack(alignment: .top, spacing: columnSpacing) { + miniCard(.faceDown) + Spacer(minLength: 0) + ForEach(0..<4, id: \.self) { _ in + miniCard(.faceUp) + } + } + } + private var tableauRow: some View { HStack(alignment: .top, spacing: columnSpacing) { ForEach(tableauColumns.indices, id: \.self) { columnIndex in @@ -525,7 +546,9 @@ private struct MiniBoardView: View { ? Array(repeating: .faceDown, count: 3) + Array(repeating: .faceUp, count: 4) : Array(repeating: .faceUp, count: 7) } - case .pyramid, .tripeaks: + case .pyramid, .tripeaks, .canfield: + // Pyramid, TriPeaks, and Canfield draw their boards through their + // own row builders. return [] } } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 4ba8392..853b03a 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -403,7 +403,7 @@ private struct GameStatisticsDetailView: View { return stats.highScoreTwoSuits case .spiderFourSuits: return stats.highScoreFourSuits - case .freecell, .pyramid, .tripeaks, .yukon, .fortyThieves, .scorpion: + case .freecell, .pyramid, .tripeaks, .yukon, .fortyThieves, .scorpion, .canfield: return stats.highScore case .golf: return nil diff --git a/ComputerSolitaireTests/Canfield/CanfieldPersistenceTests.swift b/ComputerSolitaireTests/Canfield/CanfieldPersistenceTests.swift new file mode 100644 index 0000000..02d6918 --- /dev/null +++ b/ComputerSolitaireTests/Canfield/CanfieldPersistenceTests.swift @@ -0,0 +1,235 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class CanfieldPersistenceTests: XCTestCase { + private func payload( + for state: GameState, + score: Int = 0, + stockDrawCount: Int = DrawMode.three.rawValue + ) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + score: score, + stockDrawCount: stockDrawCount, + history: [] + ) + } + + /// A mid-game shape: one three-card turn onto the waste — the 52-card + /// census stays intact and every dealt invariant still holds. + private func midGameState() -> GameState { + var state = GameStateFixtures.seededCanfieldDeal(seed: 2) + for _ in 0..<3 { + var drawn = state.stock.removeLast() + drawn.isFaceUp = true + state.waste.append(drawn) + } + state.wasteDrawCount = 3 + return state + } + + func testFreshDealRoundTripsThroughSanitization() throws { + let state = GameState.newCanfieldGame() + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual(restored.state, state) + XCTAssertEqual(restored.stockDrawCount, DrawMode.three.rawValue) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testMidGameStateSurvivesEncodeDecode() throws { + let state = midGameState() + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertEqual(decoded, state) + + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + XCTAssertEqual(try XCTUnwrap(sanitized).state, state) + } + + func testDecodingAPayloadWithoutAReserveDefaultsToEmpty() throws { + // Saves written before the reserve existed carry no key for it. + var state = GameStateFixtures.seededKlondikeDeal(seed: 4) + state.variant = .klondike + let data = try JSONEncoder().encode(state) + let object = try XCTUnwrap( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + var stripped = object + stripped.removeValue(forKey: "reserve") + let strippedData = try JSONSerialization.data(withJSONObject: stripped) + + let decoded = try JSONDecoder().decode(GameState.self, from: strippedData) + XCTAssertTrue(decoded.reserve.isEmpty) + } + + func testSanitizationFloorsTheFanAtOneWhileTheWasteHolds() throws { + // A save that buries a non-empty waste (fan count zero) predates the + // always-available rule or is corrupt; the sanitizer repairs it so the + // restored game keeps its waste top playable. + var state = midGameState() + state.wasteDrawCount = 0 + let sanitized = try XCTUnwrap( + payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + ) + XCTAssertEqual(sanitized.state.wasteDrawCount, 1) + } + + func testSanitizationForcesCanfieldDrawCounts() throws { + let state = GameState.newCanfieldGame() + let sanitized = payload(for: state, stockDrawCount: DrawMode.one.rawValue) + .sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual( + restored.stockDrawCount, + DrawMode.three.rawValue, + "Canfield always turns three" + ) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testSanitizationRejectsCorruptCanfieldStates() { + func assertRejected( + _ message: String, + mutate: (inout GameState) -> Void + ) { + var state = midGameState() + mutate(&state) + XCTAssertNil( + payload(for: state).sanitizedForRestore(at: DateFixtures.reference), + message + ) + } + + assertRejected("Wrong pile count") { state in + state.tableau.removeLast() + } + assertRejected("Wrong foundation count") { state in + state.foundations.removeLast() + } + assertRejected("A face-down tableau card breaks the all-face-up invariant") { state in + state.tableau[1][0].isFaceUp = false + } + assertRejected("A face-up stock card breaks the stock invariant") { state in + state.stock[0].isFaceUp = true + } + assertRejected("A face-down reserve top breaks the reserve invariant") { state in + state.reserve[state.reserve.count - 1].isFaceUp = false + } + assertRejected("A second face-up reserve card breaks the reserve invariant") { state in + state.reserve[0].isFaceUp = true + } + assertRejected("A reserve beyond thirteen cards exceeds the deal") { state in + var card = state.waste.removeLast() + card.isFaceUp = false + state.reserve.insert(card, at: 0) + state.wasteDrawCount = 2 + } + assertRejected("A space must not persist while the reserve holds cards") { state in + var card = state.tableau[0].removeLast() + card.isFaceUp = true + state.waste.append(card) + } + assertRejected("An unseeded board has no base rank") { state in + state.waste.append(contentsOf: state.foundations[0]) + state.foundations[0] = [] + state.wasteDrawCount = 3 + } + assertRejected("A missing card breaks deck composition") { state in + state.tableau[2].removeLast() + } + assertRejected("A duplicate identity breaks deck composition") { state in + let copied = state.tableau[1][0] + state.tableau[2][0] = TestCards.make(copied.suit, copied.rank) + } + assertRejected("Cards stranded in a free cell are invisible") { state in + state.freeCells[0] = state.waste.removeLast() + state.wasteDrawCount = 2 + } + assertRejected("Cards stranded in the pyramid field are invisible") { state in + state.pyramid = [state.waste.removeLast()] + state.wasteDrawCount = 2 + } + assertRejected("Canfield recycles are deliberately untracked") { state in + state.wasteRecyclesUsed = 1 + } + } + + func testLayoutRuleChecksFoundationRuns() { + // Census-independent checks against the layout rule directly: a + // foundation grows one suit upward from the base rank, turning the + // corner, or it is corrupt. + let offSuitRun = GameStateFixtures.canfieldState( + columns: [], + foundations: [[TestCards.make(.spades, .nine), TestCards.make(.hearts, .ten)]] + ) + XCTAssertFalse(CanfieldPersistenceRules.hasValidLayout(state: offSuitRun)) + + let skippedRank = GameStateFixtures.canfieldState( + columns: [], + foundations: [[TestCards.make(.spades, .nine), TestCards.make(.spades, .jack)]] + ) + XCTAssertFalse(CanfieldPersistenceRules.hasValidLayout(state: skippedRank)) + + let mismatchedBase = GameStateFixtures.canfieldState( + columns: [], + foundations: [ + [TestCards.make(.spades, .nine)], + [TestCards.make(.hearts, .ten)] + ] + ) + XCTAssertFalse( + CanfieldPersistenceRules.hasValidLayout(state: mismatchedBase), + "Every foundation starts at the same base rank" + ) + + let wrappedRun = GameStateFixtures.canfieldState( + columns: [], + foundations: [[ + TestCards.make(.spades, .queen), + TestCards.make(.spades, .king), + TestCards.make(.spades, .ace) + ]] + ) + XCTAssertTrue( + CanfieldPersistenceRules.hasValidLayout(state: wrappedRun), + "A run turning the corner from King to Ace is the rule, not corruption" + ) + } + + func testLayoutRuleChecksPackedTableauPiles() { + let unpacked = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .eight), TestCards.make(.clubs, .seven)]], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + XCTAssertFalse( + CanfieldPersistenceRules.hasValidLayout(state: unpacked), + "A same-color join can never have been dealt or played" + ) + + let wrappedPile = GameStateFixtures.canfieldState( + columns: [[ + TestCards.make(.spades, .two), + TestCards.make(.hearts, .ace), + TestCards.make(.clubs, .king) + ]], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + XCTAssertTrue(CanfieldPersistenceRules.hasValidLayout(state: wrappedPile)) + } + + func testViewModelRestoresCanfieldPayload() throws { + let state = midGameState() + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: payload(for: state))) + XCTAssertEqual(viewModel.gameVariant, .canfield) + XCTAssertEqual(viewModel.gameMode, .canfield) + XCTAssertEqual(viewModel.state, state) + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.three.rawValue) + } +} diff --git a/ComputerSolitaireTests/Canfield/CanfieldPlannerTests.swift b/ComputerSolitaireTests/Canfield/CanfieldPlannerTests.swift new file mode 100644 index 0000000..f64a3b4 --- /dev/null +++ b/ComputerSolitaireTests/Canfield/CanfieldPlannerTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class CanfieldPlannerTests: XCTestCase { + // MARK: - Action application + + func testStockTapTurnsThreeCardsPreservingOrder() throws { + let state = GameStateFixtures.seededCanfieldDeal(seed: 11) + let expected = Array(state.stock.suffix(3)).reversed().map(\.id) + + let next = try XCTUnwrap(CanfieldPlanner.apply(.stockTap, to: state)) + + XCTAssertEqual(next.waste.map(\.id), expected) + XCTAssertTrue(next.waste.allSatisfy(\.isFaceUp)) + XCTAssertEqual(next.wasteDrawCount, 3) + XCTAssertEqual(next.stock.count, state.stock.count - 3) + } + + func testStockTapOnASpentStockRecyclesTheWaste() throws { + var state = GameStateFixtures.seededCanfieldDeal(seed: 11) + state.waste = state.stock.reversed().map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + state.stock = [] + state.wasteDrawCount = 3 + let wasteOrder = state.waste.map(\.id) + + let next = try XCTUnwrap(CanfieldPlanner.apply(.stockTap, to: state)) + + XCTAssertTrue(next.waste.isEmpty) + XCTAssertEqual(next.stock.map(\.id), wasteOrder.reversed()) + XCTAssertTrue(next.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertEqual(next.wasteDrawCount, 0) + + XCTAssertNil( + CanfieldPlanner.apply(.stockTap, to: GameStateFixtures.canfieldState(columns: [])), + "A tap with no stock and no waste is meaningless" + ) + } + + func testApplyingATableauMoveMirrorsTheReserveFill() throws { + let reserveTop = TestCards.make(.diamonds, .queen) + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.clubs, .six)], + [TestCards.make(.hearts, .seven)] + ], + reserve: [TestCards.make(.spades, .two), reserveTop], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: state.tableau[0]) + + let next = try XCTUnwrap( + CanfieldPlanner.apply(.move(selection: selection, destination: .tableau(1)), to: state) + ) + + XCTAssertEqual(next.tableau[0].first?.id, reserveTop.id) + XCTAssertEqual(next.reserve.count, 1) + XCTAssertEqual(next.reserve.last?.isFaceUp, true) + XCTAssertEqual(next.tableau[1].count, 2) + } + + func testApplyingAWastePlayKeepsTheUncoveredTopAvailable() throws { + // The planner mirrors the session: playing the last fanned card + // uncovers the card beneath, which stays in the one-card fan rather + // than being buried until the next tap. + let buried = TestCards.make(.clubs, .nine) + let fanned = TestCards.make(.hearts, .five) + let state = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .six)]], + waste: [buried, fanned], + foundations: [[TestCards.make(.diamonds, .ten)]], + wasteDrawCount: 1 + ) + let selection = Selection(source: .waste, cards: [fanned]) + + let next = try XCTUnwrap( + CanfieldPlanner.apply(.move(selection: selection, destination: .tableau(0)), to: state) + ) + + XCTAssertEqual(next.wasteDrawCount, 1) + XCTAssertEqual(next.waste.last?.id, buried.id) + XCTAssertFalse( + AutoMoveAdvisor.candidateSelections(in: next) + .filter { $0.source == .waste } + .isEmpty, + "The uncovered waste top must stay a planner candidate" + ) + } + + // MARK: - Search + + func testBestLineBanksAnAvailableBaseCard() throws { + // The base-rank 5♥ sits on a tableau pile; banking it is pure + // permanent progress, so the best line must start moving toward it. + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.hearts, .five)], + [TestCards.make(.spades, .nine)], + [TestCards.make(.diamonds, .jack)], + [TestCards.make(.clubs, .three)] + ], + reserve: [TestCards.make(.clubs, .ten)], + foundations: [[TestCards.make(.spades, .five)]], + fillStockFromRemainder: true + ) + + guard case .line(let actions) = CanfieldPlanner.bestLine(in: state) else { + return XCTFail("A base card in the open must yield an improving line") + } + var current = state + for action in actions { + current = try XCTUnwrap(CanfieldPlanner.apply(action, to: current)) + } + XCTAssertGreaterThan( + current.foundations.reduce(0) { $0 + $1.count }, + state.foundations.reduce(0) { $0 + $1.count }, + "The improving line banks at least one card" + ) + } + + func testBestLineFinishesAWonEndgame() throws { + // Base five, every foundation one card short; the four closing fours + // wait as tableau tops. The line must run to the win. + var foundations: [[Card]] = [] + for suit in Suit.allCases { + var pile: [Card] = [] + for offset in 0..<(Rank.allCases.count - 1) { + let rawValue = (Rank.five.rawValue - 1 + offset) % Rank.allCases.count + 1 + pile.append(TestCards.make(suit, Rank(rawValue: rawValue) ?? .ace)) + } + foundations.append(pile) + } + let columns = Suit.allCases.map { suit in + [TestCards.make(suit, .four)] + } + let state = GameStateFixtures.canfieldState(columns: columns, foundations: foundations) + + guard case .line(let actions) = CanfieldPlanner.bestLine(in: state) else { + return XCTFail("A four-move win must be found") + } + var current = state + for action in actions { + current = try XCTUnwrap(CanfieldPlanner.apply(action, to: current)) + } + XCTAssertTrue(current.isWon) + } + + func testExhaustedDeadPositionIsAProofAndSilencesTheHint() { + // No stock, no waste, no reserve, and four same-rank piles that can + // neither pack nor bank: the whole reachable space is this position. + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.spades, .eight)], + [TestCards.make(.clubs, .eight)], + [TestCards.make(.hearts, .eight)], + [TestCards.make(.diamonds, .eight)] + ], + foundations: [[TestCards.make(.spades, .five)]] + ) + + guard case .noProgress(let searchWasExhaustive) = CanfieldPlanner.bestLine(in: state) else { + return XCTFail("A dead position must not produce a line") + } + XCTAssertTrue(searchWasExhaustive, "The tiny reachable space must be fully searched") + XCTAssertNil( + HintAdvisor.bestHint(in: state, stockDrawCount: CanfieldGameRules.stockDrawCount), + "An exhausted search is a proof; a tap hint would churn a dead game" + ) + } + + // MARK: - Cached lines + + func testKeyedActionsFollowTheLinePositionByPosition() throws { + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.hearts, .five)], + [TestCards.make(.spades, .nine)], + [TestCards.make(.diamonds, .jack)], + [TestCards.make(.clubs, .three)] + ], + reserve: [TestCards.make(.clubs, .ten)], + foundations: [[TestCards.make(.spades, .five)]], + fillStockFromRemainder: true + ) + guard case .line(let actions) = CanfieldPlanner.bestLine(in: state) else { + return XCTFail("Expected an improving line") + } + + let keyed = CanfieldPlanner.keyedActions(along: actions, from: state) + var current = state + for expected in actions { + let key = CanfieldPlanner.stateKey(for: current) + let cached = try XCTUnwrap(keyed[key], "Every position along the line is keyed") + XCTAssertNotNil( + CanfieldPlanner.materialize(cached, in: current), + "The cached action must re-validate against its position" + ) + current = try XCTUnwrap(CanfieldPlanner.apply(expected, to: current)) + } + } + + func testStateKeyDistinguishesStockOrderAfterARecycle() { + // Two positions with identical counts but different stock orders must + // not share a key — recycling rebuilds the stock from the waste, so a + // count is not an identity. + let cardA = TestCards.make(.spades, .two, isFaceUp: false) + let cardB = TestCards.make(.hearts, .nine, isFaceUp: false) + var state = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.clubs, .six)]], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + state.stock = [cardA, cardB] + var swapped = state + swapped.stock = [cardB, cardA] + + XCTAssertNotEqual( + CanfieldPlanner.stateKey(for: state), + CanfieldPlanner.stateKey(for: swapped) + ) + } +} diff --git a/ComputerSolitaireTests/Canfield/CanfieldRulesTests.swift b/ComputerSolitaireTests/Canfield/CanfieldRulesTests.swift new file mode 100644 index 0000000..27fd291 --- /dev/null +++ b/ComputerSolitaireTests/Canfield/CanfieldRulesTests.swift @@ -0,0 +1,394 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class CanfieldRulesTests: XCTestCase { + // MARK: - Foundations + + func testEmptyFoundationTakesOnlyTheBaseRank() { + // Base rank five: the deal seeded 5♠ onto the first foundation. + let state = GameStateFixtures.canfieldState( + columns: [], + foundations: [[TestCards.make(.spades, .five)]] + ) + + XCTAssertTrue( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.hearts, .five), + foundation: state.foundations[1], + in: state + ) + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.hearts, .ace), + foundation: state.foundations[1], + in: state + ), + "An Ace is just another rank when the base is a five" + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.hearts, .six), + foundation: state.foundations[1], + in: state + ), + "A started foundation's neighbor rank must not seed a new pile" + ) + } + + func testFoundationBuildsUpBySuitTurningTheCorner() { + let state = GameStateFixtures.canfieldState( + columns: [], + foundations: [ + [TestCards.make(.spades, .queen)], + [TestCards.make(.hearts, .queen), TestCards.make(.hearts, .king)] + ] + ) + + XCTAssertTrue( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.spades, .king), + foundation: state.foundations[0], + in: state + ) + ) + XCTAssertTrue( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.hearts, .ace), + foundation: state.foundations[1], + in: state + ), + "Foundations turn the corner from King to Ace" + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.clubs, .ace), + foundation: state.foundations[1], + in: state + ), + "Foundations build one suit only" + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToFoundation( + card: TestCards.make(.spades, .jack), + foundation: state.foundations[0], + in: state + ), + "Foundations build upward only" + ) + } + + func testBaseRankReadsOffTheFirstSeededFoundation() { + let state = GameStateFixtures.canfieldState( + columns: [], + foundations: [[], [TestCards.make(.diamonds, .nine)]] + ) + XCTAssertEqual(CanfieldGameRules.baseRank(in: state), .nine) + + let unseeded = GameStateFixtures.canfieldState(columns: []) + XCTAssertNil(CanfieldGameRules.baseRank(in: unseeded)) + } + + // MARK: - Tableau landings + + func testTableauLandingRequiresOppositeColorOneRankLower() { + let eightSpades = [TestCards.make(.spades, .eight)] + + XCTAssertTrue( + CanfieldGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .seven), + destinationPile: eightSpades + ) + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToTableau( + card: TestCards.make(.clubs, .seven), + destinationPile: eightSpades + ), + "A same-color card must not land, whatever its rank" + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .six), + destinationPile: eightSpades + ), + "Building skips no ranks" + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .nine), + destinationPile: eightSpades + ), + "Building runs downward only" + ) + } + + func testTableauBuildingTurnsTheCornerFromAceToKing() { + let aceHearts = [TestCards.make(.hearts, .ace)] + XCTAssertTrue( + CanfieldGameRules.canMoveToTableau( + card: TestCards.make(.spades, .king), + destinationPile: aceHearts + ), + "A King packs on an Ace when the sequence turns the corner" + ) + XCTAssertFalse( + CanfieldGameRules.canMoveToTableau( + card: TestCards.make(.diamonds, .king), + destinationPile: aceHearts + ) + ) + } + + func testPackedSequenceValidationTurnsTheCorner() { + XCTAssertTrue( + CanfieldGameRules.isPackedSequence([ + TestCards.make(.spades, .two), + TestCards.make(.hearts, .ace), + TestCards.make(.clubs, .king) + ]) + ) + XCTAssertFalse( + CanfieldGameRules.isPackedSequence([ + TestCards.make(.spades, .two), + TestCards.make(.clubs, .ace) + ]), + "Packing alternates colors" + ) + XCTAssertFalse( + CanfieldGameRules.isPackedSequence([ + TestCards.make(.spades, .two), + TestCards.make(.hearts, .king) + ]), + "Packing descends one rank per step" + ) + } + + // MARK: - Whole-pile movement + + func testOnlyTheWholePileOrItsTopCardMayBePickedUp() { + let viewModel = SolitaireViewModel(variant: .canfield) + let pile = [ + TestCards.make(.spades, .eight), + TestCards.make(.hearts, .seven), + TestCards.make(.clubs, .six) + ] + viewModel.state = GameStateFixtures.canfieldState(columns: [pile]) + + XCTAssertTrue(viewModel.canSelectTableauCards(viewModel.state.tableau[0])) + XCTAssertTrue(viewModel.canSelectTableauCards([viewModel.state.tableau[0][2]])) + XCTAssertFalse( + viewModel.canSelectTableauCards(Array(viewModel.state.tableau[0][1...])), + "A partial sequence never moves, however well packed" + ) + } + + func testBuriedCardDragIsRefusedAndEdgesAreAllowed() { + let viewModel = SolitaireViewModel(variant: .canfield) + viewModel.state = GameStateFixtures.canfieldState( + columns: [[ + TestCards.make(.spades, .eight), + TestCards.make(.hearts, .seven), + TestCards.make(.clubs, .six) + ]] + ) + + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 0)) + viewModel.cancelDrag() + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 2)) + viewModel.cancelDrag() + XCTAssertFalse( + viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 1), + "A mid-pile drag would be a partial sequence" + ) + } + + func testTopCardOfALongerPileNeverTransfersBetweenPiles() { + // 7♥ could pack on the other pile's 8♣ — but lifting it off the 8♠ + // would split the pile, so only the whole pile may go (and it cannot, + // since its bottom card is the same rank). + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.spades, .eight), TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .eight)] + ], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + let topCardSelection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [state.tableau[0][1]] + ) + XCTAssertFalse( + AutoMoveAdvisor.legalDestinations(for: topCardSelection, in: state) + .contains(.tableau(1)) + ) + } + + func testWholePileTransfersWhenTheJoinIsLegal() { + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.hearts, .seven), TestCards.make(.clubs, .six)], + [TestCards.make(.spades, .eight)] + ], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + let wholePile = Selection( + source: .tableau(pile: 0, index: 0), + cards: state.tableau[0] + ) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: wholePile, in: state) + .contains(.tableau(1)) + ) + } + + // MARK: - Empty piles + + func testEmptyPileTakesOnlyTheWasteTopOnceTheReserveIsOut() { + let state = GameStateFixtures.canfieldState( + columns: [ + [], + [TestCards.make(.spades, .eight)], + [TestCards.make(.hearts, .nine)] + ], + waste: [TestCards.make(.clubs, .four)], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + + let wasteSelection = Selection(source: .waste, cards: [state.waste[0]]) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: wasteSelection, in: state) + .contains(.tableau(0)) + ) + + let pileSelection = Selection( + source: .tableau(pile: 1, index: 0), + cards: state.tableau[1] + ) + XCTAssertFalse( + AutoMoveAdvisor.legalDestinations(for: pileSelection, in: state) + .contains(.tableau(0)), + "A space never fills from another tableau pile" + ) + } + + func testEmptyPileRefusesTheWasteWhileTheReserveHolds() { + // The invariant keeps spaces from persisting while the reserve holds; + // a hand-built space must still refuse the waste so the compulsory + // fill stays the only path. + let state = GameStateFixtures.canfieldState( + columns: [[], [TestCards.make(.spades, .eight)]], + reserve: [TestCards.make(.diamonds, .two)], + waste: [TestCards.make(.clubs, .four)], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + let wasteSelection = Selection(source: .waste, cards: [state.waste[0]]) + XCTAssertFalse( + AutoMoveAdvisor.legalDestinations(for: wasteSelection, in: state) + .contains(.tableau(0)) + ) + } + + // MARK: - Reserve + + func testReserveTopIsAvailableToFoundationsAndTableauBuilds() { + let reserveTop = TestCards.make(.hearts, .seven) + let state = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .eight)]], + reserve: [TestCards.make(.clubs, .three), reserveTop], + foundations: [[TestCards.make(.diamonds, .seven)]] + ) + + let selections = AutoMoveAdvisor.candidateSelections(in: state) + let reserveSelection = selections.first { $0.source == .reserve } + XCTAssertEqual(reserveSelection?.cards.first?.rank, .seven) + + let destinations = AutoMoveAdvisor.legalDestinations( + for: Selection(source: .reserve, cards: [state.reserve[1]]), + in: state + ) + XCTAssertTrue(destinations.contains(.tableau(0)), "Reserve cards build on occupied piles") + XCTAssertTrue( + destinations.contains(.foundation(1)), + "The base-rank reserve top starts an empty foundation" + ) + } + + func testEmptiedPileRefillsFromTheReserveAtOnce() throws { + let reserveTop = TestCards.make(.diamonds, .queen) + let state = GameStateFixtures.canfieldState( + columns: [ + [TestCards.make(.clubs, .six)], + [TestCards.make(.hearts, .seven)] + ], + reserve: [TestCards.make(.spades, .two), reserveTop], + foundations: [[TestCards.make(.diamonds, .ten)]] + ) + + let selection = Selection(source: .tableau(pile: 0, index: 0), cards: state.tableau[0]) + let next = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: .tableau(1), + in: state, + stockDrawCount: CanfieldGameRules.stockDrawCount + ) + + let refilled = try XCTUnwrap(next) + XCTAssertEqual(refilled.tableau[0].first?.id, reserveTop.id) + XCTAssertEqual(refilled.reserve.count, 1) + XCTAssertEqual( + refilled.reserve.last?.isFaceUp, + true, + "The next reserve card turns face up" + ) + } + + // MARK: - Safe foundation sends + + func testBaseAndBasePlusOneAreAlwaysSafe() { + let state = GameStateFixtures.canfieldState( + columns: [], + foundations: [[TestCards.make(.spades, .five)]] + ) + XCTAssertTrue( + CanfieldGameRules.isSafeFoundationMove(card: TestCards.make(.hearts, .five), in: state) + ) + XCTAssertTrue( + CanfieldGameRules.isSafeFoundationMove(card: TestCards.make(.spades, .six), in: state) + ) + XCTAssertFalse( + CanfieldGameRules.isSafeFoundationMove(card: TestCards.make(.spades, .seven), in: state), + "Two above the base needs the opposite-color foundations developed" + ) + } + + func testHigherOffsetsFollowTheClassicTwoStepRule() { + // Base five. Spades at seven means the next spade is offset 3 (an + // eight); it is safe once both red foundations reach offset 2 and + // clubs reaches offset 1. + let state = GameStateFixtures.canfieldState( + columns: [], + foundations: [ + [ + TestCards.make(.spades, .five), + TestCards.make(.spades, .six), + TestCards.make(.spades, .seven) + ], + [TestCards.make(.hearts, .five), TestCards.make(.hearts, .six), TestCards.make(.hearts, .seven)], + [TestCards.make(.diamonds, .five), TestCards.make(.diamonds, .six), TestCards.make(.diamonds, .seven)], + [TestCards.make(.clubs, .five), TestCards.make(.clubs, .six)] + ] + ) + XCTAssertTrue( + CanfieldGameRules.isSafeFoundationMove(card: TestCards.make(.spades, .eight), in: state) + ) + + var lagging = state + lagging.foundations[2].removeLast() + XCTAssertFalse( + CanfieldGameRules.isSafeFoundationMove(card: TestCards.make(.spades, .eight), in: lagging), + "A lagging opposite-color foundation blocks the safe call" + ) + } +} diff --git a/ComputerSolitaireTests/Canfield/CanfieldSessionTests.swift b/ComputerSolitaireTests/Canfield/CanfieldSessionTests.swift new file mode 100644 index 0000000..d18aea2 --- /dev/null +++ b/ComputerSolitaireTests/Canfield/CanfieldSessionTests.swift @@ -0,0 +1,270 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class CanfieldSessionTests: XCTestCase { + private func makeViewModel(state: GameState? = nil) -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .canfield) + if let state { + viewModel.state = state + } + return viewModel + } + + // MARK: - Deal + + func testNewGameDealsTheCanfieldLayout() { + let state = GameState.newCanfieldGame() + + XCTAssertEqual(state.variant, .canfield) + XCTAssertEqual(state.reserve.count, CanfieldGameRules.reserveCardCount) + XCTAssertEqual( + state.reserve.filter(\.isFaceUp).count, + 1, + "Exactly the reserve top deals face up" + ) + XCTAssertEqual(state.reserve.last?.isFaceUp, true) + XCTAssertEqual(state.foundations.count, 4) + XCTAssertEqual(state.foundations[0].count, 1, "The base card seeds the first foundation") + XCTAssertEqual(state.foundations[0].first?.isFaceUp, true) + XCTAssertTrue(state.foundations[1...].allSatisfy(\.isEmpty)) + XCTAssertEqual(state.tableau.count, CanfieldGameRules.tableauPileCount) + XCTAssertTrue(state.tableau.allSatisfy { $0.count == 1 && $0[0].isFaceUp }) + XCTAssertEqual(state.stock.count, CanfieldGameRules.dealStockCardCount) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertTrue(state.waste.isEmpty) + } + + func testNewGameConfiguresDrawThree() { + let viewModel = makeViewModel() + viewModel.newGame(mode: .canfield) + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.three.rawValue) + XCTAssertEqual(viewModel.gameMode, .canfield) + } + + // MARK: - Stock and recycling + + func testStockTapTurnsThreeCardsPreservingOrder() { + let viewModel = makeViewModel() + viewModel.newGame(mode: .canfield) + let expected = Array(viewModel.state.stock.suffix(3)).reversed().map(\.id) + + viewModel.handleStockTap() + + XCTAssertEqual(viewModel.state.waste.suffix(3).map(\.id), Array(expected)) + XCTAssertEqual(viewModel.state.wasteDrawCount, 3) + XCTAssertEqual(viewModel.visibleWasteCards().count, 3) + } + + func testStockTapTurnsTheRemainderWhenFewerThanThreeRemain() { + var state = GameStateFixtures.seededCanfieldDeal(seed: 7) + let leftover = Array(state.stock.suffix(2)) + state.waste = Array(state.stock.dropLast(2)).map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + state.stock = leftover + state.wasteDrawCount = min(3, state.waste.count) + let viewModel = makeViewModel(state: state) + + viewModel.handleStockTap() + + XCTAssertTrue(viewModel.state.stock.isEmpty) + XCTAssertEqual(viewModel.state.wasteDrawCount, 2) + } + + func testTapOnSpentStockRecyclesTheWasteWithoutPenalty() { + var state = GameStateFixtures.seededCanfieldDeal(seed: 3) + state.waste = state.stock.reversed().map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + state.stock = [] + state.wasteDrawCount = 3 + let viewModel = makeViewModel(state: state) + let wasteOrder = viewModel.state.waste.map(\.id) + let scoreBefore = viewModel.score + + viewModel.handleStockTap() + + XCTAssertTrue(viewModel.state.waste.isEmpty) + XCTAssertEqual( + viewModel.state.stock.map(\.id), + wasteOrder.reversed(), + "The waste turns over as-is to form the new stock" + ) + XCTAssertTrue(viewModel.state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertEqual(viewModel.score, scoreBefore, "Canfield redeals are free and unlimited") + XCTAssertTrue(viewModel.canInteractWithStock, "Redeals are unlimited") + } + + // MARK: - Moves and effects + + func testFoundationMoveFromTableauTriggersTheReserveFill() throws { + let reserveTop = TestCards.make(.diamonds, .queen) + let baseCard = TestCards.make(.spades, .five) + let movingCard = TestCards.make(.hearts, .five) + let state = GameStateFixtures.canfieldState( + columns: [[movingCard]], + reserve: [TestCards.make(.clubs, .nine), reserveTop], + foundations: [[baseCard]], + fillStockFromRemainder: true + ) + let viewModel = makeViewModel(state: state) + viewModel.selectFromTableau(pileIndex: 0, cardIndex: 0) + + XCTAssertTrue(viewModel.tryMoveSelection(to: .foundation(1))) + XCTAssertEqual(viewModel.state.foundations[1].first?.id, movingCard.id) + XCTAssertEqual( + viewModel.state.tableau[0].first?.id, + reserveTop.id, + "The emptied pile refills from the reserve at once" + ) + XCTAssertEqual(viewModel.state.reserve.count, 1) + XCTAssertEqual(viewModel.state.reserve.last?.isFaceUp, true) + XCTAssertEqual( + viewModel.score, + Scoring.delta(for: .tableauToFoundation) + Scoring.delta(for: .reserveToTableau), + "The compulsory fill scores like any reserve-to-tableau play" + ) + } + + func testReserveMovesScore() { + let reserveTop = TestCards.make(.hearts, .five) + let state = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .six)]], + reserve: [TestCards.make(.clubs, .nine), reserveTop], + foundations: [[TestCards.make(.spades, .five)]], + fillStockFromRemainder: true + ) + + // Reserve to foundation. + var viewModel = makeViewModel(state: state) + viewModel.selection = Selection(source: .reserve, cards: [reserveTop]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .foundation(1))) + XCTAssertEqual(viewModel.score, Scoring.delta(for: .reserveToFoundation)) + + // Reserve to tableau. + viewModel = makeViewModel(state: state) + viewModel.selection = Selection(source: .reserve, cards: [reserveTop]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + XCTAssertEqual(viewModel.score, Scoring.delta(for: .reserveToTableau)) + XCTAssertEqual(viewModel.state.reserve.count, 1) + XCTAssertEqual(viewModel.state.reserve.last?.isFaceUp, true) + } + + func testReserveDragStartsOnlyWithACardToGive() { + let withReserve = makeViewModel( + state: GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .six)]], + reserve: [TestCards.make(.hearts, .five)], + foundations: [[TestCards.make(.spades, .five)]], + fillStockFromRemainder: true + ) + ) + XCTAssertTrue(withReserve.startDragFromReserve()) + XCTAssertEqual(withReserve.selection?.source, .reserve) + + let emptyReserve = makeViewModel( + state: GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .six)]], + foundations: [[TestCards.make(.spades, .five)]], + fillStockFromRemainder: true + ) + ) + XCTAssertFalse(emptyReserve.startDragFromReserve()) + } + + func testSpentFanUncoversThePreviousWasteCard() { + // Play every card of the current three-card turn: the card beneath — + // dealt on an earlier turn — becomes the waste top, and it must stay + // visible and playable. Burying it until the next stock action would + // break Canfield's "top waste card is always available" rule. + let buried = TestCards.make(.clubs, .nine) + let fanned = TestCards.make(.hearts, .five) + let state = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .six)]], + waste: [buried, fanned], + foundations: [[TestCards.make(.diamonds, .ten)]], + wasteDrawCount: 1, + fillStockFromRemainder: true + ) + let viewModel = makeViewModel(state: state) + viewModel.selection = Selection(source: .waste, cards: [fanned]) + + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1, "The fan floors at one, not zero") + XCTAssertEqual(viewModel.visibleWasteCards().map(\.id), [buried.id]) + + let wasteSelections = AutoMoveAdvisor.candidateSelections(in: viewModel.state) + .filter { $0.source == .waste } + XCTAssertEqual(wasteSelections.first?.cards.first?.id, buried.id) + XCTAssertTrue(viewModel.startDragFromWaste()) + } + + func testWasteMoveScoresAndDecrementsTheFan() { + let wastePlay = TestCards.make(.hearts, .five) + let state = GameStateFixtures.canfieldState( + columns: [[TestCards.make(.spades, .six)]], + waste: [TestCards.make(.clubs, .nine), wastePlay], + foundations: [[TestCards.make(.diamonds, .ten)]], + wasteDrawCount: 2, + fillStockFromRemainder: true + ) + let viewModel = makeViewModel(state: state) + viewModel.selection = Selection(source: .waste, cards: [wastePlay]) + + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + XCTAssertEqual(viewModel.score, Scoring.delta(for: .wasteToTableau)) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1) + } + + // MARK: - Win and auto-finish + + func testAllFoundationsFullIsAWin() { + var foundations: [[Card]] = [] + let base = Rank.nine + for suit in Suit.allCases { + var pile: [Card] = [] + for offset in 0.. Bool] = [ + .klondike: KlondikePersistenceRules.hasValidLayout, + .freecell: FreeCellPersistenceRules.hasValidLayout, + .yukon: YukonPersistenceRules.hasValidLayout, + .spider: SpiderPersistenceRules.hasValidLayout, + .pyramid: PyramidPersistenceRules.hasValidLayout, + .tripeaks: TriPeaksPersistenceRules.hasValidLayout, + .golf: GolfPersistenceRules.hasValidLayout, + .fortyThieves: FortyThievesPersistenceRules.hasValidLayout, + .scorpion: ScorpionPersistenceRules.hasValidLayout + ] + let deal: [GameVariant: GameState] = [ + .klondike: GameStateFixtures.seededKlondikeDeal(seed: 5), + .freecell: GameStateFixtures.seededFreeCellDeal(seed: 5), + .yukon: GameStateFixtures.seededYukonDeal(seed: 5), + .spider: GameStateFixtures.seededSpiderDeal(seed: 5, suitCount: .two), + .pyramid: GameStateFixtures.seededPyramidDeal(seed: 5), + .tripeaks: GameStateFixtures.seededTriPeaksDeal(seed: 5), + .golf: GameStateFixtures.seededGolfDeal(seed: 5), + .fortyThieves: GameStateFixtures.seededFortyThievesDeal(seed: 5), + .scorpion: GameStateFixtures.seededScorpionDeal(seed: 5) + ] + + for variant in GameVariant.allCases where variant != .canfield { + guard let rule = layoutRule[variant], var state = deal[variant] else { + XCTFail("\(variant): missing layout rule or deal fixture") + continue + } + XCTAssertTrue(rule(state), "\(variant): the untouched deal must pass its layout rule") + state.reserve = [TestCards.make(.spades, .ace, isFaceUp: false)] + XCTAssertFalse(rule(state), "\(variant): a card stranded in the reserve must be rejected") + } + } + func testSanitizedForRestoreClampsDrawModesCountsAndHistory() { let validState = GameStateFixtures.validPersistenceState() let validSnapshot = GameSnapshot( diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index d26f2ab..b70a732 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -513,6 +513,58 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("Scorpion fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Canfield board is a fresh deal with the first three-card + /// turn fanned in the waste. The eye lands on the base card, the reserve + /// top, the four tableau singles, and the fan, so seeds are scanned for + /// the most photogenic spread across those nine cards. + func testGenerateCanfieldFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededCanfieldDeal(seed: seed) + let score = canfieldDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededCanfieldDeal(seed: seed) + viewModel.configureCanfieldNewGame() + 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.three.rawValue, + history: [], + hasStartedTrackedGame: false + ) + + XCTAssertNotNil(payload.sanitizedForRestore(), "Generated fixture failed the validity gate") + let restoredViewModel = SolitaireViewModel() + XCTAssertTrue(restoredViewModel.restore(from: payload), "Generated fixture failed to restore") + XCTAssertEqual(restoredViewModel.gameVariant, .canfield, "Fixture did not restore as Canfield") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("canfield.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Canfield fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -670,6 +722,28 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Canfield deal by the nine cards the eye lands on (the + /// base card, the reserve top, the four tableau singles, and the three + /// stock cards a first turn fans into the waste): rank variety, red/black + /// balance, all four suits, a few face cards, and a mid-rank base card — + /// the deal's signature — read well. + private func canfieldDealScore(of deal: GameState) -> Int { + let visible = deal.foundations.compactMap { $0.last } + + Array(deal.reserve.suffix(1)) + + deal.tableau.compactMap { $0.last } + + Array(deal.stock.suffix(DrawMode.three.rawValue)) + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + if let base = CanfieldGameRules.baseRank(in: deal) { + score += (base >= .four && base <= .ten) ? 6 : 0 + } + return score + } + /// Scores a fresh deal by the ten cards a first draw makes visible: the /// seven tableau tops plus the three stock cards that land in the waste. /// Rank variety, red/black balance, all four suits, and a couple of face diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index db0fbf2..be4ca12 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -484,6 +484,123 @@ enum GameStateFixtures { ) } + /// A reproducible Canfield deal matching the shape of + /// `GameState.newCanfieldGame`. Mirrored by the hint probe's + /// `seededCanfieldDeal` so seeds are comparable. + static func seededCanfieldDeal(seed: UInt64) -> GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var reserve: [Card] = [] + for _ in 0.. GameState { + var tableau = columns.map { column in + column.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + } + if tableau.count < CanfieldGameRules.tableauPileCount { + tableau.append( + contentsOf: [[Card]]( + repeating: [], + count: CanfieldGameRules.tableauPileCount - tableau.count + ) + ) + } + var fullReserve = reserve.map { card in + var faceDown = card + faceDown.isFaceUp = false + return faceDown + } + if !fullReserve.isEmpty { + fullReserve[fullReserve.count - 1].isFaceUp = true + } + var faceDownStock = stock.map { card in + var faceDown = card + faceDown.isFaceUp = false + return faceDown + } + let fullWaste = waste.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + var fullFoundations = foundations.map { pile in + pile.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + } + if fullFoundations.count < 4 { + fullFoundations.append( + contentsOf: [[Card]](repeating: [], count: 4 - fullFoundations.count) + ) + } + if fillStockFromRemainder { + func identity(_ card: Card) -> Int { + (Suit.allCases.firstIndex(of: card.suit) ?? 0) * 16 + card.rank.rawValue + } + let placed = tableau.flatMap { $0 } + fullReserve + faceDownStock + fullWaste + + fullFoundations.flatMap { $0 } + let usedIdentities = Set(placed.map(identity)) + let remainder = TestCards.fullDeck().filter { card in + !usedIdentities.contains(identity(card)) + } + faceDownStock = remainder + faceDownStock + } + return GameState( + variant: .canfield, + stock: faceDownStock, + waste: fullWaste, + wasteDrawCount: wasteDrawCount ?? min(1, fullWaste.count), + freeCells: Array(repeating: nil, count: 4), + foundations: fullFoundations, + tableau: tableau, + reserve: fullReserve + ) + } + private static func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { seededShuffle(TestCards.fullDeck(faceUp: faceUp), seed: seed) } diff --git a/README.md b/README.md index b8470e7..d22f397 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS -- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, **Forty Thieves**, **Yukon**, and **Scorpion** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, **Forty Thieves**, **Yukon**, **Scorpion**, and **Canfield** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -31,3 +31,4 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | **Forty Thieves** | Two decks, ten columns — build down by suit one card at a time, managing a single-pass stock | [Rules](docs/rules/fortythieves.md) | | **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/rules/yukon.md) | | **Scorpion** | Spider's single-deck cousin — untangle suit runs in place with Yukon-style group moves | [Rules](docs/rules/scorpion.md) | +| **Canfield** | The casino classic (Demon in Britain) — drain a 13-card reserve onto foundations that start at a dealt rank and wrap | [Rules](docs/rules/canfield.md) | diff --git a/docs/rules/canfield.md b/docs/rules/canfield.md new file mode 100644 index 0000000..e5bf3f0 --- /dev/null +++ b/docs/rules/canfield.md @@ -0,0 +1,66 @@ +# Canfield Rules + +These rules describe Canfield as implemented in the app: the strict classic game — known as Demon in Britain, where it was first recorded — with a thirteen-card reserve, foundations that start at a dealt base rank and wrap, whole-pile tableau moves only, and a draw-three stock with unlimited redeals. The classic sources agree on the essentials; where modern software commonly relaxes them, the choices made here (and why) are called out below. + +## Objective +Move all 52 cards onto the four foundations, building each up by suit from the base rank — the rank dealt to the first foundation — and turning the corner from King to Ace along the way. + +## Terminology +- **Reserve:** A packet of thirteen cards, all face down except the exposed top card, which is always playable. British sources call it *the demon*. It must fill any empty tableau pile at once. +- **Base card:** The card dealt face up to the first foundation; its rank is where all four foundations start. +- **Tableau:** Four piles built down in alternating colors, wrapping from Ace to King. A pile moves onto another only in its entirety. +- **Foundations:** Four suit piles built up from the base rank, wrapping from King to Ace. Cards placed here never return to play. +- **Stock:** The face-down draw pile (34 cards after the deal). Three cards turn at a time, with unlimited redeals. +- **Waste:** Face-up cards turned from the stock; only the top card is playable. + +## Setup +- Use one standard 52-card deck (no jokers). +- **Reserve:** Deal 13 cards into one packet, the top card face up. +- **Base card:** Deal the next card face up onto the first foundation. +- **Tableau:** Deal one card face up onto each of the four piles. +- **Stock:** The remaining 34 cards, face down. The **waste** starts empty. + +## Play +### Tableau +- Build piles **down in alternating colors**, turning the corner from Ace to King — the K♠ plays onto the A♥ or A♦. +- A pile moves onto another pile **only in its entirety**; a partial sequence never moves, however well packed. The exposed top card may always play to a foundation. +- An **empty pile** fills at once from the reserve's top card — this is compulsory, and the app performs it automatically. Once the reserve is empty, fill a space with the **top waste card** whenever you choose; a space never fills from another tableau pile. + +### Foundations +- Each foundation starts with a card of the **base rank** and builds up in its suit, turning the corner from King to Ace, until it holds all thirteen cards. +- A card placed on a foundation is **locked** — it never returns to the tableau. + +### The reserve +- The exposed top card is always available, to a foundation or onto a tableau pile. When it leaves, the next reserve card turns face up. + +### The stock +- Tap the stock to turn **three** cards face up onto the waste, order preserved; fewer than three remaining turn together. +- Only the top waste card is playable, to the tableau or a foundation. +- When the stock is spent, tap again to turn the waste over — unshuffled — as the new stock. **Redeals are unlimited.** + +## Scoring +- Waste to tableau: +5. +- Waste to foundation: +10. +- Reserve to tableau: +5. +- Reserve to foundation: +10. +- Tableau to foundation: +10. +- Winning adds a time bonus that starts at 900 and drops one point per second. +- The score never goes below zero. + +## Winning +You win by moving all 52 cards to the foundations. The game is lost when no play exists from the reserve, tableau, or waste, and a full unchanged pass through the stock surfaces nothing playable. Canfield's reputation as a grind is earned — the casino legend has players buying the deck for $50 and winning $5 per card banked, against an average of five or six — but the deals themselves are more generous than the legend: solver studies find roughly two thirds winnable under these strict rules, with expert play converting about a third. Draining the reserve is the heart of the game. + +## Rule choices +The classic sources are unusually consistent for a patience game; the choices here follow them against the relaxations common in software: +- **Whole-pile movement only** — the classic rule in every printed source (Coops' *100 Games of Solitaire*: "sequences on tableau may be moved bodily, but not parts of sequences"; Morehead & Mott-Smith agree). Much modern software permits partial-sequence moves, a measurable easing (solver studies put it at roughly four points of winnability); the app keeps the by-the-book rule. +- **Wrapping in both directions** — foundations turn King-to-Ace and tableau builds turn Ace-to-King. Both are definitional: with a random base rank, the cards just below it would otherwise be nearly unplayable. +- **Compulsory reserve fill** — a space takes the reserve's top card at once, not at the player's option; choice enters only after the reserve is out, when the top waste card may fill a space (and nothing else may — not another tableau pile). +- **Locked foundations** — a banked card never returns to play. No classic source permits "worrying back"; some apps offer it as a house option. +- **Draw three with unlimited redeals** — universal across the classic sources. Deal-one variants exist (Rainbow) but are a different, far easier game. The in-app **Redeal** command replays the same deal from the start; it is a fresh attempt at the layout, not the stock recycle, which is unlimited and free. + +## Sources +- https://en.wikipedia.org/wiki/Canfield_(solitaire) +- https://politaire.com/article/canfield.html +- http://www.solitairecity.com/Demon.shtml +- https://www.goodsol.net/forum/vanilla/discussion/162/clarification-on-canfield-rules +- https://arxiv.org/abs/1906.12314 diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index ac7d5d4..d517851 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -29,6 +29,7 @@ tools/hint-probe/run.sh tripeaks 500 tools/hint-probe/run.sh golf 500 tools/hint-probe/run.sh fortythieves 500 tools/hint-probe/run.sh scorpion 500 +tools/hint-probe/run.sh canfield 500 ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -73,6 +74,7 @@ consecutive runs, serial and parallel. | `golf` | **22.6%** | 0.0% | | `fortythieves` | **3.4%** | 0.0% | | `scorpion` | **14.8%** | 2.8% | +| `canfield` | **25.0%** | 1.2% | Reading the table honestly: @@ -179,6 +181,23 @@ Reading the table honestly: same-class transfers are no-ops) also reproduced 14.8% exactly — the affected position class is rare enough that no outcome changed in 500 deals. +- **Canfield (25.0% vs 1.2%)**: solver studies put theoretical winnability + near 67% under the strict whole-pile rules this app implements, with expert + human play claiming ~35%, so the follower plays within reach of a skilled + human and far above the greedy floor. The random control winning 1.2% + despite unlimited redeals says Canfield wins are essentially never stumbled + into (contrast Klondike draw-1's 39.4%: Canfield's base-rank foundations and + 13-card reserve demand plans, not passes); the control also never reaches a + true deadlock — its 494 losses are all action caps, aimless cycling through + the recycling stock. Every follower loss is an honest deadlock proven by an + exhaustive search: Canfield is the one variant whose no-progress fallback + tap can *recycle* (not monotone like Forty Thieves' single-pass tap), so the + follower only taps on truncated verdicts and stops on exhaustive ones — + taps and recycles are inside the searched space, making exhaustion a proof + the game is over. Revisit events measure zero under that policy and are + gated to zero like Yukon's; zero action caps means no follower game ever + wandered. The banked-at-loss gap (median 14 vs 7) is the per-deal quality + signal on the lost majority, and the over-banking detector measures zero. - 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 @@ -192,20 +211,20 @@ add its sources to `run.sh`, then run 500 deals. Acceptance gates: - The hint column must **decisively beat the random control**. - **Zero stalemate-loops** for the hint player, machine-enforced: the probe exits nonzero if any hint follower loops in any variant. **Revisit events** - are additionally gated to zero for Yukon, Forty Thieves, and Scorpion - (their planners measure zero, so any revisit is a regression signal); - Spider's are reported but not gated — see the baseline notes for why a few - transients per 500 deals are structural there. (Revisits are reported - without reclassifying the game, so win rates stay honestly measured; the - exit code is what enforces the gates.) + are additionally gated to zero for Yukon, Forty Thieves, Scorpion, and + Canfield (their planners measure zero, so any revisit is a regression + signal); Spider's are reported but not gated — see the baseline notes for + why a few transients per 500 deals are structural there. (Revisits are + reported without reclassifying the game, so win rates stay honestly + measured; the exit code is what enforces the gates.) - **Watch the over-banking detector** (`losses with >=40 banked`): it should be zero for stockless variants (Yukon and FreeCell measure zero). The Klondike draw-1 baseline records a single such loss, and Spider records 6/7/1 by suit count (its losses can strand nearly-done boards); Forty Thieves records 167 — legitimately high, its losses strand well-banked boards by nature; Scorpion measures zero by structure (a loss with three - banked runs would need 40 cards banked — never observed). Treat any - increase as a regression. + banked runs would need 40 cards banked — never observed); Canfield measures + zero. Treat any increase as a regression. - Record the measured numbers in the table above; they become the variant's regression baseline. Mechanical refactors must reproduce every figure exactly; deliberate quality changes must move the hint column up, never diff --git a/tools/hint-probe/main.swift b/tools/hint-probe/main.swift index d9ec04d..63eac42 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -240,6 +240,34 @@ func seededDeal(variant: GameVariant, seed: UInt64, spiderSuitCount: SpiderSuitC foundations: Array(repeating: [], count: 4), tableau: tableau ) + + case .canfield: + // Mirrors GameState.newCanfieldGame (and GameStateFixtures.seededCanfieldDeal). + var deck = seededDeck(seed: seed, faceUp: false) + var reserve: [Card] = [] + for _ in 0.. UInt64 { mix(0xF9) if let card = slot { mix(card: card) } } + mix(0xF8) + for card in state.reserve { mix(card: card) } return hash } @@ -348,6 +378,12 @@ func fortyThievesStockTap(_ state: GameState) -> GameState? { FortyThievesPlanner.apply(.stockTap, to: state) } +/// Mirrors handleCanfieldStockTap in the session: turn three, or turn the +/// spent waste over. The planner's apply is the same pure logic. +func canfieldStockTap(_ state: GameState) -> GameState? { + CanfieldPlanner.apply(.stockTap, to: state) +} + func golfCleared(_ state: GameState) -> Int { GolfGameRules.dealTableauCardCount - state.tableau.reduce(0) { $0 + $1.count } } @@ -390,8 +426,9 @@ enum Outcome { /// Golf at 51: every action consumes a board card or a stock card.) func actionCap(for variant: GameVariant) -> Int { switch variant { - case .klondike, .fortyThieves: - // Forty Thieves needs 104 banks plus 64 draws plus tableau grooming. + case .klondike, .fortyThieves, .canfield: + // Forty Thieves needs 104 banks plus 64 draws plus tableau grooming; + // Klondike and Canfield need headroom for unlimited stock cycling. return 1_200 case .spider: return 1_000 @@ -878,6 +915,76 @@ func playFortyThievesFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitE return (.actionCap(foundation: foundationCount(state)), revisitEvents) } +func playCanfieldFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: Int) { + // Replicates HintPlanner's Canfield path without its wall-clock deadline: + // follow each improving line (which may include stock taps) to its end, + // then replan. On no-progress the exhaustive/truncated distinction is + // load-bearing: an exhaustive search proved the position dead (taps and + // recycles were searched too), so the follower stops there like the real + // hint stack goes silent; only a truncated verdict falls back to one tap. + // Unlike Forty Thieves' single-pass tap the fallback is not monotone — + // a tap on the spent stock recycles — so the revisit accounting below is + // what verifies the fallback cannot circle in practice. + var state = seededDeal(variant: .canfield, seed: seed) + var visitCounts: [UInt64: Int] = [fingerprint(state): 1] + var revisitEvents = 0 + var actions = 0 + + func record(_ nextState: GameState) -> Outcome? { + state = nextState + actions += 1 + let key = fingerprint(state) + let count = (visitCounts[key] ?? 0) + 1 + visitCounts[key] = count + if count > 1 { revisitEvents += 1 } + // A transient cross-line revisit is survivable (the next plan differs); + // a third visit to the same exact layout means the hints are looping. + if count >= 3 { + return .stalemateLoop(foundation: foundationCount(state)) + } + // Cap before win, matching the other players: their win check only + // runs on the next loop iteration, so a win landed on the final + // permitted action classifies as .actionCap everywhere. + if actions >= actionCap(for: .canfield) { + return .actionCap(foundation: foundationCount(state)) + } + if state.isWon { return .win(moves: actions) } + return nil + } + + func applied(_ action: CanfieldPlanner.PlannedAction) -> GameState? { + switch action { + case .move(let selection, let destination): + return apply(selection, destination, to: state, stockDrawCount: 3) + case .stockTap: + return canfieldStockTap(state) + } + } + + while actions < actionCap(for: .canfield) { + if state.isWon { return (.win(moves: actions), revisitEvents) } + switch CanfieldPlanner.bestLine(in: state) { + case .line(let line): + for action in line { + guard let next = applied(action) else { + fatalError("Seed \(seed): illegal Canfield hint") + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + + case .noProgress(let searchWasExhaustive): + guard !searchWasExhaustive else { + return (.deadlock(foundation: foundationCount(state)), revisitEvents) + } + guard let next = canfieldStockTap(state) else { + return (.deadlock(foundation: foundationCount(state)), revisitEvents) + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + } + return (.actionCap(foundation: foundationCount(state)), revisitEvents) +} + // MARK: - Control player // The random-moves floor calibrates each variant's deal universe. Deliberately @@ -906,7 +1013,7 @@ func playRandom( lossProgress = triPeaksCleared case .golf: lossProgress = golfCleared - case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion: + case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion, .canfield: lossProgress = foundationCount } var actions = 0 @@ -922,7 +1029,7 @@ func playRandom( } let canTapStock: Bool switch variant { - case .klondike: + case .klondike, .canfield: canTapStock = !state.stock.isEmpty || !state.waste.isEmpty case .spider: canTapStock = SpiderGameRules.canDealFromStock(state: state) @@ -954,6 +1061,8 @@ func playRandom( tapped = golfStockTap(state) case .fortyThieves: tapped = fortyThievesStockTap(state) + case .canfield: + tapped = canfieldStockTap(state) case .klondike, .freecell, .yukon: tapped = stockTap(state, drawCount: drawCount) } @@ -1087,6 +1196,8 @@ func run( label = "fortythieves" case .scorpion: label = "scorpion" + case .canfield: + label = "canfield" } // Pyramid, TriPeaks, and Golf bank no foundations; their loss columns // record board cards cleared. @@ -1098,7 +1209,7 @@ func run( lossProgressLabel = "tripeaks-cleared-at-loss" case .golf: lossProgressLabel = "golf-cleared-at-loss" - case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion: + case .klondike, .freecell, .yukon, .spider, .fortyThieves, .scorpion, .canfield: lossProgressLabel = "foundation-at-loss" } let tracksOverBanking = variant != .pyramid && variant != .tripeaks && variant != .golf @@ -1127,6 +1238,8 @@ func run( return playFortyThievesFollowingHints(seed: seed) case .scorpion: return playScorpionFollowingHints(seed: seed) + case .canfield: + return playCanfieldFollowingHints(seed: seed) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -1145,7 +1258,8 @@ func run( tracksOverBanking: tracksOverBanking ) print(String(format: "elapsed: %.1fs", seconds)) - if variant == .yukon || variant == .spider || variant == .fortyThieves || variant == .scorpion { + if variant == .yukon || variant == .spider || variant == .fortyThieves || variant == .scorpion + || variant == .canfield { print("hint revisit events: \(revisitEvents)") } if followerLoops > 0 { @@ -1155,10 +1269,13 @@ func run( // Spider revisit events are reported but not gated: the deal-preparation // fallback deliberately plays score-losing fills, so a later line can // transiently re-cross an earlier position (a handful per 500 deals). - // Yukon's, Forty Thieves', and Scorpion's planners measure zero (Forty - // Thieves' bare stock-tap fallback and Scorpion's stock-deal fallback are - // strictly monotone), so for them any revisit is a regression. - if variant == .yukon || variant == .fortyThieves || variant == .scorpion, revisitEvents > 0 { + // Yukon's, Forty Thieves', Scorpion's, and Canfield's planners measure + // zero (Forty Thieves' bare stock-tap fallback and Scorpion's stock-deal + // fallback are strictly monotone; Canfield's tap can recycle but its + // exhaustion-aware fallback measured zero revisits over 500 deals), so + // for them any revisit is a regression. + if variant == .yukon || variant == .fortyThieves || variant == .scorpion || variant == .canfield, + revisitEvents > 0 { print("GATE VIOLATION: \(label) hint follower revisited positions \(revisitEvents) time(s)") gateViolations += revisitEvents } @@ -1192,7 +1309,8 @@ setvbuf(stdout, nil, _IOLBF, 0) func exitWithUsage() -> Never { print( - "usage: run.sh " + "usage: run.sh " + "[deals >= 1] [klondike draw count: 1 or 3 | spider suit count: 1, 2, or 4]" ) exit(1) @@ -1236,6 +1354,8 @@ case "fortythieves": run(variant: .fortyThieves, seeds: seeds, drawCount: 1) case "scorpion": run(variant: .scorpion, seeds: seeds, drawCount: 3) +case "canfield": + run(variant: .canfield, seeds: seeds, drawCount: 3) case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) @@ -1249,6 +1369,7 @@ case "all": run(variant: .golf, seeds: seeds, drawCount: 1) run(variant: .fortyThieves, seeds: seeds, drawCount: 1) run(variant: .scorpion, seeds: seeds, drawCount: 3) + run(variant: .canfield, seeds: seeds, drawCount: 3) default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index 5056868..db4439d 100755 --- a/tools/hint-probe/run.sh +++ b/tools/hint-probe/run.sh @@ -1,6 +1,6 @@ #!/bin/bash # Compiles the hint-quality probe against the UI-free Game sources and runs it. -# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count | spider suit count] +# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count | spider suit count] set -euo pipefail cd "$(dirname "$0")/../.." @@ -53,6 +53,10 @@ SOURCES=( ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift + ComputerSolitaire/Game/Canfield/GameStateCanfield.swift + ComputerSolitaire/Game/Canfield/GameRulesCanfield.swift + ComputerSolitaire/Game/Canfield/AutoMoveAdvisorCanfield.swift + ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift ) for source in "${SOURCES[@]}"; do