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:
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
)