From 7e7ce9634a0ac407a015568d351fb58d4203c3e5 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Thu, 16 Jul 2026 12:51:53 -0700 Subject: [PATCH 1/2] add board wipe and deal-in animations for fresh games --- .../Animation/BoardWipeCoordinator.swift | 103 ++++++ .../Animation/DealAnimationCoordinator.swift | 89 ++++- .../Animation/MotionPolicy.swift | 4 + .../Game/Shared/GameSession.swift | 12 + ComputerSolitaire/Views/Cards/CardView.swift | 9 + .../Views/Shared/BoardOverlayViews.swift | 106 +++++- .../Views/Shared/ContentView.swift | 234 +++++++++++-- .../Shared/GameSessionActivationTests.swift | 69 ++-- .../Shared/GameSessionTrackingTests.swift | 65 +--- .../Shared/NewGameDealAnimationTests.swift | 321 ++++++++++++++++++ ComputerSolitaireTests/TestSupport.swift | 45 +++ 11 files changed, 929 insertions(+), 128 deletions(-) create mode 100644 ComputerSolitaire/Animation/BoardWipeCoordinator.swift create mode 100644 ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift diff --git a/ComputerSolitaire/Animation/BoardWipeCoordinator.swift b/ComputerSolitaire/Animation/BoardWipeCoordinator.swift new file mode 100644 index 0000000..1187483 --- /dev/null +++ b/ComputerSolitaire/Animation/BoardWipeCoordinator.swift @@ -0,0 +1,103 @@ +import CoreGraphics +import Foundation + +struct BoardWipeCard: Identifiable { + let id: UUID + let card: Card + let size: CGSize + let start: CGPoint + /// How far behind the palm's leading edge this card rides once caught; + /// small offsets fan the clump instead of stacking it into one pixel. + let rideOffset: CGFloat + /// Vertical push the card picks up as it's carried. + let wobble: CGFloat + /// Tilt the card picks up as it's carried. + let rotationDegrees: Double +} + +/// Builds the clear-the-table sweep that precedes a fresh deal: a palm +/// lands at the board's left edge and wipes across, accelerating as it +/// goes. Cards are caught where they rest and then ride the palm's front, +/// piling into a clump that grows as the stroke crosses and carries the +/// whole board off the right edge. The stock never wipes: it is the deck +/// the next deal comes from (and it publishes no card frames, so it is +/// excluded by construction). +enum BoardWipeCoordinator { + struct Plan { + let cards: [BoardWipeCard] + let token: UUID + /// How far the palm's front travels over the stroke; far enough to + /// shove the whole clump past the right edge. + let sweepSpan: CGFloat + } + + /// One stroke, plant to follow-through. The overlay's animation and the + /// teardown completion both read this single constant. + static let strokeDuration: Double = 0.45 + + /// The palm's position along the stroke: quadratic in time, so the + /// stroke plants slowly and whips through the far side. Drive it with + /// linear progress; the acceleration lives here. + static func frontPosition(progress: CGFloat, sweepSpan: CGFloat) -> CGFloat { + sweepSpan * progress * progress + } + + /// A card's rightward displacement at `progress`: zero until the palm's + /// front reaches its resting spot, then pinned to the front (less its + /// ride offset) — which is what piles caught cards into one traveling + /// clump instead of letting each glide off alone. + static func sweptDisplacement( + progress: CGFloat, + startX: CGFloat, + rideOffset: CGFloat, + sweepSpan: CGFloat + ) -> CGFloat { + max(0, frontPosition(progress: progress, sweepSpan: sweepSpan) - rideOffset - startX) + } + + static func makeWipePlan( + cards: [Card], + cardFrames: [UUID: CGRect], + boardSize: CGSize + ) -> Plan? { + guard boardSize != .zero else { return nil } + let framed = cards + .compactMap { card -> (card: Card, frame: CGRect)? in + guard let frame = cardFrames[card.id] else { return nil } + return (card, frame) + } + .sorted { + // Draw order IS stacking order, and it must reproduce the + // board's: overlapping rows (TriPeaks, Pyramid) and fanned + // piles both render lower-on-screen cards on top, so sort + // y-major — an x-major order flips who's on top along every + // overlapped edge the instant the overlay stands in. + ($0.frame.minY, $0.frame.minX) < ($1.frame.minY, $1.frame.minX) + } + guard !framed.isEmpty else { return nil } + + let maxCardWidth = framed.map(\.frame.width).max() ?? 0 + let items = framed.enumerated().map { index, item -> BoardWipeCard in + // Deterministic per-card jostle, so the clump reads as shoved + // cards rather than a block — without randomness that would + // make tests and resumes flaky. + BoardWipeCard( + id: item.card.id, + card: item.card, + size: item.frame.size, + start: CGPoint(x: item.frame.midX, y: item.frame.midY), + rideOffset: CGFloat(index % 6) * item.frame.width * 0.12, + wobble: CGFloat((index % 5) - 2) * 10, + rotationDegrees: Double((index % 9) - 4) * 3 + ) + } + + return Plan( + cards: items, + token: UUID(), + // Far enough that the front clears the board plus the deepest + // ride offset plus a full card — everything ends off-screen. + sweepSpan: boardSize.width + maxCardWidth * 2.5 + ) + } +} diff --git a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift index 4ffbadc..728f24e 100644 --- a/ComputerSolitaire/Animation/DealAnimationCoordinator.swift +++ b/ComputerSolitaire/Animation/DealAnimationCoordinator.swift @@ -24,6 +24,11 @@ enum DealAnimationCoordinator { /// left-to-right sweep, reading as a deal rather than simultaneous pops. static let staggerInterval: Double = 0.05 + /// One pace for every deal flight — the stock deal and the fresh-board + /// deal must never drift apart. + static let travelDuration: Double = 0.32 + static let settleDuration: Double = 0.12 + /// `dealtCards` in pile order (leftmost pile's card first). Cards without /// a published frame — a card banked the instant it landed — are skipped; /// they surface through the banking animation instead. @@ -59,8 +64,88 @@ enum DealAnimationCoordinator { cards: items, cardIDs: Set(items.map(\.id)), token: UUID(), - travelDuration: 0.32, - settleDuration: 0.12, + travelDuration: travelDuration, + settleDuration: settleDuration, + maxDelay: items.last?.delay ?? 0 + ) + } + + /// The whole fresh deal's takeoff window: per-card stagger shrinks as the + /// deal grows, so Klondike's 28 cards and Spider's 54 read as one sweep + /// of the same length rather than a parade that scales with card count. + static let newGameTakeoffWindow: Double = 1.0 + + /// A fresh board's cards in the order a dealer lays them down: the + /// reserve packet is set down first (only its exposed top card flies — + /// the buried cards render as the set-down stack), the tableau deals in + /// left-to-right passes, the pyramid and peaks build from the top row + /// down, and starter cards (Canfield's foundation base, Golf's and + /// TriPeaks' waste card) turn over last. Zones a variant doesn't deal to + /// are empty, so the one ordering serves every variant. + static func newGameDealSequence(in state: GameState) -> [Card] { + var sequence: [Card] = [] + if let reserveTop = state.reserve.last { + sequence.append(reserveTop) + } + let tallestPileCount = state.tableau.map(\.count).max() ?? 0 + for row in 0.. Plan? { + let flying = dealtCards.compactMap { card -> (card: Card, frame: CGRect)? in + guard let frame = cardFrames[card.id] else { return nil } + return (card, frame) + } + guard !flying.isEmpty else { return nil } + + let start: CGPoint + if stockFrame != .zero { + start = CGPoint(x: stockFrame.midX, y: stockFrame.midY) + } else { + guard boardSize != .zero else { return nil } + let cardHeight = flying[0].frame.height + start = CGPoint(x: boardSize.width * 0.5, y: -cardHeight) + } + + let stagger = min( + staggerInterval, + newGameTakeoffWindow / Double(max(1, flying.count - 1)) + ) + let items = flying.enumerated().map { index, item -> DrawAnimationCard in + DrawAnimationCard( + id: item.card.id, + card: item.card, + start: start, + end: CGPoint(x: item.frame.midX, y: item.frame.midY), + delay: stagger * Double(index) + ) + } + + return Plan( + cards: items, + cardIDs: Set(items.map(\.id)), + token: UUID(), + travelDuration: travelDuration, + settleDuration: settleDuration, maxDelay: items.last?.delay ?? 0 ) } diff --git a/ComputerSolitaire/Animation/MotionPolicy.swift b/ComputerSolitaire/Animation/MotionPolicy.swift index 614a93a..ccefb8f 100644 --- a/ComputerSolitaire/Animation/MotionPolicy.swift +++ b/ComputerSolitaire/Animation/MotionPolicy.swift @@ -51,6 +51,10 @@ struct MotionPolicy: Equatable { isInstant ? nil : .spring(response: response * scale, dampingFraction: dampingFraction) } + func linear(_ baseDuration: Double) -> Animation? { + isInstant ? nil : .linear(duration: baseDuration * scale) + } + func easeOut(_ baseDuration: Double) -> Animation? { isInstant ? nil : .easeOut(duration: baseDuration * scale) } diff --git a/ComputerSolitaire/Game/Shared/GameSession.swift b/ComputerSolitaire/Game/Shared/GameSession.swift index 59b6185..4960ac1 100644 --- a/ComputerSolitaire/Game/Shared/GameSession.swift +++ b/ComputerSolitaire/Game/Shared/GameSession.swift @@ -49,6 +49,15 @@ final class SolitaireViewModel { func publishTableauDealEvent(dealtCardIDs: [UUID]) { latestTableauDealEvent = TableauDealEvent(id: UUID(), dealtCardIDs: dealtCardIDs) } + + /// The most recent fresh deal (new game, redeal, Golf's next hole), + /// published for the board's deal-in flight. Like `TableauDealEvent`, an + /// explicit event rather than an inferred state diff, so restores and + /// game switches can never replay a deal that already happened. + struct BoardDealEvent: Equatable { + let id: UUID + } + private(set) var latestBoardDealEvent: BoardDealEvent? private(set) var movesCount: Int = 0 private(set) var score: Int = 0 private(set) var gameStartedAt: Date = .now @@ -307,6 +316,7 @@ final class SolitaireViewModel { isDragging = false pendingAutoMove = nil latestTableauDealEvent = nil + latestBoardDealEvent = BoardDealEvent(id: UUID()) movesCount = 0 score = 0 gameStartedAt = dateProvider.now @@ -332,6 +342,7 @@ final class SolitaireViewModel { selection = nil isDragging = false pendingAutoMove = nil + latestBoardDealEvent = BoardDealEvent(id: UUID()) movesCount = 0 score = 0 gameStartedAt = dateProvider.now @@ -398,6 +409,7 @@ final class SolitaireViewModel { guard let sanitizedPayload = payload.sanitizedForRestore(at: now) else { return false } clearHint() latestTableauDealEvent = nil + latestBoardDealEvent = nil let offlineDurationSinceSave = max(0, now.timeIntervalSince(sanitizedPayload.savedAt)) state = sanitizedPayload.state movesCount = sanitizedPayload.movesCount diff --git a/ComputerSolitaire/Views/Cards/CardView.swift b/ComputerSolitaire/Views/Cards/CardView.swift index 1e5c571..6697c04 100644 --- a/ComputerSolitaire/Views/Cards/CardView.swift +++ b/ComputerSolitaire/Views/Cards/CardView.swift @@ -206,6 +206,15 @@ struct CardView: View { self.currentTilt = cardTilts.wrappedValue[card.id] let startFaceDown = flipOnAppear && card.isFaceUp _flipRotation = State(initialValue: startFaceDown ? 180 : (card.isFaceUp ? 0 : 180)) + // A card with a stored tilt must render leaning from its very first + // frame: `onAppear` lands after the first render, so seeding there + // leaves one straight frame — invisible for cards that mount hidden + // or in flight, but a visible whole-board twitch when an overlay + // (the wipe) replaces resting cards with copies in place. Cards + // without a stored tilt still get one in `onAppear`. + _tiltAngle = State( + initialValue: isCardTiltEnabled ? (cardTilts.wrappedValue[card.id] ?? 0) : 0 + ) } var body: some View { diff --git a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift index b05bb39..584913c 100644 --- a/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift +++ b/ComputerSolitaire/Views/Shared/BoardOverlayViews.swift @@ -6,6 +6,12 @@ struct DrawOverlayView: View { let cardSize: CGSize let isCardTiltEnabled: Bool @Binding var cardTilts: [UUID: Double] + /// Fresh-board deals queue every card on one shared anchor, so waiting + /// cards hide until takeoff (a visible queue would peel from under the + /// deck). Stock deals and draws keep their queues visible: their cards + /// wait on distinct per-index anchors, covering the already-decremented + /// stock until each departs. + var hidesUntilTakeoff = false var body: some View { ForEach(cards) { item in @@ -16,7 +22,8 @@ struct DrawOverlayView: View { end: item.end, delay: item.delay, isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts + cardTilts: $cardTilts, + hidesUntilTakeoff: hidesUntilTakeoff ) } .allowsHitTesting(false) @@ -24,6 +31,90 @@ struct DrawOverlayView: View { } } +/// The clear-the-table sweep before a fresh deal: one palm stroke crosses +/// the board and every card rides it off the right edge, piling into a +/// traveling clump. One linear progress drives the whole stroke; the +/// acceleration and each card's catch-and-carry happen per frame inside +/// `WipeRideEffect`, because a clump only forms when positions track the +/// palm's front continuously — endpoint animation can't produce it. +struct BoardWipeOverlayView: View { + let cards: [BoardWipeCard] + let sweepSpan: CGFloat + let strokeDuration: Double + let isCardTiltEnabled: Bool + @Binding var cardTilts: [UUID: Double] + @Environment(\.motionPolicy) private var motion + @State private var strokeProgress: CGFloat = 0 + + var body: some View { + Group { + ForEach(cards) { item in + CardView( + card: item.card, + isSelected: false, + cardSize: item.size, + // Shares the real cards' resting tilt so replacing the + // board with the overlay is pixel-identical — otherwise + // every card visibly snaps straight before the stroke. + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts, + isAccessibilityElement: false + ) + .modifier( + WipeRideEffect( + progress: strokeProgress, + item: item, + sweepSpan: sweepSpan + ) + ) + .position(item.start) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + .onAppear { + // Linear on purpose: the whip lives in the front's quadratic + // curve, and the completion in ContentView scales through the + // same policy so teardown lands after the stroke finishes. + withAnimation(motion.linear(strokeDuration)) { + strokeProgress = 1 + } + } + } +} + +/// Per-frame catch-and-carry: a card stays planted until the palm's front +/// reaches it, then translates with the front, picking up its tilt and +/// vertical wobble over its first card-width of travel. +private struct WipeRideEffect: GeometryEffect { + var progress: CGFloat + let item: BoardWipeCard + let sweepSpan: CGFloat + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let dx = BoardWipeCoordinator.sweptDisplacement( + progress: progress, + startX: item.start.x, + rideOffset: item.rideOffset, + sweepSpan: sweepSpan + ) + // How settled into the clump the card is: jostle ramps in over the + // first card-width of carry, then holds. + let carried = min(1, dx / max(size.width, 1)) + var transform = CGAffineTransform(translationX: dx, y: item.wobble * carried) + transform = transform + .translatedBy(x: size.width / 2, y: size.height / 2) + .rotated(by: item.rotationDegrees * carried * .pi / 180) + .translatedBy(x: -size.width / 2, y: -size.height / 2) + return ProjectionTransform(transform) + } +} + struct UndoOverlayView: View { let items: [UndoAnimationItem] let progress: CGFloat @@ -82,8 +173,10 @@ private struct DrawOverlayCardView: View { let delay: Double let isCardTiltEnabled: Bool @Binding var cardTilts: [UUID: Double] + let hidesUntilTakeoff: Bool @Environment(\.motionPolicy) private var motion @State private var progress: CGFloat = 0 + @State private var hasTakenOff = false var body: some View { let currentX = start.x + (end.x - start.x) * progress @@ -102,6 +195,8 @@ private struct DrawOverlayCardView: View { isAccessibilityElement: false ) .position(x: currentX, y: currentY) + // See DrawOverlayView: only fresh-board deals hide their queue. + .opacity(!hidesUntilTakeoff || hasTakenOff ? 1 : 0) .onAppear { // Travel pace matches the coordinator plans' travelDuration; the // completion in ContentView scales through the same policy, so @@ -109,6 +204,15 @@ private struct DrawOverlayCardView: View { withAnimation(motion.spring(response: 0.32, dampingFraction: 0.86)?.delay(motion.duration(delay))) { progress = 1 } + if hidesUntilTakeoff { + // The reveal rides the same animation clock as the travel + // spring (not a wall-clock timer): if the main thread runs + // behind during a big board mount, both shift together and + // a card can never be seen mid-air before it "exists". + withAnimation(motion.linear(0.05)?.delay(motion.duration(delay))) { + hasTakenOff = true + } + } } } } diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index b4698fe..4aabb69 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -117,6 +117,12 @@ struct ContentView: View { @State private var dealAnimationCards: [DrawAnimationCard] = [] @State private var dealingCardIDs: Set = [] @State private var dealAnimationToken = UUID() + /// 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 + @State private var wipeAnimationCards: [BoardWipeCard] = [] + @State private var wipeAnimationToken = UUID() + @State private var wipeSweepSpan: CGFloat = 0 /// The move count when the active deal flight took off; a later move means /// gameplay has mutated the position the flight refers to. @State private var dealAnimationMovesCount = 0 @@ -850,6 +856,15 @@ 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 @@ -865,6 +880,17 @@ struct ContentView: View { .overlay { GeometryReader { _ in ZStack { + BoardWipeOverlayView( + cards: wipeAnimationCards, + sweepSpan: wipeSweepSpan, + strokeDuration: BoardWipeCoordinator.strokeDuration, + isCardTiltEnabled: isCardTiltEnabled, + cardTilts: $cardTilts + ) + // Fresh identity per wipe so the stroke's onAppear + // animation restarts from a planted palm every time. + .id(wipeAnimationToken) + .zIndex(45) DrawOverlayView( cards: drawAnimationCards, cardSize: effectiveCardSize, @@ -876,7 +902,8 @@ struct ContentView: View { cards: dealAnimationCards, cardSize: effectiveCardSize, isCardTiltEnabled: isCardTiltEnabled, - cardTilts: $cardTilts + cardTilts: $cardTilts, + hidesUntilTakeoff: dealFlightHidesQueuedCards ) .zIndex(50) UndoOverlayView( @@ -914,7 +941,9 @@ struct ContentView: View { // shared win overlay never presents for it. if viewModel.golfMatch.isComplete { GolfMatchSummaryOverlay(match: viewModel.golfMatch) { - viewModel.startNewGolfMatch() + dealFreshBoard { + viewModel.startNewGolfMatch() + } } .transition(.opacity) } else if viewModel.isGolfHoleOver, @@ -928,7 +957,11 @@ struct ContentView: View { matchTotalThroughHole: viewModel.golfLiveMatchTotal, isFinalHole: viewModel.golfMatch.currentHoleNumber == GolfMatchState.holeCount, didClearBoard: viewModel.isWin, - onAdvance: { viewModel.advanceGolfHole() }, + onAdvance: { + dealFreshBoard { + viewModel.advanceGolfHole() + } + }, onUndo: { viewModel.undo() } ) .transition(.opacity) @@ -1064,15 +1097,72 @@ struct ContentView: View { private func startNewGameFromUI() { stopAutoFinish() - winCelebration.reset(to: .idle) - // New Game replaces the board as thoroughly as a game switch: stale - // flights (deal, draw, undo) must not animate over the fresh deal. - resetTransientBoardState() isScreenshotSession = false - viewModel.newGame() + dealFreshBoard { + viewModel.newGame() + } + // Reset after the wipe capture: on a just-won board the celebration + // still hides the cascaded foundation cards, and capturing first + // keeps the wipe from re-materializing cards the player watched + // fly away. + winCelebration.reset(to: .idle) persistGameNow() } + /// Runs a session mutation that deals a whole fresh board (new game, + /// redeal, Golf's match flow) with animations disabled: the deal-in + /// flight hides the fresh cards, so an animated removal would leave the + /// old board fading out as ghosts under the flight instead of clearing + /// the felt. Stale flights are landed first, as every board replacement + /// requires. + private func dealFreshBoard(_ mutation: () -> Void) { + // Capture the outgoing board before any state is torn down: the + // wipe replays these cards from their last resting frames while the + // real board already holds the (hidden) fresh deal. + let wipedCards = wipeableBoardCards() + let wipeFrames = cardFrames + let eventBeforeMutation = viewModel.latestBoardDealEvent + resetTransientBoardState() + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + mutation() + } + // 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 } + startBoardWipe(for: wipedCards, frames: wipeFrames) + } + + /// The outgoing board's visible cards — everything with a resting frame + /// that isn't already hidden behind an overlay flight or the win + /// celebration. Stock cards publish no frames, so the deck stays put. + private func wipeableBoardCards() -> [Card] { + let hidden = effectiveHiddenCardIDs.union(drawingCardIDs) + return cardLookup(in: viewModel.state).values.filter { + !hidden.contains($0.id) && cardFrames[$0.id] != nil + } + } + + private func startBoardWipe(for cards: [Card], frames: [UUID: CGRect]) { + guard let plan = BoardWipeCoordinator.makeWipePlan( + cards: cards, + cardFrames: frames, + boardSize: boardViewportSize + ) else { return } + wipeAnimationCards = plan.cards + wipeSweepSpan = plan.sweepSpan + let token = plan.token + wipeAnimationToken = token + let total = motion.duration(BoardWipeCoordinator.strokeDuration) + DispatchQueue.main.asyncAfter(deadline: .now() + total) { + guard wipeAnimationToken == token else { return } + wipeAnimationCards = [] + } + } + /// Syncs the stored game selection (variant plus per-variant configuration) /// to the game now in play, so relaunches and picker defaults follow it. private func rememberSelectedGame() { @@ -1091,10 +1181,11 @@ struct ContentView: View { private func redealFromUI() { guard viewModel.canRedeal else { return } stopAutoFinish() + dealFreshBoard { + viewModel.redeal() + } + // Reset after the wipe capture; see startNewGameFromUI. winCelebration.reset(to: .idle) - // Redeal replaces the board like New Game does; see above. - resetTransientBoardState() - viewModel.redeal() persistGameNow() } @@ -1144,6 +1235,8 @@ struct ContentView: View { drawAnimationCards = [] drawingCardIDs = [] drawAnimationToken = UUID() + wipeAnimationCards = [] + wipeAnimationToken = UUID() cancelDealAnimation() undoAnimationItems = [] undoAnimationTargets = [:] @@ -1446,42 +1539,124 @@ struct ContentView: View { let token = UUID() dealAnimationToken = token DispatchQueue.main.async { - resolveDealAnimation(for: dealtCards, token: token, attemptsRemaining: 24) + resolveDealFlight( + token: token, + attemptsRemaining: 24, + retryInterval: 0.01, + isReady: { + // Only cards still on the tableau ever publish a landing + // frame: a dealt card that completed a run banked on + // arrival, and its run pile publishes the run's top card + // only. Waiting on a banked card would burn every retry + // before the plan — which already skips frame-less + // cards — could run. + let tableauCardIDs = Set(viewModel.state.tableau.joined().map(\.id)) + return dealtCards + .filter { tableauCardIDs.contains($0.id) } + .allSatisfy { cardFrames[$0.id] != nil } + }, + makePlan: { + DealAnimationCoordinator.makeDealPlan( + dealtCards: dealtCards, + cardFrames: cardFrames, + stockFrame: stockFrame + ) + } + ) } } private func cancelDealAnimation() { dealAnimationCards = [] dealingCardIDs = [] + dealFlightHidesQueuedCards = false dealAnimationToken = UUID() } - private func resolveDealAnimation(for dealtCards: [Card], token: UUID, attemptsRemaining: Int) { + /// 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() { + cancelDealAnimation() + let dealtCards = DealAnimationCoordinator.newGameDealSequence(in: viewModel.state) + guard !dealtCards.isEmpty else { return } + + dealingCardIDs = Set(dealtCards.map(\.id)) + // Fresh-board cards all queue on one shared anchor, so the overlay + // hides each until its takeoff; see DrawOverlayView. + dealFlightHidesQueuedCards = true + dealAnimationMovesCount = viewModel.movesCount + let token = UUID() + dealAnimationToken = token + DispatchQueue.main.async { + resolveDealFlight( + token: token, + attemptsRemaining: 75, + retryInterval: 0.02, + isReady: { + // The fresh board mounts entirely new card views (every + // deal mints new card IDs), so landing frames arrive a + // beat after the state swap — 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) + } + ) + } + } + + /// 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. + private func resolveDealFlight( + token: UUID, + attemptsRemaining: Int, + retryInterval: TimeInterval, + isReady: @escaping () -> Bool, + makePlan: @escaping () -> DealAnimationCoordinator.Plan?, + onTakeoff: @escaping () -> Void = {} + ) { guard dealAnimationToken == token else { return } - // Only cards still on the tableau ever publish a landing frame: a - // dealt card that completed a run banked on arrival, and its run pile - // publishes the run's top card only. Waiting on a banked card would - // burn every retry before the plan — which already skips frame-less - // cards — could run. - let tableauCardIDs = Set(viewModel.state.tableau.joined().map(\.id)) - let awaitedCards = dealtCards.filter { tableauCardIDs.contains($0.id) } - let framesReady = awaitedCards.allSatisfy { cardFrames[$0.id] != nil } - if !framesReady, attemptsRemaining > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - resolveDealAnimation(for: dealtCards, token: token, attemptsRemaining: attemptsRemaining - 1) + if !isReady(), attemptsRemaining > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) { + resolveDealFlight( + token: token, + attemptsRemaining: attemptsRemaining - 1, + retryInterval: retryInterval, + isReady: isReady, + makePlan: makePlan, + onTakeoff: onTakeoff + ) } return } - guard let plan = DealAnimationCoordinator.makeDealPlan( - dealtCards: dealtCards, - cardFrames: cardFrames, - stockFrame: stockFrame - ) else { + guard let plan = makePlan() else { cancelDealAnimation() return } + onTakeoff() dealAnimationCards = plan.cards // The plan drops cards without a landing frame (banked on arrival); // trimming the hidden set to the flying cards un-hides those. @@ -1720,6 +1895,7 @@ struct ContentView: View { var lookup: [UUID: Card] = [:] for card in state.stock { lookup[card.id] = card } for card in state.waste { lookup[card.id] = card } + for card in state.reserve { lookup[card.id] = card } for card in state.freeCells.compactMap({ $0 }) { lookup[card.id] = card } for pile in state.foundations { for card in pile { lookup[card.id] = card } diff --git a/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift b/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift index 68a90be..2513e02 100644 --- a/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift +++ b/ComputerSolitaireTests/Shared/GameSessionActivationTests.swift @@ -5,12 +5,11 @@ import XCTest /// stashes and resumes per-mode sessions without touching statistics. @MainActor final class GameSessionActivationTests: XCTestCase { - private static var retainedViewModels: [SolitaireViewModel] = [] // Verifies switching variants mid-game records no loss in either variant's bucket. func testActivateVariantDoesNotRecordLoss() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame(mode: .klondikeDrawThree) viewModel.activateGame(.freecell, restoringFrom: nil) @@ -24,8 +23,8 @@ final class GameSessionActivationTests: XCTestCase { // Verifies a stashed session survives the switch round trip exactly: board, progress, // score, undo history, and redeal baseline. func testActivateVariantRoundTripsStashedSession() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let state = GameStateFixtures.seededFreeCellDeal(seed: 7) let redealState = GameStateFixtures.seededFreeCellDeal(seed: 8) let snapshot = GameSnapshot( @@ -59,8 +58,8 @@ final class GameSessionActivationTests: XCTestCase { // Verifies a missing payload deals a fresh game for the requested variant. func testActivateVariantDealsFreshWhenPayloadNil() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame(mode: .klondikeDrawThree) XCTAssertFalse(viewModel.activateGame(.freecell, restoringFrom: nil)) @@ -75,8 +74,8 @@ final class GameSessionActivationTests: XCTestCase { // Verifies a payload that fails restore sanitization falls back to a fresh deal. func testActivateVariantDealsFreshWhenPayloadInvalid() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let invalid = makePayload(state: GameStateFixtures.emptyBoard(), movesCount: 3) XCTAssertFalse(viewModel.activateGame(.klondikeDrawThree, restoringFrom: invalid)) @@ -88,8 +87,8 @@ final class GameSessionActivationTests: XCTestCase { // Verifies a payload belonging to another variant is rejected in favor of a fresh deal. func testActivateVariantRejectsWrongVariantPayload() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let freeCellPayload = makePayload( state: GameStateFixtures.seededFreeCellDeal(seed: 7), movesCount: 12 @@ -107,8 +106,8 @@ final class GameSessionActivationTests: XCTestCase { // locked to the deal). Its statistics must land entirely in the bucket // of the mode it lives and displays as. func testLegacyMismatchedDrawCountsRecordIntoTheModesOwnBucket() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let payload = makePayload( state: GameStateFixtures.seededKlondikeDeal(seed: 7), movesCount: 12, @@ -131,8 +130,8 @@ final class GameSessionActivationTests: XCTestCase { // rejected: a draw-three Klondike session must not restore into a // requested draw-one game. func testActivateGameRejectsWrongModePayloadOfSameVariant() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let drawThreePayload = makePayload( state: GameStateFixtures.seededKlondikeDeal(seed: 7), movesCount: 12 @@ -148,10 +147,10 @@ final class GameSessionActivationTests: XCTestCase { // Verifies elapsed time does not accrue while a session sits stashed: 600s of play // stashed for 300s still reports 600s after reactivation. func testStashedTimeDoesNotAccrue() { - withIsolatedStatsStore { + SessionTestHarness.withIsolatedStatsStore { let clock = TestDateProvider(now: DateFixtures.reference) let viewModel = SolitaireViewModel(dateProvider: clock) - Self.retainedViewModels.append(viewModel) + SessionTestHarness.retain(viewModel) let payload = makePayload( state: GameStateFixtures.seededFreeCellDeal(seed: 7), movesCount: 12, @@ -168,10 +167,10 @@ final class GameSessionActivationTests: XCTestCase { // A payload stashed while paused restores paused; resuming must not // charge the game for time spent paused or stashed. func testRestoredPausedPayloadResumesWithoutAccruingStashTime() { - withIsolatedStatsStore { + SessionTestHarness.withIsolatedStatsStore { let clock = TestDateProvider(now: DateFixtures.reference) let viewModel = SolitaireViewModel(dateProvider: clock) - Self.retainedViewModels.append(viewModel) + SessionTestHarness.retain(viewModel) let payload = makePayload( state: GameStateFixtures.seededFreeCellDeal(seed: 7), movesCount: 12, @@ -196,8 +195,8 @@ final class GameSessionActivationTests: XCTestCase { // Verifies reactivating a finalized (won) session does not finalize it again on New Game. func testActivateVariantWithFinalizedPayloadDoesNotRefinalizeOnNewGame() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let payload = makePayload( state: GameStateFixtures.seededFreeCellDeal(seed: 7), movesCount: 12, @@ -213,34 +212,6 @@ final class GameSessionActivationTests: XCTestCase { // MARK: - Helpers - private func withIsolatedStatsStore(_ body: () -> Void) { - let defaults = UserDefaults.standard - let statsKeys = GameMode.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } - let previousStatsData = statsKeys.reduce(into: [String: Data]()) { result, key in - if let data = defaults.data(forKey: key) { - result[key] = data - } - } - for key in statsKeys { - defaults.removeObject(forKey: key) - } - defer { - for key in statsKeys { - if let value = previousStatsData[key] { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - } - body() - } - - private func makeViewModel() -> SolitaireViewModel { - let viewModel = SolitaireViewModel() - Self.retainedViewModels.append(viewModel) - return viewModel - } private func makePayload( state: GameState, diff --git a/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift index fd16980..37577a7 100644 --- a/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift +++ b/ComputerSolitaireTests/Shared/GameSessionTrackingTests.swift @@ -3,12 +3,11 @@ import XCTest @MainActor final class GameSessionTrackingTests: XCTestCase { - private static var retainedViewModels: [SolitaireViewModel] = [] // Verifies app startup initializes tracking metadata without starting a trackable game. func testInitMarksTrackingStartWithoutActiveTrackedGame() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let stats = GameStatisticsStore.load(for: .klondikeDrawThree) XCTAssertNotNil(stats.trackedSince) @@ -21,8 +20,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies the first explicit New Game starts tracking and does not finalize bootstrap state. func testFirstNewGameStartsTrackingWithoutFinalizingBootstrapSession() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame() @@ -36,8 +35,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies starting a second game finalizes exactly one previously tracked session. func testSecondNewGameFinalizesExactlyOneTrackedGame() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame() viewModel.newGame() @@ -49,8 +48,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies redeal finalizes the current tracked session once and starts a fresh one. func testRedealFinalizesCurrentTrackedGameExactlyOnce() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame() viewModel.redeal() @@ -65,8 +64,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies restore resumes live elapsed reporting when payload is active and unfinalized. func testRestoreWithActiveTrackedGameReportsLiveElapsed() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let payload = makePayload( hasStartedTrackedGame: true, isCurrentGameFinalized: false @@ -79,8 +78,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies finalized restored sessions are not finalized again when starting a new game. func testRestoreWithFinalizedGameDoesNotFinalizeAgainOnNewGame() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let payload = makePayload( hasStartedTrackedGame: true, isCurrentGameFinalized: true @@ -98,8 +97,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies untracked restored sessions stay untracked until an explicit New Game starts tracking. func testRestoreWithUntrackedPayloadRemainsUntrackedUntilNewGameStarts() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() let payload = makePayload( hasStartedTrackedGame: false, isCurrentGameFinalized: true @@ -120,8 +119,8 @@ final class GameSessionTrackingTests: XCTestCase { // Verifies resetting statistics untracks the active session so pre-reset progress is not counted. func testResetStatisticsUntracksCurrentSessionUntilNextNewGame() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame() let activeProbeDate = viewModel.gameStartedAt.addingTimeInterval(120) @@ -148,8 +147,8 @@ final class GameSessionTrackingTests: XCTestCase { // own stats bucket. (The game picker goes through `activateGame` instead, which // never finalizes — see GameSessionActivationTests.) func testNewGameAcrossModesFinalizesIntoPriorBucket() { - withIsolatedStatsStore { - let viewModel = makeViewModel() + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() viewModel.newGame(mode: .klondikeDrawThree) viewModel.newGame(mode: .freecell) @@ -177,34 +176,6 @@ final class GameSessionTrackingTests: XCTestCase { } } - private func withIsolatedStatsStore(_ body: () -> Void) { - let defaults = UserDefaults.standard - let statsKeys = GameMode.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } - let previousStatsData = statsKeys.reduce(into: [String: Data]()) { result, key in - if let data = defaults.data(forKey: key) { - result[key] = data - } - } - for key in statsKeys { - defaults.removeObject(forKey: key) - } - defer { - for key in statsKeys { - if let value = previousStatsData[key] { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } - } - body() - } - - private func makeViewModel() -> SolitaireViewModel { - let viewModel = SolitaireViewModel() - Self.retainedViewModels.append(viewModel) - return viewModel - } private func makePayload( hasStartedTrackedGame: Bool, diff --git a/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift new file mode 100644 index 0000000..e966fff --- /dev/null +++ b/ComputerSolitaireTests/Shared/NewGameDealAnimationTests.swift @@ -0,0 +1,321 @@ +import XCTest +@testable import Computer_Solitaire + +/// Covers the fresh-board deal-in flight: the dealer ordering of +/// `newGameDealSequence`, the flight plan's anchors and takeoff budget, and +/// the session's `BoardDealEvent` — published only by fresh deals, never by +/// restores, so a hydrated board can never replay its deal. +@MainActor +final class NewGameDealAnimationTests: XCTestCase { + // MARK: - Deal sequence ordering + + // Verifies the tableau deals in left-to-right passes: pass one lays each + // pile's first card, pass two starts at the second pile (pile one holds a + // single card), like a real dealer. + func testKlondikeSequenceDealsRowMajor() { + let state = GameStateFixtures.seededKlondikeDeal(seed: 1) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + + XCTAssertEqual(sequence.count, 28) + XCTAssertEqual( + sequence.prefix(7).map(\.id), + state.tableau.map { $0[0].id } + ) + XCTAssertEqual(sequence[7].id, state.tableau[1][1].id) + } + + func testFreeCellSequenceDealsRowMajor() { + let state = GameStateFixtures.seededFreeCellDeal(seed: 2) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + + XCTAssertEqual(sequence.count, 52) + XCTAssertEqual( + sequence.prefix(8).map(\.id), + state.tableau.map { $0[0].id } + ) + } + + // Verifies Canfield's dealer order: the reserve packet flies only its + // exposed top card, then the four tableau cards, then the foundation base. + func testCanfieldSequenceIsReserveTopThenTableauThenFoundationBase() { + let state = GameStateFixtures.seededCanfieldDeal(seed: 3) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + + XCTAssertEqual(sequence.count, 6) + XCTAssertEqual(sequence.first?.id, state.reserve.last?.id) + XCTAssertEqual( + sequence[1...4].map(\.id), + state.tableau.map { $0[0].id } + ) + XCTAssertEqual(sequence.last?.id, state.foundations[0][0].id) + } + + // Verifies the starter cards land last: Golf's and TriPeaks' waste card + // turns over after the board is down. + func testStarterCardsDealLast() { + let golf = GameStateFixtures.seededGolfDeal(seed: 4) + let golfSequence = DealAnimationCoordinator.newGameDealSequence(in: golf) + XCTAssertEqual(golfSequence.count, 36) + XCTAssertEqual(golfSequence.last?.id, golf.waste[0].id) + + let triPeaks = GameStateFixtures.seededTriPeaksDeal(seed: 5) + let triPeaksSequence = DealAnimationCoordinator.newGameDealSequence(in: triPeaks) + XCTAssertEqual(triPeaksSequence.count, 29) + XCTAssertEqual(triPeaksSequence.last?.id, triPeaks.waste[0].id) + } + + func testPyramidSequenceFollowsBoardOrder() { + let state = GameStateFixtures.seededPyramidDeal(seed: 6) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + + XCTAssertEqual(sequence.map(\.id), state.pyramid.compactMap { $0?.id }) + } + + // MARK: - Flight plan + + func testPlanFliesFromStockWhenStockFrameExists() { + let state = GameStateFixtures.seededKlondikeDeal(seed: 7) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + let stockFrame = CGRect(x: 10, y: 20, width: 80, height: 112) + + let plan = DealAnimationCoordinator.makeNewGameDealPlan( + dealtCards: sequence, + cardFrames: frames(for: sequence), + stockFrame: stockFrame, + boardSize: CGSize(width: 800, height: 600) + ) + + XCTAssertEqual(plan?.cards.count, 28) + XCTAssertEqual( + plan?.cards.first?.start, + CGPoint(x: stockFrame.midX, y: stockFrame.midY) + ) + } + + // Verifies the stockless variants (FreeCell, Yukon) deal from an + // invisible deck just above the board's top edge. + func testPlanFallsBackToAboveBoardWhenStockless() { + 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) + ) + + XCTAssertEqual(plan?.cards.first?.start, CGPoint(x: 400, y: -112)) + } + + func testPlanRequiresAnAnchor() { + let state = GameStateFixtures.seededFreeCellDeal(seed: 9) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + + XCTAssertNil( + DealAnimationCoordinator.makeNewGameDealPlan( + dealtCards: sequence, + cardFrames: frames(for: sequence), + stockFrame: .zero, + boardSize: .zero + ) + ) + } + + func testPlanSkipsCardsWithoutLandingFrames() { + let state = GameStateFixtures.seededKlondikeDeal(seed: 10) + let sequence = DealAnimationCoordinator.newGameDealSequence(in: state) + let framedCards = Array(sequence.dropFirst(3)) + + 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) + ) + + XCTAssertEqual(plan?.cards.count, sequence.count - 3) + XCTAssertEqual(plan?.cardIDs, Set(framedCards.map(\.id))) + } + + // Verifies the takeoff window budget: a big deal compresses its stagger + // so Spider's 54 cards sweep out in the same window as Klondike's 28, + // while a small deal keeps the stock flight's relaxed stagger. + func testPlanBudgetsTakeoffWindow() { + let spider = GameStateFixtures.seededSpiderDeal(seed: 11, suitCount: .two) + let spiderSequence = DealAnimationCoordinator.newGameDealSequence(in: spider) + 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) + ) + XCTAssertEqual(spiderSequence.count, 54) + XCTAssertEqual( + spiderPlan?.maxDelay ?? .infinity, + DealAnimationCoordinator.newGameTakeoffWindow, + accuracy: 0.001 + ) + + let canfield = GameStateFixtures.seededCanfieldDeal(seed: 12) + let canfieldSequence = DealAnimationCoordinator.newGameDealSequence(in: canfield) + 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) + ) + XCTAssertEqual( + canfieldPlan?.maxDelay ?? .infinity, + DealAnimationCoordinator.staggerInterval * 5, + accuracy: 0.001 + ) + } + + // MARK: - Board wipe + + // Verifies the palm-stroke physics: the front accelerates (the second + // half of the stroke covers more ground than the first), a resting card + // stays planted until the front reaches it, caught cards pile into one + // clump, and the sweep span carries everything past the right edge. + func testWipeStrokeCatchesAndClumpsCards() { + let span: CGFloat = 1000 + + let midpoint = BoardWipeCoordinator.frontPosition(progress: 0.5, sweepSpan: span) + XCTAssertLessThan(midpoint, span / 2) + XCTAssertEqual(BoardWipeCoordinator.frontPosition(progress: 1, sweepSpan: span), span) + + // Planted until caught: the front at progress 0.3 sits at 90pt, so + // a card resting at 400pt has not moved yet. + XCTAssertEqual( + BoardWipeCoordinator.sweptDisplacement( + progress: 0.3, startX: 400, rideOffset: 0, sweepSpan: span + ), + 0 + ) + + // Clumping: once caught, two cards from different columns share the + // same absolute position for the same ride offset. + let left = BoardWipeCoordinator.sweptDisplacement( + progress: 0.9, startX: 100, rideOffset: 0, sweepSpan: span + ) + let right = BoardWipeCoordinator.sweptDisplacement( + progress: 0.9, startX: 500, rideOffset: 0, sweepSpan: span + ) + XCTAssertEqual(100 + left, 500 + right) + } + + func testWipePlanSpansPastTheRightEdge() { + let state = GameStateFixtures.seededKlondikeDeal(seed: 13) + let cards = state.tableau.flatMap { $0 } + let boardSize = CGSize(width: 800, height: 600) + var frames: [UUID: CGRect] = [:] + for (pileIndex, pile) in state.tableau.enumerated() { + for (cardIndex, card) in pile.enumerated() { + frames[card.id] = CGRect( + x: CGFloat(pileIndex) * 110, + y: 200 + CGFloat(cardIndex) * 24, + width: 80, + height: 112 + ) + } + } + + let plan = BoardWipeCoordinator.makeWipePlan( + cards: cards, + cardFrames: frames, + boardSize: boardSize + ) + + XCTAssertEqual(plan?.cards.count, 28) + // The stroke must push even the deepest-riding card fully off: + // at full progress the card's displacement lands it past the edge. + for item in plan?.cards ?? [] { + let finalX = item.start.x + BoardWipeCoordinator.sweptDisplacement( + progress: 1, + startX: item.start.x, + rideOffset: item.rideOffset, + sweepSpan: plan?.sweepSpan ?? 0 + ) + XCTAssertGreaterThan(finalX, boardSize.width + item.size.width / 2) + } + } + + func testWipePlanSkipsFramelessCardsAndRequiresABoard() { + let state = GameStateFixtures.seededKlondikeDeal(seed: 14) + let cards = state.tableau.flatMap { $0 } + + XCTAssertNil( + BoardWipeCoordinator.makeWipePlan( + cards: cards, + cardFrames: [:], + boardSize: CGSize(width: 800, height: 600) + ) + ) + XCTAssertNil( + BoardWipeCoordinator.makeWipePlan( + cards: cards, + cardFrames: frames(for: cards), + boardSize: .zero + ) + ) + + let framed = Array(cards.prefix(4)) + let plan = BoardWipeCoordinator.makeWipePlan( + cards: cards, + cardFrames: frames(for: framed), + boardSize: CGSize(width: 800, height: 600) + ) + XCTAssertEqual(plan?.cards.count, 4) + } + + // MARK: - Session event + + func testFreshDealsPublishBoardDealEvent() { + SessionTestHarness.withIsolatedStatsStore { + let viewModel = SessionTestHarness.makeViewModel() + XCTAssertNil(viewModel.latestBoardDealEvent) + + viewModel.newGame(mode: .klondikeDrawThree) + let newGameEvent = viewModel.latestBoardDealEvent + XCTAssertNotNil(newGameEvent) + + viewModel.redeal() + let redealEvent = viewModel.latestBoardDealEvent + XCTAssertNotNil(redealEvent) + XCTAssertNotEqual(newGameEvent, redealEvent) + + viewModel.activateGame(.freecell, restoringFrom: nil) + XCTAssertNotEqual(viewModel.latestBoardDealEvent, redealEvent) + } + } + + func testRestoreClearsBoardDealEvent() { + SessionTestHarness.withIsolatedStatsStore { + let source = SessionTestHarness.makeViewModel() + source.newGame(mode: .klondikeDrawThree) + let payload = source.persistencePayload() + + let viewModel = SessionTestHarness.makeViewModel() + viewModel.newGame(mode: .klondikeDrawThree) + XCTAssertNotNil(viewModel.latestBoardDealEvent) + + XCTAssertTrue(viewModel.activateGame(.klondikeDrawThree, restoringFrom: payload)) + XCTAssertNil(viewModel.latestBoardDealEvent) + } + } + + // MARK: - Helpers + + private func frames(for cards: [Card]) -> [UUID: CGRect] { + cards.enumerated().reduce(into: [:]) { result, item in + result[item.element.id] = CGRect( + x: CGFloat(item.offset) * 10, + y: 200, + width: 80, + height: 112 + ) + } + } + +} diff --git a/ComputerSolitaireTests/TestSupport.swift b/ComputerSolitaireTests/TestSupport.swift index be4ca12..244edd4 100644 --- a/ComputerSolitaireTests/TestSupport.swift +++ b/ComputerSolitaireTests/TestSupport.swift @@ -712,3 +712,48 @@ final class TestDateProvider: DateProviding { self.now = now } } + +/// Shared harness for suites that spin up live view models and touch the +/// persisted statistics store. +@MainActor +enum SessionTestHarness { + /// Keeps live sessions alive for the process so no view model tears + /// down mid-test. + private static var retainedViewModels: [SolitaireViewModel] = [] + + static func makeViewModel() -> SolitaireViewModel { + let viewModel = SolitaireViewModel() + retainedViewModels.append(viewModel) + return viewModel + } + + static func retain(_ viewModel: SolitaireViewModel) { + retainedViewModels.append(viewModel) + } + + /// Snapshots every mode's persisted statistics, clears them for the + /// test body, and restores them afterward — tests never read or leak + /// into the real statistics store. + static func withIsolatedStatsStore(_ body: () -> Void) { + let defaults = UserDefaults.standard + let statsKeys = GameMode.allCases.map { GameStatisticsStore.defaultsKey(for: $0) } + let previousStatsData = statsKeys.reduce(into: [String: Data]()) { result, key in + if let data = defaults.data(forKey: key) { + result[key] = data + } + } + for key in statsKeys { + defaults.removeObject(forKey: key) + } + defer { + for key in statsKeys { + if let value = previousStatsData[key] { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + body() + } +} From c08dbe0be4e6d61cc0a7e0ab5d0a31d5569f3362 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Thu, 16 Jul 2026 16:57:42 -0700 Subject: [PATCH 2/2] drop stale card frames before planning fresh-deal flights redeal reuses the outgoing game's card ids, so leftover frames from the played-out layout satisfied the deal flight's readiness check and could land cards at their old positions before popping to the redealt spread --- .../Views/Shared/ContentView.swift | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 4aabb69..4920e0e 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -1133,6 +1133,12 @@ struct ContentView: View { // 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) } @@ -1594,15 +1600,17 @@ struct ContentView: View { attemptsRemaining: 75, retryInterval: 0.02, isReady: { - // The fresh board mounts entirely new card views (every - // deal mints new card IDs), so landing frames arrive a - // beat after the state swap — 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 + // 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 }