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