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