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:
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")