crossmate

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

commit 49b44688003889ee15894ddee6182c7edacd813c
parent de84aac82ac6573ced60bf1b4eb6313701b70ecb
Author: Michael Camilleri <[email protected]>
Date:   Wed, 20 May 2026 07:55:01 +0900

Support seenOtherAt cursor on the Player record

GameEntity.lastSeenOtherMoveAt drives the unseen-moves badge but is per-device.
Cross-device agreement between a user's own devices currently relies on the
.opened lease ping's markOtherMovesSeenWithoutLoading side effect — a 2-min
refresh that accumulates in the account zone, gets replayed through an
epoch-floored fast path on every cold launch, and overruns the 200-entry
shownPingNames FIFO that also dedupes directed win/resign pings.

The Player record is already keyed by (gameID, authorID) and syncs to every
device of one iCloud account, so it's the natural carrier for the cursor. A
sibling merges the inbound field into its own lastSeenOtherMoveAt as a
monotonic max, with no presence stream needed.

This commit is plumbing only. RecordSerializer.playerRecord gains an optional
seenOtherAt parameter with a default so the existing builder caller stays
compiling; parsePlayerSeenOtherAt mirrors parsePlayerSelection. The reader, the
writer, the deletion of the .opened/.closed subsystem, and the one-shot
migration that purges legacy lease records all land in the follow-up commit.

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

Diffstat:
MCrossmate/Sync/RecordSerializer.swift | 17+++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 36++++++++++++++++++++++++++++++++++++
2 files changed, 53 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -255,6 +255,7 @@ enum RecordSerializer { name: String, updatedAt: Date, selection: PlayerSelection?, + seenOtherAt: Date? = nil, zone: CKRecordZone.ID, systemFields: Data? ) -> CKRecord { @@ -278,10 +279,26 @@ enum RecordSerializer { record["selCol"] = nil record["selDir"] = nil } + if let seenOtherAt { + record["seenOtherAt"] = seenOtherAt as CKRecordValue + } else { + record["seenOtherAt"] = nil + } return record } + /// Reads `seenOtherAt` off an inbound Player record — the per-account + /// cursor of how far this user has seen their collaborator's moves. Any + /// of the account's own devices writes it; sibling devices merge it into + /// `GameEntity.lastSeenOtherMoveAt` (monotonic max) so the unseen-moves + /// badge agrees across the user's devices without a presence ping. + /// Returns `nil` if the field is missing — older records, or a slot that + /// has not yet recorded a view. + static func parsePlayerSeenOtherAt(from record: CKRecord) -> Date? { + record["seenOtherAt"] as? Date + } + /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These /// fields carry the peer's cursor track start, not their exact local /// reticle. Returns `nil` if any field is missing — the peer either hasn't diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -68,6 +68,42 @@ struct RecordSerializerTests { #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil) } + @Test("playerRecord writes seenOtherAt and parses it back") + func playerRecordSeenOtherAtRoundTrip() { + let id = UUID() + let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) + let seen = Date(timeIntervalSince1970: 1_700_000_000) + let record = RecordSerializer.playerRecord( + gameID: id, + authorID: "alice", + name: "Alice", + updatedAt: Date(timeIntervalSince1970: 1_700_000_100), + selection: nil, + seenOtherAt: seen, + zone: zone, + systemFields: nil + ) + #expect(record["seenOtherAt"] as? Date == seen) + #expect(RecordSerializer.parsePlayerSeenOtherAt(from: record) == seen) + } + + @Test("playerRecord omits seenOtherAt when nil and parser returns nil") + func playerRecordSeenOtherAtNil() { + let id = UUID() + let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) + let record = RecordSerializer.playerRecord( + gameID: id, + authorID: "alice", + name: "Alice", + updatedAt: Date(timeIntervalSince1970: 1_700_000_100), + selection: nil, + zone: zone, + systemFields: nil + ) + #expect(record["seenOtherAt"] == nil) + #expect(RecordSerializer.parsePlayerSeenOtherAt(from: record) == nil) + } + // MARK: - Ping @Test("recordName(forPingInGame:authorID:deviceID:eventTimestampMs:) includes deviceID")