crossmate

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

commit 138801f76599506863675c7225ab4018d72b83cf
parent 88a4904b61a4e821f161cd561b8a3a5bb2e917c3
Author: Michael Camilleri <[email protected]>
Date:   Tue, 19 May 2026 19:48:43 +0900

Replace the broadcast win ping with directed completion pings

The win notification was a single broadcast .win ping emitted from PlayerSession
on the .solved transition. Because the server-side cleanup deliberately kept
every .win ping forever while dedup was per-device and FIFO-capped, that record
re-notified on sibling devices and after cache eviction — the puzzle reporting
itself solved long after the user watched it solved.

This commit lays the groundwork for addressing this: GameEntity gains
completedBy (solver authorID, round-tripped via the Game record, single-writer
so LWW is safe) and Ping gains addressee (recipient authorID; nil broadcasts)
threaded through the builder, structs, enqueue, build, and parse paths.

Completion is then rebuilt on top of those fields. markCompleted stamps
completedBy as the local author; resignGame forces it nil so resignations stay
distinguishable. AppServices.sendCompletionPings fans out one .win/.resign ping
addressed to each other roster player, wired into the in-puzzle onComplete and
onResign paths; bodyText renders .resign accordingly. The dead
lastWinCompletionState and .win onPlayerEvent emission are removed.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 8+++++++-
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Models/PlayerSession.swift | 11-----------
MCrossmate/Persistence/GameStore.swift | 6++++++
MCrossmate/Services/AppServices.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 10++++++++++
MCrossmate/Sync/SyncEngine.swift | 25++++++++++++++++++++-----
7 files changed, 92 insertions(+), 17 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -389,9 +389,15 @@ private struct PuzzleDisplayView: View { store.markCompleted(id: gameID) Task { await services.cleanupPingsAfterCompletion(gameID: gameID) + await services.sendCompletionPings(gameID: gameID, resigned: false) + } + }, + onResign: { + try store.resignGame(id: gameID) + Task { + await services.sendCompletionPings(gameID: gameID, resigned: true) } }, - onResign: { try store.resignGame(id: gameID) }, onDelete: { try store.deleteGame(id: gameID) } ) } else if let loadError { diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -10,6 +10,7 @@ <attribute name="ckZoneName" optional="YES" attributeType="String"/> <attribute name="ckZoneOwnerName" optional="YES" attributeType="String"/> <attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="completedBy" optional="YES" attributeType="String"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="gridHeight" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -48,12 +48,6 @@ final class PlayerSession { @ObservationIgnored var onPlayerEvent: ((PingKind, PingScope?) -> Void)? - /// Last completion state we fired a `.win` ping for. Tracked so the win - /// ping fires once on the `.solved` transition and not on every keystroke - /// after the puzzle is already solved. - @ObservationIgnored - private var lastWinCompletionState: Game.CompletionState? - /// Rebus mode lets the player type a multi-character value into a single /// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in /// `rebusBuffer` rather than going straight to `Game.squares`; on commit @@ -67,7 +61,6 @@ final class PlayerSession { self.game = game self.mutator = mutator self.cursorStore = cursorStore - self.lastWinCompletionState = game.completionState let puzzle = game.puzzle // Default: start at the first across clue. Fall back to the first down @@ -432,10 +425,6 @@ final class PlayerSession { private func publishTerminalCompletionState(_ state: Game.CompletionState? = nil) { let state = state ?? game.completionState guard state != .incomplete else { return } - if state == .solved, lastWinCompletionState != .solved { - onPlayerEvent?(.win, nil) - } - lastWinCompletionState = state onCompletionStateChanged?(state) } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -495,6 +495,9 @@ final class GameStore { guard let entity = currentEntity else { return } entity.completedAt = Date() + // Resignation: no solver, so `completedBy` stays nil — that's how a + // resigned game is told apart from a win. + entity.completedBy = nil entity.hasPendingSave = true try context.save() if let ckName = entity.ckRecordName { @@ -518,6 +521,9 @@ final class GameStore { guard let entity = try? context.fetch(request).first, entity.completedAt == nil else { return } entity.completedAt = Date() + // A win: stamp the solver so the Game record can distinguish wins from + // resignations and drive the directed `.win` ping's body. + entity.completedBy = authorIDProvider() entity.hasPendingSave = true try? context.save() if let ckName = entity.ckRecordName { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -362,6 +362,52 @@ final class AppServices { } } + /// Fans out the directed completion ping. The completing device has + /// already written `completedAt`/`completedBy` to its own Game record; + /// this tells every *other* roster player exactly once — `.win` for a + /// solve, `.resign` when the player gave up. Addressed per-recipient so + /// each device deletes its own copy on consumption (no central cleanup). + func sendCompletionPings(gameID: UUID, resigned: Bool) async { + guard preferences.isICloudSyncEnabled, + let localAuthorID = identity.currentID, + !localAuthorID.isEmpty + else { return } + guard await ensureICloudSyncStarted() else { return } + + let ctx = persistence.container.newBackgroundContext() + let recipients: [String] = ctx.performAndWait { + let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gReq).first else { return [] } + var authors = Set<String>() + let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + pReq.predicate = NSPredicate(format: "game == %@", game) + for p in (try? ctx.fetch(pReq)) ?? [] { + guard let a = p.authorID else { continue } + authors.insert(a) + } + authors.remove(localAuthorID) + authors.remove(CKCurrentUserDefaultName) + authors.remove("") + return Array(authors) + } + guard !recipients.isEmpty else { return } + + let playerName = preferences.name + let kind: PingKind = resigned ? .resign : .win + for recipient in recipients { + await syncEngine.enqueuePing( + kind: kind, + scope: nil, + gameID: gameID, + authorID: localAuthorID, + playerName: playerName, + addressee: recipient + ) + } + } + /// Pull-to-refresh action for the library. Discovers any zones the /// device hasn't seen yet on both database scopes, then runs the normal /// engine fetch so any in-flight changes also catch up. Bypasses @@ -1232,6 +1278,8 @@ final class AppServices { return "\(player) joined \(puzzleSuffix)" case .win: return "\(player) solved \(puzzleSuffix)" + case .resign: + return "\(player) gave up on \(puzzleSuffix)" case .check: switch ping.scope { case .square: return "\(player) checked a square in \(puzzleSuffix)" diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -158,6 +158,9 @@ enum RecordSerializer { ) { record["title"] = entity.title as CKRecordValue? record["completedAt"] = entity.completedAt as CKRecordValue? + // Solver's authorID on a win; nil for a resignation. Single-writer + // (the device that first completes the game) so plain LWW is safe. + record["completedBy"] = entity.completedBy as CKRecordValue? // Owner-side share marker. Propagated so other owner-devices can flip // their `isShared` flag without reading the zone's CKShare directly. record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue? @@ -190,6 +193,7 @@ enum RecordSerializer { kind: PingKind, scope: PingScope?, payload: String? = nil, + addressee: String? = nil, zone: CKRecordZone.ID ) -> CKRecord { let name = recordName( @@ -205,6 +209,11 @@ enum RecordSerializer { record["playerName"] = playerName as CKRecordValue record["puzzleTitle"] = puzzleTitle as CKRecordValue record["kind"] = kind.rawValue as CKRecordValue + // Directed pings target one player by authorID; nil ⇒ broadcast (every + // recipient acts on it), which keeps mixed-version peers working. + if let addressee { + record["addressee"] = addressee as CKRecordValue + } // `scope` and `payload` never co-occur across kinds; fold a legacy // `scope` into the generic payload rather than writing the now-frozen // top-level field. @@ -447,6 +456,7 @@ enum RecordSerializer { entity.title = record["title"] as? String ?? entity.title entity.completedAt = record["completedAt"] as? Date + entity.completedBy = record["completedBy"] as? String // Owner-side share marker — set on the device that created the share // and round-tripped via the Game record so other owner-devices learn // the game is shared. On participant devices `databaseScope == 1` diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -30,6 +30,10 @@ extension Notification.Name { enum PingKind: String, Sendable { case join case win + /// The completing player gave up (revealed the grid). Directed, like + /// `.win`: one per other roster player via `addressee`. The Game record's + /// `completedBy` stays nil so resigned games stay distinguishable. + case resign case check case reveal case opened @@ -78,6 +82,10 @@ struct Ping: Sendable { /// `.invite`: `{gameShareURL}`; `.check`/`.reveal`: `{scope}` (see /// PingScope); `.opened`/`.closed`: the OpenedLease. nil for join/win. let payload: String? + /// Recipient authorID for a directed ping (`.win`/`.resign`); nil ⇒ + /// broadcast — every recipient acts on it. A device ignores a ping whose + /// `addressee` is set to someone other than its own author. + let addressee: String? } struct Session: Sendable { @@ -160,6 +168,7 @@ actor SyncEngine { let kind: PingKind let scope: PingScope? let payload: String? + let addressee: String? } /// Label for the in-flight fetch — surfaced in traces so the diagnostics @@ -419,7 +428,8 @@ actor SyncEngine { gameID: UUID, authorID: String, playerName: String, - payload: String? = nil + payload: String? = nil, + addressee: String? = nil ) { let ctx = persistence.container.newBackgroundContext() let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait { @@ -470,7 +480,8 @@ actor SyncEngine { eventTimestampMs: eventTimestampMs, kind: kind, scope: scope, - payload: payload + payload: payload, + addressee: addressee ) let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -542,7 +553,8 @@ actor SyncEngine { payload: OpenedLease( leaseMs: leaseMs, sentAtMs: eventTimestampMs - ).encoded() + ).encoded(), + addressee: nil ) let zoneID = RecordSerializer.accountZoneID // Make sure the zone exists before the record write. CKSyncEngine @@ -638,7 +650,8 @@ actor SyncEngine { eventTimestampMs: eventTimestampMs, kind: .invite, scope: nil, - payload: payload + payload: payload, + addressee: nil ) let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) @@ -2090,6 +2103,7 @@ actor SyncEngine { kind: payload.kind, scope: payload.scope, payload: payload.payload, + addressee: payload.addressee, zone: zoneID ) } @@ -2585,7 +2599,8 @@ actor SyncEngine { puzzleTitle: (record["puzzleTitle"] as? String) ?? "", kind: kind, scope: scope, - payload: payloadString + payload: payloadString, + addressee: record["addressee"] as? String ) }