crossmate

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

commit 2d1afc0f405b59e6c6f98c26d8bb778a60371e71
parent eebe94def9bbba777d0f14be5dc0675e0c2eea33
Author: Michael Camilleri <[email protected]>
Date:   Fri, 19 Jun 2026 15:58:39 +0900

Derive collaborator colours per game

Collaborator colours were derived from the authorID alone, so a friend
read as the same colour in every game and across the friends list. Now
that colour carries more of the interface — the Game List colour strip
and the puzzle scoreboard — that stability worked against it: a colour
should read as 'who is in this grid', not a permanent badge for a
person.

This commit folds the game's UUID into the seed, so a collaborator's
colour depends on their authorID and the game (stableColor becomes
assignedColor(forAuthorID:inGame:reserved:)). The colours are still
threaded through a running reserved set, seeded with the local user's
own stable colour, so collaborators stay as distinct as the palette
allows within a game while deliberately differing between games. The
gameID is plumbed through ParticipantSummaries and 'GameStore' to the
roster.

Because colour is no longer a per-person identity, the friends list
drops it: a crossmate's avatar is now a neutral grey medallion carrying
their still-stable symbol.

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

Diffstat:
MCrossmate/Models/ParticipantSummaries.swift | 25+++++++++++++++++--------
MCrossmate/Models/PlayerColor.swift | 41++++++++++++++++++++++++-----------------
MCrossmate/Models/PlayerRoster.swift | 12++++++------
MCrossmate/Persistence/GameStore.swift | 3+++
MCrossmate/Views/Components/FriendAvatarView.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCrossmate/Views/Friends/FriendPickerView.swift | 2--
MCrossmate/Views/Friends/FriendsView.swift | 6+-----
MCrossmate/Views/GameList/GameShareItem.swift | 2--
MTests/Unit/GameSummaryThumbnailTests.swift | 14+++-----------
MTests/Unit/PlayerRosterTests.swift | 46+++++++++++++++++++++++++---------------------
10 files changed, 149 insertions(+), 80 deletions(-)

diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift @@ -14,6 +14,7 @@ struct GameParticipantSummary: Identifiable, Equatable { enum ParticipantSummaries { static func allParticipants( + gameID: UUID, namesByAuthor: [String: String], moveAuthorIDs: [String], nicknamesByAuthor: [String: String], @@ -24,6 +25,7 @@ enum ParticipantSummaries { additionalAuthorIDs: [String] = [] ) -> [GameParticipantSummary] { let remotes = remoteParticipants( + gameID: gameID, namesByAuthor: namesByAuthor, moveAuthorIDs: moveAuthorIDs, nicknamesByAuthor: nicknamesByAuthor, @@ -45,6 +47,7 @@ enum ParticipantSummaries { } static func remoteParticipants( + gameID: UUID, namesByAuthor: [String: String], moveAuthorIDs: [String], nicknamesByAuthor: [String: String], @@ -62,19 +65,25 @@ enum ParticipantSummaries { authorIDs.remove(localAuthorID) } - // 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] + // Hand each collaborator a colour that is as distinct as the palette + // allows: walk them in sorted-authorID order, seed `taken` with the + // local user's (stable) colour, and thread each assignment back in so + // the next collaborator avoids it. The colour is derived from authorID + // *and* `gameID`, so it is distinct within the game but deliberately + // differs from game to game — a colour reads as "who is in this grid", + // never a permanent badge for a person. + var taken: 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: reserved) + let color = PlayerColor.assignedColor( + forAuthorID: authorID, + inGame: gameID, + reserved: taken + ) + taken.insert(color.id) summaries.append(GameParticipantSummary( authorID: authorID, name: name, diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -11,7 +11,7 @@ struct PlayerColor: Sendable, Identifiable, Hashable { /// The colour's *intended* position on the hue wheel, in degrees `0..<360`. /// Hand-assigned rather than derived from `tint` so it stays a pure value /// (resolving a SwiftUI `Color` to RGB needs a trait environment). Used - /// only by `stableColor` to keep a participant clear of colours that are + /// only by `assignedColor` to keep a participant clear of colours that are /// perceptually similar to one already taken — not just exact matches. let hue: Double @@ -68,7 +68,7 @@ extension PlayerColor { ] /// Two colours whose hues are within this many degrees of each other are - /// treated as perceptually similar by `stableColor`, so a participant is + /// treated as perceptually similar by `assignedColor`, so a participant is /// kept clear of them — not only exact matches. Sized so a reserved blue /// also rules out green (≈85° away), per the desired behaviour. static let similarHueThreshold: Double = 90 @@ -95,24 +95,31 @@ extension PlayerColor { return min(d, 360 - d) } - /// Deterministic highlight colour for a participant: hash `authorID` into - /// the palette, then linear-probe forward for a colour that is not just - /// absent from `reserved` but also more than `similarHueThreshold` degrees - /// away from every reserved colour's hue — so a participant never lands on - /// a colour that looks like one already taken (a reserved blue also rules - /// out green, etc.). 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 now collides or clashes with it. + /// Highlight colour assigned to a collaborator *within one game*: hash + /// `authorID` together with `gameID` into the palette, then linear-probe + /// forward for a colour that is not just absent from `reserved` but also + /// more than `similarHueThreshold` degrees away from every reserved + /// colour's hue — so a collaborator never lands on a colour that looks like + /// one already taken (a reserved blue also rules out green, etc.). + /// + /// Folding `gameID` into the seed makes the colour deliberately *unstable* + /// across games: the same friend reads as a different colour in each game, + /// so a colour is felt as "whoever is in this grid" rather than a fixed + /// badge for a person. Callers thread the colours already handed out in the + /// game through `reserved`, so collaborators within a single game stay as + /// distinct as the palette allows. The local user is never assigned this + /// way — their colour is their stable preference, passed in via `reserved`. /// /// Degrades in two steps when the palette can't satisfy the spacing: first - /// relax to avoiding only the exact reserved colours (the old behaviour), - /// then, if every colour is reserved, reuse the hashed base colour. Both - /// fallbacks stay pure, so the guarantee above still holds. - static func stableColor(forAuthorID authorID: String, reserved: Set<String>) -> PlayerColor { + /// relax to avoiding only the exact reserved colours, then, if every colour + /// is reserved, reuse the hashed base colour. + static func assignedColor( + forAuthorID authorID: String, + inGame gameID: UUID, + reserved: Set<String> + ) -> PlayerColor { let n = palette.count - let base = stableIndex(for: authorID, count: n) + let base = stableIndex(for: "\(gameID.uuidString)-\(authorID)", count: n) let reservedHues = palette.filter { reserved.contains($0.id) }.map(\.hue) // Pass 1: clear of every reserved hue (distance 0 covers exact matches). diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -326,13 +326,13 @@ final class PlayerRoster { } } - // 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. + // Assign each collaborator a colour for this game. Colours are derived + // from (authorID, gameID) and de-conflicted against each other and the + // local user's stable colour, so they are distinct within the game but + // deliberately vary from game to game — see + // `ParticipantSummaries.remoteParticipants`. let remoteEntries = ParticipantSummaries.remoteParticipants( + gameID: gameID, namesByAuthor: fetched.namesMap, moveAuthorIDs: fetched.moveAuthorIDs, nicknamesByAuthor: fetched.nicknamesByAuthor, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -145,6 +145,7 @@ struct GameSummary: Identifiable, Equatable { self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 self.isAccessRevoked = entity.isAccessRevoked self.allParticipants = Self.computeParticipants( + gameID: id, entity: entity, localAuthorID: localAuthorID, localName: localName, @@ -175,6 +176,7 @@ struct GameSummary: Identifiable, Equatable { } private static func computeParticipants( + gameID: UUID, entity: GameEntity, localAuthorID: String?, localName: String, @@ -204,6 +206,7 @@ struct GameSummary: Identifiable, Equatable { let nicknames = friendNicknames(in: entity.managedObjectContext) return ParticipantSummaries.allParticipants( + gameID: gameID, namesByAuthor: namesByAuthor, moveAuthorIDs: moveAuthorIDs, nicknamesByAuthor: nicknames, diff --git a/Crossmate/Views/Components/FriendAvatarView.swift b/Crossmate/Views/Components/FriendAvatarView.swift @@ -14,7 +14,6 @@ struct FriendAvatarView: View { let authorID: String var size: CGFloat = 34 - var reservedColorIDs: Set<String> = [] var invitePhase: InvitePhase? = nil /// Spin shared by the avatar → paper plane and paper plane → checkmark @@ -26,7 +25,7 @@ struct FriendAvatarView: View { @State private var ringsTask: Task<Void, Never>? private var avatar: FriendAvatar { - FriendAvatar.avatar(for: authorID, reservedColorIDs: reservedColorIDs) + FriendAvatar.avatar(for: authorID) } /// The glyph shown in the avatar. While sending it is a paper plane; on @@ -59,6 +58,22 @@ struct FriendAvatarView: View { Circle() .fill(avatar.background) + // A soft radial wash from a lighter centre out to the base + // grey, so the medallion reads as gently domed rather than + // flat. Disabled for now — swap the flat `.fill` above for this + // to turn it back on. + // .fill( + // RadialGradient( + // colors: [Color(.systemGray3), avatar.background], + // center: .center, + // startRadius: 0, + // endRadius: size / 2 + // ) + // ) + + // A faint, tiled watermark of the friend's own symbol — just enough + // texture to lift the flat medallion, sitting under the main glyph. + SymbolPattern(symbolName: avatar.symbolName, size: size) Image(systemName: symbolName) .font(.system(size: size * 0.46, weight: .semibold)) @@ -128,17 +143,64 @@ private struct BroadcastRing: View { } } +/// A faint, tiled wallpaper of an SF Symbol, clipped to the avatar circle, used +/// as a subtle background texture behind the main glyph. Tiles are staggered +/// row-to-row for a diaper pattern and drawn at low opacity in a single +/// `Canvas`. Everything scales off `size`, so it reads the same on a list-row +/// avatar and a larger share-grid tile. +private struct SymbolPattern: View { + let symbolName: String + let size: CGFloat + var tint: Color = .white + var opacity: Double = 0.16 + + /// Tile glyph size as a fraction of the avatar size. + private static let tileRatio: CGFloat = 0.26 + /// Centre-to-centre tile spacing as a fraction of the avatar size. + private static let spacingRatio: CGFloat = 0.30 + + var body: some View { + Canvas { context, canvasSize in + guard let glyph = context.resolveSymbol(id: 0) else { return } + context.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: canvasSize))) + context.opacity = opacity + + let spacing = size * Self.spacingRatio + guard spacing > 0 else { return } + var row = 0 + var y = spacing / 2 + while y < canvasSize.height + spacing { + // Offset alternate rows by half a step for the staggered look. + let stagger = row.isMultiple(of: 2) ? 0 : spacing / 2 + var x = spacing / 2 + stagger - spacing + while x < canvasSize.width + spacing { + context.draw(glyph, at: CGPoint(x: x, y: y)) + x += spacing + } + y += spacing + row += 1 + } + } symbols: { + Image(systemName: symbolName) + .font(.system(size: size * Self.tileRatio, weight: .semibold)) + .foregroundStyle(tint) + .tag(0) + } + .frame(width: size, height: size) + } +} + private struct FriendAvatar { let symbolName: String let background: Color - static func avatar(for authorID: String, reservedColorIDs: Set<String>) -> FriendAvatar { - // 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. + static func avatar(for authorID: String) -> FriendAvatar { + // Friend avatars are intentionally colourless. A player's colour now + // lives only inside a game and varies per game, so outside one a friend + // is identified by their symbol alone on a neutral grey — never a fixed + // colour badge. The symbol is a stable, deterministic choice per author. let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)] - let color = PlayerColor.stableColor(forAuthorID: authorID, reserved: reservedColorIDs) - return FriendAvatar(symbolName: symbol, background: color.tint) + return FriendAvatar(symbolName: symbol, background: Color(.darkGray)) } private static let symbols: [String] = [ diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -7,7 +7,6 @@ import SwiftUI struct FriendPickerView: View { let gameID: UUID - @Environment(PlayerPreferences.self) private var preferences @Environment(\.inviteFriend) private var inviteFriend @Environment(\.dismiss) private var dismiss @@ -72,7 +71,6 @@ struct FriendPickerView: View { HStack { FriendAvatarView( authorID: authorID, - reservedColorIDs: [preferences.color.id], invitePhase: invitePhase(authorID: authorID, invited: invited) ) .padding(.trailing, 8) diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift @@ -6,7 +6,6 @@ import SwiftUI /// nickname via `\.renameFriend`) and blocking, which tears down the pairwise /// channel via `\.blockFriend`. struct FriendsView: View { - @Environment(PlayerPreferences.self) private var preferences @Environment(\.blockFriend) private var blockFriend @Environment(\.renameFriend) private var renameFriend @Environment(\.dismiss) private var dismiss @@ -84,10 +83,7 @@ struct FriendsView: View { @ViewBuilder private func friendRow(for friend: FriendEntity) -> some View { HStack { - FriendAvatarView( - authorID: friend.authorID ?? "", - reservedColorIDs: [preferences.color.id] - ) + FriendAvatarView(authorID: friend.authorID ?? "") .padding(.trailing, 8) Text(friend.resolvedDisplayName) Spacer() diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -6,7 +6,6 @@ struct GameShareSheet: View { let title: String let shareController: ShareController - @Environment(PlayerPreferences.self) private var preferences @Environment(\.inviteFriend) private var inviteFriend @Environment(\.dismiss) private var dismiss @FetchRequest( @@ -204,7 +203,6 @@ struct GameShareSheet: View { FriendAvatarView( authorID: authorID, size: 40, - reservedColorIDs: [preferences.color.id], invitePhase: invitePhase(authorID: authorID, invited: wasInvited) ) Text(friend.resolvedDisplayName) diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift @@ -164,18 +164,10 @@ struct GameSummaryThumbnailTests { #expect(summary.allParticipants.map(\.name) == ["Local Player", "Ace", "Waiting for player..."]) #expect(summary.allParticipants.map(\.isLocal) == [true, false, false]) let remoteParticipants = summary.allParticipants.filter { !$0.isLocal } + // Collaborators are de-conflicted within the game, so the two remotes + // get distinct colours and neither collides with the local 'blue'. #expect(!remoteParticipants.map(\.color.id).contains(PlayerColor.blue.id)) - // 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) - } + #expect(Set(remoteParticipants.map(\.color.id)).count == 2) } @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("Remote colours match the Friends-list computation, none matching local") - func threeParticipantsMatchFriendListColors() async throws { + @Test("Three remote participants get distinct colours, none matching local") + func threeParticipantsGetDistinctColors() async throws { let (persistence, gameID) = try makePersistenceWithGame() addMoves(authorIDs: ["_B", "_C", "_D"], gameID: gameID, persistence: persistence) @@ -122,23 +122,14 @@ struct PlayerRosterTests { await roster.refresh() #expect(roster.entries.count == 4) // 1 local + 3 remote - // 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") - } + let allColorIDs = roster.entries.map { $0.color.id } + #expect(Set(allColorIDs).count == 4, "all four participants should have distinct colours") + let remoteColorIDs = roster.entries.filter { !$0.isLocal }.map { $0.color.id } + #expect(!remoteColorIDs.contains(prefs.color.id), "remote colours should not collide with local") } - @Test("Friend colour is identical across two different games (stable across games)") - func friendColourStableAcrossGames() async throws { + @Test("Friend colour is derived per game (deliberately not stable across games)") + func friendColourIsPerGame() async throws { let (p1, gameA) = try makePersistenceWithGame() addMoves(authorIDs: ["_B"], gameID: gameA, persistence: p1) let rosterA = makeRoster(gameID: gameA, persistence: p1) @@ -149,10 +140,23 @@ struct PlayerRosterTests { 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") + // Each game derives the friend's colour from (authorID, gameID), + // de-conflicted against the local user's stable colour. The colour is a + // function of the game, so it is allowed to differ between games rather + // than acting as a fixed per-person badge. + func remoteColour(in roster: PlayerRoster, gameID: UUID) -> String? { + let localColorID = roster.entries.first { $0.isLocal }?.color.id ?? PlayerColor.blue.id + let expected = PlayerColor.assignedColor( + forAuthorID: "_B", + inGame: gameID, + reserved: [localColorID] + ) + #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected.id) + return roster.entries.first { $0.authorID == "_B" }?.color.id + } + + #expect(remoteColour(in: rosterA, gameID: gameA) != nil) + #expect(remoteColour(in: rosterB, gameID: gameB) != nil) } @Test("Entry name comes from PlayerEntity when available")