Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/Canfield/CanfieldPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ nonisolated private extension CanfieldPlanner {
var best: (index: Int, score: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/FortyThieves/FortyThievesPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ nonisolated private extension FortyThievesPlanner {
var best: (index: Int, score: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down
4 changes: 4 additions & 0 deletions ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ nonisolated enum FreeCellSolver {
var expansions = 0

while let entry = heap.pop() {
if Task.isCancelled {
return nil
}

let nodeIndex = entry.index
let board = nodes[nodeIndex].board

Expand Down
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/Golf/GolfPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,11 @@ nonisolated private extension GolfPlanner {
var best: (index: Int32, remaining: Int, depth: UInt16)?

while let nodeIndex = pending.popLast() {
if Task.isCancelled {
wasTruncated = true
break
}

let node = nodes[Int(nodeIndex)]
let board = Board(code: node.code)
let remaining = board.remainingCount
Expand Down
4 changes: 4 additions & 0 deletions ComputerSolitaire/Game/Klondike/KlondikePlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ nonisolated enum KlondikePlanner {
var best: (index: Int, score: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down
10 changes: 10 additions & 0 deletions ComputerSolitaire/Game/Pyramid/PyramidPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,11 @@ nonisolated private extension PyramidPlanner {
var wasTruncated = false

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down Expand Up @@ -592,6 +597,11 @@ nonisolated private extension PyramidPlanner {
var best: (index: Int, remaining: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]
let remaining = node.board.remainingCount
Expand Down
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/Scorpion/ScorpionPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ nonisolated private extension ScorpionPlanner {
var best: (index: Int, score: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down
84 changes: 67 additions & 17 deletions ComputerSolitaire/Game/Shared/GameSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class SolitaireViewModel {
}
private var selectedCardIDs: Set<UUID> = []
private(set) var activeHint: HintAdvisor.Hint?
private(set) var isSearchingForHint = false
private(set) var hintWiggleToken = UUID()
private var hintAutoClearToken = UUID()
var isDragging: Bool = false
Expand Down Expand Up @@ -81,7 +82,9 @@ final class SolitaireViewModel {
/// Internal so variant session extensions (Golf's hole advance) share the
/// injected clock.
let dateProvider: any DateProviding
@ObservationIgnored private let hintPlanner = HintPlanner()
@ObservationIgnored private let hintSearch: any HintSearching
@ObservationIgnored private var hintSearchTask: Task<Void, Never>?
@ObservationIgnored private var hintSearchID: UUID?

private var history: [GameSnapshot] = []

Expand All @@ -93,9 +96,11 @@ final class SolitaireViewModel {

init(
dateProvider: any DateProviding = SystemDateProvider(),
variant: GameVariant = .klondike
variant: GameVariant = .klondike,
hintSearch: any HintSearching = HintSearchWorker()
) {
self.dateProvider = dateProvider
self.hintSearch = hintSearch
let startedAt = dateProvider.now
let initialState = GameState.newGame(variant: variant)
state = initialState
Expand All @@ -107,6 +112,10 @@ final class SolitaireViewModel {
GameStatisticsStore.markTrackingStarted(for: gameMode, at: startedAt)
}

deinit {
hintSearchTask?.cancel()
}

var gameVariant: GameVariant {
state.variant
}
Expand Down Expand Up @@ -179,30 +188,69 @@ final class SolitaireViewModel {
pauseStartedAt == nil && finalElapsedSeconds == nil
}

func requestHint() {
@discardableResult
func requestHint() -> Task<Void, Never>? {
guard !isWin else {
clearHint()
return
return nil
}
guard !isDragging, pendingAutoMove == nil else { return nil }
if let hintSearchTask { return hintSearchTask }

guard let hint = hintPlanner.bestHint(in: state, stockDrawCount: stockDrawCount) else {
clearHint()
// The cheap availability check can't know the planner would come up empty
// (e.g. a dead stock cycle); now that the full search has, keep the button
// honest until the next state change re-evaluates it.
isHintAvailable = false
HapticManager.shared.play(.invalidDrop)
return
clearHint()
let snapshot = state
let drawCount = stockDrawCount
let requestID = UUID()
hintSearchID = requestID
isSearchingForHint = true
let task = Task { [weak self, hintSearch] in
let hint: HintAdvisor.Hint?
do {
hint = try await hintSearch.bestHint(in: snapshot, stockDrawCount: drawCount)
} catch {
self?.finishHintSearch(requestID)
return
}

guard let self, self.hintSearchID == requestID else { return }
self.finishHintSearch(requestID)
guard !Task.isCancelled,
self.state == snapshot,
self.stockDrawCount == drawCount,
!self.isWin, !self.isDragging, self.pendingAutoMove == nil else { return }

guard let hint else {
// Only a completed search of the current position may disable hints.
self.isHintAvailable = false
HapticManager.shared.play(.invalidDrop)
return
}
self.activeHint = hint
self.hintWiggleToken = UUID()
self.scheduleHintAutoClear(for: hint)
self.hintRequestsInCurrentGame += 1
HapticManager.shared.play(.hintFound)
}
hintSearchTask = task
return task
}

private func finishHintSearch(_ requestID: UUID) {
guard hintSearchID == requestID else { return }
hintSearchTask = nil
hintSearchID = nil
isSearchingForHint = false
}

activeHint = hint
hintWiggleToken = UUID()
scheduleHintAutoClear(for: hint)
hintRequestsInCurrentGame += 1
HapticManager.shared.play(.hintFound)
private func cancelHintSearch() {
hintSearchTask?.cancel()
hintSearchTask = nil
hintSearchID = nil
isSearchingForHint = false
}

func clearHint() {
cancelHintSearch()
hintAutoClearToken = UUID()
activeHint = nil
}
Expand All @@ -224,6 +272,7 @@ final class SolitaireViewModel {
}

func resetStatisticsTracking() {
cancelHintSearch()
hasStartedTrackedGame = false
isCurrentGameFinalized = true
hintRequestsInCurrentGame = 0
Expand Down Expand Up @@ -265,6 +314,7 @@ final class SolitaireViewModel {

@discardableResult
func pauseTimeScoring(at date: Date = .now) -> Bool {
cancelHintSearch()
guard !hasAppliedTimeBonus else { return false }
guard pauseStartedAt == nil else { return false }
pauseStartedAt = date
Expand Down
18 changes: 18 additions & 0 deletions ComputerSolitaire/Game/Shared/HintSearchWorker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

nonisolated protocol HintSearching: Sendable {
func bestHint(in state: GameState, stockDrawCount: Int) async throws -> HintAdvisor.Hint?
}

/// Owns one session's mutable plan caches. Searching never suspends inside
/// this actor, so subsequent requests cannot interleave with cache updates.
actor HintSearchWorker: HintSearching {
private let planner = HintPlanner()

func bestHint(in state: GameState, stockDrawCount: Int) throws -> HintAdvisor.Hint? {
try Task.checkCancellation()
let hint = planner.bestHint(in: state, stockDrawCount: stockDrawCount)
try Task.checkCancellation()
return hint
}
}
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/Spider/SpiderPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ nonisolated private extension SpiderPlanner {
var best: (index: Int, score: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/TriPeaks/TriPeaksPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,11 @@ nonisolated private extension TriPeaksPlanner {
var best: (index: Int, remaining: Int, depth: Int)?

while let nodeIndex = pending.popLast() {
if Task.isCancelled {
wasTruncated = true
break
}

let node = nodes[nodeIndex]
let remaining = node.board.remainingCount

Expand Down
5 changes: 5 additions & 0 deletions ComputerSolitaire/Game/Yukon/YukonPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ nonisolated private extension YukonPlanner {
var best: (index: Int, score: Int, depth: Int)?

while let entry = heap.pop() {
if Task.isCancelled {
wasTruncated = true
break
}

let nodeIndex = entry.index
let node = nodes[nodeIndex]

Expand Down
8 changes: 8 additions & 0 deletions ComputerSolitaire/Views/Shared/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ struct ContentView: View {
}
}
}
.onChange(of: viewModel.hintWiggleToken) { _, _ in
// Search completion can arrive after the board's pending autosave.
scheduleAutosave()
}
.onChange(of: viewModel.pendingAutoMove?.id) { _, _ in
processPendingAutoMoveIfPossible()
queueAutoFinishStepIfPossible()
Expand Down Expand Up @@ -597,6 +601,7 @@ struct ContentView: View {
initializeGameIfNeeded()
}
.onDisappear {
viewModel.clearHint()
persistGameNow()
}
#if os(macOS)
Expand Down Expand Up @@ -1099,6 +1104,7 @@ struct ContentView: View {

private var isHintDisabled: Bool {
viewModel.isWin
|| viewModel.isSearchingForHint
|| isUndoAnimating
|| isDroppingCards
|| isReturningDrag
Expand Down Expand Up @@ -1291,6 +1297,7 @@ struct ContentView: View {
/// Clears in-flight drag/drop/undo/draw animation state so stale animation
/// completions cannot mutate the game that replaces the current one.
private func resetTransientBoardState() {
viewModel.clearHint()
drag.reset()
overlayTilt = 0
dragReturnOffset = .zero
Expand Down Expand Up @@ -1319,6 +1326,7 @@ struct ContentView: View {

private func startAutoFinish() {
guard !isAutoFinishDisabled else { return }
viewModel.clearHint()
HapticManager.shared.play(.autoFinishStart)
isAutoFinishing = true
queueAutoFinishStepIfPossible()
Expand Down
Loading