From c62817fa1ce27eb2beced767233f4b21257137ca Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 31 Aug 2026 14:24:30 -0700 Subject: [PATCH 1/2] stabilize cold-launch deal animation --- .../Animation/DealAnimationCoordinator.swift | 20 +- .../Views/Shared/ContentView.swift | 199 ++++++++++-------- .../Views/Shared/StockWasteViews.swift | 7 +- .../Views/Shared/TableauStockView.swift | 7 +- .../Shared/NewGameDealAnimationTests.swift | 50 +++-- 5 files changed, 178 insertions(+), 105 deletions(-) diff --git a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift index 728f24e..86ec68c 100644 --- a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift @@ -7,6 +7,15 @@ import Foundation /// of `UndoAnimationCoordinator`'s `.dealTableauRow` flight, which flies the /// same cards back to the same stock anchors. enum DealAnimationCoordinator { + /// A concrete, already-resolved source for a fresh-board deal. Callers + /// choose the semantic source first, then wait for its geometry; a missing + /// stock frame can never silently turn a stock deal into an above-board + /// deal. + enum NewGameDealSource { + case stock(frame: CGRect) + case aboveBoard(boardSize: CGSize) + } + struct Plan { let cards: [DrawAnimationCard] let cardIDs: Set @@ -108,8 +117,7 @@ enum DealAnimationCoordinator { static func makeNewGameDealPlan( dealtCards: [Card], cardFrames: [UUID: CGRect], - stockFrame: CGRect, - boardSize: CGSize + source: NewGameDealSource ) -> Plan? { let flying = dealtCards.compactMap { card -> (card: Card, frame: CGRect)? in guard let frame = cardFrames[card.id] else { return nil } @@ -118,10 +126,12 @@ enum DealAnimationCoordinator { guard !flying.isEmpty else { return nil } let start: CGPoint - if stockFrame != .zero { + switch source { + case .stock(let stockFrame): + guard !stockFrame.isEmpty else { return nil } start = CGPoint(x: stockFrame.midX, y: stockFrame.midY) - } else { - guard boardSize != .zero else { return nil } + case .aboveBoard(let boardSize): + guard boardSize.width > 0, boardSize.height > 0 else { return nil } let cardHeight = flying[0].frame.height start = CGPoint(x: boardSize.width * 0.5, y: -cardHeight) } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 6a70909..7348639 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -12,20 +12,27 @@ struct DropTargetFrameKey: PreferenceKey { } } -// Single-frame keys must ignore sizeless candidates: sibling subtrees that -// never set the key still run reduce, and a last-wins reducer lets them erase -// the real frame (the draw animation then never runs). Rejecting only `.zero` -// is not enough — a subtree measured before layout reports a rect with a real -// origin but no size, e.g. `(148, 0, 0 x 0)`, which clears that test and -// clobbers the live frame. Size is what makes a candidate meaningful here. -struct StockFrameKey: PreferenceKey { - static var defaultValue: CGRect = .zero +/// One layout-pass snapshot for fresh-deal source and destination frames. +/// Keeping them in one preference value prevents a cold layout from combining +/// current card destinations with a missing or stale stock anchor from another +/// pass. +struct BoardFramePreferences: Equatable { + var stockFrame: CGRect = .zero + var cardFrames: [UUID: CGRect] = [:] +} - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { +// Sizeless candidates are never meaningful geometry. A subtree measured +// before layout can report a real origin with a zero size, so checking only +// against `.zero` would still let it clobber the live stock frame. +struct BoardFrameKey: PreferenceKey { + static var defaultValue = BoardFramePreferences() + + static func reduce(value: inout BoardFramePreferences, nextValue: () -> BoardFramePreferences) { let next = nextValue() - if !next.isEmpty { - value = next + if !next.stockFrame.isEmpty { + value.stockFrame = next.stockFrame } + value.cardFrames.merge(next.cardFrames, uniquingKeysWith: { _, new in new }) } } @@ -40,14 +47,6 @@ struct WasteFrameKey: PreferenceKey { } } -struct CardFrameKey: PreferenceKey { - static var defaultValue: [UUID: CGRect] = [:] - - static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { _, new in new }) - } -} - struct CardFramePreference: ViewModifier { let cardID: UUID let xOffset: CGFloat @@ -64,7 +63,10 @@ struct CardFramePreference: ViewModifier { height: frame.height ) Color.clear - .preference(key: CardFrameKey.self, value: [cardID: adjustedFrame]) + .preference( + key: BoardFrameKey.self, + value: BoardFramePreferences(cardFrames: [cardID: adjustedFrame]) + ) } ) } @@ -77,6 +79,13 @@ extension View { } struct ContentView: View { + private struct PendingNewGameDeal { + let eventID: UUID + let cards: [Card] + let startsFromStock: Bool + let token: UUID + } + @Environment(\.modelContext) private var modelContext @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var isReduceMotionEnabled @@ -120,6 +129,7 @@ struct ContentView: View { @State private var dealAnimationCards: [DrawAnimationCard] = [] @State private var dealingCardIDs: Set = [] @State private var dealAnimationToken = UUID() + @State private var pendingNewGameDeal: PendingNewGameDeal? /// True while the active deal flight is a fresh-board deal, whose queued /// cards hide until takeoff; stock deals keep their queues visible. @State private var dealFlightHidesQueuedCards = false @@ -803,23 +813,26 @@ struct ContentView: View { dropFrames = frames refreshLoadedWinPresentationIfNeeded() } - .onPreferenceChange(StockFrameKey.self) { frame in - stockFrame = frame - } .onPreferenceChange(WasteFrameKey.self) { frame in wasteFrame = frame } - .onPreferenceChange(CardFrameKey.self) { frames in - if shouldUpdateCardFrames(with: frames) { - cardFrames = frames + .onPreferenceChange(BoardFrameKey.self) { frames in + if !framesApproximatelyEqual(stockFrame, frames.stockFrame) { + stockFrame = frames.stockFrame + } + if shouldUpdateCardFrames(with: frames.cardFrames) { + cardFrames = frames.cardFrames } + resolvePendingNewGameDealIfReady() } .onAppear { boardViewportSize = geometry.size + resolvePendingNewGameDealIfReady() refreshLoadedWinPresentationIfNeeded() } .onChange(of: geometry.size) { _, newSize in boardViewportSize = newSize + resolvePendingNewGameDealIfReady() refreshLoadedWinPresentationIfNeeded() } .onChange(of: viewModel.isWin) { _, isWin in @@ -900,15 +913,6 @@ struct ContentView: View { guard let event else { return } startDealAnimation(for: event.dealtCardIDs) } - .onChange(of: viewModel.latestBoardDealEvent) { _, event in - // A fresh board deals itself in from the stock: new game, - // redeal, Golf's next hole, or a game switch that found nothing - // to restore. Like the tableau deal above, the session publishes - // an explicit event — restores never set it — so a hydrated - // board can never replay a deal that already happened. - guard event != nil else { return } - startNewGameDealAnimation() - } .onChange(of: viewModel.movesCount) { _, movesCount in // The board stays live during a deal flight, and a move that lands // mid-flight can relocate a card the overlay is still flying toward @@ -1171,18 +1175,20 @@ struct ContentView: View { transaction.disablesAnimations = true withTransaction(transaction) { mutation() + guard let event = viewModel.latestBoardDealEvent, + event != eventBeforeMutation else { return } + // Redeal reuses card IDs, so no geometry from the outgoing board + // may satisfy the fresh deal. Both frame sets republish together + // from the new board through BoardFrameKey. + stockFrame = .zero + cardFrames = [:] + prepareNewGameDealAnimation(for: event) } // Only wipe when the mutation actually dealt a fresh board: Golf's // final-hole advance completes the match and deliberately stays on // the finished board, and sweeping copies off a board that never // leaves would read as a ghost board peeling away. guard viewModel.latestBoardDealEvent != eventBeforeMutation else { return } - // Drop the outgoing layout's frames: Redeal reuses the outgoing - // game's card IDs, so stale entries would satisfy the deal flight's - // readiness check and land cards at their played-out positions. - // The wipe is unaffected — it plans from the snapshot captured - // above — and the fresh board republishes within a frame. - cardFrames = [:] startBoardWipe(for: wipedCards, frames: wipeFrames) } @@ -1210,6 +1216,7 @@ struct ContentView: View { DispatchQueue.main.asyncAfter(deadline: .now() + total) { guard wipeAnimationToken == token else { return } wipeAnimationCards = [] + resolvePendingNewGameDealIfReady() } } @@ -1253,7 +1260,12 @@ struct ContentView: View { isSettlingHydratedGame = true isScreenshotSession = false let payload = GamePersistence.load(mode: mode, from: modelContext) - viewModel.activateGame(mode, restoringFrom: payload) + let restored = viewModel.activateGame(mode, restoringFrom: payload) + if !restored, let event = viewModel.latestBoardDealEvent { + stockFrame = .zero + cardFrames = [:] + prepareNewGameDealAnimation(for: event) + } rememberSelectedGame() reconcileTimeScoringPause() winCelebration.syncForLoadedGame( @@ -1632,16 +1644,17 @@ struct ContentView: View { } private func cancelDealAnimation() { + pendingNewGameDeal = nil dealAnimationCards = [] dealingCardIDs = [] dealFlightHidesQueuedCards = false dealAnimationToken = UUID() } - /// Flies a fresh board's cards in from the stock, sharing the deal - /// flight's overlay, hiding, and cancellation rules — a move or a game - /// switch mid-deal lands the flight the same way it lands a stock deal. - private func startNewGameDealAnimation() { + /// Arms a fresh deal before its board is allowed to render. Real cards are + /// hidden immediately; the flight begins only after one current layout + /// snapshot contains every destination and the required source anchor. + private func prepareNewGameDealAnimation(for event: SolitaireViewModel.BoardDealEvent) { cancelDealAnimation() let dealtCards = DealAnimationCoordinator.newGameDealSequence(in: viewModel.state) guard !dealtCards.isEmpty else { return } @@ -1653,48 +1666,61 @@ struct ContentView: View { dealAnimationMovesCount = viewModel.movesCount let token = UUID() dealAnimationToken = token - DispatchQueue.main.async { - resolveDealFlight( - token: token, - attemptsRemaining: 75, - retryInterval: 0.02, - isReady: { - // A fresh board's landing frames arrive a beat after the - // state swap (dealFreshBoard drops the stale set first — - // New Game mints new card IDs, but Redeal reuses the - // outgoing game's, whose leftover frames would otherwise - // pass this check) — and whole seconds later when the - // deal rides a game switch or first launch, where the - // board tree is still building. Patience here is cheap: - // attempts burn only while frames are missing, and any - // interaction lands the flight through the usual cancel - // paths. The deal also waits for the wipe sweep to - // finish clearing the old board off the felt — the - // dealer doesn't deal onto a messy table. - wipeAnimationCards.isEmpty - && dealtCards.allSatisfy { cardFrames[$0.id] != nil } - }, - makePlan: { - DealAnimationCoordinator.makeNewGameDealPlan( - dealtCards: dealtCards, - cardFrames: cardFrames, - stockFrame: stockFrame, - boardSize: boardViewportSize - ) - }, - onTakeoff: { - SoundManager.shared.play(.cardDrawFromStock) - HapticManager.shared.play(.stockDraw) - } - ) + pendingNewGameDeal = PendingNewGameDeal( + eventID: event.id, + cards: dealtCards, + startsFromStock: !viewModel.state.stock.isEmpty, + token: token + ) + resolvePendingNewGameDealIfReady() + } + + /// Geometry publication drives this transition. There is no retry clock: + /// whichever required frame arrives last completes the coherent snapshot + /// and starts the flight exactly once. + private func resolvePendingNewGameDealIfReady() { + guard let pending = pendingNewGameDeal, + viewModel.latestBoardDealEvent?.id == pending.eventID, + dealAnimationToken == pending.token, + wipeAnimationCards.isEmpty, + pending.cards.allSatisfy({ cardFrames[$0.id] != nil }) else { return } + + let source: DealAnimationCoordinator.NewGameDealSource + if pending.startsFromStock { + guard !stockFrame.isEmpty else { return } + source = .stock(frame: stockFrame) + } else { + guard boardViewportSize.width > 0, boardViewportSize.height > 0 else { return } + source = .aboveBoard(boardSize: boardViewportSize) + } + + guard let plan = DealAnimationCoordinator.makeNewGameDealPlan( + dealtCards: pending.cards, + cardFrames: cardFrames, + source: source + ) else { + assertionFailure("Complete fresh-deal geometry must produce a flight plan") + cancelDealAnimation() + return + } + + pendingNewGameDeal = nil + SoundManager.shared.play(.cardDrawFromStock) + HapticManager.shared.play(.stockDraw) + dealAnimationCards = plan.cards + dealingCardIDs = plan.cardIDs + + let total = motion.duration(plan.maxDelay + plan.travelDuration + plan.settleDuration) + DispatchQueue.main.asyncAfter(deadline: .now() + total) { + guard dealAnimationToken == pending.token else { return } + dealAnimationCards = [] + dealingCardIDs = [] } } - /// One resolver drives both deal flights (the stock deal and the fresh - /// board): poll until the flight's readiness condition holds, build its - /// plan or land the flight, then tear the overlay down once the last - /// card has settled — token-gated throughout, so a superseding flight - /// or any cancel path orphans the loop harmlessly. + /// Stock-onto-tableau deals can bank a card immediately and therefore use + /// their existing bounded resolver; fresh-board deals require complete + /// geometry and are driven by preference publication above. private func resolveDealFlight( token: UUID, attemptsRemaining: Int, @@ -2142,6 +2168,9 @@ struct ContentView: View { } else { winCelebration.reset(to: .idle) viewModel.newGame(mode: launchMode) + if let event = viewModel.latestBoardDealEvent { + prepareNewGameDealAnimation(for: event) + } rememberSelectedGame() persistGameNow() } diff --git a/ComputerSolitaire/Views/Shared/StockWasteViews.swift b/ComputerSolitaire/Views/Shared/StockWasteViews.swift index bddcc6d..7b6e07f 100644 --- a/ComputerSolitaire/Views/Shared/StockWasteViews.swift +++ b/ComputerSolitaire/Views/Shared/StockWasteViews.swift @@ -54,7 +54,12 @@ struct StockView: View { .background( GeometryReader { proxy in Color.clear - .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + .preference( + key: BoardFrameKey.self, + value: BoardFramePreferences( + stockFrame: proxy.frame(in: .named("board")) + ) + ) } ) .contentShape(Rectangle()) diff --git a/ComputerSolitaire/Views/Shared/TableauStockView.swift b/ComputerSolitaire/Views/Shared/TableauStockView.swift index 59d277a..765f39f 100644 --- a/ComputerSolitaire/Views/Shared/TableauStockView.swift +++ b/ComputerSolitaire/Views/Shared/TableauStockView.swift @@ -44,7 +44,12 @@ struct TableauStockView: View { .background( GeometryReader { proxy in Color.clear - .preference(key: StockFrameKey.self, value: proxy.frame(in: .named("board"))) + .preference( + key: BoardFrameKey.self, + value: BoardFramePreferences( + stockFrame: proxy.frame(in: .named("board")) + ) + ) } ) .contentShape(Rectangle()) diff --git a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift index e966fff..4b7c5d5 100644 --- a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift +++ b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift @@ -81,8 +81,7 @@ final class NewGameDealAnimationTests: XCTestCase { let plan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: sequence), - stockFrame: stockFrame, - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: stockFrame) ) XCTAssertEqual(plan?.cards.count, 28) @@ -94,15 +93,14 @@ final class NewGameDealAnimationTests: XCTestCase { // Verifies the stockless variants (FreeCell, Yukon) deal from an // invisible deck just above the board's top edge. - func testPlanFallsBackToAboveBoardWhenStockless() { + func testPlanUsesAboveBoardSourceWhenStockless() { let state = GameStateFixtures.seededFreeCellDeal(seed: 8) let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) let plan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: sequence), - stockFrame: .zero, - boardSize: CGSize(width: 800, height: 600) + source: .aboveBoard(boardSize: CGSize(width: 800, height: 600)) ) XCTAssertEqual(plan?.cards.first?.start, CGPoint(x: 400, y: -112)) @@ -116,12 +114,41 @@ final class NewGameDealAnimationTests: XCTestCase { DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: sequence), - stockFrame: .zero, - boardSize: .zero + source: .aboveBoard(boardSize: .zero) + ) + ) + XCTAssertNil( + DealAnimationCoordinator.makeNewGameDealPlan( + dealtCards: sequence, + cardFrames: frames(for: sequence), + source: .stock(frame: .zero) ) ) } + // The launch planner receives one coherent layout snapshot: card frames + // cannot arrive without the stock frame from the same reduction pass, and + // a sizeless navigation/layout candidate cannot erase the real anchor. + func testBoardFramePreferencesCombineStockAndCardsAtomically() { + let stockFrame = CGRect(x: 20, y: 30, width: 80, height: 112) + let card = GameStateFixtures.seededKlondikeDeal(seed: 15).tableau[0][0] + let cardFrame = CGRect(x: 120, y: 200, width: 80, height: 112) + var snapshot = BoardFrameKey.defaultValue + + BoardFrameKey.reduce(value: &snapshot) { + BoardFramePreferences(stockFrame: stockFrame) + } + BoardFrameKey.reduce(value: &snapshot) { + BoardFramePreferences(cardFrames: [card.id: cardFrame]) + } + BoardFrameKey.reduce(value: &snapshot) { + BoardFramePreferences(stockFrame: CGRect(x: 400, y: 0, width: 0, height: 0)) + } + + XCTAssertEqual(snapshot.stockFrame, stockFrame) + XCTAssertEqual(snapshot.cardFrames, [card.id: cardFrame]) + } + func testPlanSkipsCardsWithoutLandingFrames() { let state = GameStateFixtures.seededKlondikeDeal(seed: 10) let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) @@ -130,8 +157,7 @@ final class NewGameDealAnimationTests: XCTestCase { let plan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: sequence, cardFrames: frames(for: framedCards), - stockFrame: CGRect(x: 0, y: 0, width: 80, height: 112), - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: CGRect(x: 0, y: 0, width: 80, height: 112)) ) XCTAssertEqual(plan?.cards.count, sequence.count - 3) @@ -147,8 +173,7 @@ final class NewGameDealAnimationTests: XCTestCase { let spiderPlan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: spiderSequence, cardFrames: frames(for: spiderSequence), - stockFrame: CGRect(x: 0, y: 0, width: 80, height: 112), - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: CGRect(x: 0, y: 0, width: 80, height: 112)) ) XCTAssertEqual(spiderSequence.count, 54) XCTAssertEqual( @@ -162,8 +187,7 @@ final class NewGameDealAnimationTests: XCTestCase { let canfieldPlan = DealAnimationCoordinator.makeNewGameDealPlan( dealtCards: canfieldSequence, cardFrames: frames(for: canfieldSequence), - stockFrame: CGRect(x: 0, y: 0, width: 80, height: 112), - boardSize: CGSize(width: 800, height: 600) + source: .stock(frame: CGRect(x: 0, y: 0, width: 80, height: 112)) ) XCTAssertEqual( canfieldPlan?.maxDelay ?? .infinity, From 54776a819b53f84e0d68d5e4d0defaa4684c8050 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 31 Aug 2026 15:14:28 -0700 Subject: [PATCH 2/2] tag board geometry by deal event --- .../Views/Shared/ContentView.swift | 53 +++++++++++++++---- .../Shared/NewGameDealAnimationTests.swift | 33 ++++++++++-- 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 7348639..a0bf059 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -17,6 +17,7 @@ struct DropTargetFrameKey: PreferenceKey { /// current card destinations with a missing or stale stock anchor from another /// pass. struct BoardFramePreferences: Equatable { + var dealEventID: UUID? var stockFrame: CGRect = .zero var cardFrames: [UUID: CGRect] = [:] } @@ -29,6 +30,9 @@ struct BoardFrameKey: PreferenceKey { static func reduce(value: inout BoardFramePreferences, nextValue: () -> BoardFramePreferences) { let next = nextValue() + if let dealEventID = next.dealEventID { + value.dealEventID = dealEventID + } if !next.stockFrame.isEmpty { value.stockFrame = next.stockFrame } @@ -115,6 +119,7 @@ struct ContentView: View { @State private var wasteReturnAnchorCardID: UUID? @State private var wasteReturnAnchorFrame: CGRect? @State private var cardFrames: [UUID: CGRect] = [:] + @State private var boardFrameDealEventID: UUID? @State private var cardTilts: [UUID: Double] = [:] #if os(iOS) @State private var isShowingSettings = false @@ -603,6 +608,7 @@ struct ContentView: View { @ViewBuilder private func boardRoot(for geometry: GeometryProxy) -> some View { let boardColumnCount = max(viewModel.state.tableau.count, viewModel.gameVariant.boardColumnCount) + let boardDealEventID = viewModel.latestBoardDealEvent?.id #if os(iOS) let metrics = Layout.metrics( for: geometry.size, @@ -806,6 +812,12 @@ struct ContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .coordinateSpace(name: "board") + .transformPreference(BoardFrameKey.self) { preferences in + // Geometry belongs to a specific fresh-board event. Including the + // event in the Equatable preference guarantees that an unchanged + // redeal still publishes a new, causally tagged snapshot. + preferences.dealEventID = boardDealEventID + } .sensoryFeedback(trigger: hapticFeedback.trigger) { hapticFeedback.feedbackForTrigger } @@ -817,13 +829,7 @@ struct ContentView: View { wasteFrame = frame } .onPreferenceChange(BoardFrameKey.self) { frames in - if !framesApproximatelyEqual(stockFrame, frames.stockFrame) { - stockFrame = frames.stockFrame - } - if shouldUpdateCardFrames(with: frames.cardFrames) { - cardFrames = frames.cardFrames - } - resolvePendingNewGameDealIfReady() + acceptBoardFramePreferences(frames) } .onAppear { boardViewportSize = geometry.size @@ -1180,8 +1186,7 @@ struct ContentView: View { // Redeal reuses card IDs, so no geometry from the outgoing board // may satisfy the fresh deal. Both frame sets republish together // from the new board through BoardFrameKey. - stockFrame = .zero - cardFrames = [:] + invalidateBoardFramePreferences() prepareNewGameDealAnimation(for: event) } // Only wipe when the mutation actually dealt a fresh board: Golf's @@ -1262,8 +1267,7 @@ struct ContentView: View { let payload = GamePersistence.load(mode: mode, from: modelContext) let restored = viewModel.activateGame(mode, restoringFrom: payload) if !restored, let event = viewModel.latestBoardDealEvent { - stockFrame = .zero - cardFrames = [:] + invalidateBoardFramePreferences() prepareNewGameDealAnimation(for: event) } rememberSelectedGame() @@ -1651,6 +1655,32 @@ struct ContentView: View { dealAnimationToken = UUID() } + private func invalidateBoardFramePreferences() { + boardFrameDealEventID = nil + stockFrame = .zero + cardFrames = [:] + } + + private func acceptBoardFramePreferences(_ preferences: BoardFramePreferences) { + // A callback from the outgoing layout may already be queued when a + // new game or restore becomes current. Never let that stale snapshot + // replace the current board's geometry, even if card IDs match. + guard preferences.dealEventID == viewModel.latestBoardDealEvent?.id else { + return + } + + if boardFrameDealEventID != preferences.dealEventID { + boardFrameDealEventID = preferences.dealEventID + } + if !framesApproximatelyEqual(stockFrame, preferences.stockFrame) { + stockFrame = preferences.stockFrame + } + if shouldUpdateCardFrames(with: preferences.cardFrames) { + cardFrames = preferences.cardFrames + } + resolvePendingNewGameDealIfReady() + } + /// Arms a fresh deal before its board is allowed to render. Real cards are /// hidden immediately; the flight begins only after one current layout /// snapshot contains every destination and the required source anchor. @@ -1682,6 +1712,7 @@ struct ContentView: View { guard let pending = pendingNewGameDeal, viewModel.latestBoardDealEvent?.id == pending.eventID, dealAnimationToken == pending.token, + boardFrameDealEventID == pending.eventID, wipeAnimationCards.isEmpty, pending.cards.allSatisfy({ cardFrames[$0.id] != nil }) else { return } diff --git a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift index 4b7c5d5..82d7af2 100644 --- a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift +++ b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift @@ -126,17 +126,18 @@ final class NewGameDealAnimationTests: XCTestCase { ) } - // The launch planner receives one coherent layout snapshot: card frames - // cannot arrive without the stock frame from the same reduction pass, and - // a sizeless navigation/layout candidate cannot erase the real anchor. + // The launch planner receives one coherent layout snapshot: source and + // destination frames reduce together, and a sizeless layout candidate + // cannot erase the real stock anchor or deal generation. func testBoardFramePreferencesCombineStockAndCardsAtomically() { + let dealEventID = UUID() let stockFrame = CGRect(x: 20, y: 30, width: 80, height: 112) let card = GameStateFixtures.seededKlondikeDeal(seed: 15).tableau[0][0] let cardFrame = CGRect(x: 120, y: 200, width: 80, height: 112) var snapshot = BoardFrameKey.defaultValue BoardFrameKey.reduce(value: &snapshot) { - BoardFramePreferences(stockFrame: stockFrame) + BoardFramePreferences(dealEventID: dealEventID, stockFrame: stockFrame) } BoardFrameKey.reduce(value: &snapshot) { BoardFramePreferences(cardFrames: [card.id: cardFrame]) @@ -145,10 +146,34 @@ final class NewGameDealAnimationTests: XCTestCase { BoardFramePreferences(stockFrame: CGRect(x: 400, y: 0, width: 0, height: 0)) } + XCTAssertEqual(snapshot.dealEventID, dealEventID) XCTAssertEqual(snapshot.stockFrame, stockFrame) XCTAssertEqual(snapshot.cardFrames, [card.id: cardFrame]) } + // Redeal deliberately preserves card identity and may reproduce the exact + // same geometry. The event generation must still change the Equatable + // preference so SwiftUI publishes a fresh snapshot to the deal resolver. + func testBoardFramePreferencesDistinguishIdenticalRedealGeometry() { + let firstDealID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let redealID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let stockFrame = CGRect(x: 20, y: 30, width: 80, height: 112) + let card = GameStateFixtures.seededKlondikeDeal(seed: 16).tableau[0][0] + let cardFrames = [card.id: CGRect(x: 120, y: 200, width: 80, height: 112)] + let firstDeal = BoardFramePreferences( + dealEventID: firstDealID, + stockFrame: stockFrame, + cardFrames: cardFrames + ) + let identicalRedeal = BoardFramePreferences( + dealEventID: redealID, + stockFrame: stockFrame, + cardFrames: cardFrames + ) + + XCTAssertNotEqual(firstDeal, identicalRedeal) + } + func testPlanSkipsCardsWithoutLandingFrames() { let state = GameStateFixtures.seededKlondikeDeal(seed: 10) let sequence = DealAnimationCoordinator.newGameDealSequence(in: state)