diff --git a/AGENTS.md b/AGENTS.md index 525a934..0a188da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ This file defines hard project constraints for any coding agent working in this - Use plain lowercase kebab-case for branch names. Keep names descriptive and do not include issue numbers, prefixes, or namespaces such as `feature/`, `fix/`, usernames, or agent names. - Before every commit or amend, show the exact current diff and validation, then get explicit approval. Branch or pull-request requests are not commit approval; later changes require fresh approval. - Never amend, rebase, squash, reset, rewrite history, or force-push without explicit approval for that exact operation. -- Write commit messages entirely lowercase. Use the imperative mood for the subject, keep each commit focused on one logical change, do not use type or scope prefixes, and do not end the subject with a period. Add a body when the reason or important tradeoffs are not clear from the subject. +- Write commit messages entirely lowercase. Use the imperative mood for the subject, keep each commit focused on one logical change when the changes separate cleanly, do not use type or scope prefixes, and do not end the subject with a period. Add a body when the reason or important tradeoffs are not clear from the subject. Do not arbitrarily split existing completed work into multiple commits. - Keep each pull request focused on one coherent change. - Write concise, specific, imperative pull request titles in sentence case. Do not use prefixes or trailing periods, and make the title understandable without the branch name. - Pull request descriptions must include `What Changed`, `Why`, and `Validation`. Include `UI Changes` only when the pull request changes the UI. Keep descriptions concise, self-contained, complete, and accurate to the final diff. diff --git a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift new file mode 100644 index 0000000..4ffbadc --- /dev/null +++ b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift @@ -0,0 +1,67 @@ +import CoreGraphics +import Foundation + +/// Builds the forward flight for a stock-onto-tableau deal (Spider's ten-card +/// row, Scorpion's three-card stock): overlay cards leave the stock in pile +/// order, flip face up in the air, and land on their piles' tops. The mirror +/// of `UndoAnimationCoordinator`'s `.dealTableauRow` flight, which flies the +/// same cards back to the same stock anchors. +enum DealAnimationCoordinator { + struct Plan { + let cards: [DrawAnimationCard] + let cardIDs: Set + let token: UUID + let travelDuration: Double + /// Spring tail after the nominal travel time; the overlay comes down + /// once the cards have visibly settled. + let settleDuration: Double + /// The last card's takeoff delay; the whole deal is done after + /// `maxDelay + travelDuration + settleDuration`. + let maxDelay: Double + } + + /// Per-card takeoff stagger: the packet leaves the stock as one quick + /// left-to-right sweep, reading as a deal rather than simultaneous pops. + static let staggerInterval: Double = 0.05 + + /// `dealtCards` in pile order (leftmost pile's card first). Cards without + /// a published frame — a card banked the instant it landed — are skipped; + /// they surface through the banking animation instead. + static func makeDealPlan( + dealtCards: [Card], + cardFrames: [UUID: CGRect], + stockFrame: CGRect + ) -> Plan? { + guard !dealtCards.isEmpty, stockFrame != .zero else { return nil } + + var items: [DrawAnimationCard] = [] + for (index, card) in dealtCards.enumerated() { + guard let startFrame = UndoAnimationCoordinator.stockAnchorFrame( + for: index, + stockFrame: stockFrame + ), + let endFrame = cardFrames[card.id] else { + continue + } + items.append( + DrawAnimationCard( + id: card.id, + card: card, + start: CGPoint(x: startFrame.midX, y: startFrame.midY), + end: CGPoint(x: endFrame.midX, y: endFrame.midY), + delay: staggerInterval * Double(index) + ) + ) + } + guard !items.isEmpty else { return nil } + + return Plan( + cards: items, + cardIDs: Set(items.map(\.id)), + token: UUID(), + travelDuration: 0.32, + settleDuration: 0.12, + maxDelay: items.last?.delay ?? 0 + ) + } +} diff --git a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift index 5b849ee..271a13f 100644 --- a/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/UndoAnimationCoordinator.swift @@ -3,7 +3,12 @@ import Foundation struct UndoAnimationItem: Identifiable { let id: UUID + /// The card as it should render at the current flight phase. Plans build + /// items with the pre-undo face; when the flight starts, the driver swaps + /// in `endFaceUp` so the card visibly flips in the air — the mirror of the + /// forward deal/draw flights — instead of snapping faces on landing. let card: Card + let endFaceUp: Bool let startFrame: CGRect let endFrame: CGRect } @@ -27,11 +32,21 @@ enum UndoAnimationCoordinator { var targets: [UUID: UndoAnimationEndTarget] = [:] let cardIDs = context.cardIDs + func endFaceUp(_ id: UUID, fallback: Card) -> Bool { + (afterCards[id] ?? fallback).isFaceUp + } + switch context.action { case .moveSelection: for id in cardIDs { guard let card = beforeCards[id] ?? afterCards[id], let startFrame = cardFrames[id] else { continue } - items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) + items.append(UndoAnimationItem( + id: id, + card: card, + endFaceUp: endFaceUp(id, fallback: card), + startFrame: startFrame, + endFrame: startFrame + )) targets[id] = .card(id) } return Plan(items: items, targets: targets, needsPostUndoFrames: true) @@ -47,18 +62,33 @@ enum UndoAnimationCoordinator { ) else { continue } - items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) + items.append(UndoAnimationItem( + id: id, + card: card, + endFaceUp: endFaceUp(id, fallback: card), + startFrame: startFrame, + endFrame: startFrame + )) targets[id] = .stock(index) } return Plan(items: items, targets: targets, needsPostUndoFrames: false) case .recycleWaste: + // These cards sit face down on the stock when the flight begins, + // so the pre-undo face keeps the takeoff honest; they flip face up + // in the air on their way back to the waste. for (index, id) in cardIDs.enumerated() { - guard let card = afterCards[id] ?? beforeCards[id], + guard let card = beforeCards[id] ?? afterCards[id], let startFrame = stockAnchorFrame(for: index, stockFrame: stockFrame) else { continue } - items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) + items.append(UndoAnimationItem( + id: id, + card: card, + endFaceUp: endFaceUp(id, fallback: card), + startFrame: startFrame, + endFrame: startFrame + )) targets[id] = .card(id) } return Plan(items: items, targets: targets, needsPostUndoFrames: true) @@ -70,7 +100,13 @@ enum UndoAnimationCoordinator { for (index, id) in cardIDs.enumerated() { guard let card = beforeCards[id] ?? afterCards[id], let startFrame = cardFrames[id] else { continue } - items.append(UndoAnimationItem(id: id, card: card, startFrame: startFrame, endFrame: startFrame)) + items.append(UndoAnimationItem( + id: id, + card: card, + endFaceUp: endFaceUp(id, fallback: card), + startFrame: startFrame, + endFrame: startFrame + )) targets[id] = .stock(index) } return Plan(items: items, targets: targets, needsPostUndoFrames: false) diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index f741f5e..05a9c07 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -35,7 +35,8 @@ enum ScreenshotFixtures { ScreenshotFixture(name: "spider", title: "Spider – 2 suits"), ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal"), ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal"), - ScreenshotFixture(name: "golf", title: "Golf – fresh deal") + ScreenshotFixture(name: "golf", title: "Golf – fresh deal"), + ScreenshotFixture(name: "scorpion", title: "Scorpion – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/scorpion.json b/ComputerSolitaire/Fixtures/scorpion.json new file mode 100644 index 0000000..97623b7 --- /dev/null +++ b/ComputerSolitaire/Fixtures/scorpion.json @@ -0,0 +1,594 @@ +{ + "gameStartedAt" : 721692797, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 0, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 0, + "scoringDrawCount" : 3, + "state" : { + "discard" : [ + + ], + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "pyramid" : [ + + ], + "stock" : [ + { + "id" : "B0B93E17-6228-40D1-85A8-8F0CBF085D74", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "96049FD5-B0C6-4C39-AD0C-A57BCE5999DB", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "E0616D6C-0B97-4A03-A147-1BFBE9F31341", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + } + ], + "tableau" : [ + [ + { + "id" : "7A429CB8-364E-4C1C-9DC2-265B07B7C063", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "07F63F53-BB08-4FB4-AD1D-3E1E71675A38", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2961964B-29A2-4DAE-8026-B2C80DF06954", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "79C7CB45-8B71-41B3-AD52-C2D954188A35", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "5618190D-DF1C-46AF-8E6D-1B9956BE949D", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "8280ED25-959A-4818-AA45-1C576847378D", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "88B4D8F4-F212-437A-AE4B-F3EA907CB271", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "57A7F368-85E7-4BC4-BE99-73004648F3D2", + "isFaceUp" : false, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "617832AF-D494-4723-A636-B8A728E66542", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "66DACC2F-F496-4655-9211-E00DC632A79B", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "9887D81D-D2F4-49C3-9FF7-9808E844C493", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "415C2708-34F8-4B2B-A38D-52BAF65E2954", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "FB54FB6A-6115-4706-BA81-6532C3E1D137", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C6FB6650-FC2A-4D6C-AA78-B5101BDF74D6", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "DC927BCF-0533-4FC4-8C4E-CF590A33D041", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "7F13E2F6-56EC-4F9C-AAA7-D8B6E8A8329C", + "isFaceUp" : false, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "736F37FF-264C-4CA4-A03E-F6577716A1F3", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "6747EA35-3234-48D7-AB1A-293941DDE6F8", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "93003AB7-616B-4BB8-BDB2-037FE3B23052", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "7C40F9ED-5752-45DA-B939-5230717298BF", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "2C1CE50D-DC2C-43EC-B45A-59BD3B4D9830", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "15B92C14-67C6-4CAD-A96D-A8BF6CCCA0A3", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "13BBD744-02FE-494E-A0AE-584945B7B9E8", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "0950CD79-BBEF-461C-A163-193F9DA4F2F5", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "A95957AC-355B-4F7E-BC12-9AC0689D95F1", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "98DAA934-E924-4764-8ABB-DAD459EB38EF", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "F158D58B-EC43-4603-B0AD-3943E44D7524", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "1A6C99B6-6B3C-4C26-86FA-E5A9C6405669", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "EC656386-1CF7-499A-949C-A8F2967503AE", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "DF0E9EFA-0D3D-43B7-BB78-BDD9F4CCB0AB", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "EE6926A6-F3CF-43BD-93F2-87AC667D3ACC", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "A5A33901-F444-44BB-A942-D77D8C84B633", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "DC5B655F-7F9D-45AF-B1F7-38674CB52D6E", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "F5A800E5-3220-4536-9468-A8E0D38EF649", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "8281BD73-CA4D-4FEF-93E1-4CBCC9BD3551", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "8907976E-9FBB-4205-965F-03263A7C8E2D", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "BCD09447-B727-4E65-869A-8927C07B65CE", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "B83ABB62-2B77-4805-BB8B-4744A2C3A571", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "75F6790F-9CD8-4908-AA53-FAB4D0E68FEE", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "5D8AB49B-D772-4F4E-917A-C78D9B21098E", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "86809C00-0987-4198-BB9F-D6981A015A70", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "C60937FE-7945-4B28-BDBE-EFDE576E85F8", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "0F06078D-6974-4C0E-B1D2-CC189A07A222", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "C67EB978-D693-44B0-B0B6-53955759479B", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "FF5798F8-E778-4729-811E-C7275DE187CB", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "93996014-AC58-4297-AD8E-51AFF4B1EAA6", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "CE550AF7-DF6E-4AE1-9888-1DC29954FBD7", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "D1D35040-DABD-406A-9C6B-673902DB29B2", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "27378495-5C65-4BFF-A7B4-161A7A916644", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + } + ] + ], + "triPeaks" : [ + + ], + "triPeaksChainLength" : 0, + "variant" : "scorpion", + "waste" : [ + + ], + "wasteDrawCount" : 0, + "wasteRecyclesUsed" : 0 + }, + "stockDrawCount" : 3, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index bdacb05..ce1907a 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -48,9 +48,9 @@ private extension AutoFinishPlanner { return true case .yukon: return !state.tableau.joined().contains(where: { !$0.isFaceUp }) - case .spider: - // Spider banks completed runs automatically; there is never a - // foundation run left for auto-finish to play. + case .spider, .scorpion: + // Spider and Scorpion bank completed runs automatically; there is + // never a foundation run left for auto-finish to play. return false case .pyramid: // Pyramid has no deterministic mop-up phase: which pair to remove diff --git a/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift b/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift new file mode 100644 index 0000000..fea13e2 --- /dev/null +++ b/ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift @@ -0,0 +1,59 @@ +import Foundation + +enum ScorpionAutoMoveAdvisor { + static func allowsTableauPickup(of cards: [Card], in state: GameState) -> Bool { + // Scorpion moves groups Yukon-style: any face-up card can be picked up + // together with every card above it, regardless of whether they form a + // sequence. Only the picked card must connect at the destination. + true + } + + static func allowsTableauTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + true + } + + static func isRedundantEmptyColumnTransfer( + selection: Selection, + destinationTableauIndex: Int, + in state: GameState + ) -> Bool { + guard AutoMoveAdvisor.isRedundantWholePileKingTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) else { + return false + } + // While the stock is undealt, each of the first `stock.count` columns + // awaits its own specific dealt card, so relocating a whole king pile + // into or out of one changes the position the deal produces — vacating + // a target column lands its card in the open, filling one buries the + // card on the pile. Only transfers between the interchangeable columns + // are no-ops (all of them, once the stock is spent). Mirrors the + // interchangeability classes in `ScorpionPlanner.stateHash`. + guard case .tableau(let sourcePile, _) = selection.source else { return false } + let positionalCount = state.stock.count + return sourcePile >= positionalCount && destinationTableauIndex >= positionalCount + } + + static func appendAuxiliaryDestinations( + for selection: Selection, + in state: GameState, + destinations: inout [Destination] + ) { + // Scorpion has no auxiliary destination type; completed runs bank + // themselves. + } + + static func applyTableauSourceRemovalEffects(on state: inout GameState, pileIndex: Int) { + AutoMoveAdvisor.flipExposedFaceDownTop(on: &state, pileIndex: pileIndex) + } + + static func applyTableauDestinationEffects(on state: inout GameState, pileIndex: Int) { + ScorpionGameRules.resolveCompletedRuns(in: &state) + } +} diff --git a/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift b/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift new file mode 100644 index 0000000..ef5bcf8 --- /dev/null +++ b/ComputerSolitaire/Game/Scorpion/GamePersistenceScorpion.swift @@ -0,0 +1,30 @@ +import Foundation + +enum ScorpionPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == 7 else { return false } + // The stock deals exactly once, wholesale: three cards or none. + guard state.stock.count == 3 || state.stock.isEmpty else { return false } + guard state.stock.allSatisfy({ !$0.isFaceUp }) else { return false } + guard state.waste.isEmpty else { return false } + // Scorpion renders no free-cell slots, so a card stranded there would + // be invisible and the game unwinnable. + guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } + // The pyramid and TriPeaks fields belong to those variants alone; a card + // stranded there would be invisible here. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.wasteRecyclesUsed == 0 else { + return false + } + guard state.triPeaks.isEmpty, state.triPeaksChainLength == 0 else { return false } + guard state.wasteDrawCount == 0 else { return false } + return state.foundations.allSatisfy(isValidFoundationPile) + } + + /// A Scorpion foundation is empty until a run completes, then holds exactly + /// one banked run: thirteen same-suit cards, Ace at the bottom. + private static func isValidFoundationPile(_ pile: [Card]) -> Bool { + guard !pile.isEmpty else { return true } + guard pile.count == Rank.allCases.count else { return false } + return SharedGameRules.isDescendingSameSuitRun(Array(pile.reversed())) + } +} diff --git a/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift b/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift new file mode 100644 index 0000000..de57632 --- /dev/null +++ b/ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift @@ -0,0 +1,82 @@ +import Foundation + +enum ScorpionGameRules { + /// Scorpion's landing rule: an empty pile takes only a king, and a face-up + /// top takes the card one rank lower of the same suit. + static func canMoveToTableau(card: Card, destinationPile: [Card]) -> Bool { + guard let top = destinationPile.last else { return card.rank == .king } + return top.isFaceUp + && top.suit == card.suit + && card.rank.rawValue == top.rank.rawValue - 1 + } + + /// The three-card stock may be dealt at any time; it is used exactly once. + static func canDealFromStock(state: GameState) -> Bool { + !state.stock.isEmpty + } + + /// Index where a complete face-up King-to-Ace same-suit run starts at the + /// top of the pile, or `nil` when the pile holds none. + static func completedRunStartIndex(in pile: [Card]) -> Int? { + let runLength = Rank.allCases.count + guard pile.count >= runLength else { return nil } + let startIndex = pile.count - runLength + let run = Array(pile[startIndex...]) + guard run.first?.rank == .king else { return nil } + guard SharedGameRules.isDescendingSameSuitRun(run) else { return nil } + return startIndex + } + + /// What a resolution sweep did: how many runs it banked, and how many + /// face-down cards those removals turned face up. The session scores both + /// — a reveal is a reveal whether a move or a banked run exposed it. + struct Resolution: Equatable { + var bankedRunCount = 0 + var revealedCardCount = 0 + } + + /// Banks every complete run to the first empty foundation (Ace at the + /// bottom, King on top), flipping the tops the removals expose, until no + /// complete run remains: a removal or a dealt card can complete another. + /// Returns what the sweep banked and revealed. + @discardableResult + static func resolveCompletedRuns(in state: inout GameState) -> Resolution { + var resolution = Resolution() + var didRemoveRun = true + while didRemoveRun { + didRemoveRun = false + for pileIndex in state.tableau.indices { + guard let startIndex = completedRunStartIndex(in: state.tableau[pileIndex]), + let foundationIndex = state.foundations.firstIndex(where: \.isEmpty) else { + continue + } + let run = Array(state.tableau[pileIndex][startIndex...]) + state.tableau[pileIndex].removeSubrange(startIndex...) + state.foundations[foundationIndex] = run.reversed() + if state.tableau[pileIndex].last?.isFaceUp == false { + resolution.revealedCardCount += 1 + } + AutoMoveAdvisor.flipExposedFaceDownTop(on: &state, pileIndex: pileIndex) + resolution.bankedRunCount += 1 + didRemoveRun = true + } + } + return resolution + } + + /// Deals the stock's three cards face-up, one onto each of the first three + /// piles left to right, then banks any runs the deal completed. Returns + /// what the resolution sweep banked and revealed, or `nil` when the stock + /// is already spent. Shared verbatim by the session, the planner, and the + /// hint probe so simulated deals match real ones. + static func dealStock(in state: inout GameState) -> Resolution? { + guard canDealFromStock(state: state) else { return nil } + let dealCount = state.stock.count + for pileIndex in 0.. 0 else { return } + // A banked run exposing the face-down card beneath it is a reveal + // like any other; the rules promise +5 for every card turned face up. + for _ in 0.. GameState { + var deck = Card.fullDeck().shuffled() + var tableau = Array(repeating: [Card](), count: 7) + + for pileIndex in 0..<7 { + let faceDownCount = pileIndex < 4 ? 3 : 0 + for cardIndex in 0..<7 { + var card = deck.removeLast() + card.isFaceUp = cardIndex >= faceDownCount + tableau[pileIndex].append(card) + } + } + + return GameState( + variant: .scorpion, + stock: deck, + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } +} diff --git a/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift b/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift new file mode 100644 index 0000000..11fb7c9 --- /dev/null +++ b/ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift @@ -0,0 +1,377 @@ +import Foundation + +/// Bounded best-first hint planner for Scorpion. +/// +/// Searches sequences of real actions — tableau moves and the single stock +/// deal — up to a node/time budget, scoring positions by banked runs, revealed +/// cards, open columns, in-suit ordering, and same-suit knots. `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 Yukon, every Scorpion tableau move is reversible until a card +/// flips, the stock deals, or a run banks, 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 cards the player hasn't seen yet, +/// but it only ever recommends actions that are legal right now. Single-deck +/// building narrows the tree sharply: every non-king card has exactly one +/// landing card (its same-suit successor), which must be an exposed top, and +/// kings additionally target only empty columns. That uniqueness also makes +/// Spider's same-suit-unbind prune unnecessary — a card already lying on its +/// successor has no other landing card anywhere, so no action re-hosts it. +/// Searching through the deal lets the planner groom the tableau *before* +/// recommending it; the deal itself is score-neutral (the heuristic has no +/// stock term), so deal-crossing lines only win when the flips and joins they +/// enable pay for them. Scorpion banks completed runs automatically and they +/// never return, so there is no rollback stage. +/// +/// Measured in the `tools/hint-probe` ledger: following every hint wins 14.8% +/// of 500 seeded deals versus the random control's 2.8%, with zero revisit +/// events — at the level of published practical win rates for Scorpion. +enum ScorpionPlanner { + struct Limits { + var maxNodes: Int + var maxDepth: Int + var deadline: Date? + + // Scorpion branches narrowest of the tableau variants (one landing + // card per non-king selection), so 30k nodes reaches deep lines. + // Affordable because lines are cached: the search only runs when a + // followed line runs out, not on every hint. + init(maxNodes: Int = 30_000, maxDepth: Int = 64, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.maxDepth = maxDepth + self.deadline = deadline + } + } + + enum PlannedAction { + case move(selection: Selection, destination: Destination) + case stockDeal + } + + 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 tableau cannot progress without the deal. + 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 + } + switch action { + case .move(let selection, let destination): + return .move(HintAdvisor.HintMove(selection: selection, destination: destination)) + case .stockDeal: + return .stockTap + } + } + + /// 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(160) + 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)))) + } + // The stock deals exactly once, wholesale, so its count (3 or 0) + // identifies its exact contents. + key.append("#\(state.stock.count)") + 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 + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .scorpion else { return .noProgress(searchWasExhaustive: false) } + return search(in: state, limits: limits) + } +} + +// MARK: - Search internals + +private extension ScorpionPlanner { + static func search(in state: GameState, limits: Limits) -> 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 reveals a card or banks a run is a solid hint; once one + // is in hand, cap how long we keep hunting for something better. The + // floor matches Yukon's: Scorpion's narrow branching keeps each + // expansion cheap. + if let best, best.score - rootScore >= 20, expansions >= 16_384 { + break + } + + for action in actions(from: node.state) { + guard let nextState = apply(action, to: node.state) else { continue } + guard visited.insert(stateHash(nextState)).inserted else { continue } + + let nextScore = score(nextState) + nodes.append( + Node( + state: nextState, + parent: nodeIndex, + action: action, + depth: node.depth + 1, + score: nextScore + ) + ) + order += 1 + // Best-first on score, shallow bias so equal outcomes prefer short lines. + heap.push( + HeapEntry( + priority: nextScore * 4 - (node.depth + 1), + order: order, + index: nodes.count - 1 + ) + ) + } + } + + guard let best, let actions = line(to: best.index, nodes: nodes) else { + return .noProgress(searchWasExhaustive: !wasTruncated) + } + return .line(actions) + } + + struct Node { + let state: GameState + let parent: Int + let action: PlannedAction? + let depth: Int + let score: Int + } + + struct HeapEntry: HeapPrioritizable { + let priority: Int + let order: Int + let index: Int + + func takesPriority(over other: HeapEntry) -> Bool { + priority != other.priority ? priority > other.priority : order < other.order + } + } + + static func score(_ state: GameState) -> Int { + var hiddenCount = 0 + var emptyPiles = 0 + var suitedRunBonus = 0 + var sameSuitInversions = 0 + for pile in state.tableau { + if pile.isEmpty { emptyPiles += 1 } + var suitedRunLength = 1 + for index in pile.indices { + let card = pile[index] + if card.isFaceUp { + if index + 1 < pile.count { + let upper = pile[index + 1] + if upper.suit == card.suit, + upper.rank.rawValue == card.rank.rawValue - 1 { + suitedRunLength += 1 + continue + } + } + } else { + hiddenCount += 1 + } + // The suited run ends here (or the pile did); credit it. + suitedRunBonus += (suitedRunLength - 1) * (suitedRunLength - 1) + suitedRunLength = 1 + } + for index in pile.indices { + let card = pile[index] + for upperIndex in (index + 1).. card.rank { + sameSuitInversions += 1 + } + } + } + let bankedCards = state.foundations.reduce(0) { $0 + $1.count } + // Between reveals, progress in Scorpion is untangling into suits: reward + // suited runs quadratically in their length — only thirteen-long runs + // bank, and a linear count would never prefer consolidating two short + // runs into one long one — and penalize burying a card under a higher + // card of its own suit (that card must move again, onto its unique + // landing card, before the suit can finish). Off-suit descending pairs + // earn nothing: they neither move as a unit nor enable a landing. Open + // columns rate between Yukon's 4 and Spider's 10 — Scorpion's take Kings + // only and nothing forces filling them — and stay below one reveal so + // the search digs rather than hoards. No stock term: rewarding the deal + // would burn the three-card reserve before the tableau is groomed. + return bankedCards * 20 + - hiddenCount * 25 + + emptyPiles * 5 + + suitedRunBonus + - sameSuitInversions * 3 + } + + static func actions(from state: GameState) -> [PlannedAction] { + // Empty columns are interchangeable only within their deal class: + // while the stock is undealt, each of columns 0–2 still awaits its own + // specific stock card, so they stay distinct from each other and from + // the rest; once the stock is spent every column is interchangeable. + // Searching a drop into every interchangeable twin only multiplies + // column-permuted duplicates, so canonicalize to the class's first. + // (Players can still drop on any empty column.) + let interchangeableStart = state.stock.isEmpty ? 0 : 3 + let firstInterchangeableEmpty = state.tableau.indices.first { + $0 >= interchangeableStart && state.tableau[$0].isEmpty + } + var actions: [PlannedAction] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + if case .tableau(let index) = destination, + state.tableau[index].isEmpty, + index >= interchangeableStart, + index != firstInterchangeableEmpty { + continue + } + actions.append(.move(selection: selection, destination: destination)) + } + } + if ScorpionGameRules.canDealFromStock(state: state) { + actions.append(.stockDeal) + } + return actions + } + + /// Applies an action without re-validating legality: the planner only feeds + /// in actions it just generated, and revalidating each one there dominates + /// search cost. Mirrors the session's move effects, including banking any + /// completed run. + static func apply(_ action: PlannedAction, to state: GameState) -> GameState? { + var nextState = state + switch action { + case .move(let selection, let destination): + guard case .tableau(let pile, let index) = selection.source else { return nil } + nextState.tableau[pile].removeSubrange(index.. UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { + hash = (hash ^ UInt64(value)) &* 0x100000001b3 + } + func encode(card: Card) -> UInt8 { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + return UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0)) + } + mix(UInt8(state.stock.count)) + for suit in Suit.allCases { + mix(0xFE) + let bankedRuns = state.foundations.count { $0.first?.suit == suit } + mix(UInt8(bankedRuns)) + } + let positionalCount = state.stock.isEmpty ? 0 : 3 + for pile in state.tableau.prefix(positionalCount) { + mix(0xFD) + for card in pile { mix(encode(card: card)) } + } + let encodedPiles = state.tableau.dropFirst(positionalCount) + .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/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index a823c7f..10f2b1e 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -281,6 +281,8 @@ private extension AutoMoveAdvisor { return YukonAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) case .spider: return SpiderAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) + case .scorpion: + return ScorpionAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) case .pyramid, .tripeaks, .golf: // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale // before the tableau flow. @@ -318,6 +320,12 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) + case .scorpion: + return ScorpionAutoMoveAdvisor.allowsTableauTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) case .pyramid, .tripeaks, .golf: // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale // before the tableau flow. @@ -351,6 +359,12 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) + case .scorpion: + return ScorpionAutoMoveAdvisor.isRedundantEmptyColumnTransfer( + selection: selection, + destinationTableauIndex: destinationTableauIndex, + in: state + ) case .pyramid, .tripeaks, .golf: // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale // before the tableau flow. @@ -388,6 +402,12 @@ private extension AutoMoveAdvisor { in: state, destinations: &destinations ) + case .scorpion: + ScorpionAutoMoveAdvisor.appendAuxiliaryDestinations( + for: selection, + in: state, + destinations: &destinations + ) case .pyramid, .tripeaks, .golf: // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale // before the tableau flow. @@ -405,6 +425,8 @@ private extension AutoMoveAdvisor { YukonAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) case .spider: SpiderAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) + case .scorpion: + ScorpionAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) case .pyramid, .tripeaks, .golf: // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale // before the tableau flow. @@ -412,14 +434,16 @@ private extension AutoMoveAdvisor { } } - /// Effects a landing triggers on the destination pile. Spider banks any - /// run the landing completed; the other variants have none. + /// Effects a landing triggers on the destination pile. Spider and Scorpion + /// bank any run the landing completed; the other variants have none. static func applyVariantTableauDestinationEffects(on state: inout GameState, pileIndex: Int) { switch state.variant { case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: break case .spider: SpiderAutoMoveAdvisor.applyTableauDestinationEffects(on: &state, pileIndex: pileIndex) + case .scorpion: + ScorpionAutoMoveAdvisor.applyTableauDestinationEffects(on: &state, pileIndex: pileIndex) } } } diff --git a/ComputerSolitaire/Game/Shared/GameMode.swift b/ComputerSolitaire/Game/Shared/GameMode.swift index 17ed4a1..d26a165 100644 --- a/ComputerSolitaire/Game/Shared/GameMode.swift +++ b/ComputerSolitaire/Game/Shared/GameMode.swift @@ -18,6 +18,7 @@ enum GameMode: String, CaseIterable, Codable { case pyramid case golf case yukon + case scorpion var variant: GameVariant { switch self { @@ -35,6 +36,8 @@ enum GameMode: String, CaseIterable, Codable { return .tripeaks case .golf: return .golf + case .scorpion: + return .scorpion } } @@ -46,7 +49,7 @@ enum GameMode: String, CaseIterable, Codable { case .klondikeDrawThree: return .three case .freecell, .yukon, .spiderOneSuit, .spiderTwoSuits, .spiderFourSuits, .pyramid, - .tripeaks, .golf: + .tripeaks, .golf, .scorpion: return nil } } @@ -60,7 +63,8 @@ enum GameMode: String, CaseIterable, Codable { return .two case .spiderFourSuits: return .four - case .klondikeDrawOne, .klondikeDrawThree, .freecell, .yukon, .pyramid, .tripeaks, .golf: + case .klondikeDrawOne, .klondikeDrawThree, .freecell, .yukon, .pyramid, .tripeaks, .golf, + .scorpion: return nil } } @@ -115,6 +119,8 @@ enum GameMode: String, CaseIterable, Codable { self = .tripeaks case .golf: self = .golf + case .scorpion: + self = .scorpion } } diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index a1e249f..6ac305e 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -201,7 +201,7 @@ struct SavedGamePayload: Codable { case .pyramid, .tripeaks, .golf: // All three always draw a single card to the waste. return DrawMode.one.rawValue - case .freecell, .yukon, .spider: + case .freecell, .yukon, .spider, .scorpion: return DrawMode.three.rawValue } }() @@ -297,7 +297,7 @@ struct SavedGamePayload: Codable { return min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) case .pyramid, .tripeaks, .golf: return min(max(0, state.wasteDrawCount), min(1, state.waste.count)) - case .freecell, .yukon, .spider: + case .freecell, .yukon, .spider, .scorpion: return 0 } } @@ -907,7 +907,7 @@ private extension GameState { private var expectedIdentityCounts: [CardIdentity: Int] { switch variant { - case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf, .scorpion: var counts: [CardIdentity: Int] = [:] for suit in Suit.allCases { for rank in Rank.allCases { @@ -937,6 +937,8 @@ private extension GameState { return TriPeaksPersistenceRules.hasValidLayout(state: self) case .golf: return GolfPersistenceRules.hasValidLayout(state: self) + case .scorpion: + return ScorpionPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 46fe214..d6e52ce 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -21,6 +21,8 @@ enum GameRules { return YukonGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) case .spider: return SpiderGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) + case .scorpion: + return ScorpionGameRules.canMoveToTableau(card: card, destinationPile: destinationPile) case .pyramid, .tripeaks: // Neither has tableau piles; their moves flow through // PyramidGameRules and TriPeaksGameRules. diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 3e0a0d2..82739c2 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -33,6 +33,22 @@ final class SolitaireViewModel { private var hintAutoClearToken = UUID() var isDragging: Bool = false var pendingAutoMove: PendingAutoMove? + /// The most recent stock-onto-tableau deal (Spider's row, Scorpion's + /// three cards), published for the board's deal-flight animation. An + /// explicit event rather than an inferred state diff, so restores, undos, + /// and game switches can never replay a deal that already happened. Card + /// IDs are in stock order, matching the deal's undo context. + struct TableauDealEvent: Equatable { + let id: UUID + let dealtCardIDs: [UUID] + } + private(set) var latestTableauDealEvent: TableauDealEvent? + + /// Publishes a just-executed stock-onto-tableau deal for the board's + /// flight animation; called by the variant session extensions. + func publishTableauDealEvent(dealtCardIDs: [UUID]) { + latestTableauDealEvent = TableauDealEvent(id: UUID(), dealtCardIDs: dealtCardIDs) + } private(set) var movesCount: Int = 0 private(set) var score: Int = 0 private(set) var gameStartedAt: Date = .now @@ -290,6 +306,7 @@ final class SolitaireViewModel { selection = nil isDragging = false pendingAutoMove = nil + latestTableauDealEvent = nil movesCount = 0 score = 0 gameStartedAt = dateProvider.now @@ -380,6 +397,7 @@ final class SolitaireViewModel { let now = dateProvider.now guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { return false } clearHint() + latestTableauDealEvent = nil let offlineDurationSinceSave = max(0, now.timeIntervalSince(sanitizedPayload.savedAt)) state = sanitizedPayload.state movesCount = sanitizedPayload.movesCount @@ -544,7 +562,7 @@ final class SolitaireViewModel { switch variant { case .klondike: configureKlondikeNewGame(drawMode: drawMode) - case .freecell, .yukon: + case .freecell, .yukon, .scorpion: configureWastelessNewGame() case .spider: configureSpiderNewGame() @@ -561,7 +579,7 @@ final class SolitaireViewModel { switch state.variant { case .klondike: configureKlondikeRedeal() - case .freecell, .yukon: + case .freecell, .yukon, .scorpion: configureWastelessRedeal() case .spider: configureSpiderRedeal() @@ -581,7 +599,7 @@ final class SolitaireViewModel { switch state.variant { case .klondike: return sanitizeKlondikeRedealState(state, stockDrawCount: stockDrawCount) - case .freecell, .yukon, .spider: + case .freecell, .yukon, .spider, .scorpion: return sanitizeWastelessRedealState(state) case .pyramid: return sanitizePyramidRedealState(state) @@ -619,7 +637,7 @@ final class SolitaireViewModel { card: Card ) -> Bool { switch state.variant { - case .klondike, .yukon, .spider: + case .klondike, .yukon, .spider, .scorpion: return handleFaceDownTableauTap( pile: pile, pileIndex: pileIndex, @@ -674,7 +692,7 @@ final class SolitaireViewModel { /// variant's rules. Also drives which tableau cards are accessibility elements. func canSelectTableauCards(_ cards: [Card]) -> Bool { switch state.variant { - case .klondike, .yukon: + case .klondike, .yukon, .scorpion: return true case .freecell: return canSelectFreeCellTableauCards(cards) @@ -712,6 +730,8 @@ extension SolitaireViewModel { handleKlondikeStockTap() case .spider: handleSpiderStockTap() + case .scorpion: + handleScorpionStockTap() case .pyramid: handlePyramidStockTap() case .tripeaks: @@ -734,8 +754,9 @@ extension SolitaireViewModel { case .tripeaks, .golf: // Single pass with no recycles: an empty stock is dead. return !state.stock.isEmpty - case .spider: - // Spider's stock renders through its own view; recorded for honesty. + case .spider, .scorpion: + // Spider's and Scorpion's stocks render through their own views; + // recorded for honesty. return !state.stock.isEmpty case .freecell, .yukon: return false @@ -749,7 +770,7 @@ extension SolitaireViewModel { return Array(state.waste.suffix(count)) case .pyramid, .tripeaks, .golf: return Array(state.waste.suffix(min(1, state.wasteDrawCount))) - case .freecell, .yukon, .spider: + case .freecell, .yukon, .spider, .scorpion: return [] } } @@ -887,6 +908,8 @@ extension SolitaireViewModel { applyScore(for: selection.source, destination: .tableau(index)) if state.variant == .spider { resolveCompletedSpiderRuns() + } else if state.variant == .scorpion { + resolveCompletedScorpionRuns() } applyTimeBonusIfWon() self.selection = nil @@ -951,7 +974,7 @@ extension SolitaireViewModel { func flipTopCardIfNeeded(in pileIndex: Int) { switch state.variant { - case .klondike, .yukon, .spider: + case .klondike, .yukon, .spider, .scorpion: flipFaceDownTopCardIfNeeded(in: pileIndex) case .freecell, .pyramid, .tripeaks, .golf: break @@ -1001,6 +1024,10 @@ extension SolitaireViewModel { applyYukonMoveScore(for: source, destination: destination) case .spider: applySpiderMoveScore(for: source, destination: destination) + case .scorpion: + // Scorpion scores reveals (via the shared flip path) and banked + // runs only; tableau moves themselves are free. + break case .pyramid: applyPyramidMoveScore(for: destination) case .tripeaks: diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift index ead95fa..4830f8f 100644 --- a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -86,7 +86,7 @@ extension SolitaireViewModel { state.tableau.indices.contains(pile), index == state.tableau[pile].count - 1 else { return false } return GolfGameRules.canPlay(column: pile, in: state) - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .scorpion: return false } diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index 139534f..50d7d05 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -86,9 +86,10 @@ struct GameState: Equatable, Codable { var isWon: Bool { switch variant { - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .scorpion: // Won once every foundation holds a full run (Ace-to-King on the - // build-up variants, a banked King-to-Ace run per Spider foundation). + // build-up variants, a banked King-to-Ace run per Spider or + // Scorpion foundation). return foundations.allSatisfy { $0.count == Rank.allCases.count } case .pyramid: // Won once every pyramid slot is cleared; stock and waste may keep cards. @@ -122,6 +123,8 @@ struct GameState: Equatable, Codable { return newTriPeaksGame() case .golf: return newGolfGame() + case .scorpion: + return newScorpionGame() } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index a6e5b22..97fd411 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -11,6 +11,7 @@ enum GameVariant: String, CaseIterable, Codable { case pyramid case golf case yukon + case scorpion var title: String { switch self { @@ -28,6 +29,8 @@ enum GameVariant: String, CaseIterable, Codable { return "TriPeaks" case .golf: return "Golf" + case .scorpion: + return "Scorpion" } } @@ -47,12 +50,14 @@ enum GameVariant: String, CaseIterable, Codable { return "Chain up or down the ranks" case .golf: return "Play one rank up or down" + case .scorpion: + return "Untangle runs suit by suit" } } var boardColumnCount: Int { switch self { - case .klondike, .yukon, .pyramid, .golf: + case .klondike, .yukon, .pyramid, .golf, .scorpion: return 7 case .freecell: return 8 @@ -66,30 +71,30 @@ enum GameVariant: String, CaseIterable, Codable { /// flip automatically once uncovered — never by tapping. var dealsFaceDownTableauCards: Bool { switch self { - case .klondike, .yukon, .spider: + case .klondike, .yukon, .spider, .scorpion: return true case .freecell, .pyramid, .tripeaks, .golf: return false } } - /// Whether the variant deals from a stock into a waste pile. Spider has a - /// stock but deals it onto the tableau, never into a waste. + /// Whether the variant deals from a stock into a waste pile. Spider and + /// Scorpion have stocks but deal them onto the tableau, never into a waste. var dealsFromStock: Bool { switch self { case .klondike, .pyramid, .tripeaks, .golf: return true - case .freecell, .yukon, .spider: + case .freecell, .yukon, .spider, .scorpion: return false } } /// How many foundation piles the variant plays with. Spider banks its - /// eight completed King-to-Ace runs in foundations; the other variants - /// build one foundation per suit. + /// eight completed King-to-Ace runs in foundations, Scorpion its four; the + /// other variants build one foundation per suit. var foundationPileCount: Int { switch self { - case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf, .scorpion: return 4 case .spider: return 8 @@ -99,7 +104,7 @@ enum GameVariant: String, CaseIterable, Codable { /// How many cards a deal uses. Spider plays with two decks. var deckCardCount: Int { switch self { - case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf, .scorpion: return 52 case .spider: return 104 @@ -107,15 +112,15 @@ enum GameVariant: String, CaseIterable, Codable { } /// Whether the player builds foundations by moving cards onto them. - /// Spider's completed runs move to a foundation automatically, and - /// Pyramid's, TriPeaks', and Golf's foundations stay empty (their removed - /// cards go to the discard and waste respectively), so none of them treats - /// foundations as a drag, drop, or tap target. + /// Spider's and Scorpion's completed runs move to a foundation + /// automatically, and Pyramid's, TriPeaks', and Golf's foundations stay + /// empty (their removed cards go to the discard and waste respectively), + /// so none of them treats foundations as a drag, drop, or tap target. var playerBuildsFoundations: Bool { switch self { case .klondike, .freecell, .yukon: return true - case .spider, .pyramid, .tripeaks, .golf: + case .spider, .pyramid, .tripeaks, .golf, .scorpion: return false } } diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index af54a71..eaf111b 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -25,6 +25,9 @@ enum HintAdvisor { if state.variant == .spider, SpiderGameRules.canDealFromStock(state: state) { return true } + if state.variant == .scorpion, ScorpionGameRules.canDealFromStock(state: state) { + return true + } if state.variant == .tripeaks, !state.stock.isEmpty { return true } @@ -95,6 +98,12 @@ enum HintAdvisor { /// not one more column card is clearable. Its ratchet matches TriPeaks' — /// every Golf move consumes a card — so a followed line can never revisit a /// position. +/// Scorpion hints work like Spider's: `ScorpionPlanner` lines (which may cross +/// the single stock deal) are cached and followed. When the tableau holds no +/// improving line but the stock remains, the hint is the deal itself — it is +/// legal at any time with no preparation needed, and holding it until the +/// tableau is provably stuck is exactly the strong player's timing. Silence is +/// reserved for positions with no line and no stock. final class HintPlanner { /// How long a single interactive hint request may spend searching. private static let freeCellSearchBudget: TimeInterval = 0.3 @@ -108,6 +117,7 @@ final class HintPlanner { /// the rare hard deal beats truncating a provably winnable position into /// a best-effort line. private static let golfSearchBudget: TimeInterval = 0.5 + private static let scorpionSearchBudget: TimeInterval = 0.25 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] @@ -115,6 +125,7 @@ final class HintPlanner { private var pyramidPlan: [String: PyramidPlanner.Move] = [:] private var triPeaksPlan: [String: TriPeaksPlanner.Move] = [:] private var golfPlan: [String: GolfPlanner.Move] = [:] + private var scorpionPlan: [String: ScorpionPlanner.PlannedAction] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -138,6 +149,8 @@ final class HintPlanner { return triPeaksHint(in: state) case .golf: return golfHint(in: state) + case .scorpion: + return scorpionHint(in: state) } } } @@ -357,6 +370,53 @@ private extension HintPlanner { } } + func scorpionHint(in state: GameState) -> HintAdvisor.Hint? { + let key = ScorpionPlanner.stateKey(for: state) + if let hint = plannedScorpionHint(for: key, in: state) { + return hint + } + + scorpionPlan.removeAll() + let limits = ScorpionPlanner.Limits( + deadline: Date().addingTimeInterval(Self.scorpionSearchBudget) + ) + switch ScorpionPlanner.bestLine(in: state, limits: limits) { + case .line(let line): + scorpionPlan = ScorpionPlanner.keyedActions(along: line, from: state) + return plannedScorpionHint(for: key, in: state) + + case .noProgress: + // The searched region holds no tableau progress. Like Spider, + // Scorpion has a rescue the planner can vouch for: the deal is what + // a strong player does with a stuck tableau, and unlike Spider's it + // is legal at any time, so no preparation line is needed. Falls + // back for truncated no-progress too, mirroring Spider's measured + // rationale — most stuck verdicts are truncated, and a withheld + // deal is a stalled game. With the stock spent, silence is the + // honest answer. + guard ScorpionGameRules.canDealFromStock(state: state) else { return nil } + scorpionPlan = ScorpionPlanner.keyedActions(along: [.stockDeal], from: state) + return plannedScorpionHint(for: key, in: state) + } + } + + func plannedScorpionHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + switch scorpionPlan[key] { + 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 .stockDeal: + guard ScorpionGameRules.canDealFromStock(state: state) else { return nil } + return .stockTap + case .none: + return nil + } + } + func materializedHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { guard let planned = freeCellPlan[key], let move = FreeCellSolver.materialize(planned, in: state) else { diff --git a/ComputerSolitaire/Game/Shared/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift index ff3c1fd..1166ff9 100644 --- a/ComputerSolitaire/Game/Shared/Scoring.swift +++ b/ComputerSolitaire/Game/Shared/Scoring.swift @@ -9,6 +9,7 @@ enum ScoringAction { case recycleWasteInDrawOne case spiderMove case spiderCompletedRun + case scorpionCompletedRun case removePyramidPair case removePyramidKing /// The n-th consecutive TriPeaks discard in a chain scores n points. @@ -53,6 +54,8 @@ enum Scoring { return -1 case .spiderCompletedRun: return 100 + case .scorpionCompletedRun: + return 100 case .removePyramidPair: return 10 case .removePyramidKing: diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index 7c873e1..0911aa7 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -98,8 +98,9 @@ private extension TapMovePolicy { // No stock to refill the board: an eager unsafe foundation move can // strand a card another pile still needs as a landing spot. tier = isSafeFoundationMove(card: card, in: state) ? 100 : 60 - case .spider: - // Unreachable: Spider foundations are never player destinations. + case .spider, .scorpion: + // Unreachable: Spider and Scorpion foundations are never + // player destinations. tier = 100 case .pyramid, .tripeaks, .golf: // Unreachable: Pyramid, TriPeaks, and Golf moves never target diff --git a/ComputerSolitaire/Game/Spider/GameSessionSpider.swift b/ComputerSolitaire/Game/Spider/GameSessionSpider.swift index a322317..28c2c7b 100644 --- a/ComputerSolitaire/Game/Spider/GameSessionSpider.swift +++ b/ComputerSolitaire/Game/Spider/GameSessionSpider.swift @@ -37,6 +37,7 @@ extension SolitaireViewModel { ) ) let completedRunCount = SpiderGameRules.dealStockRow(in: &state) ?? 0 + publishTableauDealEvent(dealtCardIDs: dealtCardIDs) incrementMovesCount() applyScore(.spiderMove) applyCompletedSpiderRunEffects(count: completedRunCount) diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index a40e6f5..3910ead 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -311,6 +311,22 @@ struct RulesAndScoringView: View { definition: "45 strokes for a nine-hole match. Like golf, lower is better." ) ] + case .scorpion: + return [ + TermRow(term: "Tableau", definition: "The seven play piles where you build down by suit."), + TermRow( + term: "Group move", + definition: "Any face-up card together with every card stacked on top of it, moved as one, even out of order." + ), + TermRow( + term: "Completed run", + definition: "A full King-to-Ace run of one suit. It leaves the tableau automatically; four complete the game." + ), + TermRow( + term: "Stock", + definition: "Three face-down cards, dealt face up onto the first three piles at any time — once." + ) + ] } } @@ -384,6 +400,17 @@ struct RulesAndScoringView: View { "The hole ends when you clear all 35 column cards, or when the stock is spent and nothing plays.", "A match is nine holes; the lowest total wins. Switching games keeps the match — it resumes with your Golf session." ] + case .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.", + "A card can move only onto the card one rank higher of its own suit. Nothing can be placed on an Ace.", + "Move any face-up card along with all cards on top of it, even if they are not in sequence.", + "Only Kings (with any cards stacked on them) can fill an empty pile.", + "Tapping the stock deals its three cards face up, one onto each of the first three piles. Deal at any time — but only once.", + "A completed King-to-Ace run of one suit is removed from the tableau automatically.", + "Face-down cards turn face up when they become the top of a pile.", + "You win by completing all four runs." + ] } } @@ -474,6 +501,16 @@ struct RulesAndScoringView: View { note: "Per stock card left — scores below zero are the best results." ) ] + case .scorpion: + return [ + ScoringRow(move: "Turn over Tableau card", points: Scoring.delta(for: .turnOverTableauCard), note: nil), + ScoringRow(move: "Complete a run", points: Scoring.delta(for: .scorpionCompletedRun), note: nil), + ScoringRow( + move: "Win time bonus", + points: Scoring.timedMaxBonusDrawThree, + note: "Reduced by elapsed time." + ) + ] } } } diff --git a/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift b/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift new file mode 100644 index 0000000..c9a912f --- /dev/null +++ b/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift @@ -0,0 +1,55 @@ +import SwiftUI +import Observation + +struct ScorpionTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let isStockHinted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintWiggleToken: UUID + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + // Stock on the left like Spider's, two clear columns, then the + // four banked-run piles aligned over tableau columns 4-7. + TableauStockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken, + dealDescription: "Deals one card onto each of the first three piles" + ) + .frame(width: cardSize.width, alignment: .leading) + + ForEach(0..<2) { _ in + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + } + + // Iterate the piles the state actually holds, not a fixed 0..<4: + // during a game switch this row can re-evaluate against the + // incoming variant's eight-foundation state before the board + // replaces it. + ForEach(Array(viewModel.state.foundations.enumerated()), id: \.offset) { index, pile in + CompletedRunPileView( + pile: pile, + index: index, + cardSize: cardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs + ) + .frame(width: cardSize.width, alignment: .leading) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 21d737e..a3ce972 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -448,6 +448,18 @@ struct TopRowView: View { hiddenCardIDs: hiddenCardIDs, hintWiggleToken: hintWiggleToken ) + case .scorpion: + ScorpionTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + isStockHinted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintWiggleToken: hintWiggleToken + ) case .pyramid: PyramidTopRowView( viewModel: viewModel, @@ -742,9 +754,11 @@ struct TableauPileView: View { let isAccessibilityElement = (isValidRunOrigin || isExposedFaceDownCard) && !isDragged && !isHidden - // Yukon groups need not be ordered; every other multi-card - // pickup is a run. - let multiCardNoun = viewModel.state.variant == .yukon ? "group" : "run" + // Yukon and Scorpion groups need not be ordered; every + // other multi-card pickup is a run. + let isGroupMoveVariant = viewModel.state.variant == .yukon + || viewModel.state.variant == .scorpion + let multiCardNoun = isGroupMoveVariant ? "group" : "run" let accessibilityHint = isExposedFaceDownCard ? "Flip card" : selectableCards.count > 1 diff --git a/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift b/ComputerSolitaire/Views/Shared/CompletedRunPileView.swift similarity index 79% rename from ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift rename to ComputerSolitaire/Views/Shared/CompletedRunPileView.swift index 0cff680..8dfbbe9 100644 --- a/ComputerSolitaire/Views/Spider/SpiderCompletedRunPileView.swift +++ b/ComputerSolitaire/Views/Shared/CompletedRunPileView.swift @@ -1,12 +1,13 @@ import SwiftUI -/// One of Spider's eight banked-run piles. Runs arrive here automatically, so -/// unlike `FoundationView` this pile is never a tap, drag, or drop target; it -/// still publishes its frames so the win cascade can launch cards from it. -/// The pile arrives by value — this view must stay renderable against any -/// game's state, because during a game switch it can re-evaluate after the -/// board's state has already changed variant. -struct SpiderCompletedRunPileView: View { +/// A banked-run pile for the variants that complete runs in place (Spider's +/// eight, Scorpion's four). Runs arrive here automatically, so unlike +/// `FoundationView` this pile is never a tap, drag, or drop target; it still +/// publishes its frames so the win cascade can launch cards from it. The pile +/// arrives by value — this view must stay renderable against any game's state, +/// because during a game switch it can re-evaluate after the board's state has +/// already changed variant. +struct CompletedRunPileView: View { let pile: [Card] let index: Int let cardSize: CGSize diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index cbdc63a..4fccb6a 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -106,6 +106,9 @@ struct ContentView: View { @State private var drawAnimationCards: [DrawAnimationCard] = [] @State private var drawingCardIDs: Set = [] @State private var drawAnimationToken = UUID() + @State private var dealAnimationCards: [DrawAnimationCard] = [] + @State private var dealingCardIDs: Set = [] + @State private var dealAnimationToken = UUID() @State private var undoAnimationItems: [UndoAnimationItem] = [] @State private var undoAnimationTargets: [UUID: UndoAnimationEndTarget] = [:] @State private var undoAnimationProgress: CGFloat = 0 @@ -719,6 +722,15 @@ struct ContentView: View { previousWasteCount = newValue previousStockCount = stockCount } + .onChange(of: viewModel.latestTableauDealEvent) { _, event in + // The tableau-deal variants send stock cards straight onto piles — + // no waste change for the draw animation to key on. The session + // publishes each deal as an explicit event (never set by restores, + // undos, or game switches), so a hydrated game whose last move was + // a deal can never replay the flight. + guard let event else { return } + startDealAnimation(for: event.dealtCardIDs) + } .animation(.spring(response: 0.35, dampingFraction: 0.86), value: viewModel.state) .animation(.easeInOut(duration: 0.12), value: activeTarget) .overlay { @@ -731,6 +743,13 @@ struct ContentView: View { cardTilts: $cardTilts ) .zIndex(50) + DrawOverlayView( + cards: dealAnimationCards, + cardSize: effectiveCardSize, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts + ) + .zIndex(50) UndoOverlayView( items: undoAnimationItems, progress: undoAnimationProgress @@ -833,7 +852,9 @@ struct ContentView: View { } private var effectiveHiddenCardIDs: Set { - hiddenCardIDs.union(winCelebration.hiddenFoundationCardIDs) + hiddenCardIDs + .union(winCelebration.hiddenFoundationCardIDs) + .union(dealingCardIDs) } private var isWinCascadeAnimating: Bool { @@ -914,6 +935,9 @@ struct ContentView: View { private func startNewGameFromUI() { stopAutoFinish() winCelebration.reset(to: .idle) + // New Game replaces the board as thoroughly as a game switch: stale + // flights (deal, draw, undo) must not animate over the fresh deal. + resetTransientBoardState() isScreenshotSession = false viewModel.newGame() persistGameNow() @@ -938,6 +962,8 @@ struct ContentView: View { guard viewModel.canRedeal else { return } stopAutoFinish() winCelebration.reset(to: .idle) + // Redeal replaces the board like New Game does; see above. + resetTransientBoardState() viewModel.redeal() persistGameNow() } @@ -988,6 +1014,7 @@ struct ContentView: View { drawAnimationCards = [] drawingCardIDs = [] drawAnimationToken = UUID() + cancelDealAnimation() undoAnimationItems = [] undoAnimationTargets = [:] undoAnimationProgress = 0 @@ -1278,6 +1305,67 @@ struct ContentView: View { } } + /// Flies a stock-onto-tableau deal (Spider, Scorpion). The session records + /// the dealt IDs in stock order and `removeLast()` deals, so the reversed + /// order is pile order — leftmost pile's card takes off first. The real + /// cards hide immediately (in the same update as the deal, so they never + /// pop in) while their landing frames publish; the overlay then flies. + private func startDealAnimation(for dealtCardIDs: [UUID]) { + // Deals serialize: a rapid follow-up supersedes any active flight. + // Clearing first is glitch-free — the superseded flight's cards are + // real-rendered the moment they unhide — and keeps a failed follow-up + // plan from stranding the prior overlay (its cleanup is token-gated). + cancelDealAnimation() + let lookup = cardLookup(in: viewModel.state) + let dealtCards = dealtCardIDs.reversed().compactMap { lookup[$0] } + guard !dealtCards.isEmpty, stockFrame != .zero else { return } + + dealingCardIDs = Set(dealtCards.map(\.id)) + let token = UUID() + dealAnimationToken = token + DispatchQueue.main.async { + resolveDealAnimation(for: dealtCards, token: token, attemptsRemaining: 24) + } + } + + private func cancelDealAnimation() { + dealAnimationCards = [] + dealingCardIDs = [] + dealAnimationToken = UUID() + } + + private func resolveDealAnimation(for dealtCards: [Card], token: UUID, attemptsRemaining: Int) { + guard dealAnimationToken == token else { return } + let framesReady = dealtCards.allSatisfy { cardFrames[$0.id] != nil } + if !framesReady, attemptsRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + resolveDealAnimation(for: dealtCards, token: token, attemptsRemaining: attemptsRemaining - 1) + } + return + } + + guard let plan = DealAnimationCoordinator.makeDealPlan( + dealtCards: dealtCards, + cardFrames: cardFrames, + stockFrame: stockFrame + ) else { + cancelDealAnimation() + return + } + + dealAnimationCards = plan.cards + // The plan drops cards without a landing frame (banked on arrival); + // trimming the hidden set to the flying cards un-hides those. + dealingCardIDs = plan.cardIDs + + let total = plan.maxDelay + plan.travelDuration + plan.settleDuration + DispatchQueue.main.asyncAfter(deadline: .now() + total) { + guard dealAnimationToken == token else { return } + dealAnimationCards = [] + dealingCardIDs = [] + } + } + private func destination(for target: DropTarget) -> Destination { switch target { case .freeCell(let index): @@ -1327,6 +1415,9 @@ struct ContentView: View { guard !viewModel.isWin else { return } guard let snapshot = viewModel.peekUndoSnapshot() else { return } HapticManager.shared.play(.undoMove) + // Undo mutates the position a live deal flight refers to; land the + // flight before its reverse begins so the two never run concurrently. + cancelDealAnimation() let currentFrames = cardFrames let beforeState = viewModel.state @@ -1433,7 +1524,13 @@ struct ContentView: View { let resolvedItems = undoAnimationItems.compactMap { item -> UndoAnimationItem? in guard let target = undoAnimationTargets[item.id], let endFrame = resolveUndoTargetFrame(target) else { return nil } - return UndoAnimationItem(id: item.id, card: item.card, startFrame: item.startFrame, endFrame: endFrame) + return UndoAnimationItem( + id: item.id, + card: item.card, + endFaceUp: item.endFaceUp, + startFrame: item.startFrame, + endFrame: endFrame + ) } let hasMovement = resolvedItems.contains { item in @@ -1445,7 +1542,27 @@ struct ContentView: View { withAnimation(.spring(response: 0.3, dampingFraction: 0.88)) { undoAnimationProgress = 1 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { + // One turn later — once the overlay views exist with their takeoff + // faces — hand each card its post-undo face. CardView animates the + // change, so a card returning to the stock turns face down in the + // air instead of snapping on landing. + DispatchQueue.main.async { + guard isUndoAnimating else { return } + undoAnimationItems = undoAnimationItems.map { item in + var flownCard = item.card + flownCard.isFaceUp = item.endFaceUp + return UndoAnimationItem( + id: item.id, + card: flownCard, + endFaceUp: item.endFaceUp, + startFrame: item.startFrame, + endFrame: item.endFrame + ) + } + } + // Slightly past the flight spring AND the mid-air flip (which + // starts a turn late and runs 0.32s) so neither gets clipped. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.38) { finishUndoAnimation() } return @@ -1571,6 +1688,7 @@ struct ContentView: View { UndoAnimationItem( id: id, card: card, + endFaceUp: (afterCards[id] ?? card).isFaceUp, startFrame: startFrame, endFrame: startFrame ) @@ -1719,7 +1837,7 @@ struct ContentView: View { return [viewModel.state.discard] case .tripeaks, .golf: return [viewModel.state.waste] - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .scorpion: return viewModel.state.foundations } } @@ -1730,7 +1848,7 @@ struct ContentView: View { return [.discard] case .tripeaks, .golf: return [.waste] - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .scorpion: return viewModel.state.foundations.indices.map(DropTarget.foundation) } } diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index fc6dd67..9b4970a 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -363,7 +363,7 @@ private struct MiniBoardView: View { pyramidRows case .tripeaks: triPeaksRows - case .klondike, .spider, .freecell, .yukon, .golf: + case .klondike, .spider, .freecell, .yukon, .golf, .scorpion: tableauRow } } @@ -403,6 +403,10 @@ private struct MiniBoardView: View { miniCard(.faceDown) Spacer(minLength: 0) foundationSlots(count: 8) + case .scorpion: + miniCard(.faceDown) + Spacer(minLength: 0) + foundationSlots(count: 4) case .pyramid: miniCard(.faceDown) miniCard(.slot) @@ -509,6 +513,12 @@ private struct MiniBoardView: View { } case .golf: return Array(repeating: Array(repeating: .faceUp, count: 5), count: 7) + case .scorpion: + return (0..<7).map { column in + column < 4 + ? Array(repeating: .faceDown, count: 3) + Array(repeating: .faceUp, count: 4) + : Array(repeating: .faceUp, count: 7) + } case .pyramid, .tripeaks: return [] } diff --git a/ComputerSolitaire/Views/Spider/SpiderStockView.swift b/ComputerSolitaire/Views/Shared/TableauStockView.swift similarity index 82% rename from ComputerSolitaire/Views/Spider/SpiderStockView.swift rename to ComputerSolitaire/Views/Shared/TableauStockView.swift index 09907bf..e63c8a7 100644 --- a/ComputerSolitaire/Views/Spider/SpiderStockView.swift +++ b/ComputerSolitaire/Views/Shared/TableauStockView.swift @@ -1,12 +1,16 @@ import SwiftUI import Observation -struct SpiderStockView: View { +/// The stock for the variants that deal it directly onto the tableau (Spider, +/// Scorpion): a tap deals, there is no waste, and an empty stock is inert. +struct TableauStockView: View { @Bindable var viewModel: SolitaireViewModel let cardSize: CGSize let isHintTargeted: Bool let hintHighlightOpacity: Double let hintWiggleToken: UUID + /// How a deal lands, for accessibility — e.g. "Deals one card to each pile". + let dealDescription: String var body: some View { Button { @@ -48,6 +52,6 @@ struct SpiderStockView: View { private var stockAccessibilityValue: String { guard !viewModel.state.stock.isEmpty else { return "Empty" } - return "\(viewModel.state.stock.count) cards. Deals one card to each pile" + return "\(viewModel.state.stock.count) cards. \(dealDescription)" } } diff --git a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift index 151cfe7..2f23bf8 100644 --- a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift +++ b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift @@ -16,12 +16,13 @@ struct SpiderTopRowView: View { HStack(alignment: .top, spacing: columnSpacing) { // Stock on the left like Klondike's, one clear column, then the // eight banked-run piles aligned over tableau columns 3-10. - SpiderStockView( + TableauStockView( viewModel: viewModel, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, - hintWiggleToken: hintWiggleToken + hintWiggleToken: hintWiggleToken, + dealDescription: "Deals one card to each pile" ) .frame(width: cardSize.width, alignment: .leading) @@ -34,7 +35,7 @@ struct SpiderTopRowView: View { // incoming variant's four-foundation state before the board // replaces it. ForEach(Array(viewModel.state.foundations.enumerated()), id: \.offset) { index, pile in - SpiderCompletedRunPileView( + CompletedRunPileView( pile: pile, index: index, cardSize: cardSize, diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index 7adaff1..601eda2 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -403,7 +403,7 @@ private struct GameStatisticsDetailView: View { return stats.highScoreTwoSuits case .spiderFourSuits: return stats.highScoreFourSuits - case .freecell, .pyramid, .tripeaks, .yukon: + case .freecell, .pyramid, .tripeaks, .yukon, .scorpion: return stats.highScore case .golf: return nil diff --git a/ComputerSolitaireTests/Scorpion/ScorpionPersistenceTests.swift b/ComputerSolitaireTests/Scorpion/ScorpionPersistenceTests.swift new file mode 100644 index 0000000..293c87b --- /dev/null +++ b/ComputerSolitaireTests/Scorpion/ScorpionPersistenceTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class ScorpionPersistenceTests: XCTestCase { + private func payload(for state: GameState) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + stockDrawCount: DrawMode.three.rawValue, + history: [] + ) + } + + // MARK: - Valid states + + func testFreshDealRoundTrips() throws { + let state = GameStateFixtures.seededScorpionDeal(seed: 9) + let sanitized = try XCTUnwrap( + payload(for: state).sanitizedForRestore(), + "A fresh deal must pass the validity gate" + ) + XCTAssertEqual(sanitized.state, state) + XCTAssertEqual(sanitized.gameMode, .scorpion) + + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: sanitized)) + XCTAssertEqual(viewModel.gameVariant, .scorpion) + } + + func testPostDealStateIsValid() { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + XCTAssertNotNil(ScorpionGameRules.dealStock(in: &state)) + XCTAssertNotNil(payload(for: state).sanitizedForRestore()) + } + + func testMidGameStateWithBankedRunIsValid() { + // Bank one complete heart run by hand: the board keeps all 52 cards, + // so the multiset gate still holds. The stock is dealt first so every + // heart is guaranteed to be somewhere in the tableau. + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + XCTAssertNotNil(ScorpionGameRules.dealStock(in: &state)) + var bankedRun: [Card] = [] + for rank in Rank.allCases { + let location = firstLocation( + where: { $0.suit == .hearts && $0.rank == rank }, + in: state.tableau + )! + var card = state.tableau[location.pile].remove(at: location.index) + card.isFaceUp = true + bankedRun.append(card) + } + state.foundations[0] = bankedRun + for pileIndex in state.tableau.indices { + if let topIndex = state.tableau[pileIndex].indices.last { + state.tableau[pileIndex][topIndex].isFaceUp = true + } + } + XCTAssertNotNil(payload(for: state).sanitizedForRestore()) + } + + // MARK: - Rejected states + + func testRejectsPartiallyDealtStock() { + for remaining in [1, 2] { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + for _ in 0..<(3 - remaining) { + var card = state.stock.removeLast() + card.isFaceUp = true + state.tableau[0].append(card) + } + XCTAssertNil( + payload(for: state).sanitizedForRestore(), + "The stock deals wholesale: \(remaining) remaining cards cannot occur" + ) + } + } + + func testRejectsFaceUpStockCard() { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + state.stock[0].isFaceUp = true + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsWrongPileCount() { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + let removedPile = state.tableau.removeLast() + state.tableau[0].append(contentsOf: removedPile) + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsWrongFoundationCount() { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + state.foundations = Array(repeating: [], count: 8) + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsNonEmptyWasteAndOccupiedFreeCell() { + var wasteState = GameStateFixtures.seededScorpionDeal(seed: 9) + wasteState.waste.append(wasteState.stock.removeLast()) + XCTAssertNil(payload(for: wasteState).sanitizedForRestore()) + + var freeCellState = GameStateFixtures.seededScorpionDeal(seed: 9) + freeCellState.freeCells[0] = freeCellState.stock.removeLast() + XCTAssertNil(payload(for: freeCellState).sanitizedForRestore()) + } + + func testRejectsPartialOrMixedFoundationPile() { + // Move five in-suit cards to a foundation: partial banked runs cannot + // occur. The stock is dealt first so every needed card is in the tableau. + var partialState = GameStateFixtures.seededScorpionDeal(seed: 9) + XCTAssertNotNil(ScorpionGameRules.dealStock(in: &partialState)) + var moved: [Card] = [] + for rank in [Rank.ace, .two, .three, .four, .five] { + let location = firstLocation( + where: { $0.suit == .hearts && $0.rank == rank }, + in: partialState.tableau + )! + moved.append(partialState.tableau[location.pile].remove(at: location.index)) + } + partialState.foundations[0] = moved + XCTAssertNil(payload(for: partialState).sanitizedForRestore()) + + // A full 13-card foundation pile that mixes suits is equally impossible. + var mixedState = GameStateFixtures.seededScorpionDeal(seed: 9) + XCTAssertNotNil(ScorpionGameRules.dealStock(in: &mixedState)) + var mixedRun: [Card] = [] + for rank in Rank.allCases { + let suit: Suit = rank == .king ? .hearts : .spades + let location = firstLocation( + where: { $0.suit == suit && $0.rank == rank }, + in: mixedState.tableau + )! + var card = mixedState.tableau[location.pile].remove(at: location.index) + card.isFaceUp = true + mixedRun.append(card) + } + mixedState.foundations[0] = mixedRun + XCTAssertNil(payload(for: mixedState).sanitizedForRestore()) + } + + func testRejectsDuplicateCardIDs() { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + state.tableau[0][0] = state.tableau[1][0] + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + func testRejectsWrongCardCount() { + var state = GameStateFixtures.seededScorpionDeal(seed: 9) + state.tableau[6].removeLast() + XCTAssertNil(payload(for: state).sanitizedForRestore()) + } + + // MARK: - Helpers + + private func firstLocation( + where predicate: (Card) -> Bool, + in tableau: [[Card]] + ) -> (pile: Int, index: Int)? { + for pileIndex in tableau.indices { + if let cardIndex = tableau[pileIndex].firstIndex(where: predicate) { + return (pileIndex, cardIndex) + } + } + return nil + } +} diff --git a/ComputerSolitaireTests/Scorpion/ScorpionPlannerTests.swift b/ComputerSolitaireTests/Scorpion/ScorpionPlannerTests.swift new file mode 100644 index 0000000..9461926 --- /dev/null +++ b/ComputerSolitaireTests/Scorpion/ScorpionPlannerTests.swift @@ -0,0 +1,312 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class ScorpionPlannerTests: XCTestCase { + func testHintIsDeterministicAcrossCalls() { + let state = ScorpionTestStates.board( + tableau: [ + [TestCards.make(.spades, .six)], + [TestCards.make(.spades, .seven)], + [TestCards.make(.hearts, .five)], + [TestCards.make(.clubs, .nine)] + ] + ) + + let first = ScorpionPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(ScorpionPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal may hold no improving tableau line at all — the deal is + // then the way forward, and the full hint stack must say so rather + // than go silent. Runs the whole stack, stock fallback included. + for seed in 1...10 { + let state = GameStateFixtures.seededScorpionDeal(seed: UInt64(seed)) + XCTAssertNotNil( + HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.three.rawValue), + "seed \(seed): a fresh Scorpion deal should always yield a hint" + ) + } + } + + func testFollowingPlannedLinesNeverLoops() { + // Hints follow one cached improving line to its end before re-planning, + // and every completed line strictly improves the anchor position — that + // ratchet is what makes looping impossible. Within a line, positions + // never repeat; across lines a transient revisit is survivable, but the + // same exact layout a third time would mean the hints loop. + let limits = ScorpionPlanner.Limits(maxNodes: 4_000) + for seed in [11, 12] as [UInt64] { + var state = GameStateFixtures.seededScorpionDeal(seed: seed) + var visitCounts: [UInt64: Int] = [stateFingerprint(state): 1] + var actions = 0 + while actions < 400 { + let line: [ScorpionPlanner.PlannedAction] + switch ScorpionPlanner.bestLine(in: state, limits: limits) { + case .line(let found): + line = found + case .noProgress: + // Mirror the hint stack's fallback: deal if the stock + // remains, otherwise the game is over. + guard ScorpionGameRules.canDealFromStock(state: state) else { + return + } + line = [.stockDeal] + } + var lineKeys: Set = [stateFingerprint(state)] + for action in line { + guard let next = applied(action, to: state) else { + return XCTFail("Planned action was not legal") + } + state = next + actions += 1 + let key = stateFingerprint(state) + XCTAssertTrue( + lineKeys.insert(key).inserted, + "A planned line revisited a position" + ) + let count = (visitCounts[key] ?? 0) + 1 + visitCounts[key] = count + if count >= 3 { + return XCTFail("Following planned lines revisited the same position twice") + } + } + } + } + } + + func testHintPrefersRevealingLine() { + // Moving the 7♣ onto the 8♣ reveals a face-down card; no other move + // reveals anything. The hint should pick the reveal. + let hiddenKing = TestCards.make(.hearts, .king, isFaceUp: false) + let sevenClubs = TestCards.make(.clubs, .seven) + let eightClubs = TestCards.make(.clubs, .eight) + let state = ScorpionTestStates.board( + tableau: [[hiddenKing, sevenClubs], [eightClubs], [TestCards.make(.diamonds, .four)]] + ) + + guard case .move(let move)? = ScorpionPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, sevenClubs.id) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testKingToEmptyColumnEnablesReveal() { + // The K♠ sits on a face-down card and lands nowhere but the empty + // column; parking it there is the only reveal available. + let hiddenQueen = TestCards.make(.diamonds, .queen, isFaceUp: false) + let kingSpades = TestCards.make(.spades, .king) + let state = ScorpionTestStates.board( + tableau: [[hiddenQueen, kingSpades], [], [TestCards.make(.clubs, .four)]] + ) + + guard case .move(let move)? = ScorpionPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.map(\.id), [kingSpades.id]) + XCTAssertEqual(move.destination, .tableau(1)) + } + + func testCompletingARunIsFoundAndModeled() { + // One move banks a full heart run; the hint must be that move, and the + // shared simulation must model the banking so cached-line replay, + // tests, and the probe stay in lockstep with real play. + let kingThroughTwoHearts = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.hearts, $0) } + let aceHearts = TestCards.make(.hearts, .ace) + let state = ScorpionTestStates.board( + tableau: [Array(kingThroughTwoHearts), [aceHearts], [TestCards.make(.spades, .four)]] + ) + + guard case .move(let move)? = ScorpionPlanner.bestHint(in: state) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.map(\.id), [aceHearts.id]) + XCTAssertEqual(move.destination, .tableau(0)) + + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) else { + return XCTFail("Hinted move was not legal") + } + XCTAssertTrue(next.tableau[0].isEmpty, "Simulation must bank the completed run") + XCTAssertEqual(next.foundations[0].count, 13) + XCTAssertEqual(next.foundations[0].first?.rank, .ace) + } + + func testNoTableauProgressFallsBackToStockDealHint() { + // Seven stuck tops allow no tableau move at all; with stock in hand + // the deal is the only way forward and the hint stack must say so. + let stock = [ + TestCards.make(.hearts, .two, isFaceUp: false), + TestCards.make(.clubs, .six, isFaceUp: false), + TestCards.make(.diamonds, .ten, isFaceUp: false) + ] + let state = ScorpionTestStates.stuckBoard(stock: stock) + + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + XCTAssertEqual( + HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.three.rawValue), + .stockTap + ) + } + + func testDeadlockedStateReturnsNilAndReportsNoMoves() { + // Seven stuck tops and no stock: nothing is legal, so the hint stack + // stays silent — the loss is implicit, exactly like Spider's. + let state = ScorpionTestStates.stuckBoard() + + XCTAssertNil(ScorpionPlanner.bestHint(in: state)) + XCTAssertNil(HintPlanner().bestHint(in: state, stockDrawCount: DrawMode.three.rawValue)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testHintTargetsTheFirstInterchangeableEmptyColumn() { + // With the stock spent, empty columns are interchangeable and the + // planner canonicalizes drops to the first; the hint should never + // point at a later twin. + let hiddenThree = TestCards.make(.diamonds, .three, isFaceUp: false) + let kingSpades = TestCards.make(.spades, .king) + var board = ScorpionTestStates.board( + tableau: [ + [hiddenThree, kingSpades], + [TestCards.make(.clubs, .five)], + [TestCards.make(.clubs, .jack)] + ] + ) + board.tableau[3] = [] + board.tableau[4] = [] + + guard case .move(let move)? = ScorpionPlanner.bestHint(in: board) else { + return XCTFail("Expected a move hint") + } + XCTAssertEqual(move.selection.cards.first?.id, kingSpades.id) + XCTAssertEqual(move.destination, .tableau(3)) + } + + func testPreDealEmptyFirstColumnsStayDistinct() { + // While the stock is undealt, each of columns 0-2 awaits its own + // dealt card, so an empty column there is NOT interchangeable with an + // empty column among 3-6: a king may be sent to either class. The + // planner must still generate the class-distinct drops. + let kingSpades = TestCards.make(.spades, .king) + let fourHearts = TestCards.make(.hearts, .four) + var board = ScorpionTestStates.board( + tableau: [ + [], + [TestCards.make(.clubs, .five)], + [TestCards.make(.clubs, .jack)], + [fourHearts, kingSpades] + ], + stock: [ + TestCards.make(.hearts, .two, isFaceUp: false), + TestCards.make(.clubs, .six, isFaceUp: false), + TestCards.make(.diamonds, .ten, isFaceUp: false) + ] + ) + board.tableau[4] = [] + + let selection = Selection(source: .tableau(pile: 3, index: 1), cards: [kingSpades]) + let destinations = AutoMoveAdvisor.legalDestinations(for: selection, in: board) + XCTAssertTrue(destinations.contains(.tableau(0))) + XCTAssertTrue(destinations.contains(.tableau(4))) + } + + func testTruncatedSearchReportsNoProgressWithoutClaimingProof() { + // A one-node budget cannot explore a fresh deal, so the search must + // report truncation — not exhaustion, which would wrongly claim the + // tableau is dead — and the planner yields no unverified nudge. + let limits = ScorpionPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededScorpionDeal(seed: 5) + + guard case .noProgress(searchWasExhaustive: false) = ScorpionPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(ScorpionPlanner.bestHint(in: state, limits: limits)) + } + + func testHintPlannerWinsAKnownDealEndToEnd() { + // Probe-verified winning seed: following the HintPlanner's cached lines + // (including its stock-deal fallback) plays this deal to a win without + // a single nil hint. Guards the whole hint stack. + let planner = HintPlanner() + var state = GameStateFixtures.seededScorpionDeal(seed: 1) + var actions = 0 + + while actions < 500 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: DrawMode.three.rawValue) else { + return XCTFail("Hint stack gave up after \(actions) actions") + } + let next: GameState? + switch hint { + case .move(let move): + next = AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + case .stockTap: + var dealt = state + next = ScorpionGameRules.dealStock(in: &dealt) != nil ? dealt : nil + } + guard let next else { + return XCTFail("Hinted action was not legal after \(actions) actions") + } + state = next + actions += 1 + } + XCTFail("Did not win within 500 actions") + } + + // MARK: - Helpers + + private func applied(_ action: ScorpionPlanner.PlannedAction, to state: GameState) -> GameState? { + switch action { + case .move(let selection, let destination): + return AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: DrawMode.three.rawValue + ) + case .stockDeal: + var next = state + guard ScorpionGameRules.dealStock(in: &next) != nil else { return nil } + return next + } + } + + private func stateFingerprint(_ state: GameState) -> UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { hash = (hash ^ UInt64(value)) &* 0x100000001b3 } + func mix(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + mix(UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0))) + } + mix(UInt8(state.stock.count)) + for pile in state.foundations { + mix(0xFE) + for card in pile { mix(card: card) } + } + for pile in state.tableau { + mix(0xFD) + for card in pile { mix(card: card) } + } + return hash + } +} diff --git a/ComputerSolitaireTests/Scorpion/ScorpionRulesTests.swift b/ComputerSolitaireTests/Scorpion/ScorpionRulesTests.swift new file mode 100644 index 0000000..02cb024 --- /dev/null +++ b/ComputerSolitaireTests/Scorpion/ScorpionRulesTests.swift @@ -0,0 +1,454 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class ScorpionRulesTests: XCTestCase { + // MARK: - Deal shape and deck composition + + func testNewGameDealShape() { + let state = GameState.newScorpionGame() + + XCTAssertEqual(state.variant, .scorpion) + XCTAssertEqual(state.tableau.count, 7) + XCTAssertEqual(state.tableau.map(\.count), Array(repeating: 7, count: 7)) + for (pileIndex, pile) in state.tableau.enumerated() { + let faceDownCount = pileIndex < 4 ? 3 : 0 + for (cardIndex, card) in pile.enumerated() { + XCTAssertEqual( + card.isFaceUp, + cardIndex >= faceDownCount, + "Pile \(pileIndex) hides its bottom \(faceDownCount) cards" + ) + } + } + XCTAssertEqual(state.stock.count, 3) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertTrue(state.waste.isEmpty) + XCTAssertEqual(state.wasteDrawCount, 0) + XCTAssertEqual(state.foundations.count, 4) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertTrue(state.freeCells.allSatisfy { $0 == nil }) + + let allCards = state.stock + state.tableau.joined() + XCTAssertEqual(allCards.count, 52) + XCTAssertEqual(Set(allCards.map(\.id)).count, 52, "Every card needs a unique identity") + XCTAssertEqual( + Set(allCards.map { CardIdentity(suit: $0.suit, rank: $0.rank) }).count, + 52, + "The deal uses one standard deck" + ) + } + + // MARK: - Landing rules + + func testLandingRequiresSameSuitOneRankHigher() { + let sevenClubs = TestCards.make(.clubs, .seven) + XCTAssertTrue( + ScorpionGameRules.canMoveToTableau( + card: sevenClubs, + destinationPile: [TestCards.make(.clubs, .eight)] + ) + ) + XCTAssertFalse( + ScorpionGameRules.canMoveToTableau( + card: sevenClubs, + destinationPile: [TestCards.make(.spades, .eight)] + ), + "An off-suit eight must reject the landing" + ) + XCTAssertFalse( + ScorpionGameRules.canMoveToTableau( + card: sevenClubs, + destinationPile: [TestCards.make(.clubs, .nine)] + ), + "The landing must be exactly one rank higher" + ) + XCTAssertFalse( + ScorpionGameRules.canMoveToTableau( + card: sevenClubs, + destinationPile: [TestCards.make(.clubs, .seven)] + ) + ) + } + + func testEmptyPileAcceptsOnlyKings() { + XCTAssertTrue( + ScorpionGameRules.canMoveToTableau( + card: TestCards.make(.clubs, .king), + destinationPile: [] + ) + ) + for rank in Rank.allCases where rank != .king { + XCTAssertFalse( + ScorpionGameRules.canMoveToTableau( + card: TestCards.make(.clubs, rank), + destinationPile: [] + ) + ) + } + } + + func testNothingLandsOnAnAce() { + let ace = [TestCards.make(.spades, .ace)] + for suit in Suit.allCases { + for rank in Rank.allCases { + XCTAssertFalse( + ScorpionGameRules.canMoveToTableau( + card: TestCards.make(suit, rank), + destinationPile: ace + ) + ) + } + } + } + + func testFaceDownTopRejectsLandings() { + XCTAssertFalse( + ScorpionGameRules.canMoveToTableau( + card: TestCards.make(.hearts, .seven), + destinationPile: [TestCards.make(.hearts, .eight, isFaceUp: false)] + ) + ) + } + + // MARK: - Pickup rules + + func testEveryFaceUpCardIsPickableWithItsCover() { + // Pile: 9♠ 4♥ 2♣ — thoroughly unordered, yet every face-up card leads a + // pickable group. That is Scorpion's Yukon-style defining rule. + let state = ScorpionTestStates.board( + tableau: [ + [ + TestCards.make(.spades, .nine), + TestCards.make(.hearts, .four), + TestCards.make(.clubs, .two) + ], + [TestCards.make(.spades, .ten)] + ] + ) + + let pileZeroSources = AutoMoveAdvisor.candidateSelections(in: state) + .compactMap { selection -> Int? in + guard case .tableau(let pile, let index) = selection.source, pile == 0 else { + return nil + } + return index + } + XCTAssertEqual(pileZeroSources.sorted(), [0, 1, 2]) + } + + func testFaceDownCardsAreNotPickable() { + let state = ScorpionTestStates.board( + tableau: [ + [ + TestCards.make(.spades, .nine, isFaceUp: false), + TestCards.make(.hearts, .four) + ] + ] + ) + + let pileZeroSources = AutoMoveAdvisor.candidateSelections(in: state) + .compactMap { selection -> Int? in + guard case .tableau(let pile, let index) = selection.source, pile == 0 else { + return nil + } + return index + } + XCTAssertEqual(pileZeroSources, [1]) + } + + func testUnorderedGroupLandsByItsLeadingCardOnly() { + // The 9♠-led group is unordered and multi-suit; only the 9♠ must + // connect, so the 10♠ top is legal and the 10♥ top is not. + let nineSpades = TestCards.make(.spades, .nine) + let state = ScorpionTestStates.board( + tableau: [ + [nineSpades, TestCards.make(.hearts, .four), TestCards.make(.clubs, .two)], + [TestCards.make(.spades, .ten)], + [TestCards.make(.hearts, .ten)] + ] + ) + + let group = Selection( + source: .tableau(pile: 0, index: 0), + cards: Array(state.tableau[0]) + ) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: group, in: state), + [.tableau(1)] + ) + } + + func testFoundationsAreNeverSourcesOrDestinations() { + let bankedRun = Rank.allCases.map { TestCards.make(.spades, $0) } + var foundations: [[Card]] = Array(repeating: [], count: 4) + foundations[0] = bankedRun + let aceHearts = TestCards.make(.hearts, .ace) + let state = ScorpionTestStates.board( + tableau: [[aceHearts]], + foundations: foundations + ) + + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source { + XCTFail("Scorpion foundations must not be pickup sources") + } + } + + let aceSelection = Selection(source: .tableau(pile: 0, index: 0), cards: [aceHearts]) + for destination in AutoMoveAdvisor.legalDestinations(for: aceSelection, in: state) { + if case .foundation = destination { + XCTFail("Scorpion foundations must not be legal destinations") + } + } + } + + func testWholePileKingTransferBetweenEmptyColumnsIsRedundantAfterTheDeal() { + // With the stock spent, every column is interchangeable: a king-led + // whole pile parked next to empty columns is a no-op relocation, so + // the advisor refuses it (players still can). + let kingSpades = TestCards.make(.spades, .king) + let fourHearts = TestCards.make(.hearts, .four) + let state = ScorpionTestStates.board(tableau: [[kingSpades, fourHearts]]) + + let wholePile = Selection( + source: .tableau(pile: 0, index: 0), + cards: [kingSpades, fourHearts] + ) + XCTAssertTrue(AutoMoveAdvisor.legalDestinations(for: wholePile, in: state).isEmpty) + } + + func testWholePileKingTransfersTouchingDealColumnsStayAvailableBeforeTheDeal() { + // While the stock is undealt, each of the first three columns awaits + // its own dealt card, so relocating a whole king pile out of one (or + // into one) genuinely changes the position the deal produces. Only + // transfers between the interchangeable columns 4-7 are no-ops. + let kingSpades = TestCards.make(.spades, .king) + let fourHearts = TestCards.make(.hearts, .four) + let stock = [ + TestCards.make(.hearts, .two, isFaceUp: false), + TestCards.make(.clubs, .six, isFaceUp: false), + TestCards.make(.diamonds, .ten, isFaceUp: false) + ] + + // Whole king pile in a deal-target column: moving it anywhere vacates + // the column, so every empty column is a meaningful destination. + let fromDealColumn = ScorpionTestStates.board( + tableau: [[kingSpades, fourHearts]], + stock: stock + ) + let wholePile = Selection( + source: .tableau(pile: 0, index: 0), + cards: [kingSpades, fourHearts] + ) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: wholePile, in: fromDealColumn), + [.tableau(1), .tableau(2), .tableau(3), .tableau(4), .tableau(5), .tableau(6)] + ) + + // Whole king pile outside the deal columns: filling an empty deal + // column is meaningful, but relocating among columns 4-7 is not. + let fromInterchangeableColumn = ScorpionTestStates.board( + tableau: [[], [], [], [], [kingSpades, fourHearts]], + stock: stock + ) + let wholePileAtFour = Selection( + source: .tableau(pile: 4, index: 0), + cards: [kingSpades, fourHearts] + ) + XCTAssertEqual( + AutoMoveAdvisor.legalDestinations(for: wholePileAtFour, in: fromInterchangeableColumn), + [.tableau(0), .tableau(1), .tableau(2)] + ) + } + + // MARK: - Run completion + + func testCompletedRunStartIndexFindsOnlyFullKingLedSameSuitRuns() { + let fullRun = Rank.allCases.reversed().map { TestCards.make(.hearts, $0) } + XCTAssertEqual(ScorpionGameRules.completedRunStartIndex(in: Array(fullRun)), 0) + + let buried = [TestCards.make(.clubs, .four, isFaceUp: false)] + fullRun + XCTAssertEqual(ScorpionGameRules.completedRunStartIndex(in: buried), 1) + + let partial = Array(fullRun.dropLast()) + XCTAssertNil(ScorpionGameRules.completedRunStartIndex(in: partial)) + + var mixedSuit = Array(fullRun) + mixedSuit[12] = TestCards.make(.spades, .ace) + XCTAssertNil(ScorpionGameRules.completedRunStartIndex(in: mixedSuit)) + } + + func testResolveCompletedRunsBanksFlipsAndCascades() { + // Pile 0 ends in a full heart run over a face-down card; banking must + // move the run to the first empty foundation (Ace at the bottom), flip + // the exposed card, and repeat — the flip completes nothing here, but a + // second pile's full run banks in the same resolution pass. + let hiddenCard = TestCards.make(.clubs, .four, isFaceUp: false) + let heartRun = Rank.allCases.reversed().map { TestCards.make(.hearts, $0) } + let spadeRun = Rank.allCases.reversed().map { TestCards.make(.spades, $0) } + var state = ScorpionTestStates.board( + tableau: [[hiddenCard] + heartRun, Array(spadeRun)] + ) + + let resolution = ScorpionGameRules.resolveCompletedRuns(in: &state) + + XCTAssertEqual(resolution.bankedRunCount, 2) + XCTAssertEqual( + resolution.revealedCardCount, + 1, + "The heart run's removal reveals the buried card; the spade run's reveals nothing" + ) + XCTAssertEqual(state.foundations[0].count, 13) + XCTAssertEqual(state.foundations[0].first?.rank, .ace) + XCTAssertEqual(state.foundations[0].last?.rank, .king) + XCTAssertEqual(state.foundations[1].count, 13) + XCTAssertEqual(state.tableau[0].map(\.id), [hiddenCard.id]) + XCTAssertTrue(state.tableau[0][0].isFaceUp, "Banking must flip the exposed card") + XCTAssertTrue(state.tableau[1].isEmpty) + } + + // MARK: - Stock deal + + func testDealStockPlacesThreeFaceUpCardsOnTheFirstThreePiles() { + var state = GameStateFixtures.seededScorpionDeal(seed: 6) + state.tableau[0] = [] + let expectedDealtIDs = Array(state.stock.map(\.id).reversed()) + + let resolution = ScorpionGameRules.dealStock(in: &state) + + XCTAssertEqual(resolution, ScorpionGameRules.Resolution()) + XCTAssertTrue(state.stock.isEmpty) + let dealtByPile = (0..<3).map { state.tableau[$0].last! } + XCTAssertEqual(dealtByPile.map(\.id), expectedDealtIDs) + XCTAssertTrue(dealtByPile.allSatisfy(\.isFaceUp)) + XCTAssertEqual( + state.tableau[0].count, + 1, + "The deal lands on an empty first pile like any other — it is not a move" + ) + } + + func testDealStockBanksARunTheDealCompletes() { + // Pile 0 holds K♥…2♥; the stock's last card is the A♥, dealt onto pile + // 0 first — completing and banking the run in the same action. + let heartRunToTwo = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.hearts, $0) } + var state = ScorpionTestStates.board( + tableau: [Array(heartRunToTwo), [TestCards.make(.clubs, .nine)], [TestCards.make(.spades, .four)]], + stock: [ + TestCards.make(.clubs, .two, isFaceUp: false), + TestCards.make(.spades, .two, isFaceUp: false), + TestCards.make(.hearts, .ace, isFaceUp: false) + ] + ) + + let resolution = ScorpionGameRules.dealStock(in: &state) + + XCTAssertEqual(resolution, ScorpionGameRules.Resolution(bankedRunCount: 1, revealedCardCount: 0)) + XCTAssertEqual(state.foundations[0].count, 13) + XCTAssertTrue(state.tableau[0].isEmpty) + } + + func testDealStockIsSingleUse() { + var state = GameStateFixtures.seededScorpionDeal(seed: 6) + XCTAssertNotNil(ScorpionGameRules.dealStock(in: &state)) + + let stateAfterDeal = state + XCTAssertNil(ScorpionGameRules.dealStock(in: &state), "An empty stock cannot deal again") + XCTAssertEqual(state, stateAfterDeal) + } + + func testCanDealFromStockIgnoresTableauShape() { + // Unlike Spider, empty piles never block the deal. + var state = GameStateFixtures.seededScorpionDeal(seed: 6) + state.tableau[0] = [] + state.tableau[5] = [] + XCTAssertTrue(ScorpionGameRules.canDealFromStock(state: state)) + + state.stock = [] + XCTAssertFalse(ScorpionGameRules.canDealFromStock(state: state)) + } + + // MARK: - Session move semantics + + func testSessionMovesUnorderedGroupAndFlipsExposedCard() { + let hiddenKing = TestCards.make(.clubs, .king, isFaceUp: false) + let nineSpades = TestCards.make(.spades, .nine) + let fourHearts = TestCards.make(.hearts, .four) + let tenSpades = TestCards.make(.spades, .ten) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board( + tableau: [[hiddenKing, nineSpades, fourHearts], [tenSpades]] + ) + viewModel.configureWastelessNewGame() + + XCTAssertTrue(viewModel.canSelectTableauCards([nineSpades, fourHearts])) + viewModel.selection = Selection( + source: .tableau(pile: 0, index: 1), + cards: [nineSpades, fourHearts] + ) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + + XCTAssertEqual( + viewModel.state.tableau[1].map(\.id), + [tenSpades.id, nineSpades.id, fourHearts.id] + ) + XCTAssertEqual(viewModel.state.tableau[0].count, 1) + XCTAssertTrue( + viewModel.state.tableau[0][0].isFaceUp, + "The exposed face-down card should flip when the group leaves" + ) + XCTAssertEqual(viewModel.movesCount, 1) + } + + func testSessionRefusesOffSuitDropAndSelfDrop() { + let nineSpades = TestCards.make(.spades, .nine) + let tenHearts = TestCards.make(.hearts, .ten) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board( + tableau: [[nineSpades], [tenHearts]] + ) + viewModel.configureWastelessNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 0, index: 0), cards: [nineSpades]) + XCTAssertFalse(viewModel.canDrop(to: .tableau(1)), "Off-suit landings are illegal") + XCTAssertFalse(viewModel.canDrop(to: .tableau(0)), "A self-drop is a cancel, not a move") + } +} + +/// Constructs Scorpion board states for tests: piles are padded to Scorpion's +/// seven columns, foundations to its four banked-run piles. +@MainActor +enum ScorpionTestStates { + static func board( + tableau: [[Card]], + stock: [Card] = [], + foundations: [[Card]] = Array(repeating: [], count: 4) + ) -> GameState { + var paddedTableau = tableau + while paddedTableau.count < 7 { + paddedTableau.append([]) + } + return GameState( + variant: .scorpion, + stock: stock, + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: foundations, + tableau: paddedTableau + ) + } + + /// A seven-pile board with a single face-up spade in every pile, chosen so + /// no card is another's same-suit successor: no tableau move is legal. + static func stuckBoard(stock: [Card] = []) -> GameState { + board( + tableau: [Rank.ace, .three, .five, .seven, .nine, .jack, .king].map { + [TestCards.make(.spades, $0)] + }, + stock: stock + ) + } +} diff --git a/ComputerSolitaireTests/Scorpion/ScorpionSessionTests.swift b/ComputerSolitaireTests/Scorpion/ScorpionSessionTests.swift new file mode 100644 index 0000000..45bff56 --- /dev/null +++ b/ComputerSolitaireTests/Scorpion/ScorpionSessionTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class ScorpionSessionTests: XCTestCase { + // MARK: - Stock deal + + func testStockTapDealsAnytimeIncludingOntoAnEmptyPile() { + let viewModel = SolitaireViewModel() + var board = GameStateFixtures.seededScorpionDeal(seed: 7) + board.tableau[0] = [] + viewModel.state = board + viewModel.configureWastelessNewGame() + // The stock deals from its end: its last card lands on pile 0 first. + let expectedDealtIDs = Array(viewModel.state.stock.map(\.id).reversed()) + + viewModel.handleStockTap() + + XCTAssertTrue(viewModel.state.stock.isEmpty) + let dealtByPile = (0..<3).map { viewModel.state.tableau[$0].last! } + XCTAssertEqual(dealtByPile.map(\.id), expectedDealtIDs) + XCTAssertTrue(dealtByPile.allSatisfy(\.isFaceUp)) + XCTAssertEqual( + viewModel.state.tableau[0].count, + 1, + "The deal lands on an empty pile too — mid-run and empty piles never block it" + ) + XCTAssertEqual(viewModel.movesCount, 1, "A deal is one move") + } + + func testEmptyStockTapIsANoOp() { + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededScorpionDeal(seed: 7) + viewModel.configureWastelessNewGame() + + viewModel.handleStockTap() + XCTAssertTrue(viewModel.state.stock.isEmpty) + let stateAfterDeal = viewModel.state + + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state, stateAfterDeal, "An empty stock tap is a no-op") + XCTAssertEqual(viewModel.movesCount, 1) + XCTAssertFalse(viewModel.canInteractWithStock) + } + + func testSingleUndoRestoresTheWholeDeal() { + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededScorpionDeal(seed: 8) + viewModel.configureWastelessNewGame() + let stateBeforeDeal = viewModel.state + let scoreBeforeDeal = viewModel.score + + viewModel.handleStockTap() + XCTAssertNotEqual(viewModel.state, stateBeforeDeal) + XCTAssertEqual( + viewModel.peekUndoSnapshot()?.undoContext?.action, + .dealTableauRow + ) + + viewModel.undo() + XCTAssertEqual(viewModel.state, stateBeforeDeal, "One undo must restore all three dealt cards") + XCTAssertEqual(viewModel.score, scoreBeforeDeal) + XCTAssertEqual(viewModel.movesCount, 0) + } + + // MARK: - Scoring + + func testTableauMovesAreFreeAndRevealsScore() { + let hiddenKing = TestCards.make(.clubs, .king, isFaceUp: false) + let nineSpades = TestCards.make(.spades, .nine) + let tenSpades = TestCards.make(.spades, .ten) + let eightSpades = TestCards.make(.spades, .eight) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board( + tableau: [[hiddenKing, nineSpades], [tenSpades], [eightSpades]] + ) + viewModel.configureWastelessNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 0, index: 1), cards: [nineSpades]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + + XCTAssertEqual( + viewModel.score, + Scoring.delta(for: .turnOverTableauCard), + "The move itself is free; the flip it exposes scores" + ) + + viewModel.selection = Selection(source: .tableau(pile: 2, index: 0), cards: [eightSpades]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(1))) + XCTAssertEqual( + viewModel.score, + Scoring.delta(for: .turnOverTableauCard), + "A move that reveals nothing scores nothing" + ) + } + + func testBankingARunScores() { + let heartRunToTwo = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.hearts, $0) } + let aceHearts = TestCards.make(.hearts, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board( + tableau: [Array(heartRunToTwo), [aceHearts]] + ) + viewModel.configureWastelessNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertEqual(viewModel.state.foundations[0].count, 13) + XCTAssertTrue(viewModel.state.tableau[0].isEmpty) + XCTAssertEqual(viewModel.score, Scoring.delta(for: .scorpionCompletedRun)) + } + + func testBankingARunOverAFaceDownCardScoresTheRevealToo() { + // The banked run's removal turns the buried card face up; that reveal + // earns the same +5 as any other, alongside the run's +100. + let hiddenCard = TestCards.make(.clubs, .four, isFaceUp: false) + let heartRunToTwo = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.hearts, $0) } + let aceHearts = TestCards.make(.hearts, .ace) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board( + tableau: [[hiddenCard] + heartRunToTwo, [aceHearts]] + ) + viewModel.configureWastelessNewGame() + + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertEqual(viewModel.state.foundations[0].count, 13) + XCTAssertTrue(viewModel.state.tableau[0][0].isFaceUp) + XCTAssertEqual( + viewModel.score, + Scoring.delta(for: .scorpionCompletedRun) + Scoring.delta(for: .turnOverTableauCard) + ) + } + + func testWinAddsTheStandardTimeBonus() { + // Three runs banked; the final Ace completes the fourth. The provider + // pins the clock, so the expected bonus is exact. + var foundations: [[Card]] = [Suit.spades, .clubs, .diamonds].map { suit in + Rank.allCases.map { TestCards.make(suit, $0) } + } + foundations.append([]) + let heartRunToTwo = Rank.allCases.reversed().dropLast() + .map { TestCards.make(.hearts, $0) } + let aceHearts = TestCards.make(.hearts, .ace) + + let dateProvider = TestDateProvider(now: DateFixtures.reference) + let viewModel = SolitaireViewModel(dateProvider: dateProvider) + viewModel.state = ScorpionTestStates.board( + tableau: [Array(heartRunToTwo), [aceHearts]], + foundations: foundations + ) + viewModel.configureWastelessNewGame() + + dateProvider.now = DateFixtures.plus(60) + viewModel.selection = Selection(source: .tableau(pile: 1, index: 0), cards: [aceHearts]) + XCTAssertTrue(viewModel.tryMoveSelection(to: .tableau(0))) + + XCTAssertTrue(viewModel.isWin) + let expectedBonus = Scoring.timeBonus( + elapsedSeconds: 60, + maxBonus: Scoring.timedMaxBonus(for: DrawMode.three.rawValue) + ) + XCTAssertGreaterThan(expectedBonus, 0) + XCTAssertEqual( + viewModel.score, + Scoring.delta(for: .scorpionCompletedRun) + expectedBonus + ) + } + + // MARK: - Selection and interaction + + func testExposedFaceDownTopFlipsOnTapAndScores() { + let hiddenKing = TestCards.make(.clubs, .king, isFaceUp: false) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board(tableau: [[hiddenKing]]) + viewModel.configureWastelessNewGame() + + viewModel.handleTableauTap(pileIndex: 0, cardIndex: 0) + + XCTAssertTrue(viewModel.state.tableau[0][0].isFaceUp) + XCTAssertEqual(viewModel.score, Scoring.delta(for: .turnOverTableauCard)) + XCTAssertEqual(viewModel.movesCount, 1) + } + + func testDragFromAnyFaceUpCardStartsAnUnorderedSelection() { + let nineSpades = TestCards.make(.spades, .nine) + let fourHearts = TestCards.make(.hearts, .four) + let twoClubs = TestCards.make(.clubs, .two) + let viewModel = SolitaireViewModel() + viewModel.state = ScorpionTestStates.board( + tableau: [[nineSpades, fourHearts, twoClubs], [TestCards.make(.spades, .ten)]] + ) + viewModel.configureWastelessNewGame() + + XCTAssertTrue(viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 0)) + XCTAssertEqual( + viewModel.selection?.cards.map(\.id), + [nineSpades.id, fourHearts.id, twoClubs.id] + ) + } +} diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index e6a0813..cd59e9f 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -407,6 +407,56 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("Golf fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Scorpion board is a fresh deal — seven piles with their + /// face-up fans and the three-card stock untouched. Seeds are scanned for + /// the most photogenic spread across the fan tails. + func testGenerateScorpionFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededScorpionDeal(seed: seed) + let score = scorpionDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededScorpionDeal(seed: seed) + viewModel.configureWastelessNewGame() + + 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, .scorpion, "Fixture did not restore as Scorpion") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("scorpion.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Scorpion fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -527,6 +577,21 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Scorpion deal by the cards on the fan tails (the last + /// two face-up cards of each pile): rank variety, red/black balance, all + /// four suits, a few face cards, and an ace on a tail read well. + private func scorpionDealScore(of deal: GameState) -> Int { + let visible = deal.tableau.flatMap { $0.suffix(2).filter(\.isFaceUp) } + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + score += deal.tableau.compactMap { $0.last }.contains(where: { $0.rank == .ace }) ? 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 8dce88d..c3b2197 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -128,6 +128,30 @@ enum GameStateFixtures { ) } + /// A reproducible Scorpion deal matching the shape of `GameState.newScorpionGame`. + /// Mirrored by the hint probe's `seededScorpionDeal` so seeds are comparable. + static func seededScorpionDeal(seed: UInt64) -> GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = Array(repeating: [], count: 7) + for pileIndex in 0..<7 { + let faceDownCount = pileIndex < 4 ? 3 : 0 + for cardIndex in 0..<7 { + var card = deck.removeLast() + card.isFaceUp = cardIndex >= faceDownCount + tableau[pileIndex].append(card) + } + } + return GameState( + variant: .scorpion, + stock: deck, + waste: [], + wasteDrawCount: 0, + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } + /// A reproducible Pyramid deal matching the shape of `GameState.newPyramidGame`. static func seededPyramidDeal(seed: UInt64) -> GameState { var deck = seededDeck(seed: seed, faceUp: false) diff --git a/README.md b/README.md index 21d1386..acc07e5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. ## Features - Fully native apps for iOS, iPadOS, and macOS -- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, and **Yukon** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, **Yukon**, and **Scorpion** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -29,3 +29,4 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/rules/pyramid.md) | | **Golf** | Play columns down to nothing, one rank up or down, scored like golf across a nine-hole match | [Rules](docs/rules/golf.md) | | **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) | diff --git a/docs/rules/scorpion.md b/docs/rules/scorpion.md new file mode 100644 index 0000000..9c4bae5 --- /dev/null +++ b/docs/rules/scorpion.md @@ -0,0 +1,57 @@ +# Scorpion Rules + +These rules describe Scorpion as implemented in the app: **one deck** across **seven tableau piles**, cards building down **by suit** while any face-up card moves with everything stacked on it, and the goal of assembling — and automatically banking — **four full King-to-Ace runs**, one per suit, built in place. A **three-card stock** deals onto the first three piles, once, at any time. + +## Objective +Complete four King-to-Ace runs, one per suit, built in place on the tableau. Each completed run is removed from the tableau automatically; the game is won when all four are done. + +## Terminology +- **Tableau:** Seven piles where all building happens. +- **Group move:** Any face-up card together with every card stacked on top of it, moved as one, even out of order. Only the selected (bottom) card must connect at the destination. +- **Completed run:** A full King-to-Ace run of one suit at the end of a pile; it leaves the tableau on its own. Four complete the game. +- **Stock:** Three face-down cards, dealt face up onto the first three piles at any time — once. + +## Setup +- Use a standard 52-card deck (no jokers). +- **Tableau:** Deal 49 cards into seven piles of seven. In each of the **first four piles** the bottom three cards are face down; the **last three piles** are entirely face up. +- **Stock:** The remaining 3 cards, face down. +- **Completed runs:** Four spaces bank finished runs as they leave the tableau. + +## Play + +### Tableau +- A card lands only on the card **one rank higher of its own suit**, which must be face up at the end of a pile (e.g., 7♣ onto 8♣ only). Nothing may be placed on an Ace. +- Move **any face-up card** along with all cards on top of it, even if they are not in sequence — only the selected card must connect at the destination. +- Only **Kings** (with any cards stacked on them) can fill an empty pile. +- When a face-down card becomes the top of its pile, it flips face up. + +### The stock +- Tapping the stock deals its **three cards face up, one onto each of the first three piles** — empty or mid-run piles included. +- The stock may be dealt **at any time**, but only once; there is no waste and no redeal. + +### Completed runs +- The moment a pile's top thirteen cards form a face-up King-to-Ace run of one suit, the run is **removed automatically** and banked. +- A dealt card can complete a run, and one removal can expose another complete run beneath it. + +## Scoring +- **+5** for turning a tableau card face up. +- **+100** for each completed run. +- Tableau moves and the stock deal are free. +- On a win, a time bonus is added: it starts at 900 and drops by one point per second of play. +- The score never drops below zero. + +## Winning +You win the moment the fourth run banks. The game is lost when the stock is spent and no legal move remains before all four runs are complete. + +## Rule choices +Published Scorpion rules genuinely disagree on what happens to finished runs and when the stock may be dealt; this implementation uses: +- **Automatic banking of completed runs** — the common digital convention, matching this app's Spider — rather than the traditional description that leaves finished runs lying in the tableau. +- **Deal the stock at any time** (Solitaire Network's "or sooner if desired" and most digital versions), not only when play is blocked. +- **Kings only** on empty piles (universal for Scorpion; the any-card variant is a different game, Wasp). +- **Three face-down cards in each of the first four piles** (the standard deal; Scorpion II, which hides cards in only three piles, is not implemented). + +## Sources +- https://en.wikipedia.org/wiki/Scorpion_(solitaire) +- https://www.solitairenetwork.com/solitaire/scorpion-solitaire-game.html +- https://www.solsuite.com/games/scorpion.htm +- https://www.solitairebliss.com/scorpion diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index 15b1396..8cca2fa 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -27,6 +27,7 @@ tools/hint-probe/run.sh spider 500 4 # third arg narrows to one suit count tools/hint-probe/run.sh pyramid 500 tools/hint-probe/run.sh tripeaks 500 tools/hint-probe/run.sh golf 500 +tools/hint-probe/run.sh scorpion 500 ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -69,6 +70,7 @@ consecutive runs, serial and parallel. | `pyramid` | **80.2%** | 15.2% | | `tripeaks` | **95.4%** | 0.0% | | `golf` | **22.6%** | 0.0% | +| `scorpion` | **14.8%** | 2.8% | Reading the table honestly: @@ -137,6 +139,25 @@ Reading the table honestly: structurally bounded at 51 actions. Budget history: the TriPeaks-sized 200k node cap measured 13.6% (61% of deals undecided); the shipped 1M cap with 12-byte packed search nodes decides 92% of deals and is the baseline above. +- **Scorpion (14.8% vs 2.8%)**: hints win 5.3x as often as random in a variant + that is structurally brutal — kings-only empty columns, same-suit-only + landings, and nothing banks until a full thirteen-card run assembles in + place. That all-or-nothing shape shows in the loss column: median 0 cards + banked at loss for *both* players (there is no partial credit to strand), so + the over-banking detector measures zero and the win-rate gap is the whole + story. Published practical win rates for Scorpion sit in the low teens, so + the follower plays at the level of a good human. Revisit events measure + zero — Scorpion's no-progress fallback (deal the stock) is monotone, unlike + Spider's score-losing column fills — so Scorpion's revisits are gated to + zero like Yukon's. Tuning directions already measured flat: node budget 60k + (14.8%, searches exhaust their improvement-free regions well under 30k), + same-suit-inversion weight 5 (15.0%, one game — noise), empty-pile weight 8 + (15.0%, noise). The shipped weights keep the Yukon/Spider precedents. The + class-aware king-transfer prune (a correctness fix: pre-deal whole-pile + relocations touching the three deal columns are real moves, and only + 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. - These figures use the planners' full node budgets. The app additionally clips each interactive search at a fraction of a second so the UI never hitches; that clip rarely binds, so in-app quality is at most a hair below @@ -150,16 +171,17 @@ add its sources to `run.sh`, then run 500 deals. Acceptance gates: - The hint column must **decisively beat the random control**. - **Zero stalemate-loops** for the hint player, machine-enforced: the probe exits nonzero if any hint follower loops in any variant. **Revisit events** - are additionally gated to zero for Yukon (its planner measures zero, so any - revisit is a regression signal); Spider's are reported but not gated — see - the baseline notes for why a few transients per 500 deals are structural - there. (Revisits are reported without reclassifying the game, so win rates + are additionally gated to zero for Yukon 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.) - **Watch the over-banking detector** (`losses with >=40 banked`): it should be zero for stockless variants (Yukon and FreeCell measure zero). The Klondike draw-1 baseline records a single such loss, and Spider records - 6/7/1 by suit count (its losses can strand nearly-done boards); treat any - increase as a regression. + 6/7/1 by suit count (its losses can strand nearly-done boards); Scorpion + measures zero by structure (a loss with three banked runs would need 40 + cards banked — never observed). Treat any increase as a regression. - Record the measured numbers in the table above; they become the variant's regression baseline. Mechanical refactors must reproduce every figure exactly; deliberate quality changes must move the hint column up, never diff --git a/tools/hint-probe/main.swift b/tools/hint-probe/main.swift index 54fb233..ba8e942 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -189,6 +189,27 @@ func seededDeal(variant: GameVariant, seed: UInt64, spiderSuitCount: SpiderSuitC foundations: Array(repeating: [], count: 4), tableau: tableau ) + + case .scorpion: + // Mirrors GameState.newScorpionGame (and GameStateFixtures.seededScorpionDeal). + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = Array(repeating: [], count: 7) + for pileIndex in 0..<7 { + let faceDownCount = pileIndex < 4 ? 3 : 0 + for cardIndex in 0..<7 { + var card = deck.removeLast() + card.isFaceUp = cardIndex >= faceDownCount + tableau[pileIndex].append(card) + } + } + return GameState( + variant: .scorpion, + stock: deck, + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) } } @@ -257,6 +278,14 @@ func spiderStockDeal(_ state: GameState) -> GameState? { return next } +/// Mirrors dealScorpionStock in the session (via the shared rules function, +/// including the completed-run sweep). +func scorpionStockDeal(_ state: GameState) -> GameState? { + var next = state + guard ScorpionGameRules.dealStock(in: &next) != nil else { return nil } + return next +} + /// Mirrors handlePyramidStockTap / recyclePyramidWaste in the session: draw one, /// or recycle within the pass limit. The planner's apply is the same pure logic. func pyramidStockTap(_ state: GameState) -> GameState? { @@ -329,7 +358,7 @@ func actionCap(for variant: GameVariant) -> Int { return 1_200 case .spider: return 1_000 - case .freecell, .yukon, .pyramid, .tripeaks, .golf: + case .freecell, .yukon, .pyramid, .tripeaks, .golf, .scorpion: return 600 } } @@ -535,6 +564,70 @@ func playSpiderFollowingHints( return (.actionCap(foundation: foundationCount(state)), revisitEvents) } +func playScorpionFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: Int) { + // Replicates HintPlanner's Scorpion path without its wall-clock deadline: + // follow each improving line (which may include the stock deal) to its end, + // then replan; on no-progress, play the deal the real hint stack falls back + // to, and declare a deadlock only when the stock is spent. + var state = seededDeal(variant: .scorpion, 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: .scorpion) { + return .actionCap(foundation: foundationCount(state)) + } + if state.isWon { return .win(moves: actions) } + return nil + } + + func applied(_ action: ScorpionPlanner.PlannedAction) -> GameState? { + switch action { + case .move(let selection, let destination): + return apply(selection, destination, to: state, stockDrawCount: 3) + case .stockDeal: + return scorpionStockDeal(state) + } + } + + while actions < actionCap(for: .scorpion) { + if state.isWon { return (.win(moves: actions), revisitEvents) } + switch ScorpionPlanner.bestLine(in: state) { + case .line(let line): + for action in line { + guard let next = applied(action) else { + fatalError("Seed \(seed): illegal Scorpion hint") + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + + case .noProgress: + // Mirrors HintPlanner's fallback: the deal is legal at any time, + // so no preparation line exists — deal or die. + guard let next = scorpionStockDeal(state) else { + return (.deadlock(foundation: foundationCount(state)), revisitEvents) + } + if let outcome = record(next) { return (outcome, revisitEvents) } + } + } + return (.actionCap(foundation: foundationCount(state)), revisitEvents) +} + func playPyramidFollowingHints(seed: UInt64) -> Outcome { // Replicates HintPlanner's Pyramid path without its wall-clock deadline: // follow each planned line — winning or max-clear — to its end, then replan; @@ -713,7 +806,7 @@ func playRandom( lossProgress = triPeaksCleared case .golf: lossProgress = golfCleared - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .scorpion: lossProgress = foundationCount } var actions = 0 @@ -733,6 +826,8 @@ func playRandom( canTapStock = !state.stock.isEmpty || !state.waste.isEmpty case .spider: canTapStock = SpiderGameRules.canDealFromStock(state: state) + case .scorpion: + canTapStock = ScorpionGameRules.canDealFromStock(state: state) case .pyramid: canTapStock = !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) case .tripeaks, .golf: @@ -749,6 +844,8 @@ func playRandom( switch variant { case .spider: tapped = spiderStockDeal(state) + case .scorpion: + tapped = scorpionStockDeal(state) case .pyramid: tapped = pyramidStockTap(state) case .tripeaks: @@ -884,6 +981,8 @@ func run( label = "tripeaks" case .golf: label = "golf" + case .scorpion: + label = "scorpion" } // Pyramid, TriPeaks, and Golf bank no foundations; their loss columns // record board cards cleared. @@ -895,7 +994,7 @@ func run( lossProgressLabel = "tripeaks-cleared-at-loss" case .golf: lossProgressLabel = "golf-cleared-at-loss" - case .klondike, .freecell, .yukon, .spider: + case .klondike, .freecell, .yukon, .spider, .scorpion: lossProgressLabel = "foundation-at-loss" } let tracksOverBanking = variant != .pyramid && variant != .tripeaks && variant != .golf @@ -920,6 +1019,8 @@ func run( return (playTriPeaksFollowingHints(seed: seed), 0) case .golf: return (playGolfFollowingHints(seed: seed), 0) + case .scorpion: + return playScorpionFollowingHints(seed: seed) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -938,7 +1039,7 @@ func run( tracksOverBanking: tracksOverBanking ) print(String(format: "elapsed: %.1fs", seconds)) - if variant == .yukon || variant == .spider { + if variant == .yukon || variant == .spider || variant == .scorpion { print("hint revisit events: \(revisitEvents)") } if followerLoops > 0 { @@ -948,9 +1049,11 @@ func run( // Spider revisit events are reported but not gated: the deal-preparation // fallback deliberately plays score-losing fills, so a later line can // transiently re-cross an earlier position (a handful per 500 deals). - // Yukon's planner measures zero, so for it any revisit is a regression. - if variant == .yukon, revisitEvents > 0 { - print("GATE VIOLATION: yukon hint follower revisited positions \(revisitEvents) time(s)") + // Yukon's and Scorpion's planners measure zero (Scorpion's no-progress + // fallback is a monotone stock deal), so for them any revisit is a + // regression. + if variant == .yukon || variant == .scorpion, revisitEvents > 0 { + print("GATE VIOLATION: \(label) hint follower revisited positions \(revisitEvents) time(s)") gateViolations += revisitEvents } @@ -983,7 +1086,7 @@ setvbuf(stdout, nil, _IOLBF, 0) func exitWithUsage() -> Never { print( - "usage: run.sh [deals >= 1] " + "usage: run.sh [deals >= 1] " + "[klondike draw count: 1 or 3 | spider suit count: 1, 2, or 4]" ) exit(1) @@ -1023,6 +1126,8 @@ case "tripeaks": run(variant: .tripeaks, seeds: seeds, drawCount: 1) case "golf": run(variant: .golf, seeds: seeds, drawCount: 1) +case "scorpion": + run(variant: .scorpion, seeds: seeds, drawCount: 3) case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) @@ -1034,6 +1139,7 @@ case "all": run(variant: .pyramid, seeds: seeds, drawCount: 1) run(variant: .tripeaks, seeds: seeds, drawCount: 1) run(variant: .golf, seeds: seeds, drawCount: 1) + run(variant: .scorpion, seeds: seeds, drawCount: 3) default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index b77bd6c..6c01611 100755 --- a/tools/hint-probe/run.sh +++ b/tools/hint-probe/run.sh @@ -1,6 +1,6 @@ #!/bin/bash # Compiles the hint-quality probe against the UI-free Game sources and runs it. -# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count | spider suit count] +# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count | spider suit count] set -euo pipefail cd "$(dirname "$0")/../.." @@ -45,6 +45,10 @@ SOURCES=( ComputerSolitaire/Game/Golf/GameRulesGolf.swift ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift ComputerSolitaire/Game/Golf/GolfPlanner.swift + ComputerSolitaire/Game/Scorpion/GameStateScorpion.swift + ComputerSolitaire/Game/Scorpion/GameRulesScorpion.swift + ComputerSolitaire/Game/Scorpion/AutoMoveAdvisorScorpion.swift + ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift ) for source in "${SOURCES[@]}"; do