crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 7ad5a2aa4a014be581bdbd89cfb50e866c768e16
parent ad58c7f003eb953fa1c8cf24651eab69d7b12b45
Author: Michael Camilleri <[email protected]>
Date:   Mon,  1 Jun 2026 08:11:59 +0900

Retire the live session when a puzzle is completed

A completed game kept behaving like a live collaborative session: the
engagement room stayed up, and a present peer's cursor still tracked
across the grid — including when the puzzle was reopened from the
Completed list, where activateSharing re-offered engagement and the
roster's persisted cursor was gated only on peer presence.

Completion is durable (Game.completedAt), so a just-solved puzzle and
one reopened from Completed are the same state and now get the same
handling.  reconcileEngagement — the single chokepoint behind the open
path, the reconnect tick, the lease tick, and the manual offer — bails
when the game is completed, reconciling the coordinator to "no room" and
refusing even a forced offer, so a finished puzzle never (re)connects.
The onComplete callback additionally tears the room down at the moment
of completion via endEngagement, which is idempotent across the repeated
observed/on-appear completions. The peer that learns of the win through
synced Moves runs the same observed-completion path, so teardown is
symmetric.

The peer cursor is suppressed separately from the room, since
endEngagement only clears the live engagement cursors — a present peer's
persisted Player cursor would otherwise survive. GridView gains a
showsPeerCursors flag (PuzzleView passes !isSolved) that gates only the
remote word-tint overlay; author tints stay on so the finished grid
keeps its per-author colouring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 4++++
MCrossmate/Persistence/GameStore.swift | 10++++++++++
MCrossmate/Services/AppServices.swift | 9+++++++++
MCrossmate/Views/GridView.swift | 6+++++-
MCrossmate/Views/PuzzleView.swift | 1+
5 files changed, 29 insertions(+), 1 deletion(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -433,6 +433,10 @@ private struct PuzzleDisplayView: View { } } } + // The game is done — drop the other player's cursor + // and tear the live room down. Idempotent, so the + // repeated observed/on-appear completions are safe. + Task { await services.endEngagement(gameID: gameID) } } catch { services.announcements.post(Announcement( id: "mark-completed-error-\(gameID.uuidString)", diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -683,6 +683,16 @@ final class GameStore { // MARK: - Engagement room + /// `true` once `gameID` has been completed (solved or resigned). A + /// completed game is no longer a live collaborative session, so engagement + /// is torn down and peer cursors are suppressed for it. + func isCompleted(gameID: UUID) -> Bool { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + return (try? context.fetch(request).first)?.completedAt != nil + } + /// The shared live-engagement room creds for `gameID` (an encoded /// `EngagementRoomCredentials`), or nil if none has been minted yet. func engagement(for gameID: UUID) -> String? { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1017,6 +1017,15 @@ final class AppServices { /// which mints and connects without waiting to see a present peer. func reconcileEngagement(gameID: UUID, force: Bool = false) async { guard preferences.isICloudSyncEnabled else { return } + // A completed game is not a live session. Never connect (this is also + // the reopen-from-Completed path, which re-runs through + // `startEngagementIfPossible`) and tear down anything still up — even + // a `force` manual offer is refused once the puzzle is done. + guard !store.isCompleted(gameID: gameID) else { + cancelEngagementLeaseExpiry(gameID: gameID) + await engagementCoordinator.reconcile(gameID: gameID, creds: nil, hasPeer: false) + return + } let soonestLease = await Self.soonestPeerLease( persistence: persistence, gameID: gameID, diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -4,6 +4,10 @@ struct GridView: View { @Bindable var session: PlayerSession let roster: PlayerRoster let showsSharedAnnotations: Bool + /// Whether to render peers' live cursor tracks. Off for a solved puzzle — + /// the game is no longer a live session, so the other player's cursor is + /// dropped while author tints stay to colour the finished grid. + var showsPeerCursors: Bool = true /// When non-nil, the grid renders this reconstructed history (finish-banner /// replay) instead of the live `Game`: each touched cell shows the /// after-state at the current scrub position, blanks elsewhere. Live @@ -21,7 +25,7 @@ struct GridView: View { let width = session.puzzle.width let height = session.puzzle.height let tintByCell: [GridPosition: Color] = - (showsSharedAnnotations && !isReplaying) ? remoteTrackTints() : [:] + (showsSharedAnnotations && showsPeerCursors && !isReplaying) ? remoteTrackTints() : [:] // Author colours are shown for shared live play and for any replay // (the rewind reads as coloured per author, matching the scoreboard). let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying) diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -295,6 +295,7 @@ struct PuzzleView: View { session: session, roster: roster, showsSharedAnnotations: session.mutator.isShared, + showsPeerCursors: !isSolved, replayCells: replay.gridOverride, replayCursor: replay.cursor )