diff --git a/ComputerSolitaire/Game/Shared/GamePersistence.swift b/ComputerSolitaire/Game/Shared/GamePersistence.swift index 7f58c7f..584623b 100644 --- a/ComputerSolitaire/Game/Shared/GamePersistence.swift +++ b/ComputerSolitaire/Game/Shared/GamePersistence.swift @@ -908,12 +908,6 @@ enum GameStatisticsStore { } nonisolated private extension GameState { - var allCards: [Card] { - stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } - + tableau.flatMap { $0 } + pyramid.compactMap { $0 } + discard - + triPeaks.compactMap { $0 } + reserve - } - var isValidForPersistence: Bool { guard foundations.count == variant.foundationPileCount else { return false } guard freeCells.count == 4 else { return false } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 524f8c4..6aeb843 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -746,6 +746,12 @@ final class SolitaireViewModel { } func refreshAutoFinishAvailability() { + // Every mutating flow ends here, making it the shared checkpoint for + // the deck integrity invariant. + assert( + state.hasNoDuplicateCardIDs, + "Board corruption: a card instance appears more than once" + ) isAutoFinishAvailable = AutoFinishPlanner.canAutoFinish(in: state) isHintAvailable = !isWin && HintAdvisor.anyPlayerMoveExists(in: state) } @@ -935,7 +941,21 @@ extension SolitaireViewModel { } func tryMoveSelection(to destination: Destination) -> Bool { - guard let selection, let movingCard = selection.cards.first else { return false } + guard let staleSelection = selection else { return false } + // A selection is a snapshot of the board, and moves apply only after an + // animated flight, so the board may have moved on beneath it (for + // example, a tap queued during another move's drop flight). Applying a + // stale selection duplicates cards: `removeSelection` removes by + // position while the destination receives the snapshot's copies. + // Re-derive the cards from the current state and refuse on mismatch. + guard let selection = liveSelection(matching: staleSelection), + let movingCard = selection.cards.first else { + self.selection = nil + return false + } + if selection != staleSelection { + self.selection = selection + } switch destination { case .foundation(let index): @@ -1019,6 +1039,39 @@ extension SolitaireViewModel { } } + /// Re-derives `selection` from the current state, returning fresh copies + /// of the cards its source holds right now. Returns nil when the source no + /// longer holds exactly the selected cards — the selection predates a + /// mutation, and applying it would corrupt the board (see + /// `tryMoveSelection`). + func liveSelection(matching selection: Selection) -> Selection? { + let liveCards: [Card]? + switch selection.source { + case .waste: + liveCards = state.waste.last.map { [$0] } + case .foundation(let pile): + guard state.foundations.indices.contains(pile) else { return nil } + liveCards = state.foundations[pile].last.map { [$0] } + case .freeCell(let slot): + guard state.freeCells.indices.contains(slot) else { return nil } + liveCards = state.freeCells[slot].map { [$0] } + case .tableau(let pile, let index): + guard state.tableau.indices.contains(pile), + state.tableau[pile].indices.contains(index) else { return nil } + liveCards = Array(state.tableau[pile][index...]) + case .pyramid(let index): + guard state.pyramid.indices.contains(index) else { return nil } + liveCards = state.pyramid[index].map { [$0] } + case .triPeaks(let index): + guard state.triPeaks.indices.contains(index) else { return nil } + liveCards = state.triPeaks[index].map { [$0] } + case .reserve: + liveCards = state.reserve.last.map { [$0] } + } + guard let liveCards, liveCards.map(\.id) == selection.cards.map(\.id) else { return nil } + return Selection(source: selection.source, cards: liveCards) + } + func removeSelection(_ selection: Selection) { switch selection.source { case .waste: diff --git a/ComputerSolitaire/Game/Shared/GameState.swift b/ComputerSolitaire/Game/Shared/GameState.swift index f0f0b94..d3d8a5f 100644 --- a/ComputerSolitaire/Game/Shared/GameState.swift +++ b/ComputerSolitaire/Game/Shared/GameState.swift @@ -111,6 +111,22 @@ nonisolated struct GameState: Equatable, Codable { } } + /// Every card currently in play, across all of the variant containers. + var allCards: [Card] { + stock + waste + freeCells.compactMap { $0 } + foundations.flatMap { $0 } + + tableau.flatMap { $0 } + pyramid.compactMap { $0 } + discard + + triPeaks.compactMap { $0 } + reserve + } + + /// Whether no card instance appears twice on the board. Deliberately + /// weaker than the full-deck composition check persistence applies, so + /// partially dealt fixture states still satisfy it; a violation always + /// means a move-application bug duplicated a card. + var hasNoDuplicateCardIDs: Bool { + let cards = allCards + return Set(cards.map(\.id)).count == cards.count + } + static func newGame() -> GameState { newGame(variant: .klondike) } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 0ccb088..6a70909 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -1344,13 +1344,18 @@ struct ContentView: View { guard !viewModel.isDragging else { return } viewModel.clearPendingAutoMove() + // The request holds a snapshot from tap time; a move that landed while + // it waited (its tap arrived during a drop flight) may have moved the + // board on beneath it. The session would refuse the stale move anyway; + // dropping the request here also skips its flight animation. + guard let liveSelection = viewModel.liveSelection(matching: request.selection) else { return } drag.dragTranslation = .zero dragReturnOffset = .zero drag.setActiveTarget(nil) - viewModel.selection = request.selection + viewModel.selection = liveSelection viewModel.isDragging = true - if let firstCard = request.selection.cards.first { + if let firstCard = liveSelection.cards.first { overlayTilt = cardTilts[firstCard.id] ?? 0 let tiltSettleDuration = isAutoFinishing ? 0.1 : 0.15 withAnimation(motion.easeOut(tiltSettleDuration)) { diff --git a/ComputerSolitaireTests/Shared/StaleSelectionMoveTests.swift b/ComputerSolitaireTests/Shared/StaleSelectionMoveTests.swift new file mode 100644 index 0000000..971f698 --- /dev/null +++ b/ComputerSolitaireTests/Shared/StaleSelectionMoveTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import Computer_Solitaire + +/// Regression coverage for the duplicate-card corruption: moves apply after an +/// animated drop flight, so a selection queued during that window snapshots the +/// pre-move board — including the in-flight card. Applying such a stale +/// selection used to remove cards by position while appending the snapshot's +/// copies, leaving the same card on the board twice. `tryMoveSelection` now +/// re-derives every selection from the live state and refuses on mismatch. +@MainActor +final class StaleSelectionMoveTests: XCTestCase { + private func spadeRun(through rank: Rank) -> [Card] { + Rank.allCases + .filter { $0 <= rank } + .map { Card(suit: .spades, rank: $0, isFaceUp: true) } + } + + private func allBoardCards(in viewModel: SolitaireViewModel) -> [Card] { + viewModel.state.allCards + } + + /// The confirmed corruption sequence, driven in the exact order + /// ContentView does: tap 7♠ (auto-move to foundation queued, flight + /// starts), tap the 8♥ beneath it during the flight (queues a stale + /// [8♥, 7♠] stack), land flight one, then process the stale request. + func testStaleSelectionQueuedDuringDropFlightIsRefused() { + let viewModel = SolitaireViewModel(variant: .klondike) + + let eightOfHearts = Card(suit: .hearts, rank: .eight, isFaceUp: true) + let sevenOfSpades = Card(suit: .spades, rank: .seven, isFaceUp: true) + let nineOfClubs = Card(suit: .clubs, rank: .nine, isFaceUp: true) + + var state = viewModel.state + state.foundations = [spadeRun(through: .six), [], [], []] + state.tableau = [ + [eightOfHearts, sevenOfSpades], + [nineOfClubs], + [], [], [], [], [] + ] + state.stock = [] + state.waste = [] + state.wasteDrawCount = 0 + viewModel.state = state + + // Tap 1: queues the 7♠'s auto-move; no model mutation yet. + viewModel.handleTableauTap(pileIndex: 0, cardIndex: 1) + guard let firstRequest = viewModel.pendingAutoMove else { + return XCTFail("expected first auto-move to queue") + } + XCTAssertEqual(firstRequest.destination, .foundation(0)) + + // ContentView installs the selection and defers handleDrop for the + // drop flight. + viewModel.clearPendingAutoMove() + viewModel.selection = firstRequest.selection + viewModel.isDragging = true + + // Tap 2 lands mid-flight: the pile still holds the in-flight 7♠, so + // the queued request snapshots the stale [8♥, 7♠] stack. + viewModel.handleTableauTap(pileIndex: 0, cardIndex: 0) + guard let secondRequest = viewModel.pendingAutoMove else { + return XCTFail("expected second auto-move to queue") + } + XCTAssertEqual(secondRequest.destination, .tableau(1)) + + // Flight one lands and applies its move. + XCTAssertTrue(viewModel.handleDrop(to: firstRequest.destination)) + XCTAssertEqual(viewModel.state.foundations[0].last?.id, sevenOfSpades.id) + + // The stale request must be refused at both defense layers: the + // re-derivation ContentView performs, and the session funnel itself. + viewModel.clearPendingAutoMove() + XCTAssertNil(viewModel.liveSelection(matching: secondRequest.selection)) + viewModel.selection = secondRequest.selection + XCTAssertFalse(viewModel.handleDrop(to: secondRequest.destination)) + XCTAssertNil(viewModel.selection) + + // The board must be untouched by the refused move. + XCTAssertEqual(viewModel.state.tableau[0].map(\.id), [eightOfHearts.id]) + XCTAssertEqual(viewModel.state.tableau[1].map(\.id), [nineOfClubs.id]) + let allCards = allBoardCards(in: viewModel) + XCTAssertEqual(allCards.filter { $0.id == sevenOfSpades.id }.count, 1) + XCTAssertEqual(allCards.filter { $0.id == eightOfHearts.id }.count, 1) + XCTAssertTrue(viewModel.state.hasNoDuplicateCardIDs) + } + + /// The waste variant of the race: a waste-top selection held across a + /// stock draw must not pop the newly drawn card while appending the old + /// top's copy elsewhere. + func testWasteSelectionHeldAcrossStockDrawIsRefused() { + let viewModel = SolitaireViewModel(variant: .klondike) + viewModel.setStockDrawCount(1) + + let sevenOfSpades = Card(suit: .spades, rank: .seven, isFaceUp: true) + let queenOfDiamonds = Card(suit: .diamonds, rank: .queen) + let eightOfHearts = Card(suit: .hearts, rank: .eight, isFaceUp: true) + + var state = viewModel.state + state.foundations = Array(repeating: [], count: 4) + state.tableau = [[eightOfHearts], [], [], [], [], [], []] + state.stock = [queenOfDiamonds] + state.waste = [sevenOfSpades] + state.wasteDrawCount = 1 + viewModel.state = state + + // Selection captured from the waste top, then a draw lands beneath it + // before the deferred move applies. + viewModel.selection = Selection(source: .waste, cards: [sevenOfSpades]) + viewModel.drawFromStock() + XCTAssertEqual(viewModel.state.waste.last?.id, queenOfDiamonds.id) + + XCTAssertFalse(viewModel.handleDrop(to: .tableau(0))) + + XCTAssertEqual( + viewModel.state.waste.map(\.id), + [sevenOfSpades.id, queenOfDiamonds.id] + ) + XCTAssertEqual(viewModel.state.tableau[0].map(\.id), [eightOfHearts.id]) + XCTAssertTrue(viewModel.state.hasNoDuplicateCardIDs) + } + + /// The validation must not get in the way of a normal, up-to-date move. + func testFreshSelectionStillMoves() { + let viewModel = SolitaireViewModel(variant: .klondike) + + let eightOfHearts = Card(suit: .hearts, rank: .eight, isFaceUp: true) + let sevenOfSpades = Card(suit: .spades, rank: .seven, isFaceUp: true) + + var state = viewModel.state + state.foundations = Array(repeating: [], count: 4) + state.tableau = [[sevenOfSpades], [eightOfHearts], [], [], [], [], []] + state.stock = [] + state.waste = [] + state.wasteDrawCount = 0 + viewModel.state = state + + viewModel.selection = Selection( + source: .tableau(pile: 0, index: 0), + cards: [sevenOfSpades] + ) + XCTAssertTrue(viewModel.handleDrop(to: .tableau(1))) + XCTAssertEqual( + viewModel.state.tableau[1].map(\.id), + [eightOfHearts.id, sevenOfSpades.id] + ) + XCTAssertTrue(viewModel.state.tableau[0].isEmpty) + XCTAssertTrue(viewModel.state.hasNoDuplicateCardIDs) + } +} diff --git a/README.md b/README.md index cda4e45..17a36fd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
Computer Solitaire -Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. [Find it on the App Store](https://apps.apple.com/us/app/computer-solitaire/id6761316105). +Computer Solitaire is a fully native Solitaire app for iOS, iPadOS, and macOS. It includes things you enjoy. [Find it on the App Store](https://apps.apple.com/us/app/computer-solitaire/id6761316105).

iOS screenshot