commit b99e52ee430b0d25dab0ac369b1d9c9d488f14d3
parent f747c91fc21655078a03d9046302309c06b2ec3e
Author: Michael Camilleri <[email protected]>
Date: Wed, 27 May 2026 13:44:47 +0900
Stamp completion from observed solved state
When realtime or synced moves complete a grid, the puzzle can become solved
without going through the local keystroke completion callback. Reconcile that
observed solved state back into GameEntity.completedAt so the library moves the
game out of In Progress.
Local wins still send completion pings and stamp the local author as
completedBy. Passive reconciliation infers completedBy from the merged Moves
provenance, using the writer of the latest winning cell so a collaborator who
supplies the final letter is credited correctly.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 74 insertions(+), 11 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -407,11 +407,17 @@ private struct PuzzleDisplayView: View {
session: session,
shareController: shareController,
roster: roster,
- onComplete: {
+ onComplete: { notifyPeers in
do {
- try store.markCompleted(id: gameID)
- Task {
- await services.sendCompletionPings(gameID: gameID, resigned: false)
+ let changed = try notifyPeers
+ ? store.markCompleted(id: gameID)
+ : store.markCompletedFromObservedSolvedState(id: gameID)
+ if changed {
+ if notifyPeers {
+ Task {
+ await services.sendCompletionPings(gameID: gameID, resigned: false)
+ }
+ }
}
} catch {
services.announcements.post(Announcement(
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -582,25 +582,42 @@ final class GameStore {
currentEntity = nil
}
- /// Marks a game as completed after a normal win. No-ops if already marked.
+ /// Marks a game as completed after a normal win. Returns whether the
+ /// entity changed; no-ops if already marked.
/// Triggers a buffer flush so the completion snapshot is created promptly
/// rather than waiting for the next keystroke or app-background event.
- func markCompleted(id: UUID) throws {
+ @discardableResult
+ func markCompleted(id: UUID) throws -> Bool {
+ try persistCompletion(id: id, completedBy: authorIDProvider())
+ }
+
+ /// Marks a game completed after the visible grid became solved through
+ /// observed state, e.g. a collaborator's realtime edit. Uses the writer of
+ /// the latest winning cell as the solver when provenance is available.
+ @discardableResult
+ func markCompletedFromObservedSolvedState(id: UUID) throws -> Bool {
+ let solver = inferredObservedCompletionAuthorID(for: id) ?? authorIDProvider()
+ return try persistCompletion(id: id, completedBy: solver)
+ }
+
+ @discardableResult
+ private func persistCompletion(id: UUID, completedBy authorID: String?) throws -> Bool {
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
guard let entity = try context.fetch(request).first,
- entity.completedAt == nil else { return }
+ entity.completedAt == nil else { return false }
entity.completedAt = Date()
// A win: stamp the solver so the Game record can distinguish wins
// from resignations and the completion APN body can name them.
- entity.completedBy = authorIDProvider()
+ entity.completedBy = authorID
entity.hasPendingSave = true
try context.save()
if let ckName = entity.ckRecordName {
onGameUpdated(ckName)
}
Task { await movesUpdater.flush() }
+ return true
}
// MARK: - Reset
@@ -912,6 +929,40 @@ final class GameStore {
)
}
+ private func inferredObservedCompletionAuthorID(for id: UUID) -> String? {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first,
+ let source = entity.puzzleSource,
+ let xd = try? XD.parse(source)
+ else { return nil }
+
+ let puzzle = Puzzle(xd: xd)
+ let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ movesRequest.predicate = NSPredicate(format: "game == %@", entity)
+ let movesEntities = (try? context.fetch(movesRequest)) ?? []
+ let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) }
+ let provenance = GridStateMerger.mergeWithProvenance(values)
+
+ var latest: (date: Date, authorID: String)?
+ for row in puzzle.cells {
+ for cell in row {
+ guard !cell.isBlock, cell.solution != nil else { continue }
+ let position = GridPosition(row: cell.row, col: cell.col)
+ guard let winner = provenance[position],
+ !winner.cell.letter.isEmpty,
+ cell.accepts(winner.cell.letter)
+ else { return nil }
+
+ if latest.map({ winner.cell.updatedAt > $0.date }) ?? true {
+ latest = (winner.cell.updatedAt, winner.writerAuthorID)
+ }
+ }
+ }
+ return latest?.authorID
+ }
+
/// Reconciles a `GameEntity`'s `CellEntity` cache against `grid` inside
/// `ctx`. Caller is responsible for saving `ctx`. Used from both the
/// main-context `updateCellCache` and the background-context
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -26,7 +26,7 @@ struct PuzzleView: View {
@Bindable var session: PlayerSession
var shareController: ShareController? = nil
let roster: PlayerRoster
- var onComplete: (() -> Void)? = nil
+ var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil
var onResign: (() throws -> Void)? = nil
var onDelete: (() throws -> Void)? = nil
@Environment(InputMonitor.self) private var inputMonitor
@@ -124,7 +124,10 @@ struct PuzzleView: View {
roster: roster,
hasSolved: $hasSolved,
onLocalCompletionStateChanged: handleLocalCompletionState,
- onObservedCompletionStateChanged: handleObservedCompletionState
+ onObservedCompletionStateChanged: handleObservedCompletionState,
+ onSolvedOnAppear: {
+ onComplete?(false)
+ }
))
.modifier(PuzzlePresentationModifier(
session: session,
@@ -255,7 +258,7 @@ struct PuzzleView: View {
session.togglePencil()
}
Task { @MainActor in
- onComplete?()
+ onComplete?(true)
}
}
}
@@ -268,6 +271,7 @@ struct PuzzleView: View {
showErrorsAlert = true
case .solved:
hasSolved = true
+ onComplete?(false)
}
}
@@ -971,6 +975,7 @@ private struct PuzzleLifecycleModifier: ViewModifier {
@Binding var hasSolved: Bool
let onLocalCompletionStateChanged: (Game.CompletionState) -> Void
let onObservedCompletionStateChanged: (Game.CompletionState) -> Void
+ let onSolvedOnAppear: () -> Void
func body(content: Content) -> some View {
content
@@ -980,6 +985,7 @@ private struct PuzzleLifecycleModifier: ViewModifier {
.onAppear {
if session.game.completionState == .solved {
hasSolved = true
+ onSolvedOnAppear()
}
}
.onChange(of: session.game.completionState) { _, newValue in