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