crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 16++++++++++++++++
MCrossmate/CrossmateApp.swift | 12++++++++++--
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 13+++++++++++++
ACrossmate/Models/GamePlayerColorStore.swift | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Models/PlayerRoster.swift | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 28+++++++++++++++++++++++++++-
ACrossmate/Services/NameBroadcaster.swift | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MCrossmate/Views/PuzzleView.swift | 33+++++++++++++++++++++++++++------
ATests/Unit/GamePlayerColorStoreTests.swift | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 26++++++++++++++++++++++++++
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")