From 19b32c7d2cc719e7518d9e558df4a20fd27ce6f0 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 21:32:43 -0700 Subject: [PATCH 1/4] add board snapshots and a pile-scoped tableau pickup rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectionsnapshot and toprowsnapshot are value projections of the observable session, captured once per body pass so board views can render from equatable slices instead of subscribing to the whole state. the pure canselecttableaucards mirrors the session rule for suffix runs — canfield's whole-pile transfer check reduces to a count comparison there — and parity tests pin the two together for every suffix of every pile across seeded deals of all ten variants. --- .../Game/Shared/GameRulesShared.swift | 29 +++++ .../Views/Shared/BoardSnapshots.swift | 80 ++++++++++++++ .../Shared/BoardSnapshotTests.swift | 94 ++++++++++++++++ .../Shared/TableauPickupParityTests.swift | 101 ++++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 ComputerSolitaire/Views/Shared/BoardSnapshots.swift create mode 100644 ComputerSolitaireTests/Shared/BoardSnapshotTests.swift create mode 100644 ComputerSolitaireTests/Shared/TableauPickupParityTests.swift diff --git a/ComputerSolitaire/Game/Shared/GameRulesShared.swift b/ComputerSolitaire/Game/Shared/GameRulesShared.swift index 98d70a7..a781e8a 100644 --- a/ComputerSolitaire/Game/Shared/GameRulesShared.swift +++ b/ComputerSolitaire/Game/Shared/GameRulesShared.swift @@ -73,6 +73,35 @@ nonisolated enum GameRules { SharedGameRules.isValidDescendingAlternatingSequence(cards) } + /// Pure, pile-scoped equivalent of the session's `canSelectTableauCards` + /// for a run that is a suffix of `pile`; board views call this so + /// rendering never reads the observable session. Canfield's whole-pile + /// transfer check reduces to a count comparison here: a suffix can only + /// be "some entire pile" when it is this entire pile, because card + /// identities are unique across the board. `TableauPickupParityTests` + /// pins this equivalence against the session method. + static func canSelectTableauCards( + _ cards: [Card], + within pile: [Card], + variant: GameVariant + ) -> Bool { + switch variant { + case .klondike, .yukon, .scorpion: + return true + case .freecell: + return isValidDescendingAlternatingSequence(cards) + case .spider: + return SharedGameRules.isDescendingSameSuitRun(cards) + case .golf, .fortyThieves: + return cards.count == 1 + case .canfield: + return cards.count == 1 + || (CanfieldGameRules.isPackedSequence(cards) && cards.count == pile.count) + case .pyramid, .tripeaks: + return false + } + } + static func maxFreeCellTransferCount( freeCellSlots: [Card?], tableau: [[Card]], diff --git a/ComputerSolitaire/Views/Shared/BoardSnapshots.swift b/ComputerSolitaire/Views/Shared/BoardSnapshots.swift new file mode 100644 index 0000000..bce7d7f --- /dev/null +++ b/ComputerSolitaire/Views/Shared/BoardSnapshots.swift @@ -0,0 +1,80 @@ +import Foundation + +// Value projections of the session's observable surface, captured once per +// ContentView body pass. Board views render from these slices instead of +// reading the observable session, so a move that leaves a view's slice +// unchanged lets the view prune — its manual `==` sees equal inputs. CardView +// established the pattern; the pile and row views follow it. + +/// The selection and drag flags every card-bearing view renders from. +nonisolated struct SelectionSnapshot: Equatable { + let isDragging: Bool + /// The full source, not a reduced flag — TableauPileView's highlight + /// placement needs the `.tableau(pile:index:)` payload. + let source: Selection.Source? + let selectedCardIDs: Set + + init(selection: Selection?, isDragging: Bool) { + self.isDragging = isDragging + self.source = selection?.source + self.selectedCardIDs = Set(selection?.cards.map(\.id) ?? []) + } + + func isSelected(_ card: Card) -> Bool { + selectedCardIDs.contains(card.id) + } + + /// The selection source while a drag is in flight; nil for tap selections. + var dragSource: Selection.Source? { + isDragging ? source : nil + } +} + +/// What an empty foundation renders beneath its placeholder ring. +nonisolated enum FoundationPlaceholder: Equatable { + /// Foundations that build up from the Ace — every variant but Canfield. + case ace + /// Canfield's dealt base rank. + case baseRank(Rank) + /// Canfield before the base card exists; renders nothing. + case blank +} + +/// Everything the top row (stock, waste, foundations, discard) renders from. +nonisolated struct TopRowSnapshot: Equatable { + let variant: GameVariant + let foundations: [[Card]] + let foundationPlaceholder: FoundationPlaceholder + let stockCount: Int + let canInteractWithStock: Bool + /// Pyramid's remaining waste recycles; nil for every other variant. + let stockRecyclesRemaining: Int? + let visibleWasteCards: [Card] + /// Pyramid's discard pile; empty for every other variant. + let discard: [Card] +} + +extension SolitaireViewModel { + var selectionSnapshot: SelectionSnapshot { + SelectionSnapshot(selection: selection, isDragging: isDragging) + } + + var topRowSnapshot: TopRowSnapshot { + TopRowSnapshot( + variant: state.variant, + foundations: state.foundations, + foundationPlaceholder: foundationPlaceholder, + stockCount: state.stock.count, + canInteractWithStock: canInteractWithStock, + stockRecyclesRemaining: state.variant == .pyramid ? pyramidWasteRecyclesRemaining : nil, + visibleWasteCards: visibleWasteCards(), + discard: state.discard + ) + } + + private var foundationPlaceholder: FoundationPlaceholder { + guard state.variant == .canfield else { return .ace } + guard let baseRank = CanfieldGameRules.baseRank(in: state) else { return .blank } + return .baseRank(baseRank) + } +} diff --git a/ComputerSolitaireTests/Shared/BoardSnapshotTests.swift b/ComputerSolitaireTests/Shared/BoardSnapshotTests.swift new file mode 100644 index 0000000..bf7c023 --- /dev/null +++ b/ComputerSolitaireTests/Shared/BoardSnapshotTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import Computer_Solitaire + +/// The snapshots are what board views render from instead of reading the +/// observable session; these tests pin each derived field to the session +/// state it projects. +@MainActor +final class BoardSnapshotTests: XCTestCase { + func testSelectionSnapshotWithNoSelection() { + let session = SolitaireViewModel(variant: .klondike) + let snapshot = session.selectionSnapshot + XCTAssertFalse(snapshot.isDragging) + XCTAssertNil(snapshot.source) + XCTAssertNil(snapshot.dragSource) + XCTAssertTrue(snapshot.selectedCardIDs.isEmpty) + } + + func testSelectionSnapshotMatchesSessionSelection() { + let session = SolitaireViewModel(variant: .klondike) + guard let pileIndex = session.state.tableau.lastIndex(where: { $0.count > 1 }) else { + return XCTFail("expected a multi-card tableau pile in a fresh deal") + } + let cardIndex = session.state.tableau[pileIndex].count - 1 + session.selectFromTableau(pileIndex: pileIndex, cardIndex: cardIndex) + guard let selection = session.selection else { + return XCTFail("expected a selection") + } + + let tapped = session.selectionSnapshot + XCTAssertFalse(tapped.isDragging) + XCTAssertEqual(tapped.source, selection.source) + XCTAssertNil(tapped.dragSource, "tap selections are not drag sources") + for card in selection.cards { + XCTAssertEqual(tapped.isSelected(card), session.isSelected(card: card)) + } + + session.isDragging = true + let dragged = session.selectionSnapshot + XCTAssertTrue(dragged.isDragging) + XCTAssertEqual(dragged.dragSource, selection.source) + } + + func testSnapshotsOfTheSamePositionAreEqual() { + let session = SolitaireViewModel(variant: .klondike) + XCTAssertEqual(session.topRowSnapshot, session.topRowSnapshot) + XCTAssertEqual(session.selectionSnapshot, session.selectionSnapshot) + } + + func testTopRowSnapshotChangesWithAStockDraw() { + let session = SolitaireViewModel(variant: .klondike) + let before = session.topRowSnapshot + session.handleStockTap() + let after = session.topRowSnapshot + XCTAssertNotEqual(before, after) + XCTAssertEqual(after.stockCount, session.state.stock.count) + XCTAssertEqual(after.visibleWasteCards, session.visibleWasteCards()) + } + + func testTopRowSnapshotVisibleWasteMatchesSessionAcrossVariants() { + for variant in GameVariant.allCases { + let session = SolitaireViewModel(variant: variant) + session.handleStockTap() + let snapshot = session.topRowSnapshot + XCTAssertEqual(snapshot.visibleWasteCards, session.visibleWasteCards(), "\(variant)") + XCTAssertEqual(snapshot.stockCount, session.state.stock.count, "\(variant)") + XCTAssertEqual(snapshot.canInteractWithStock, session.canInteractWithStock, "\(variant)") + XCTAssertEqual(snapshot.foundations, session.state.foundations, "\(variant)") + } + } + + func testFoundationPlaceholderIsAceOutsideCanfield() { + let session = SolitaireViewModel(variant: .klondike) + XCTAssertEqual(session.topRowSnapshot.foundationPlaceholder, .ace) + } + + func testFoundationPlaceholderTracksCanfieldBaseRank() { + let session = SolitaireViewModel(variant: .canfield) + guard let baseRank = CanfieldGameRules.baseRank(in: session.state) else { + return XCTFail("a fresh Canfield deal seeds a base card") + } + XCTAssertEqual(session.topRowSnapshot.foundationPlaceholder, .baseRank(baseRank)) + + var state = session.state + state.foundations = Array(repeating: [], count: 4) + session.state = state + XCTAssertEqual(session.topRowSnapshot.foundationPlaceholder, .blank) + } + + func testStockRecyclesRemainingIsPyramidOnly() { + XCTAssertNotNil(SolitaireViewModel(variant: .pyramid).topRowSnapshot.stockRecyclesRemaining) + XCTAssertNil(SolitaireViewModel(variant: .klondike).topRowSnapshot.stockRecyclesRemaining) + XCTAssertNil(SolitaireViewModel(variant: .canfield).topRowSnapshot.stockRecyclesRemaining) + } +} diff --git a/ComputerSolitaireTests/Shared/TableauPickupParityTests.swift b/ComputerSolitaireTests/Shared/TableauPickupParityTests.swift new file mode 100644 index 0000000..6d9b948 --- /dev/null +++ b/ComputerSolitaireTests/Shared/TableauPickupParityTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import Computer_Solitaire + +/// Pins `GameRules.canSelectTableauCards(_:within:variant:)` — the pure, +/// pile-scoped check the board views render from — to the session's +/// `canSelectTableauCards(_:)` for every suffix of every pile. The pure form +/// exists so views never read the observable session while rendering; these +/// tests are the proof the two can never drift. +@MainActor +final class TableauPickupParityTests: XCTestCase { + func testEverySuffixOfEverySeededDealMatchesTheSessionRule() { + for variant in GameVariant.allCases { + for seed in UInt64(1)...4 { + var generator = SeededRandomNumberGenerator(seed: seed) + let session = SolitaireViewModel(variant: variant) + var state = session.state + state.tableau = state.tableau.map { $0.shuffled(using: &generator) } + session.state = state + assertSuffixParity(for: session) + } + } + } + + func testCanfieldWholePileAndPartialRunParity() { + // A packed two-card pile: the whole pile may move, a suffix may not. + let pile = [ + TestCards.make(.spades, .nine), + TestCards.make(.hearts, .eight), + ] + let session = makeSession(variant: .canfield, tableau: [pile, [], [], []]) + assertSuffixParity(for: session) + XCTAssertTrue( + GameRules.canSelectTableauCards(pile, within: pile, variant: .canfield) + ) + // A packed suffix that is not the entire pile must not move. + let deeperPile = [TestCards.make(.diamonds, .ten)] + pile + let deeperSession = makeSession(variant: .canfield, tableau: [deeperPile, [], [], []]) + assertSuffixParity(for: deeperSession) + XCTAssertFalse( + GameRules.canSelectTableauCards(pile, within: deeperPile, variant: .canfield) + ) + } + + func testFreeCellRunAndBrokenRunParity() { + let run = [ + TestCards.make(.spades, .nine), + TestCards.make(.hearts, .eight), + TestCards.make(.clubs, .seven), + ] + let broken = [ + TestCards.make(.spades, .nine), + TestCards.make(.clubs, .eight), + ] + let session = makeSession(variant: .freecell, tableau: [run, broken, [], [], [], [], [], []]) + assertSuffixParity(for: session) + } + + func testSpiderSameSuitRunParity() { + let sameSuit = [ + TestCards.make(.spades, .five), + TestCards.make(.spades, .four), + ] + let mixedSuit = [ + TestCards.make(.spades, .five), + TestCards.make(.hearts, .four), + ] + let session = makeSession( + variant: .spider, + tableau: [sameSuit, mixedSuit] + Array(repeating: [Card](), count: 8) + ) + assertSuffixParity(for: session) + } + + private func assertSuffixParity( + for session: SolitaireViewModel, + file: StaticString = #filePath, + line: UInt = #line + ) { + let state = session.state + for pile in state.tableau where !pile.isEmpty { + for startIndex in pile.indices { + let suffix = Array(pile[startIndex...]) + XCTAssertEqual( + GameRules.canSelectTableauCards(suffix, within: pile, variant: state.variant), + session.canSelectTableauCards(suffix), + "variant \(state.variant), pile of \(pile.count), suffix from \(startIndex)", + file: file, + line: line + ) + } + } + } + + private func makeSession(variant: GameVariant, tableau: [[Card]]) -> SolitaireViewModel { + let session = SolitaireViewModel(variant: variant) + var state = session.state + state.tableau = tableau + session.state = state + return session + } +} From 03323df86ba219a64eb386eea9c8319ba9eb7dbe Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 21:32:43 -0700 Subject: [PATCH 2/4] convert the tableau region to value slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tableaupileview and tableaurowview take the pile arrays, variant, and selection snapshot by value and prune through manual equatable — the session reference stays for tap wiring only and participates in == by identity. the interaction value types become nonisolated so the nonisolated == can compare drop targets. equatable regression tests assert pruning survives fresh gesture closures and breaks on every rendered input. --- .../Interaction/BoardInteractionTypes.swift | 12 +- .../Views/Canfield/CanfieldTopRowView.swift | 5 +- .../Views/Shared/BoardViews.swift | 320 ++++++++++-------- .../Views/Shared/ContentView.swift | 9 +- .../Shared/BoardViewEquatableTests.swift | 91 +++++ 5 files changed, 294 insertions(+), 143 deletions(-) create mode 100644 ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift diff --git a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift index f517e3a..cc2b39b 100644 --- a/ComputerSolitaire/Interaction/BoardInteractionTypes.swift +++ b/ComputerSolitaire/Interaction/BoardInteractionTypes.swift @@ -1,6 +1,6 @@ import SwiftUI -enum DropTarget: Hashable { +nonisolated enum DropTarget: Hashable { case foundation(Int) case tableau(Int) case freeCell(Int) @@ -9,7 +9,7 @@ enum DropTarget: Hashable { case discard } -enum DragOrigin: Hashable { +nonisolated enum DragOrigin: Hashable { case waste case foundation(Int) case freeCell(Int) @@ -19,12 +19,12 @@ enum DragOrigin: Hashable { case reserve } -struct DropTargetGeometry: Equatable { +nonisolated struct DropTargetGeometry: Equatable { let snapFrame: CGRect let hitFrame: CGRect } -enum DropTargetHitArea { +nonisolated enum DropTargetHitArea { static let freeCellHorizontalGrace: CGFloat = 16 static let freeCellTopGrace: CGFloat = 14 static let freeCellBottomGrace: CGFloat = 18 @@ -44,7 +44,7 @@ enum DropTargetHitArea { static let pyramidBottomGrace: CGFloat = 8 } -extension CGRect { +nonisolated extension CGRect { func expanded(horizontal: CGFloat, top: CGFloat, bottom: CGFloat) -> CGRect { CGRect( x: minX - horizontal, @@ -55,7 +55,7 @@ extension CGRect { } } -enum UndoAnimationEndTarget { +nonisolated enum UndoAnimationEndTarget { case card(UUID) case stock(Int) } diff --git a/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift b/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift index 4b3612d..31ba6e7 100644 --- a/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift +++ b/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift @@ -122,7 +122,10 @@ struct CanfieldBoardRowView: View { } TableauRowView( - viewModel: viewModel, + session: viewModel, + tableau: viewModel.state.tableau, + variant: viewModel.gameVariant, + selection: viewModel.selectionSnapshot, cardSize: cardSize, columnSpacing: columnSpacing, faceDownOffset: faceDownOffset, diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 1332d0c..9ce32f3 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -567,7 +567,11 @@ struct TopRowView: View { } struct TableauRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let tableau: [[Card]] + let variant: GameVariant + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let faceDownOffset: CGFloat @@ -585,10 +589,13 @@ struct TableauRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { - ForEach(Array(viewModel.state.tableau.indices), id: \.self) { index in + ForEach(Array(tableau.indices), id: \.self) { index in TableauPileView( - viewModel: viewModel, + session: session, + pile: tableau[index], pileIndex: index, + variant: variant, + selection: selection, cardSize: cardSize, faceDownOffset: faceDownOffset, faceUpOffset: faceUpOffset, @@ -611,6 +618,29 @@ struct TableauRowView: View { } } +/// Prunes the whole tableau subtree between drop-target crossings; see +/// TableauPileView's Equatable note for the exclusion contract. +extension TableauRowView: Equatable { + nonisolated static func == (lhs: TableauRowView, rhs: TableauRowView) -> Bool { + lhs.session === rhs.session + && lhs.tableau == rhs.tableau + && lhs.variant == rhs.variant + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.columnSpacing == rhs.columnSpacing + && lhs.faceDownOffset == rhs.faceDownOffset + && lhs.faceUpOffset == rhs.faceUpOffset + && lhs.maxPileHeight == rhs.maxPileHeight + && lhs.activeTarget == rhs.activeTarget + && lhs.hintedTarget == rhs.hintedTarget + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} + struct FoundationView: View { @Bindable var viewModel: SolitaireViewModel let index: Int @@ -733,8 +763,14 @@ struct FoundationView: View { } struct TableauPileView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only (taps route through it); never read in body — + /// reading the observable session while rendering would re-couple this + /// pile to whole-board invalidation. + let session: SolitaireViewModel + let pile: [Card] let pileIndex: Int + let variant: GameVariant + let selection: SelectionSnapshot let cardSize: CGSize let faceDownOffset: CGFloat let faceUpOffset: CGFloat @@ -750,145 +786,132 @@ struct TableauPileView: View { let dragGesture: (DragOrigin) -> AnyGesture var body: some View { - if viewModel.state.tableau.indices.contains(pileIndex) { - let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .tableau(let pile, _) = selection.source { - return pile == pileIndex - } - return false - }() - - let pile = viewModel.state.tableau[pileIndex] - let yOffsets = tableauYOffsets(for: pile) - let topCardYOffset = yOffsets.last ?? 0 - let stackDropYOffset = dropYOffset(for: pile, yOffsets: yOffsets) - let height = max(cardSize.height, cardSize.height + topCardYOffset) - let highlightYOffset: CGFloat = { - guard viewModel.isDragging, let selection = viewModel.selection else { - return stackDropYOffset - } - if case .tableau(let sourcePile, let sourceIndex) = selection.source, - sourcePile == pileIndex, - sourceIndex < yOffsets.count { - return yOffsets[sourceIndex] - } - return stackDropYOffset - }() - let highlightZ: Double = Double(pile.count) + 0.5 + let isDragSource: Bool = { + if case .tableau(let sourcePile, _) = selection.dragSource { + return sourcePile == pileIndex + } + return false + }() - ZStack(alignment: .top) { - Color.clear - .frame(width: cardSize.width, height: height) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: nil) - } - .accessibilityAddTraits(.isButton) - .accessibilityLabel("Tableau \(pileIndex + 1)") - .accessibilityValue("Empty") - .accessibilityHidden(!pile.isEmpty) + let yOffsets = tableauYOffsets(for: pile) + let topCardYOffset = yOffsets.last ?? 0 + let stackDropYOffset = dropYOffset(for: pile, yOffsets: yOffsets) + let height = max(cardSize.height, cardSize.height + topCardYOffset) + let highlightYOffset: CGFloat = { + if case .tableau(let sourcePile, let sourceIndex) = selection.dragSource, + sourcePile == pileIndex, + sourceIndex < yOffsets.count { + return yOffsets[sourceIndex] + } + return stackDropYOffset + }() + let highlightZ: Double = Double(pile.count) + 0.5 - PilePlaceholderView(cardSize: cardSize) - DropHighlightView( + ZStack(alignment: .top) { + Color.clear + .frame(width: cardSize.width, height: height) + .contentShape(Rectangle()) + .onTapGesture { + session.handleTableauTap(pileIndex: pileIndex, cardIndex: nil) + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel("Tableau \(pileIndex + 1)") + .accessibilityValue("Empty") + .accessibilityHidden(!pile.isEmpty) + + PilePlaceholderView(cardSize: cardSize) + DropHighlightView( + cardSize: cardSize, + isTargeted: isTargeted, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + .offset(y: highlightYOffset) + .zIndex(highlightZ) + + ForEach(Array(pile.enumerated()), id: \.element.id) { index, card in + let isDragged = selection.isDragging && selection.isSelected(card) + let isHidden = hiddenCardIDs.contains(card.id) + let isSelected = selection.isSelected(card) + let selectableCards = Array(pile[index...]) + let isValidRunOrigin = card.isFaceUp + && GameRules.canSelectTableauCards(selectableCards, within: pile, variant: variant) + let isExposedFaceDownCard = variant.dealsFaceDownTableauCards + && !card.isFaceUp + && index == pile.indices.last + let isAccessibilityElement = (isValidRunOrigin || isExposedFaceDownCard) + && !isDragged + && !isHidden + // Yukon and Scorpion groups need not be ordered; every + // other multi-card pickup is a run. + let isGroupMoveVariant = variant == .yukon || variant == .scorpion + let multiCardNoun = isGroupMoveVariant ? "group" : "run" + let accessibilityHint = isExposedFaceDownCard + ? "Flip card" + : selectableCards.count > 1 + ? "Selects a \(selectableCards.count)-card \(multiCardNoun)" + : "Selects this card" + let yOffset = yOffsets[index] + let cardView = CardView( + card: card, + isSelected: isSelected, cardSize: cardSize, - isTargeted: isTargeted, - isHintTargeted: isHintTargeted, - hintOpacity: hintHighlightOpacity + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: isAccessibilityElement ) - .offset(y: highlightYOffset) - .zIndex(highlightZ) - - ForEach(Array(pile.enumerated()), id: \.element.id) { index, card in - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) - let isHidden = hiddenCardIDs.contains(card.id) - let isSelected = viewModel.isSelected(card: card) - let selectableCards = Array(pile[index...]) - let isValidRunOrigin = card.isFaceUp - && viewModel.canSelectTableauCards(selectableCards) - let isExposedFaceDownCard = viewModel.state.variant.dealsFaceDownTableauCards - && !card.isFaceUp - && index == pile.indices.last - let isAccessibilityElement = (isValidRunOrigin || isExposedFaceDownCard) - && !isDragged - && !isHidden - // Yukon and Scorpion groups need not be ordered; every - // other multi-card pickup is a run. - let isGroupMoveVariant = viewModel.state.variant == .yukon - || viewModel.state.variant == .scorpion - let multiCardNoun = isGroupMoveVariant ? "group" : "run" - let accessibilityHint = isExposedFaceDownCard - ? "Flip card" - : selectableCards.count > 1 - ? "Selects a \(selectableCards.count)-card \(multiCardNoun)" - : "Selects this card" - let yOffset = yOffsets[index] - let cardView = CardView( - card: card, - isSelected: isSelected, - cardSize: cardSize, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, - isAccessibilityElement: isAccessibilityElement - ) - .opacity(isDragged || isHidden ? 0 : 1) - .offset(x: 0, y: yOffset) - .zIndex(isDragged ? 20 + Double(index) : Double(index)) - .allowsHitTesting(!isHidden) - .onTapGesture { - viewModel.handleTableauTap(pileIndex: pileIndex, cardIndex: index) - } - .accessibilityAddTraits(.isButton) - .accessibilityAddTraits(isSelected ? .isSelected : []) - .accessibilityHint(accessibilityHint) - .cardFramePreference(card.id, yOffset: yOffset) - - cardView.gesture(dragGesture(.tableau(pile: pileIndex, index: index))) + .opacity(isDragged || isHidden ? 0 : 1) + .offset(x: 0, y: yOffset) + .zIndex(isDragged ? 20 + Double(index) : Double(index)) + .allowsHitTesting(!isHidden) + .onTapGesture { + session.handleTableauTap(pileIndex: pileIndex, cardIndex: index) } + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityHint(accessibilityHint) + .cardFramePreference(card.id, yOffset: yOffset) + + cardView.gesture(dragGesture(.tableau(pile: pileIndex, index: index))) } - .frame(width: cardSize.width, height: height, alignment: .top) - .background( - GeometryReader { proxy in - let boardFrame = proxy.frame(in: .named("board")) - let snapFrame = CGRect( - x: boardFrame.minX, - y: boardFrame.minY + highlightYOffset, - width: cardSize.width, - height: cardSize.height + } + .frame(width: cardSize.width, height: height, alignment: .top) + .background( + GeometryReader { proxy in + let boardFrame = proxy.frame(in: .named("board")) + let snapFrame = CGRect( + x: boardFrame.minX, + y: boardFrame.minY + highlightYOffset, + width: cardSize.width, + height: cardSize.height + ) + let topCardFrame = CGRect( + x: boardFrame.minX, + y: boardFrame.minY + topCardYOffset, + width: cardSize.width, + height: cardSize.height + ) + let hitFrame = snapFrame + .union(topCardFrame) + .expanded( + horizontal: DropTargetHitArea.tableauHorizontalGrace, + top: DropTargetHitArea.tableauTopGrace, + bottom: DropTargetHitArea.tableauBottomGrace ) - let topCardFrame = CGRect( - x: boardFrame.minX, - y: boardFrame.minY + topCardYOffset, - width: cardSize.width, - height: cardSize.height + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .tableau(pileIndex): DropTargetGeometry( + snapFrame: snapFrame, + hitFrame: hitFrame + ) + ] ) - let hitFrame = snapFrame - .union(topCardFrame) - .expanded( - horizontal: DropTargetHitArea.tableauHorizontalGrace, - top: DropTargetHitArea.tableauTopGrace, - bottom: DropTargetHitArea.tableauBottomGrace - ) - Color.clear - .preference( - key: DropTargetFrameKey.self, - value: [ - .tableau(pileIndex): DropTargetGeometry( - snapFrame: snapFrame, - hitFrame: hitFrame - ) - ] - ) - } - ) - .zIndex(isDragSource ? 10 : 0) - } else { - Color.clear - .frame(width: cardSize.width, height: cardSize.height) - .allowsHitTesting(false) - .accessibilityHidden(true) - } + } + ) + .zIndex(isDragSource ? 10 : 0) } private func tableauYOffsets(for pile: [Card]) -> [CGFloat] { @@ -922,6 +945,33 @@ struct TableauPileView: View { } } +/// Covers every rendered input so an unrelated move prunes this pile. The +/// session participates by identity only (event wiring); the tilt binding and +/// gesture closure are excluded — both act purely through identity-stable +/// storage, the contract CardView's Equatable documents. Card-level tilt +/// changes always accompany a pile-content change here (only the waste +/// rerolls a tilt in place), so no per-card tilt capture is needed. +extension TableauPileView: Equatable { + nonisolated static func == (lhs: TableauPileView, rhs: TableauPileView) -> Bool { + lhs.session === rhs.session + && lhs.pile == rhs.pile + && lhs.pileIndex == rhs.pileIndex + && lhs.variant == rhs.variant + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.faceDownOffset == rhs.faceDownOffset + && lhs.faceUpOffset == rhs.faceUpOffset + && lhs.maxPileHeight == rhs.maxPileHeight + && lhs.isTargeted == rhs.isTargeted + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} + struct PilePlaceholderView: View { let cardSize: CGSize diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index a72c43f..7b648a7 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -549,6 +549,10 @@ struct ContentView: View { height: cardSize.height * boardScaleFactor ) let isBoardReady = hasLoadedGame && !isHydratingGame + // One value capture per body pass; the board views render from these + // slices (and prune when they're unchanged) instead of reading the + // observable session. + let selection = viewModel.selectionSnapshot let hintedTarget: DropTarget? = { guard let destination = viewModel.hintedDestination else { return nil } return dropTarget(for: destination) @@ -662,7 +666,10 @@ struct ContentView: View { .animation(Self.boardSpring, value: viewModel.state) } else { TableauRowView( - viewModel: viewModel, + session: viewModel, + tableau: viewModel.state.tableau, + variant: viewModel.gameVariant, + selection: selection, cardSize: cardSize, columnSpacing: metrics.columnSpacing, faceDownOffset: metrics.tableauFaceDownOffset, diff --git a/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift b/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift new file mode 100644 index 0000000..d8d67b7 --- /dev/null +++ b/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift @@ -0,0 +1,91 @@ +import SwiftUI +import XCTest +@testable import Computer_Solitaire + +/// Regression guard for the board views' manual `==`: SwiftUI silently +/// skips a pruned view's body, so a rendered input that someone adds without +/// extending `==` would show up as a stale board, not a compile error. Each +/// converted view asserts both directions — equal when only the excluded +/// fields differ (fresh gesture closures), unequal when any rendered input +/// is perturbed. +@MainActor +final class BoardViewEquatableTests: XCTestCase { + private func anyDragGesture() -> (DragOrigin) -> AnyGesture { + { _ in AnyGesture(DragGesture()) } + } + + private func makeTableauPileView( + session: SolitaireViewModel, + pile: [Card], + selection: SelectionSnapshot, + hintWiggleToken: UUID + ) -> TableauPileView { + TableauPileView( + session: session, + pile: pile, + pileIndex: 0, + variant: .klondike, + selection: selection, + cardSize: CGSize(width: 50, height: 70), + faceDownOffset: 8, + faceUpOffset: 18, + maxPileHeight: 400, + isTargeted: false, + isHintTargeted: false, + hintHighlightOpacity: 0, + isCardTiltEnabled: true, + cardTilts: .constant([:]), + hiddenCardIDs: [], + hintedCardIDs: [], + hintWiggleToken: hintWiggleToken, + dragGesture: anyDragGesture() + ) + } + + func testTableauPileViewPrunesWhenOnlyExcludedFieldsDiffer() { + let session = SolitaireViewModel(variant: .klondike) + let pile = [TestCards.make(.spades, .king), TestCards.make(.hearts, .queen)] + let selection = session.selectionSnapshot + let token = UUID() + // Fresh closures and a fresh binding on the right side — the excluded + // fields — must not defeat pruning. + let lhs = makeTableauPileView(session: session, pile: pile, selection: selection, hintWiggleToken: token) + let rhs = makeTableauPileView(session: session, pile: pile, selection: selection, hintWiggleToken: token) + XCTAssertEqual(lhs, rhs) + } + + func testTableauPileViewReRendersWhenRenderedInputsChange() { + let session = SolitaireViewModel(variant: .klondike) + let pile = [TestCards.make(.spades, .king), TestCards.make(.hearts, .queen)] + let selection = session.selectionSnapshot + let token = UUID() + let base = makeTableauPileView(session: session, pile: pile, selection: selection, hintWiggleToken: token) + + var flippedPile = pile + flippedPile[1].isFaceUp = false + XCTAssertNotEqual( + base, + makeTableauPileView(session: session, pile: flippedPile, selection: selection, hintWiggleToken: token) + ) + + let selected = SelectionSnapshot( + selection: Selection(source: .tableau(pile: 0, index: 1), cards: [pile[1]]), + isDragging: false + ) + XCTAssertNotEqual( + base, + makeTableauPileView(session: session, pile: pile, selection: selected, hintWiggleToken: token) + ) + + XCTAssertNotEqual( + base, + makeTableauPileView(session: session, pile: pile, selection: selection, hintWiggleToken: UUID()) + ) + + let otherSession = SolitaireViewModel(variant: .klondike) + XCTAssertNotEqual( + base, + makeTableauPileView(session: otherSession, pile: pile, selection: selection, hintWiggleToken: token) + ) + } +} From 5d66450e2d1b04b83a581a318697538a501f41ad Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 21:32:43 -0700 Subject: [PATCH 3/4] convert the remaining board views to value slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stock, waste, tableau stock, foundation, free cell, discard, the pyramid and tripeaks boards, canfield's reserve and board row, the top-row dispatcher, and all ten per-variant top rows now render from toprowsnapshot and selectionsnapshot slices, so a move that leaves a view's slice unchanged skips its body entirely. wasteview captures its top card's tilt at init and compares it in == because the invalid-drop return rerolls that tilt without touching the pile's contents. the per-variant rows carry no equatable of their own — the dispatcher's == gates the whole row. --- .../Views/Canfield/CanfieldTopRowView.swift | 93 ++++++++--- .../FortyThieves/FortyThievesTopRowView.swift | 22 ++- .../Views/FreeCell/FreeCellSlotView.swift | 158 ++++++++++-------- .../Views/FreeCell/FreeCellTopRowView.swift | 45 +++-- .../Views/Golf/GolfTopRowView.swift | 15 +- .../Views/Klondike/KlondikeTopRowView.swift | 20 ++- .../Views/Pyramid/PyramidBoardView.swift | 35 +++- .../Views/Pyramid/PyramidDiscardView.swift | 19 ++- .../Views/Pyramid/PyramidTopRowView.swift | 17 +- .../Views/Scorpion/ScorpionTopRowView.swift | 11 +- .../Views/Shared/BoardSnapshots.swift | 2 + .../Views/Shared/BoardViews.swift | 140 ++++++++++++---- .../Views/Shared/ContentView.swift | 19 ++- .../Views/Shared/StockWasteViews.swift | 154 +++++++++++++---- .../Views/Shared/TableauStockView.swift | 30 +++- .../Views/Spider/SpiderTopRowView.swift | 11 +- .../Views/TriPeaks/TriPeaksBoardView.swift | 32 +++- .../Views/TriPeaks/TriPeaksTopRowView.swift | 15 +- .../Views/Yukon/YukonTopRowView.swift | 11 +- .../Shared/BoardViewEquatableTests.swift | 86 ++++++++++ 20 files changed, 690 insertions(+), 245 deletions(-) diff --git a/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift b/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift index 31ba6e7..4bc2d73 100644 --- a/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift +++ b/ComputerSolitaire/Views/Canfield/CanfieldTopRowView.swift @@ -1,12 +1,14 @@ import SwiftUI -import Observation /// Canfield's top row matches Klondike's shape — stock, fanned waste, a /// spacer column the fan can overflow into, and four foundations. The /// reserve renders in the tableau band (see `CanfieldBoardRowView`), beside /// the piles it feeds, as on a physical table. struct CanfieldTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let wasteFanSpacing: CGFloat @@ -27,7 +29,10 @@ struct CanfieldTopRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { StockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, + canInteract: board.canInteractWithStock, + recyclesRemaining: board.stockRecyclesRemaining, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -36,7 +41,9 @@ struct CanfieldTopRowView: View { .frame(width: cardSize.width, alignment: .leading) WasteView( - viewModel: viewModel, + session: session, + cards: board.visibleWasteCards, + selection: selection, cardSize: cardSize, fanSpacing: wasteFanSpacing, isHintTargeted: isWasteHinted, @@ -59,8 +66,11 @@ struct CanfieldTopRowView: View { ForEach(0..<4, id: \.self) { index in FoundationView( - viewModel: viewModel, + session: session, + pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil, index: index, + placeholder: board.foundationPlaceholder, + selection: selection, cardSize: cardSize, isTargeted: activeTarget == .foundation(index), isHintTargeted: hintedTarget == .foundation(index), @@ -85,7 +95,11 @@ struct CanfieldTopRowView: View { /// stock, as on a physical table — a spacer pair, then the four tableau /// piles aligned directly beneath the four foundations. struct CanfieldBoardRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let reserve: [Card] + let tableau: [[Card]] + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let faceDownOffset: CGFloat @@ -104,7 +118,9 @@ struct CanfieldBoardRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { CanfieldReserveView( - viewModel: viewModel, + session: session, + reserve: reserve, + selection: selection, cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, @@ -122,10 +138,10 @@ struct CanfieldBoardRowView: View { } TableauRowView( - session: viewModel, - tableau: viewModel.state.tableau, - variant: viewModel.gameVariant, - selection: viewModel.selectionSnapshot, + session: session, + tableau: tableau, + variant: .canfield, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, faceDownOffset: faceDownOffset, @@ -151,7 +167,10 @@ struct CanfieldBoardRowView: View { /// The reserve pile ("the demon"): a face-down packet whose exposed top card /// is always playable. It is never a drop target — cards only ever leave. struct CanfieldReserveView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let reserve: [Card] + let selection: SelectionSnapshot let cardSize: CGSize let isCardTiltEnabled: Bool @Binding var cardTilts: [UUID: Double] @@ -161,22 +180,20 @@ struct CanfieldReserveView: View { let dragGesture: (DragOrigin) -> AnyGesture var body: some View { - let reserve = viewModel.state.reserve let topCard = reserve.last let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .reserve = selection.source { + if case .reserve = selection.dragSource { return true } return false }() let accessibleTopCard: Card? = topCard.flatMap { card in guard card.isFaceUp else { return nil } - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isDragged = selection.isDragging && selection.isSelected(card) return isDragged || hiddenCardIDs.contains(card.id) ? nil : card } let isAccessibleTopCardSelected = accessibleTopCard.map { - viewModel.isSelected(card: $0) + selection.isSelected($0) } ?? false VStack(spacing: 4) { @@ -186,11 +203,11 @@ struct CanfieldReserveView: View { CardBackView(cardSize: cardSize) } if let topCard, topCard.isFaceUp { - let isDragged = viewModel.isDragging && viewModel.isSelected(card: topCard) + let isDragged = selection.isDragging && selection.isSelected(topCard) let isHidden = hiddenCardIDs.contains(topCard.id) CardView( card: topCard, - isSelected: viewModel.isSelected(card: topCard), + isSelected: selection.isSelected(topCard), cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, @@ -212,7 +229,7 @@ struct CanfieldReserveView: View { .allowsHitTesting(false) } .onTapGesture { - viewModel.handleReserveTap() + session.handleReserveTap() } .zIndex(isDragSource ? 10 : 0) .accessibilityElement(children: .ignore) @@ -229,3 +246,39 @@ struct CanfieldReserveView: View { return "\(topCard.accessibilityName). \(count) cards" } } + +/// See TableauPileView's Equatable note for the exclusion contract. +extension CanfieldReserveView: Equatable { + nonisolated static func == (lhs: CanfieldReserveView, rhs: CanfieldReserveView) -> Bool { + lhs.session === rhs.session + && lhs.reserve == rhs.reserve + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} + +/// See TableauPileView's Equatable note for the exclusion contract. +extension CanfieldBoardRowView: Equatable { + nonisolated static func == (lhs: CanfieldBoardRowView, rhs: CanfieldBoardRowView) -> Bool { + lhs.session === rhs.session + && lhs.reserve == rhs.reserve + && lhs.tableau == rhs.tableau + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.columnSpacing == rhs.columnSpacing + && lhs.faceDownOffset == rhs.faceDownOffset + && lhs.faceUpOffset == rhs.faceUpOffset + && lhs.maxPileHeight == rhs.maxPileHeight + && lhs.activeTarget == rhs.activeTarget + && lhs.hintedTarget == rhs.hintedTarget + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} diff --git a/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift b/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift index 7b5d1ca..123035e 100644 --- a/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift +++ b/ComputerSolitaire/Views/FortyThieves/FortyThievesTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct FortyThievesTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let wasteFanSpacing: CGFloat @@ -25,7 +27,10 @@ struct FortyThievesTopRowView: View { // Stock and waste on the left like Klondike's, then the eight // foundations — two per suit — aligned over tableau columns 3-10. StockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, + canInteract: board.canInteractWithStock, + recyclesRemaining: board.stockRecyclesRemaining, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -34,7 +39,9 @@ struct FortyThievesTopRowView: View { .frame(width: cardSize.width, alignment: .leading) WasteView( - viewModel: viewModel, + session: session, + cards: board.visibleWasteCards, + selection: selection, cardSize: cardSize, fanSpacing: wasteFanSpacing, isHintTargeted: isWasteHinted, @@ -53,10 +60,13 @@ struct FortyThievesTopRowView: View { // during a game switch this row can re-evaluate against the // incoming variant's four-foundation state before the board // replaces it. - ForEach(viewModel.state.foundations.indices, id: \.self) { index in + ForEach(board.foundations.indices, id: \.self) { index in FoundationView( - viewModel: viewModel, + session: session, + pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil, index: index, + placeholder: board.foundationPlaceholder, + selection: selection, cardSize: cardSize, isTargeted: activeTarget == .foundation(index), isHintTargeted: hintedTarget == .foundation(index), diff --git a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift index 6e11050..a5f87dd 100644 --- a/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift +++ b/ComputerSolitaire/Views/FreeCell/FreeCellSlotView.swift @@ -1,9 +1,11 @@ import SwiftUI -import Observation struct FreeCellView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let card: Card? let index: Int + let selection: SelectionSnapshot let cardSize: CGSize let isTargeted: Bool let isHintTargeted: Bool @@ -16,81 +18,93 @@ struct FreeCellView: View { let dragGesture: (DragOrigin) -> AnyGesture var body: some View { - // Variant switches can empty the free cells while this view is - // still mounted; render nothing until the board rebuilds. - if viewModel.state.freeCells.indices.contains(index) { - let card = viewModel.state.freeCells[index] - let accessibleCard: Card? = card.flatMap { card in - let isHidden = hiddenCardIDs.contains(card.id) - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) - return isHidden || isDragged ? nil : card + let accessibleCard: Card? = card.flatMap { card in + let isHidden = hiddenCardIDs.contains(card.id) + let isDragged = selection.isDragging && selection.isSelected(card) + return isHidden || isDragged ? nil : card + } + let isAccessibleCardSelected = accessibleCard.map { + selection.isSelected($0) + } ?? false + let isDragSource: Bool = { + if case .freeCell(let slot) = selection.dragSource { + return slot == index } - let isAccessibleCardSelected = accessibleCard.map { - viewModel.isSelected(card: $0) - } ?? false - let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .freeCell(let slot) = selection.source { - return slot == index - } - return false - }() + return false + }() - ZStack { - PilePlaceholderView(cardSize: cardSize) - DropHighlightView( + ZStack { + PilePlaceholderView(cardSize: cardSize) + DropHighlightView( + cardSize: cardSize, + isTargeted: isTargeted, + isHintTargeted: isHintTargeted, + hintOpacity: hintHighlightOpacity + ) + if let card { + CardView( + card: card, + isSelected: selection.isSelected(card), cardSize: cardSize, - isTargeted: isTargeted, - isHintTargeted: isHintTargeted, - hintOpacity: hintHighlightOpacity + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, + isAccessibilityElement: false ) - if let card { - CardView( - card: card, - isSelected: viewModel.isSelected(card: card), - cardSize: cardSize, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hintWiggleToken: hintedCardIDs.contains(card.id) ? hintWiggleToken : nil, - isAccessibilityElement: false - ) - .opacity( - (viewModel.isDragging && viewModel.isSelected(card: card)) - || hiddenCardIDs.contains(card.id) ? 0 : 1 - ) - .gesture(dragGesture(.freeCell(index))) - .cardFramePreference(card.id) - } - } - .onTapGesture { - viewModel.handleFreeCellTap(index: index) + .opacity( + (selection.isDragging && selection.isSelected(card)) + || hiddenCardIDs.contains(card.id) ? 0 : 1 + ) + .gesture(dragGesture(.freeCell(index))) + .cardFramePreference(card.id) } - .accessibilityElement(children: .ignore) - .accessibilityAddTraits(.isButton) - .accessibilityAddTraits(isAccessibleCardSelected ? .isSelected : []) - .background( - GeometryReader { proxy in - let boardFrame = proxy.frame(in: .named("board")) - let hitFrame = boardFrame.expanded( - horizontal: DropTargetHitArea.freeCellHorizontalGrace, - top: DropTargetHitArea.freeCellTopGrace, - bottom: DropTargetHitArea.freeCellBottomGrace - ) - Color.clear - .preference( - key: DropTargetFrameKey.self, - value: [ - .freeCell(index): DropTargetGeometry( - snapFrame: boardFrame, - hitFrame: hitFrame - ) - ] - ) - } - ) - .zIndex(isDragSource ? 10 : 0) - .accessibilityLabel("Free Cell \(index + 1)") - .accessibilityValue(accessibleCard?.accessibilityName ?? "Empty") } + .onTapGesture { + session.handleFreeCellTap(index: index) + } + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isAccessibleCardSelected ? .isSelected : []) + .background( + GeometryReader { proxy in + let boardFrame = proxy.frame(in: .named("board")) + let hitFrame = boardFrame.expanded( + horizontal: DropTargetHitArea.freeCellHorizontalGrace, + top: DropTargetHitArea.freeCellTopGrace, + bottom: DropTargetHitArea.freeCellBottomGrace + ) + Color.clear + .preference( + key: DropTargetFrameKey.self, + value: [ + .freeCell(index): DropTargetGeometry( + snapFrame: boardFrame, + hitFrame: hitFrame + ) + ] + ) + } + ) + .zIndex(isDragSource ? 10 : 0) + .accessibilityLabel("Free Cell \(index + 1)") + .accessibilityValue(accessibleCard?.accessibilityName ?? "Empty") + } +} + +/// See TableauPileView's Equatable note for the exclusion contract. +extension FreeCellView: Equatable { + nonisolated static func == (lhs: FreeCellView, rhs: FreeCellView) -> Bool { + lhs.session === rhs.session + && lhs.card == rhs.card + && lhs.index == rhs.index + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.isTargeted == rhs.isTargeted + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken } } diff --git a/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift b/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift index 93d7fc2..9d6d1d0 100644 --- a/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift +++ b/ComputerSolitaire/Views/FreeCell/FreeCellTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct FreeCellTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let activeTarget: DropTarget? @@ -23,21 +25,25 @@ struct FreeCellTopRowView: View { HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: innerGap) { ForEach(0..<4, id: \.self) { index in - FreeCellView( - viewModel: viewModel, - index: index, - cardSize: cardSize, - isTargeted: activeTarget == .freeCell(index), - isHintTargeted: hintedTarget == .freeCell(index), - hintHighlightOpacity: hintHighlightOpacity, - isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts, - hiddenCardIDs: hiddenCardIDs, - hintedCardIDs: hintedCardIDs, - hintWiggleToken: hintWiggleToken, - dragGesture: dragGesture - ) - .frame(width: cardSize.width, alignment: .leading) + if board.freeCells.indices.contains(index) { + FreeCellView( + session: session, + card: board.freeCells[index], + index: index, + selection: selection, + cardSize: cardSize, + isTargeted: activeTarget == .freeCell(index), + isHintTargeted: hintedTarget == .freeCell(index), + hintHighlightOpacity: hintHighlightOpacity, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + hiddenCardIDs: hiddenCardIDs, + hintedCardIDs: hintedCardIDs, + hintWiggleToken: hintWiggleToken, + dragGesture: dragGesture + ) + .frame(width: cardSize.width, alignment: .leading) + } } } .frame(width: groupWidth, alignment: .leading) @@ -49,8 +55,11 @@ struct FreeCellTopRowView: View { HStack(alignment: .top, spacing: innerGap) { ForEach(0..<4, id: \.self) { index in FoundationView( - viewModel: viewModel, + session: session, + pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil, index: index, + placeholder: board.foundationPlaceholder, + selection: selection, cardSize: cardSize, isTargeted: activeTarget == .foundation(index), isHintTargeted: hintedTarget == .foundation(index), diff --git a/ComputerSolitaire/Views/Golf/GolfTopRowView.swift b/ComputerSolitaire/Views/Golf/GolfTopRowView.swift index 1f9ecf0..621d3dd 100644 --- a/ComputerSolitaire/Views/Golf/GolfTopRowView.swift +++ b/ComputerSolitaire/Views/Golf/GolfTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct GolfTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let activeTarget: DropTarget? @@ -22,7 +24,10 @@ struct GolfTopRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { StockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, + canInteract: board.canInteractWithStock, + recyclesRemaining: board.stockRecyclesRemaining, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -31,7 +36,9 @@ struct GolfTopRowView: View { .frame(width: cardSize.width, alignment: .leading) WasteView( - viewModel: viewModel, + session: session, + cards: board.visibleWasteCards, + selection: selection, cardSize: cardSize, fanSpacing: 0, isTargeted: activeTarget == .waste, diff --git a/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift b/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift index 255755b..6d12685 100644 --- a/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift +++ b/ComputerSolitaire/Views/Klondike/KlondikeTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct KlondikeTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let wasteFanSpacing: CGFloat @@ -23,7 +25,10 @@ struct KlondikeTopRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { StockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, + canInteract: board.canInteractWithStock, + recyclesRemaining: board.stockRecyclesRemaining, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -32,7 +37,9 @@ struct KlondikeTopRowView: View { .frame(width: cardSize.width, alignment: .leading) WasteView( - viewModel: viewModel, + session: session, + cards: board.visibleWasteCards, + selection: selection, cardSize: cardSize, fanSpacing: wasteFanSpacing, isHintTargeted: isWasteHinted, @@ -55,8 +62,11 @@ struct KlondikeTopRowView: View { ForEach(0..<4, id: \.self) { index in FoundationView( - viewModel: viewModel, + session: session, + pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil, index: index, + placeholder: board.foundationPlaceholder, + selection: selection, cardSize: cardSize, isTargeted: activeTarget == .foundation(index), isHintTargeted: hintedTarget == .foundation(index), diff --git a/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift b/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift index 09ec26f..fd02e0d 100644 --- a/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift +++ b/ComputerSolitaire/Views/Pyramid/PyramidBoardView.swift @@ -1,10 +1,12 @@ import SwiftUI -import Observation /// The 28-slot pyramid replaces the shared tableau row for the Pyramid variant: /// seven centered rows where each card half-overlaps the two cards above it. struct PyramidBoardView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let pyramid: [Card?] + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let maxBoardHeight: CGFloat @@ -29,7 +31,7 @@ struct PyramidBoardView: View { // 0..<28: during a game switch this view can re-evaluate against // the incoming variant's empty pyramid before the board replaces // it. - ForEach(Array(viewModel.state.pyramid.enumerated()), id: \.offset) { index, slot in + ForEach(Array(pyramid.enumerated()), id: \.offset) { index, slot in if let card = slot { pyramidCard(card, at: index, rowOverlap: rowOverlap) } @@ -63,10 +65,10 @@ struct PyramidBoardView: View { private func pyramidCard(_ card: Card, at index: Int, rowOverlap: CGFloat) -> some View { let row = PyramidGeometry.row(of: index) let offset = slotOffset(for: index, rowOverlap: rowOverlap) - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isDragged = selection.isDragging && selection.isSelected(card) let isHidden = hiddenCardIDs.contains(card.id) - let isSelected = viewModel.isSelected(card: card) - let isSelectable = PyramidGameRules.isSelectable(index: index, in: viewModel.state.pyramid) + let isSelected = selection.isSelected(card) + let isSelectable = PyramidGameRules.isSelectable(index: index, in: pyramid) let isAccessibilityElement = isSelectable && !isDragged && !isHidden let isTargeted = activeTarget == .pyramid(index) let isHintTargeted = hintedTarget == .pyramid(index) @@ -97,7 +99,7 @@ struct PyramidBoardView: View { .zIndex(isDragged ? 40 + Double(row) : Double(row)) .allowsHitTesting(!isHidden) .onTapGesture { - viewModel.handlePyramidTap(index: index) + session.handlePyramidTap(index: index) } .gesture(dragGesture(.pyramid(index))) .accessibilityHidden(!isAccessibilityElement) @@ -128,3 +130,22 @@ struct PyramidBoardView: View { ) } } + +/// See TableauPileView's Equatable note for the exclusion contract. +extension PyramidBoardView: Equatable { + nonisolated static func == (lhs: PyramidBoardView, rhs: PyramidBoardView) -> Bool { + lhs.session === rhs.session + && lhs.pyramid == rhs.pyramid + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.columnSpacing == rhs.columnSpacing + && lhs.maxBoardHeight == rhs.maxBoardHeight + && lhs.activeTarget == rhs.activeTarget + && lhs.hintedTarget == rhs.hintedTarget + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} diff --git a/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift b/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift index 29ea49e..01f46d4 100644 --- a/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift +++ b/ComputerSolitaire/Views/Pyramid/PyramidDiscardView.swift @@ -1,11 +1,10 @@ import SwiftUI -import Observation /// Where removed pairs and Kings land. Inert by design: cards here are out of /// play, so the pile takes drops (via the shared drop targeting) but offers no -/// taps or drags of its own. +/// taps or drags of its own — it needs no session reference at all. struct PyramidDiscardView: View { - @Bindable var viewModel: SolitaireViewModel + let discard: [Card] let cardSize: CGSize let isTargeted: Bool let isHintTargeted: Bool @@ -15,7 +14,6 @@ struct PyramidDiscardView: View { let hiddenCardIDs: Set var body: some View { - let discard = viewModel.state.discard let visibleDepth = min(discard.count, 4) let startIndex = discard.count - visibleDepth @@ -74,3 +72,16 @@ struct PyramidDiscardView: View { ) } } + +/// See TableauPileView's Equatable note for the exclusion contract. +extension PyramidDiscardView: Equatable { + nonisolated static func == (lhs: PyramidDiscardView, rhs: PyramidDiscardView) -> Bool { + lhs.discard == rhs.discard + && lhs.cardSize == rhs.cardSize + && lhs.isTargeted == rhs.isTargeted + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + } +} diff --git a/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift b/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift index a8dac4d..8ee16ab 100644 --- a/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift +++ b/ComputerSolitaire/Views/Pyramid/PyramidTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct PyramidTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let activeTarget: DropTarget? @@ -22,7 +24,10 @@ struct PyramidTopRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { StockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, + canInteract: board.canInteractWithStock, + recyclesRemaining: board.stockRecyclesRemaining, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -31,7 +36,9 @@ struct PyramidTopRowView: View { .frame(width: cardSize.width, alignment: .leading) WasteView( - viewModel: viewModel, + session: session, + cards: board.visibleWasteCards, + selection: selection, cardSize: cardSize, fanSpacing: 0, isTargeted: activeTarget == .waste, @@ -73,7 +80,7 @@ struct PyramidTopRowView: View { } PyramidDiscardView( - viewModel: viewModel, + discard: board.discard, cardSize: cardSize, isTargeted: activeTarget == .discard, isHintTargeted: hintedTarget == .discard, diff --git a/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift b/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift index c9a912f..ab480e2 100644 --- a/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift +++ b/ComputerSolitaire/Views/Scorpion/ScorpionTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct ScorpionTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let isStockHinted: Bool @@ -17,7 +19,8 @@ struct ScorpionTopRowView: View { // Stock on the left like Spider's, two clear columns, then the // four banked-run piles aligned over tableau columns 4-7. TableauStockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -36,7 +39,7 @@ struct ScorpionTopRowView: View { // during a game switch this row can re-evaluate against the // incoming variant's eight-foundation state before the board // replaces it. - ForEach(Array(viewModel.state.foundations.enumerated()), id: \.offset) { index, pile in + ForEach(Array(board.foundations.enumerated()), id: \.offset) { index, pile in CompletedRunPileView( pile: pile, index: index, diff --git a/ComputerSolitaire/Views/Shared/BoardSnapshots.swift b/ComputerSolitaire/Views/Shared/BoardSnapshots.swift index bce7d7f..5e0aec9 100644 --- a/ComputerSolitaire/Views/Shared/BoardSnapshots.swift +++ b/ComputerSolitaire/Views/Shared/BoardSnapshots.swift @@ -45,6 +45,7 @@ nonisolated struct TopRowSnapshot: Equatable { let variant: GameVariant let foundations: [[Card]] let foundationPlaceholder: FoundationPlaceholder + let freeCells: [Card?] let stockCount: Int let canInteractWithStock: Bool /// Pyramid's remaining waste recycles; nil for every other variant. @@ -64,6 +65,7 @@ extension SolitaireViewModel { variant: state.variant, foundations: state.foundations, foundationPlaceholder: foundationPlaceholder, + freeCells: state.freeCells, stockCount: state.stock.count, canInteractWithStock: canInteractWithStock, stockRecyclesRemaining: state.variant == .pyramid ? pyramidWasteRecyclesRemaining : nil, diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 9ce32f3..26f28b9 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -1,5 +1,4 @@ import SwiftUI -import Observation enum Layout { struct Metrics { @@ -368,8 +367,10 @@ struct StatTileView: View { } struct TopRowView: View { - @Bindable var viewModel: SolitaireViewModel - let variant: GameVariant + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let wasteFanSpacing: CGFloat @@ -389,10 +390,12 @@ struct TopRowView: View { var body: some View { Group { - switch variant { + switch board.variant { case .klondike: KlondikeTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, wasteFanSpacing: wasteFanSpacing, @@ -412,7 +415,9 @@ struct TopRowView: View { ) case .freecell: FreeCellTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, activeTarget: activeTarget, @@ -427,7 +432,9 @@ struct TopRowView: View { ) case .yukon: YukonTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, activeTarget: activeTarget, @@ -442,7 +449,9 @@ struct TopRowView: View { ) case .spider: SpiderTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, isStockHinted: isStockHinted, @@ -454,7 +463,9 @@ struct TopRowView: View { ) case .scorpion: ScorpionTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, isStockHinted: isStockHinted, @@ -466,7 +477,9 @@ struct TopRowView: View { ) case .pyramid: PyramidTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, activeTarget: activeTarget, @@ -485,7 +498,9 @@ struct TopRowView: View { ) case .tripeaks: TriPeaksTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, activeTarget: activeTarget, @@ -504,7 +519,9 @@ struct TopRowView: View { ) case .golf: GolfTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, activeTarget: activeTarget, @@ -523,7 +540,9 @@ struct TopRowView: View { ) case .fortyThieves: FortyThievesTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, wasteFanSpacing: wasteFanSpacing, @@ -543,7 +562,9 @@ struct TopRowView: View { ) case .canfield: CanfieldTopRowView( - viewModel: viewModel, + session: session, + board: board, + selection: selection, cardSize: cardSize, columnSpacing: columnSpacing, wasteFanSpacing: wasteFanSpacing, @@ -566,6 +587,30 @@ struct TopRowView: View { } } +/// Prunes the whole top row when nothing it renders changed; see +/// TableauPileView's Equatable note for the exclusion contract. +extension TopRowView: Equatable { + nonisolated static func == (lhs: TopRowView, rhs: TopRowView) -> Bool { + lhs.session === rhs.session + && lhs.board == rhs.board + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.columnSpacing == rhs.columnSpacing + && lhs.wasteFanSpacing == rhs.wasteFanSpacing + && lhs.activeTarget == rhs.activeTarget + && lhs.hintedTarget == rhs.hintedTarget + && lhs.isStockHinted == rhs.isStockHinted + && lhs.isWasteHinted == rhs.isWasteHinted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + && lhs.drawingCardIDs == rhs.drawingCardIDs + && lhs.fanProgress == rhs.fanProgress + } +} + struct TableauRowView: View { /// Event wiring only; never read in body. let session: SolitaireViewModel @@ -642,8 +687,15 @@ extension TableauRowView: Equatable { } struct FoundationView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + /// nil when a variant switch left this index without a pile — the view + /// stays mounted rendering nothing, exactly like the old in-body guard, + /// so the row's layout holds through the transient. + let pile: [Card]? let index: Int + let placeholder: FoundationPlaceholder + let selection: SelectionSnapshot let cardSize: CGSize let isTargeted: Bool let isHintTargeted: Bool @@ -656,43 +708,40 @@ struct FoundationView: View { let dragGesture: (DragOrigin) -> AnyGesture var body: some View { - // Variant switches can shrink the foundations array while this view - // is still mounted; render nothing until the parent row rebuilds. - if viewModel.state.foundations.indices.contains(index) { - let foundation = viewModel.state.foundations[index] + if let foundation = pile { let visibleDepth = min(foundation.count, 4) let startIndex = foundation.count - visibleDepth let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .foundation(let pile) = selection.source { - return pile == index + if case .foundation(let sourcePile) = selection.dragSource { + return sourcePile == index } return false }() let accessibleTopCard: Card? = foundation.last.flatMap { card in - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isDragged = selection.isDragging && selection.isSelected(card) return isDragged || hiddenCardIDs.contains(card.id) ? nil : card } let isAccessibleTopCardSelected = accessibleTopCard.map { - viewModel.isSelected(card: $0) + selection.isSelected($0) } ?? false let highlightZ: Double = 1 ZStack { PilePlaceholderView(cardSize: cardSize) if foundation.isEmpty { - // Canfield foundations start at the dealt base rank, not the Ace. - if viewModel.gameVariant == .canfield { - if let baseRank = CanfieldGameRules.baseRank(in: viewModel.state) { - Text(baseRank.label) - .font(.system(size: cardSize.width * 0.22, weight: .semibold)) - .foregroundStyle(.white.opacity(0.28)) - .allowsHitTesting(false) - } - } else { + switch placeholder { + case .ace: Image(systemName: "a") .font(.system(size: cardSize.width * 0.22, weight: .semibold)) .foregroundStyle(.white.opacity(0.28)) .allowsHitTesting(false) + case .baseRank(let rank): + // Canfield foundations start at the dealt base rank. + Text(rank.label) + .font(.system(size: cardSize.width * 0.22, weight: .semibold)) + .foregroundStyle(.white.opacity(0.28)) + .allowsHitTesting(false) + case .blank: + EmptyView() } } DropHighlightView( @@ -704,11 +753,11 @@ struct FoundationView: View { .zIndex(highlightZ) ForEach(Array(foundation.enumerated().dropFirst(startIndex)), id: \.element.id) { cardIndex, card in let isTopCard = cardIndex == foundation.count - 1 - let isDragged = isTopCard && viewModel.isDragging && viewModel.isSelected(card: card) + let isDragged = isTopCard && selection.isDragging && selection.isSelected(card) let isHidden = hiddenCardIDs.contains(card.id) let cardView = CardView( card: card, - isSelected: isTopCard && viewModel.isSelected(card: card), + isSelected: isTopCard && selection.isSelected(card), cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, @@ -730,7 +779,7 @@ struct FoundationView: View { } } .onTapGesture { - viewModel.handleFoundationTap(index: index) + session.handleFoundationTap(index: index) } .accessibilityElement(children: .ignore) .accessibilityAddTraits(.isButton) @@ -762,6 +811,25 @@ struct FoundationView: View { } } +/// See TableauPileView's Equatable note for the exclusion contract. +extension FoundationView: Equatable { + nonisolated static func == (lhs: FoundationView, rhs: FoundationView) -> Bool { + lhs.session === rhs.session + && lhs.pile == rhs.pile + && lhs.index == rhs.index + && lhs.placeholder == rhs.placeholder + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.isTargeted == rhs.isTargeted + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} + struct TableauPileView: View { /// Event wiring only (taps route through it); never read in body — /// reading the observable session while rendering would re-couple this diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 7b648a7..90f6305 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -553,6 +553,7 @@ struct ContentView: View { // slices (and prune when they're unchanged) instead of reading the // observable session. let selection = viewModel.selectionSnapshot + let topRow = viewModel.topRowSnapshot let hintedTarget: DropTarget? = { guard let destination = viewModel.hintedDestination else { return nil } return dropTarget(for: destination) @@ -583,8 +584,9 @@ struct ContentView: View { .animation(Self.boardSpring, value: viewModel.state) } TopRowView( - viewModel: viewModel, - variant: viewModel.gameVariant, + session: viewModel, + board: topRow, + selection: selection, cardSize: cardSize, columnSpacing: metrics.columnSpacing, wasteFanSpacing: metrics.wasteFanSpacing, @@ -611,7 +613,9 @@ struct ContentView: View { .animation(Self.boardSpring, value: viewModel.state) if viewModel.gameVariant == .pyramid { PyramidBoardView( - viewModel: viewModel, + session: viewModel, + pyramid: viewModel.state.pyramid, + selection: selection, cardSize: cardSize, columnSpacing: metrics.columnSpacing, maxBoardHeight: metrics.tableauMaxHeight, @@ -629,7 +633,9 @@ struct ContentView: View { .animation(Self.boardSpring, value: viewModel.state.pyramid) } else if viewModel.gameVariant == .tripeaks { TriPeaksBoardView( - viewModel: viewModel, + session: viewModel, + triPeaks: viewModel.state.triPeaks, + selection: selection, cardSize: cardSize, columnSpacing: metrics.columnSpacing, maxBoardHeight: metrics.tableauMaxHeight, @@ -644,7 +650,10 @@ struct ContentView: View { .animation(Self.boardSpring, value: viewModel.state.triPeaks) } else if viewModel.gameVariant == .canfield { CanfieldBoardRowView( - viewModel: viewModel, + session: viewModel, + reserve: viewModel.state.reserve, + tableau: viewModel.state.tableau, + selection: selection, cardSize: cardSize, columnSpacing: metrics.columnSpacing, faceDownOffset: metrics.tableauFaceDownOffset, diff --git a/ComputerSolitaire/Views/Shared/StockWasteViews.swift b/ComputerSolitaire/Views/Shared/StockWasteViews.swift index 073ef60..eb948fc 100644 --- a/ComputerSolitaire/Views/Shared/StockWasteViews.swift +++ b/ComputerSolitaire/Views/Shared/StockWasteViews.swift @@ -1,13 +1,16 @@ import SwiftUI -import Observation /// The stock and waste piles shared by every variant that deals from a stock: /// Klondike, Spider (stock only), Pyramid, and TriPeaks compose these into -/// their top rows. Variant behavior stays in the view model (`handleStockTap`, -/// `handleWasteTap`, `canInteractWithStock`, `visibleWasteCards`); these views -/// only render and forward interaction. +/// their top rows. Variant behavior stays in the session (`handleStockTap`, +/// `handleWasteTap`); these views render value slices and forward interaction. struct StockView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let stockCount: Int + let canInteract: Bool + /// Pyramid's remaining waste recycles; nil for every other variant. + let recyclesRemaining: Int? let cardSize: CGSize let isHintTargeted: Bool let hintHighlightOpacity: Double @@ -17,13 +20,13 @@ struct StockView: View { var body: some View { Button { - viewModel.handleStockTap() + session.handleStockTap() } label: { ZStack { PilePlaceholderView(cardSize: cardSize) .allowsHitTesting(false) - if viewModel.state.stock.isEmpty { - if viewModel.canInteractWithStock { + if stockCount == 0 { + if canInteract { Image(systemName: "arrow.counterclockwise") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white.opacity(0.7)) @@ -33,7 +36,7 @@ struct StockView: View { CardBackView(cardSize: cardSize) } if isStockCountVisible { - Text("\(viewModel.state.stock.count)") + Text("\(stockCount)") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white.opacity(0.8)) .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) @@ -57,34 +60,54 @@ struct StockView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(!viewModel.canInteractWithStock) + .disabled(!canInteract) .accessibilityLabel("Stock") .accessibilityValue(stockAccessibilityValue) } private var stockAccessibilityValue: String { - if !viewModel.state.stock.isEmpty { - if viewModel.gameVariant == .pyramid { - let recycles = viewModel.pyramidWasteRecyclesRemaining - return "\(viewModel.state.stock.count) cards. \(recycles) recycles left" + if stockCount > 0 { + if let recyclesRemaining { + return "\(stockCount) cards. \(recyclesRemaining) recycles left" } - return "\(viewModel.state.stock.count) cards" + return "\(stockCount) cards" } - if viewModel.canInteractWithStock { + if canInteract { return "Empty. Activate to recycle the waste pile" } return "Empty" } } +/// Covers every rendered input so unrelated moves prune the stock; the +/// session participates by identity only and the `@AppStorage` count toggle +/// self-invalidates as a DynamicProperty, so it needs no place in `==`. +extension StockView: Equatable { + nonisolated static func == (lhs: StockView, rhs: StockView) -> Bool { + lhs.session === rhs.session + && lhs.stockCount == rhs.stockCount + && lhs.canInteract == rhs.canInteract + && lhs.recyclesRemaining == rhs.recyclesRemaining + && lhs.cardSize == rhs.cardSize + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} + struct WasteView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + /// The fanned cards — the session's `visibleWasteCards()`, precomputed + /// into the top-row snapshot. + let cards: [Card] + let selection: SelectionSnapshot let cardSize: CGSize let fanSpacing: CGFloat var isTargeted: Bool = false - /// Whether tapping the waste does anything. TriPeaks turns this off — its - /// waste top is the match target, never a mover — so the pile neither - /// handles taps nor advertises itself to VoiceOver as a button. + /// Whether tapping the waste does anything. TriPeaks and Golf turn this + /// off — their waste top is the match target, never a mover — so the pile + /// neither handles taps nor advertises itself to VoiceOver as a button. var isTapEnabled: Bool = true let isHintTargeted: Bool let isCardTiltEnabled: Bool @@ -95,26 +118,67 @@ struct WasteView: View { let drawingCardIDs: Set let fanProgress: [UUID: Double] let dragGesture: (DragOrigin) -> AnyGesture + /// The top card's tilt captured at init for the `Equatable` check: an + /// invalid waste drag rerolls the hidden top card's tilt while the pile's + /// contents and selection are unchanged (`beginReturnAnimation`), and + /// nothing else in `==` would see that write — a pruned waste would then + /// visibly re-tilt on reveal. + private let topCardTilt: Double? + + init( + session: SolitaireViewModel, + cards: [Card], + selection: SelectionSnapshot, + cardSize: CGSize, + fanSpacing: CGFloat, + isTargeted: Bool = false, + isTapEnabled: Bool = true, + isHintTargeted: Bool, + isCardTiltEnabled: Bool, + cardTilts: Binding<[UUID: Double]>, + hiddenCardIDs: Set, + hintedCardIDs: Set, + hintWiggleToken: UUID, + drawingCardIDs: Set, + fanProgress: [UUID: Double], + dragGesture: @escaping (DragOrigin) -> AnyGesture + ) { + self.session = session + self.cards = cards + self.selection = selection + self.cardSize = cardSize + self.fanSpacing = fanSpacing + self.isTargeted = isTargeted + self.isTapEnabled = isTapEnabled + self.isHintTargeted = isHintTargeted + self.isCardTiltEnabled = isCardTiltEnabled + self._cardTilts = cardTilts + self.hiddenCardIDs = hiddenCardIDs + self.hintedCardIDs = hintedCardIDs + self.hintWiggleToken = hintWiggleToken + self.drawingCardIDs = drawingCardIDs + self.fanProgress = fanProgress + self.dragGesture = dragGesture + self.topCardTilt = cards.last.flatMap { cardTilts.wrappedValue[$0.id] } + } var body: some View { let isDragSource: Bool = { - guard viewModel.isDragging, let selection = viewModel.selection else { return false } - if case .waste = selection.source { + if case .waste = selection.dragSource { return true } return false }() - let visibleWaste = viewModel.visibleWasteCards() - let accessibleTopCard: Card? = visibleWaste.last.flatMap { card in - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let accessibleTopCard: Card? = cards.last.flatMap { card in + let isDragged = selection.isDragging && selection.isSelected(card) let isUnavailable = isDragged || drawingCardIDs.contains(card.id) || hiddenCardIDs.contains(card.id) return isUnavailable ? nil : card } let isAccessibleTopCardSelected = accessibleTopCard.map { - viewModel.isSelected(card: $0) + selection.isSelected($0) } ?? false - let isSelected = visibleWaste.contains(where: { viewModel.isSelected(card: $0) }) - let fanWidth = fanSpacing * CGFloat(max(0, visibleWaste.count - 1)) + let isSelected = cards.contains(where: { selection.isSelected($0) }) + let fanWidth = fanSpacing * CGFloat(max(0, cards.count - 1)) ZStack(alignment: .topLeading) { PilePlaceholderView(cardSize: cardSize) @@ -127,16 +191,16 @@ struct WasteView: View { ) .zIndex(3) .allowsHitTesting(false) - ForEach(Array(visibleWaste.enumerated()), id: \.element.id) { index, card in - let isTopCard = index == visibleWaste.count - 1 - let isDragged = isTopCard && viewModel.isDragging && viewModel.isSelected(card: card) + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + let isTopCard = index == cards.count - 1 + let isDragged = isTopCard && selection.isDragging && selection.isSelected(card) let isDrawing = drawingCardIDs.contains(card.id) let isHidden = hiddenCardIDs.contains(card.id) let progress = fanProgress[card.id] ?? 1 let xOffset = CGFloat(index) * fanSpacing * progress let cardView = CardView( card: card, - isSelected: viewModel.isSelected(card: card), + isSelected: selection.isSelected(card), cardSize: cardSize, isCardTiltEnabled: isCardTiltEnabled, cardTilts: $cardTilts, @@ -165,7 +229,7 @@ struct WasteView: View { ) .onTapGesture { guard isTapEnabled else { return } - viewModel.handleWasteTap() + session.handleWasteTap() } .zIndex(isDragSource || isSelected ? 10 : 0) .accessibilityElement(children: .ignore) @@ -179,3 +243,27 @@ struct WasteView: View { .accessibilityHidden(accessibleTopCard == nil) } } + +/// Covers every rendered input — including the fan-driving `drawingCardIDs` +/// and `fanProgress`, and the captured `topCardTilt` — so unrelated moves +/// prune the waste. The session participates by identity only; the tilt +/// binding and gesture closure are excluded per CardView's contract. +extension WasteView: Equatable { + nonisolated static func == (lhs: WasteView, rhs: WasteView) -> Bool { + lhs.session === rhs.session + && lhs.cards == rhs.cards + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.fanSpacing == rhs.fanSpacing + && lhs.isTargeted == rhs.isTargeted + && lhs.isTapEnabled == rhs.isTapEnabled + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + && lhs.drawingCardIDs == rhs.drawingCardIDs + && lhs.fanProgress == rhs.fanProgress + && lhs.topCardTilt == rhs.topCardTilt + } +} diff --git a/ComputerSolitaire/Views/Shared/TableauStockView.swift b/ComputerSolitaire/Views/Shared/TableauStockView.swift index 02485dd..59d277a 100644 --- a/ComputerSolitaire/Views/Shared/TableauStockView.swift +++ b/ComputerSolitaire/Views/Shared/TableauStockView.swift @@ -1,10 +1,11 @@ import SwiftUI -import Observation /// The stock for the variants that deal it directly onto the tableau (Spider, /// Scorpion): a tap deals, there is no waste, and an empty stock is inert. struct TableauStockView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let stockCount: Int let cardSize: CGSize let isHintTargeted: Bool let hintHighlightOpacity: Double @@ -16,15 +17,15 @@ struct TableauStockView: View { var body: some View { Button { - viewModel.handleStockTap() + session.handleStockTap() } label: { ZStack { PilePlaceholderView(cardSize: cardSize) .allowsHitTesting(false) - if !viewModel.state.stock.isEmpty { + if stockCount > 0 { CardBackView(cardSize: cardSize) if isStockCountVisible { - Text("\(viewModel.state.stock.count)") + Text("\(stockCount)") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white.opacity(0.8)) .offset(x: cardSize.width * 0.28, y: cardSize.height * 0.38) @@ -49,13 +50,26 @@ struct TableauStockView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(viewModel.state.stock.isEmpty) + .disabled(stockCount == 0) .accessibilityLabel("Stock") .accessibilityValue(stockAccessibilityValue) } private var stockAccessibilityValue: String { - guard !viewModel.state.stock.isEmpty else { return "Empty" } - return "\(viewModel.state.stock.count) cards. \(dealDescription)" + guard stockCount > 0 else { return "Empty" } + return "\(stockCount) cards. \(dealDescription)" + } +} + +/// See StockView's Equatable note; the `@AppStorage` toggle self-invalidates. +extension TableauStockView: Equatable { + nonisolated static func == (lhs: TableauStockView, rhs: TableauStockView) -> Bool { + lhs.session === rhs.session + && lhs.stockCount == rhs.stockCount + && lhs.cardSize == rhs.cardSize + && lhs.isHintTargeted == rhs.isHintTargeted + && lhs.hintHighlightOpacity == rhs.hintHighlightOpacity + && lhs.hintWiggleToken == rhs.hintWiggleToken + && lhs.dealDescription == rhs.dealDescription } } diff --git a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift index 2f23bf8..24fefb2 100644 --- a/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift +++ b/ComputerSolitaire/Views/Spider/SpiderTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct SpiderTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let isStockHinted: Bool @@ -17,7 +19,8 @@ struct SpiderTopRowView: View { // Stock on the left like Klondike's, one clear column, then the // eight banked-run piles aligned over tableau columns 3-10. TableauStockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -34,7 +37,7 @@ struct SpiderTopRowView: View { // during a game switch this row can re-evaluate against the // incoming variant's four-foundation state before the board // replaces it. - ForEach(Array(viewModel.state.foundations.enumerated()), id: \.offset) { index, pile in + ForEach(Array(board.foundations.enumerated()), id: \.offset) { index, pile in CompletedRunPileView( pile: pile, index: index, diff --git a/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift b/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift index aedbe46..4503e66 100644 --- a/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift +++ b/ComputerSolitaire/Views/TriPeaks/TriPeaksBoardView.swift @@ -1,12 +1,14 @@ import SwiftUI -import Observation /// The 28-slot three-peak layout replaces the shared tableau row for the /// TriPeaks variant: three face-down rows overlapping down to the face-up /// ten-card base row. Slots are never drop targets — cards play from here onto /// the waste — so the board registers no drop frames. struct TriPeaksBoardView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let triPeaks: [Card?] + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let maxBoardHeight: CGFloat @@ -28,7 +30,7 @@ struct TriPeaksBoardView: View { // 0..<28: during a game switch this view can re-evaluate against // the incoming variant's empty triPeaks array before the board // replaces it. - ForEach(Array(viewModel.state.triPeaks.enumerated()), id: \.offset) { index, slot in + ForEach(Array(triPeaks.enumerated()), id: \.offset) { index, slot in if let card = slot { peakCard(card, at: index, rowOverlap: rowOverlap) } @@ -60,10 +62,10 @@ struct TriPeaksBoardView: View { private func peakCard(_ card: Card, at index: Int, rowOverlap: CGFloat) -> some View { let row = TriPeaksGeometry.row(of: index) let offset = slotOffset(for: index, rowOverlap: rowOverlap) - let isDragged = viewModel.isDragging && viewModel.isSelected(card: card) + let isDragged = selection.isDragging && selection.isSelected(card) let isHidden = hiddenCardIDs.contains(card.id) - let isSelected = viewModel.isSelected(card: card) - let isUncovered = TriPeaksGeometry.isUncovered(index, in: viewModel.state.triPeaks) + let isSelected = selection.isSelected(card) + let isUncovered = TriPeaksGeometry.isUncovered(index, in: triPeaks) let isAccessibilityElement = card.isFaceUp && isUncovered && !isDragged && !isHidden CardView( @@ -80,7 +82,7 @@ struct TriPeaksBoardView: View { .zIndex(isDragged ? 40 + Double(row) : Double(row)) .allowsHitTesting(!isHidden) .onTapGesture { - viewModel.handleTriPeaksTap(index: index) + session.handleTriPeaksTap(index: index) } .gesture(dragGesture(.triPeaks(index))) .accessibilityHidden(!isAccessibilityElement) @@ -90,3 +92,19 @@ struct TriPeaksBoardView: View { .cardFramePreference(card.id, xOffset: offset.width, yOffset: offset.height) } } + +/// See TableauPileView's Equatable note for the exclusion contract. +extension TriPeaksBoardView: Equatable { + nonisolated static func == (lhs: TriPeaksBoardView, rhs: TriPeaksBoardView) -> Bool { + lhs.session === rhs.session + && lhs.triPeaks == rhs.triPeaks + && lhs.selection == rhs.selection + && lhs.cardSize == rhs.cardSize + && lhs.columnSpacing == rhs.columnSpacing + && lhs.maxBoardHeight == rhs.maxBoardHeight + && lhs.isCardTiltEnabled == rhs.isCardTiltEnabled + && lhs.hiddenCardIDs == rhs.hiddenCardIDs + && lhs.hintedCardIDs == rhs.hintedCardIDs + && lhs.hintWiggleToken == rhs.hintWiggleToken + } +} diff --git a/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift b/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift index 8e2a114..802d8e6 100644 --- a/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift +++ b/ComputerSolitaire/Views/TriPeaks/TriPeaksTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct TriPeaksTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let activeTarget: DropTarget? @@ -22,7 +24,10 @@ struct TriPeaksTopRowView: View { var body: some View { HStack(alignment: .top, spacing: columnSpacing) { StockView( - viewModel: viewModel, + session: session, + stockCount: board.stockCount, + canInteract: board.canInteractWithStock, + recyclesRemaining: board.stockRecyclesRemaining, cardSize: cardSize, isHintTargeted: isStockHinted, hintHighlightOpacity: hintHighlightOpacity, @@ -31,7 +36,9 @@ struct TriPeaksTopRowView: View { .frame(width: cardSize.width, alignment: .leading) WasteView( - viewModel: viewModel, + session: session, + cards: board.visibleWasteCards, + selection: selection, cardSize: cardSize, fanSpacing: 0, isTargeted: activeTarget == .waste, diff --git a/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift b/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift index 2896340..ecc09aa 100644 --- a/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift +++ b/ComputerSolitaire/Views/Yukon/YukonTopRowView.swift @@ -1,8 +1,10 @@ import SwiftUI -import Observation struct YukonTopRowView: View { - @Bindable var viewModel: SolitaireViewModel + /// Event wiring only; never read in body. + let session: SolitaireViewModel + let board: TopRowSnapshot + let selection: SelectionSnapshot let cardSize: CGSize let columnSpacing: CGFloat let activeTarget: DropTarget? @@ -27,8 +29,11 @@ struct YukonTopRowView: View { ForEach(0..<4, id: \.self) { index in FoundationView( - viewModel: viewModel, + session: session, + pile: board.foundations.indices.contains(index) ? board.foundations[index] : nil, index: index, + placeholder: board.foundationPlaceholder, + selection: selection, cardSize: cardSize, isTargeted: activeTarget == .foundation(index), isHintTargeted: hintedTarget == .foundation(index), diff --git a/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift b/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift index d8d67b7..51b46f1 100644 --- a/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift +++ b/ComputerSolitaireTests/Shared/BoardViewEquatableTests.swift @@ -88,4 +88,90 @@ final class BoardViewEquatableTests: XCTestCase { makeTableauPileView(session: otherSession, pile: pile, selection: selection, hintWiggleToken: token) ) } + + func testWasteViewPrunesOnEqualInputsButSeesTopCardTiltRerolls() { + let session = SolitaireViewModel(variant: .klondike) + let cards = [TestCards.make(.clubs, .four), TestCards.make(.diamonds, .nine)] + let topID = cards[1].id + let hintToken = UUID() + // Fresh closures/bindings per call; only `tilts` varies. + func makeWasteView(tilts: [UUID: Double]) -> WasteView { + WasteView( + session: session, + cards: cards, + selection: session.selectionSnapshot, + cardSize: CGSize(width: 50, height: 70), + fanSpacing: 14, + isHintTargeted: false, + isCardTiltEnabled: true, + cardTilts: .constant(tilts), + hiddenCardIDs: [], + hintedCardIDs: [], + hintWiggleToken: hintToken, + drawingCardIDs: [], + fanProgress: [:], + dragGesture: anyDragGesture() + ) + } + + let base = makeWasteView(tilts: [topID: 1.2]) + XCTAssertEqual(base, makeWasteView(tilts: [topID: 1.2])) + + // The waste-return tilt reroll mutates only the tilt dictionary; the + // captured topCardTilt must surface it or the pile would prune past + // the write and visibly re-tilt on reveal. + XCTAssertNotEqual(base, makeWasteView(tilts: [topID: -1.7])) + + // A tilt write for a non-top card cannot affect rendering here and + // must not defeat pruning. + XCTAssertEqual(base, makeWasteView(tilts: [topID: 1.2, cards[0].id: 0.4])) + } + + private func makeFoundationView( + session: SolitaireViewModel, + pile: [Card]?, + placeholder: FoundationPlaceholder, + hintWiggleToken: UUID + ) -> FoundationView { + FoundationView( + session: session, + pile: pile, + index: 0, + placeholder: placeholder, + selection: session.selectionSnapshot, + cardSize: CGSize(width: 50, height: 70), + isTargeted: false, + isHintTargeted: false, + hintHighlightOpacity: 0, + isCardTiltEnabled: true, + cardTilts: .constant([:]), + hiddenCardIDs: [], + hintedCardIDs: [], + hintWiggleToken: hintWiggleToken, + dragGesture: anyDragGesture() + ) + } + + func testFoundationViewEquatableCoversPileAndPlaceholder() { + let session = SolitaireViewModel(variant: .klondike) + let pile = [TestCards.make(.spades, .ace)] + let token = UUID() + let base = makeFoundationView(session: session, pile: pile, placeholder: .ace, hintWiggleToken: token) + XCTAssertEqual( + base, + makeFoundationView(session: session, pile: pile, placeholder: .ace, hintWiggleToken: token) + ) + XCTAssertNotEqual( + base, + makeFoundationView(session: session, pile: pile + [TestCards.make(.spades, .two)], placeholder: .ace, hintWiggleToken: token) + ) + XCTAssertNotEqual( + base, + makeFoundationView(session: session, pile: nil, placeholder: .ace, hintWiggleToken: token) + ) + XCTAssertNotEqual( + base, + makeFoundationView(session: session, pile: pile, placeholder: .baseRank(.five), hintWiggleToken: token) + ) + } } From 605c64f0a0c2ec4cf03e5002b9e605565f765d25 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 21:50:38 -0700 Subject: [PATCH 4/4] document the tilt invalidation contract on the equality gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex review of #72 reasonably worried that toprowview's pruning could swallow the waste-return tilt reroll before wasteview's topcardtilt check runs. it cannot: cardview reads the tilt binding in its own body, and binding reads register direct per-reader dependencies that survive ancestor pruning — verified on the exact repro. record that contract where the next reader will look for it. --- ComputerSolitaire/Views/Shared/BoardViews.swift | 6 ++++++ ComputerSolitaire/Views/Shared/StockWasteViews.swift | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/BoardViews.swift b/ComputerSolitaire/Views/Shared/BoardViews.swift index 26f28b9..ab0ecac 100644 --- a/ComputerSolitaire/Views/Shared/BoardViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardViews.swift @@ -589,6 +589,12 @@ struct TopRowView: View { /// Prunes the whole top row when nothing it renders changed; see /// TableauPileView's Equatable note for the exclusion contract. +/// +/// Tilt writes deliberately have no place here: `cardTilts` reaches each +/// CardView as a binding it reads in its own body, so a tilt write (the +/// waste-return reroll included) invalidates the affected CardView directly — +/// pruning any ancestor, this row included, cannot stale it. That direct +/// dependency is the contract a CardView refactor must preserve. extension TopRowView: Equatable { nonisolated static func == (lhs: TopRowView, rhs: TopRowView) -> Bool { lhs.session === rhs.session diff --git a/ComputerSolitaire/Views/Shared/StockWasteViews.swift b/ComputerSolitaire/Views/Shared/StockWasteViews.swift index eb948fc..bddcc6d 100644 --- a/ComputerSolitaire/Views/Shared/StockWasteViews.swift +++ b/ComputerSolitaire/Views/Shared/StockWasteViews.swift @@ -121,8 +121,11 @@ struct WasteView: View { /// The top card's tilt captured at init for the `Equatable` check: an /// invalid waste drag rerolls the hidden top card's tilt while the pile's /// contents and selection are unchanged (`beginReturnAnimation`), and - /// nothing else in `==` would see that write — a pruned waste would then - /// visibly re-tilt on reveal. + /// nothing else in `==` would see that write. Live propagation of the + /// reroll is carried by CardView's own body read of the tilt binding — a + /// direct dependency that survives ancestor pruning — so this capture is + /// defense in depth: it keeps a re-created waste honest about what its + /// cards were built with. private let topCardTilt: Double? init(