commit d476b9f8f8f29f4d9eb5a11aecdb0ce33aa348db
parent 5e69e71ac54b4f027c6aabd133f0b7036d51816b
Author: Michael Camilleri <[email protected]>
Date: Sun, 26 Apr 2026 10:55:14 +0900
Share player names in multiplayer
This commit adds support for player name syncing in multiplayer games.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
12 files changed, 971 insertions(+), 15 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; };
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
+ 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
+ 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; };
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */; };
@@ -51,11 +53,13 @@
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
+ CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */; };
CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; };
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
+ DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */; };
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; };
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; };
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; };
@@ -88,12 +92,14 @@
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
+ 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentityTests.swift; sourceTree = "<group>"; };
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; };
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
+ 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; };
4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; };
50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; };
52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; };
@@ -101,6 +107,7 @@
5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; };
5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
+ 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStore.swift; sourceTree = "<group>"; };
68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; };
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
@@ -139,6 +146,7 @@
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; };
EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; };
+ EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStoreTests.swift; sourceTree = "<group>"; };
F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; };
F7422F19AA1F1692A98E3602 /* MoveLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLog.swift; sourceTree = "<group>"; };
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; };
@@ -183,6 +191,7 @@
isa = PBXGroup;
children = (
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */,
BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */,
543481AA9FA32BF14076EB1C /* MoveLogTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
@@ -198,8 +207,10 @@
children = (
B135C285570F91181595B405 /* CellMark.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
+ 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */,
+ 3292748EAE27B608C769D393 /* PlayerRoster.swift */,
20B331CC55827FEF3420ABCE /* PlayerSession.swift */,
64C8064F04FC6177D987ACA2 /* Puzzle.swift */,
4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */,
@@ -297,6 +308,7 @@
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */,
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */,
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
+ 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */,
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */,
@@ -413,6 +425,7 @@
files = (
A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */,
3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */,
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */,
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
@@ -441,6 +454,7 @@
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
818B1F2693962832BE14578E /* GameListView.swift in Sources */,
3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
+ DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */,
765B50552B13175F91A25EA1 /* GridView.swift in Sources */,
@@ -454,10 +468,12 @@
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */,
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */,
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */,
+ CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */,
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */,
+ 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */,
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -79,13 +79,14 @@ struct RootView: View {
PuzzleDisplayView(
gameID: gameID,
store: services.store,
- shareController: services.shareController
+ shareController: services.shareController,
+ services: services
)
}
}
.environment(preferences)
.task {
- await services.start(appDelegate: appDelegate)
+ await services.start(appDelegate: appDelegate, preferences: preferences)
}
.onOpenURL { url in
if let id = services.handleOpenURL(url) {
@@ -112,8 +113,11 @@ private struct PuzzleDisplayView: View {
let gameID: UUID
let store: GameStore
let shareController: ShareController
+ let services: AppServices
+ @Environment(PlayerPreferences.self) private var preferences
@State private var session: PlayerSession?
+ @State private var roster: PlayerRoster?
@State private var loadError: String?
var body: some View {
@@ -122,6 +126,7 @@ private struct PuzzleDisplayView: View {
PuzzleView(
session: session,
shareController: shareController,
+ roster: roster,
onComplete: { store.markCompleted(id: gameID) }
)
} else if let loadError {
@@ -138,6 +143,9 @@ private struct PuzzleDisplayView: View {
do {
let (game, mutator) = try store.loadGame(id: gameID)
session = PlayerSession(game: game, mutator: mutator)
+ if mutator.isShared {
+ roster = services.makePlayerRoster(for: gameID, preferences: preferences)
+ }
} catch {
loadError = String(describing: error)
}
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -18,8 +18,21 @@
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/>
<relationship name="moves" toMany="YES" deletionRule="Cascade" destinationEntity="MoveEntity" inverseName="game" inverseEntity="MoveEntity"/>
+ <relationship name="players" toMany="YES" deletionRule="Cascade" destinationEntity="PlayerEntity" inverseName="game" inverseEntity="PlayerEntity"/>
<relationship name="snapshots" toMany="YES" deletionRule="Cascade" destinationEntity="SnapshotEntity" inverseName="game" inverseEntity="SnapshotEntity"/>
</entity>
+ <entity name="PlayerEntity" representedClassName="PlayerEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="authorID" attributeType="String"/>
+ <attribute name="ckRecordName" attributeType="String"/>
+ <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
+ <attribute name="name" attributeType="String" defaultValueString=""/>
+ <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
+ <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="players" inverseEntity="GameEntity"/>
+ <fetchIndex name="byGameAndAuthor">
+ <fetchIndexElement property="game" type="Binary" order="ascending"/>
+ <fetchIndexElement property="authorID" type="Binary" order="ascending"/>
+ </fetchIndex>
+ </entity>
<entity name="MoveEntity" representedClassName="MoveEntity" syncable="YES" codeGenerationType="class">
<attribute name="authorID" optional="YES" attributeType="String"/>
<attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
diff --git a/Crossmate/Models/GamePlayerColorStore.swift b/Crossmate/Models/GamePlayerColorStore.swift
@@ -0,0 +1,125 @@
+import Foundation
+
+extension Notification.Name {
+ static let gamePlayerColorsChanged = Notification.Name("gamePlayerColorsChanged")
+}
+
+/// Persists per-game, per-author colour assignments in `UserDefaults`.
+/// Colours are device-local by design — they are never synced to CloudKit.
+///
+/// Layout under the `"gamePlayerColors"` key:
+/// `[gameID.uuidString: [authorID: colorID]]`
+@MainActor
+final class GamePlayerColorStore {
+ private let defaults: UserDefaults
+ private let defaultsKey = "gamePlayerColors"
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ // MARK: - Read
+
+ func color(forGame gameID: UUID, authorID: String) -> PlayerColor? {
+ guard let colorID = rawStore[gameID.uuidString]?[authorID] else { return nil }
+ return PlayerColor.color(for: colorID)
+ }
+
+ /// Returns the stored colour for `authorID` if present; otherwise picks a
+ /// random palette entry not in `reservedColorIDs` ∪ already-assigned
+ /// colours for this game, persists it, and returns it.
+ func ensureColor(
+ forGame gameID: UUID,
+ authorID: String,
+ reservedColorIDs: Set<String>
+ ) -> PlayerColor {
+ var rng = SystemRandomNumberGenerator()
+ return ensureColor(
+ forGame: gameID,
+ authorID: authorID,
+ reservedColorIDs: reservedColorIDs,
+ using: &rng
+ )
+ }
+
+ func ensureColor<RNG: RandomNumberGenerator>(
+ forGame gameID: UUID,
+ authorID: String,
+ reservedColorIDs: Set<String>,
+ using rng: inout RNG
+ ) -> PlayerColor {
+ if let existing = color(forGame: gameID, authorID: authorID) {
+ return existing
+ }
+ let alreadyAssigned = assignedColorIDs(forGame: gameID, excludingAuthorID: authorID)
+ let taken = reservedColorIDs.union(alreadyAssigned)
+ let available = PlayerColor.palette.filter { !taken.contains($0.id) }
+ let chosen: PlayerColor
+ if let pick = available.randomElement(using: &rng) {
+ chosen = pick
+ } else {
+ // Palette exhausted — fall back to any palette colour.
+ chosen = PlayerColor.palette.randomElement(using: &rng) ?? .blue
+ }
+ setColor(chosen, forGame: gameID, authorID: authorID)
+ return chosen
+ }
+
+ /// Returns the set of colorIDs already assigned to any author for `gameID`,
+ /// optionally skipping one author (useful for collision reassignment).
+ func assignedColorIDs(forGame gameID: UUID, excludingAuthorID: String?) -> Set<String> {
+ let gameMap = rawStore[gameID.uuidString] ?? [:]
+ let ids = gameMap.compactMap { key, value -> String? in
+ key == excludingAuthorID ? nil : value
+ }
+ return Set(ids)
+ }
+
+ /// Returns all authorIDs that have a stored colour entry for `gameID`.
+ func storedAuthorIDs(forGame gameID: UUID) -> Set<String> {
+ Set((rawStore[gameID.uuidString] ?? [:]).keys)
+ }
+
+ // MARK: - Write
+
+ func setColor(_ color: PlayerColor, forGame gameID: UUID, authorID: String) {
+ var s = rawStore
+ var gameMap = s[gameID.uuidString] ?? [:]
+ gameMap[authorID] = color.id
+ s[gameID.uuidString] = gameMap
+ rawStore = s
+ postChange(gameID: gameID)
+ }
+
+ func clearColors(forGame gameID: UUID) {
+ var s = rawStore
+ s.removeValue(forKey: gameID.uuidString)
+ rawStore = s
+ postChange(gameID: gameID)
+ }
+
+ func clearColor(forGame gameID: UUID, authorID: String) {
+ var s = rawStore
+ s[gameID.uuidString]?.removeValue(forKey: authorID)
+ if s[gameID.uuidString]?.isEmpty == true {
+ s.removeValue(forKey: gameID.uuidString)
+ }
+ rawStore = s
+ postChange(gameID: gameID)
+ }
+
+ // MARK: - Private
+
+ private var rawStore: [String: [String: String]] {
+ get { defaults.dictionary(forKey: defaultsKey) as? [String: [String: String]] ?? [:] }
+ set { defaults.set(newValue, forKey: defaultsKey) }
+ }
+
+ private func postChange(gameID: UUID) {
+ NotificationCenter.default.post(
+ name: .gamePlayerColorsChanged,
+ object: nil,
+ userInfo: ["gameID": gameID]
+ )
+ }
+}
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -0,0 +1,268 @@
+import CloudKit
+import CoreData
+import Foundation
+import Observation
+
+/// Observable view-model that represents all participants (local + remote)
+/// in a single shared game. Drives the "Players" menu in `PuzzleView`.
+@Observable
+@MainActor
+final class PlayerRoster {
+
+ struct Entry: Identifiable {
+ let authorID: String
+ let name: String
+ let color: PlayerColor
+ let isLocal: Bool
+ var id: String { authorID }
+ }
+
+ private(set) var entries: [Entry] = []
+
+ private let gameID: UUID
+ private let colorStore: GamePlayerColorStore
+ private let authorIdentity: AuthorIdentity
+ private let preferences: PlayerPreferences
+ private let persistence: PersistenceController
+ private let container: CKContainer
+ private let syncEngine: SyncEngine
+
+ private var cachedShare: CKShare?
+ private var observationTasks: [Task<Void, Never>] = []
+
+ init(
+ gameID: UUID,
+ colorStore: GamePlayerColorStore,
+ authorIdentity: AuthorIdentity,
+ preferences: PlayerPreferences,
+ persistence: PersistenceController,
+ container: CKContainer,
+ syncEngine: SyncEngine
+ ) {
+ self.gameID = gameID
+ self.colorStore = colorStore
+ self.authorIdentity = authorIdentity
+ self.preferences = preferences
+ self.persistence = persistence
+ self.container = container
+ self.syncEngine = syncEngine
+ startObserving()
+ }
+
+ isolated deinit {
+ for task in observationTasks {
+ task.cancel()
+ }
+ }
+
+ // MARK: - Observation
+
+ private func startObserving() {
+ let gameID = self.gameID
+
+ // Local colour assignments changed (from `setColor` / collision
+ // resolution / gc) — refresh so the menu reflects the new mapping.
+ observationTasks.append(
+ Task { [weak self] in
+ for await note in NotificationCenter.default.notifications(
+ named: .gamePlayerColorsChanged
+ ) {
+ guard let self else { return }
+ guard let id = note.userInfo?["gameID"] as? UUID,
+ id == gameID else { continue }
+ await self.refresh()
+ }
+ }
+ )
+
+ // Remote record changes affecting this game — name records arriving
+ // from other participants, new participants joining (move records
+ // carry a fresh authorID), share metadata changes. Invalidate the
+ // cached share so the next refresh re-fetches it; participant lists
+ // may have moved.
+ observationTasks.append(
+ Task { [weak self] in
+ for await note in NotificationCenter.default.notifications(
+ named: .playerRosterShouldRefresh
+ ) {
+ guard let self else { return }
+ guard let ids = note.userInfo?["gameIDs"] as? Set<UUID>,
+ ids.contains(gameID) else { continue }
+ self.cachedShare = nil
+ await self.refresh()
+ }
+ }
+ )
+ }
+
+ // MARK: - Refresh
+
+ func refresh() async {
+ let localAuthorID = authorIdentity.currentID ?? ""
+
+ // Pull Core Data fields off a background context.
+ let ctx = persistence.container.newBackgroundContext()
+ let (databaseScope, ckShareRecordName, ckZoneName, ckZoneOwnerName, namesMap, moveAuthorIDs) =
+ ctx.performAndWait { () -> (Int16, String?, String?, String?, [String: String], [String]) in
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first else {
+ return (0, nil, nil, nil, [:], [])
+ }
+ let nameReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ nameReq.predicate = NSPredicate(format: "game == %@", entity)
+ let nameEntities = (try? ctx.fetch(nameReq)) ?? []
+ var namesMap: [String: String] = [:]
+ for nr in nameEntities {
+ guard let aid = nr.authorID, !aid.isEmpty,
+ let name = nr.name, !name.isEmpty
+ else { continue }
+ namesMap[aid] = name
+ }
+ let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
+ moveReq.predicate = NSPredicate(format: "game == %@", entity)
+ let moveEntities = (try? ctx.fetch(moveReq)) ?? []
+ let authorIDs = Array(
+ Set(moveEntities.compactMap { $0.authorID })
+ .subtracting([localAuthorID, ""])
+ )
+ return (
+ entity.databaseScope,
+ entity.ckShareRecordName,
+ entity.ckZoneName,
+ entity.ckZoneOwnerName,
+ namesMap,
+ authorIDs
+ )
+ }
+
+ // Fetch the CKShare if not already cached.
+ let share = await fetchShare(
+ databaseScope: databaseScope,
+ ckShareRecordName: ckShareRecordName,
+ ckZoneName: ckZoneName,
+ ckZoneOwnerName: ckZoneOwnerName
+ )
+
+ // Collect all remote participant authorIDs.
+ var otherAuthorIDs = Set<String>()
+ for key in namesMap.keys where key != localAuthorID && !key.isEmpty {
+ otherAuthorIDs.insert(key)
+ }
+ if let share {
+ for participant in share.participants {
+ guard participant.acceptanceStatus == .accepted,
+ let recordName = participant.userIdentity.userRecordID?.recordName,
+ recordName != localAuthorID
+ else { continue }
+ otherAuthorIDs.insert(recordName)
+ }
+ }
+ for authorID in moveAuthorIDs {
+ otherAuthorIDs.insert(authorID)
+ }
+
+ // Build remote entries, assigning stable colours.
+ var remoteEntries: [Entry] = []
+ let reservedColorIDs: Set<String> = [preferences.color.id]
+ for authorID in otherAuthorIDs.sorted() {
+ let name = resolveName(authorID: authorID, namesMap: namesMap, share: share)
+ let color = colorStore.ensureColor(
+ forGame: gameID,
+ authorID: authorID,
+ reservedColorIDs: reservedColorIDs
+ )
+ remoteEntries.append(Entry(authorID: authorID, name: name, color: color, isLocal: false))
+ }
+ remoteEntries.sort {
+ $0.name == $1.name ? $0.authorID < $1.authorID : $0.name < $1.name
+ }
+
+ // Garbage-collect stale colour entries.
+ let currentRemoteIDs = Set(remoteEntries.map { $0.authorID })
+ for staleID in colorStore.storedAuthorIDs(forGame: gameID).subtracting(currentRemoteIDs) {
+ colorStore.clearColor(forGame: gameID, authorID: staleID)
+ }
+
+ let localEntry = Entry(
+ authorID: localAuthorID,
+ name: preferences.name,
+ color: preferences.color,
+ isLocal: true
+ )
+ entries = [localEntry] + remoteEntries
+ }
+
+ // MARK: - Collision resolution
+
+ /// When the local user picks a colour that collides with a remote entry,
+ /// silently reassign the victim to the first free palette colour.
+ func reassignOnLocalColorChange(newColor: PlayerColor) async {
+ guard let victim = entries.first(where: { !$0.isLocal && $0.color.id == newColor.id }) else {
+ return
+ }
+ let taken = Set([newColor.id]).union(
+ colorStore.assignedColorIDs(forGame: gameID, excludingAuthorID: victim.authorID)
+ )
+ let replacement = PlayerColor.palette.first { !taken.contains($0.id) }
+ ?? PlayerColor.palette.randomElement()
+ ?? .blue
+ colorStore.setColor(replacement, forGame: gameID, authorID: victim.authorID)
+ await refresh()
+ }
+
+ // MARK: - Private helpers
+
+ private func fetchShare(
+ databaseScope: Int16,
+ ckShareRecordName: String?,
+ ckZoneName: String?,
+ ckZoneOwnerName: String?
+ ) async -> CKShare? {
+ if let cached = cachedShare { return cached }
+ guard let zoneName = ckZoneName else { return nil }
+ let ownerName = ckZoneOwnerName ?? CKCurrentUserDefaultName
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
+ do {
+ if databaseScope == 0, let shareRecordName = ckShareRecordName {
+ let shareID = CKRecord.ID(recordName: shareRecordName, zoneID: zoneID)
+ let share = try await container.privateCloudDatabase.record(for: shareID) as? CKShare
+ cachedShare = share
+ return share
+ } else if databaseScope == 1 {
+ let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID)
+ let share = try await container.sharedCloudDatabase.record(for: shareID) as? CKShare
+ cachedShare = share
+ return share
+ }
+ } catch {
+ // Best effort — proceed without share metadata.
+ }
+ return nil
+ }
+
+ private func resolveName(
+ authorID: String,
+ namesMap: [String: String],
+ share: CKShare?
+ ) -> String {
+ // (a) PlayerEntity for this (game, author)
+ if let name = namesMap[authorID], !name.isEmpty { return name }
+ // (b–c) CKShare participant name / email
+ if let share,
+ let participant = share.participants.first(where: {
+ $0.userIdentity.userRecordID?.recordName == authorID
+ }) {
+ if let components = participant.userIdentity.nameComponents {
+ let formatted = PersonNameComponentsFormatter().string(from: components)
+ if !formatted.isEmpty { return formatted }
+ }
+ if let email = participant.userIdentity.lookupInfo?.emailAddress {
+ return email
+ }
+ }
+ // (d) Fallback
+ return "Player"
+ }
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -13,9 +13,11 @@ final class AppServices {
let moveBuffer: MoveBuffer
let identity: AuthorIdentity
let shareController: ShareController
+ let colorStore: GamePlayerColorStore
private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
private var started = false
+ private var nameBroadcaster: NameBroadcaster?
init() {
self.persistence = PersistenceController()
@@ -62,9 +64,10 @@ final class AppServices {
store.onGameDeleted = { [syncEngine] ckRecordNames in
Task { await syncEngine.enqueueDeleteRecords(ckRecordNames) }
}
+ self.colorStore = GamePlayerColorStore()
}
- func start(appDelegate: AppDelegate) async {
+ func start(appDelegate: AppDelegate, preferences: PlayerPreferences) async {
guard !started else { return }
started = true
@@ -91,6 +94,7 @@ final class AppServices {
await syncEngine.setOnAccountChange { [weak self] in
guard let self else { return }
await self.identity.refresh(using: self.ckContainer)
+ await self.syncEngine.setLocalAuthorID(self.identity.currentID)
}
await syncEngine.setOnGameAccessRevoked { [store] gameID in
@@ -99,6 +103,15 @@ final class AppServices {
// Fetch identity before starting engines so first moves get an authorID.
await identity.refresh(using: ckContainer)
+ await syncEngine.setLocalAuthorID(identity.currentID)
+
+ // NameBroadcaster fans out name changes to all shared/joined games.
+ nameBroadcaster = NameBroadcaster(
+ preferences: preferences,
+ persistence: persistence,
+ authorIdentity: identity,
+ syncEngine: syncEngine
+ )
await syncEngine.start()
@@ -162,9 +175,22 @@ final class AppServices {
_ = await (privateCleanup, sharedCleanup)
store.resetAllData()
+ UserDefaults.standard.removeObject(forKey: "gamePlayerColors")
syncMonitor.note("Database reset — all games and sync state cleared")
}
+ func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster {
+ PlayerRoster(
+ gameID: gameID,
+ colorStore: colorStore,
+ authorIdentity: identity,
+ preferences: preferences,
+ persistence: persistence,
+ container: ckContainer,
+ syncEngine: syncEngine
+ )
+ }
+
func handleOpenURL(_ url: URL) -> UUID? {
guard url.pathExtension.lowercased() == "xd" else { return nil }
diff --git a/Crossmate/Services/NameBroadcaster.swift b/Crossmate/Services/NameBroadcaster.swift
@@ -0,0 +1,126 @@
+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.
+///
+/// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
+/// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
+/// a single rename should produce exactly one fan-out.
+@MainActor
+final class NameBroadcaster {
+ private let preferences: PlayerPreferences
+ private let persistence: PersistenceController
+ private let authorIdentity: AuthorIdentity
+ private let syncEngine: SyncEngine
+
+ private var debounceTask: Task<Void, Never>?
+ private var observationTask: Task<Void, Never>?
+
+ init(
+ preferences: PlayerPreferences,
+ persistence: PersistenceController,
+ authorIdentity: AuthorIdentity,
+ syncEngine: SyncEngine
+ ) {
+ self.preferences = preferences
+ self.persistence = persistence
+ self.authorIdentity = authorIdentity
+ self.syncEngine = syncEngine
+ startObserving()
+ }
+
+ private func startObserving() {
+ observationTask = Task { [weak self] in
+ guard let self else { return }
+ while !Task.isCancelled {
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
+ withObservationTracking {
+ _ = self.preferences.name
+ } onChange: {
+ cont.resume()
+ }
+ }
+ guard !Task.isCancelled else { break }
+ self.scheduleDebounce()
+ }
+ }
+ }
+
+ private func scheduleDebounce() {
+ debounceTask?.cancel()
+ debounceTask = Task { [weak self] in
+ do {
+ try await Task.sleep(for: .milliseconds(250))
+ } catch {
+ return
+ }
+ guard let self, !Task.isCancelled else { return }
+ await self.fanOut(newName: self.preferences.name)
+ }
+ }
+
+ /// Writes the local user's name into the `PlayerEntity` row for every
+ /// shared or joined game and asks the sync engine to push each one. Called
+ /// directly on game-share creation/accept so the partner sees a name on
+ /// the very first sync.
+ func broadcastName() async {
+ await fanOut(newName: preferences.name)
+ }
+
+ private func fanOut(newName: String) async {
+ guard let authorID = authorIdentity.currentID else { return }
+
+ let ctx = persistence.container.newBackgroundContext()
+ let touchedGameIDs = Self.upsertPlayerRecords(
+ in: ctx,
+ authorID: authorID,
+ name: newName
+ )
+
+ for gameID in touchedGameIDs {
+ await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID)
+ }
+ }
+
+ /// Background-context work — main-actor isolation does not apply here.
+ private nonisolated static func upsertPlayerRecords(
+ in ctx: NSManagedObjectContext,
+ authorID: String,
+ name: String
+ ) -> [UUID] {
+ ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "ckShareRecordName != nil OR databaseScope == 1"
+ )
+ let games = (try? ctx.fetch(req)) ?? []
+ var ids: [UUID] = []
+ let now = Date()
+ for game in games {
+ guard let gameID = game.id else { continue }
+ let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
+ let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ lookup.fetchLimit = 1
+
+ let entity: PlayerEntity
+ if let existing = try? ctx.fetch(lookup).first {
+ entity = existing
+ } else {
+ entity = PlayerEntity(context: ctx)
+ entity.game = game
+ entity.ckRecordName = recordName
+ entity.authorID = authorID
+ }
+ entity.name = name
+ entity.updatedAt = now
+ ids.append(gameID)
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ return ids
+ }
+ }
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -44,6 +44,13 @@ enum RecordSerializer {
"snapshot-\(gameID.uuidString)-\(upToLamport)-\(localDeviceID)"
}
+ /// One player record per (game, author). Each participant only ever
+ /// writes to their own slot, so there are no write-write conflicts on
+ /// the field.
+ static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String {
+ "player-\(gameID.uuidString)-\(authorID)"
+ }
+
// MARK: - Zone
/// Zone ID for a per-game zone. `ownerName` defaults to the current user
@@ -112,6 +119,49 @@ enum RecordSerializer {
return record
}
+ static func playerRecord(
+ gameID: UUID,
+ authorID: String,
+ name: String,
+ updatedAt: Date,
+ zone: CKRecordZone.ID,
+ systemFields: Data?
+ ) -> CKRecord {
+ let recordName = recordName(forPlayerInGame: gameID, authorID: authorID)
+ let record = restoreOrCreate(
+ recordType: "Player",
+ recordName: recordName,
+ zone: zone,
+ systemFields: systemFields
+ )
+
+ record["authorID"] = authorID as CKRecordValue
+ record["name"] = name as CKRecordValue
+ record["updatedAt"] = updatedAt as CKRecordValue
+
+ let gameName = self.recordName(forGameID: gameID)
+ let parentID = CKRecord.ID(recordName: gameName, zoneID: zone)
+ record.parent = CKRecord.Reference(recordID: parentID, action: .none)
+ return record
+ }
+
+ /// Parses an incoming `Player` record name back into its `(gameID,
+ /// authorID)` components. Returns `nil` if the name doesn't match the
+ /// `player-<UUID>-<authorID>` shape.
+ static func parsePlayerRecordName(_ name: String) -> (UUID, String)? {
+ guard name.hasPrefix("player-") else { return nil }
+ let rest = name.dropFirst("player-".count)
+ let uuidLength = 36
+ guard rest.count > uuidLength,
+ rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
+ else { return nil }
+ let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
+ guard let gameID = UUID(uuidString: uuidPart) else { return nil }
+ let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1)))
+ guard !authorPart.isEmpty else { return nil }
+ return (gameID, authorPart)
+ }
+
/// Parses an incoming `Move` CKRecord into a value type without
/// touching Core Data. Returns `nil` if required fields are missing or
/// the record name doesn't match the `move-<gameUUID>-<lamport>` shape.
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1,6 +1,7 @@
import CloudKit
import CoreData
import Foundation
+import os.lock
import SwiftUI
extension EnvironmentValues {
@@ -8,6 +9,14 @@ extension EnvironmentValues {
@Entry var resetDatabase: (() async -> Void)? = nil
}
+extension Notification.Name {
+ /// Posted by `SyncEngine` after applying fetched record zone changes
+ /// that affected one or more games. `userInfo["gameIDs"]` is a
+ /// `Set<UUID>`. `PlayerRoster` observes this to refresh in response
+ /// to remote name updates and new participants joining.
+ static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
+}
+
/// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
/// the private database (owned games and shares) and one for the shared
/// database (joined games). Zone creation, subscription setup, change-token
@@ -32,6 +41,10 @@ actor SyncEngine {
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
+ // Cached local identity — written from the main actor, read from
+ // nonisolated buildRecord via OSAllocatedUnfairLock.
+ private let cachedLocalAuthorID = OSAllocatedUnfairLock<String?>(initialState: nil)
+
func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
tracer = t
}
@@ -48,6 +61,10 @@ actor SyncEngine {
onGameAccessRevoked = cb
}
+ func setLocalAuthorID(_ id: String?) {
+ cachedLocalAuthorID.withLock { $0 = id }
+ }
+
init(container: CKContainer, persistence: PersistenceController) {
self.container = container
self.persistence = persistence
@@ -137,6 +154,19 @@ actor SyncEngine {
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
+ /// Registers a Player record as a pending send. Used by `NameBroadcaster`
+ /// when the local user renames; one record per (game, authorID), so
+ /// participants only ever write their own slot.
+ func enqueuePlayerRecord(gameID: UUID, authorID: String) {
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
+ let engine = info.scope == 1 ? sharedEngine : privateEngine
+ guard let engine else { return }
+ let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
+ engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ }
+
/// Registers a Game record as a pending send and ensures its zone is
/// created in CloudKit first. Called when a new game is created locally.
func enqueueGame(ckRecordName: String) {
@@ -278,7 +308,7 @@ actor SyncEngine {
}
/// Extracts the game UUID from any of our record name formats:
- /// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`.
+ /// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`, `player-<UUID>-…`.
private nonisolated func gameID(fromRecordName name: String) -> UUID? {
if name.hasPrefix("game-") {
return UUID(uuidString: String(name.dropFirst("game-".count)))
@@ -286,6 +316,7 @@ actor SyncEngine {
let prefix: String
if name.hasPrefix("move-") { prefix = "move-" }
else if name.hasPrefix("snapshot-") { prefix = "snapshot-" }
+ else if name.hasPrefix("player-") { prefix = "player-" }
else { return nil }
let rest = name.dropFirst(prefix.count)
return UUID(uuidString: String(rest.prefix(36)))
@@ -369,6 +400,24 @@ actor SyncEngine {
zone: zoneID,
systemFields: entity.ckSystemFields
)
+ } else if name.hasPrefix("player-") {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", name)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first,
+ let gameID = entity.game?.id,
+ let authorID = entity.authorID,
+ let renderedName = entity.name,
+ let updatedAt = entity.updatedAt
+ else { return nil }
+ return RecordSerializer.playerRecord(
+ gameID: gameID,
+ authorID: authorID,
+ name: renderedName,
+ updatedAt: updatedAt,
+ zone: zoneID,
+ systemFields: entity.ckSystemFields
+ )
}
return nil
}
@@ -450,6 +499,52 @@ actor SyncEngine {
}
}
+ private nonisolated func applyPlayerRecord(
+ _ record: CKRecord,
+ in ctx: NSManagedObjectContext
+ ) {
+ let ckName = record.recordID.recordName
+ guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else {
+ return
+ }
+ guard let renderedName = record["name"] as? String else { return }
+ let updatedAt = record["updatedAt"] as? Date
+ ?? record.modificationDate
+ ?? Date()
+
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
+ req.fetchLimit = 1
+
+ let entity: PlayerEntity
+ if let existing = try? ctx.fetch(req).first {
+ entity = existing
+ } else {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ if let parentRef = record.parent {
+ gameReq.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ parentRef.recordID.recordName
+ )
+ } else {
+ gameReq.predicate = NSPredicate(
+ format: "ckRecordName == %@",
+ RecordSerializer.recordName(forGameID: gameID)
+ )
+ }
+ gameReq.fetchLimit = 1
+ guard let game = try? ctx.fetch(gameReq).first else { return }
+ entity = PlayerEntity(context: ctx)
+ entity.game = game
+ }
+
+ entity.ckRecordName = ckName
+ entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record)
+ entity.authorID = authorID
+ entity.name = renderedName
+ entity.updatedAt = updatedAt
+ }
+
private nonisolated func applySnapshotRecord(
_ record: CKRecord,
snapshot: Snapshot,
@@ -641,21 +736,30 @@ actor SyncEngine {
)
let ctx = persistence.container.newBackgroundContext()
- let newMoves: [Move] = ctx.performAndWait {
+ let (newMoves, affectedGameIDs): ([Move], Set<UUID>) = ctx.performAndWait {
var moves: [Move] = []
+ var affected = Set<UUID>()
for mod in event.modifications {
let record = mod.record
switch record.recordType {
case "Game":
- _ = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope)
+ let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope)
+ if let id = entity.id { affected.insert(id) }
case "Move":
if let move = RecordSerializer.parseMoveRecord(record) {
self.applyMoveRecord(record, move: move, in: ctx)
moves.append(move)
+ affected.insert(move.gameID)
}
case "Snapshot":
if let snapshot = RecordSerializer.parseSnapshotRecord(record) {
self.applySnapshotRecord(record, snapshot: snapshot, in: ctx)
+ affected.insert(snapshot.gameID)
+ }
+ case "Player":
+ if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
+ self.applyPlayerRecord(record, in: ctx)
+ affected.insert(gameID)
}
default:
break
@@ -667,18 +771,27 @@ actor SyncEngine {
recordType: deletion.recordType,
in: ctx
)
+ if let id = self.gameID(fromRecordName: deletion.recordID.recordName) {
+ affected.insert(id)
+ }
}
- let affectedGameIDs = Set(moves.map { $0.gameID })
- for gameID in affectedGameIDs {
+ for gameID in Set(moves.map { $0.gameID }) {
self.replayCellCache(for: gameID, in: ctx)
}
if ctx.hasChanges { try? ctx.save() }
- return moves
+ return (moves, affected)
}
if let onRemoteMoves, !newMoves.isEmpty {
await onRemoteMoves(newMoves)
}
+ if !affectedGameIDs.isEmpty {
+ NotificationCenter.default.post(
+ name: .playerRosterShouldRefresh,
+ object: nil,
+ userInfo: ["gameIDs": affectedGameIDs]
+ )
+ }
}
private nonisolated func applyDeletion(
@@ -692,12 +805,15 @@ actor SyncEngine {
entityName = "MoveEntity"
} else if name.hasPrefix("snapshot-") {
entityName = "SnapshotEntity"
+ } else if name.hasPrefix("player-") {
+ entityName = "PlayerEntity"
} else if name.hasPrefix("game-") {
entityName = "GameEntity"
} else {
switch recordType {
case "Move": entityName = "MoveEntity"
case "Snapshot": entityName = "SnapshotEntity"
+ case "Player": entityName = "PlayerEntity"
case "Game": entityName = "GameEntity"
default: return
}
@@ -745,6 +861,7 @@ actor SyncEngine {
let entityName: String
if name.hasPrefix("move-") { entityName = "MoveEntity" }
else if name.hasPrefix("snapshot-") { entityName = "SnapshotEntity" }
+ else if name.hasPrefix("player-") { entityName = "PlayerEntity" }
else if name.hasPrefix("game-") { entityName = "GameEntity" }
else { return }
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct PuzzleView: View {
@Bindable var session: PlayerSession
var shareController: ShareController? = nil
+ var roster: PlayerRoster? = nil
var onComplete: (() -> Void)? = nil
@Environment(PlayerPreferences.self) private var preferences
@Environment(\.dismiss) private var dismiss
@@ -130,14 +131,27 @@ struct PuzzleView: View {
Menu {
Section {
- Button {} label: {
- Label {
- Text(preferences.name)
- } icon: {
- swatchImage(for: preferences.color)
+ if let roster, !roster.entries.isEmpty {
+ ForEach(roster.entries) { entry in
+ Button {} label: {
+ Label {
+ Text(entry.isLocal ? "\(entry.name) (you)" : entry.name)
+ } icon: {
+ swatchImage(for: entry.color)
+ }
+ }
+ .disabled(true)
}
+ } else {
+ Button {} label: {
+ Label {
+ Text(preferences.name)
+ } icon: {
+ swatchImage(for: preferences.color)
+ }
+ }
+ .disabled(true)
}
- .disabled(true)
}
Section {
@@ -145,6 +159,9 @@ struct PuzzleView: View {
ForEach(PlayerColor.palette) { color in
Button {
preferences.color = color
+ if let roster {
+ Task { await roster.reassignOnLocalColorChange(newColor: color) }
+ }
} label: {
Label {
Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name)
@@ -177,6 +194,10 @@ struct PuzzleView: View {
}
}
}
+ .task {
+ guard let roster else { return }
+ await roster.refresh()
+ }
.onChange(of: session.game.completionState) { _, newValue in
switch newValue {
case .incomplete:
diff --git a/Tests/Unit/GamePlayerColorStoreTests.swift b/Tests/Unit/GamePlayerColorStoreTests.swift
@@ -0,0 +1,160 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("GamePlayerColorStore")
+@MainActor
+struct GamePlayerColorStoreTests {
+
+ private func makeStore() -> GamePlayerColorStore {
+ // Use a fresh UserDefaults suite per test to avoid cross-test pollution.
+ let suiteName = "test-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suiteName)!
+ return GamePlayerColorStore(defaults: defaults)
+ }
+
+ // MARK: - ensureColor
+
+ @Test("ensureColor on empty store picks a palette entry and persists it")
+ func ensureColorPersists() {
+ let store = makeStore()
+ let gameID = UUID()
+ let color = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [])
+ #expect(PlayerColor.palette.contains(color))
+ // Second call returns the same colour.
+ let again = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [])
+ #expect(again.id == color.id)
+ }
+
+ @Test("ensureColor never returns a reserved colour when alternatives exist")
+ func ensureColorRespectsReserved() {
+ let store = makeStore()
+ let gameID = UUID()
+ // Reserve all colours except the last one.
+ let reserved = Set(PlayerColor.palette.dropLast().map { $0.id })
+ let color = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: reserved)
+ #expect(!reserved.contains(color.id))
+ #expect(color.id == PlayerColor.palette.last!.id)
+ }
+
+ @Test("ensureColor with seeded RNG is deterministic")
+ func ensureColorSeededRNG() {
+ let store = makeStore()
+ let gameID = UUID()
+ var rng = SeededRNG(seed: 42)
+ let c1 = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [], using: &rng)
+
+ let store2 = makeStore()
+ var rng2 = SeededRNG(seed: 42)
+ let c2 = store2.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: [], using: &rng2)
+
+ #expect(c1.id == c2.id)
+ }
+
+ @Test("ensureColor with palette fully saturated falls back to a palette entry")
+ func ensureColorFallback() {
+ let store = makeStore()
+ let gameID = UUID()
+ // Assign all palette colours to other authors for this game.
+ for (i, color) in PlayerColor.palette.enumerated() {
+ store.setColor(color, forGame: gameID, authorID: "_other\(i)")
+ }
+ // All colours are assigned; ensureColor should still return something.
+ let reserved = Set(PlayerColor.palette.map { $0.id })
+ var rng = SeededRNG(seed: 99)
+ let fallback = store.ensureColor(
+ forGame: gameID,
+ authorID: "_new",
+ reservedColorIDs: reserved,
+ using: &rng
+ )
+ #expect(PlayerColor.palette.contains(fallback))
+ }
+
+ // MARK: - setColor / color round-trip
+
+ @Test("setColor and color round-trip correctly")
+ func setColorRoundTrip() {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setColor(.red, forGame: gameID, authorID: "_A")
+ #expect(store.color(forGame: gameID, authorID: "_A")?.id == PlayerColor.red.id)
+ }
+
+ @Test("color returns nil for unknown authorID")
+ func colorNilForUnknown() {
+ let store = makeStore()
+ #expect(store.color(forGame: UUID(), authorID: "_unknown") == nil)
+ }
+
+ // MARK: - clearColors / clearColor
+
+ @Test("clearColors removes all entries for a game")
+ func clearColorsRemovesAll() {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setColor(.red, forGame: gameID, authorID: "_A")
+ store.setColor(.blue, forGame: gameID, authorID: "_B")
+ store.clearColors(forGame: gameID)
+ #expect(store.color(forGame: gameID, authorID: "_A") == nil)
+ #expect(store.color(forGame: gameID, authorID: "_B") == nil)
+ }
+
+ @Test("clearColor removes only the targeted author")
+ func clearColorScopedToAuthor() {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setColor(.red, forGame: gameID, authorID: "_A")
+ store.setColor(.blue, forGame: gameID, authorID: "_B")
+ store.clearColor(forGame: gameID, authorID: "_A")
+ #expect(store.color(forGame: gameID, authorID: "_A") == nil)
+ #expect(store.color(forGame: gameID, authorID: "_B")?.id == PlayerColor.blue.id)
+ }
+
+ @Test("clearColors for one game does not affect another game")
+ func clearColorsGameScoped() {
+ let store = makeStore()
+ let g1 = UUID()
+ let g2 = UUID()
+ store.setColor(.red, forGame: g1, authorID: "_A")
+ store.setColor(.blue, forGame: g2, authorID: "_A")
+ store.clearColors(forGame: g1)
+ #expect(store.color(forGame: g2, authorID: "_A")?.id == PlayerColor.blue.id)
+ }
+
+ // MARK: - assignedColorIDs
+
+ @Test("assignedColorIDs returns IDs of all assigned colours for a game")
+ func assignedColorIDsAll() {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setColor(.red, forGame: gameID, authorID: "_A")
+ store.setColor(.blue, forGame: gameID, authorID: "_B")
+ let ids = store.assignedColorIDs(forGame: gameID, excludingAuthorID: nil)
+ #expect(ids == ["red", "blue"])
+ }
+
+ @Test("assignedColorIDs excludes the specified authorID")
+ func assignedColorIDsExcludes() {
+ let store = makeStore()
+ let gameID = UUID()
+ store.setColor(.red, forGame: gameID, authorID: "_A")
+ store.setColor(.blue, forGame: gameID, authorID: "_B")
+ let ids = store.assignedColorIDs(forGame: gameID, excludingAuthorID: "_A")
+ #expect(ids == ["blue"])
+ #expect(!ids.contains("red"))
+ }
+}
+
+// Minimal seeded RNG for deterministic tests.
+private struct SeededRNG: RandomNumberGenerator {
+ private var state: UInt64
+
+ init(seed: UInt64) { state = seed }
+
+ mutating func next() -> UInt64 {
+ state = state &* 6364136223846793005 &+ 1442695040888963407
+ return state
+ }
+}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -42,6 +42,32 @@ struct RecordSerializerTests {
#expect(zone.ownerName == "alice_record_id")
}
+ // MARK: - Player round-trip
+
+ @Test("recordName(forPlayerInGame:authorID:) uses expected format")
+ func playerRecordNameFormat() {
+ let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
+ let name = RecordSerializer.recordName(forPlayerInGame: id, authorID: "_abc")
+ #expect(name == "player-12345678-1234-1234-1234-123456789ABC-_abc")
+ }
+
+ @Test("parsePlayerRecordName splits gameID and authorID")
+ func parsePlayerRecordRoundTrip() {
+ let gameID = UUID()
+ let authorID = "_someAuthorID"
+ let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
+ let parsed = RecordSerializer.parsePlayerRecordName(recordName)
+ #expect(parsed?.0 == gameID)
+ #expect(parsed?.1 == authorID)
+ }
+
+ @Test("parsePlayerRecordName rejects malformed names")
+ func parsePlayerRecordRejectsBadInput() {
+ #expect(RecordSerializer.parsePlayerRecordName("game-foo") == nil)
+ #expect(RecordSerializer.parsePlayerRecordName("player-not-a-uuid") == nil)
+ #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil)
+ }
+
// MARK: - System fields round-trip
@Test("Encode and decode system fields preserves record type and zone")