diff --git a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift index 9c3b2d9..f741f5e 100644 --- a/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift +++ b/ComputerSolitaire/Fixtures/ScreenshotFixtures.swift @@ -34,7 +34,8 @@ enum ScreenshotFixtures { ScreenshotFixture(name: "yukon", title: "Yukon – fresh deal"), ScreenshotFixture(name: "spider", title: "Spider – 2 suits"), ScreenshotFixture(name: "pyramid", title: "Pyramid – fresh deal"), - ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal") + ScreenshotFixture(name: "tripeaks", title: "TriPeaks – fresh deal"), + ScreenshotFixture(name: "golf", title: "Golf – fresh deal") ] static func payloadFromLaunchArguments() -> SavedGamePayload? { diff --git a/ComputerSolitaire/Fixtures/golf.json b/ComputerSolitaire/Fixtures/golf.json new file mode 100644 index 0000000..3d25264 --- /dev/null +++ b/ComputerSolitaire/Fixtures/golf.json @@ -0,0 +1,598 @@ +{ + "gameStartedAt" : 721692797, + "golfMatch" : { + "completedHoleScores" : [ + + ] + }, + "hasAppliedTimeBonus" : false, + "hasStartedTrackedGame" : false, + "hintRequestsInCurrentGame" : 0, + "history" : [ + + ], + "isCurrentGameFinalized" : false, + "movesCount" : 0, + "savedAt" : 721692800, + "schemaVersion" : 1, + "score" : 35, + "scoringDrawCount" : 1, + "state" : { + "discard" : [ + + ], + "foundations" : [ + [ + + ], + [ + + ], + [ + + ], + [ + + ] + ], + "freeCells" : [ + null, + null, + null, + null + ], + "pyramid" : [ + + ], + "stock" : [ + { + "id" : "83748569-7148-4E7D-AB05-0540128C06EF", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "BF38FF27-EFA6-4B5F-BAD3-2E3A3F3B8261", + "isFaceUp" : false, + "rank" : 11, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "49C30C8A-4B77-41FA-AC03-E872403E9856", + "isFaceUp" : false, + "rank" : 10, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "07481F13-BE30-4ED9-9F96-8A7A28714387", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "58709428-E6BA-43DC-BEC1-356D3B0BA5A0", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "5FEA25E2-D395-4E5A-9A04-65D2AE5F2D5B", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "EA68DAC1-98EC-4F29-B7A0-FDFB46C599A8", + "isFaceUp" : false, + "rank" : 4, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F4B668BA-0B89-4A86-8A96-2FB01342B81D", + "isFaceUp" : false, + "rank" : 6, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "9AAA647D-6194-42D0-AD1D-B440D14A2C30", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "1CF05041-991E-40D6-B63E-F104915EFF33", + "isFaceUp" : false, + "rank" : 5, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "E312E3C7-E0E1-456B-A987-BD3694A59B3D", + "isFaceUp" : false, + "rank" : 8, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "B93A1466-B5EA-401E-990B-C844B5586183", + "isFaceUp" : false, + "rank" : 2, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "CBFDD75E-B995-428E-8708-8752AA59028E", + "isFaceUp" : false, + "rank" : 7, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "2C709E64-B485-4D91-A172-DF45A4061D86", + "isFaceUp" : false, + "rank" : 1, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "6A2C2E66-43DC-443F-A511-27D24D1C6ACC", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "5F6E3C66-A8AE-4913-B2AF-47598AE8C1A9", + "isFaceUp" : false, + "rank" : 3, + "suit" : { + "clubs" : { + + } + } + } + ], + "tableau" : [ + [ + { + "id" : "69511DF1-AB7B-4198-96CE-1E5E48484462", + "isFaceUp" : true, + "rank" : 7, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "2763A2A0-AACA-425B-A70D-4EEA19911CDB", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "2D2A1A23-3921-4487-900A-C1546FE50E22", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "52D8FEE7-3B38-4801-A65A-38A17A4D1E92", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "4A0480D6-FA0E-436A-A5DE-F403F20C3F42", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "clubs" : { + + } + } + } + ], + [ + { + "id" : "280786DE-98AE-4227-B310-C435DBCBBA53", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "94E32960-BE3E-4F91-876E-988CB52F02BB", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "548D550C-BCCE-4177-92E1-A15D35A05782", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "E0CCE727-1CF1-4237-BDCB-976D8B260D3F", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "D0D17CE3-6CF3-4A9E-8073-F4A2ADBBDEDD", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "44354439-7BD4-4722-B12A-37230327ED06", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "D0D29053-1C89-430E-88A5-E0E2150ECAFF", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "53AFA746-EFEB-455A-8423-AE4426E913D6", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "2999C716-9E77-401B-BFE1-CBC1DE2B335E", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "1B3950FD-DFC6-4AE0-9889-8A4BD4AB569E", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "0FC9CD8E-17B3-44E7-AA34-39E4D75ADADD", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "2FB062E6-D033-4B29-AE7C-E7F56A6F80BB", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "36ABEE25-ED54-4568-8D97-BF227EC752A7", + "isFaceUp" : true, + "rank" : 13, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "5D10F573-452F-4E6C-A0B5-CB3E862C7B14", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "BB2BC901-0372-4337-9D2A-C27CD56B0009", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "hearts" : { + + } + } + } + ], + [ + { + "id" : "5907E50E-64B9-4D93-9B19-FC06F456D48C", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "FC855708-E294-4AAF-9273-7502FA9A730F", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "1919E0F3-F6FF-463D-B793-EC75712CFDA6", + "isFaceUp" : true, + "rank" : 11, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "3E9E7FE6-4126-4E93-8816-3ED1442D0C54", + "isFaceUp" : true, + "rank" : 3, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "A88BDF1C-AC3A-4F83-B23C-CB6D0A06C9BD", + "isFaceUp" : true, + "rank" : 10, + "suit" : { + "spades" : { + + } + } + } + ], + [ + { + "id" : "0B7E5056-2E5D-4918-8A7D-A4E310724F77", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "clubs" : { + + } + } + }, + { + "id" : "F7C17C87-5C7D-4972-8876-F837E297AEFF", + "isFaceUp" : true, + "rank" : 1, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "B49FDA2B-DAF9-4D3F-BE67-A4B14EE49095", + "isFaceUp" : true, + "rank" : 4, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "A390C540-9C3B-4C19-A34E-F8CFB9818C48", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "05FEA680-EEA7-4334-A945-5F3E93C4EF3E", + "isFaceUp" : true, + "rank" : 12, + "suit" : { + "diamonds" : { + + } + } + } + ], + [ + { + "id" : "FF19E978-043B-4C06-B3B0-A66EA70A9BDE", + "isFaceUp" : true, + "rank" : 5, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "F788240D-EDF3-466C-A8F1-CF50BA406D92", + "isFaceUp" : true, + "rank" : 9, + "suit" : { + "spades" : { + + } + } + }, + { + "id" : "8E0DEFE8-C873-4D06-9900-2F18F6123193", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "hearts" : { + + } + } + }, + { + "id" : "159C216D-3665-4109-8591-829A7A46BB69", + "isFaceUp" : true, + "rank" : 8, + "suit" : { + "diamonds" : { + + } + } + }, + { + "id" : "9CE8489E-1798-4C3C-9323-0CB01953B4C8", + "isFaceUp" : true, + "rank" : 6, + "suit" : { + "hearts" : { + + } + } + } + ] + ], + "triPeaks" : [ + + ], + "triPeaksChainLength" : 0, + "variant" : "golf", + "waste" : [ + { + "id" : "8AB3705A-2EB9-4E95-B52F-8D25D0331987", + "isFaceUp" : true, + "rank" : 2, + "suit" : { + "clubs" : { + + } + } + } + ], + "wasteDrawCount" : 1, + "wasteRecyclesUsed" : 0 + }, + "stockDrawCount" : 1, + "undosUsedInCurrentGame" : 0, + "usedRedealInCurrentGame" : false +} \ No newline at end of file diff --git a/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift b/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift new file mode 100644 index 0000000..d96267f --- /dev/null +++ b/ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Golf shares no foundation/tableau/free-cell move algebra with the building +/// variants — its only move is exposed column card onto the waste — so +/// `AutoMoveAdvisor` dispatches to it wholesale instead of threading its moves +/// through the pile-oriented hooks. +enum GolfAutoMoveAdvisor { + /// The exposed (last) card of every non-empty column, each as a + /// single-card selection. The waste top is never a selection: in Golf it + /// is the match target, not a mover. + static func candidateSelections(in state: GameState) -> [Selection] { + var selections: [Selection] = [] + + for pileIndex in state.tableau.indices { + guard let card = state.tableau[pileIndex].last else { continue } + selections.append( + Selection( + source: .tableau(pile: pileIndex, index: state.tableau[pileIndex].count - 1), + cards: [card] + ) + ) + } + + return selections + } + + /// `[.waste]` when the selection is rank-adjacent to a non-King waste top; + /// the waste is Golf's only destination. + static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state) else { return [] } + guard case .tableau(let pile, let index) = selection.source, + index == state.tableau[pile].count - 1 else { return [] } + guard GolfGameRules.canPlay(column: pile, in: state) else { return [] } + return [.waste] + } + + static func simulatedState( + afterMoving selection: Selection, + to destination: Destination, + in state: GameState + ) -> GameState? { + guard AutoMoveAdvisor.selectionMatchesState(selection, in: state) else { return nil } + return GolfGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + } +} diff --git a/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift b/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift new file mode 100644 index 0000000..b28115d --- /dev/null +++ b/ComputerSolitaire/Game/Golf/GamePersistenceGolf.swift @@ -0,0 +1,31 @@ +import Foundation + +enum GolfPersistenceRules { + static func hasValidLayout(state: GameState) -> Bool { + guard state.tableau.count == GolfGameRules.columnCount else { return false } + // Columns deal five cards and only ever shrink; every board card is + // dealt (and stays) face up. + guard state.tableau.allSatisfy({ $0.count <= GolfGameRules.columnDepth }) else { + return false + } + guard state.tableau.allSatisfy({ $0.allSatisfy(\.isFaceUp) }) else { return false } + // Golf renders no free-cell slots or foundations, so a card stranded + // there would be invisible and the game unwinnable. + guard state.freeCells.allSatisfy({ $0 == nil }) else { return false } + guard state.foundations.allSatisfy(\.isEmpty) else { return false } + // The pyramid and TriPeaks fields belong to those variants alone; a + // card stranded there would be invisible here. + guard state.pyramid.isEmpty, state.discard.isEmpty, state.triPeaks.isEmpty, + state.wasteRecyclesUsed == 0 else { + return false + } + // The deal starts the waste with one card and the waste only grows, so + // an empty waste is corrupt (there would be no match target). + guard !state.waste.isEmpty else { return false } + guard state.wasteDrawCount == 1 else { return false } + // The stock deals sixteen cards and only ever shrinks, face down. + guard state.stock.count <= GolfGameRules.dealStockCardCount else { return false } + guard state.stock.allSatisfy({ !$0.isFaceUp }) else { return false } + return true + } +} diff --git a/ComputerSolitaire/Game/Golf/GameRulesGolf.swift b/ComputerSolitaire/Game/Golf/GameRulesGolf.swift new file mode 100644 index 0000000..099aca2 --- /dev/null +++ b/ComputerSolitaire/Game/Golf/GameRulesGolf.swift @@ -0,0 +1,61 @@ +import Foundation + +enum GolfGameRules { + static let columnCount = 7 + static let columnDepth = 5 + static let dealTableauCardCount = 35 + /// 52 cards minus the 35-card board and the one-card waste starter. + static let dealStockCardCount = 16 + + /// Exactly one rank apart, suit ignored. Strict Golf never wraps, so + /// K and A do not connect. + static func ranksAreAdjacent(_ first: Int, _ second: Int) -> Bool { + abs(first - second) == 1 + } + + /// Strict Golf legality on raw rank values, shared verbatim by the + /// session rules and `GolfPlanner`: one rank up or down with no + /// wraparound, and a waste-top King accepts nothing — it is dead until a + /// stock flip. (A King may still be played onto a Queen; the dead-end is + /// one-directional.) + static func canPlayRank(_ cardRank: Int, ontoWasteTop wasteTopRank: Int) -> Bool { + guard wasteTopRank != Rank.king.rawValue else { return false } + return ranksAreAdjacent(cardRank, wasteTopRank) + } + + /// Whether the exposed (last) card of `column` may play onto the waste + /// right now. + static func canPlay(column: Int, in state: GameState) -> Bool { + guard state.variant == .golf, + state.tableau.indices.contains(column), + let card = state.tableau[column].last, + let wasteTop = state.waste.last else { return false } + return canPlayRank(card.rank.rawValue, ontoWasteTop: wasteTop.rank.rawValue) + } + + /// Single source of truth for applying a Golf move; used by the session + /// and the advisor so their outcomes can never drift. The only legal move + /// shape is playing the exposed card of a column onto the waste. Returns + /// nil for illegal moves. + static func stateByApplying( + selection: Selection, + destination: Destination, + to state: GameState + ) -> GameState? { + guard state.variant == .golf else { return nil } + guard selection.cards.count == 1, let selectedCard = selection.cards.first else { return nil } + guard case .tableau(let pile, let index) = selection.source, + case .waste = destination else { return nil } + guard state.tableau.indices.contains(pile), + index == state.tableau[pile].count - 1, + canPlay(column: pile, in: state), + state.tableau[pile].last?.id == selectedCard.id else { return nil } + + var nextState = state + nextState.tableau[pile].removeLast() + nextState.waste.append(selectedCard) + // The single visible waste card follows the new top. + nextState.wasteDrawCount = 1 + return nextState + } +} diff --git a/ComputerSolitaire/Game/Golf/GameSessionGolf.swift b/ComputerSolitaire/Game/Golf/GameSessionGolf.swift new file mode 100644 index 0000000..8cc1513 --- /dev/null +++ b/ComputerSolitaire/Game/Golf/GameSessionGolf.swift @@ -0,0 +1,173 @@ +import Foundation + +extension SolitaireViewModel { + // MARK: Configuration + + /// Golf draws a single card to the waste. The scoring draw count keeps the + /// draw-three basis the other stockless-choice variants use even though + /// Golf's stroke scoring adds no time bonus, so the shared invariant that + /// every variant defines a basis still holds. + func configureGolfNewGame() { + setStockDrawCount(DrawMode.one.rawValue) + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(min(1, state.waste.count)) + setInitialScore(golfBoardCardCount) + } + + func configureGolfRedeal() { + setScoringDrawCount(DrawMode.three.rawValue) + setWasteDrawCount(min(1, state.waste.count)) + setInitialScore(golfBoardCardCount) + } + + func sanitizeGolfRedealState(_ baseState: GameState) -> GameState { + var sanitizedState = baseState + sanitizedState.wasteDrawCount = min(1, sanitizedState.waste.count) + return sanitizedState + } + + /// The live Golf score: one stroke per card still on the board. Derived + /// from the actual state (35 on a fresh deal) so restored redeals stay + /// honest. + var golfBoardCardCount: Int { + state.tableau.reduce(0) { $0 + $1.count } + } + + // MARK: Moves + + /// Executes the Golf move (`.waste`): plays the exposed card of a column + /// onto the waste as one scored, undoable move. Each play removes one + /// stroke; the play that clears the board also banks one bonus stroke per + /// card left in the stock, making negative finals the best results. + @discardableResult + func performGolfMove(selection: Selection, to destination: Destination) -> Bool { + guard let nextState = GolfGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) else { return false } + + clearHint() + pushHistory( + undoContext: UndoAnimationContext( + action: .moveSelection, + cardIDs: selection.cards.map(\.id) + ) + ) + state = nextState + incrementMovesCount() + applyScore(.golfBoardPlay) + if state.isWon { + applyScore(.golfBoardClear(remainingStockCount: state.stock.count)) + } + applyTimeBonusIfWon() + self.selection = nil + SoundManager.shared.play(.cardPlaced) + refreshAutoFinishAvailability() + return true + } + + // MARK: Interaction + + /// A Golf card either plays onto the waste or it doesn't, so a tap + /// auto-moves the exposed card and any other tap just gives failure + /// feedback — there is no two-step select-then-tap flow. + func handleGolfTableauTap( + pile: [Card], + pileIndex: Int, + cardIndex: Int, + card: Card + ) -> Bool { + HapticManager.shared.play(.cardPickUp) + + guard cardIndex == pile.count - 1 else { + selection = nil + HapticManager.shared.play(.invalidDrop) + return true + } + + let tappedSelection = Selection( + source: .tableau(pile: pileIndex, index: cardIndex), + cards: [card] + ) + _ = queueBestAutoMove(for: tappedSelection) + selection = nil + return true + } + + // MARK: Stock + + /// Flips one stock card onto the waste. Single pass: once the stock is + /// empty the slot goes dead — Golf never recycles. The flip costs no + /// strokes; its price is the board card it didn't play. + func handleGolfStockTap() { + clearHint() + selection = nil + isDragging = false + pendingAutoMove = nil + guard !state.stock.isEmpty else { return } + drawFromStock() + } + + // MARK: Match + + /// Whether the hole is over without a win: the stock is spent and no + /// exposed card plays. Derived rather than stored — the check is exact + /// and cheap for Golf — so persistence and undo need nothing extra. + var isGolfHoleDead: Bool { + gameVariant == .golf && !isWin && !HintAdvisor.anyPlayerMoveExists(in: state) + } + + /// A hole ends won (board cleared) or dead (nothing left to play). + var isGolfHoleOver: Bool { + gameVariant == .golf && (isWin || isGolfHoleDead) + } + + /// The match total as it stands right now: the banked holes plus the hole + /// in play (whose strokes are its live score) — what the match would + /// total if play stopped here. Once the ninth hole banks, the live score + /// is already in the scorecard, so the sum drops out. The header's Match + /// tile and the hole-complete overlay both read this, so they can never + /// disagree. + var golfLiveMatchTotal: Int { + golfMatch.isComplete ? golfMatch.runningTotal : golfMatch.runningTotal + score + } + + /// Banks the finished hole's strokes into the scorecard and moves the + /// match forward: deals the next hole, or completes the match after the + /// ninth. Nothing enters the scorecard or the match statistics until the + /// player advances, so undoing out of a dead hole is always safe. + func advanceGolfHole() { + guard isGolfHoleOver, !golfMatch.isComplete else { return } + // A dead hole finalizes as played-not-won here (a won hole already + // finalized when the time bonus applied), recording its per-hole + // statistics — including the completed hole's stroke score — through + // the same funnel every variant's games end in. + finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) + golfMatch.completedHoleScores.append(score) + if golfMatch.isComplete { + // Stay on the finished board; the match summary presents from + // this persisted state, so quitting here re-presents it. + if golfMatch.countsTowardStatistics { + GameStatisticsStore.update(for: .golf) { stats in + stats.recordCompletedGolfMatch(total: golfMatch.runningTotal) + } + } + } else { + dealNextGolfHole() + } + } + + /// Resets the scorecard and deals the first hole of a fresh match. + func startNewGolfMatch() { + newGame(mode: .golf) + } + + /// Deals the next hole through the shared new-game path while preserving + /// the match — the one sanctioned exception to a fresh deal abandoning it. + private func dealNextGolfHole() { + let match = golfMatch + newGame(mode: .golf) + golfMatch = match + } +} diff --git a/ComputerSolitaire/Game/Golf/GameStateGolf.swift b/ComputerSolitaire/Game/Golf/GameStateGolf.swift new file mode 100644 index 0000000..76396d3 --- /dev/null +++ b/ComputerSolitaire/Game/Golf/GameStateGolf.swift @@ -0,0 +1,36 @@ +import Foundation + +extension GameState { + /// The Golf deal: seven columns of five face-up cards dealt column-major + /// (column 0 bottom-to-top first, then column 1, and so on), one face-up + /// card starting the waste, and the remaining 16 cards face down in the + /// stock. The hint probe and test fixtures copy this dealing order + /// verbatim — change them together. + static func newGolfGame() -> GameState { + var deck = Card.fullDeck().shuffled() + var tableau: [[Card]] = [] + + for _ in 0.. GolfMatchState { + GolfMatchState(completedHoleScores: completedHoleScores, countsTowardStatistics: false) + } + + /// Structural bounds for restoring a persisted match: at most nine holes, + /// each within the range a real hole can produce (+35 for an untouched + /// board down to −16 for a board cleared without a single draw). + var isValidForPersistence: Bool { + guard completedHoleScores.count <= Self.holeCount else { return false } + return completedHoleScores.allSatisfy { score in + (-GolfGameRules.dealStockCardCount...GolfGameRules.dealTableauCardCount) + .contains(score) + } + } +} diff --git a/ComputerSolitaire/Game/Golf/GolfPlanner.swift b/ComputerSolitaire/Game/Golf/GolfPlanner.swift new file mode 100644 index 0000000..1570e82 --- /dev/null +++ b/ComputerSolitaire/Game/Golf/GolfPlanner.swift @@ -0,0 +1,449 @@ +import Foundation + +/// Exact solver behind Golf hints. +/// +/// Golf's exact state is tiny: how deep each of the seven columns still is, +/// how many stock cards were drawn, and the waste's top rank (the only waste +/// fact that gates legality — buried waste history and suits are +/// strategically inert, so merging them is exact state merging, not a +/// collision). Columns only ever shrink from the exposed end and draws are +/// strictly sequential, so depths determine exactly which cards remain, and +/// the position packs into one collision-free 30-bit code (see `Board`). +/// Every move consumes a card (plays shrink the board, draws shrink the +/// stock), so the game graph is a DAG of depth ≤ 51 — but its seven +/// independent columns reach far more positions per deal than TriPeaks' +/// covering DAG, which is why the node budget is larger and the search nodes +/// are packed to twelve bytes (see `Limits` and `Node`). +/// +/// The search is the same single depth-first pass as `TriPeaksPlanner`, plays +/// explored before draws — no heuristic, no pruning. Strict Golf legality +/// (one rank up or down, no wraparound, nothing plays on a King) lives in +/// `GolfGameRules.canPlayRank`, which the move generator calls directly, so +/// the rules and the solver cannot drift. Because nothing is pruned, one +/// exhausted pass is simultaneously a proof the deal cannot be won and the +/// exact max-clear answer, so unwinnable deals (the majority under strict +/// rules) still get the best continuation found. Silence is reserved for +/// positions where not one more column card is clearable. +/// +/// Hint-quality baselines live in the `tools/hint-probe` ledger; the measured +/// verdict split is recorded below `Limits`. +enum GolfPlanner { + struct Limits { + var maxNodes: Int + var deadline: Date? + + // Golf's seven independent columns reach far more positions per deal + // than TriPeaks' covering DAG, so the budget is sized to decide deals, + // not just to bound pathology: measured over 10,000 seeded + // release-build deals, this cap proves 26.1% winnable, 66.3% + // unwinnable, and leaves 7.6% undecided (a 200k cap left 61% + // undecided), at a median `bestLine` of 36ms with ~40 MB of transient + // search state on the hardest deals. It stays affordable because a + // search node packs into 12 bytes (the whole board is a 30-bit code — + // see `Board` and `Node`). No maxDepth: the game graph is a DAG whose + // depth is structurally bounded (≤ 35 plays + 16 draws). + init(maxNodes: Int = 1_000_000, deadline: Date? = nil) { + self.maxNodes = maxNodes + self.deadline = deadline + } + } + + enum Move: Equatable { + /// Play the exposed card of `column` onto the waste, making it the new + /// match target. + case play(column: Int) + /// Flip the next stock card onto the waste (single pass, no redeals). + case draw + } + + enum SearchOutcome { + /// Replaying this line clears the columns; the deal is won. + case winningLine([Move]) + /// No winning line exists (or fit the budget); this line clears the most + /// column cards found. The flag is a proof when the unpruned graph was + /// exhausted rather than the budget running out. + case bestEffortLine([Move], dealIsProvedUnwinnable: Bool) + /// Not even one more column card is clearable within the horizon. + /// Exhaustive means proof (the full move graph was emptied). + case noProgress(searchWasExhaustive: Bool) + } + + static func bestHint(in state: GameState, limits: Limits = Limits()) -> HintAdvisor.Hint? { + let line: [Move] + switch bestLine(in: state, limits: limits) { + case .winningLine(let moves): + line = moves + case .bestEffortLine(let moves, _): + line = moves + case .noProgress: + return nil + } + guard let move = line.first else { return nil } + return materialize(move, in: state) + } + + static func bestLine(in state: GameState, limits: Limits = Limits()) -> SearchOutcome { + guard state.variant == .golf, let position = Position(state: state) else { + return .noProgress(searchWasExhaustive: false) + } + + let result = search(from: position, limits: limits) + if let line = result.winLine { + return .winningLine(line) + } + guard let line = result.bestLine else { + return .noProgress(searchWasExhaustive: result.exhaustive) + } + return .bestEffortLine(line, dealIsProvedUnwinnable: result.exhaustive) + } + + /// Exact position key, stable across `Card` identities; used to look up the + /// cached line as the player follows it. Ranks only, and only the waste's + /// top card: suits and buried waste history never matter in Golf, so + /// positions with identical hint futures intentionally share a key. + static func stateKey(for state: GameState) -> String { + var key = String() + key.reserveCapacity(64) + func append(card: Card) { + key.append(String(UnicodeScalar(UInt8(96 + card.rank.rawValue)))) + } + for column in state.tableau { + for card in column { append(card: card) } + key.append("|") + } + for card in state.stock { append(card: card) } + key.append("|") + if let wasteTop = state.waste.last { + append(card: wasteTop) + } + return key + } + + /// Maps each position along the line to the move to play there, so consecutive + /// hints are instant while the player follows (or plays ahead along) the line. + static func keyedMoves(along line: [Move], from state: GameState) -> [String: Move] { + var keyed: [String: Move] = [:] + var current = state + for move in line { + keyed[stateKey(for: current)] = move + guard let next = apply(move, to: current) else { break } + current = next + } + return keyed + } + + /// Converts a planner move into the executable hint, re-validating against the + /// live state so a stale cached move can never surface. + static func materialize(_ move: Move, in state: GameState) -> HintAdvisor.Hint? { + switch move { + case .play: + guard let (selection, destination) = sessionMove(for: move, in: state), + AutoMoveAdvisor.selectionMatchesState(selection, in: state), + AutoMoveAdvisor.legalDestinations(for: selection, in: state) + .contains(destination) else { + return nil + } + return .move(HintAdvisor.HintMove(selection: selection, destination: destination)) + case .draw: + guard !state.stock.isEmpty else { return nil } + return .stockTap + } + } + + /// Applies a planner move to a real game state, mirroring the session's move + /// effects; used to walk `keyedMoves` and to replay lines in tests. + static func apply(_ move: Move, to state: GameState) -> GameState? { + switch move { + case .play: + guard let (selection, destination) = sessionMove(for: move, in: state) else { return nil } + return GolfGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + case .draw: + guard !state.stock.isEmpty else { return nil } + var nextState = state + var card = nextState.stock.removeLast() + card.isFaceUp = true + nextState.waste.append(card) + nextState.wasteDrawCount = 1 + return nextState + } + } +} + +// MARK: - Session move mapping + +private extension GolfPlanner { + static func sessionMove( + for move: Move, + in state: GameState + ) -> (selection: Selection, destination: Destination)? { + guard case .play(let column) = move else { return nil } + guard state.tableau.indices.contains(column), + let card = state.tableau[column].last else { + return nil + } + return ( + Selection( + source: .tableau(pile: column, index: state.tableau[column].count - 1), + cards: [card] + ), + .waste + ) + } +} + +// MARK: - Compact position + +private extension GolfPlanner { + /// The deal's immutable rank tables plus the packed dynamic board. Stock cards + /// are indexed in draw order relative to the root; cards already in the waste + /// below its top are simply absent — the search never needs them. + struct Position { + /// Rank per column card, bottom to top, as dealt at the root. Columns + /// only shrink from the exposed (last) end, so a board depth indexes a + /// prefix of each column. + let columnRanks: [[Int]] + /// Rank per undrawn stock card, in draw order (index 0 draws next). + let stockRanks: [Int] + let root: Board + + init?(state: GameState) { + guard state.tableau.count == GolfGameRules.columnCount else { return nil } + guard state.tableau.allSatisfy({ $0.count <= GolfGameRules.columnDepth }) else { + return nil + } + guard state.stock.count <= GolfGameRules.dealStockCardCount else { return nil } + // The deal starts the waste with one card and nothing ever leaves + // it, so an empty waste marks a malformed state. + guard let wasteTop = state.waste.last else { return nil } + + columnRanks = state.tableau.map { column in column.map(\.rank.rawValue) } + stockRanks = state.stock.reversed().map(\.rank.rawValue) + root = Board( + columnDepths: state.tableau.map(\.count), + drawsUsed: 0, + wasteTopRank: wasteTop.rank.rawValue + ) + } + } + + /// One Golf position in 30 bits: the seven column depths (3 bits each, + /// 0–5), draws made since the root (5 bits, ≤ 16), and the waste's top + /// rank (4 bits, 1–13). Columns only shrink from the exposed end and + /// draws are strictly sequential, so depths determine exactly which cards + /// remain; the waste top is the one fact depths and draws cannot + /// reconstruct (it records whether the last event was a play or a draw). + /// Together the three fields determine the position's entire future — + /// making `code` an exact, collision-free transposition key that round- + /// trips losslessly (which is what lets search nodes store the code alone). + struct Board: Equatable { + /// Packed 3-bit depths, column 0 in the lowest bits. + var packedDepths: UInt32 + var drawsUsed: Int + var wasteTopRank: Int + + init(columnDepths: [Int], drawsUsed: Int, wasteTopRank: Int) { + var packed: UInt32 = 0 + for (column, depth) in columnDepths.enumerated() { + packed |= UInt32(depth) << (3 * UInt32(column)) + } + packedDepths = packed + self.drawsUsed = drawsUsed + self.wasteTopRank = wasteTopRank + } + + init(code: UInt32) { + packedDepths = code & 0x1F_FFFF + drawsUsed = Int((code >> 21) & 0b11111) + wasteTopRank = Int((code >> 26) & 0b1111) + } + + var code: UInt32 { + packedDepths + | (UInt32(drawsUsed) << 21) + | (UInt32(wasteTopRank) << 26) + } + + var remainingCount: Int { + (0.. Int { + Int((packedDepths >> (3 * UInt32(column))) & 0b111) + } + + mutating func removeExposedCard(from column: Int) { + packedDepths -= 1 << (3 * UInt32(column)) + } + } +} + +// MARK: - Move generation and transitions + +private extension GolfPlanner { + /// Legal moves in a fixed, deterministic order: playable columns ascending, + /// then draw — so equal-depth ties favor clearing over flipping and lines + /// read sensibly. Legality is `GolfGameRules.canPlayRank`, so the strict + /// no-wraparound and dead-King rules hold here by construction: a waste-top + /// King generates no plays, leaving the draw as the only move. + static func moves(from board: Board, position: Position) -> [Move] { + var moves: [Move] = [] + + for column in 0.. 0, + GolfGameRules.canPlayRank( + position.columnRanks[column][depth - 1], + ontoWasteTop: board.wasteTopRank + ) else { continue } + moves.append(.play(column: column)) + } + + if board.drawsUsed < position.stockRanks.count { + moves.append(.draw) + } + + return moves + } + + /// Applies a generated move without re-validating legality (the search only + /// feeds in moves it just generated). + static func apply(_ move: Move, to board: Board, position: Position) -> Board { + var next = board + switch move { + case .play(let column): + next.wasteTopRank = position.columnRanks[column][board.depth(of: column) - 1] + next.removeExposedCard(from: column) + case .draw: + next.wasteTopRank = position.stockRanks[next.drawsUsed] + next.drawsUsed += 1 + } + return next + } +} + +// MARK: - Search + +private extension GolfPlanner { + /// One explored position, packed to 12 bytes so the million-node budget + /// costs ~12 MB of nodes instead of ~50: the 30-bit board code stands in + /// for the whole board (it round-trips through `Board(code:)`), the move + /// that reached it packs into a byte, and depth fits sixteen bits (≤ 51). + struct Node { + let code: UInt32 + let parent: Int32 + let move: UInt8 + let depth: UInt16 + + static let noMove = UInt8.max + static let drawMove = UInt8(GolfGameRules.columnCount) + + static func encode(_ move: Move) -> UInt8 { + switch move { + case .play(let column): + return UInt8(column) + case .draw: + return drawMove + } + } + + var decodedMove: Move? { + switch move { + case Self.noMove: + return nil + case Self.drawMove: + return .draw + default: + return .play(column: Int(move)) + } + } + } + + /// One depth-first pass over the transposition-deduplicated game graph. + /// Children push in reverse generation order so the dive pops plays + /// (lowest column first) before the draw — the search plays whenever it + /// can and flips only when a branch is spent. Because nothing is pruned, + /// draining the stack proves unwinnability and makes the best-effort line + /// the exact max-clear answer (ties prefer the shallower line). + static func search( + from position: Position, + limits: Limits + ) -> (winLine: [Move]?, bestLine: [Move]?, exhaustive: Bool) { + if position.root.packedDepths == 0 { + return (winLine: [], bestLine: nil, exhaustive: true) + } + + var nodes: [Node] = [ + Node(code: position.root.code, parent: -1, move: Node.noMove, depth: 0) + ] + var visited: Set = [position.root.code] + var pending: [Int32] = [0] + var expansions = 0 + var wasTruncated = false + var best: (index: Int32, remaining: Int, depth: UInt16)? + + while let nodeIndex = pending.popLast() { + let node = nodes[Int(nodeIndex)] + let board = Board(code: node.code) + let remaining = board.remainingCount + + if remaining == 0 { + return ( + winLine: line(to: nodeIndex, nodes: nodes), + bestLine: nil, + exhaustive: false + ) + } + let improvesBest = best.map { + remaining < $0.remaining || (remaining == $0.remaining && node.depth < $0.depth) + } ?? (remaining < position.root.remainingCount) + if improvesBest { + best = (nodeIndex, remaining, node.depth) + } + + expansions += 1 + if nodes.count >= limits.maxNodes { + wasTruncated = true + break + } + if expansions % 64 == 0, let deadline = limits.deadline, Date() > deadline { + wasTruncated = true + break + } + + for move in moves(from: board, position: position).reversed() { + let nextBoard = apply(move, to: board, position: position) + guard visited.insert(nextBoard.code).inserted else { continue } + nodes.append( + Node( + code: nextBoard.code, + parent: nodeIndex, + move: Node.encode(move), + depth: node.depth + 1 + ) + ) + pending.append(Int32(nodes.count - 1)) + } + } + + guard let best, let moves = line(to: best.index, nodes: nodes) else { + return (winLine: nil, bestLine: nil, exhaustive: !wasTruncated) + } + return (winLine: nil, bestLine: moves, exhaustive: !wasTruncated) + } + + static func line(to index: Int32, nodes: [Node]) -> [Move]? { + var moves: [Move] = [] + var current = index + while current > 0 { + let node = nodes[Int(current)] + guard let move = node.decodedMove else { return nil } + moves.append(move) + current = node.parent + } + return moves.reversed() + } +} diff --git a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift index 7e22290..bdacb05 100644 --- a/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift +++ b/ComputerSolitaire/Game/Klondike/AutoFinishPlanner.swift @@ -60,6 +60,10 @@ private extension AutoFinishPlanner { // TriPeaks has no deterministic mop-up phase either: play order // matters to the last card, so the game never auto-finishes. return false + case .golf: + // Golf has no deterministic mop-up phase either: play order + // matters to the last card, so the game never auto-finishes. + return false } } diff --git a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift index dc08594..a823c7f 100644 --- a/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift @@ -5,8 +5,8 @@ import Foundation /// state looks like after a move. enum AutoMoveAdvisor { static func legalDestinations(for selection: Selection, in state: GameState) -> [Destination] { - // Pyramid and TriPeaks remove cards instead of building piles, so their - // move sets are generated wholesale rather than through the + // Pyramid, TriPeaks, and Golf remove cards instead of building piles, + // so their move sets are generated wholesale rather than through the // pile-oriented flow below. if state.variant == .pyramid { return PyramidAutoMoveAdvisor.legalDestinations(for: selection, in: state) @@ -14,6 +14,9 @@ enum AutoMoveAdvisor { if state.variant == .tripeaks { return TriPeaksAutoMoveAdvisor.legalDestinations(for: selection, in: state) } + if state.variant == .golf { + return GolfAutoMoveAdvisor.legalDestinations(for: selection, in: state) + } guard selectionMatchesState(selection, in: state) else { return [] } guard let movingCard = selection.cards.first else { return [] } @@ -69,6 +72,9 @@ enum AutoMoveAdvisor { if state.variant == .tripeaks { return TriPeaksAutoMoveAdvisor.candidateSelections(in: state) } + if state.variant == .golf { + return GolfAutoMoveAdvisor.candidateSelections(in: state) + } var selections: [Selection] = [] @@ -126,6 +132,13 @@ enum AutoMoveAdvisor { in: state ) } + if state.variant == .golf { + return GolfAutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state + ) + } guard selectionMatchesState(selection, in: state) else { return nil } guard legalDestinations(for: selection, in: state).contains(destination) else { return nil } @@ -268,9 +281,9 @@ private extension AutoMoveAdvisor { return YukonAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) case .spider: return SpiderAutoMoveAdvisor.allowsTableauPickup(of: cards, in: state) - case .pyramid, .tripeaks: - // Unreachable: Pyramid and TriPeaks dispatch wholesale before the - // tableau flow. + case .pyramid, .tripeaks, .golf: + // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale + // before the tableau flow. return false } } @@ -305,9 +318,9 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) - case .pyramid, .tripeaks: - // Unreachable: Pyramid and TriPeaks dispatch wholesale before the - // tableau flow. + case .pyramid, .tripeaks, .golf: + // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale + // before the tableau flow. return false } } @@ -338,9 +351,9 @@ private extension AutoMoveAdvisor { destinationTableauIndex: destinationTableauIndex, in: state ) - case .pyramid, .tripeaks: - // Unreachable: Pyramid and TriPeaks dispatch wholesale before the - // tableau flow. + case .pyramid, .tripeaks, .golf: + // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale + // before the tableau flow. return false } } @@ -375,9 +388,9 @@ private extension AutoMoveAdvisor { in: state, destinations: &destinations ) - case .pyramid, .tripeaks: - // Unreachable: Pyramid and TriPeaks dispatch wholesale before the - // tableau flow. + case .pyramid, .tripeaks, .golf: + // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale + // before the tableau flow. break } } @@ -392,9 +405,9 @@ private extension AutoMoveAdvisor { YukonAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) case .spider: SpiderAutoMoveAdvisor.applyTableauSourceRemovalEffects(on: &state, pileIndex: pileIndex) - case .pyramid, .tripeaks: - // Unreachable: Pyramid and TriPeaks dispatch wholesale before the - // tableau flow. + case .pyramid, .tripeaks, .golf: + // Unreachable: Pyramid, TriPeaks, and Golf dispatch wholesale + // before the tableau flow. break } } @@ -403,7 +416,7 @@ private extension AutoMoveAdvisor { /// 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: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: break case .spider: SpiderAutoMoveAdvisor.applyTableauDestinationEffects(on: &state, pileIndex: pileIndex) diff --git a/ComputerSolitaire/Game/Shared/GameMode.swift b/ComputerSolitaire/Game/Shared/GameMode.swift index 853fbc9..17ed4a1 100644 --- a/ComputerSolitaire/Game/Shared/GameMode.swift +++ b/ComputerSolitaire/Game/Shared/GameMode.swift @@ -16,6 +16,7 @@ enum GameMode: String, CaseIterable, Codable { case freecell case tripeaks case pyramid + case golf case yukon var variant: GameVariant { @@ -32,6 +33,8 @@ enum GameMode: String, CaseIterable, Codable { return .pyramid case .tripeaks: return .tripeaks + case .golf: + return .golf } } @@ -42,7 +45,8 @@ enum GameMode: String, CaseIterable, Codable { return .one case .klondikeDrawThree: return .three - case .freecell, .yukon, .spiderOneSuit, .spiderTwoSuits, .spiderFourSuits, .pyramid, .tripeaks: + case .freecell, .yukon, .spiderOneSuit, .spiderTwoSuits, .spiderFourSuits, .pyramid, + .tripeaks, .golf: return nil } } @@ -56,7 +60,7 @@ enum GameMode: String, CaseIterable, Codable { return .two case .spiderFourSuits: return .four - case .klondikeDrawOne, .klondikeDrawThree, .freecell, .yukon, .pyramid, .tripeaks: + case .klondikeDrawOne, .klondikeDrawThree, .freecell, .yukon, .pyramid, .tripeaks, .golf: return nil } } @@ -109,6 +113,8 @@ enum GameMode: String, CaseIterable, Codable { self = .pyramid case .tripeaks: self = .tripeaks + case .golf: + self = .golf } } diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 909638c..a1e249f 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -46,6 +46,10 @@ struct SavedGamePayload: Codable { let hintRequestsInCurrentGame: Int let undosUsedInCurrentGame: Int let usedRedealInCurrentGame: Bool + /// The nine-hole Golf match in progress; nil for every other variant. + /// Lives in the payload — not a side store — so the match restores + /// atomically with the board it belongs to. + let golfMatch: GolfMatchState? enum CodingKeys: String, CodingKey { case schemaVersion @@ -66,6 +70,7 @@ struct SavedGamePayload: Codable { case hintRequestsInCurrentGame case undosUsedInCurrentGame case usedRedealInCurrentGame + case golfMatch } init( @@ -86,7 +91,8 @@ struct SavedGamePayload: Codable { isCurrentGameFinalized: Bool = false, hintRequestsInCurrentGame: Int = 0, undosUsedInCurrentGame: Int = 0, - usedRedealInCurrentGame: Bool = false + usedRedealInCurrentGame: Bool = false, + golfMatch: GolfMatchState? = nil ) { self.schemaVersion = schemaVersion self.savedAt = savedAt @@ -106,6 +112,7 @@ struct SavedGamePayload: Codable { self.hintRequestsInCurrentGame = max(0, hintRequestsInCurrentGame) self.undosUsedInCurrentGame = max(0, undosUsedInCurrentGame) self.usedRedealInCurrentGame = usedRedealInCurrentGame + self.golfMatch = golfMatch } init(from decoder: Decoder) throws { @@ -134,6 +141,7 @@ struct SavedGamePayload: Codable { try container.decodeIfPresent(Int.self, forKey: .undosUsedInCurrentGame) ?? 0 ) usedRedealInCurrentGame = try container.decodeIfPresent(Bool.self, forKey: .usedRedealInCurrentGame) ?? false + golfMatch = try container.decodeIfPresent(GolfMatchState.self, forKey: .golfMatch) } /// The game this payload belongs to. Spider's suit count is derived from @@ -170,7 +178,11 @@ struct SavedGamePayload: Codable { isCurrentGameFinalized: false, hintRequestsInCurrentGame: 0, undosUsedInCurrentGame: 0, - usedRedealInCurrentGame: false + usedRedealInCurrentGame: false, + // The scorecard is gameplay progress and survives the reset; only + // its statistics eligibility is revoked, so completing the match + // later cannot finalize pre-reset holes into the fresh bucket. + golfMatch: golfMatch?.withStatisticsTrackingReset() ) } @@ -186,15 +198,15 @@ struct SavedGamePayload: Codable { switch state.variant { case .klondike: return DrawMode(rawValue: stockDrawCount)?.rawValue ?? DrawMode.three.rawValue - case .pyramid, .tripeaks: - // Both always draw a single card to the waste. + case .pyramid, .tripeaks, .golf: + // All three always draw a single card to the waste. return DrawMode.one.rawValue case .freecell, .yukon, .spider: return DrawMode.three.rawValue } }() let sanitizedMovesCount = max(0, movesCount) - let sanitizedScore = Scoring.clamped(score) + let sanitizedScore = Scoring.clamped(score, for: state.variant) let sanitizedSavedAt = min(savedAt, now) let sanitizedStartedAt = min(gameStartedAt, now) let sanitizedScoringDrawCount: Int = { @@ -222,7 +234,7 @@ struct SavedGamePayload: Codable { GameSnapshot( state: snapshot.state, movesCount: snapshot.movesCount, - score: Scoring.clamped(snapshot.score), + score: Scoring.clamped(snapshot.score, for: snapshot.state.variant), hasAppliedTimeBonus: snapshot.hasAppliedTimeBonus, undoContext: snapshot.undoContext ) @@ -235,6 +247,15 @@ struct SavedGamePayload: Codable { stockDrawCount: sanitizedStockDrawCount ) + // The match belongs to Golf alone; a structurally impossible scorecard + // drops to a fresh match rather than poisoning the restore. Scores are + // deliberately not clamped to zero — negative hole scores are Golf's + // best results. + let sanitizedGolfMatch: GolfMatchState? = { + guard state.variant == .golf, let golfMatch else { return nil } + return golfMatch.isValidForPersistence ? golfMatch : nil + }() + let sanitizedRedealState: GameState? = { guard var baseState = redealState, baseState.isValidForPersistence else { return nil } baseState.wasteDrawCount = Self.sanitizedWasteDrawCount( @@ -262,17 +283,19 @@ struct SavedGamePayload: Codable { isCurrentGameFinalized: sanitizedIsCurrentGameFinalized, hintRequestsInCurrentGame: sanitizedHintRequestsInCurrentGame, undosUsedInCurrentGame: sanitizedUndosUsedInCurrentGame, - usedRedealInCurrentGame: sanitizedUsedRedealInCurrentGame + usedRedealInCurrentGame: sanitizedUsedRedealInCurrentGame, + golfMatch: sanitizedGolfMatch ) } - /// Klondike fans up to a draw's worth of waste cards; Pyramid and TriPeaks - /// show a single waste card; the stockless variants keep no waste at all. + /// Klondike fans up to a draw's worth of waste cards; Pyramid, TriPeaks, + /// and Golf show a single waste card; the stockless variants keep no + /// waste at all. private static func sanitizedWasteDrawCount(for state: GameState, stockDrawCount: Int) -> Int { switch state.variant { case .klondike: return min(max(0, state.wasteDrawCount), min(stockDrawCount, state.waste.count)) - case .pyramid, .tripeaks: + case .pyramid, .tripeaks, .golf: return min(max(0, state.wasteDrawCount), min(1, state.waste.count)) case .freecell, .yukon, .spider: return 0 @@ -419,6 +442,13 @@ struct GameStatistics: Codable, Equatable { var highScoreOneSuit: Int? var highScoreTwoSuits: Int? var highScoreFourSuits: Int? + /// Best (lowest) Golf hole score. Unclamped: Golf strokes run lower-is- + /// better and clearing the board can leave a negative score, so negatives + /// are legal results here, never corruption. + var lowestScore: Int? + var golfMatchesCompleted: Int + /// Best (lowest) nine-hole Golf match total; unclamped like `lowestScore`. + var bestMatchTotal: Int? var cleanWins: Int enum CodingKeys: String, CodingKey { @@ -434,6 +464,9 @@ struct GameStatistics: Codable, Equatable { case highScoreOneSuit case highScoreTwoSuits case highScoreFourSuits + case lowestScore + case golfMatchesCompleted + case bestMatchTotal case cleanWins } @@ -450,6 +483,9 @@ struct GameStatistics: Codable, Equatable { highScoreOneSuit: Int? = nil, highScoreTwoSuits: Int? = nil, highScoreFourSuits: Int? = nil, + lowestScore: Int? = nil, + golfMatchesCompleted: Int = 0, + bestMatchTotal: Int? = nil, cleanWins: Int = 0 ) { self.schemaVersion = schemaVersion @@ -464,6 +500,9 @@ struct GameStatistics: Codable, Equatable { self.highScoreOneSuit = highScoreOneSuit.map { max(0, $0) } self.highScoreTwoSuits = highScoreTwoSuits.map { max(0, $0) } self.highScoreFourSuits = highScoreFourSuits.map { max(0, $0) } + self.lowestScore = lowestScore + self.golfMatchesCompleted = max(0, golfMatchesCompleted) + self.bestMatchTotal = bestMatchTotal self.cleanWins = max(0, min(cleanWins, self.gamesWon)) } @@ -495,6 +534,12 @@ struct GameStatistics: Codable, Equatable { highScoreOneSuit = try container.decodeIfPresent(Int.self, forKey: .highScoreOneSuit).map { max(0, $0) } highScoreTwoSuits = try container.decodeIfPresent(Int.self, forKey: .highScoreTwoSuits).map { max(0, $0) } highScoreFourSuits = try container.decodeIfPresent(Int.self, forKey: .highScoreFourSuits).map { max(0, $0) } + lowestScore = try container.decodeIfPresent(Int.self, forKey: .lowestScore) + golfMatchesCompleted = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .golfMatchesCompleted) ?? 0 + ) + bestMatchTotal = try container.decodeIfPresent(Int.self, forKey: .bestMatchTotal) cleanWins = max( 0, min( @@ -532,6 +577,9 @@ struct GameStatistics: Codable, Equatable { var highScoreOneSuit: Int? var highScoreTwoSuits: Int? var highScoreFourSuits: Int? + var lowestScore: Int? + var golfMatchesCompleted = 0 + var bestMatchTotal: Int? for stats in statsByVariant { gamesPlayed = addingSafely(gamesPlayed, stats.gamesPlayed) @@ -573,6 +621,13 @@ struct GameStatistics: Codable, Equatable { if let candidate = stats.highScoreFourSuits { highScoreFourSuits = max(highScoreFourSuits ?? 0, candidate) } + if let candidate = stats.lowestScore { + lowestScore = min(lowestScore ?? candidate, candidate) + } + golfMatchesCompleted = addingSafely(golfMatchesCompleted, stats.golfMatchesCompleted) + if let candidate = stats.bestMatchTotal { + bestMatchTotal = min(bestMatchTotal ?? candidate, candidate) + } } gamesWon = min(gamesWon, gamesPlayed) @@ -590,6 +645,9 @@ struct GameStatistics: Codable, Equatable { highScoreOneSuit: highScoreOneSuit, highScoreTwoSuits: highScoreTwoSuits, highScoreFourSuits: highScoreFourSuits, + lowestScore: lowestScore, + golfMatchesCompleted: golfMatchesCompleted, + bestMatchTotal: bestMatchTotal, cleanWins: cleanWins ) } @@ -600,6 +658,7 @@ struct GameStatistics: Codable, Equatable { finalScore: Int, drawCount: Int, spiderSuitCount: SpiderSuitCount? = nil, + lowerScoreIsBetter: Bool = false, hintsUsedInGame: Int, undosUsedInGame: Int, usedRedealInGame: Bool @@ -621,25 +680,30 @@ struct GameStatistics: Codable, Equatable { bestTimeSeconds = sanitizedElapsed } - if let spiderSuitCount { - // Spider difficulties aren't score-comparable, so each suit count - // keeps its own high score (mirroring Klondike's draw-mode split). - switch spiderSuitCount { - case .one: - highScoreOneSuit = max(highScoreOneSuit ?? 0, sanitizedScore) - case .two: - highScoreTwoSuits = max(highScoreTwoSuits ?? 0, sanitizedScore) - case .four: - highScoreFourSuits = max(highScoreFourSuits ?? 0, sanitizedScore) + // Golf's stroke scores are lower-is-better and record per completed + // hole through `recordCompletedGolfHole` (a win's negative final would + // sanitize to zero here); the high-score fields never apply to it. + if !lowerScoreIsBetter { + if let spiderSuitCount { + // Spider difficulties aren't score-comparable, so each suit count + // keeps its own high score (mirroring Klondike's draw-mode split). + switch spiderSuitCount { + case .one: + highScoreOneSuit = max(highScoreOneSuit ?? 0, sanitizedScore) + case .two: + highScoreTwoSuits = max(highScoreTwoSuits ?? 0, sanitizedScore) + case .four: + highScoreFourSuits = max(highScoreFourSuits ?? 0, sanitizedScore) + } + } else if drawCount == DrawMode.one.rawValue { + highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) + } else if drawCount == DrawMode.three.rawValue { + highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) + } else { + // Variants without a draw mode (FreeCell, Yukon, Pyramid) keep a + // single high score. + highScore = max(highScore ?? 0, sanitizedScore) } - } else if drawCount == DrawMode.one.rawValue { - highScoreDrawOne = max(highScoreDrawOne ?? 0, sanitizedScore) - } else if drawCount == DrawMode.three.rawValue { - highScoreDrawThree = max(highScoreDrawThree ?? 0, sanitizedScore) - } else { - // Variants without a draw mode (FreeCell, Yukon, Pyramid) keep a single - // high score. - highScore = max(highScore ?? 0, sanitizedScore) } let isCleanWin = sanitizedHintsUsedInGame == 0 @@ -650,6 +714,22 @@ struct GameStatistics: Codable, Equatable { } } + /// Records a finished Golf hole's stroke score, won or dead — every hole + /// played to its end has one, and best means lowest. Unsanitized on + /// purpose: negative scores (a cleared board banking leftover stock) are + /// Golf's best results, never corruption. Abandoned holes must not reach + /// this — their live score is a snapshot of an unfinished hole. + mutating func recordCompletedGolfHole(score: Int) { + lowestScore = min(lowestScore ?? score, score) + } + + /// Records a finished nine-hole Golf match; best means lowest, and + /// negative totals are legal results, never corruption. + mutating func recordCompletedGolfMatch(total: Int) { + golfMatchesCompleted = addingSafely(golfMatchesCompleted, 1) + bestMatchTotal = min(bestMatchTotal ?? total, total) + } + mutating func markTrackingStarted(at date: Date = .now) { if trackedSince == nil { trackedSince = date @@ -827,7 +907,7 @@ private extension GameState { private var expectedIdentityCounts: [CardIdentity: Int] { switch variant { - case .klondike, .freecell, .yukon, .pyramid, .tripeaks: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: var counts: [CardIdentity: Int] = [:] for suit in Suit.allCases { for rank in Rank.allCases { @@ -855,6 +935,8 @@ private extension GameState { return PyramidPersistenceRules.hasValidLayout(state: self) case .tripeaks: return TriPeaksPersistenceRules.hasValidLayout(state: self) + case .golf: + return GolfPersistenceRules.hasValidLayout(state: self) } } } diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 0945d23..46fe214 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -25,6 +25,10 @@ enum GameRules { // Neither has tableau piles; their moves flow through // PyramidGameRules and TriPeaksGameRules. return false + case .golf: + // Golf columns are never a destination; its one move flows + // through GolfGameRules. + return false } } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index aeac396..3e0a0d2 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -46,7 +46,16 @@ final class SolitaireViewModel { private var hintRequestsInCurrentGame: Int = 0 private var undosUsedInCurrentGame: Int = 0 private var usedRedealInCurrentGame = false - private let dateProvider: any DateProviding + /// The nine-hole Golf match in progress; meaningless (and left at its + /// fresh value) for every other variant. A fresh deal abandons it, a + /// statistics reset revokes its eligibility, and redeal and undo leave it + /// alone (undo snapshots deliberately exclude it, so undo can never cross + /// a hole boundary); the match methods in `GameSessionGolf.swift` drive + /// everything else. + var golfMatch = GolfMatchState() + /// Internal so variant session extensions (Golf's hole advance) share the + /// injected clock. + let dateProvider: any DateProviding @ObservationIgnored private let hintPlanner = HintPlanner() private var history: [GameSnapshot] = [] @@ -92,8 +101,19 @@ final class SolitaireViewModel { state.isWon } + // A completed Golf match's final board is an archive: its hole score is + // already banked into the scorecard and its statistics are recorded, so + // no gameplay command may mutate it. These two properties are the single + // gate for every entry point — toolbar, macOS menu, keyboard shortcut. var canUndo: Bool { - !history.isEmpty && !isWin + !history.isEmpty && !isWin && !golfMatch.isComplete + } + + /// Whether the current deal may be replayed. Redealing under a completed + /// Golf match would run a hidden tracked deal beneath the match summary + /// and finalize it as a phantom loss when the next match starts. + var canRedeal: Bool { + !golfMatch.isComplete } var hintedCardIDs: Set { @@ -184,18 +204,30 @@ final class SolitaireViewModel { hintRequestsInCurrentGame = 0 undosUsedInCurrentGame = 0 usedRedealInCurrentGame = false + if gameVariant == .golf { + // The scorecard plays on, but a match holding pre-reset holes can + // no longer finalize into the fresh statistics bucket. + golfMatch.countsTowardStatistics = false + } } func displayScore(at date: Date = .now) -> Int { guard !hasAppliedTimeBonus else { return score } let elapsedSeconds = elapsedActiveSeconds(at: date) - let maxBonus = Scoring.timedMaxBonus(for: scoringDrawCount) + let maxBonus = winTimeMaxBonus let bonus = Scoring.timeBonus( elapsedSeconds: elapsedSeconds, maxBonus: maxBonus, pointsLostPerSecond: Scoring.timedPointsLostPerSecond ) - return Scoring.clamped(score + bonus) + return Scoring.clamped(score + bonus, for: state.variant) + } + + /// A positive win bonus is perverse under Golf's lower-is-better stroke + /// scoring, so Golf's basis is zero; every other variant keeps the + /// draw-count basis. + var winTimeMaxBonus: Int { + state.variant.lowerScoreIsBetter ? 0 : Scoring.timedMaxBonus(for: scoringDrawCount) } func elapsedActiveSeconds(at date: Date = .now) -> Int { @@ -249,6 +281,10 @@ final class SolitaireViewModel { variant: mode.variant, spiderSuitCount: mode.spiderSuitCount ?? .two ) + // A fresh deal abandons any Golf match in progress. `dealNextGolfHole` + // restores the match around this reset when advancing holes, and + // `activateGame` re-adopts a stashed match through its payload restore. + golfMatch = GolfMatchState() state = initialState redealState = initialState selection = nil @@ -272,6 +308,7 @@ final class SolitaireViewModel { } func redeal() { + guard canRedeal else { return } finalizeCurrentGameIfNeeded(didWin: isWin, endedAt: dateProvider.now) clearHint() state = redealState @@ -296,7 +333,7 @@ final class SolitaireViewModel { } func undo() { - guard !isWin else { return } + guard canUndo else { return } guard let snapshot = history.popLast() else { return } clearHint() state = snapshot.state @@ -333,7 +370,8 @@ final class SolitaireViewModel { isCurrentGameFinalized: isCurrentGameFinalized, hintRequestsInCurrentGame: hintRequestsInCurrentGame, undosUsedInCurrentGame: undosUsedInCurrentGame, - usedRedealInCurrentGame: usedRedealInCurrentGame + usedRedealInCurrentGame: usedRedealInCurrentGame, + golfMatch: state.variant == .golf ? golfMatch : nil ) } @@ -361,6 +399,7 @@ final class SolitaireViewModel { hintRequestsInCurrentGame = sanitizedPayload.hintRequestsInCurrentGame undosUsedInCurrentGame = sanitizedPayload.undosUsedInCurrentGame usedRedealInCurrentGame = sanitizedPayload.usedRedealInCurrentGame + golfMatch = sanitizedPayload.golfMatch ?? GolfMatchState() history = Array(sanitizedPayload.history.suffix(Self.maxUndoHistoryCount)) var restoredRedealState = sanitizedPayload.redealState ?? history.first?.state ?? state restoredRedealState = normalizedRedealStateForCurrentVariant( @@ -486,7 +525,7 @@ final class SolitaireViewModel { } func setInitialScore(_ initialScore: Int) { - score = Scoring.clamped(initialScore) + score = Scoring.clamped(initialScore, for: state.variant) } func setScoringDrawCount(_ count: Int) { @@ -513,6 +552,8 @@ final class SolitaireViewModel { configurePyramidNewGame() case .tripeaks: configureTriPeaksNewGame() + case .golf: + configureGolfNewGame() } } @@ -528,6 +569,8 @@ final class SolitaireViewModel { configurePyramidRedeal() case .tripeaks: configureTriPeaksRedeal() + case .golf: + configureGolfRedeal() } } @@ -544,6 +587,8 @@ final class SolitaireViewModel { return sanitizePyramidRedealState(state) case .tripeaks: return sanitizeTriPeaksRedealState(state) + case .golf: + return sanitizeGolfRedealState(state) } } @@ -581,6 +626,13 @@ final class SolitaireViewModel { cardIndex: cardIndex, card: card ) + case .golf: + return handleGolfTableauTap( + pile: pile, + pileIndex: pileIndex, + cardIndex: cardIndex, + card: card + ) case .freecell, .pyramid, .tripeaks: return false } @@ -628,6 +680,9 @@ final class SolitaireViewModel { return canSelectFreeCellTableauCards(cards) case .spider: return SharedGameRules.isDescendingSameSuitRun(cards) + case .golf: + // Only the exposed card of a column can ever move. + return cards.count == 1 case .pyramid, .tripeaks: // Pyramid and TriPeaks have no tableau piles. return false @@ -661,6 +716,8 @@ extension SolitaireViewModel { handlePyramidStockTap() case .tripeaks: handleTriPeaksStockTap() + case .golf: + handleGolfStockTap() case .freecell, .yukon: break } @@ -674,7 +731,7 @@ extension SolitaireViewModel { return !(state.stock.isEmpty && state.waste.isEmpty) case .pyramid: return !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) - case .tripeaks: + case .tripeaks, .golf: // Single pass with no recycles: an empty stock is dead. return !state.stock.isEmpty case .spider: @@ -690,7 +747,7 @@ extension SolitaireViewModel { case .klondike: let count = min(state.wasteDrawCount, stockDrawCount) return Array(state.waste.suffix(count)) - case .pyramid, .tripeaks: + case .pyramid, .tripeaks, .golf: return Array(state.waste.suffix(min(1, state.wasteDrawCount))) case .freecell, .yukon, .spider: return [] @@ -699,8 +756,8 @@ extension SolitaireViewModel { func handleWasteTap() { guard state.variant.dealsFromStock else { return } - // The TriPeaks waste top is the match target, never a mover. - guard state.variant != .tripeaks else { return } + // The TriPeaks and Golf waste tops are the match target, never a mover. + guard state.variant != .tripeaks, state.variant != .golf else { return } guard let top = state.waste.last, state.wasteDrawCount > 0 else { return } HapticManager.shared.play(.cardPickUp) @@ -726,8 +783,8 @@ extension SolitaireViewModel { @discardableResult func startDragFromWaste() -> Bool { guard state.variant.dealsFromStock else { return false } - // The TriPeaks waste top is the match target, never a mover. - guard state.variant != .tripeaks else { return false } + // The TriPeaks and Golf waste tops are the match target, never a mover. + guard state.variant != .tripeaks, state.variant != .golf else { return false } guard let top = state.waste.last, state.wasteDrawCount > 0 else { return false } clearHint() selection = Selection(source: .waste, cards: [top]) @@ -860,6 +917,9 @@ extension SolitaireViewModel { if state.variant == .tripeaks { return performTriPeaksMove(selection: selection, to: destination) } + if state.variant == .golf { + return performGolfMove(selection: selection, to: destination) + } return performPyramidMove(selection: selection, to: destination) } } @@ -893,7 +953,7 @@ extension SolitaireViewModel { switch state.variant { case .klondike, .yukon, .spider: flipFaceDownTopCardIfNeeded(in: pileIndex) - case .freecell, .pyramid, .tripeaks: + case .freecell, .pyramid, .tripeaks, .golf: break } } @@ -947,24 +1007,28 @@ extension SolitaireViewModel { // TriPeaks chain scoring reads the before/after states, so // `performTriPeaksMove` applies it directly. break + case .golf: + // Golf stroke scoring reads the after state, so `performGolfMove` + // applies it directly. + break } } func applyScore(_ action: ScoringAction) { - score = Scoring.applying(action, to: score) + score = Scoring.applying(action, to: score, variant: state.variant) } func applyTimeBonusIfWon() { guard isWin, !hasAppliedTimeBonus else { return } let endedAt = dateProvider.now let elapsedSeconds = elapsedActiveSeconds(at: endedAt) - let maxBonus = Scoring.timedMaxBonus(for: scoringDrawCount) + let maxBonus = winTimeMaxBonus let bonus = Scoring.timeBonus( elapsedSeconds: elapsedSeconds, maxBonus: maxBonus, pointsLostPerSecond: Scoring.timedPointsLostPerSecond ) - score = Scoring.clamped(score + bonus) + score = Scoring.clamped(score + bonus, for: state.variant) finalElapsedSeconds = elapsedSeconds hasAppliedTimeBonus = true pauseStartedAt = nil @@ -974,6 +1038,11 @@ extension SolitaireViewModel { func finalizeCurrentGameIfNeeded(didWin: Bool, endedAt: Date) { guard hasStartedTrackedGame, !isCurrentGameFinalized else { return } let elapsedSeconds = elapsedActiveSeconds(at: endedAt) + // A Golf hole's stroke score is final only once the hole is over (won + // or dead); a mid-hole abandonment finalizes as a played game like any + // variant, but its score is an unfinished snapshot and must not enter + // the best-hole record. + let completedGolfHoleScore = isGolfHoleOver ? score : nil GameStatisticsStore.update(for: gameMode) { stats in stats.recordCompletedGame( didWin: didWin, @@ -981,10 +1050,14 @@ extension SolitaireViewModel { finalScore: score, drawCount: statisticsDrawCountForCurrentVariant(), spiderSuitCount: state.spiderSuitCount, + lowerScoreIsBetter: state.variant.lowerScoreIsBetter, hintsUsedInGame: hintRequestsInCurrentGame, undosUsedInGame: undosUsedInCurrentGame, usedRedealInGame: usedRedealInCurrentGame ) + if let completedGolfHoleScore { + stats.recordCompletedGolfHole(score: completedGolfHoleScore) + } } isCurrentGameFinalized = true } diff --git a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift index a45855d..ead95fa 100644 --- a/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift +++ b/ComputerSolitaire/Game/Shared/GameSessionInteraction.swift @@ -16,6 +16,10 @@ extension SolitaireViewModel { if state.variant == .spider, !canSelectTableauCards(cards) { return false } + if state.variant == .golf, cardIndex != pile.count - 1 { + // Only the exposed card of a Golf column can move. + return false + } selection = Selection(source: .tableau(pile: pileIndex, index: cardIndex), cards: cards) isDragging = true return true @@ -77,6 +81,11 @@ extension SolitaireViewModel { case .tripeaks: guard case .triPeaks(let sourceIndex) = selection.source else { return false } return TriPeaksGameRules.canPlay(index: sourceIndex, in: state) + case .golf: + guard case .tableau(let pile, let index) = selection.source, + 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: return false } diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index ed91350..139534f 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -96,6 +96,9 @@ struct GameState: Equatable, Codable { case .tripeaks: // Won once every peak slot is cleared; stock and waste may keep cards. return !triPeaks.isEmpty && triPeaks.allSatisfy { $0 == nil } + case .golf: + // Won once every column is emptied; stock and waste may keep cards. + return !tableau.isEmpty && tableau.allSatisfy(\.isEmpty) } } @@ -117,6 +120,8 @@ struct GameState: Equatable, Codable { return newPyramidGame() case .tripeaks: return newTriPeaksGame() + case .golf: + return newGolfGame() } } } diff --git a/ComputerSolitaire/Game/Shared/GameVariant.swift b/ComputerSolitaire/Game/Shared/GameVariant.swift index b7b5186..a6e5b22 100644 --- a/ComputerSolitaire/Game/Shared/GameVariant.swift +++ b/ComputerSolitaire/Game/Shared/GameVariant.swift @@ -9,6 +9,7 @@ enum GameVariant: String, CaseIterable, Codable { case freecell case tripeaks case pyramid + case golf case yukon var title: String { @@ -25,6 +26,8 @@ enum GameVariant: String, CaseIterable, Codable { return "Pyramid" case .tripeaks: return "TriPeaks" + case .golf: + return "Golf" } } @@ -42,12 +45,14 @@ enum GameVariant: String, CaseIterable, Codable { return "Pair cards that total 13" case .tripeaks: return "Chain up or down the ranks" + case .golf: + return "Play one rank up or down" } } var boardColumnCount: Int { switch self { - case .klondike, .yukon, .pyramid: + case .klondike, .yukon, .pyramid, .golf: return 7 case .freecell: return 8 @@ -63,7 +68,7 @@ enum GameVariant: String, CaseIterable, Codable { switch self { case .klondike, .yukon, .spider: return true - case .freecell, .pyramid, .tripeaks: + case .freecell, .pyramid, .tripeaks, .golf: return false } } @@ -72,7 +77,7 @@ enum GameVariant: String, CaseIterable, Codable { /// stock but deals it onto the tableau, never into a waste. var dealsFromStock: Bool { switch self { - case .klondike, .pyramid, .tripeaks: + case .klondike, .pyramid, .tripeaks, .golf: return true case .freecell, .yukon, .spider: return false @@ -84,7 +89,7 @@ enum GameVariant: String, CaseIterable, Codable { /// build one foundation per suit. var foundationPileCount: Int { switch self { - case .klondike, .freecell, .yukon, .pyramid, .tripeaks: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: return 4 case .spider: return 8 @@ -94,7 +99,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: + case .klondike, .freecell, .yukon, .pyramid, .tripeaks, .golf: return 52 case .spider: return 104 @@ -103,17 +108,26 @@ 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 and TriPeaks' foundations stay empty (their removed cards go - /// to the discard and waste respectively), so none of the three treats + /// 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: + case .spider, .pyramid, .tripeaks, .golf: return false } } + + /// Golf keeps a stroke-style score: lower is better, the win adds no time + /// bonus, the floor-0 clamp does not apply (clearing the board subtracts + /// one point per leftover stock card, so negative finals are the best + /// results), and statistics track the lowest final score instead of the + /// highest. + var lowerScoreIsBetter: Bool { + self == .golf + } } enum DrawMode: Int, CaseIterable, Codable { diff --git a/ComputerSolitaire/Game/Shared/HintAdvisor.swift b/ComputerSolitaire/Game/Shared/HintAdvisor.swift index 1c7d8e4..af54a71 100644 --- a/ComputerSolitaire/Game/Shared/HintAdvisor.swift +++ b/ComputerSolitaire/Game/Shared/HintAdvisor.swift @@ -28,6 +28,9 @@ enum HintAdvisor { if state.variant == .tripeaks, !state.stock.isEmpty { return true } + if state.variant == .golf, !state.stock.isEmpty { + return true + } for selection in AutoMoveAdvisor.candidateSelections(in: state) { // Foundation rollbacks only count as available moves where the hint // stack can actually turn one into a hint: Yukon's planner searches @@ -86,6 +89,12 @@ enum HintAdvisor { /// ratchet is the strongest of any variant — every TriPeaks move consumes a card /// (plays shrink the board, draws shrink the stock), so a followed line can never /// revisit a position. +/// Golf hints come from `GolfPlanner`'s exact search and behave exactly like +/// TriPeaks': winning lines when the deal is winnable, the max-clear line on +/// unwinnable deals (common under strict no-wraparound rules), nil only when +/// 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. final class HintPlanner { /// How long a single interactive hint request may spend searching. private static let freeCellSearchBudget: TimeInterval = 0.3 @@ -94,12 +103,18 @@ final class HintPlanner { private static let spiderSearchBudget: TimeInterval = 0.3 private static let pyramidSearchBudget: TimeInterval = 0.3 private static let triPeaksSearchBudget: TimeInterval = 0.3 + /// Golf's exact searches are the largest of the planners (see + /// `GolfPlanner.Limits`), so its clip is looser: a half-second think on + /// the rare hard deal beats truncating a provably winnable position into + /// a best-effort line. + private static let golfSearchBudget: TimeInterval = 0.5 private var freeCellPlan: [String: FreeCellSolver.Move] = [:] private var yukonPlan: [String: YukonPlanner.PlannedMove] = [:] private var spiderPlan: [String: SpiderPlanner.PlannedAction] = [:] private var pyramidPlan: [String: PyramidPlanner.Move] = [:] private var triPeaksPlan: [String: TriPeaksPlanner.Move] = [:] + private var golfPlan: [String: GolfPlanner.Move] = [:] func bestHint(in state: GameState, stockDrawCount: Int) -> HintAdvisor.Hint? { switch state.variant { @@ -121,6 +136,8 @@ final class HintPlanner { return pyramidHint(in: state) case .tripeaks: return triPeaksHint(in: state) + case .golf: + return golfHint(in: state) } } } @@ -215,6 +232,40 @@ private extension HintPlanner { return TriPeaksPlanner.materialize(move, in: state) } + func golfHint(in state: GameState) -> HintAdvisor.Hint? { + let key = GolfPlanner.stateKey(for: state) + if let hint = plannedGolfHint(for: key, in: state) { + return hint + } + + golfPlan.removeAll() + let limits = GolfPlanner.Limits( + deadline: Date().addingTimeInterval(Self.golfSearchBudget) + ) + switch GolfPlanner.bestLine(in: state, limits: limits) { + case .winningLine(let line), .bestEffortLine(let line, _): + golfPlan = GolfPlanner.keyedMoves(along: line, from: state) + return plannedGolfHint(for: key, in: state) + + case .noProgress: + // A proof, not a budget artifact: any clearable line registers + // within the search's first ~two dozen expansions (a root play + // pops immediately; otherwise only draws are legal, a chain of at + // most 16, and a draw-enabled play pops right after its draw), so + // no-progress is only ever reached by exhausting that region — + // far under the interactive budget. Every remaining action is + // provably futile stock-churning and silence is the honest + // answer. The hint button re-enables after the player's next move. + return nil + } + } + + func plannedGolfHint(for key: String, in state: GameState) -> HintAdvisor.Hint? { + // materialize re-validates the cached move against the live state. + guard let move = golfPlan[key] else { return nil } + return GolfPlanner.materialize(move, in: state) + } + func yukonHint(in state: GameState) -> HintAdvisor.Hint? { let key = YukonPlanner.stateKey(for: state) if let hint = plannedYukonHint(for: key, in: state) { diff --git a/ComputerSolitaire/Game/Shared/Scoring.swift b/ComputerSolitaire/Game/Shared/Scoring.swift index c63ea42..ff3c1fd 100644 --- a/ComputerSolitaire/Game/Shared/Scoring.swift +++ b/ComputerSolitaire/Game/Shared/Scoring.swift @@ -18,6 +18,13 @@ enum ScoringAction { /// replaces (not joins) the third `triPeaksPeakClear`. case triPeaksBoardClear case triPeaksStockFlip + /// Golf strokes count down: each card played off the board is one fewer + /// stroke against you. + case golfBoardPlay + /// Clearing the Golf board banks one bonus stroke per card left in the + /// stock, making the final score negative — traditional golf's best + /// outcome. + case golfBoardClear(remainingStockCount: Int) } enum Scoring { @@ -58,15 +65,22 @@ enum Scoring { return 30 case .triPeaksStockFlip: return -5 + case .golfBoardPlay: + return -1 + case .golfBoardClear(let remainingStockCount): + return -max(0, remainingStockCount) } } - static func applying(_ action: ScoringAction, to score: Int) -> Int { - clamped(score + delta(for: action)) + static func applying(_ action: ScoringAction, to score: Int, variant: GameVariant) -> Int { + clamped(score + delta(for: action), for: variant) } - static func clamped(_ score: Int) -> Int { - max(minimumScore, score) + /// Golf's stroke score is exempt from the floor: clearing the board + /// subtracts a point per leftover stock card, so negative finals are the + /// best results. Every other variant floors at `minimumScore`. + static func clamped(_ score: Int, for variant: GameVariant) -> Int { + variant.lowerScoreIsBetter ? score : max(minimumScore, score) } static func timeBonus( diff --git a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift index e4a7fda..7c873e1 100644 --- a/ComputerSolitaire/Game/Shared/TapMovePolicy.swift +++ b/ComputerSolitaire/Game/Shared/TapMovePolicy.swift @@ -101,8 +101,9 @@ private extension TapMovePolicy { case .spider: // Unreachable: Spider foundations are never player destinations. tier = 100 - case .pyramid, .tripeaks: - // Unreachable: Pyramid and TriPeaks moves never target a foundation. + case .pyramid, .tripeaks, .golf: + // Unreachable: Pyramid, TriPeaks, and Golf moves never target + // a foundation. tier = 0 } return Priority(tier: tier, buildLength: 0, pileOrder: -index) diff --git a/ComputerSolitaire/GameMenuCommands.swift b/ComputerSolitaire/GameMenuCommands.swift index 4a0ca6a..eda42cd 100644 --- a/ComputerSolitaire/GameMenuCommands.swift +++ b/ComputerSolitaire/GameMenuCommands.swift @@ -14,6 +14,7 @@ struct GameMenuActions { struct GameMenuState { var currentVariant: GameVariant var canUndo: Bool + var canRedeal: Bool var canAutoFinish: Bool var canHint: Bool var isHintVisible: Bool @@ -74,7 +75,7 @@ struct GameMenuCommands: Commands { } label: { Label("Redeal", systemImage: "arrow.clockwise") } - .disabled(actions == nil) + .disabled(!(state?.canRedeal ?? false)) Divider() diff --git a/ComputerSolitaire/Views/Golf/GolfHoleOverlays.swift b/ComputerSolitaire/Views/Golf/GolfHoleOverlays.swift new file mode 100644 index 0000000..9014000 --- /dev/null +++ b/ComputerSolitaire/Views/Golf/GolfHoleOverlays.swift @@ -0,0 +1,162 @@ +import SwiftUI + +/// End-of-hole interstitial: shows the finished hole's strokes and the match +/// standing, and advances the match on the player's tap. A dead hole (stock +/// spent, nothing plays) also offers Undo — nothing is recorded until the +/// player advances, so backing out is always safe. +struct GolfHoleCompleteOverlay: View { + let holeNumber: Int + let holeScore: Int + let matchTotalThroughHole: Int + let isFinalHole: Bool + let didClearBoard: Bool + let onAdvance: () -> Void + let onUndo: () -> Void + + var body: some View { + VStack(spacing: 12) { + Text(didClearBoard ? "Hole Cleared!" : "Hole Over") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text("Hole \(holeNumber) of \(GolfMatchState.holeCount): \(holeScore) strokes") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text(GolfScoreFormatting.parStanding( + total: matchTotalThroughHole, + holesPlayed: holeNumber + )) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.85)) + Button(isFinalHole ? "Finish Match" : "Next Hole") { + onAdvance() + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 0.0235, green: 0.4431, blue: 0.7176)) + if !didClearBoard { + Button("Undo") { + onUndo() + } + .buttonStyle(.bordered) + .tint(.white) + } + } + .golfOverlayChrome() + } +} + +/// Match-complete scorecard: nine hole scores, the total, and its standing +/// against par. Presents over the final board (it derives from persisted +/// state, so quitting here re-presents it on relaunch). +struct GolfMatchSummaryOverlay: View { + let match: GolfMatchState + let onNewMatch: () -> Void + + var body: some View { + VStack(spacing: 14) { + Text("Match Complete") + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Grid(alignment: .trailing, horizontalSpacing: 14, verticalSpacing: 4) { + ForEach(Array(match.completedHoleScores.enumerated()), id: \.offset) { index, score in + scorecardRow(holeNumber: index + 1, score: score) + } + } + .font(.system(size: 16, weight: .semibold, design: .rounded)) + + Text(GolfScoreFormatting.parStanding( + total: match.runningTotal, + holesPlayed: GolfMatchState.holeCount + )) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Button("New Match") { + onNewMatch() + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 0.0235, green: 0.4431, blue: 0.7176)) + } + .golfOverlayChrome() + } + + private func scorecardRow(holeNumber: Int, score: Int) -> some View { + // A hole at zero or under was cleared — Golf's best results — so it + // reads emphasized. + let holeWasCleared = score <= 0 + return GridRow { + Text("Hole \(holeNumber)") + .foregroundStyle(.white.opacity(0.75)) + .gridColumnAlignment(.leading) + Text("\(score)") + .monospacedDigit() + .foregroundStyle(holeWasCleared ? .white : .white.opacity(0.75)) + .fontWeight(holeWasCleared ? .bold : .regular) + } + } +} + +private extension View { + /// The Golf end-of-play presentation, matching the win overlay's chrome: + /// a dimming scrim behind a dark rounded panel, contained and modal to + /// VoiceOver because the board underneath is finished and inert — the + /// overlay's own controls are the only actions. + func golfOverlayChrome() -> some View { + ZStack { + Color.black.opacity(0.35) + .ignoresSafeArea() + + padding(32) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color.black.opacity(0.4)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + } + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + } +} + +enum GolfScoreFormatting { + /// "Total 32 — 8 under par pace" framing. Par is 45 across nine holes, + /// so the pace through N holes is 5N; after the ninth hole the pace IS + /// par, and the wording drops "pace". + static func parStanding(total: Int, holesPlayed: Int) -> String { + let parPace = (GolfMatchState.parTotal / GolfMatchState.holeCount) * holesPlayed + let difference = total - parPace + let isFinal = holesPlayed == GolfMatchState.holeCount + let reference = isFinal ? "par" : "par pace" + let standing: String + if difference == 0 { + standing = "even with \(reference)" + } else if difference < 0 { + standing = "\(-difference) under \(reference)" + } else { + standing = "\(difference) over \(reference)" + } + return "Total \(total) — \(standing)" + } +} + +#Preview("Hole Complete") { + GolfHoleCompleteOverlay( + holeNumber: 3, + holeScore: -2, + matchTotalThroughHole: 11, + isFinalHole: false, + didClearBoard: true, + onAdvance: {}, + onUndo: {} + ) +} + +#Preview("Match Summary") { + GolfMatchSummaryOverlay( + match: GolfMatchState(completedHoleScores: [3, 7, -2, 12, 0, 5, 9, 4, 2]), + onNewMatch: {} + ) +} diff --git a/ComputerSolitaire/Views/Golf/GolfTopRowView.swift b/ComputerSolitaire/Views/Golf/GolfTopRowView.swift new file mode 100644 index 0000000..1f9ecf0 --- /dev/null +++ b/ComputerSolitaire/Views/Golf/GolfTopRowView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Observation + +struct GolfTopRowView: View { + @Bindable var viewModel: SolitaireViewModel + let cardSize: CGSize + let columnSpacing: CGFloat + let activeTarget: DropTarget? + let hintedTarget: DropTarget? + let isStockHinted: Bool + let isWasteHinted: Bool + let hintHighlightOpacity: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + let hiddenCardIDs: Set + let hintedCardIDs: Set + let hintWiggleToken: UUID + let drawingCardIDs: Set + let fanProgress: [UUID: Double] + let dragGesture: (DragOrigin) -> AnyGesture + + var body: some View { + HStack(alignment: .top, spacing: columnSpacing) { + StockView( + viewModel: viewModel, + cardSize: cardSize, + isHintTargeted: isStockHinted, + hintHighlightOpacity: hintHighlightOpacity, + hintWiggleToken: hintWiggleToken + ) + .frame(width: cardSize.width, alignment: .leading) + + WasteView( + viewModel: viewModel, + cardSize: cardSize, + fanSpacing: 0, + isTargeted: activeTarget == .waste, + isTapEnabled: false, + isHintTargeted: hintedTarget == .waste || isWasteHinted, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + .background( + GeometryReader { proxy in + let frame = proxy.frame(in: .named("board")) + let hitFrame = frame.expanded( + horizontal: DropTargetHitArea.foundationHorizontalGrace, + top: DropTargetHitArea.foundationTopGrace, + bottom: DropTargetHitArea.foundationBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .waste: DropTargetGeometry(snapFrame: frame, hitFrame: hitFrame) + ] + ) + } + ) + + // Fill the seven-column board width so the stock and waste align + // with the leftmost columns. + ForEach(0..<(GolfGameRules.columnCount - 2), id: \.self) { _ in + Color.clear + .frame(width: cardSize.width, height: cardSize.height) + .accessibilityHidden(true) + } + } +#if os(iOS) + .frame(maxWidth: .infinity, alignment: .leading) +#endif + } +} diff --git a/ComputerSolitaire/Views/RulesAndScoringView.swift b/ComputerSolitaire/Views/RulesAndScoringView.swift index b2eb30f..a40e6f5 100644 --- a/ComputerSolitaire/Views/RulesAndScoringView.swift +++ b/ComputerSolitaire/Views/RulesAndScoringView.swift @@ -185,19 +185,28 @@ struct RulesAndScoringView: View { } } } - Text("On win, a time bonus is added.") - .font(.caption) - .foregroundStyle(.secondary) - Text( - "Time bonus starts at \(Scoring.timedMaxBonusDrawOne) in 1-card draw and " - + "\(Scoring.timedMaxBonusDrawThree) in 3-card draw, then drops by " - + "\(Scoring.timedPointsLostPerSecond) point per second." - ) - .font(.caption) - .foregroundStyle(.secondary) - Text("Score cannot go below \(Scoring.minimumScore).") - .font(.caption) - .foregroundStyle(.secondary) + if gameVariant.lowerScoreIsBetter { + Text( + "Golf scores run like golf: lower is better, there is no time bonus, " + + "and negative hole scores are possible after clearing the board." + ) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("On win, a time bonus is added.") + .font(.caption) + .foregroundStyle(.secondary) + Text( + "Time bonus starts at \(Scoring.timedMaxBonusDrawOne) in 1-card draw and " + + "\(Scoring.timedMaxBonusDrawThree) in 3-card draw, then drops by " + + "\(Scoring.timedPointsLostPerSecond) point per second." + ) + .font(.caption) + .foregroundStyle(.secondary) + Text("Score cannot go below \(Scoring.minimumScore).") + .font(.caption) + .foregroundStyle(.secondary) + } } } } @@ -285,6 +294,23 @@ struct RulesAndScoringView: View { definition: "Consecutive discards without flipping the stock; each discard in a chain is worth one more point than the last." ) ] + case .golf: + return [ + TermRow( + term: "Columns", + definition: "Seven face-up piles of five cards; only each column's exposed card may play." + ), + TermRow(term: "Stock", definition: "The face-down draw pile. One pass only — there are no recycles."), + TermRow( + term: "Waste", + definition: "The growing face-up pile; play any exposed card one rank above or below its top." + ), + TermRow(term: "Hole", definition: "One deal. Nine holes make a match."), + TermRow( + term: "Par", + definition: "45 strokes for a nine-hole match. Like golf, lower is better." + ) + ] } } @@ -348,6 +374,16 @@ struct RulesAndScoringView: View { "Tap the stock to flip one card onto the waste. The stock allows a single pass — there are no recycles.", "You win by clearing all 28 peak cards; stock and waste may keep cards." ] + case .golf: + return [ + "Deal 35 cards face up into seven columns of five. One card starts the waste; the remaining 16 form the stock.", + "Play any exposed column card that is one rank above or below the top waste card, regardless of suit. It becomes the new target.", + "Ranks never wrap: an Ace connects only to a Two, and a King only to a Queen.", + "Nothing plays on a King — once a King tops the waste, flip the stock to bury it.", + "Tap the stock to flip one card onto the waste. The stock allows a single pass — there are no recycles.", + "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." + ] } } @@ -424,6 +460,20 @@ struct RulesAndScoringView: View { note: "Reduced by elapsed time." ) ] + case .golf: + return [ + ScoringRow( + move: "Play a card onto the waste", + points: Scoring.delta(for: .golfBoardPlay), + note: "Your score is the cards still on the board." + ), + ScoringRow(move: "Flip a stock card", points: 0, note: nil), + ScoringRow( + move: "Clear the board", + points: Scoring.delta(for: .golfBoardClear(remainingStockCount: 1)), + note: "Per stock card left — scores below zero are the best results." + ) + ] } } } diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index eeee77c..21d737e 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -204,6 +204,10 @@ struct HeaderView: View { let movesCount: Int let elapsedSeconds: Int let score: Int + /// Golf titles its score tile with the hole in play ("Hole 3/9") and adds + /// a running match-total tile; nil for every other game. + var golfHoleLabel: String? + var golfMatchTotal: Int? let onGameTitleTapped: () -> Void let onScoreTapped: () -> Void @@ -248,12 +252,19 @@ struct HeaderView: View { Button(action: onScoreTapped) { StatTileView( - title: "Score", + title: golfHoleLabel ?? "Score", value: "\(score)" ) } .buttonStyle(.plain) .accessibilityLabel("Score \(score). Open scoring details") + + if let golfMatchTotal { + StatTileView( + title: "Match", + value: "\(golfMatchTotal)" + ) + } } private func formattedDuration(_ totalSeconds: Int) -> String { @@ -475,6 +486,25 @@ struct TopRowView: View { fanProgress: fanProgress, dragGesture: dragGesture ) + case .golf: + GolfTopRowView( + viewModel: viewModel, + cardSize: cardSize, + columnSpacing: columnSpacing, + activeTarget: activeTarget, + hintedTarget: hintedTarget, + isStockHinted: isStockHinted, + isWasteHinted: isWasteHinted, + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + drawingCardIDs: drawingCardIDs, + fanProgress: fanProgress, + dragGesture: dragGesture + ) } } } @@ -910,6 +940,10 @@ struct WinOverlay: View { ) ) } + // Modal to VoiceOver: the board underneath is won and inert; the only + // action is dealing again. + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 2808066..cbdc63a 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -155,6 +155,10 @@ struct ContentView: View { private enum TimeScoringPauseReason: Hashable { case lifecycle case menuPresentation + /// A dead Golf hole froze the clock: the hole's time is final the + /// moment nothing plays, however long the completion overlay sits, + /// and undoing out of the hole resumes without counting the dwell. + case golfHoleOver } private var isAnyMenuPresented: Bool { @@ -229,6 +233,7 @@ struct ContentView: View { Button("Redeal", systemImage: "arrow.clockwise") { redealFromUI() } + .disabled(!viewModel.canRedeal) Button("Auto Finish", systemImage: "bolt") { startAutoFinish() } @@ -279,6 +284,7 @@ struct ContentView: View { } .labelStyle(.iconOnly) .help("Redeal") + .disabled(!viewModel.canRedeal) } ToolbarSpacer(.fixed) ToolbarItemGroup(placement: .primaryAction) { @@ -415,6 +421,9 @@ struct ContentView: View { .onChange(of: viewModel.state) { _, _ in scheduleAutosave() queueAutoFinishStepIfPossible() + // Every path into or out of a dead Golf hole is a state + // change: the killing move freezes the clock, undo thaws it. + updateGolfHoleOverPauseState() } .onChange(of: viewModel.movesCount) { _, _ in scheduleAutosave() @@ -422,6 +431,12 @@ struct ContentView: View { .onChange(of: viewModel.stockDrawCount) { _, _ in scheduleAutosave() } + .onChange(of: viewModel.golfMatch) { _, _ in + // Finishing the ninth hole mutates only the match — the board + // stays as it was won or died — so the scorecard needs its own + // autosave trigger to survive a relaunch at the summary. + scheduleAutosave() + } .onChange(of: viewModel.hasActiveHint) { _, hasActiveHint in if !hasActiveHint { withAnimation(.easeOut(duration: 0.3)) { @@ -737,17 +752,45 @@ struct ContentView: View { overlayTilt: overlayTilt ) .zIndex(100) - if viewModel.isWin && winCelebration.phase != .idle { - WinOverlay(score: viewModel.score) { - startNewGameFromUI() - } - .zIndex(200) - .transition(.opacity) - } } } + // Hidden deliberately: these are animation ghosts of on-board + // cards. The interactive end-of-game overlays live in their own + // layer below so VoiceOver can reach their controls. .accessibilityHidden(true) } + .overlay { + if viewModel.gameVariant == .golf { + // Golf ends every hole through the match flow, so the + // shared win overlay never presents for it. + if viewModel.golfMatch.isComplete { + GolfMatchSummaryOverlay(match: viewModel.golfMatch) { + viewModel.startNewGolfMatch() + } + .transition(.opacity) + } else if viewModel.isGolfHoleOver, + // A dead hole presents immediately; a won hole waits + // for the celebration to start, the same gate the + // shared win overlay uses. + !viewModel.isWin || winCelebration.phase != .idle { + GolfHoleCompleteOverlay( + holeNumber: viewModel.golfMatch.currentHoleNumber, + holeScore: viewModel.score, + matchTotalThroughHole: viewModel.golfLiveMatchTotal, + isFinalHole: viewModel.golfMatch.currentHoleNumber == GolfMatchState.holeCount, + didClearBoard: viewModel.isWin, + onAdvance: { viewModel.advanceGolfHole() }, + onUndo: { viewModel.undo() } + ) + .transition(.opacity) + } + } else if viewModel.isWin && winCelebration.phase != .idle { + WinOverlay(score: viewModel.score) { + startNewGameFromUI() + } + .transition(.opacity) + } + } } private func headerMetrics(at date: Date) -> (elapsedSeconds: Int, score: Int) { @@ -768,12 +811,17 @@ struct ContentView: View { boardContentWidth: CGFloat, onScoreTapped: @escaping () -> Void ) -> some View { + let isGolf = viewModel.gameVariant == .golf return HeaderView( gameTitle: viewModel.gameVariant.title, gameQualifier: viewModel.gameMode.qualifier, movesCount: viewModel.movesCount, elapsedSeconds: elapsedSeconds, score: score, + golfHoleLabel: isGolf + ? "Hole \(viewModel.golfMatch.currentHoleNumber)/\(GolfMatchState.holeCount)" + : nil, + golfMatchTotal: isGolf ? viewModel.golfLiveMatchTotal : nil, onGameTitleTapped: { withAnimation(.smooth(duration: 0.25)) { isShowingGamePicker = true @@ -848,6 +896,7 @@ struct ContentView: View { GameMenuState( currentVariant: gameVariant, canUndo: !isUndoDisabled, + canRedeal: viewModel.canRedeal, canAutoFinish: isAutoFinishing || !isAutoFinishDisabled, canHint: !isHintDisabled, isHintVisible: isHintButtonVisible, @@ -886,6 +935,7 @@ struct ContentView: View { } private func redealFromUI() { + guard viewModel.canRedeal else { return } stopAutoFinish() winCelebration.reset(to: .idle) viewModel.redeal() @@ -1625,6 +1675,9 @@ struct ContentView: View { if isAnyMenuPresented { timeScoringPauseReasons.insert(.menuPresentation) } + if viewModel.isGolfHoleDead { + timeScoringPauseReasons.insert(.golfHoleOver) + } let shouldPauseTimeScoring = !timeScoringPauseReasons.isEmpty let didChange = shouldPauseTimeScoring ? viewModel.pauseTimeScoring() @@ -1658,13 +1711,13 @@ struct ContentView: View { } /// The cascade erupts from the foundations, except in Pyramid where every - /// removed card lives on the discard, and in TriPeaks where every played - /// card lives on the waste. + /// removed card lives on the discard, and in TriPeaks and Golf where every + /// played card lives on the waste. private var winCascadeLaunchPiles: [[Card]] { switch viewModel.gameVariant { case .pyramid: return [viewModel.state.discard] - case .tripeaks: + case .tripeaks, .golf: return [viewModel.state.waste] case .klondike, .freecell, .yukon, .spider: return viewModel.state.foundations @@ -1675,7 +1728,7 @@ struct ContentView: View { switch viewModel.gameVariant { case .pyramid: return [.discard] - case .tripeaks: + case .tripeaks, .golf: return [.waste] case .klondike, .freecell, .yukon, .spider: return viewModel.state.foundations.indices.map(DropTarget.foundation) @@ -1690,6 +1743,10 @@ struct ContentView: View { updatePauseReason(.menuPresentation, shouldPause: isAnyMenuPresented) } + private func updateGolfHoleOverPauseState() { + updatePauseReason(.golfHoleOver, shouldPause: viewModel.isGolfHoleDead) + } + private func updatePauseReason(_ reason: TimeScoringPauseReason, shouldPause: Bool) { guard hasLoadedGame else { return } let wasPaused = !timeScoringPauseReasons.isEmpty diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index f7f6fec..fc6dd67 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: + case .klondike, .spider, .freecell, .yukon, .golf: tableauRow } } @@ -408,7 +408,7 @@ private struct MiniBoardView: View { miniCard(.slot) Spacer(minLength: 0) miniCard(.slot) - case .tripeaks: + case .tripeaks, .golf: miniCard(.faceDown) miniCard(.faceUp) Spacer(minLength: 0) @@ -507,6 +507,8 @@ private struct MiniBoardView: View { return (0..<10).map { column in Array(repeating: .faceDown, count: column < 4 ? 5 : 4) + [.faceUp] } + case .golf: + return Array(repeating: Array(repeating: .faceUp, count: 5), count: 7) case .pyramid, .tripeaks: return [] } diff --git a/ComputerSolitaire/Views/StatisticsView.swift b/ComputerSolitaire/Views/StatisticsView.swift index cf510de..7adaff1 100644 --- a/ComputerSolitaire/Views/StatisticsView.swift +++ b/ComputerSolitaire/Views/StatisticsView.swift @@ -274,7 +274,15 @@ private struct GameStatisticsDetailView: View { statsKeyValueRow("Total Time", statsDurationLabel(displayTotalTimeSeconds(at: context.date))) statsKeyValueRow("Avg Time", statsDurationLabel(stats.averageTimeSeconds)) statsKeyValueRow("Best Time", bestTimeLabel) - statsKeyValueRow("High Score", statsScoreLabel(highScore(for: effectiveMode))) + if variant.lowerScoreIsBetter { + // Golf scores like its namesake: bests are lowest, and + // holes roll up into nine-hole matches. + statsKeyValueRow("Best Hole (lowest)", statsScoreLabel(stats.lowestScore)) + statsKeyValueRow("Best Match (lowest)", statsScoreLabel(stats.bestMatchTotal)) + statsKeyValueRow("Matches Completed", "\(stats.golfMatchesCompleted)") + } else { + statsKeyValueRow("High Score", statsScoreLabel(highScore(for: effectiveMode))) + } } header: { Text("Performance") } @@ -381,6 +389,8 @@ private struct GameStatisticsDetailView: View { /// Each mode bucket carries a single high score, routed to the field its /// wins record into (Klondike per draw count, Spider per suit count). + /// Golf never records one — its stroke scores are lower-is-better and + /// render through their own rows instead. private func highScore(for mode: GameMode) -> Int? { switch mode { case .klondikeDrawOne: @@ -395,6 +405,8 @@ private struct GameStatisticsDetailView: View { return stats.highScoreFourSuits case .freecell, .pyramid, .tripeaks, .yukon: return stats.highScore + case .golf: + return nil } } diff --git a/ComputerSolitaireTests/Golf/GolfMatchTests.swift b/ComputerSolitaireTests/Golf/GolfMatchTests.swift new file mode 100644 index 0000000..3481db8 --- /dev/null +++ b/ComputerSolitaireTests/Golf/GolfMatchTests.swift @@ -0,0 +1,534 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GolfMatchTests: XCTestCase { + // MARK: Model + + func testMatchStateInvariants() { + var match = GolfMatchState() + XCTAssertEqual(match.currentHoleNumber, 1) + XCTAssertEqual(match.runningTotal, 0) + XCTAssertFalse(match.isComplete) + + match.completedHoleScores = [3, -2, 7] + XCTAssertEqual(match.currentHoleNumber, 4) + XCTAssertEqual(match.runningTotal, 8, "Negative holes subtract from the total") + XCTAssertFalse(match.isComplete) + + match.completedHoleScores = Array(repeating: 5, count: GolfMatchState.holeCount) + XCTAssertEqual(match.currentHoleNumber, GolfMatchState.holeCount) + XCTAssertEqual(match.runningTotal, GolfMatchState.parTotal) + XCTAssertTrue(match.isComplete) + } + + func testLegacyMatchStateDecodesAsTracked() throws { + // Scorecards persisted before the eligibility flag existed decode as + // counting toward statistics. + let legacy = Data(#"{"completedHoleScores":[3,-2]}"#.utf8) + let decoded = try JSONDecoder().decode(GolfMatchState.self, from: legacy) + XCTAssertEqual(decoded.completedHoleScores, [3, -2]) + XCTAssertTrue(decoded.countsTowardStatistics) + } + + func testMatchStatePersistenceBounds() { + XCTAssertTrue(GolfMatchState(completedHoleScores: [-16, 35, 0]).isValidForPersistence) + XCTAssertFalse( + GolfMatchState(completedHoleScores: [36]).isValidForPersistence, + "No hole can leave more strokes than the board deals" + ) + XCTAssertFalse( + GolfMatchState(completedHoleScores: [-17]).isValidForPersistence, + "No clear can bank more than the full stock" + ) + XCTAssertFalse( + GolfMatchState( + completedHoleScores: Array(repeating: 1, count: GolfMatchState.holeCount + 1) + ).isValidForPersistence, + "A match never records more than nine holes" + ) + } + + // MARK: Match flow + + /// A one-card hole the session can win on demand: play the six onto the + /// waste seven, leaving two stock cards banked (hole score −2). + private func stageWinnableHole(on viewModel: SolitaireViewModel) { + viewModel.state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .six)]], + stock: [TestCards.make(.clubs, .nine), TestCards.make(.hearts, .two)], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + viewModel.configureGolfNewGame() + } + + private func winStagedHole(on viewModel: SolitaireViewModel) { + let selection = Selection( + source: .tableau(pile: 0, index: viewModel.state.tableau[0].count - 1), + cards: [viewModel.state.tableau[0].last!] + ) + XCTAssertTrue(viewModel.performGolfMove(selection: selection, to: .waste)) + XCTAssertTrue(viewModel.isWin) + } + + func testAdvanceRecordsHoleAndDealsNext() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + stageWinnableHole(on: viewModel) + winStagedHole(on: viewModel) + XCTAssertTrue(viewModel.isGolfHoleOver) + XCTAssertEqual(viewModel.score, -2) + + viewModel.advanceGolfHole() + + XCTAssertEqual(viewModel.golfMatch.completedHoleScores, [-2]) + XCTAssertEqual(viewModel.golfMatch.currentHoleNumber, 2) + XCTAssertFalse(viewModel.golfMatch.isComplete) + XCTAssertEqual( + viewModel.score, + GolfGameRules.dealTableauCardCount, + "Advancing deals a fresh hole with a fresh stroke score" + ) + XCTAssertFalse(viewModel.isWin) + XCTAssertFalse(viewModel.canUndo, "Undo can never cross a hole boundary") + } + + func testAdvanceRequiresAFinishedHole() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + let before = viewModel.state + + viewModel.advanceGolfHole() + + XCTAssertEqual(viewModel.state, before, "A live hole cannot be advanced past") + XCTAssertTrue(viewModel.golfMatch.completedHoleScores.isEmpty) + } + + func testDeadHoleAdvancesWithPenaltyStrokes() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + viewModel.configureGolfNewGame() + XCTAssertTrue(viewModel.isGolfHoleDead) + XCTAssertTrue(viewModel.isGolfHoleOver) + XCTAssertEqual(viewModel.score, 1, "One card left on the board is one stroke") + + viewModel.advanceGolfHole() + + XCTAssertEqual(viewModel.golfMatch.completedHoleScores, [1]) + XCTAssertEqual(viewModel.golfMatch.currentHoleNumber, 2) + } + + func testNinthHoleCompletesMatchWithoutDealing() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7, -2, 12, 0, 5, 9, 4] + stageWinnableHole(on: viewModel) + winStagedHole(on: viewModel) + let boardAfterWin = viewModel.state + let statsBefore = GameStatisticsStore.load(for: .golf) + + viewModel.advanceGolfHole() + + XCTAssertTrue(viewModel.golfMatch.isComplete) + XCTAssertEqual(viewModel.golfMatch.runningTotal, 36) + XCTAssertEqual( + viewModel.state, + boardAfterWin, + "The match summary presents over the finished board — no tenth deal" + ) + + let statsAfter = GameStatisticsStore.load(for: .golf) + XCTAssertEqual(statsAfter.golfMatchesCompleted, statsBefore.golfMatchesCompleted + 1) + XCTAssertEqual( + statsAfter.bestMatchTotal, + min(statsBefore.bestMatchTotal ?? 36, 36), + "Best match total records the lowest" + ) + + // Advancing again must not double-record the finished match. + viewModel.advanceGolfHole() + XCTAssertEqual( + GameStatisticsStore.load(for: .golf).golfMatchesCompleted, + statsAfter.golfMatchesCompleted + ) + + viewModel.startNewGolfMatch() + XCTAssertEqual(viewModel.golfMatch, GolfMatchState()) + XCTAssertEqual(viewModel.golfMatch.currentHoleNumber, 1) + XCTAssertFalse(viewModel.isWin) + } + + func testCompletedMatchArchivesTheFinalBoard() { + // Once the ninth hole banks, its board is an archive: the score is in + // the scorecard and the statistics are recorded, so Undo and Redeal — + // from any surface — must be inert. A dead ninth is the dangerous + // case: unlike a won one, `isWin` doesn't gate anything. + GameStatisticsStore.reset(for: .golf) + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7, -2, 12, 0, 5, 9, 4] + viewModel.state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .six)], [TestCards.make(.hearts, .ten)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + viewModel.configureGolfNewGame() + + // A real move (the six onto the seven) kills the hole and leaves + // undo history behind. + let selection = Selection( + source: .tableau(pile: 0, index: 0), + cards: [viewModel.state.tableau[0][0]] + ) + XCTAssertTrue(viewModel.performGolfMove(selection: selection, to: .waste)) + XCTAssertTrue(viewModel.isGolfHoleDead) + XCTAssertTrue(viewModel.canUndo, "A live dead hole may still be undone out of") + + viewModel.advanceGolfHole() + XCTAssertTrue(viewModel.golfMatch.isComplete) + let archivedBoard = viewModel.state + let statsAfterMatch = GameStatisticsStore.load(for: .golf) + + XCTAssertFalse(viewModel.canUndo, "The banked board may not be mutated") + viewModel.undo() + XCTAssertEqual(viewModel.state, archivedBoard) + + XCTAssertFalse(viewModel.canRedeal, "Redeal would run a hidden deal under the summary") + viewModel.redeal() + XCTAssertEqual(viewModel.state, archivedBoard) + + // Starting the next match must not finalize any phantom deal the + // blocked commands could have re-armed. + viewModel.startNewGolfMatch() + XCTAssertEqual( + GameStatisticsStore.load(for: .golf).gamesPlayed, + statsAfterMatch.gamesPlayed, + "No hidden deal was started, so no extra loss records" + ) + } + + func testFreshDealsAbandonTheMatch() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7] + + viewModel.newGame(mode: .klondikeDrawThree) + XCTAssertEqual(viewModel.golfMatch, GolfMatchState()) + + viewModel.newGame(mode: .golf) + XCTAssertEqual(viewModel.golfMatch, GolfMatchState(), "A fresh deal starts at hole one") + } + + func testActivatingAStashedSessionRestoresItsMatch() { + // Game switching stashes each mode's session and re-activates it from + // its payload, so leaving Golf and coming back resumes the match. + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7] + let stashed = viewModel.persistencePayload() + + viewModel.activateGame(.klondikeDrawThree, restoringFrom: nil) + XCTAssertEqual(viewModel.golfMatch, GolfMatchState()) + + XCTAssertTrue(viewModel.activateGame(.golf, restoringFrom: stashed)) + XCTAssertEqual( + viewModel.golfMatch.completedHoleScores, + [3, 7], + "Re-activating the stashed Golf session resumes the match" + ) + + // Without a stashed payload, activation falls back to a fresh deal + // and a fresh match. + viewModel.activateGame(.klondikeDrawThree, restoringFrom: nil) + XCTAssertFalse(viewModel.activateGame(.golf, restoringFrom: nil)) + XCTAssertEqual(viewModel.golfMatch, GolfMatchState()) + } + + func testLiveMatchTotalIncludesTheActiveHole() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + XCTAssertEqual( + viewModel.golfLiveMatchTotal, + GolfGameRules.dealTableauCardCount, + "On hole one, the live hole strokes are the whole match so far" + ) + + viewModel.golfMatch.completedHoleScores = [3, 7] + stageWinnableHole(on: viewModel) + XCTAssertEqual(viewModel.golfLiveMatchTotal, 11, "10 banked plus the staged hole's 1") + + winStagedHole(on: viewModel) + XCTAssertEqual(viewModel.golfLiveMatchTotal, 8, "10 banked plus the won hole's −2") + + // Once the ninth hole banks, the live hole score is already in the + // scorecard and must not double-count. + viewModel.golfMatch.completedHoleScores = [3, 7, -2, 12, 0, 5, 9, 4, -2] + XCTAssertEqual(viewModel.golfLiveMatchTotal, 36) + } + + func testRedealKeepsTheMatchAndResetsStrokes() { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7] + + viewModel.redeal() + + XCTAssertEqual( + viewModel.golfMatch.completedHoleScores, + [3, 7], + "Redeal replays the hole; the match stands" + ) + XCTAssertEqual(viewModel.score, GolfGameRules.dealTableauCardCount) + } + + // MARK: Statistics + + func testWonHoleRecordsBestHoleScore() { + GameStatisticsStore.reset(for: .golf) + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + stageWinnableHole(on: viewModel) + + winStagedHole(on: viewModel) + + XCTAssertEqual( + GameStatisticsStore.load(for: .golf).lowestScore, + -2, + "A won hole's final score records the moment the win finalizes" + ) + } + + func testDeadHoleRecordsBestHoleScore() { + // Strict Golf loses most holes; a dead hole is a completed hole and + // its strokes belong in the best-hole record. + GameStatisticsStore.reset(for: .golf) + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + viewModel.configureGolfNewGame() + XCTAssertTrue(viewModel.isGolfHoleDead) + + viewModel.advanceGolfHole() + + XCTAssertEqual(GameStatisticsStore.load(for: .golf).lowestScore, 1) + } + + func testMidHoleAbandonDoesNotRecordBestHole() { + // Abandoning a live hole finalizes a played game, but its score is an + // unfinished snapshot — never a hole score. + GameStatisticsStore.reset(for: .golf) + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + stageWinnableHole(on: viewModel) + XCTAssertFalse(viewModel.isGolfHoleOver, "The staged hole still has a play available") + + viewModel.newGame(mode: .golf) + + let stats = GameStatisticsStore.load(for: .golf) + XCTAssertGreaterThan(stats.gamesPlayed, 0, "The abandoned hole still counts as played") + XCTAssertNil(stats.lowestScore) + } + + func testDeadHolePauseKeepsOverlayDwellOutOfStatistics() { + // A dead hole's time is final the moment nothing plays; the view + // freezes the clock then, so however long the completion overlay + // sits, only play time reaches the statistics. + GameStatisticsStore.reset(for: .golf) + let clock = TestDateProvider(now: DateFixtures.reference) + let viewModel = SolitaireViewModel(dateProvider: clock, variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + viewModel.configureGolfNewGame() + XCTAssertTrue(viewModel.isGolfHoleDead) + + // The hole dies ten seconds in; the view pauses the clock… + clock.now = DateFixtures.plus(10) + XCTAssertTrue(viewModel.pauseTimeScoring(at: clock.now)) + + // …and the overlay dwells another ninety before the player advances. + clock.now = DateFixtures.plus(100) + viewModel.advanceGolfHole() + + XCTAssertEqual( + GameStatisticsStore.load(for: .golf).totalTimeSeconds, + 10, + "Overlay dwell time is not play time" + ) + } + + func testStatisticsResetMidMatchAbandonsTheAggregate() { + GameStatisticsStore.reset(for: .golf) + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7, -2, 12, 0, 5, 9, 4] + + // The user resets statistics with the match on hole nine. + GameStatisticsStore.reset(for: .golf) + viewModel.resetStatisticsTracking() + XCTAssertFalse(viewModel.golfMatch.countsTowardStatistics) + + stageWinnableHole(on: viewModel) + winStagedHole(on: viewModel) + viewModel.advanceGolfHole() + + XCTAssertTrue(viewModel.golfMatch.isComplete, "The match still plays to its end") + let stats = GameStatisticsStore.load(for: .golf) + XCTAssertEqual( + stats.golfMatchesCompleted, + 0, + "A match holding pre-reset holes must not finalize into the fresh bucket" + ) + XCTAssertNil(stats.bestMatchTotal) + + // The next match is fully post-reset and records again. + viewModel.startNewGolfMatch() + XCTAssertTrue(viewModel.golfMatch.countsTowardStatistics) + } + + // MARK: Persistence + + func testMatchSurvivesPayloadRoundTripMidHole() throws { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7, -2] + + let payload = viewModel.persistencePayload() + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(SavedGamePayload.self, from: data) + + let restored = SolitaireViewModel() + XCTAssertTrue(restored.restore(from: decoded)) + XCTAssertEqual(restored.golfMatch.completedHoleScores, [3, 7, -2]) + XCTAssertEqual(restored.golfMatch.currentHoleNumber, 4) + } + + func testLegacyPayloadWithoutMatchRestoresAFreshOne() throws { + let payload = SavedGamePayload( + state: GameState.newGolfGame(), + movesCount: 0, + stockDrawCount: DrawMode.one.rawValue, + history: [] + ) + var json = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(payload) + ) as? [String: Any] ?? [:] + json.removeValue(forKey: "golfMatch") + let data = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(SavedGamePayload.self, from: data) + + let restored = SolitaireViewModel() + XCTAssertTrue(restored.restore(from: decoded)) + XCTAssertEqual(restored.golfMatch, GolfMatchState()) + } + + func testSanitizerDropsForeignAndCorruptMatches() throws { + // A non-Golf payload never carries a match. + let klondikePayload = SavedGamePayload( + state: GameStateFixtures.seededKlondikeDeal(seed: 1), + movesCount: 0, + stockDrawCount: DrawMode.three.rawValue, + history: [], + golfMatch: GolfMatchState(completedHoleScores: [3]) + ) + let sanitizedKlondike = try XCTUnwrap( + klondikePayload.sanitizedForRestore(at: DateFixtures.reference) + ) + XCTAssertNil(sanitizedKlondike.golfMatch) + + // A structurally impossible scorecard drops to a fresh match. + let corruptPayload = SavedGamePayload( + state: GameState.newGolfGame(), + movesCount: 0, + stockDrawCount: DrawMode.one.rawValue, + history: [], + golfMatch: GolfMatchState(completedHoleScores: [99]) + ) + let sanitizedCorrupt = try XCTUnwrap( + corruptPayload.sanitizedForRestore(at: DateFixtures.reference) + ) + XCTAssertNil(sanitizedCorrupt.golfMatch) + + // A legal mid-match scorecard survives, negatives included. + let legalPayload = SavedGamePayload( + state: GameState.newGolfGame(), + movesCount: 0, + stockDrawCount: DrawMode.one.rawValue, + history: [], + golfMatch: GolfMatchState(completedHoleScores: [3, -2]) + ) + let sanitizedLegal = try XCTUnwrap( + legalPayload.sanitizedForRestore(at: DateFixtures.reference) + ) + XCTAssertEqual(sanitizedLegal.golfMatch?.completedHoleScores, [3, -2]) + } + + func testTrackingResetPreservesTheScorecardButRevokesEligibility() { + // Resetting statistics invalidates every stashed session's tracking; + // the Golf scorecard is gameplay progress and must survive the copy + // with only its statistics eligibility revoked. + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = [3, 7, -2] + + let reset = viewModel.persistencePayload().withStatisticsTrackingReset() + + XCTAssertEqual(reset.golfMatch?.completedHoleScores, [3, 7, -2]) + XCTAssertEqual(reset.golfMatch?.countsTowardStatistics, false) + XCTAssertFalse(reset.hasStartedTrackedGame) + + // The revoked eligibility survives restore sanitization too. + let restored = SolitaireViewModel() + XCTAssertTrue(restored.restore(from: reset)) + XCTAssertEqual(restored.golfMatch.completedHoleScores, [3, 7, -2]) + XCTAssertFalse(restored.golfMatch.countsTowardStatistics) + } + + func testCompletedMatchSurvivesRestoreForTheSummary() throws { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + viewModel.golfMatch.completedHoleScores = Array(repeating: 4, count: GolfMatchState.holeCount) + + let payload = viewModel.persistencePayload() + let restored = SolitaireViewModel() + XCTAssertTrue(restored.restore(from: payload)) + XCTAssertTrue( + restored.golfMatch.isComplete, + "Quitting at the summary re-presents it on relaunch" + ) + } + + // MARK: Formatting + + func testParStandingFormatting() { + XCTAssertEqual( + GolfScoreFormatting.parStanding(total: 45, holesPlayed: 9), + "Total 45 — even with par" + ) + XCTAssertEqual( + GolfScoreFormatting.parStanding(total: 42, holesPlayed: 9), + "Total 42 — 3 under par" + ) + XCTAssertEqual( + GolfScoreFormatting.parStanding(total: 50, holesPlayed: 9), + "Total 50 — 5 over par" + ) + XCTAssertEqual( + GolfScoreFormatting.parStanding(total: 11, holesPlayed: 3), + "Total 11 — 4 under par pace" + ) + } +} diff --git a/ComputerSolitaireTests/Golf/GolfPersistenceTests.swift b/ComputerSolitaireTests/Golf/GolfPersistenceTests.swift new file mode 100644 index 0000000..8f18941 --- /dev/null +++ b/ComputerSolitaireTests/Golf/GolfPersistenceTests.swift @@ -0,0 +1,211 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GolfPersistenceTests: XCTestCase { + private func payload( + for state: GameState, + score: Int = 0, + stockDrawCount: Int = DrawMode.one.rawValue + ) -> SavedGamePayload { + SavedGamePayload( + state: state, + movesCount: 0, + score: score, + stockDrawCount: stockDrawCount, + history: [] + ) + } + + /// A legally reachable mid-game shape: one stock card flipped, then two + /// column cards played onto the waste. + private func midGameState() -> GameState { + var state = GameStateFixtures.seededGolfDeal(seed: 2) + var drawn = state.stock.removeLast() + drawn.isFaceUp = true + state.waste.append(drawn) + for column in [0, 3] { + let played = state.tableau[column].removeLast() + state.waste.append(played) + } + state.wasteDrawCount = 1 + return state + } + + func testFreshDealRoundTripsThroughSanitization() throws { + let state = GameState.newGolfGame() + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual(restored.state, state) + XCTAssertEqual(restored.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testMidGameStateSurvivesEncodeDecode() throws { + let state = midGameState() + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertEqual(decoded, state) + + let sanitized = payload(for: state).sanitizedForRestore(at: DateFixtures.reference) + XCTAssertEqual(try XCTUnwrap(sanitized).state, state) + } + + func testNegativeGolfScoreSurvivesSanitizationWhileOthersClamp() throws { + // A cleared Golf board with leftover stock ends below zero; the + // sanitizer must preserve that for Golf and keep flooring everyone else. + var wonState = GameStateFixtures.golfState( + columns: [], + stock: [TestCards.make(.clubs, .nine), TestCards.make(.hearts, .two)], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + wonState.wasteDrawCount = 1 + let golfPayload = payload(for: wonState, score: -2) + let restoredGolf = try XCTUnwrap(golfPayload.sanitizedForRestore(at: DateFixtures.reference)) + XCTAssertEqual(restoredGolf.score, -2, "A negative Golf score is a legal result") + + let klondike = GameStateFixtures.seededKlondikeDeal(seed: 1) + let klondikePayload = payload( + for: klondike, + score: -2, + stockDrawCount: DrawMode.three.rawValue + ) + let restoredKlondike = try XCTUnwrap( + klondikePayload.sanitizedForRestore(at: DateFixtures.reference) + ) + XCTAssertEqual(restoredKlondike.score, 0, "Non-Golf scores still floor at zero") + } + + func testDecodingLegacySaveKeepsValidating() throws { + // Saves written before the Golf variant decode unchanged; Golf reuses + // the tableau/stock/waste fields, so no new GameState keys exist to + // go missing. + let legacy = GameStateFixtures.seededKlondikeDeal(seed: 1) + let data = try JSONEncoder().encode(legacy) + let decoded = try JSONDecoder().decode(GameState.self, from: data) + XCTAssertNotNil( + payload(for: decoded, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference) + ) + } + + func testSanitizationAcceptsLegalMidGameStates() { + XCTAssertNotNil(payload(for: midGameState()).sanitizedForRestore(at: DateFixtures.reference)) + } + + func testSanitizationRejectsCorruptGolfStates() { + func assertRejected( + _ message: String, + mutate: (inout GameState) -> Void + ) { + var state = midGameState() + mutate(&state) + XCTAssertNil( + payload(for: state).sanitizedForRestore(at: DateFixtures.reference), + message + ) + } + + assertRejected("Wrong column count") { state in + state.tableau.removeLast() + } + assertRejected("A six-card column exceeds the deal") { state in + let card = state.waste.removeLast() + state.tableau[6].append(card) + } + assertRejected("A face-down board card breaks the all-face-up invariant") { state in + state.tableau[1][0].isFaceUp = false + } + assertRejected("A face-up stock card breaks the stock invariant") { state in + state.stock[0].isFaceUp = true + } + assertRejected("A stock beyond sixteen cards exceeds the deal") { state in + // The mid-game stock holds fifteen; adding two crosses the deal's + // sixteen-card ceiling. + for _ in 0..<2 { + var card = state.waste.removeFirst() + card.isFaceUp = false + state.stock.append(card) + } + } + assertRejected("Cards stranded in the pyramid field are invisible") { state in + state.pyramid = [state.waste.removeLast()] + } + assertRejected("Cards stranded in the triPeaks field are invisible") { state in + state.triPeaks = [state.waste.removeLast()] + } + assertRejected("Cards stranded in the discard are invisible") { state in + state.discard = [state.waste.removeLast()] + } + assertRejected("Cards stranded in a foundation are invisible") { state in + state.foundations[0] = [state.waste.removeLast()] + } + assertRejected("Cards stranded in a free cell are invisible") { state in + state.freeCells[0] = state.waste.removeLast() + } + assertRejected("Golf never recycles the waste") { state in + state.wasteRecyclesUsed = 1 + } + assertRejected("Duplicate cards break deck composition") { state in + state.waste[0] = state.waste[1] + } + } + + func testLayoutRuleRejectsEmptyWasteAndWrongWasteDrawCount() { + // A full 52-card state cannot isolate these guards (the waste starter + // has nowhere else legal to live), so the layout rule is checked + // directly as defense in depth. + var state = midGameState() + state.waste = [] + XCTAssertFalse( + GolfPersistenceRules.hasValidLayout(state: state), + "An empty waste has no match target" + ) + + state = midGameState() + state.wasteDrawCount = 3 + XCTAssertFalse( + GolfPersistenceRules.hasValidLayout(state: state), + "Golf always shows a single waste card" + ) + } + + func testOtherVariantsRejectStrandedGolfShapedTableau() { + // Golf reuses the shared tableau, so the guard here is the reverse: + // a Golf save must reject cards stranded in the other variants' fields, + // and a Golf state under another variant's rules fails that variant's + // layout validation. + var state = midGameState() + state.variant = .tripeaks + XCTAssertNil( + payload(for: state).sanitizedForRestore(at: DateFixtures.reference), + "A Golf board is not a valid TriPeaks layout" + ) + } + + func testSanitizationForcesGolfDrawCounts() throws { + let state = GameState.newGolfGame() + let sanitized = payload(for: state, stockDrawCount: DrawMode.three.rawValue) + .sanitizedForRestore(at: DateFixtures.reference) + + let restored = try XCTUnwrap(sanitized) + XCTAssertEqual( + restored.stockDrawCount, + DrawMode.one.rawValue, + "Golf always draws a single card" + ) + XCTAssertEqual(restored.scoringDrawCount, DrawMode.three.rawValue) + } + + func testViewModelRestoresGolfPayload() throws { + let state = midGameState() + let viewModel = SolitaireViewModel() + XCTAssertTrue(viewModel.restore(from: payload(for: state))) + XCTAssertEqual(viewModel.gameVariant, .golf) + XCTAssertEqual(viewModel.state, state) + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + } +} diff --git a/ComputerSolitaireTests/Golf/GolfPlannerTests.swift b/ComputerSolitaireTests/Golf/GolfPlannerTests.swift new file mode 100644 index 0000000..3a2c5d6 --- /dev/null +++ b/ComputerSolitaireTests/Golf/GolfPlannerTests.swift @@ -0,0 +1,458 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GolfPlannerTests: XCTestCase { + /// The first seeded deal a *small* exhaustive search proves winnable. + /// Scanning a fixed seed range is deterministic and cheap — no magic + /// probe-verified constant to go stale. The node cap matters: the + /// end-to-end tests follow the real `HintPlanner`, whose interactive + /// deadline may truncate a search that needs the planner's full budget + /// (designed behavior), so the staged deal must be one whose winning + /// line is found well inside any deadline, debug builds included. + private func firstWinnableDeal(in seeds: ClosedRange = 1...50) -> GameState? { + let limits = GolfPlanner.Limits(maxNodes: 100_000) + for seed in seeds { + let state = GameStateFixtures.seededGolfDeal(seed: seed) + if case .winningLine = GolfPlanner.bestLine(in: state, limits: limits) { + return state + } + } + return nil + } + + private func firstProvedUnwinnableDeal(in seeds: ClosedRange = 1...50) -> GameState? { + for seed in seeds { + let state = GameStateFixtures.seededGolfDeal(seed: seed) + if case .bestEffortLine(_, dealIsProvedUnwinnable: true) = GolfPlanner.bestLine(in: state) { + return state + } + } + return nil + } + + func testHintIsDeterministicAcrossCalls() { + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .six)], + [TestCards.make(.hearts, .eight)] + ], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + + let first = GolfPlanner.bestHint(in: state) + XCTAssertNotNil(first) + for _ in 0..<10 { + XCTAssertEqual(GolfPlanner.bestHint(in: state), first) + } + } + + func testFreshDealsAlwaysHaveAHint() { + // A fresh deal always has a suggestible line within easy reach, so a + // small budget keeps the suite fast; production searches are capped by + // the interactive deadline. + let limits = GolfPlanner.Limits(maxNodes: 20_000) + for seed in 1...10 { + let state = GameStateFixtures.seededGolfDeal(seed: UInt64(seed)) + XCTAssertNotNil( + GolfPlanner.bestHint(in: state, limits: limits), + "Seed \(seed): a fresh Golf deal should have a suggestible line" + ) + } + } + + func testWinningLineReplaysLegallyToAClearedBoard() throws { + let state = try XCTUnwrap( + firstWinnableDeal(), + "No winnable deal in the scanned seed range — widen the range" + ) + guard case .winningLine(let line) = GolfPlanner.bestLine(in: state) else { + return XCTFail("The scanned winnable deal must reproduce its winning line") + } + + var current = state + for move in line { + if case .play = move { + replayThroughAdvisor(move, on: ¤t) + } else { + guard let next = GolfPlanner.apply(move, to: current) else { + return XCTFail("Stock move in the winning line was not legal") + } + current = next + } + } + XCTAssertTrue(current.isWon, "Replaying the winning line must clear the columns") + } + + func testKeyedMovesFollowTheSolutionLine() throws { + let state = try XCTUnwrap(firstWinnableDeal()) + guard case .winningLine(let line) = GolfPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line") + } + + let keyed = GolfPlanner.keyedMoves(along: line, from: state) + XCTAssertEqual(keyed.count, line.count, "Every position along the line gets its move") + + var current = state + for move in line { + XCTAssertEqual(keyed[GolfPlanner.stateKey(for: current)], move) + guard let next = GolfPlanner.apply(move, to: current) else { + return XCTFail("Line move was not legal") + } + current = next + } + } + + func testHintPlannerWinsAKnownDealEndToEnd() throws { + // Following the HintPlanner's cached lines (including stock taps) + // plays a winnable deal to a win. Guards the whole stack. The game is + // structurally bounded at 51 actions. + var state = try XCTUnwrap(firstWinnableDeal()) + let planner = HintPlanner() + var steps = 0 + + while steps < 60 { + if state.isWon { + return + } + guard let hint = planner.bestHint(in: state, stockDrawCount: 1) else { + return XCTFail("Hint stack gave up after \(steps) steps") + } + guard let next = applied(hint, to: state) else { + return XCTFail("Hinted action was not legal after \(steps) steps") + } + state = next + steps += 1 + } + XCTFail("Did not win within 60 steps") + } + + func testUnwinnableDealIsProvedAndStillYieldsBestEffortHints() throws { + var state = try XCTUnwrap( + firstProvedUnwinnableDeal(), + "No proved-unwinnable deal in the scanned seed range — widen the range" + ) + guard case .bestEffortLine(let line, dealIsProvedUnwinnable: true) = + GolfPlanner.bestLine(in: state) else { + return XCTFail("The scanned lost deal must reproduce its best-effort line") + } + XCTAssertFalse(line.isEmpty) + + // Following hints clears strictly more cards, never plays an illegal + // move, and ends in silence rather than churn. + let planner = HintPlanner() + func boardCount(_ state: GameState) -> Int { + state.tableau.reduce(0) { $0 + $1.count } + } + let boardAtStart = boardCount(state) + var steps = 0 + while steps < 60, let hint = planner.bestHint(in: state, stockDrawCount: 1) { + guard let next = applied(hint, to: state) else { + return XCTFail("Hinted action was not legal after \(steps) steps") + } + state = next + steps += 1 + } + XCTAssertLessThan(steps, 60, "Hints on a lost deal must eventually go silent") + XCTAssertFalse(state.isWon) + XCTAssertLessThan( + boardCount(state), + boardAtStart, + "Best-effort hints should still clear column cards" + ) + } + + func testFollowingHintsNeverRepeatsAPosition() { + // Every Golf move consumes a card, so followed lines can never + // revisit a position; this guards the state mapping and cache. + for seed in 1...5 { + let planner = HintPlanner() + var state = GameStateFixtures.seededGolfDeal(seed: UInt64(seed)) + var seen: Set = [GolfPlanner.stateKey(for: state)] + var steps = 0 + while steps < 60, !state.isWon, + let hint = planner.bestHint(in: state, stockDrawCount: 1) { + guard let next = applied(hint, to: state) else { + return XCTFail("Seed \(seed): hinted action was not legal") + } + state = next + steps += 1 + XCTAssertTrue( + seen.insert(GolfPlanner.stateKey(for: state)).inserted, + "Seed \(seed): following hints revisited a position" + ) + } + } + } + + func testDrawHintWhenNoPlayExists() { + // The 9 cannot play on the 6, but drawing the 8 makes it playable: the + // only winning line starts with a flip, so the hint is a stock tap. + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .nine)]], + stock: [TestCards.make(.clubs, .eight)], + waste: [TestCards.make(.diamonds, .six)], + fillWasteFromRemainder: true + ) + + guard case .winningLine(let line) = GolfPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line through the flip") + } + XCTAssertEqual(line.first, .draw) + XCTAssertEqual(GolfPlanner.bestHint(in: state), .stockTap) + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testWasteTopKingForcesADraw() { + // Strict Golf: nothing plays on a King — not even the exposed Queen. + // Flipping the Jack revives the board (the Queen plays on it), so the + // only winning line is draw, then play the Queen. + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .queen)]], + stock: [TestCards.make(.clubs, .jack)], + waste: [TestCards.make(.diamonds, .king)], + fillWasteFromRemainder: true + ) + + guard case .winningLine(let line) = GolfPlanner.bestLine(in: state) else { + return XCTFail("Expected a winning line through the flip") + } + XCTAssertEqual( + line.first, + .draw, + "A waste-top King is dead: the first move must be the flip" + ) + XCTAssertEqual(GolfPlanner.bestHint(in: state), .stockTap) + } + + func testNoWrapMakesKingOnlyBoardsUnwinnable() { + // Waste Ace, lone King on the board: only a wraparound link could + // clear it, and strict Golf has none. With stock remaining the search + // must prove the deal lost rather than churn. + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .king)]], + stock: [ + TestCards.make(.clubs, .ace), + TestCards.make(.hearts, .seven) + ], + waste: [TestCards.make(.diamonds, .ace)], + fillWasteFromRemainder: true + ) + + guard case .noProgress(searchWasExhaustive: true) = GolfPlanner.bestLine(in: state) else { + return XCTFail("Expected an exhaustive proof that no column card is clearable") + } + XCTAssertNil(GolfPlanner.bestHint(in: state)) + // The position still has legal draws — the hint's nil is a verdict, not a bug. + XCTAssertTrue(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testExhaustedSearchYieldsExactMaxClearLine() { + // The 2 plays on the 3; the King then blocks everything (no wrap), so + // the exact max-clear answer is one card, proved. + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .two)], + [TestCards.make(.hearts, .king)] + ], + waste: [TestCards.make(.diamonds, .three)], + fillWasteFromRemainder: true + ) + + guard case .bestEffortLine(let line, dealIsProvedUnwinnable: true) = + GolfPlanner.bestLine(in: state) else { + return XCTFail("Expected a proved best-effort outcome") + } + XCTAssertEqual(line, [.play(column: 0)]) + } + + func testNoMovesWhenStockEmptyAndNoPlay() { + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .nine)]], + waste: [TestCards.make(.diamonds, .six)], + fillWasteFromRemainder: true + ) + + guard case .noProgress(searchWasExhaustive: true) = GolfPlanner.bestLine(in: state) else { + return XCTFail("Expected an exhaustive no-progress outcome") + } + XCTAssertNil(GolfPlanner.bestHint(in: state)) + XCTAssertFalse(HintAdvisor.anyPlayerMoveExists(in: state)) + } + + func testStateKeyMergesOnlyStrategicallyIdenticalStates() { + let base = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .six)]], + stock: [TestCards.make(.clubs, .nine)], + waste: [TestCards.make(.diamonds, .seven)] + ) + + // Buried waste history and suits are strategically inert: same key. + var differentHistory = base + differentHistory.waste.insert(TestCards.make(.hearts, .two), at: 0) + XCTAssertEqual( + GolfPlanner.stateKey(for: base), + GolfPlanner.stateKey(for: differentHistory) + ) + var differentSuit = base + differentSuit.waste[0] = TestCards.make(.clubs, .seven) + XCTAssertEqual( + GolfPlanner.stateKey(for: base), + GolfPlanner.stateKey(for: differentSuit) + ) + + // The waste top rank, the board, and the stock all gate the future: + // each changes the key. + var differentTop = base + differentTop.waste[0] = TestCards.make(.diamonds, .eight) + XCTAssertNotEqual( + GolfPlanner.stateKey(for: base), + GolfPlanner.stateKey(for: differentTop) + ) + var differentBoard = base + differentBoard.tableau[0] = [TestCards.make(.spades, .five, isFaceUp: true)] + XCTAssertNotEqual( + GolfPlanner.stateKey(for: base), + GolfPlanner.stateKey(for: differentBoard) + ) + var differentStock = base + differentStock.stock = [TestCards.make(.clubs, .ten, isFaceUp: false)] + XCTAssertNotEqual( + GolfPlanner.stateKey(for: base), + GolfPlanner.stateKey(for: differentStock) + ) + // Identical cards in a different column arrangement are a different + // position (their exposure order differs). + var differentColumn = base + differentColumn.tableau[0] = [] + differentColumn.tableau[1] = [TestCards.make(.spades, .six, isFaceUp: true)] + XCTAssertNotEqual( + GolfPlanner.stateKey(for: base), + GolfPlanner.stateKey(for: differentColumn) + ) + } + + func testMaterializeRejectsStaleMoves() { + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .six)]], + waste: [TestCards.make(.diamonds, .seven)] + ) + let move = GolfPlanner.Move.play(column: 0) + XCTAssertNotNil(GolfPlanner.materialize(move, in: state)) + + var emptiedColumn = state + emptiedColumn.tableau[0] = [] + XCTAssertNil( + GolfPlanner.materialize(move, in: emptiedColumn), + "A cached move for an emptied column must not surface" + ) + + var changedTop = state + changedTop.waste[0] = TestCards.make(.hearts, .ten) + XCTAssertNil( + GolfPlanner.materialize(move, in: changedTop), + "A cached move that is no longer adjacent must not surface" + ) + + XCTAssertNil( + GolfPlanner.materialize(.draw, in: state), + "A draw hint with an empty stock must not surface" + ) + } + + func testTruncatedSearchReportsNoProgressWithoutClaimingProof() { + // A one-node budget cannot explore a fresh deal, so the search must + // report truncation — not exhaustion, which would wrongly claim the + // deal is dead. + let limits = GolfPlanner.Limits(maxNodes: 1) + let state = GameStateFixtures.seededGolfDeal(seed: 5) + + guard case .noProgress(searchWasExhaustive: false) = GolfPlanner.bestLine( + in: state, + limits: limits + ) else { + return XCTFail("Expected a truncated no-progress outcome") + } + XCTAssertNil(GolfPlanner.bestHint(in: state, limits: limits)) + } + + func testHintsAreAlwaysLegalFromArbitraryMidGamePositions() { + // Planner moves must materialize into advisor-legal moves from any + // reachable position, not just fresh deals. + for seed in 1...5 { + var state = GameStateFixtures.seededGolfDeal(seed: UInt64(seed)) + var generator = SeededRandomNumberGenerator(seed: UInt64(seed) &* 977) + for _ in 0..<8 { + var options: [(Selection, Destination)] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + options.append((selection, destination)) + } + } + if !state.stock.isEmpty, generator.next() % 2 == 0 { + state = GolfPlanner.apply(.draw, to: state) ?? state + } else if let choice = options.isEmpty + ? nil + : options[Int(generator.next() % UInt64(options.count))] { + state = AutoMoveAdvisor.simulatedState( + afterMoving: choice.0, + to: choice.1, + in: state, + stockDrawCount: 1 + ) ?? state + } + } + + guard let hint = GolfPlanner.bestHint(in: state) else { continue } + switch hint { + case .move(let move): + XCTAssertTrue( + AutoMoveAdvisor.selectionMatchesState(move.selection, in: state), + "Seed \(seed): hinted selection did not match the state" + ) + XCTAssertTrue( + AutoMoveAdvisor.legalDestinations(for: move.selection, in: state) + .contains(move.destination), + "Seed \(seed): hinted destination was not legal" + ) + case .stockTap: + XCTAssertFalse( + state.stock.isEmpty, + "Seed \(seed): stock tap hinted with a dead stock" + ) + } + } + } + + // MARK: - Helpers + + private func applied(_ hint: HintAdvisor.Hint, to state: GameState) -> GameState? { + switch hint { + case .move(let move): + return AutoMoveAdvisor.simulatedState( + afterMoving: move.selection, + to: move.destination, + in: state, + stockDrawCount: 1 + ) + case .stockTap: + return GolfPlanner.apply(.draw, to: state) + } + } + + private func replayThroughAdvisor(_ move: GolfPlanner.Move, on state: inout GameState) { + guard case .move(let hintMove)? = GolfPlanner.materialize(move, in: state) else { + return XCTFail("Play move failed to materialize") + } + guard let next = AutoMoveAdvisor.simulatedState( + afterMoving: hintMove.selection, + to: hintMove.destination, + in: state, + stockDrawCount: 1 + ) else { + return XCTFail("Materialized move was not advisor-legal") + } + state = next + } +} diff --git a/ComputerSolitaireTests/Golf/GolfRulesTests.swift b/ComputerSolitaireTests/Golf/GolfRulesTests.swift new file mode 100644 index 0000000..01269ad --- /dev/null +++ b/ComputerSolitaireTests/Golf/GolfRulesTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GolfRulesTests: XCTestCase { + // MARK: Adjacency + + func testStrictAdjacencyNeverWraps() { + XCTAssertTrue(GolfGameRules.ranksAreAdjacent(Rank.ace.rawValue, Rank.two.rawValue)) + XCTAssertTrue(GolfGameRules.ranksAreAdjacent(Rank.queen.rawValue, Rank.king.rawValue)) + XCTAssertFalse(GolfGameRules.ranksAreAdjacent(Rank.king.rawValue, Rank.ace.rawValue)) + XCTAssertFalse(GolfGameRules.ranksAreAdjacent(Rank.ace.rawValue, Rank.king.rawValue)) + XCTAssertFalse(GolfGameRules.ranksAreAdjacent(Rank.five.rawValue, Rank.five.rawValue)) + XCTAssertFalse(GolfGameRules.ranksAreAdjacent(Rank.five.rawValue, Rank.seven.rawValue)) + } + + func testNothingPlaysOnAKingButAKingPlaysOnAQueen() { + // The dead-end is one-directional: a King may land on a Queen, but a + // waste-top King accepts nothing — not even a Queen. + XCTAssertTrue( + GolfGameRules.canPlayRank(Rank.king.rawValue, ontoWasteTop: Rank.queen.rawValue) + ) + XCTAssertFalse( + GolfGameRules.canPlayRank(Rank.queen.rawValue, ontoWasteTop: Rank.king.rawValue) + ) + XCTAssertFalse( + GolfGameRules.canPlayRank(Rank.ace.rawValue, ontoWasteTop: Rank.king.rawValue) + ) + } + + func testAceConnectsOnlyToTwo() { + XCTAssertTrue(GolfGameRules.canPlayRank(Rank.two.rawValue, ontoWasteTop: Rank.ace.rawValue)) + XCTAssertTrue(GolfGameRules.canPlayRank(Rank.ace.rawValue, ontoWasteTop: Rank.two.rawValue)) + XCTAssertFalse(GolfGameRules.canPlayRank(Rank.king.rawValue, ontoWasteTop: Rank.ace.rawValue)) + } + + // MARK: canPlay + + func testCanPlayRequiresExposedAdjacentCard() { + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .nine), TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .three)] + ], + waste: [TestCards.make(.diamonds, .six)] + ) + + // Column 0's exposed seven is adjacent to the six; the buried nine is not playable. + XCTAssertTrue(GolfGameRules.canPlay(column: 0, in: state)) + // Column 1's three is not adjacent to the six. + XCTAssertFalse(GolfGameRules.canPlay(column: 1, in: state)) + // Empty columns and out-of-range indices never play. + XCTAssertFalse(GolfGameRules.canPlay(column: 2, in: state)) + XCTAssertFalse(GolfGameRules.canPlay(column: 99, in: state)) + } + + func testCanPlayRejectsWasteTopKing() { + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .queen)]], + waste: [TestCards.make(.diamonds, .king)] + ) + XCTAssertFalse(GolfGameRules.canPlay(column: 0, in: state)) + } + + func testCanPlayRejectsWrongVariant() { + var state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .seven)]], + waste: [TestCards.make(.diamonds, .six)] + ) + state.variant = .klondike + XCTAssertFalse(GolfGameRules.canPlay(column: 0, in: state)) + } + + // MARK: stateByApplying + + private func exposedSelection(column: Int, in state: GameState) -> Selection { + Selection( + source: .tableau(pile: column, index: state.tableau[column].count - 1), + cards: [state.tableau[column].last!] + ) + } + + func testStateByApplyingPlaysExposedCardOntoWaste() { + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .nine), TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .three)] + ], + waste: [TestCards.make(.diamonds, .six)] + ) + let seven = state.tableau[0].last! + + let nextState = GolfGameRules.stateByApplying( + selection: exposedSelection(column: 0, in: state), + destination: .waste, + to: state + ) + + XCTAssertNotNil(nextState) + XCTAssertEqual(nextState?.tableau[0].count, 1) + XCTAssertEqual(nextState?.tableau[0].last?.rank, .nine) + XCTAssertEqual(nextState?.waste.last?.id, seven.id) + XCTAssertEqual(nextState?.wasteDrawCount, 1) + } + + func testStateByApplyingRejectsIllegalMoves() { + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .nine), TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .three)] + ], + waste: [TestCards.make(.diamonds, .six)] + ) + + // Buried card. + let buriedSelection = Selection( + source: .tableau(pile: 0, index: 0), + cards: [state.tableau[0][0]] + ) + XCTAssertNil( + GolfGameRules.stateByApplying(selection: buriedSelection, destination: .waste, to: state) + ) + + // Non-adjacent exposed card. + XCTAssertNil( + GolfGameRules.stateByApplying( + selection: exposedSelection(column: 1, in: state), + destination: .waste, + to: state + ) + ) + + // Wrong destination. + XCTAssertNil( + GolfGameRules.stateByApplying( + selection: exposedSelection(column: 0, in: state), + destination: .tableau(1), + to: state + ) + ) + + // Stale card identity. + let staleSelection = Selection( + source: .tableau(pile: 0, index: state.tableau[0].count - 1), + cards: [TestCards.make(.hearts, .seven)] + ) + XCTAssertNil( + GolfGameRules.stateByApplying(selection: staleSelection, destination: .waste, to: state) + ) + + // Wrong variant. + var klondikeState = state + klondikeState.variant = .klondike + XCTAssertNil( + GolfGameRules.stateByApplying( + selection: exposedSelection(column: 0, in: klondikeState), + destination: .waste, + to: klondikeState + ) + ) + } + + func testStateByApplyingRejectsWasteTopKing() { + let state = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .queen)]], + waste: [TestCards.make(.diamonds, .king)] + ) + XCTAssertNil( + GolfGameRules.stateByApplying( + selection: exposedSelection(column: 0, in: state), + destination: .waste, + to: state + ) + ) + } + + // MARK: Advisor + + func testAdvisorCandidatesAreExposedCardsOnly() { + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .nine), TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .three)], + [] + ], + waste: [TestCards.make(.diamonds, .six)] + ) + + let selections = GolfAutoMoveAdvisor.candidateSelections(in: state) + + XCTAssertEqual(selections.count, 2) + XCTAssertTrue(selections.allSatisfy { $0.cards.count == 1 }) + XCTAssertEqual( + Set(selections.compactMap { $0.cards.first?.rank }), + Set([Rank.seven, Rank.three]) + ) + } + + func testAdvisorDestinationsAreWasteExactlyWhenPlayable() { + let state = GameStateFixtures.golfState( + columns: [ + [TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .three)] + ], + waste: [TestCards.make(.diamonds, .six)] + ) + + XCTAssertEqual( + GolfAutoMoveAdvisor.legalDestinations( + for: exposedSelection(column: 0, in: state), + in: state + ), + [.waste] + ) + XCTAssertEqual( + GolfAutoMoveAdvisor.legalDestinations( + for: exposedSelection(column: 1, in: state), + in: state + ), + [] + ) + } + + func testAdvisorSimulatedStateMatchesRules() { + let state = GameStateFixtures.seededGolfDeal(seed: 7) + + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + let simulated = AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: 1 + ) + let applied = GolfGameRules.stateByApplying( + selection: selection, + destination: destination, + to: state + ) + XCTAssertEqual(simulated, applied) + } + } + } +} diff --git a/ComputerSolitaireTests/Golf/GolfSessionTests.swift b/ComputerSolitaireTests/Golf/GolfSessionTests.swift new file mode 100644 index 0000000..6c256f6 --- /dev/null +++ b/ComputerSolitaireTests/Golf/GolfSessionTests.swift @@ -0,0 +1,314 @@ +import XCTest +@testable import Computer_Solitaire + +@MainActor +final class GolfSessionTests: XCTestCase { + private func makeGolfSession() -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.newGame(mode: .golf) + return viewModel + } + + /// A session staged on a hand-constructed board; draw counts and the + /// initial stroke score configured as a real Golf game would be. + private func makeStagedSession(state: GameState) -> SolitaireViewModel { + let viewModel = SolitaireViewModel(variant: .golf) + viewModel.state = state + viewModel.configureGolfNewGame() + return viewModel + } + + private func exposedSelection(column: Int, in viewModel: SolitaireViewModel) -> Selection { + Selection( + source: .tableau(pile: column, index: viewModel.state.tableau[column].count - 1), + cards: [viewModel.state.tableau[column].last!] + ) + } + + func testNewGolfGameLayout() { + let state = GameState.newGolfGame() + XCTAssertEqual(state.variant, .golf) + XCTAssertEqual(state.tableau.count, GolfGameRules.columnCount) + XCTAssertTrue(state.tableau.allSatisfy { $0.count == GolfGameRules.columnDepth }) + XCTAssertTrue(state.tableau.allSatisfy { $0.allSatisfy(\.isFaceUp) }) + XCTAssertEqual(state.stock.count, GolfGameRules.dealStockCardCount) + XCTAssertTrue(state.stock.allSatisfy { !$0.isFaceUp }) + XCTAssertEqual(state.waste.count, 1) + XCTAssertEqual(state.waste.last?.isFaceUp, true) + XCTAssertEqual(state.wasteDrawCount, 1) + XCTAssertTrue(state.pyramid.isEmpty) + XCTAssertTrue(state.triPeaks.isEmpty) + XCTAssertTrue(state.discard.isEmpty) + XCTAssertTrue(state.foundations.allSatisfy(\.isEmpty)) + XCTAssertFalse(state.isWon) + } + + func testSeededDealMatchesRealDealShape() { + let real = GameState.newGolfGame() + let seeded = GameStateFixtures.seededGolfDeal(seed: 1) + XCTAssertEqual(seeded.tableau.count, real.tableau.count) + XCTAssertEqual(seeded.tableau.map(\.count), real.tableau.map(\.count)) + XCTAssertEqual(seeded.stock.count, real.stock.count) + XCTAssertEqual(seeded.waste.count, real.waste.count) + XCTAssertEqual(seeded.wasteDrawCount, real.wasteDrawCount) + XCTAssertTrue(seeded.tableau.allSatisfy { $0.allSatisfy(\.isFaceUp) }) + } + + func testNewGameConfiguresDrawCountsAndInitialScore() { + let viewModel = makeGolfSession() + XCTAssertEqual(viewModel.stockDrawCount, DrawMode.one.rawValue) + XCTAssertEqual(viewModel.scoringDrawCount, DrawMode.three.rawValue) + XCTAssertFalse(viewModel.supportsDrawMode) + XCTAssertEqual( + viewModel.score, + GolfGameRules.dealTableauCardCount, + "The stroke score starts at one per board card" + ) + } + + func testPlayReducesScoreByOneAndPushesUndo() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .nine), TestCards.make(.hearts, .seven)], + [TestCards.make(.clubs, .jack)] + ], + waste: [TestCards.make(.diamonds, .six)], + fillWasteFromRemainder: true + ) + ) + XCTAssertEqual(viewModel.score, 3) + + XCTAssertTrue(viewModel.performGolfMove( + selection: exposedSelection(column: 0, in: viewModel), + to: .waste + )) + + XCTAssertEqual(viewModel.score, 2, "One card left the board") + XCTAssertEqual(viewModel.state.waste.last?.rank, .seven) + XCTAssertEqual(viewModel.movesCount, 1) + + viewModel.undo() + XCTAssertEqual(viewModel.score, 3) + XCTAssertEqual(viewModel.state.tableau[0].count, 2) + } + + func testStockTapDrawsOneCardAndScoresNothing() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + stock: [TestCards.make(.clubs, .nine)], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + let scoreBefore = viewModel.score + + viewModel.handleStockTap() + + XCTAssertTrue(viewModel.state.stock.isEmpty) + XCTAssertEqual(viewModel.state.waste.last?.rank, .nine) + XCTAssertEqual(viewModel.state.waste.last?.isFaceUp, true) + XCTAssertEqual(viewModel.state.wasteDrawCount, 1) + XCTAssertEqual(viewModel.score, scoreBefore, "A stock flip costs no strokes") + XCTAssertEqual(viewModel.movesCount, 1) + + viewModel.undo() + XCTAssertEqual(viewModel.state.stock.count, 1) + } + + func testStockExhaustsWithNoRecycle() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + XCTAssertFalse(viewModel.canInteractWithStock, "An empty Golf stock is dead") + + let movesBefore = viewModel.movesCount + let before = viewModel.state + viewModel.handleStockTap() + XCTAssertEqual(viewModel.state, before, "An empty stock never recycles") + XCTAssertEqual(viewModel.movesCount, movesBefore) + XCTAssertFalse(viewModel.canUndo, "A dead stock tap must not push history") + } + + func testWinningMoveBanksStockBonusIntoNegativeScoreWithNoTimeBonus() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .six)]], + stock: [ + TestCards.make(.clubs, .nine), + TestCards.make(.hearts, .two), + TestCards.make(.clubs, .king) + ], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + XCTAssertEqual(viewModel.score, 1) + + XCTAssertTrue(viewModel.performGolfMove( + selection: exposedSelection(column: 0, in: viewModel), + to: .waste + )) + + XCTAssertTrue(viewModel.isWin, "Clearing the last column card wins with stock remaining") + XCTAssertEqual( + viewModel.score, + -3, + "The final play removes the last stroke and banks one per leftover stock card" + ) + XCTAssertEqual(viewModel.displayScore(), -3, "No time bonus ever pads a Golf score") + XCTAssertNotNil(viewModel.finalElapsedSeconds, "The win still stops the clock") + } + + func testTapQueuesAutoMoveToWaste() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .six)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + + viewModel.handleTableauTap(pileIndex: 0, cardIndex: 0) + + XCTAssertEqual(viewModel.pendingAutoMove?.destination, .waste) + XCTAssertEqual(viewModel.pendingAutoMove?.selection.source, .tableau(pile: 0, index: 0)) + XCTAssertNil(viewModel.selection, "Golf has no two-step selection flow") + } + + func testTappingBuriedOrUnplayableCardsQueuesNothing() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .six), TestCards.make(.hearts, .ten)] + ], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + let before = viewModel.state + + viewModel.handleTableauTap(pileIndex: 0, cardIndex: 0) + XCTAssertEqual(viewModel.state, before, "A buried card ignores taps") + XCTAssertNil(viewModel.pendingAutoMove) + XCTAssertNil(viewModel.selection) + + viewModel.handleTableauTap(pileIndex: 0, cardIndex: 1) + XCTAssertEqual(viewModel.state, before, "An unplayable exposed card gives feedback only") + XCTAssertNil(viewModel.pendingAutoMove) + XCTAssertNil(viewModel.selection) + } + + func testWasteTapAndDragAreInert() { + let viewModel = makeGolfSession() + let before = viewModel.state + + viewModel.handleWasteTap() + XCTAssertEqual(viewModel.state, before) + XCTAssertNil(viewModel.selection, "The waste top is a target, never a mover") + + XCTAssertFalse(viewModel.startDragFromWaste()) + XCTAssertFalse(viewModel.isDragging) + } + + func testDragRequiresExposedCard() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .six), TestCards.make(.hearts, .ten)] + ], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + + XCTAssertFalse( + viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 0), + "Buried cards cannot drag" + ) + XCTAssertTrue( + viewModel.startDragFromTableau(pileIndex: 0, cardIndex: 1), + "Any exposed card can drag; playability is checked at the drop" + ) + XCTAssertEqual(viewModel.selection?.cards.count, 1) + XCTAssertTrue(viewModel.isDragging) + XCTAssertFalse(viewModel.canDrop(to: .waste), "Ten is not adjacent to seven") + + viewModel.selection = exposedSelection(column: 0, in: viewModel) + XCTAssertFalse( + viewModel.canDrop(to: .tableau(1)), + "Golf columns are never a destination" + ) + } + + func testDropOnWasteFollowsAdjacency() { + let viewModel = makeStagedSession( + state: GameStateFixtures.golfState( + columns: [ + [TestCards.make(.spades, .six)], + [TestCards.make(.hearts, .king)] + ], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + ) + + viewModel.selection = exposedSelection(column: 0, in: viewModel) + XCTAssertTrue(viewModel.canDrop(to: .waste)) + + viewModel.selection = exposedSelection(column: 1, in: viewModel) + XCTAssertFalse(viewModel.canDrop(to: .waste), "King is not adjacent to seven") + } + + func testHintAvailabilityTracksStockAndAdjacency() { + let withStock = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + stock: [TestCards.make(.clubs, .nine)], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + XCTAssertTrue( + HintAdvisor.anyPlayerMoveExists(in: withStock), + "A flip is a legal action while stock remains" + ) + + let deadBoard = GameStateFixtures.golfState( + columns: [[TestCards.make(.spades, .ten)]], + waste: [TestCards.make(.diamonds, .seven)], + fillWasteFromRemainder: true + ) + XCTAssertFalse( + HintAdvisor.anyPlayerMoveExists(in: deadBoard), + "Empty stock and no adjacent play: nothing is legal" + ) + } + + func testRedealRestoresDealAndStrokes() { + let viewModel = makeGolfSession() + let dealtState = viewModel.state + + let playable = AutoMoveAdvisor.candidateSelections(in: viewModel.state).first { selection in + !AutoMoveAdvisor.legalDestinations(for: selection, in: viewModel.state).isEmpty + } + if let playable { + XCTAssertTrue(viewModel.performGolfMove(selection: playable, to: .waste)) + } else { + viewModel.handleStockTap() + } + XCTAssertNotEqual(viewModel.state, dealtState) + + viewModel.redeal() + + XCTAssertEqual(viewModel.state, dealtState, "Redeal replays the same deal") + XCTAssertEqual( + viewModel.score, + GolfGameRules.dealTableauCardCount, + "Redeal resets the stroke score to the board count" + ) + } +} diff --git a/ComputerSolitaireTests/Shared/ScoringTests.swift b/ComputerSolitaireTests/Shared/ScoringTests.swift index f7d0497..981801a 100644 --- a/ComputerSolitaireTests/Shared/ScoringTests.swift +++ b/ComputerSolitaireTests/Shared/ScoringTests.swift @@ -12,10 +12,25 @@ final class ScoringTests: XCTestCase { XCTAssertEqual(Scoring.delta(for: .recycleWasteInDrawOne), -100) } + func testGolfScoringDeltaValuesMatchRules() { + XCTAssertEqual(Scoring.delta(for: .golfBoardPlay), -1) + XCTAssertEqual(Scoring.delta(for: .golfBoardClear(remainingStockCount: 5)), -5) + XCTAssertEqual(Scoring.delta(for: .golfBoardClear(remainingStockCount: 0)), 0) + XCTAssertEqual(Scoring.delta(for: .golfBoardClear(remainingStockCount: -3)), 0) + } + func testApplyingScoreClampsAtMinimumZero() { - XCTAssertEqual(Scoring.applying(.foundationToTableau, to: 10), 0) - XCTAssertEqual(Scoring.applying(.recycleWasteInDrawOne, to: 99), 0) - XCTAssertEqual(Scoring.applying(.wasteToFoundation, to: 0), 10) + XCTAssertEqual(Scoring.applying(.foundationToTableau, to: 10, variant: .klondike), 0) + XCTAssertEqual(Scoring.applying(.recycleWasteInDrawOne, to: 99, variant: .klondike), 0) + XCTAssertEqual(Scoring.applying(.wasteToFoundation, to: 0, variant: .klondike), 10) + } + + func testClampIsVariantAware() { + // Golf's stroke score legitimately goes negative on a cleared board; + // every other variant floors at zero. + XCTAssertEqual(Scoring.clamped(-3, for: .golf), -3) + XCTAssertEqual(Scoring.clamped(-3, for: .klondike), 0) + XCTAssertEqual(Scoring.applying(.golfBoardClear(remainingStockCount: 4), to: 0, variant: .golf), -4) } func testTimeBonusUsesConfiguredLossRate() { diff --git a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift index 3d1e0f2..e6a0813 100644 --- a/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift +++ b/ComputerSolitaireTests/Shared/ScreenshotFixtureTests.swift @@ -355,6 +355,58 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { print("TriPeaks fixture — seed \(seed), photogenic \(bestScore)") } + /// The staged Golf board is a fresh deal — seven face-up columns, one + /// waste starter. All 35 cards are visible, but the eye lands on the + /// exposed column ends, so seeds are scanned for the most photogenic + /// spread there with a first play available off the waste top. + func testGenerateGolfFixture() throws { + try skipUnlessGenerating() + + var bestSeed: UInt64? + var bestScore = Int.min + for seed in Self.candidateSeeds { + let deal = GameStateFixtures.seededGolfDeal(seed: seed) + let score = golfDealScore(of: deal) + if score > bestScore { + bestScore = score + bestSeed = seed + } + } + let seed = try XCTUnwrap(bestSeed) + + let viewModel = SolitaireViewModel() + viewModel.state = GameStateFixtures.seededGolfDeal(seed: seed) + viewModel.configureGolfNewGame() + + let savedAt = DateFixtures.reference + let payload = SavedGamePayload( + savedAt: savedAt, + state: viewModel.state, + movesCount: viewModel.movesCount, + score: viewModel.score, + gameStartedAt: savedAt.addingTimeInterval(-Self.stagedElapsedSeconds), + stockDrawCount: DrawMode.one.rawValue, + history: [], + hasStartedTrackedGame: false, + golfMatch: GolfMatchState() + ) + + 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, .golf, "Fixture did not restore as Golf") + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(payload) + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("golf.json") + try data.write(to: outputURL) + + print("SCREENSHOT-FIXTURE-OUTPUT: \(outputURL.path)") + print("Golf fixture — seed \(seed), photogenic \(bestScore)") + } + // MARK: - Photogenic scoring private struct Candidate { @@ -438,6 +490,28 @@ final class ScreenshotFixtureGeneratorTests: XCTestCase { return score } + /// Scores a fresh Golf deal by the eight cards the eye lands on (the seven + /// exposed column ends plus the waste starter): rank variety, red/black + /// balance, all four suits, a few face cards, and several playable exposed + /// cards to suggest an opening run. + private func golfDealScore(of deal: GameState) -> Int { + let exposed = deal.tableau.compactMap { $0.last } + let visible = exposed + Array(deal.waste.suffix(1)) + var score = 0 + score += Set(visible.map(\.rank)).count * 6 + let redCount = visible.count(where: { $0.suit.isRed }) + score -= abs(redCount * 2 - visible.count) * 4 + score += Set(visible.map(\.suit)).count == Suit.allCases.count ? 8 : 0 + score += visible.count(where: { $0.rank >= .jack }) >= 3 ? 6 : 0 + if let wasteTop = deal.waste.last { + let playableCount = exposed.count { card in + GolfGameRules.canPlayRank(card.rank.rawValue, ontoWasteTop: wasteTop.rank.rawValue) + } + score += min(playableCount, 3) * 4 + } + return score + } + /// Scores a fresh Spider deal by its ten face-up tops: rank variety, /// red/black balance, both composed suits, a few face cards, and an ace /// on a top read well. diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index c96f627..8dce88d 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -229,6 +229,87 @@ enum GameStateFixtures { ) } + /// A reproducible Golf deal matching the shape of `GameState.newGolfGame`. + /// Mirrored by the hint probe's `seededGolfDeal` so seeds are comparable. + static func seededGolfDeal(seed: UInt64) -> GameState { + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = [] + for _ in 0.. GameState { + var tableau = columns.map { column in + column.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + } + if tableau.count < GolfGameRules.columnCount { + tableau.append( + contentsOf: [[Card]](repeating: [], count: GolfGameRules.columnCount - tableau.count) + ) + } + let faceDownStock = stock.map { card in + var faceDown = card + faceDown.isFaceUp = false + return faceDown + } + var fullWaste = waste.map { card in + var faceUp = card + faceUp.isFaceUp = true + return faceUp + } + if fillWasteFromRemainder { + func identity(_ card: Card) -> Int { + (Suit.allCases.firstIndex(of: card.suit) ?? 0) * 16 + card.rank.rawValue + } + let usedIdentities = Set((tableau.flatMap { $0 } + faceDownStock + fullWaste).map(identity)) + fullWaste = TestCards.fullDeck(faceUp: true).filter { card in + !usedIdentities.contains(identity(card)) + } + fullWaste + } + return GameState( + variant: .golf, + stock: faceDownStock, + waste: fullWaste, + wasteDrawCount: min(1, fullWaste.count), + freeCells: Array(repeating: nil, count: 4), + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } + static func pyramidState( slots: [Card?], stock: [Card] = [], diff --git a/README.md b/README.md index 6203b00..21d1386 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**, and **Yukon** +- Multiple game variants: **Klondike** (both 1-card and 3-card draw), **Spider** (1, 2, or 4 suits), **FreeCell**, **TriPeaks**, **Pyramid**, **Golf**, and **Yukon** - Automatic game persistence and resume - Customizable table appearance - Other things you enjoy @@ -22,9 +22,10 @@ Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. | Variant | Description | Rules | |---------|-------------|-------| -| **Klondike** | Classic Solitaire, with 1-card and 3-card draw modes | [Rules](docs/solitaire-rules-klondike.md) | -| **Spider** | Two decks, ten piles — build full suit runs from King to Ace, with 1/2/4-suit difficulty | [Rules](docs/solitaire-rules-spider.md) | -| **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/solitaire-rules-freecell.md) | -| **TriPeaks** | Chain uncovered cards one rank up or down to level three peaks | [Rules](docs/solitaire-rules-tripeaks.md) | -| **Pyramid** | Pair exposed cards totaling 13 to dismantle a 28-card pyramid | [Rules](docs/solitaire-rules-pyramid.md) | -| **Yukon** | Klondike's wilder sibling — no stock, and any face-up card moves with everything stacked on it | [Rules](docs/solitaire-rules-yukon.md) | +| **Klondike** | Classic Solitaire, with 1-card and 3-card draw modes | [Rules](docs/rules/klondike.md) | +| **Spider** | Two decks, ten piles — build full suit runs from King to Ace, with 1/2/4-suit difficulty | [Rules](docs/rules/spider.md) | +| **FreeCell** | Strategy-focused variant where every card is visible from the start | [Rules](docs/rules/freecell.md) | +| **TriPeaks** | Chain uncovered cards one rank up or down to level three peaks | [Rules](docs/rules/tripeaks.md) | +| **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) | diff --git a/docs/solitaire-rules-freecell.md b/docs/rules/freecell.md similarity index 72% rename from docs/solitaire-rules-freecell.md rename to docs/rules/freecell.md index 9c0bed3..53cfb6f 100644 --- a/docs/solitaire-rules-freecell.md +++ b/docs/rules/freecell.md @@ -1,6 +1,6 @@ # FreeCell Rules -These rules describe standard FreeCell setup and play. Unlike Klondike, all cards are dealt face up at the start, there is no stock or waste, and nearly every deal is winnable with correct play. +These rules describe FreeCell as implemented in the app. Unlike Klondike, all cards are dealt face up at the start, there is no stock or waste, and nearly every deal is winnable with correct play. ## Objective Move all 52 cards to the four foundations, building each suit from Ace to King. @@ -11,39 +11,35 @@ Move all 52 cards to the four foundations, building each suit from Ace to King. - **Foundations:** Four suit piles built from Ace to King. - **Supermove:** Moving an ordered run of cards at once, as a shortcut for a series of single-card moves through free cells and empty cascades. -## Layout -- Eight cascades (tableau columns), all cards face up. -- Four free cells (temporary one-card storage slots). -- Four foundations (built by suit). -- There is no stock or waste — every card is visible and in play from the start. - ## Setup - Use a standard 52-card deck (no jokers). -- Deal all 52 cards face up across the eight cascades, left to right. -- The first four cascades receive **7 cards** each; the last four receive **6 cards** each. +- Deal all 52 cards face up across the eight cascades, left to right: the first four cascades receive **7 cards** each, the last four **6 cards** each. - The four free cells and four foundations start empty. +- There is no stock or waste — every card is visible and in play from the start. -## Cascade Play +## Play + +### Cascades - Build cascades **down in rank** while **alternating colors** (e.g., red 6 on black 7). -- You may move the **bottom (exposed) card** of a cascade, or a properly ordered run of cards ending with it (see Multi-Card Moves below). +- You may move the **bottom (exposed) card** of a cascade, or a properly ordered run of cards ending with it (see Supermoves below). - An exposed card may move to: - another cascade, onto a card one rank higher of the opposite color; - an empty free cell; - its foundation, if it is the next card in that suit's sequence. - **Any card** may be placed on an empty cascade — unlike Klondike, empty spaces are not restricted to Kings. -## Free Cells +### Free cells - Each free cell holds **exactly one card**. - Any exposed card may be moved to an empty free cell at any time. - A card in a free cell may return to a cascade (following the normal build rule), move to an empty cascade, or move to its foundation. - Free cells are the game's main maneuvering space; keeping them open preserves mobility. -## Foundations +### Foundations - Foundations are built **by suit** from **Ace to King**. - Aces start each foundation pile. - Cards may move to foundations from cascades or free cells. -## Multi-Card Moves (Supermoves) +### Supermoves Formally, FreeCell only allows moving one card at a time. Moving a run of cards is a shortcut for a series of single-card moves through free cells and empty cascades, so the length of a movable run is limited by the available space: - Maximum run length = `(empty free cells + 1) × 2^(empty cascades)` @@ -52,9 +48,20 @@ Formally, FreeCell only allows moving one card at a time. Moving a run of cards - The run itself must already be properly ordered (descending rank, alternating colors), and its destination must follow the normal build rule. - **When moving a run onto an empty cascade**, that destination cascade does not count as an available empty cascade, since it cannot be used as an intermediate stop for its own move. +## Scoring +- Moves score no points — FreeCell tracks time and completion. +- 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 when all 52 cards are moved to the foundations in ascending order by suit. Because all cards are visible from the deal, FreeCell is a game of near-complete information — of the original 32,000 Microsoft deals, only one (#11982) is unwinnable. +## Rule choices +FreeCell's rules are essentially settled; the decisions this implementation makes: +- **Any card on an empty cascade** (standard FreeCell; some variants restrict empty columns). +- **The strict supermove cap**, including the rule that a run's empty destination cascade doesn't count as maneuvering space for its own move. +- **Time-and-completion scoring** — classic FreeCell awards no per-move points, so the win time bonus is the whole score. + ## Sources - https://en.wikipedia.org/wiki/FreeCell - https://www.247freecell.com/news/mastering-the-freecell-rules-a-beginners-guide/ diff --git a/docs/rules/golf.md b/docs/rules/golf.md new file mode 100644 index 0000000..4dd238d --- /dev/null +++ b/docs/rules/golf.md @@ -0,0 +1,51 @@ +# Golf Rules + +These rules describe Golf as implemented in the app: strict classic Golf — exposed column cards one rank above or below the waste top are played onto it, with no wraparound, a draw-one stock, and a single pass — scored like its namesake across a nine-hole match. The published sources offer several relaxations; the choices made here (and why) are called out below. + +## Objective +Clear all 35 column cards by playing them onto the waste, one rank up or down at a time, in as few strokes as possible. The stock and waste do **not** need to be emptied. A hole's score is the cards left on the board — or better, below zero for a cleared board — and nine holes make a match. Lower is better throughout. + +## Terminology +- **Columns:** Seven face-up piles of five cards; only each column's exposed (last-dealt) card may play. +- **Stock:** The face-down draw pile (16 cards after the deal). One pass only — there are no recycles. +- **Waste:** The face-up pile every played and drawn card lands on; its top card is the match target. +- **Hole:** One deal. Nine holes make a match. +- **Par:** 45 strokes for a nine-hole match — five per hole, matching a hole where a straightforward game clears most of the board. + +## Card Values +Rank order runs Ace, 2 … 10, Jack, Queen, King, and does **not** wrap: an Ace connects only to a 2, and a King only to a Queen. Nothing may be played on a King — once a King tops the waste, only a stock flip revives the board. Suits never matter. + +## Setup +- Use a standard 52-card deck (no jokers). +- **Columns:** Deal 35 cards face up into seven columns of five. +- **Waste:** Flip one card face up to start the waste. +- **Stock:** The remaining 16 cards, face down. + +## Play +- Play any **exposed** column card that is one rank above or below the top waste card, regardless of suit. It becomes the new match target. +- Ranks never wrap, and nothing plays on a King. +- Tap the stock to flip **one** card onto the waste. The stock allows a **single pass** — there are no recycles. +- Cards never leave the waste, and there is no building. +- The hole ends when the board is cleared, or when the stock is spent and nothing plays. + +## Scoring +- Your hole score is the number of cards still on the board — it starts at 35 and each play removes a stroke. +- Flipping a stock card costs nothing; its price is the plays it didn't make. +- Clearing the board subtracts one point per stock card left, so scores below zero are the best results. +- There is no time bonus — strokes are the whole score. +- A match is nine consecutive holes; the totals sum, and par for the match is 45. +- **New Game** abandons the match and starts a fresh one at hole 1; **Redeal** replays the current hole. Switching games keeps the match — every game's session is stashed, and Golf resumes where it left off. + +## Winning +You win a hole the moment the last column card is played, regardless of the stock and waste. Most holes are not winnable under strict rules — that is the classic game — so the match score is the real contest: clear what the deal allows and beat par across nine holes. + +## Rule choices +The linked sources offer relaxations; this implementation uses: +- **Strict adjacency** — no Ace↔King wraparound and nothing plays on a King (Wikipedia's standard rules). The relaxed "Turn the Corner" variant raises winnability dramatically but is a different, easier game. +- **Single pass** through the stock with no recycles (universal across sources — the pass limit is the game). +- **Traditional golf scoring** — one stroke per card left, minus one per banked stock card on a clear, totaled across a nine-hole match with par at 45 (Wikipedia's scoring). Lower is better, unlike every other variant in the app, and this is the app's only multi-deal structure. + +## Sources +- https://en.wikipedia.org/wiki/Golf_(patience) +- https://cardgames.io/golfsolitaire/ +- https://solitaired.com/golf diff --git a/docs/solitaire-rules-klondike.md b/docs/rules/klondike.md similarity index 53% rename from docs/solitaire-rules-klondike.md rename to docs/rules/klondike.md index 48fe18e..f49d31b 100644 --- a/docs/solitaire-rules-klondike.md +++ b/docs/rules/klondike.md @@ -1,6 +1,6 @@ -# Klondike Solitaire Rules (1-Card and 3-Card Draw) +# Klondike Rules -These rules describe standard Klondike setup and play, with **1-card** and **3-card** draw options for the stock. Both variants use **unlimited passes** through the stock. +These rules describe Klondike as implemented in the app: standard setup and play, with **1-card** and **3-card** draw modes for the stock and **unlimited passes** through it in both modes. ## Objective Move all 52 cards to the four foundations, building each suit from Ace to King. @@ -18,33 +18,42 @@ Move all 52 cards to the four foundations, building each suit from Ace to King. - **Stock:** Remaining cards are placed face down. - **Waste:** Empty pile beside the stock. -## Tableau Play +## Play + +### Tableau - Build tableau piles **down in rank** while **alternating colors** (e.g., red 6 on black 7). - You may move a face-up card or a properly ordered sequence as a unit. - When a face-up card is moved, flip the next card in that pile face up. - Empty tableau spaces may be filled **only by a King** (or a sequence starting with a King). -## Foundations +### Foundations - Foundations are built **by suit** from **Ace to King**. - Aces start each foundation pile. -## Stock and Waste (Draw Variants) - -### 1-Card Draw -- Turn **one card at a time** from the stock to the waste. +### Stock and waste +- Tap the stock to turn **one card** (1-card draw) or **three cards** (3-card draw) onto the waste. - Only the **top waste card** is playable. -- If no moves are available, continue drawing one card at a time. -- When the stock is exhausted, **turn the waste pile face down** to form a new stock and continue. **Unlimited passes** through the stock are allowed. - -### 3-Card Draw -- Turn **three cards at a time** from the stock to the waste. -- Only the **top waste card** is playable. -- If no moves are available, continue drawing three cards at a time. -- When the stock is exhausted, **turn the waste pile face down** to form a new stock and continue. **Unlimited passes** through the stock are allowed. +- When the stock is exhausted, the waste turns face down to form a new stock. **Unlimited passes** are allowed in both draw modes. + +## Scoring +- Waste to tableau: +5. +- Waste to foundation: +10. +- Tableau to foundation: +10. +- Turning a tableau card face up: +5. +- Foundation back to tableau: −15. +- Recycling the waste in 1-card draw: −100. +- On a win, a time bonus is added: it starts at 600 in 1-card draw and 900 in 3-card draw, and drops by one point per second of play. +- The score never drops below zero. ## Winning You win when all cards are moved to the foundations in ascending order by suit. +## Rule choices +Published Klondike rules differ mainly in how the stock is handled; this implementation uses: +- **Unlimited stock passes in both draw modes** — several published rule sets cap 3-card draw at three passes. +- **The classic −100 recycle penalty in 1-card draw**, where unlimited free passes would otherwise remove the mode's tension; 3-card recycles are free. +- **Kings only on empty piles** (the standard rule; some variants allow any card). + ## Sources - https://en.wikipedia.org/wiki/Klondike_(solitaire)#Rules - https://officialgamerules.org/game-rules/klondike/ diff --git a/docs/solitaire-rules-pyramid.md b/docs/rules/pyramid.md similarity index 71% rename from docs/solitaire-rules-pyramid.md rename to docs/rules/pyramid.md index 0354a29..73f4186 100644 --- a/docs/solitaire-rules-pyramid.md +++ b/docs/rules/pyramid.md @@ -44,20 +44,6 @@ The linked sources disagree; this implementation uses: - **Three passes** through the stock (solitaired.com; Wikipedia's "Par Pyramid"), not one pass (strict) or unlimited (cardgames.io). - **Cover pairs allowed** (cardgames.io and most digital implementations). -## Solver-backed hints -Pyramid is a perfect-information game once dealt, so `PyramidPlanner` searches the exact position graph: stage one runs weighted A* for a full winning line, with a partner-count prune that can prove a deal unwinnable; stage two finds the line clearing the most pyramid cards when no win exists, and hints follow it — unlike the other variants, lost Pyramid deals are common and still played for cards cleared. Hints go silent only when not one more pyramid card is clearable. - -### Measured baselines -The canonical hint-quality figures live in the hint-probe ledger -(`tools/hint-probe/README.md`): over 500 seeded deals, following every hint -wins **80.2%** against a **15.2%** random-control floor, with zero loops and a -median winning game of 67 moves. The solver's own verdict sweep at its default -budget proves **79.5%** of deals winnable and **0.8%** unwinnable, with 19.8% -undecided at budget (hard deals whose reachable graphs exceed it — they still -get best-effort hints); interactive searches resolve in well under a -millisecond on the median deal. Validate planner changes against the ledger -before shipping. - ## Sources - https://en.wikipedia.org/wiki/Pyramid_(solitaire) - https://cardgames.io/pyramidsolitaire/ diff --git a/docs/solitaire-rules-spider.md b/docs/rules/spider.md similarity index 67% rename from docs/solitaire-rules-spider.md rename to docs/rules/spider.md index 840208b..bd53578 100644 --- a/docs/solitaire-rules-spider.md +++ b/docs/rules/spider.md @@ -1,6 +1,6 @@ # Spider Rules -These rules describe standard Spider setup and play. Spider is played with **two decks (104 cards)** across **ten tableau piles**. Cards build down regardless of suit, but only same-suit runs move together, and the goal is to assemble — and automatically bank — **eight full King-to-Ace runs**, one suit each. +These rules describe Spider as implemented in the app: **two decks (104 cards)** across **ten tableau piles**, cards building down regardless of suit while only same-suit runs move together, and the goal of assembling — and automatically banking — **eight full King-to-Ace runs**, one suit each. ## Objective Complete eight King-to-Ace runs of a single suit. Each completed run is removed from the tableau automatically; the game is won when all eight are done. @@ -12,39 +12,44 @@ Complete eight King-to-Ace runs of a single suit. Each completed run is removed - **Stock:** The face-down pile that deals one card onto every tableau pile at once. - **Suits (difficulty):** The two decks are composed of 1, 2, or 4 distinct suits — always 104 cards. -## Layout -- Ten tableau piles. -- A stock of 50 face-down cards (five deals of ten). -- Eight spaces where completed runs are banked. - ## Setup - Use two standard 52-card decks (no jokers). Difficulty changes the composition: **1 suit** (eight sets of Spades), **2 suits** (four sets each of Spades and Hearts), or **4 suits** (two full decks). - **Tableau:** Deal 54 cards left to right: the first four piles get **6 cards**, the remaining six piles get **5 cards**, only the top card of each pile face up. -- **Stock:** The remaining 50 cards, face down. +- **Stock:** The remaining 50 cards, face down (five deals of ten). +- **Completed runs:** Eight spaces bank finished runs as they leave the tableau. + +## Play -## Tableau Play +### Tableau - Build tableau piles **down in rank**, **regardless of suit** (e.g., a red 6 may land on any 7). - Nothing may be placed on an Ace. - Several cards move together only as a **face-up, same-suit descending run**. Mixed-suit or gapped stacks move one card at a time. - Any card or movable run may fill an **empty pile**. - When the last face-up card leaves a pile, flip the newly exposed face-down card face up. -## The Stock +### The stock - Tapping the stock deals **one face-up card onto every pile** (ten cards per deal; five deals in a game). - Dealing is **not allowed while any pile is empty** — fill every space first. -## Completed Runs +### 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. -## Winning -You win when all eight runs are completed. With good play, roughly one in three 4-suit games is winnable; 2-suit games considerably more, and 1-suit games almost always. - -## Scoring (classic) +## Scoring - Start at **500**. - **−1** for every move, including each stock deal. - **+100** for each completed run. -- The score never drops below 0. On a win, the app adds its standard time bonus. +- 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 when all eight runs are completed. With good play, roughly one in three 4-suit games is winnable; 2-suit games considerably more, and 1-suit games almost always. + +## Rule choices +Published Spider rules differ in scoring and stock handling; this implementation uses: +- **Classic Spider scoring** — the 500-point start with −1 per move and +100 per run, rather than per-card schemes some sites use. +- **Deals blocked over empty piles** (the standard rule; some implementations allow dealing across gaps). +- **Suit-count difficulty** as deck composition — 1, 2, or 4 suits always totaling 104 cards, each mode keeping its own statistics. ## Sources - https://en.wikipedia.org/wiki/Spider_(solitaire) diff --git a/docs/solitaire-rules-tripeaks.md b/docs/rules/tripeaks.md similarity index 71% rename from docs/solitaire-rules-tripeaks.md rename to docs/rules/tripeaks.md index 35b6639..9c928c0 100644 --- a/docs/solitaire-rules-tripeaks.md +++ b/docs/rules/tripeaks.md @@ -44,20 +44,6 @@ The linked sources disagree; this implementation uses: - **Single pass** through the stock with no recycles (universal across sources — the pass limit is the game). - **Chain scoring with peak bonuses** (the conventional scheme from the original game: escalating discards, −5 flips, 15/15/30 peak bonuses), not plain per-card scoring. -## Solver-backed hints -TriPeaks is a perfect-information game once dealt, so `TriPeaksPlanner` searches the exact position graph: a plays-first depth-first pass over a collision-free 37-bit packed position that returns a winning line, or — because nothing is pruned — an exhausted pass that both proves the deal unwinnable and yields the exact max-clear line, which hints then follow. Timing the stock flip is most of the strategy, so "flip the stock" is itself a hint verdict: it appears only when every available play was searched and lost. Hints go silent only when not one more peak card is clearable. - -### Measured baselines -The canonical hint-quality figures live in the hint-probe ledger -(`tools/hint-probe/README.md`): over 500 seeded deals, following every hint -wins **95.4%** against a **0.0%** random-control floor, with zero loops and a -median winning game of 49 moves. The solver's own verdict sweep at its default -budget proves **95.6%** of deals winnable and **0.2%** unwinnable, with 4.1% -undecided at budget (hard deals whose reachable graphs exceed it — they still -get best-effort hints); interactive searches resolve in well under a -millisecond on the median deal. Validate planner changes against the ledger -before shipping. - ## Sources - https://en.wikipedia.org/wiki/Tri_Peaks_(game) - https://cardgames.io/tripeakssolitaire/ diff --git a/docs/solitaire-rules-yukon.md b/docs/rules/yukon.md similarity index 66% rename from docs/solitaire-rules-yukon.md rename to docs/rules/yukon.md index ab887e5..5539623 100644 --- a/docs/solitaire-rules-yukon.md +++ b/docs/rules/yukon.md @@ -1,6 +1,6 @@ # Yukon Rules -These rules describe standard Yukon setup and play. Yukon resembles Klondike — same tableau building and foundation rules, with face-down cards to uncover — but all 52 cards are dealt at the start (there is no stock or waste), and any face-up card may be moved together with every card stacked on top of it, **even if those cards are not in sequence**. +These rules describe Yukon as implemented in the app. Yukon resembles Klondike — same tableau building and foundation rules, with face-down cards to uncover — but all 52 cards are dealt at the start (there is no stock or waste), and any face-up card may be moved together with every card stacked on top of it, **even if those cards are not in sequence**. ## Objective Move all 52 cards to the four foundations, building each suit from Ace to King. @@ -10,39 +10,49 @@ Move all 52 cards to the four foundations, building each suit from Ace to King. - **Foundations:** Four suit piles built from Ace to King. - **Group move:** Moving a face-up card together with every card above it as one unit, regardless of order. -## Layout -- Seven tableau piles of uneven depth, each (except the first) with face-down cards beneath a fan of five face-up cards. -- Four foundations (built by suit). -- There is no stock or waste — every card is dealt to the tableau from the start. - ## Setup - Use a standard 52-card deck (no jokers). - **Tableau:** Deal seven piles left to right. The first pile has 1 card face up. Each following pile has one more face-down card than the last — 1 through 6 — with **5 cards face up** on top of them, so the piles hold 1, 6, 7, 8, 9, 10, and 11 cards. - **Foundations:** Four empty piles, one per suit. -- All 52 cards are dealt; 21 are face down and 31 face up. +- All 52 cards are dealt (21 face down, 31 face up); there is no stock or waste. + +## Play -## Tableau Play +### Tableau - Build tableau piles **down in rank** while **alternating colors** (e.g., red 6 on black 7). - **Any face-up card may be moved**, carrying every card stacked on top of it along as a group — the group does **not** need to be in any order. - Only the **bottom card of the moving group** must fit the destination: one rank lower and the opposite color of the destination pile's top card. - When the last face-up card leaves a pile, flip the newly exposed face-down card face up. - Empty tableau spaces may be filled **only by a King** (alone or carrying a group). -## Foundations +### Foundations - Foundations are built **by suit** from **Ace to King**. - Aces start each foundation pile. - Only one card at a time moves to a foundation — the top card of a tableau pile. -## Group Moves +### Group moves Group moves are what set Yukon apart: - In Klondike, a multi-card move must be a properly ordered sequence. In Yukon, the cards riding on top of the moved card can be in **any order** — they simply come along. - This means buried cards can be dug out by relocating whole messy stacks, at the cost of tangling the destination pile. - Every card that lands out of sequence must eventually be moved again before the cards beneath it can reach the foundations, so group moves trade immediate access for future untangling work. +## Scoring +- Tableau to foundation: +10. +- Turning a tableau card face up: +5. +- Foundation back to tableau: −15. +- 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 when all 52 cards are moved to the foundations in ascending order by suit. With no stock to cycle and most cards visible or discoverable through play, skilled play wins considerably more often than in Klondike — roughly 80% of deals are estimated to be winnable with best play. +## Rule choices +Yukon's play rules are essentially settled; the decisions this implementation makes: +- **Kings only on empty piles** (the standard Yukon rule; some variants allow any card or group). +- **Foundation rollbacks allowed** at −15, matching Klondike — pulling a banked card back down is sometimes the only way to untangle a pile. +- **Klondike-style scoring minus the waste rows** — Yukon has no stock or waste, so only the foundation, reveal, and rollback values apply. + ## Sources - https://en.wikipedia.org/wiki/Yukon_(solitaire) - https://cardgames.io/yukonsolitaire/ diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md index 945b758..15b1396 100644 --- a/tools/hint-probe/README.md +++ b/tools/hint-probe/README.md @@ -26,6 +26,7 @@ tools/hint-probe/run.sh spider 500 # all three suit counts tools/hint-probe/run.sh spider 500 4 # third arg narrows to one suit count tools/hint-probe/run.sh pyramid 500 tools/hint-probe/run.sh tripeaks 500 +tools/hint-probe/run.sh golf 500 ``` The number is how many seeded deals the run plays (seeds 1 through N; default @@ -67,6 +68,7 @@ consecutive runs, serial and parallel. | `spider` 4-suit | **2.8%** | 0.0% | | `pyramid` | **80.2%** | 15.2% | | `tripeaks` | **95.4%** | 0.0% | +| `golf` | **22.6%** | 0.0% | Reading the table honestly: @@ -120,6 +122,21 @@ Reading the table honestly: median 27 of 28 cleared on lost deals — best-effort lines leave almost nothing behind), and the over-banking detector does not apply. The game is structurally bounded at 51 actions, so the hint value is the win-rate gap. +- **Golf (22.6% vs 0.0%)**: the low absolute rate is the variant, not the + planner. Strict Golf (no wraparound, nothing plays on a King, single pass) + leaves most deals unwinnable: the solver's own verdict sweep proves 26.1% + of deals winnable at its default budget (66.3% proved unwinnable, 7.6% + undecided over 10,000 deals), so the follower converts most of what the + search can prove while still following max-clear lines on the lost majority + (median 33 of 35 cleared on lost deals — best-effort lines leave almost + nothing behind). The random control winning zero and clearing a median of + 11 says Golf wins are never stumbled into; the entire hint column is solver + skill, and the cleared-at-loss gap (33 vs 11) is the per-deal quality + signal on the lost majority. Losses record column cards cleared (Golf banks + no foundations), and the over-banking detector does not apply. The game is + 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. - These figures use the planners' full node budgets. The app additionally clips each interactive search at a fraction of a second so the UI never hitches; that clip rarely binds, so in-app quality is at most a hair below diff --git a/tools/hint-probe/main.swift b/tools/hint-probe/main.swift index 29ca89c..54fb233 100644 --- a/tools/hint-probe/main.swift +++ b/tools/hint-probe/main.swift @@ -165,6 +165,30 @@ func seededDeal(variant: GameVariant, seed: UInt64, spiderSuitCount: SpiderSuitC tableau: [], triPeaks: triPeaks ) + + case .golf: + // Mirrors GameState.newGolfGame (and GameStateFixtures.seededGolfDeal). + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = [] + for _ in 0.. Int { state.triPeaks.count(where: { $0 == nil }) } +/// Mirrors handleGolfStockTap in the session: draw one, no recycles ever. +/// The planner's apply is the same pure logic. +func golfStockTap(_ state: GameState) -> GameState? { + GolfPlanner.apply(.draw, to: state) +} + +func golfCleared(_ state: GameState) -> Int { + GolfGameRules.dealTableauCardCount - state.tableau.reduce(0) { $0 + $1.count } +} + /// Mirrors drawFromStock / recycleWaste in the session. func stockTap(_ state: GameState, drawCount: Int) -> GameState? { var next = state @@ -287,15 +321,15 @@ enum Outcome { /// Yukon/FreeCell games finish or die well under this; Klondike needs headroom /// for stock cycling, and Spider for grooming 104 cards across five deals. /// (Pyramid is structurally bounded near 100 actions: three 24-card passes, -/// two resets, and at most 26 removal moves. TriPeaks is bounded at 51: every -/// action consumes a peak card or a stock card.) +/// two resets, and at most 26 removal moves. TriPeaks is bounded at 51 and +/// Golf at 51: every action consumes a board card or a stock card.) func actionCap(for variant: GameVariant) -> Int { switch variant { case .klondike: return 1_200 case .spider: return 1_000 - case .freecell, .yukon, .pyramid, .tripeaks: + case .freecell, .yukon, .pyramid, .tripeaks, .golf: return 600 } } @@ -601,6 +635,56 @@ func playTriPeaksFollowingHints(seed: UInt64) -> Outcome { return .actionCap(foundation: triPeaksCleared(state)) } +func playGolfFollowingHints(seed: UInt64) -> Outcome { + // Replicates HintPlanner's Golf path without its wall-clock deadline: + // follow each planned line — winning or max-clear — to its end, then replan; + // noProgress means not one more column card is clearable. Every Golf move + // consumes a card, so for this deterministic follower any revisit is a proven + // infinite loop. The loss column records column cards cleared (Golf banks + // no foundations). + var state = seededDeal(variant: .golf, seed: seed) + var plan: [String: GolfPlanner.Move] = [:] + var seen: Set = [fingerprint(state)] + var actions = 0 + while actions < actionCap(for: .golf) { + if state.isWon { return .win(moves: actions) } + + let key = GolfPlanner.stateKey(for: state) + var hint = plan[key].flatMap { GolfPlanner.materialize($0, in: state) } + if hint == nil { + plan.removeAll() + switch GolfPlanner.bestLine(in: state) { + case .winningLine(let line), .bestEffortLine(let line, _): + plan = GolfPlanner.keyedMoves(along: line, from: state) + case .noProgress: + return .deadlock(foundation: golfCleared(state)) + } + hint = plan[key].flatMap { GolfPlanner.materialize($0, in: state) } + } + guard let hint else { + return .deadlock(foundation: golfCleared(state)) + } + + switch hint { + case .move(let move): + guard let next = apply(move.selection, move.destination, to: state, stockDrawCount: 1) else { + fatalError("Seed \(seed): illegal Golf hint") + } + state = next + case .stockTap: + guard let next = golfStockTap(state) else { + fatalError("Seed \(seed): Golf stock tap with nothing to tap") + } + state = next + } + actions += 1 + if !seen.insert(fingerprint(state)).inserted { + return .stalemateLoop(foundation: golfCleared(state)) + } + } + return .actionCap(foundation: golfCleared(state)) +} + // MARK: - Control player // The random-moves floor calibrates each variant's deal universe. Deliberately @@ -627,6 +711,8 @@ func playRandom( lossProgress = pyramidCleared case .tripeaks: lossProgress = triPeaksCleared + case .golf: + lossProgress = golfCleared case .klondike, .freecell, .yukon, .spider: lossProgress = foundationCount } @@ -649,7 +735,7 @@ func playRandom( canTapStock = SpiderGameRules.canDealFromStock(state: state) case .pyramid: canTapStock = !state.stock.isEmpty || PyramidGameRules.canRecycleWaste(in: state) - case .tripeaks: + case .tripeaks, .golf: canTapStock = !state.stock.isEmpty case .freecell, .yukon: canTapStock = false @@ -667,6 +753,8 @@ func playRandom( tapped = pyramidStockTap(state) case .tripeaks: tapped = triPeaksStockTap(state) + case .golf: + tapped = golfStockTap(state) case .klondike, .freecell, .yukon: tapped = stockTap(state, drawCount: drawCount) } @@ -794,19 +882,23 @@ func run( label = "pyramid" case .tripeaks: label = "tripeaks" + case .golf: + label = "golf" } - // Pyramid and TriPeaks bank no foundations; their loss columns record - // board cards cleared. + // Pyramid, TriPeaks, and Golf bank no foundations; their loss columns + // record board cards cleared. let lossProgressLabel: String switch variant { case .pyramid: lossProgressLabel = "pyramid-cleared-at-loss" case .tripeaks: lossProgressLabel = "tripeaks-cleared-at-loss" + case .golf: + lossProgressLabel = "golf-cleared-at-loss" case .klondike, .freecell, .yukon, .spider: lossProgressLabel = "foundation-at-loss" } - let tracksOverBanking = variant != .pyramid && variant != .tripeaks + let tracksOverBanking = variant != .pyramid && variant != .tripeaks && variant != .golf let start = DispatchTime.now() let followerResults = mapInParallel( @@ -826,6 +918,8 @@ func run( return (playPyramidFollowingHints(seed: seed), 0) case .tripeaks: return (playTriPeaksFollowingHints(seed: seed), 0) + case .golf: + return (playGolfFollowingHints(seed: seed), 0) } } let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 @@ -889,7 +983,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) @@ -927,6 +1021,8 @@ case "pyramid": run(variant: .pyramid, seeds: seeds, drawCount: 1) case "tripeaks": run(variant: .tripeaks, seeds: seeds, drawCount: 1) +case "golf": + run(variant: .golf, seeds: seeds, drawCount: 1) case "all": run(variant: .yukon, seeds: seeds, drawCount: 3) run(variant: .klondike, seeds: seeds, drawCount: 1) @@ -937,6 +1033,7 @@ case "all": } run(variant: .pyramid, seeds: seeds, drawCount: 1) run(variant: .tripeaks, seeds: seeds, drawCount: 1) + run(variant: .golf, seeds: seeds, drawCount: 1) default: exitWithUsage() } diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh index decd2db..b77bd6c 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")/../.." @@ -41,6 +41,10 @@ SOURCES=( ComputerSolitaire/Game/TriPeaks/GameRulesTriPeaks.swift ComputerSolitaire/Game/TriPeaks/AutoMoveAdvisorTriPeaks.swift ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift + ComputerSolitaire/Game/Golf/GameStateGolf.swift + ComputerSolitaire/Game/Golf/GameRulesGolf.swift + ComputerSolitaire/Game/Golf/AutoMoveAdvisorGolf.swift + ComputerSolitaire/Game/Golf/GolfPlanner.swift ) for source in "${SOURCES[@]}"; do