commit 238812f5d75f66507d4d246f80e35385254d32f4
parent 5aa266d712a1616a8ffc4b03a4979f0f182abc76
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 14:08:49 +0900
Accept partial Player records without names
This commit stops applyPlayerRecord from treating a missing name field
as an invalid Player record. A partial fetch or future desired-key
change can legitimately omit name while still carrying selection,
presence, read cursor, time-log, or push-address updates, and the
previous early return dropped all of that state.
Now the name field is optional on apply. A present non-empty name still
updates the row, while a missing or empty value preserves the existing
resolved name and lets the rest of the record converge normally.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -228,7 +228,7 @@ extension SyncEngine {
guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else {
return
}
- guard let renderedName = record["name"] as? String else { return }
+ let renderedName = record["name"] as? String
let updatedAt = record["updatedAt"] as? Date
?? record.modificationDate
?? Date()
@@ -355,7 +355,7 @@ extension SyncEngine {
// An empty `name` is what older builds shipped from the selection publisher
// before the fix; treat it as "no information" rather than letting it
// clobber a previously-resolved name.
- if !renderedName.isEmpty {
+ if let renderedName, !renderedName.isEmpty {
entity.name = renderedName
}
entity.updatedAt = updatedAt
diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift
@@ -229,6 +229,56 @@ struct PlayerRecordPresenceTests {
#expect(row.updatedAt == Date(timeIntervalSince1970: 20))
}
+ @Test("Partial player record without name still applies other fields")
+ func partialPlayerRecordWithoutNameStillAppliesFields() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let game = try makeGame(in: ctx)
+ try makeExistingRemotePlayer(
+ in: ctx,
+ game: game,
+ updatedAt: Date(timeIntervalSince1970: 10),
+ selection: nil
+ )
+ let engine = makeEngine(persistence: persistence)
+
+ let recordName = RecordSerializer.recordName(
+ forPlayerInGame: gameID,
+ authorID: remoteAuthorID
+ )
+ let record = CKRecord(
+ recordType: "Player",
+ recordID: CKRecord.ID(recordName: recordName, zoneID: zoneID)
+ )
+ record["authorID"] = remoteAuthorID as CKRecordValue
+ record["updatedAt"] = Date(timeIntervalSince1970: 20) as CKRecordValue
+ record["selRow"] = Int64(3) as CKRecordValue
+ record["selCol"] = Int64(4) as CKRecordValue
+ record["selDir"] = Int64(Puzzle.Direction.down.rawValue) as CKRecordValue
+
+ var firstTimeGameIDs: [UUID] = []
+ var presenceGameIDs: [UUID] = []
+ engine.applyPlayerRecord(
+ record,
+ in: ctx,
+ localAuthorID: localAuthorID,
+ onFirstTime: { firstTimeGameIDs.append($0) },
+ onPresenceChange: { presenceGameIDs.append($0) },
+ onReadCursor: { _, _, _ in }
+ )
+
+ #expect(firstTimeGameIDs.isEmpty)
+ #expect(presenceGameIDs == [gameID])
+
+ let fetched = try fetchRemotePlayer(in: ctx)
+ let row = try #require(fetched)
+ #expect(row.name == "Remote")
+ #expect(row.updatedAt == Date(timeIntervalSince1970: 20))
+ #expect(row.selRow?.intValue == 3)
+ #expect(row.selCol?.intValue == 4)
+ #expect(row.selDir?.intValue == Puzzle.Direction.down.rawValue)
+ }
+
private func fetchLocalPlayer(in ctx: NSManagedObjectContext) throws -> PlayerEntity? {
let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
req.predicate = NSPredicate(