crossmate

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

commit 5bbbfa443938d15600a3e7ce670fa8b773396c63
parent 51d8695b45911984b64eba1d3847bd899dac5097
Author: Michael Camilleri <[email protected]>
Date:   Mon,  1 Jun 2026 14:54:48 +0900

Coalesce a bulk gesture into one live update

A whole-puzzle check on a co-solver's device fanned out one live
cellEdit per cell over the engagement channel — emitMove fires
onLocalCellEdit for every cell, and applyBulk loops it across the grid.
So one gesture sent ~150 messages stamped at the same instant; the
receiver ran ~150 applyRealtimeCellEdit saves and the marks trickled
onto the peer's grid over a ~15-17s growing-latency backlog before
catching up. The feature the other solver wanted — seeing which letters
are wrong, live — arrived as a slow crawl rather than in one frame.

The per-cell broadcast is now buffered for the duration of a bulk
gesture and flushed once. GameMutator.collectingBroadcast wraps the
applyBulk and applyRestores loops; emitMove parks its RealtimeCellEdit
in the buffer instead of broadcasting when one is active. A one-cell
result still goes out as the legacy single .cellEdit — so single-cell
co-typing is untouched and stays wire-compatible — while a multi-cell
result ships a new EngagementMessage kind, .cellEditBatch, carrying the
whole array under one sentAt. The receiver applies it via
GameStore.applyRealtimeCellEdits, which groups edits by Moves record and
decodes/encodes/saves once regardless of cell count, preserving per-cell
last-writer-wins; applyRealtimeCellEdit is now a wrapper over it.

The durable path is unchanged: emitMove still journals and enqueues each
cell to MovesUpdater, so a peer that predates .cellEditBatch drops the
undecodable live message but still receives the same cells via Moves
sync — bulk gestures degrade to 'appears on sync' rather than breaking.

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

Diffstat:
MCrossmate/Persistence/GameMutator.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MCrossmate/Persistence/GameStore.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
MCrossmate/Services/AppServices.swift | 16++++++++++++++++
MCrossmate/Sync/EngagementCoordinator.swift | 38++++++++++++++++++++++++++++++++++++++
4 files changed, 194 insertions(+), 69 deletions(-)

diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -19,6 +19,13 @@ final class GameMutator { private let movesJournal: MovesJournal? private let authorIDProvider: (@MainActor () -> String?)? private let onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? + private let onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? + + /// While non-nil, `emitMove` parks its live broadcast here instead of + /// firing `onLocalCellEdit` per cell. A bulk gesture (check/clear/undo of a + /// batch) thus ships one engagement message rather than one per cell, so + /// the peer's grid lights up in a single frame instead of trickling. + private var batchBroadcastBuffer: [RealtimeCellEdit]? /// `true` when the current user owns the CloudKit zone for this game. let isOwned: Bool @@ -40,6 +47,7 @@ final class GameMutator { movesJournal: MovesJournal? = nil, authorIDProvider: (@MainActor () -> String?)? = nil, onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? = nil, + onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? = nil, isOwned: Bool = true, isShared: Bool = false, isAccessRevoked: Bool = false @@ -50,6 +58,7 @@ final class GameMutator { self.movesJournal = movesJournal self.authorIDProvider = authorIDProvider self.onLocalCellEdit = onLocalCellEdit + self.onLocalCellEditBatch = onLocalCellEditBatch self.isOwned = isOwned self.isShared = isShared self.isAccessRevoked = isAccessRevoked @@ -99,13 +108,15 @@ final class GameMutator { let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) } mutate(applicable) let batch = UUID() - for (cell, priorState) in zip(applicable, before) { - emitMove( - atRow: cell.row, - atCol: cell.col, - journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col), - batchID: batch - ) + collectingBroadcast { + for (cell, priorState) in zip(applicable, before) { + emitMove( + atRow: cell.row, + atCol: cell.col, + journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col), + batchID: batch + ) + } } } @@ -176,24 +187,26 @@ final class GameMutator { private func applyRestores(_ restores: [JournalRestore], kind: JournalKind) -> Bool { let batch = UUID() var appliedAny = false - for restore in restores { - let row = restore.position.row - let col = restore.position.col - let square = game.squares[row][col] - let current = JournalCellState( - letter: square.entry, - mark: square.mark, - cellAuthorID: square.letterAuthorID - ) - guard current.letterMatches(restore.expectedCurrent) else { continue } - game.applyCellState( - restore.restoreTo.letter, - mark: restore.restoreTo.mark, - authorID: restore.restoreTo.cellAuthorID, - atRow: row, atCol: col - ) - emitMove(atRow: row, atCol: col, journalKind: kind, batchID: batch, targetSeq: restore.targetSeq) - appliedAny = true + collectingBroadcast { + for restore in restores { + let row = restore.position.row + let col = restore.position.col + let square = game.squares[row][col] + let current = JournalCellState( + letter: square.entry, + mark: square.mark, + cellAuthorID: square.letterAuthorID + ) + guard current.letterMatches(restore.expectedCurrent) else { continue } + game.applyCellState( + restore.restoreTo.letter, + mark: restore.restoreTo.mark, + authorID: restore.restoreTo.cellAuthorID, + atRow: row, atCol: col + ) + emitMove(atRow: row, atCol: col, journalKind: kind, batchID: batch, targetSeq: restore.targetSeq) + appliedAny = true + } } return appliedAny } @@ -216,6 +229,24 @@ final class GameMutator { // MARK: - Helpers + /// Runs `body` with per-cell live broadcasts buffered, then flushes them as + /// a single message. A one-cell result degrades to the legacy single-cell + /// `onLocalCellEdit` path, so it stays wire-compatible with peers that + /// don't understand batches; only genuinely multi-cell gestures send a + /// batch. The durable Moves/journal writes in `emitMove` are unaffected — + /// only the live overlay is coalesced. + private func collectingBroadcast(_ body: () -> Void) { + batchBroadcastBuffer = [] + body() + let edits = batchBroadcastBuffer ?? [] + batchBroadcastBuffer = nil + switch edits.count { + case 0: break + case 1: onLocalCellEdit?(edits[0]) + default: onLocalCellEditBatch?(edits) + } + } + private func emitMove( atRow row: Int, atCol col: Int, @@ -262,7 +293,7 @@ final class GameMutator { let enqueuedAt = Date() game.squares[row][col].enqueuedAt = enqueuedAt if let actingAuthorID, !actingAuthorID.isEmpty { - onLocalCellEdit?(RealtimeCellEdit( + let edit = RealtimeCellEdit( gameID: id, authorID: actingAuthorID, deviceID: RecordSerializer.localDeviceID, @@ -274,7 +305,12 @@ final class GameMutator { checkedWrong: checkedWrong, updatedAt: enqueuedAt, cellAuthorID: cellAuthorID - )) + ) + if batchBroadcastBuffer != nil { + batchBroadcastBuffer?.append(edit) + } else { + onLocalCellEdit?(edit) + } } Task { await movesUpdater.enqueue( diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -245,6 +245,8 @@ final class GameStore { var onUnreadOtherMovesChanged: (() -> Void)? @ObservationIgnored var onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? + @ObservationIgnored + var onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? private let eventLog: EventLog? @@ -380,56 +382,86 @@ final class GameStore { @discardableResult func applyRealtimeCellEdit(_ edit: RealtimeCellEdit) -> Bool { - guard !edit.authorID.isEmpty, - !edit.deviceID.isEmpty, - edit.deviceID != RecordSerializer.localDeviceID, - let entity = fetchGameEntity(id: edit.gameID) - else { return false } - - let recordName = RecordSerializer.recordName( - forMovesInGame: edit.gameID, - authorID: edit.authorID, - deviceID: edit.deviceID - ) - let movesEntity = ensureMovesEntity( - recordName: recordName, - game: entity, - authorID: edit.authorID, - deviceID: edit.deviceID - ) + applyRealtimeCellEdits([edit]) > 0 + } - var cells: [GridPosition: TimestampedCell] = [:] - if let data = movesEntity.cells, !data.isEmpty { - cells = (try? MovesCodec.decode(data)) ?? [:] + /// Applies a batch of live cell edits with a single Core Data save and a + /// single UI refresh, regardless of cell count. Edits are grouped by their + /// owning Moves record (author + device), so each record is decoded and + /// re-encoded once even for a whole-grid gesture like "check puzzle". + /// Per-cell last-writer-wins is preserved. Returns the number of cells that + /// actually changed. + @discardableResult + func applyRealtimeCellEdits(_ edits: [RealtimeCellEdit]) -> Int { + let groups = Dictionary(grouping: edits) { edit in + RecordSerializer.recordName( + forMovesInGame: edit.gameID, + authorID: edit.authorID, + deviceID: edit.deviceID + ) } - let position = GridPosition(row: edit.row, col: edit.col) - let incoming = TimestampedCell( - letter: edit.letter, - markKind: edit.markKind, - checkedRight: edit.checkedRight, - checkedWrong: edit.checkedWrong, - updatedAt: edit.updatedAt, - authorID: edit.cellAuthorID - ) - if let current = cells[position], current.updatedAt > incoming.updatedAt { - return false - } + var applied = 0 + var touchedGameIDs: Set<UUID> = [] + for (recordName, groupEdits) in groups { + guard let sample = groupEdits.first, + !sample.authorID.isEmpty, + !sample.deviceID.isEmpty, + sample.deviceID != RecordSerializer.localDeviceID, + let entity = fetchGameEntity(id: sample.gameID) + else { continue } + + let movesEntity = ensureMovesEntity( + recordName: recordName, + game: entity, + authorID: sample.authorID, + deviceID: sample.deviceID + ) - cells[position] = incoming - movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data() - if (movesEntity.updatedAt ?? .distantPast) < edit.updatedAt { - movesEntity.updatedAt = edit.updatedAt - } - if (entity.updatedAt ?? .distantPast) < edit.updatedAt { - entity.updatedAt = edit.updatedAt + var cells: [GridPosition: TimestampedCell] = [:] + if let data = movesEntity.cells, !data.isEmpty { + cells = (try? MovesCodec.decode(data)) ?? [:] + } + + var latest = movesEntity.updatedAt ?? .distantPast + var changed = false + for edit in groupEdits { + let position = GridPosition(row: edit.row, col: edit.col) + let incoming = TimestampedCell( + letter: edit.letter, + markKind: edit.markKind, + checkedRight: edit.checkedRight, + checkedWrong: edit.checkedWrong, + updatedAt: edit.updatedAt, + authorID: edit.cellAuthorID + ) + if let current = cells[position], current.updatedAt > incoming.updatedAt { + continue + } + cells[position] = incoming + changed = true + applied += 1 + if edit.updatedAt > latest { latest = edit.updatedAt } + } + + guard changed else { continue } + movesEntity.cells = (try? MovesCodec.encode(cells)) ?? Data() + if (movesEntity.updatedAt ?? .distantPast) < latest { + movesEntity.updatedAt = latest + } + if (entity.updatedAt ?? .distantPast) < latest { + entity.updatedAt = latest + } + touchedGameIDs.insert(sample.gameID) } - saveContext("applyRealtimeCellEdit") - if currentEntity?.id == edit.gameID { + + guard applied > 0 else { return 0 } + saveContext("applyRealtimeCellEdits") + if let openID = currentEntity?.id, touchedGameIDs.contains(openID) { refreshCurrentGame() } onUnreadOtherMovesChanged?() - return true + return applied } /// Number of shared games with unseen other-author moves — the same @@ -1445,6 +1477,9 @@ final class GameStore { onLocalCellEdit: { [weak self] edit in self?.onLocalCellEdit?(edit) }, + onLocalCellEditBatch: { [weak self] edits in + self?.onLocalCellEditBatch?(edits) + }, isOwned: entity.databaseScope == 0, isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, isAccessRevoked: entity.isAccessRevoked diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -347,6 +347,11 @@ final class AppServices { guard self.engagementStatus.isLive(gameID: edit.gameID) else { return } Task { await self.engagementCoordinator.sendCellEdit(edit) } } + self.store.onLocalCellEditBatch = { [weak self] edits in + guard let self, let gameID = edits.first?.gameID else { return } + guard self.engagementStatus.isLive(gameID: gameID) else { return } + Task { await self.engagementCoordinator.sendCellEdits(edits) } + } } func start(appDelegate: AppDelegate) async { @@ -1276,6 +1281,17 @@ final class AppServices { "latency=\(latencyMs)ms" ) } + case .cellEditBatch: + guard let edits = envelope.cellEdits, !edits.isEmpty else { + syncMonitor.note("engagement: ignored malformed cellEditBatch \(engagementID.uuidString)") + return + } + let applied = store.applyRealtimeCellEdits(edits) + syncMonitor.note( + "engagement: applied cellEditBatch \(engagementID.uuidString) " + + "applied=\(applied)/\(edits.count) device=\(edits[0].deviceID.prefix(8)) " + + "latency=\(latencyMs)ms" + ) case .selection: guard let selection = envelope.selection else { syncMonitor.note("engagement: ignored malformed selection \(engagementID.uuidString)") diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift @@ -73,12 +73,14 @@ struct EngagementMessage: Codable, Equatable, Sendable { enum Kind: String, Codable, Sendable { case debugText case cellEdit + case cellEditBatch case selection } var kind: Kind var text: String var cellEdit: RealtimeCellEdit? + var cellEdits: [RealtimeCellEdit]? var selection: EngagementSelectionUpdate? var sentAt: Date var ver: Int @@ -87,6 +89,7 @@ struct EngagementMessage: Codable, Equatable, Sendable { kind: Kind = .debugText, text: String, cellEdit: RealtimeCellEdit? = nil, + cellEdits: [RealtimeCellEdit]? = nil, selection: EngagementSelectionUpdate? = nil, sentAt: Date = Date(), ver: Int = 1 @@ -94,6 +97,7 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.kind = kind self.text = text self.cellEdit = cellEdit + self.cellEdits = cellEdits self.selection = selection self.sentAt = sentAt self.ver = ver @@ -103,6 +107,21 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.kind = .cellEdit self.text = "" self.cellEdit = cellEdit + self.cellEdits = nil + self.selection = nil + self.sentAt = sentAt + self.ver = ver + } + + /// Carries one bulk gesture (check/clear/multi-cell undo) as a single + /// message. Peers that predate this kind fail to decode it and drop the + /// live update; the same cells still arrive durably via the Moves/CloudKit + /// path, so they degrade to "appears on sync" rather than breaking. + init(cellEdits: [RealtimeCellEdit], sentAt: Date = Date(), ver: Int = 1) { + self.kind = .cellEditBatch + self.text = "" + self.cellEdit = nil + self.cellEdits = cellEdits self.selection = nil self.sentAt = sentAt self.ver = ver @@ -112,6 +131,7 @@ struct EngagementMessage: Codable, Equatable, Sendable { self.kind = .selection self.text = "" self.cellEdit = nil + self.cellEdits = nil self.selection = selection self.sentAt = sentAt self.ver = ver @@ -301,6 +321,24 @@ actor EngagementCoordinator { } } + /// Ships a bulk gesture as one batched message instead of one per cell, so + /// a whole-grid action lands on the peer in a single frame. All edits in a + /// batch share a game (they originate from one local gesture). + func sendCellEdits(_ edits: [RealtimeCellEdit]) async { + guard let first = edits.first else { return } + guard case .live(let engagementID, _) = state(for: first.gameID) else { return } + do { + let message = EngagementMessage(cellEdits: edits) + try await host.send(engagementID: engagementID, message: message.encodedData()) + await log( + "engagement: sent cellEditBatch \(engagementID.uuidString) " + + "count=\(edits.count) device=\(first.deviceID.prefix(8))" + ) + } catch { + await log("engagement: cell edit batch send failed \(engagementID.uuidString): \(error.localizedDescription)") + } + } + func sendSelection(_ selection: EngagementSelectionUpdate) async { guard case .live(let engagementID, _) = state(for: selection.gameID) else { return } do {