commit 33a3e34f8cfee4d2ddc553a855b0d3bfaa8e0cb0
parent 5d5aad03c598a6923b6c9b9ea14e7789ab8fd59c
Author: Michael Camilleri <[email protected]>
Date: Thu, 11 Jun 2026 11:52:21 +0900
Sync display names as versioned 'name' Decisions in friend zones
The friend picker could show a friend as 'Player' forever:
FriendEntity.displayName was written exactly once, at friendship
bootstrap, with whatever the collaborator's Player record carried at
first sighting — typically the default name, before they had named
themselves. A renames would not update it. The rename path itself fanned
the new name out to a Player record in every active shared game, so it
scaled with games rather than friends and had no way to reach a friend
once the last shared game was completed or deleted.
A display name is now a 'name' Decision: 'decision-name-<authorID>'
with the name as payload and a monotonic integer generation as its
version. The author writes their own copy into their account zone and
into every non-blocked friend zone — the pairwise channel both sides
can already write — so a rename publishes one record per friendship,
works after every shared game is gone, and converges the author's own
devices through the account zone. Friendship bootstrap seeds the local
name into the new zone at the current un-bumped generation, so a seed
can never outrank a real rename; each rename bumps the generation once
and rides the existing version-wins conflict path that the push secret
established. Inbound echoes of the user's own Decision adopt the
generation into the local counter so a rename on another device cannot
collide with this one's next bump.
On the apply side the Decision is honored only when its claimed
authorID hashes (with the local author, via the pair key) to the very
zone it arrived in, so neither participant can assert a name for the
other or for a third party. Adoption is last-writer-wins on the stored
displayNameVersion, and a Decision arriving for an unknown pair
resurrects the FriendEntity from its zone — a restored device recovers
friendships whose bootstrap evidence (game zones, '.friend' Ping) no
longer exists. The pending-decision payload, version, and system-fields
maps were keyed by bare record name, which collides once the same
Decision is pending in several zones (the first zone to save stripped
the payload the others still needed); they are now keyed by zone and
name, and the settle/conflict-retry paths use the engine the sent event
arrived on instead of assuming decisions only ride the private engine.
The invite surfaces resolve a friend's name as the Decision-fed
displayName first, then the freshest per-game Player snapshot, then
'Player'. Player records still carry a name snapshot written once at
game open, which is what collaborators see before a friendship's first
Decision lands and what not-yet-friended participants fall back to; the
old whole-library rename fan-out and the bootstrap-time displayName
seeding are removed.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
15 files changed, 809 insertions(+), 252 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ 00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */; };
00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */; };
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; };
0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */; };
@@ -304,6 +305,7 @@
9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; };
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; };
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; };
+ 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FriendEntity+DisplayName.swift"; sourceTree = "<group>"; };
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; };
A8C18E9B47668E008BE4CF86 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = "<group>"; };
A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; };
@@ -465,6 +467,7 @@
B135C285570F91181595B405 /* CellMark.swift */,
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */,
B09D52DB46731E92C3E9297C /* EngagementStore.swift */,
+ 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */,
7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */,
@@ -861,6 +864,7 @@
06AE6DF7AA3274480C591E47 /* EngagementStore.swift in Sources */,
4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */,
C8ACF431021E7BEE61A99153 /* FriendController.swift in Sources */,
+ 00A25F5D8DFF62EFA0C4D1D7 /* FriendEntity+DisplayName.swift in Sources */,
886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */,
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */,
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -97,6 +97,7 @@
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="databaseScope" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="displayName" optional="YES" attributeType="String" defaultValueString=""/>
+ <attribute name="displayNameVersion" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="friendZoneName" attributeType="String"/>
<attribute name="friendZoneOwnerName" attributeType="String"/>
<attribute name="isBlocked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
diff --git a/Crossmate/Models/FriendEntity+DisplayName.swift b/Crossmate/Models/FriendEntity+DisplayName.swift
@@ -0,0 +1,33 @@
+import CoreData
+import Foundation
+
+extension FriendEntity {
+ /// The name the invite surfaces should show for this friend.
+ ///
+ /// `displayName` is fed exclusively by the friend's `name` Decision in the
+ /// pairwise friend zone (`RecordSerializer.applyDecisionRecord`), so it is
+ /// the live, rename-following value. Until the first Decision syncs the
+ /// fallback is the freshest per-game `Player` snapshot the friend wrote at
+ /// game open, then the "Player" placeholder.
+ var resolvedDisplayName: String {
+ if let name = displayName?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ return name
+ }
+ if let authorID, !authorID.isEmpty, let ctx = managedObjectContext {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(
+ format: "authorID == %@ AND name != nil AND name != %@",
+ authorID, ""
+ )
+ req.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
+ req.fetchLimit = 1
+ if let name = (try? ctx.fetch(req).first)?.name?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty {
+ return name
+ }
+ }
+ return "Player"
+ }
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -675,9 +675,10 @@ final class AppServices {
await self.accountPush.publishAccountJoinedPush(gameID: gameID)
}
- // PlayerNamePublisher fans out name changes to active shared/joined
- // games. PuzzleDisplayView publishes the open game's name directly,
- // which covers first-sync-after-share-create / accept.
+ // PlayerNamePublisher fans out name changes as `name` Decisions to the
+ // account zone and every friend zone. PuzzleDisplayView publishes the
+ // open game's Player-record name snapshot directly, which covers
+ // first-sync-after-share-create / accept and pre-friendship display.
playerNamePublisher = PlayerNamePublisher(
preferences: preferences,
persistence: persistence,
@@ -690,6 +691,17 @@ final class AppServices {
authorID: authorID,
reason: reason
)
+ },
+ enqueueNameDecision: { [preferences, syncEngine] authorID, name, version, zoneID, scope in
+ let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
+ guard isEnabled else { return }
+ await syncEngine.enqueueNameDecision(
+ authorID: authorID,
+ name: name,
+ version: version,
+ zoneID: zoneID,
+ scope: scope
+ )
}
)
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -106,8 +106,8 @@ final class InviteCoordinator {
else { return }
let ctx = persistence.container.newBackgroundContext()
- let candidates: [(gameID: UUID, remoteAuthorID: String, remoteDisplayName: String?)] = ctx.performAndWait {
- var result: [(UUID, String, String?)] = []
+ let candidates: [(gameID: UUID, remoteAuthorID: String)] = ctx.performAndWait {
+ var result: [(UUID, String)] = []
for gameID in gameIDs {
let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
@@ -118,30 +118,31 @@ final class InviteCoordinator {
// Identity comes only from Player records — this feature is
// deliberately uninterested in Moves (the bootstrap trigger is
- // the first sighting of a remote Player record).
- var playersByAuthorID: [String: String?] = [:]
+ // the first sighting of a remote Player record). Display names
+ // are not gathered here: they ride `name` Decisions in the
+ // friend zone itself.
+ var remoteAuthorIDs = Set<String>()
let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
pReq.predicate = NSPredicate(format: "game == %@", game)
for p in (try? ctx.fetch(pReq)) ?? [] {
guard let authorID = p.authorID else { continue }
- playersByAuthorID[authorID] = p.name
+ remoteAuthorIDs.insert(authorID)
}
- playersByAuthorID.removeValue(forKey: localAuthorID)
- playersByAuthorID.removeValue(forKey: CKCurrentUserDefaultName)
- playersByAuthorID.removeValue(forKey: "")
- for (authorID, name) in playersByAuthorID {
- result.append((gameID, authorID, name))
+ remoteAuthorIDs.remove(localAuthorID)
+ remoteAuthorIDs.remove(CKCurrentUserDefaultName)
+ remoteAuthorIDs.remove("")
+ for authorID in remoteAuthorIDs {
+ result.append((gameID, authorID))
}
}
return result
}
- for (gameID, remoteAuthorID, remoteDisplayName) in candidates {
+ for (gameID, remoteAuthorID) in candidates {
await friendController.establishIfOwner(
localAuthorID: localAuthorID,
remoteAuthorID: remoteAuthorID,
localDisplayName: preferences.name,
- remoteDisplayName: remoteDisplayName,
viaGameID: gameID
)
}
@@ -510,7 +511,11 @@ final class InviteCoordinator {
$0.kind == .friend || $0.kind == .join || $0.kind == .hail
}
for ping in systemPings where ping.kind == .friend {
- await friendController.applyFriendPing(ping)
+ await friendController.applyFriendPing(
+ ping,
+ localAuthorID: identity.currentID,
+ localDisplayName: preferences.name
+ )
}
guard !playerFacingPings.isEmpty else { return }
guard await canPresentNotifications() else {
diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift
@@ -1,10 +1,56 @@
+import CloudKit
import CoreData
import Foundation
import Observation
-/// Observes `PlayerPreferences.name` and writes a per-(game, author)
-/// `PlayerEntity` for every shared or joined game when the name changes,
-/// so remote participants see the updated display name within one sync cycle.
+/// The local user's monotonic display-name generation, UserDefaults-backed and
+/// keyed by authorID. Each rename bumps it by one; every copy of the name
+/// Decision written in that rename (account zone + friend zones) carries the
+/// same generation, and the send path's version-wins conflict rule lets a
+/// newer rename overwrite an older copy wherever they collide. Inbound echoes
+/// of our own Decision (another device renamed) are adopted via `adopt` so
+/// this device's next rename supersedes the account's highest known
+/// generation instead of colliding with it.
+enum NameVersionStore {
+ private static func key(_ authorID: String) -> String {
+ "NameVersionStore.version.\(authorID)"
+ }
+
+ /// Highest generation this device has published or seen. 0 until the
+ /// first rename — friendship seeding publishes at this resting value so a
+ /// seed never outranks (or ties) a real rename.
+ static func current(authorID: String, defaults: UserDefaults = .standard) -> Int64 {
+ (defaults.object(forKey: key(authorID)) as? NSNumber)?.int64Value ?? 0
+ }
+
+ /// The generation for a new rename: one past everything seen. Stores.
+ static func next(authorID: String, defaults: UserDefaults = .standard) -> Int64 {
+ let value = current(authorID: authorID, defaults: defaults) + 1
+ defaults.set(NSNumber(value: value), forKey: key(authorID))
+ return value
+ }
+
+ /// Adopts a generation observed on an inbound copy of our own Decision.
+ static func adopt(
+ _ version: Int64,
+ authorID: String,
+ defaults: UserDefaults = .standard
+ ) {
+ guard version > current(authorID: authorID, defaults: defaults) else { return }
+ defaults.set(NSNumber(value: version), forKey: key(authorID))
+ }
+}
+
+/// Observes `PlayerPreferences.name` and, when it changes, publishes the new
+/// name as a `name` Decision into the account zone (own-device convergence)
+/// and every non-blocked friend zone (how friends learn the rename). The
+/// fan-out therefore scales with friendships, not games, and reaches friends
+/// even after every shared game is completed or deleted.
+///
+/// Per-game `Player` records still carry a snapshot of the name, written once
+/// at game open (`publishName(for:)`) — that's what collaborators see before
+/// a friendship's first name Decision lands, and what not-yet-friended
+/// participants fall back to.
///
/// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
/// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
@@ -15,6 +61,8 @@ final class PlayerNamePublisher {
private let persistence: PersistenceController
private let authorIdentity: AuthorIdentity
private let enqueuePlayer: (UUID, String, String) async -> Void
+ /// (authorID, name, version, zoneID, scope) → `SyncEngine.enqueueNameDecision`.
+ private let enqueueNameDecision: (String, String, Int64, CKRecordZone.ID, Int16) async -> Void
private var debounceTask: Task<Void, Never>?
private var observationTask: Task<Void, Never>?
@@ -27,12 +75,14 @@ final class PlayerNamePublisher {
preferences: PlayerPreferences,
persistence: PersistenceController,
authorIdentity: AuthorIdentity,
- enqueuePlayer: @escaping (UUID, String, String) async -> Void
+ enqueuePlayer: @escaping (UUID, String, String) async -> Void,
+ enqueueNameDecision: @escaping (String, String, Int64, CKRecordZone.ID, Int16) async -> Void
) {
self.preferences = preferences
self.persistence = persistence
self.authorIdentity = authorIdentity
self.enqueuePlayer = enqueuePlayer
+ self.enqueueNameDecision = enqueueNameDecision
startObserving()
}
@@ -66,17 +116,16 @@ final class PlayerNamePublisher {
}
}
- /// Writes the local user's name into the `PlayerEntity` row for every
- /// active shared or joined game and asks the sync engine to push each one.
- /// Used for actual name changes, where every active collaboration should
- /// see the new display name.
+ /// Publishes the local user's current name as a Decision to the account
+ /// zone and every non-blocked friend zone, at a freshly bumped generation.
func broadcastName() async {
await fanOut(newName: preferences.name)
}
/// Writes the local user's name into one game's `PlayerEntity` row. Used
- /// when opening a shared puzzle so stale shared zones elsewhere cannot
- /// hold up this game's first Player record.
+ /// when opening a shared puzzle so the game zone itself carries a name
+ /// snapshot — collaborators who aren't friends yet (or whose friend zone
+ /// hasn't delivered a name Decision) read this.
func publishName(for gameID: UUID) async {
guard let authorID = authorIdentity.currentID else { return }
@@ -94,44 +143,40 @@ final class PlayerNamePublisher {
private func fanOut(newName: String) async {
guard let authorID = authorIdentity.currentID else { return }
+ let name = newName.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else { return }
- let ctx = persistence.container.newBackgroundContext()
- ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let touchedGameIDs = Self.upsertPlayerRecords(
- in: ctx,
- authorID: authorID,
- name: newName
+ let version = NameVersionStore.next(authorID: authorID)
+ await enqueueNameDecision(
+ authorID, name, version, RecordSerializer.accountZoneID, 0
)
-
- for gameID in touchedGameIDs {
- await enqueuePlayer(gameID, authorID, "name-rename")
+ let ctx = persistence.container.newBackgroundContext()
+ for target in Self.friendZoneTargets(in: ctx) {
+ await enqueueNameDecision(
+ authorID, name, version, target.zoneID, target.scope
+ )
}
- onFanOutForTesting?(newName)
+ onFanOutForTesting?(name)
}
/// Background-context work — main-actor isolation does not apply here.
- private nonisolated static func upsertPlayerRecords(
- in ctx: NSManagedObjectContext,
- authorID: String,
- name: String
- ) -> [UUID] {
+ private nonisolated static func friendZoneTargets(
+ in ctx: NSManagedObjectContext
+ ) -> [(zoneID: CKRecordZone.ID, scope: Int16)] {
ctx.performAndWait {
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(
- format: "(ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO AND completedAt == nil AND puzzleSource != nil AND puzzleSource != %@",
- ""
- )
- let games = (try? ctx.fetch(req)) ?? []
- var ids: [UUID] = []
- let now = Date()
- for game in games {
- guard let gameID = game.id else { continue }
- upsertPlayerEntity(for: game, authorID: authorID, name: name, now: now, in: ctx)
- ids.append(gameID)
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(format: "isBlocked == NO")
+ let friends = (try? ctx.fetch(req)) ?? []
+ return friends.compactMap { friend in
+ guard let zoneName = friend.friendZoneName,
+ let ownerName = friend.friendZoneOwnerName
+ else { return nil }
+ return (
+ CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
+ friend.databaseScope
+ )
}
- if ctx.hasChanges { try? ctx.save() }
- return ids
}
}
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -55,7 +55,6 @@ final class FriendController {
localAuthorID: String,
remoteAuthorID: String,
localDisplayName: String?,
- remoteDisplayName: String?,
viaGameID: UUID
) async {
guard !remoteAuthorID.isEmpty,
@@ -77,7 +76,6 @@ final class FriendController {
let recordLocalFriendship = {
self.persistFriend(
authorID: remoteAuthorID,
- displayName: remoteDisplayName,
pairKey: pairKey,
zoneName: zoneName,
zoneOwnerName: CKCurrentUserDefaultName,
@@ -95,6 +93,12 @@ final class FriendController {
// Ping — the sibling already delivered it to the friend.
if try await existingZoneWideShare(zoneID: zoneID) != nil {
recordLocalFriendship()
+ await seedOwnNameDecision(
+ localAuthorID: localAuthorID,
+ localDisplayName: localDisplayName,
+ zoneID: zoneID,
+ scope: 0
+ )
syncMonitor?.recordSuccess("establish friendship")
return
}
@@ -109,12 +113,24 @@ final class FriendController {
// Lost the create race to a sibling between the check above
// and this save. The share now exists; adopt it as above.
recordLocalFriendship()
+ await seedOwnNameDecision(
+ localAuthorID: localAuthorID,
+ localDisplayName: localDisplayName,
+ zoneID: zoneID,
+ scope: 0
+ )
syncMonitor?.recordSuccess("establish friendship")
return
}
guard let url = share.url else { throw FriendError.missingShareURL }
recordLocalFriendship()
+ await seedOwnNameDecision(
+ localAuthorID: localAuthorID,
+ localDisplayName: localDisplayName,
+ zoneID: zoneID,
+ scope: 0
+ )
let payload = FriendZone.BootstrapPayload(
friendShareURL: url.absoluteString,
@@ -134,12 +150,42 @@ final class FriendController {
}
}
+ /// Writes the local user's current name into a just-recorded friend zone
+ /// as a `name` Decision, at the current (un-bumped) generation: a seed is
+ /// "the name as of this friendship", never a rename, so it must lose to
+ /// any real rename racing it. This is how a friend made *after* the last
+ /// rename learns the name — the rename fan-out only reaches zones that
+ /// existed at the time.
+ private func seedOwnNameDecision(
+ localAuthorID: String,
+ localDisplayName: String?,
+ zoneID: CKRecordZone.ID,
+ scope: Int16
+ ) async {
+ let name = (localDisplayName ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else { return }
+ await syncEngine.enqueueNameDecision(
+ authorID: localAuthorID,
+ name: name,
+ version: NameVersionStore.current(authorID: localAuthorID),
+ zoneID: zoneID,
+ scope: scope
+ )
+ }
+
// MARK: - Participant side
/// Handles an inbound `.friend` Ping: accepts the friend-zone share and
/// records the friendship. Idempotent — a duplicate Ping for an
- /// already-established pair is dropped.
- func applyFriendPing(_ ping: Ping) async {
+ /// already-established pair is dropped. `localAuthorID`/`localDisplayName`
+ /// let the acceptor seed its own name Decision into the just-joined zone
+ /// so the owner learns this side's name without waiting for a rename.
+ func applyFriendPing(
+ _ ping: Ping,
+ localAuthorID: String?,
+ localDisplayName: String?
+ ) async {
guard ping.kind == .friend,
let payload = FriendZone.BootstrapPayload.decode(ping.payload)
else { return }
@@ -153,15 +199,19 @@ final class FriendController {
let zoneID = metadata.share.recordID.zoneID
persistFriend(
authorID: payload.ownerAuthorID,
- displayName: displayNameFromPlayer(
- gameID: ping.gameID,
- authorID: payload.ownerAuthorID
- ) ?? ping.playerName,
pairKey: payload.pairKey,
zoneName: zoneID.zoneName,
zoneOwnerName: zoneID.ownerName,
databaseScope: 1
)
+ if let localAuthorID, !localAuthorID.isEmpty {
+ await seedOwnNameDecision(
+ localAuthorID: localAuthorID,
+ localDisplayName: localDisplayName,
+ zoneID: zoneID,
+ scope: 1
+ )
+ }
syncMonitor?.recordSuccess("accept friendship")
// `applyFriendPing` runs inside the `onPings` CKSyncEngine
// delegate callback. Awaiting a call back into CKSyncEngine from
@@ -441,9 +491,13 @@ final class FriendController {
return ((try? ctx.count(for: req)) ?? 0) > 0
}
+ /// Records the friendship row. The display name is deliberately *not*
+ /// written here — it arrives exclusively via the friend's `name` Decision
+ /// (`RecordSerializer.applyDecisionRecord`); until that syncs, the invite
+ /// surfaces fall back to the freshest per-game Player snapshot, then
+ /// "Player".
private func persistFriend(
authorID: String,
- displayName: String?,
pairKey: String,
zoneName: String,
zoneOwnerName: String,
@@ -455,9 +509,6 @@ final class FriendController {
req.fetchLimit = 1
let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx)
entity.authorID = authorID
- if let displayName, !displayName.isEmpty {
- entity.displayName = displayName
- }
entity.pairKey = pairKey
entity.friendZoneName = zoneName
entity.friendZoneOwnerName = zoneOwnerName
@@ -469,24 +520,4 @@ final class FriendController {
eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error")
}
}
-
- private func displayNameFromPlayer(gameID: UUID, authorID: String) -> String? {
- let ctx = persistence.viewContext
- let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- gameReq.fetchLimit = 1
- guard let game = try? ctx.fetch(gameReq).first else { return nil }
-
- let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- playerReq.predicate = NSPredicate(
- format: "game == %@ AND authorID == %@",
- game,
- authorID
- )
- playerReq.fetchLimit = 1
- guard let name = try? ctx.fetch(playerReq).first?.name,
- !name.isEmpty
- else { return nil }
- return name
- }
}
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -38,6 +38,10 @@ struct BatchEffects {
/// address (see `RecordSerializer.deriveGameAddress`); the version gates
/// adoption so a stale inbound copy can't undo a rotation.
var accountPushSecrets: [(secret: String, version: Int64)] = []
+ /// Versions of *our own* name Decision echoed back by sync. Adopted into
+ /// the local rename counter so the next rename supersedes the highest
+ /// generation any of this account's devices has published.
+ var selfNameVersions: [Int64] = []
/// Diagnostics emitted while applying the batch inside `performAndWait` —
/// chiefly Core Data fetch/save failures, which silently drop records (the
/// engine's change token has already advanced, so they never redeliver).
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -35,13 +35,14 @@ extension SyncEngine {
guard let (kind, key) = RecordSerializer.parseDecisionRecordName(name) else {
return nil
}
+ let stateKey = Self.decisionStateKey(recordID)
return RecordSerializer.decisionRecord(
kind: kind,
key: key,
- payload: decisions[name],
+ payload: decisions[stateKey],
zone: zoneID,
- systemFields: decisionSystemFields[name],
- version: decisionVersions[name]
+ systemFields: decisionSystemFields[stateKey],
+ version: decisionVersions[stateKey]
)
}
let ctx = persistence.container.newBackgroundContext()
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -99,6 +99,33 @@ enum RecordSerializer {
return (kind, key)
}
+ /// Kind for the display-name Decision: `decision-name-<authorID>`, payload
+ /// = the display name, `version` = the author's monotonic rename
+ /// generation. The author writes their own copy into their account zone
+ /// (own-device convergence and restore durability) and into every friend
+ /// zone they participate in (the friend's devices read it from there) —
+ /// names never sync through any other channel.
+ static let nameDecisionKind = "name"
+
+ static func nameDecisionName(authorID: String) -> String {
+ decisionRecordName(kind: nameDecisionKind, key: authorID)
+ }
+
+ /// Parses a display-name Decision into its subject author, name, and
+ /// version. Returns `nil` for any other decision or an empty payload.
+ static func parseNameDecision(
+ _ record: CKRecord
+ ) -> (authorID: String, name: String, version: Int64)? {
+ guard record.recordType == "Decision",
+ let (kind, key) = parseDecisionRecordName(record.recordID.recordName),
+ kind == nameDecisionKind,
+ ((record["kind"] as? String) ?? nameDecisionKind) == nameDecisionKind,
+ let name = record["payload"] as? String,
+ !name.isEmpty
+ else { return nil }
+ return (key, name, decisionVersion(record))
+ }
+
static let accountDecisionKind = "account"
static let accountPushAddressDecisionKey = "pushAddress"
/// Key for the account-wide push *secret* decision. The secret is the HMAC
@@ -865,7 +892,8 @@ enum RecordSerializer {
static func applyDecisionRecord(
_ record: CKRecord,
to ctx: NSManagedObjectContext,
- localAuthorID: String?
+ localAuthorID: String?,
+ databaseScope: Int16 = 0
) -> Bool {
guard record.recordType == "Decision" else { return false }
// Identity comes from the record name (always present, immutable);
@@ -906,6 +934,53 @@ enum RecordSerializer {
entity.databaseScope == 1 else { return false }
ctx.delete(entity)
return true
+ case nameDecisionKind:
+ // A friend's display name, read out of the pairwise friend zone.
+ // Our own copy (key == localAuthorID, account zone) carries no
+ // Core Data projection — the local name lives in
+ // `PlayerPreferences`; only its version is adopted, by the caller.
+ guard let localAuthorID, !localAuthorID.isEmpty,
+ key != localAuthorID,
+ let name = record["payload"] as? String,
+ !name.isEmpty
+ else { return false }
+ // Provenance: both participants can write into a friend zone, so
+ // a name Decision is only honored when the zone is *the* pairwise
+ // zone for (us, key) — the zone name embeds a hash of the author
+ // pair, so the claimed subject is verifiable without trusting the
+ // record. This also rejects a name Decision for a third party
+ // misdelivered into an unrelated zone.
+ let pairKey = FriendZone.pairKey(localAuthorID, key)
+ let zoneID = record.recordID.zoneID
+ guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else {
+ return false
+ }
+ let version = decisionVersion(record)
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(format: "pairKey == %@", pairKey)
+ req.fetchLimit = 1
+ if let friend = try? ctx.fetch(req).first {
+ guard !friend.isBlocked,
+ version >= friend.displayNameVersion
+ else { return false }
+ friend.displayName = name
+ friend.displayNameVersion = version
+ return true
+ }
+ // No local row: the bootstrap evidence (game zones, `.friend`
+ // Ping) may be long gone on a restored device, but the name
+ // Decision arriving from a live friend zone is itself proof of
+ // the friendship — resurrect the row from the zone it rode in on.
+ let friend = FriendEntity(context: ctx)
+ friend.authorID = key
+ friend.pairKey = pairKey
+ friend.friendZoneName = zoneID.zoneName
+ friend.friendZoneOwnerName = zoneID.ownerName
+ friend.databaseScope = databaseScope
+ friend.createdAt = Date()
+ friend.displayName = name
+ friend.displayNameVersion = version
+ return true
default:
// Unknown kind from a newer build — ignore rather than guess.
return false
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -61,7 +61,10 @@ actor SyncEngine {
/// backing — they're write-once-and-forget — so we stash the minimal data
/// here keyed by record name and look it up in `buildRecord`.
private var pendingPings: [String: PingPayload] = [:]
- /// Payloads for `Decision` records pending send, keyed by record name.
+ /// Payloads for `Decision` records pending send, keyed by
+ /// `decisionStateKey` (zone + record name — the same decision record can
+ /// be pending in several zones at once, e.g. a name Decision fanned out
+ /// to every friend zone, and one zone's save must not strip the others').
/// Unlike a ping body, these must survive an app kill: CKSyncEngine persists
/// the pending `.saveRecord`, so on relaunch `buildRecord` would otherwise
/// emit a payload-less Decision (e.g. an empty `pushSecret`) that uploads as
@@ -74,11 +77,11 @@ actor SyncEngine {
"SyncEngine.pendingDecisionPayloads"
/// Intended generation for a versioned `Decision` pending send, keyed by
- /// record name. Mirrors `pendingDecisionPayloads`' lifecycle and durability:
- /// the version must survive an app kill so a rebuilt rotation re-asserts at
- /// the right generation rather than a stale one. Only the push secret is
- /// versioned today; unversioned decisions (block/left/pushAddress) have no
- /// entry and stay write-once on conflict.
+ /// `decisionStateKey`. Mirrors `pendingDecisionPayloads`' lifecycle and
+ /// durability: the version must survive an app kill so a rebuilt rotation
+ /// re-asserts at the right generation rather than a stale one. The push
+ /// secret and display name are versioned; unversioned decisions
+ /// (block/left/pushAddress) have no entry and stay write-once on conflict.
private var pendingDecisionVersions: [String: Int64] = [:]
private static let pendingDecisionVersionsDefaultsKey =
@@ -90,10 +93,18 @@ actor SyncEngine {
/// rather than re-colliding. In-memory only: the version in
/// `pendingDecisionVersions` carries correctness across a relaunch (a
/// tagless re-send simply re-hits the conflict and re-recovers), so this is
- /// a round-trip optimization, not durable state. Cleared once the record
- /// saves or settles.
+ /// a round-trip optimization, not durable state. Keyed by
+ /// `decisionStateKey` — the same record name carries a different change
+ /// tag in each zone it lives in. Cleared once the record saves or settles.
private var decisionSystemFields: [String: Data] = [:]
+ /// Key for the per-zone decision state above. A decision's record name is
+ /// deterministic, so the same name can be pending in the account zone and
+ /// several friend zones simultaneously; the zone name disambiguates.
+ nonisolated static func decisionStateKey(_ recordID: CKRecord.ID) -> String {
+ "\(recordID.zoneID.zoneName)/\(recordID.recordName)"
+ }
+
struct PingPayload {
let gameID: UUID
let authorID: String
@@ -900,16 +911,81 @@ actor SyncEngine {
// CKSyncEngine dedupes redundant saveZone requests, so it's safe to
// repeat — block may be the first thing ever written to this zone.
engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
+ registerDecisionSave(
+ kind: kind, key: key, payload: payload, version: version,
+ zoneID: zoneID, engine: engine
+ )
+ }
+
+ /// Registers a `Decision` into an existing *friend* zone — the channel a
+ /// display name rides to reach the other participant. Engine selection
+ /// mirrors `enqueueFriendInvitePing`: `scope == 1` means we joined the
+ /// zone (shared engine), `scope == 0` means we own it (private engine).
+ /// The zone already exists by the time a friendship is recorded, so no
+ /// `saveZone`.
+ func enqueueFriendDecision(
+ kind: String,
+ key: String,
+ payload: String? = nil,
+ version: Int64? = nil,
+ friendZoneID: CKRecordZone.ID,
+ friendZoneScope: Int16
+ ) {
+ guard let engine = friendZoneScope == 1 ? sharedEngine : privateEngine else { return }
+ registerDecisionSave(
+ kind: kind, key: key, payload: payload, version: version,
+ zoneID: friendZoneID, engine: engine
+ )
+ }
+
+ /// Routes a display-name Decision to its zone. The account zone may not
+ /// exist yet, so that path rides `enqueueDecision`'s `saveZone` backstop;
+ /// a friend zone always exists by the time a friendship is recorded.
+ func enqueueNameDecision(
+ authorID: String,
+ name: String,
+ version: Int64,
+ zoneID: CKRecordZone.ID,
+ scope: Int16
+ ) {
+ if zoneID == RecordSerializer.accountZoneID {
+ enqueueDecision(
+ kind: RecordSerializer.nameDecisionKind,
+ key: authorID,
+ payload: name,
+ version: version
+ )
+ } else {
+ enqueueFriendDecision(
+ kind: RecordSerializer.nameDecisionKind,
+ key: authorID,
+ payload: name,
+ version: version,
+ friendZoneID: zoneID,
+ friendZoneScope: scope
+ )
+ }
+ }
+
+ private func registerDecisionSave(
+ kind: String,
+ key: String,
+ payload: String?,
+ version: Int64?,
+ zoneID: CKRecordZone.ID,
+ engine: CKSyncEngine
+ ) {
let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
+ let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
+ let stateKey = Self.decisionStateKey(recordID)
if let payload {
- pendingDecisionPayloads[name] = payload
+ pendingDecisionPayloads[stateKey] = payload
persistPendingDecisionPayloads()
}
if let version {
- pendingDecisionVersions[name] = version
+ pendingDecisionVersions[stateKey] = version
persistPendingDecisionVersions()
}
- let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
sendChangesDetached(on: engine)
}
@@ -1424,8 +1500,17 @@ actor SyncEngine {
let wrote = RecordSerializer.applyDecisionRecord(
record,
to: ctx,
- localAuthorID: localAuthorID
+ localAuthorID: localAuthorID,
+ databaseScope: scope
)
+ // Our own name Decision echoed back from the account zone
+ // (or a friend zone a sibling seeded): adopt its version so
+ // this device's next rename supersedes it rather than
+ // colliding at an equal generation.
+ if let (subject, _, version) = RecordSerializer.parseNameDecision(record),
+ let localAuthorID, subject == localAuthorID {
+ effects.selfNameVersions.append(version)
+ }
// A `left` decision hard-deletes a game row; surface it so
// an open PuzzleView / the game list reacts, the same as
// the private zone-deletion path does via onGameRemoved.
@@ -1531,6 +1616,10 @@ actor SyncEngine {
await onAccountPushSecret(entry.secret, entry.version)
}
}
+ if let localAuthorID, !localAuthorID.isEmpty,
+ let maxVersion = effects.selfNameVersions.max() {
+ NameVersionStore.adopt(maxVersion, authorID: localAuthorID)
+ }
for id in effects.removed {
if let cb = onGameRemoved { await cb(id) }
}
@@ -1632,11 +1721,12 @@ actor SyncEngine {
if name.hasPrefix("ping-") {
pendingPings.removeValue(forKey: name)
} else if name.hasPrefix("decision-") {
- pendingDecisionPayloads.removeValue(forKey: name)
+ let stateKey = Self.decisionStateKey(record.recordID)
+ pendingDecisionPayloads.removeValue(forKey: stateKey)
persistPendingDecisionPayloads()
- pendingDecisionVersions.removeValue(forKey: name)
+ pendingDecisionVersions.removeValue(forKey: stateKey)
persistPendingDecisionVersions()
- decisionSystemFields.removeValue(forKey: name)
+ decisionSystemFields.removeValue(forKey: stateKey)
}
}
// Snapshot the intended versions on-actor so the off-actor conflict
@@ -1648,7 +1738,7 @@ actor SyncEngine {
resolvedAccountAddresses, resolvedAccountSecrets, decisionWins):
([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
[String], [(secret: String, version: Int64)],
- [(name: String, systemFields: Data)]) = ctx.performAndWait {
+ [(stateKey: String, systemFields: Data)]) = ctx.performAndWait {
var messages: [String] = []
var orphaned = Set<CKRecordZone.ID>()
var settledDecisions = Set<CKRecord.ID>()
@@ -1656,9 +1746,9 @@ actor SyncEngine {
var accountAddresses: [String] = []
var accountSecrets: [(secret: String, version: Int64)] = []
// Versioned decisions that lost the change-tag race but win on
- // version: (record name, server system fields to adopt for the
- // overwrite retry).
- var decisionWins: [(name: String, systemFields: Data)] = []
+ // version: (decision state key, server system fields to adopt for
+ // the overwrite retry).
+ var decisionWins: [(stateKey: String, systemFields: Data)] = []
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
let savedName = record.recordID.recordName
@@ -1690,19 +1780,20 @@ actor SyncEngine {
err.domain == CKErrorDomain,
err.code == CKError.serverRecordChanged.rawValue {
let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord
- let intended = pendingVersionsSnapshot[name]
+ let stateKey = Self.decisionStateKey(failure.record.recordID)
+ let intended = pendingVersionsSnapshot[stateKey]
let serverVersion = serverRecord.map(RecordSerializer.decisionVersion)
if let intended, let serverRecord, let serverVersion,
intended > serverVersion,
let serverFields = RecordSerializer.encodeSystemFields(of: serverRecord) {
- // A deliberate, newer write (e.g. a rotated push secret)
- // that lost only the change-tag race. Adopt the server's
- // tag so the next build overwrites instead of
- // re-colliding, and *keep* the pending change: a
+ // A deliberate, newer write (e.g. a rotated push secret
+ // or a rename) that lost only the change-tag race. Adopt
+ // the server's tag so the next build overwrites instead
+ // of re-colliding, and *keep* the pending change: a
// serverRecordChanged failure stays pending, so the retry
// rides the normal send loop — same shape as
// recoverServerChangedSave for Game/Moves.
- decisionWins.append((name, serverFields))
+ decisionWins.append((stateKey, serverFields))
settled = true
messages.append(
"send: decision \(name) lost tag race but wins on version " +
@@ -1775,14 +1866,19 @@ actor SyncEngine {
if !orphanedZones.isEmpty {
await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
}
- if !resolvedDecisions.isEmpty, let privateEngine {
- privateEngine.state.remove(
+ // Settle/retry against the engine this sent-event belongs to: account-
+ // zone decisions ride the private engine, but a name Decision in a
+ // joined friend zone rides the shared one.
+ let decisionEngine = isPrivate ? privateEngine : sharedEngine
+ if !resolvedDecisions.isEmpty, let decisionEngine {
+ decisionEngine.state.remove(
pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) }
)
for recordID in resolvedDecisions {
- pendingDecisionPayloads.removeValue(forKey: recordID.recordName)
- pendingDecisionVersions.removeValue(forKey: recordID.recordName)
- decisionSystemFields.removeValue(forKey: recordID.recordName)
+ let stateKey = Self.decisionStateKey(recordID)
+ pendingDecisionPayloads.removeValue(forKey: stateKey)
+ pendingDecisionVersions.removeValue(forKey: stateKey)
+ decisionSystemFields.removeValue(forKey: stateKey)
}
persistPendingDecisionPayloads()
persistPendingDecisionVersions()
@@ -1792,10 +1888,10 @@ actor SyncEngine {
// promptly rather than on CKSyncEngine's own cadence.
if !decisionWins.isEmpty {
for win in decisionWins {
- decisionSystemFields[win.name] = win.systemFields
+ decisionSystemFields[win.stateKey] = win.systemFields
}
- if let privateEngine {
- sendChangesDetached(on: privateEngine)
+ if let decisionEngine {
+ sendChangesDetached(on: decisionEngine)
}
}
if let onAccountPushAddress {
diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/FriendPickerView.swift
@@ -68,7 +68,7 @@ struct FriendPickerView: View {
invitePhase: invitePhase(authorID: authorID, invited: invited)
)
.padding(.trailing, 8)
- Text(displayName(for: friend))
+ Text(friend.resolvedDisplayName)
Spacer()
}
}
@@ -84,14 +84,6 @@ struct FriendPickerView: View {
return nil
}
- private func displayName(for friend: FriendEntity) -> String {
- if let name = friend.displayName?.trimmingCharacters(in: .whitespacesAndNewlines),
- !name.isEmpty {
- return name
- }
- return "Player"
- }
-
private func invite(_ authorID: String) async {
guard !authorID.isEmpty, let inviteFriend else { return }
withAnimation(.snappy) { invitingAuthorID = authorID }
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -178,7 +178,7 @@ struct GameShareSheet: View {
size: 40,
invitePhase: invitePhase(authorID: authorID, invited: wasInvited)
)
- Text(displayName(for: friend))
+ Text(friend.resolvedDisplayName)
.font(.callout.weight(.medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
@@ -197,14 +197,6 @@ struct GameShareSheet: View {
return nil
}
- private func displayName(for friend: FriendEntity) -> String {
- if let name = friend.displayName?.trimmingCharacters(in: .whitespacesAndNewlines),
- !name.isEmpty {
- return name
- }
- return "Player"
- }
-
private func invite(_ authorID: String) async {
guard !authorID.isEmpty, let inviteFriend else { return }
withAnimation(.snappy) { invitingAuthorID = authorID }
diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift
@@ -1,3 +1,4 @@
+import CloudKit
import CoreData
import Foundation
import Testing
@@ -10,153 +11,179 @@ struct PlayerNamePublisherTests {
// MARK: - Helpers
- /// Creates a persistence store with one game that qualifies for fan-out
- /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it).
- private func makeSharedGame() throws -> (PersistenceController, UUID) {
- let p = makeTestPersistence()
- let ctx = p.viewContext
- let gameID = UUID()
- let entity = GameEntity(context: ctx)
- entity.id = gameID
- entity.title = "Shared Test"
- entity.puzzleSource = "## Metadata\nTitle: Shared Test\n"
- entity.createdAt = Date()
- entity.updatedAt = Date()
- entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
- entity.ckShareRecordName = "share-\(UUID().uuidString)"
- try ctx.save()
- return (p, gameID)
+ /// One enqueued name Decision as seen by the spy closure.
+ private struct EnqueuedDecision: Equatable {
+ let authorID: String
+ let name: String
+ let version: Int64
+ let zoneID: CKRecordZone.ID
+ let scope: Int16
}
- private func makeNonSharedGame() throws -> (PersistenceController, UUID) {
- let p = makeTestPersistence()
- let ctx = p.viewContext
- let gameID = UUID()
- let entity = GameEntity(context: ctx)
- entity.id = gameID
- entity.title = "Solo Test"
- entity.puzzleSource = ""
- entity.createdAt = Date()
- entity.updatedAt = Date()
- entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
- // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out.
- try ctx.save()
- return (p, gameID)
+ @MainActor
+ private final class DecisionSpy {
+ private(set) var decisions: [EnqueuedDecision] = []
+ func record(_ d: EnqueuedDecision) { decisions.append(d) }
}
- private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? {
- let ctx = persistence.container.newBackgroundContext()
- return ctx.performAndWait {
- let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- req.predicate = NSPredicate(format: "authorID == %@", authorID)
- req.fetchLimit = 1
- return (try? ctx.fetch(req).first)?.name
- }
+ /// A unique author per test keeps `NameVersionStore`'s UserDefaults state
+ /// from leaking between runs — every test starts at generation 0.
+ private func freshAuthorID() -> String {
+ "_local-\(UUID().uuidString)"
}
- private func fetchPlayerNames(authorID: String, in persistence: PersistenceController) -> [String] {
- let ctx = persistence.container.newBackgroundContext()
- return ctx.performAndWait {
- let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- req.predicate = NSPredicate(format: "authorID == %@", authorID)
- let entities = (try? ctx.fetch(req)) ?? []
- return entities.compactMap(\.name)
- }
+ private func addFriend(
+ to persistence: PersistenceController,
+ authorID: String,
+ zoneName: String,
+ zoneOwnerName: String = CKCurrentUserDefaultName,
+ scope: Int16 = 0,
+ blocked: Bool = false
+ ) throws {
+ let ctx = persistence.viewContext
+ let friend = FriendEntity(context: ctx)
+ friend.authorID = authorID
+ friend.pairKey = "pair-\(authorID)"
+ friend.friendZoneName = zoneName
+ friend.friendZoneOwnerName = zoneOwnerName
+ friend.databaseScope = scope
+ friend.isBlocked = blocked
+ friend.createdAt = Date()
+ try ctx.save()
}
private func makeBroadcaster(
preferences: PlayerPreferences,
persistence: PersistenceController,
- authorID: String
+ authorID: String,
+ spy: DecisionSpy,
+ enqueuePlayer: @escaping (UUID, String, String) async -> Void = { _, _, _ in }
) -> PlayerNamePublisher {
PlayerNamePublisher(
preferences: preferences,
persistence: persistence,
authorIdentity: AuthorIdentity(testing: authorID),
- enqueuePlayer: { _, _, _ in }
+ enqueuePlayer: enqueuePlayer,
+ enqueueNameDecision: { authorID, name, version, zoneID, scope in
+ await spy.record(EnqueuedDecision(
+ authorID: authorID,
+ name: name,
+ version: version,
+ zoneID: zoneID,
+ scope: scope
+ ))
+ }
)
}
- // MARK: - Tests
+ private func fetchPlayerNames(authorID: String, in persistence: PersistenceController) -> [String] {
+ let ctx = persistence.container.newBackgroundContext()
+ return ctx.performAndWait {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "authorID == %@", authorID)
+ let entities = (try? ctx.fetch(req)) ?? []
+ return entities.compactMap(\.name)
+ }
+ }
+
+ // MARK: - Decision fan-out
+
+ @Test("broadcastName publishes to the account zone and every non-blocked friend zone")
+ func broadcastNameFansOutDecisions() async throws {
+ let persistence = makeTestPersistence()
+ let authorID = freshAuthorID()
+ try addFriend(
+ to: persistence, authorID: "_bob",
+ zoneName: "friend-bob", scope: 0
+ )
+ try addFriend(
+ to: persistence, authorID: "_carol",
+ zoneName: "friend-carol", zoneOwnerName: "_carol-owner", scope: 1
+ )
+ try addFriend(
+ to: persistence, authorID: "_mallory",
+ zoneName: "friend-mallory", blocked: true
+ )
- @Test("broadcastName writes a PlayerEntity for shared/joined games")
- func broadcastNameWritesPlayerEntityForSharedGame() async throws {
- let (persistence, _) = try makeSharedGame()
let prefs = PlayerPreferences(
local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
)
prefs.name = "Alice"
+ let spy = DecisionSpy()
let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: persistence,
- authorID: "_local"
+ preferences: prefs, persistence: persistence,
+ authorID: authorID, spy: spy
)
await broadcaster.broadcastName()
- #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice")
+ // Account zone copy plus one per non-blocked friend; blocked zone skipped.
+ #expect(spy.decisions.count == 3)
+ #expect(spy.decisions.allSatisfy { $0.authorID == authorID })
+ #expect(spy.decisions.allSatisfy { $0.name == "Alice" })
+ #expect(spy.decisions.allSatisfy { $0.version == 1 })
+ let zoneNames = Set(spy.decisions.map(\.zoneID.zoneName))
+ #expect(zoneNames == [
+ RecordSerializer.accountZoneID.zoneName, "friend-bob", "friend-carol"
+ ])
+ let carol = spy.decisions.first { $0.zoneID.zoneName == "friend-carol" }
+ #expect(carol?.scope == 1)
+ #expect(carol?.zoneID.ownerName == "_carol-owner")
+ // Fan-out no longer touches per-game Player rows.
+ #expect(fetchPlayerNames(authorID: authorID, in: persistence).isEmpty)
+
+ withExtendedLifetime(broadcaster) {}
}
- @Test("broadcastName is a no-op for non-shared games")
- func broadcastNameSkipsNonSharedGames() async throws {
- let (persistence, _) = try makeNonSharedGame()
+ @Test("each broadcast bumps the name generation")
+ func broadcastNameBumpsVersion() async throws {
+ let persistence = makeTestPersistence()
+ let authorID = freshAuthorID()
let prefs = PlayerPreferences(
local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
)
prefs.name = "Alice"
+ let spy = DecisionSpy()
let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: persistence,
- authorID: "_local"
+ preferences: prefs, persistence: persistence,
+ authorID: authorID, spy: spy
)
await broadcaster.broadcastName()
+ prefs.name = "Alicia"
+ await broadcaster.broadcastName()
- #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil)
- }
+ #expect(spy.decisions.map(\.version) == [1, 2])
+ #expect(spy.decisions.map(\.name) == ["Alice", "Alicia"])
- @Test("broadcastName skips revoked completed and placeholder shared games")
- func broadcastNameSkipsInactiveSharedGames() async throws {
- let p = makeTestPersistence()
- let ctx = p.viewContext
- let activeID = UUID()
- let revokedID = UUID()
- let completedID = UUID()
- let placeholderID = UUID()
- for (id, title, source) in [
- (activeID, "Active", "## Metadata\nTitle: Active\n"),
- (revokedID, "Revoked", "## Metadata\nTitle: Revoked\n"),
- (completedID, "Completed", "## Metadata\nTitle: Completed\n"),
- (placeholderID, "Joining...", "")
- ] {
- let entity = GameEntity(context: ctx)
- entity.id = id
- entity.title = title
- entity.puzzleSource = source
- entity.createdAt = Date()
- entity.updatedAt = Date()
- entity.ckRecordName = RecordSerializer.recordName(forGameID: id)
- entity.ckShareRecordName = "share-\(id.uuidString)"
- if id == revokedID { entity.isAccessRevoked = true }
- if id == completedID { entity.completedAt = Date() }
- }
- try ctx.save()
+ withExtendedLifetime(broadcaster) {}
+ }
+ @Test("broadcastName skips an empty or whitespace-only name")
+ func broadcastNameSkipsEmptyName() async throws {
+ let persistence = makeTestPersistence()
+ let authorID = freshAuthorID()
let prefs = PlayerPreferences(
local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
)
- prefs.name = "Alice"
+ prefs.name = " "
+ let spy = DecisionSpy()
let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: p,
- authorID: "_local"
+ preferences: prefs, persistence: persistence,
+ authorID: authorID, spy: spy
)
await broadcaster.broadcastName()
- #expect(fetchPlayerNames(authorID: "_local", in: p) == ["Alice"])
+ #expect(spy.decisions.isEmpty)
+ // The skipped broadcast must not consume a generation.
+ #expect(NameVersionStore.current(authorID: authorID) == 0)
+
+ withExtendedLifetime(broadcaster) {}
}
+ // MARK: - Per-game snapshot (game open)
+
@Test("publishName writes only the requested shared game")
func publishNameWritesOnlyRequestedGame() async throws {
let p = makeTestPersistence()
@@ -175,38 +202,78 @@ struct PlayerNamePublisherTests {
}
try ctx.save()
+ let authorID = freshAuthorID()
let prefs = PlayerPreferences(
local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
)
prefs.name = "Alice"
+ let spy = DecisionSpy()
var enqueued: [UUID] = []
- let broadcaster = PlayerNamePublisher(
- preferences: prefs,
- persistence: p,
- authorIdentity: AuthorIdentity(testing: "_local"),
+ let broadcaster = makeBroadcaster(
+ preferences: prefs, persistence: p,
+ authorID: authorID, spy: spy,
enqueuePlayer: { gameID, _, _ in enqueued.append(gameID) }
)
await broadcaster.publishName(for: secondID)
#expect(enqueued == [secondID])
- #expect(fetchPlayerNames(authorID: "_local", in: p) == ["Alice"])
+ #expect(fetchPlayerNames(authorID: authorID, in: p) == ["Alice"])
+ // Opening a game publishes no Decisions.
+ #expect(spy.decisions.isEmpty)
+
+ withExtendedLifetime(broadcaster) {}
+ }
+
+ @Test("publishName is a no-op for a non-shared game")
+ func publishNameSkipsNonSharedGame() async throws {
+ let p = makeTestPersistence()
+ let ctx = p.viewContext
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Solo"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
+ try ctx.save()
+
+ let authorID = freshAuthorID()
+ let prefs = PlayerPreferences(
+ local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
+ )
+ prefs.name = "Alice"
+ let spy = DecisionSpy()
+ let broadcaster = makeBroadcaster(
+ preferences: prefs, persistence: p,
+ authorID: authorID, spy: spy
+ )
+
+ await broadcaster.publishName(for: gameID)
+
+ #expect(fetchPlayerNames(authorID: authorID, in: p).isEmpty)
+
+ withExtendedLifetime(broadcaster) {}
}
+ // MARK: - Debounce
+
@Test("Debounce coalesces two rapid name changes into one fan-out with the final name")
func debounceCoalescesPair() async throws {
- let (persistence, _) = try makeSharedGame()
+ let persistence = makeTestPersistence()
+ let authorID = freshAuthorID()
let prefs = PlayerPreferences(
local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
)
+ let spy = DecisionSpy()
let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: persistence,
- authorID: "_local"
+ preferences: prefs, persistence: persistence,
+ authorID: authorID, spy: spy
)
- let spy = FanOutSpy()
- broadcaster.onFanOutForTesting = { name in spy.record(name) }
+ let fanOuts = FanOutSpy()
+ broadcaster.onFanOutForTesting = { name in fanOuts.record(name) }
// Allow the observation task to make its first withObservationTracking
// registration before we mutate any values.
@@ -223,7 +290,7 @@ struct PlayerNamePublisherTests {
// Poll until the (single, hopefully) fan-out fires. Loose deadline so
// CI scheduler jitter doesn't fail the test.
let deadline = Date().addingTimeInterval(2.0)
- while spy.count == 0 && Date() < deadline {
+ while fanOuts.count == 0 && Date() < deadline {
try await Task.sleep(for: .milliseconds(20))
}
@@ -231,9 +298,11 @@ struct PlayerNamePublisherTests {
// timer — the bug we're guarding against would surface here as count==2.
try await Task.sleep(for: .milliseconds(150))
- #expect(spy.count == 1, "two rapid changes should debounce into one fan-out")
- #expect(spy.names.last == "Bob", "the final name should win")
- #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob")
+ #expect(fanOuts.count == 1, "two rapid changes should debounce into one fan-out")
+ #expect(fanOuts.names.last == "Bob", "the final name should win")
+ // One coalesced rename → one generation, carrying the final name.
+ #expect(spy.decisions.map(\.version) == [1])
+ #expect(spy.decisions.first?.name == "Bob")
// Keep broadcaster alive until assertions are done.
withExtendedLifetime(broadcaster) {}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -665,6 +665,203 @@ struct RecordSerializerTests {
#expect(try ctx.count(for: req) == 0)
}
+ // MARK: - Name decisions
+
+ /// The pairwise friend zone for (`local`, `remote`) — the only zone a
+ /// name Decision for `remote` is honored from.
+ private func friendZoneID(local: String, remote: String) -> CKRecordZone.ID {
+ CKRecordZone.ID(
+ zoneName: FriendZone.zoneName(pairKey: FriendZone.pairKey(local, remote)),
+ ownerName: "_zone-owner"
+ )
+ }
+
+ private func nameDecisionRecord(
+ subject: String,
+ name: String,
+ version: Int64,
+ zone: CKRecordZone.ID
+ ) -> CKRecord {
+ RecordSerializer.decisionRecord(
+ kind: RecordSerializer.nameDecisionKind,
+ key: subject,
+ payload: name,
+ zone: zone,
+ version: version
+ )
+ }
+
+ @Test("applyDecisionRecord(.name) updates an existing friend from its pair zone")
+ @MainActor func applyNameDecisionUpdatesFriend() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let pairKey = FriendZone.pairKey("_alice", "_bob")
+ let existing = FriendEntity(context: ctx)
+ existing.authorID = "_bob"
+ existing.pairKey = pairKey
+ existing.friendZoneName = FriendZone.zoneName(pairKey: pairKey)
+ existing.friendZoneOwnerName = CKCurrentUserDefaultName
+ existing.databaseScope = 0
+ existing.createdAt = Date()
+ try ctx.save()
+
+ let record = nameDecisionRecord(
+ subject: "_bob",
+ name: "Brandon",
+ version: 1,
+ zone: friendZoneID(local: "_alice", remote: "_bob")
+ )
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice"
+ )
+ #expect(wrote)
+ #expect(existing.displayName == "Brandon")
+ #expect(existing.displayNameVersion == 1)
+ }
+
+ @Test("applyDecisionRecord(.name) is last-writer-wins on version")
+ @MainActor func applyNameDecisionVersionGate() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let pairKey = FriendZone.pairKey("_alice", "_bob")
+ let existing = FriendEntity(context: ctx)
+ existing.authorID = "_bob"
+ existing.pairKey = pairKey
+ existing.friendZoneName = FriendZone.zoneName(pairKey: pairKey)
+ existing.friendZoneOwnerName = CKCurrentUserDefaultName
+ existing.databaseScope = 0
+ existing.createdAt = Date()
+ existing.displayName = "Brandon"
+ existing.displayNameVersion = 3
+ try ctx.save()
+
+ let zone = friendZoneID(local: "_alice", remote: "_bob")
+ let stale = nameDecisionRecord(subject: "_bob", name: "Old", version: 2, zone: zone)
+ #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice"))
+ #expect(existing.displayName == "Brandon")
+
+ let equal = nameDecisionRecord(subject: "_bob", name: "Bran", version: 3, zone: zone)
+ #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice"))
+ #expect(existing.displayName == "Bran")
+
+ let newer = nameDecisionRecord(subject: "_bob", name: "Brandon II", version: 4, zone: zone)
+ #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice"))
+ #expect(existing.displayName == "Brandon II")
+ #expect(existing.displayNameVersion == 4)
+ }
+
+ @Test("applyDecisionRecord(.name) resurrects a friendship from its zone")
+ @MainActor func applyNameDecisionResurrectsFriend() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let zone = friendZoneID(local: "_alice", remote: "_bob")
+
+ let record = nameDecisionRecord(subject: "_bob", name: "Brandon", version: 2, zone: zone)
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice", databaseScope: 1
+ )
+ #expect(wrote)
+
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(format: "authorID == %@", "_bob")
+ let friend = try ctx.fetch(req).first
+ #expect(friend?.displayName == "Brandon")
+ #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob"))
+ #expect(friend?.friendZoneName == zone.zoneName)
+ #expect(friend?.friendZoneOwnerName == "_zone-owner")
+ #expect(friend?.databaseScope == 1)
+ #expect(friend?.isBlocked == false)
+ }
+
+ @Test("applyDecisionRecord(.name) rejects a record outside the pair's zone")
+ @MainActor func applyNameDecisionRejectsForeignZone() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+
+ // A name for _carol arriving in the (_alice, _bob) zone: the zone
+ // hash doesn't match the (_alice, _carol) pair, so it must be dropped
+ // — a friend can't assert names for third parties.
+ let record = nameDecisionRecord(
+ subject: "_carol",
+ name: "Mallory",
+ version: 9,
+ zone: friendZoneID(local: "_alice", remote: "_bob")
+ )
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice"
+ )
+ #expect(!wrote)
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ #expect(try ctx.count(for: req) == 0)
+ }
+
+ @Test("applyDecisionRecord(.name) ignores our own name and writes no row")
+ @MainActor func applyNameDecisionIgnoresSelf() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+
+ let record = nameDecisionRecord(
+ subject: "_alice",
+ name: "Alice",
+ version: 5,
+ zone: RecordSerializer.accountZoneID
+ )
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice"
+ )
+ #expect(!wrote)
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ #expect(try ctx.count(for: req) == 0)
+ }
+
+ @Test("applyDecisionRecord(.name) leaves a blocked friend untouched")
+ @MainActor func applyNameDecisionSkipsBlocked() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let pairKey = FriendZone.pairKey("_alice", "_bob")
+ let blocked = FriendEntity(context: ctx)
+ blocked.authorID = "_bob"
+ blocked.pairKey = pairKey
+ blocked.friendZoneName = FriendZone.zoneName(pairKey: pairKey)
+ blocked.friendZoneOwnerName = CKCurrentUserDefaultName
+ blocked.databaseScope = 0
+ blocked.isBlocked = true
+ blocked.createdAt = Date()
+ try ctx.save()
+
+ let record = nameDecisionRecord(
+ subject: "_bob",
+ name: "Brandon",
+ version: 1,
+ zone: friendZoneID(local: "_alice", remote: "_bob")
+ )
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice"
+ )
+ #expect(!wrote)
+ #expect(blocked.displayName?.isEmpty != false)
+ #expect(blocked.isBlocked == true)
+ }
+
+ @Test("parseNameDecision reads subject, name, and version")
+ func parseNameDecisionFields() {
+ let record = nameDecisionRecord(
+ subject: "_bob",
+ name: "Brandon",
+ version: 7,
+ zone: RecordSerializer.accountZoneID
+ )
+ let parsed = RecordSerializer.parseNameDecision(record)
+ #expect(parsed?.authorID == "_bob")
+ #expect(parsed?.name == "Brandon")
+ #expect(parsed?.version == 7)
+ // Non-name decisions don't parse.
+ let block = RecordSerializer.decisionRecord(
+ kind: "block", key: "_bob", zone: RecordSerializer.accountZoneID
+ )
+ #expect(RecordSerializer.parseNameDecision(block) == nil)
+ }
+
@Test("applyDecisionRecord(.left) hard-deletes the participant game row")
@MainActor func applyDecisionLeftDeletesParticipantGame() throws {
let persistence = makeTestPersistence()