crossmate

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

commit 2c1d092d6407809ef26969b58ae390124bae475d
parent 27a4f6d9cafd0152653d0b06314c39105cacc29f
Author: Michael Camilleri <[email protected]>
Date:   Mon, 17 Aug 2026 14:31:31 +0900

Show collaborator changes after the first puzzle leave

A newly shared puzzle could reopen without the 'Puzzle Updated' banner
or changed-cell borders even though a collaborator edited it while the
user was away. The reason was that the peer-change ledger was first
built only when those inbound Moves arrived, so the empty-ledger seed
stamped the collaborator's snapshot in the distant past and hid it
behind the valid viewedAt cutoff.

This commit establishes the ledger while the puzzle is visible and
records a durable seed flag, including for an intentionally empty grid.
Leaving pairs the viewed baseline with the same seed request, while
existing ledger rows migrate as already seeded. Later collaborator fills
retain their real timestamps without making check re-stamps look like
fresh activity.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 8++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Persistence/GameStore.swift | 27+++++++++++++++++++++++----
MCrossmate/Sync/PeerChangeLedger.swift | 12+++++++-----
MTests/Unit/Sync/SessionMonitorTests.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 88 insertions(+), 9 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -1206,6 +1206,10 @@ private struct PuzzleDisplayView: View { /// interaction (via `PuzzleView`'s acknowledgement) and on leave/background. private func stampPuzzleViewed() { guard session?.mutator.isShared == true else { return } + // Pair the away baseline with a seed request. Sharing activation + // normally established it already; this also covers a very quick leave + // and ensures an empty grid is recorded as an intentional snapshot. + store.ensurePeerChangeLedgerSeeded(for: gameID) services.gameViewedStore.advance(Date(), forGame: gameID) } @@ -1228,6 +1232,10 @@ private struct PuzzleDisplayView: View { /// the open session. Called when the puzzle first appears as shared, and /// again if a previously-solo game becomes shared mid-session. private func activateSharing(for session: PlayerSession, refreshRoster: Bool = true) async { + // Establish the local letter snapshot while the puzzle is visible, so + // the first inbound Moves record can be compared with it. This also + // records an intentionally empty snapshot for a puzzle with no entries. + store.ensurePeerChangeLedgerSeeded(for: gameID) Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } let activeRoster: PlayerRoster if let roster { diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -23,6 +23,7 @@ <attribute name="gridHeight" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="gridWidth" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="hasPendingSave" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="hasSeededPeerChanges" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="isHidden" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -837,6 +837,20 @@ final class GameStore { private var ledgerRequests: AsyncStream<Set<UUID>>.Continuation? private var peerChangeLedgerBuildSerial = 0 + /// Establishes the silent starting snapshot for a shared game's peer-change + /// ledger. The durable flag is separate from the rows because an empty grid + /// is still a valid seed; without it, the first later peer fill would be + /// mistaken for pre-existing content and stamped at `.distantPast`. + func ensurePeerChangeLedgerSeeded(for gameID: UUID) { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let game = try? context.fetch(request).first, + game.completedAt == nil, + !game.hasSeededPeerChanges else { return } + enqueuePeerChangeLedgerUpdate(for: [gameID]) + } + /// Fire-and-forget request to refresh the peer-change ledger for `gameIDs`. /// Returns immediately — the inbound-moves hot path must never wait on this /// database write. The request is just buffered onto the serial queue; the @@ -873,8 +887,9 @@ final class GameStore { /// banner read (`recentChanges(forGame:since:)`). Recording a letter-change /// time — rather than trusting the synced cell's `updatedAt`, which a check /// bumps — is what stops a peer's check sweep from flagging the whole board - /// on rejoin. A first build for a game (no rows yet) seeds every current - /// cell at `.distantPast`, a silent baseline that surfaces nothing. + /// on rejoin. A game with neither the durable seed flag nor legacy ledger + /// rows records every current cell at `.distantPast`, a silent baseline that + /// surfaces nothing; the separate flag lets an empty grid count as seeded. /// /// In the app this is driven through `enqueuePeerChangeLedgerUpdate`, whose /// single serial consumer guarantees builds never overlap — so the @@ -924,6 +939,9 @@ final class GameStore { let values: [MovesValue] = ((try? ctx.fetch(movesReq)) ?? []) .compactMap { Self.movesValue(from: $0) } let current = GridStateMerger.mergeWithProvenance(values) + // Rows written by an older app already prove that game was + // seeded; honour them when migrating the new durable flag. + let isSeeding = !game.hasSeededPeerChanges && existingRows.isEmpty var rowByPosition: [GridPosition: PeerChangeEntity] = [:] for row in existingRows { @@ -934,12 +952,13 @@ final class GameStore { let upserts = PeerChangeLedger.upserts( current: current, recorded: recorded, - seeding: recorded.isEmpty + seeding: isSeeding ) + game.hasSeededPeerChanges = true diagnostics.append( "\(gameID.uuidString.prefix(8)) existing=\(existingRows.count) " + "moves=\(values.count) current=\(current.count) " - + "seeding=\(recorded.isEmpty) upserts=\(upserts.count) " + + "seeding=\(isSeeding) upserts=\(upserts.count) " + Self.peerChangeSampleSummary(upserts) ) for change in upserts { diff --git a/Crossmate/Sync/PeerChangeLedger.swift b/Crossmate/Sync/PeerChangeLedger.swift @@ -32,11 +32,13 @@ enum PeerChangeLedger { /// author (survives a check), falling back to whoever wrote the winning /// move when a cleared cell carries no preserved author. /// - /// `seeding` is set when the ledger has no rows for this game yet (a first - /// build, e.g. just after upgrade). Every current cell is then recorded at - /// `.distantPast`, establishing a silent baseline so the first build never - /// surfaces pre-existing content as "changed while you were away". Genuine - /// changes after the seed carry the move's real `updatedAt`. + /// `seeding` is set until the game records that its first build completed. + /// Legacy rows also prove an older app completed that build, but otherwise + /// the state cannot be inferred from row count: an empty grid is a valid + /// silent baseline. Every current cell in that first build is recorded at + /// `.distantPast`, so pre-existing content does not surface as "changed + /// while you were away". Genuine later changes carry the move's real + /// `updatedAt`. static func upserts( current: [GridPosition: GridStateMerger.Provenance], recorded: [GridPosition: PeerChange], diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -308,6 +308,55 @@ struct SessionMonitorTests { // MARK: - Integration: the moves→ledger writer + @Test("An empty seed preserves the first later peer fill") + func emptySeedPreservesFirstPeerFill() async throws { + let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") + + // The user leaves before anyone has entered a letter. This first build + // must still persist that the empty snapshot has been established. + await buildLedger(in: fixture) + + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [position(0, 0): ("A", after)] + ) + await buildLedger(in: fixture) + + let summary = try #require( + fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first + ) + #expect(summary.added == 1) + #expect(summary.cleared == 0) + } + + @Test("Legacy ledger rows preserve a later peer fill during migration") + func legacyLedgerRowsPreserveLaterPeerFill() async throws { + let fixture = try makeFixture() + try addPlayer(in: fixture, authorID: Self.alice, name: "Alice") + try writeLedger( + in: fixture, + authorID: Self.alice, + cells: [position(2, 2): ("H", before)] + ) + try writeMoves( + in: fixture, + authorID: Self.alice, + cells: [ + position(2, 2): ("H", before), + position(0, 0): ("A", after), + ] + ) + + await buildLedger(in: fixture) + + let summary = try #require( + fixture.monitor.summaries(for: fixture.gameID, since: Self.cutoff).first + ) + #expect(summary.added == 1) + } + @Test("A peer's fills drive the ledger and surface as a summary") func fillsDriveLedger() async throws { let fixture = try makeFixture()