crossmate

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

commit 512058110b95d62b34293c8129efce2d9ab06571
parent ccfb9287beb5bb0c1b6bd3b0757efac786389305
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 22:15:20 +0900

Make friend colours stable across games

Prior to this commit, GamePlayerColorStore assigned each collaborator a random
palette colour the first time they appeared in a game and persisted that choice
in device-local UserDefaults, keyed by (gameID, authorID). The mapping was
never synced, so the same friend was a different colour in every game and a
different colour on each of the user's own devices, and PlayerRoster had to
garbage-collect stale entries and run an explicit reassignment pass whenever
the local user changed their own colour.

Friend colour is now a pure function of authorID and the local user's preferred
colour, with no persistence and nothing synced. PlayerColor gains
stableColor(forAuthorID:reserved:): it hashes authorID into the palette (the
FNV-1a stableIndex lifted out of FriendAvatarView so both call one helper) and
linear-probes forward past any reserved colour. PlayerRoster walks participants
in sorted-authorID order threading a `taken` set seeded with
preferences.color.id, so the result is a pure function of (participant set,
preferred colour) — both identical across the user's devices, so every device
derives the same colour for a friend, and the friend keeps it across games.
Only a lower-sorted collaborator colliding with them in a given game can bump
it, which keeps cursors distinct within a game. Because the preferred colour is
the reserved seed and is already synced via PlayerPreferences, a local colour
change bumps any colliding friend identically on every device; PuzzleView just
refreshes the roster to re-derive instead of persisting a reassignment.

GamePlayerColorStore and its test, the gamePlayerColorsChanged notification and
observer, reassignOnLocalColorChange, the stale-entry GC, the onGameDeleted
colour-cleanup branch, and all colorStore wiring through AppServices and
PlayerRoster are removed. There is no migration: friends' avatar and grid
colours shift once on this build, by design. If two of the user's devices
momentarily see different participant sets they derive different colours until
the next refresh — within the project's eventual-consistency tolerance.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8--------
DCrossmate/Models/GamePlayerColorStore.swift | 138-------------------------------------------------------------------------------
MCrossmate/Models/PlayerColor.swift | 29+++++++++++++++++++++++++++++
MCrossmate/Models/PlayerRoster.swift | 60+++++++++++-------------------------------------------------
MCrossmate/Services/AppServices.swift | 10++--------
MCrossmate/Views/FriendAvatarView.swift | 17+++++++----------
MCrossmate/Views/PuzzleView.swift | 5++++-
DTests/Unit/GamePlayerColorStoreTests.swift | 219-------------------------------------------------------------------------------
MTests/Unit/PlayerRosterTests.swift | 41++++++++++++++++-------------------------
9 files changed, 69 insertions(+), 458 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; }; 0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; }; 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */; }; - 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; }; @@ -93,7 +92,6 @@ D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.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 */; }; DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; @@ -153,7 +151,6 @@ 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; }; 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.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>"; }; 6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; }; 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; }; @@ -213,7 +210,6 @@ 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>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; EE3412F437AABD2988B6976D /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; }; EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; }; @@ -267,7 +263,6 @@ children = ( 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, - EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */, D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */, 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, @@ -297,7 +292,6 @@ B135C285570F91181595B405 /* CellMark.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, 8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */, - 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */, DB55FC337CF72C650373210A /* PlayerColor.swift */, 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */, 3292748EAE27B608C769D393 /* PlayerRoster.swift */, @@ -535,7 +529,6 @@ 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */, 85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */, 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, - 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */, 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */, AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */, @@ -588,7 +581,6 @@ 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */, 818B1F2693962832BE14578E /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, - DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */, 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */, diff --git a/Crossmate/Models/GamePlayerColorStore.swift b/Crossmate/Models/GamePlayerColorStore.swift @@ -1,138 +0,0 @@ -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 - } - writeColor(chosen, forGame: gameID, authorID: authorID, notify: false) - 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) { - writeColor(color, forGame: gameID, authorID: authorID, notify: true) - } - - func clearColor(forGame gameID: UUID, authorID: String, notify: Bool = true) { - var s = rawStore - guard s[gameID.uuidString]?[authorID] != nil else { return } - s[gameID.uuidString]?.removeValue(forKey: authorID) - if s[gameID.uuidString]?.isEmpty == true { - s.removeValue(forKey: gameID.uuidString) - } - rawStore = s - if notify { - postChange(gameID: gameID) - } - } - - private func writeColor(_ color: PlayerColor, forGame gameID: UUID, authorID: String, notify: Bool) { - if self.color(forGame: gameID, authorID: authorID)?.id == color.id { - return - } - var s = rawStore - var gameMap = s[gameID.uuidString] ?? [:] - gameMap[authorID] = color.id - s[gameID.uuidString] = gameMap - rawStore = s - if notify { - postChange(gameID: gameID) - } - } - - func clearColors(forGame gameID: UUID) { - var s = rawStore - guard s[gameID.uuidString] != nil else { return } - 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/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -42,5 +42,34 @@ extension PlayerColor { static func color(for id: String) -> PlayerColor { palette.first { $0.id == id } ?? .blue } + + /// FNV-1a hash of `value` reduced into `0..<count`. A pure function, so + /// every process and device derives the same index for the same input. + /// Shared by avatar selection and stable colour assignment. + static func stableIndex(for value: String, count: Int) -> Int { + guard count > 0 else { return 0 } + let hash = value.utf8.reduce(UInt32(2_166_136_261)) { partial, byte in + (partial ^ UInt32(byte)) &* 16_777_619 + } + return Int(hash % UInt32(count)) + } + + /// Deterministic highlight colour for a participant: hash `authorID` into + /// the palette, then linear-probe forward past any colour already in + /// `reserved`. Being a pure function of its inputs, every device and every + /// game derives the same colour for a friend with no persistence or sync — + /// the friend stays one colour across games, and changing the local user's + /// preferred colour (passed in `reserved`) deterministically bumps any + /// friend that collided with it. Falls back to the base colour only when + /// the palette is exhausted (8+ participants). + static func stableColor(forAuthorID authorID: String, reserved: Set<String>) -> PlayerColor { + let n = palette.count + let base = stableIndex(for: authorID, count: n) + for step in 0..<n { + let candidate = palette[(base + step) % n] + if !reserved.contains(candidate.id) { return candidate } + } + return palette[base] + } } diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -49,7 +49,6 @@ final class PlayerRoster { private(set) var localAuthorID: String? private let gameID: UUID - private let colorStore: GamePlayerColorStore private let authorIdentity: AuthorIdentity private let preferences: PlayerPreferences private let persistence: PersistenceController @@ -62,7 +61,6 @@ final class PlayerRoster { init( gameID: UUID, - colorStore: GamePlayerColorStore, authorIdentity: AuthorIdentity, preferences: PlayerPreferences, persistence: PersistenceController, @@ -70,7 +68,6 @@ final class PlayerRoster { tracer: (@MainActor @Sendable (String) -> Void)? = nil ) { self.gameID = gameID - self.colorStore = colorStore self.authorIdentity = authorIdentity self.preferences = preferences self.persistence = persistence @@ -90,21 +87,6 @@ final class PlayerRoster { 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 @@ -245,28 +227,26 @@ final class PlayerRoster { otherAuthorIDs.insert(authorID) } - // Build remote entries, assigning stable colours. + // Assign each friend a deterministic colour. Walking the participants + // in sorted-authorID order and threading a running `taken` set — + // seeded with the local user's preferred colour — makes the result a + // pure function of (participant set, preferred colour). Both are + // identical across the user's devices, so every device derives the + // same colour for a friend without any persisted or synced mapping; + // the friend also keeps that colour across games (only a lower-sorted + // collaborator colliding with them in a given game can bump it). var remoteEntries: [Entry] = [] - let reservedColorIDs: Set<String> = [preferences.color.id] + var taken: Set<String> = [preferences.color.id] for authorID in otherAuthorIDs.sorted() { let name = resolveName(authorID: authorID, namesMap: namesMap) - let color = colorStore.ensureColor( - forGame: gameID, - authorID: authorID, - reservedColorIDs: reservedColorIDs - ) + let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: taken) + taken.insert(color.id) 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, notify: false) - } - let localEntry = Entry( authorID: localAuthorID, name: preferences.name, @@ -299,24 +279,6 @@ final class PlayerRoster { remoteSelections = fresh } - // 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 /// Diagnostic — surfaces the inputs that drive the entries list so we diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -36,7 +36,6 @@ final class AppServices { let identity: AuthorIdentity let shareController: ShareController let friendController: FriendController - let colorStore: GamePlayerColorStore let cursorStore: GameCursorStore let cloudService: CloudService let importService: ImportService @@ -128,13 +127,10 @@ final class AppServices { ) self.movesUpdater = movesUpdater - let colorStore = GamePlayerColorStore() - self.colorStore = colorStore let cursorStore = GameCursorStore() self.cursorStore = cursorStore let onGameDeletedHandler = Self.makeOnGameDeleted( syncEngine: syncEngine, - colorStore: colorStore, cursorStore: cursorStore ) @@ -618,7 +614,6 @@ final class AppServices { func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster { PlayerRoster( gameID: gameID, - colorStore: colorStore, authorIdentity: identity, preferences: preferences, persistence: persistence, @@ -1428,14 +1423,13 @@ final class AppServices { /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can /// drive the exact same closure that production wires up — keeps the - /// colour-cleanup branch from drifting silently. + /// cursor-cleanup branch from drifting silently. (Friend colours need no + /// cleanup: they are derived on the fly, never persisted per game.) static func makeOnGameDeleted( syncEngine: SyncEngine, - colorStore: GamePlayerColorStore, cursorStore: GameCursorStore? = nil ) -> (GameCloudDeletion) -> Void { { deletion in - colorStore.clearColors(forGame: deletion.gameID) cursorStore?.clearCursor(forGame: deletion.gameID) Task { await syncEngine.enqueueDeleteGame(deletion) } } diff --git a/Crossmate/Views/FriendAvatarView.swift b/Crossmate/Views/FriendAvatarView.swift @@ -27,8 +27,13 @@ private struct FriendAvatar { let background: Color static func avatar(for authorID: String) -> FriendAvatar { - let symbol = symbols[stableIndex(for: "symbol-\(authorID)", count: symbols.count)] - let color = PlayerColor.palette[stableIndex(for: "color-\(authorID)", count: PlayerColor.palette.count)] + // The symbol is seeded with a distinct string from the colour (which + // `stableColor` hashes off the raw authorID) so a friend's symbol and + // colour vary independently rather than always pairing up. The avatar + // is global to the friend list, so no colours are reserved here — this + // is the friend's base colour, which a busy game may probe off. + let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)] + let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: []) return FriendAvatar(symbolName: symbol, background: color.tint) } @@ -42,12 +47,4 @@ private struct FriendAvatar { "leaf.fill", "flame.fill" ] - - private static func stableIndex(for value: String, count: Int) -> Int { - guard count > 0 else { return 0 } - let hash = value.utf8.reduce(UInt32(2_166_136_261)) { partial, byte in - (partial ^ UInt32(byte)) &* 16_777_619 - } - return Int(hash % UInt32(count)) - } } diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -780,7 +780,10 @@ private struct PuzzleToolbarModifier: ViewModifier { ForEach(PlayerColor.palette) { color in Button { preferences.color = color - Task { await roster.reassignOnLocalColorChange(newColor: color) } + // Friend colours are derived with the local user's + // colour reserved, so refreshing re-derives and bumps + // any friend that now collides with the new choice. + Task { await roster.refresh() } } label: { Label { Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) diff --git a/Tests/Unit/GamePlayerColorStoreTests.swift b/Tests/Unit/GamePlayerColorStoreTests.swift @@ -1,219 +0,0 @@ -import CloudKit -import CoreData -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)) - } - - @Test("ensureColor persists without posting a change notification") - func ensureColorDoesNotPostChangeNotification() { - let store = makeStore() - let gameID = UUID() - var notificationCount = 0 - let observer = NotificationCenter.default.addObserver( - forName: .gamePlayerColorsChanged, - object: nil, - queue: nil - ) { note in - guard let id = note.userInfo?["gameID"] as? UUID, id == gameID else { return } - notificationCount += 1 - } - defer { NotificationCenter.default.removeObserver(observer) } - - _ = store.ensureColor(forGame: gameID, authorID: "_A", reservedColorIDs: []) - - #expect(notificationCount == 0) - } - - // 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")) - } - - // MARK: - Color cleanup on game deletion - - @Test("Color store is cleared when a game is deleted via onGameDeleted") - func colorStoreCleanupOnGameDelete() throws { - let persistence = makeTestPersistence() - let ctx = persistence.viewContext - let gameID = UUID() - let entity = GameEntity(context: ctx) - entity.id = gameID - entity.title = "Test" - entity.puzzleSource = "" - entity.createdAt = Date() - entity.updatedAt = Date() - entity.ckRecordName = "game-\(gameID.uuidString)" - try ctx.save() - - let colorStore = makeStore() - colorStore.setColor(.red, forGame: gameID, authorID: "_A") - colorStore.setColor(.blue, forGame: gameID, authorID: "_B") - - // Use the production factory so this test fails if the real wiring - // ever drops the colour-cleanup branch. - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") - let syncEngine = SyncEngine(container: container, persistence: persistence) - let store = makeTestStore( - persistence: persistence, - onGameDeleted: AppServices.makeOnGameDeleted( - syncEngine: syncEngine, - colorStore: colorStore - ) - ) - - try store.deleteGame(id: gameID) - - #expect(colorStore.storedAuthorIDs(forGame: gameID).isEmpty) - } -} - -// 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/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -78,19 +78,14 @@ struct PlayerRosterTests { private func makeRoster( gameID: UUID, persistence: PersistenceController, - colorStore: GamePlayerColorStore? = nil, preferences: PlayerPreferences? = nil ) -> PlayerRoster { - let store = colorStore ?? GamePlayerColorStore( - defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! - ) let prefs = preferences ?? PlayerPreferences( local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! ) let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") return PlayerRoster( gameID: gameID, - colorStore: store, authorIdentity: AuthorIdentity(testing: "_Local"), preferences: prefs, persistence: persistence, @@ -105,16 +100,12 @@ struct PlayerRosterTests { let (persistence, gameID) = try makePersistenceWithGame() addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence) - let colorStore = GamePlayerColorStore( - defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! - ) let prefsDefaults = UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! let prefs = PlayerPreferences(local: prefsDefaults) let roster = makeRoster( gameID: gameID, persistence: persistence, - colorStore: colorStore, preferences: prefs ) await roster.refresh() @@ -126,22 +117,22 @@ struct PlayerRosterTests { #expect(!remoteColorIDs.contains(prefs.color.id), "remote colors should not collide with local") } - @Test("Stale color entries are GC'd after refresh") - func staleColorEntriesAreGCd() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - addMoves(authorIDs: ["_B"], gameID: gameID, persistence: persistence) - - let colorStore = GamePlayerColorStore( - defaults: UserDefaults(suiteName: "test-cs-\(UUID().uuidString)")! - ) - colorStore.setColor(.red, forGame: gameID, authorID: "_B") - colorStore.setColor(.green, forGame: gameID, authorID: "_Stale") - - let roster = makeRoster(gameID: gameID, persistence: persistence, colorStore: colorStore) - await roster.refresh() - - #expect(colorStore.color(forGame: gameID, authorID: "_B") != nil, "_B should be kept") - #expect(colorStore.color(forGame: gameID, authorID: "_Stale") == nil, "_Stale should be GC'd") + @Test("Friend colour is identical across two different games (stable across games)") + func friendColourStableAcrossGames() async throws { + let (p1, gameA) = try makePersistenceWithGame() + addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1) + let rosterA = makeRoster(gameID: gameA, persistence: p1) + await rosterA.refresh() + + let (p2, gameB) = try makePersistenceWithGame() + addMoves(authorIDs: ["_B"], gameID: gameB, persistence: p2) + let rosterB = makeRoster(gameID: gameB, persistence: p2) + await rosterB.refresh() + + let colourInA = rosterA.entries.first { $0.authorID == "_B" }?.color.id + let colourInB = rosterB.entries.first { $0.authorID == "_B" }?.color.id + #expect(colourInA != nil) + #expect(colourInA == colourInB, "the same friend should be the same colour in every game") } @Test("Entry name comes from PlayerEntity when available")