commit eebe94def9bbba777d0f14be5dc0675e0c2eea33
parent e3455001e4e6c7f8735ae69f4a4a46da10e170de
Author: Michael Camilleri <[email protected]>
Date: Fri, 19 Jun 2026 07:11:23 +0900
Derive in-game collaborator colours from the Friends list
A collaborator's colour in a shared game no longer matched the colour
shown for that same friend in the Friends list — and, now that the Game
List surfaces those colours as an at-a-glance summary of who is playing,
that divergence reads as a bug. The two surfaces disagreed because the
in-game roster threaded a running 'taken' set, de-conflicting each
collaborator against the colours already assigned to the others, while
the Friends list reserved only the local user's colour and computed each
friend independently.
This commit drops the running 'taken' set from remoteParticipants, so
every collaborator's colour is now a pure function of their authorID and
the local user's preferred colour — exactly the computation the Friends
list performs. A friend therefore keeps a single colour across the
roster, the Game List, the friend roster, and across games, with no
persisted or synced mapping.
The trade-off is that two collaborators in one game are no longer
mutually de-conflicted and can land on the same colour; cross-surface
consistency is preferred over per-game distinctness.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
4 files changed, 40 insertions(+), 18 deletions(-)
diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift
@@ -62,14 +62,19 @@ enum ParticipantSummaries {
authorIDs.remove(localAuthorID)
}
- var taken: Set<String> = [localColor.id]
+ // Each collaborator's colour is computed independently, reserving only
+ // the local user's preferred colour — exactly as the Friends list does
+ // (`FriendAvatarView` passes `[preferences.color.id]`). A friend therefore
+ // shows the same colour in the roster, the Game List, and the friend
+ // roster. Two collaborators in one game can now land on the same colour;
+ // that's the accepted trade-off for cross-surface consistency.
+ let reserved: Set<String> = [localColor.id]
var summaries: [GameParticipantSummary] = []
for authorID in authorIDs.sorted() {
let name = nicknamesByAuthor[authorID]
?? namesByAuthor[authorID]
?? "Waiting for player..."
- let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: taken)
- taken.insert(color.id)
+ let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: reserved)
summaries.append(GameParticipantSummary(
authorID: authorID,
name: name,
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -326,14 +326,12 @@ final class PlayerRoster {
}
}
- // 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).
+ // Assign each friend a deterministic colour. Each participant's colour
+ // is a pure function of (their authorID, the local user's preferred
+ // colour) — see `ParticipantSummaries.remoteParticipants`. That matches
+ // the Friends list exactly, so a friend keeps one colour across every
+ // surface (roster, Game List, friend roster) and across games, with no
+ // persisted or synced mapping.
let remoteEntries = ParticipantSummaries.remoteParticipants(
namesByAuthor: fetched.namesMap,
moveAuthorIDs: fetched.moveAuthorIDs,
diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift
@@ -165,7 +165,17 @@ struct GameSummaryThumbnailTests {
#expect(summary.allParticipants.map(\.isLocal) == [true, false, false])
let remoteParticipants = summary.allParticipants.filter { !$0.isLocal }
#expect(!remoteParticipants.map(\.color.id).contains(PlayerColor.blue.id))
- #expect(Set(remoteParticipants.map(\.color.id)).count == 2)
+ // Each collaborator's colour is computed independently, reserving only
+ // the local user's colour — identical to the Friends list, so a friend
+ // shows one colour across every surface. (Collaborators are no longer
+ // mutually de-conflicted, so two can share a colour.)
+ for participant in remoteParticipants {
+ let expected = PlayerColor.stableColor(
+ forAuthorID: participant.authorID,
+ reserved: [PlayerColor.blue.id]
+ )
+ #expect(participant.color.id == expected.id)
+ }
}
@Test("Shared strip includes local player and orders by score")
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -106,8 +106,8 @@ struct PlayerRosterTests {
// MARK: - Tests
- @Test("Three remote participants are assigned distinct colors, none matching local")
- func threeParticipantsGetDistinctColors() async throws {
+ @Test("Remote colours match the Friends-list computation, none matching local")
+ func threeParticipantsMatchFriendListColors() async throws {
let (persistence, gameID) = try makePersistenceWithGame()
addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence)
@@ -122,10 +122,19 @@ struct PlayerRosterTests {
await roster.refresh()
#expect(roster.entries.count == 4) // 1 local + 3 remote
- let allColorIDs = roster.entries.map { $0.color.id }
- #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colors")
- let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id }
- #expect(!remoteColorIDs.contains(prefs.color.id), "remote colors should not collide with local")
+ // Each collaborator's colour is computed independently, reserving only
+ // the local colour — identical to the Friends list (which passes
+ // `[preferences.color.id]`). Collaborators are no longer mutually
+ // de-conflicted, so two can share a colour; the contract is only that
+ // each matches its cross-surface colour and none collide with local.
+ for entry in roster.entries where !entry.isLocal {
+ let expected = PlayerColor.stableColor(
+ forAuthorID: entry.authorID,
+ reserved: [prefs.color.id]
+ )
+ #expect(entry.color.id == expected.id)
+ #expect(entry.color.id != prefs.color.id, "remote colors should not collide with local")
+ }
}
@Test("Friend colour is identical across two different games (stable across games)")