crossmate

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

commit 2fa8caf1eb89b304cadec1cd69bca4ea96a03388
parent 6c3d17d1ac04cf27f55751cac132404800502d12
Author: Michael Camilleri <[email protected]>
Date:   Sat, 20 Jun 2026 14:55:30 +0900

Spread collaborator colours evenly across the eligible palette

A collaborator's highlight colour was landing on pink far more often
than any other colour — for a blue local user, roughly two times in
three. The bias was not in the spacing rule that keeps a collaborator
clear of colours perceptually similar to one already taken, but in how a
survivor was chosen from it: assignedColor hashed (gameID, authorID) to
a base index and then linear-probed forward through the full palette for
the first acceptable colour. Against a reserved blue the spacing rule
rejects green through purple as one contiguous block, so every base
index that fell in that block walked forward onto the next eligible
colour — pink — collapsing most starting points onto a single result.

This commit filters the palette to the eligible colours first and then
hashes the same seed into that survivor set, so the choice is uniform
across the colours that are actually allowed rather than skewed toward
whichever one sits just past the largest rejected block. The assignment
stays deterministic and per-game, and the de-confliction that threads
each collaborator's colour into the reserved set for the next is
unchanged. Because the index space now depends on how many colours
survive, a collaborator's colour in an existing game may shift once —
consistent with colours being deliberately unstable across games rather
than a per-person badge.

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

Diffstat:
MCrossmate/Models/PlayerColor.swift | 47++++++++++++++++++++++++++++++-----------------
1 file changed, 30 insertions(+), 17 deletions(-)

diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -95,12 +95,21 @@ extension PlayerColor { return min(d, 360 - d) } - /// 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 + /// Highlight colour assigned to a collaborator *within one game*: filter the + /// palette down to the colours that are 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.). + /// one already taken (a reserved blue also rules out green, etc.) — then + /// hash `authorID` together with `gameID` to pick one of the survivors. + /// + /// Picking by hashing *into the eligible set* (rather than hashing to a base + /// index and linear-probing forward through the full palette) keeps the + /// choice uniform across the colours that are actually allowed. Probing + /// forward biases heavily toward whichever eligible colour sits just past + /// the largest contiguous block of rejected ones: e.g. against a reserved + /// blue the spacing rule rejects green…purple as a block, so a forward probe + /// landed on pink ~67% of the time. Hashing into the survivors spreads it + /// evenly instead. /// /// Folding `gameID` into the seed makes the colour deliberately *unstable* /// across games: the same friend reads as a different colour in each game, @@ -118,23 +127,27 @@ extension PlayerColor { inGame gameID: UUID, reserved: Set<String> ) -> PlayerColor { - let n = palette.count - let base = stableIndex(for: "\(gameID.uuidString)-\(authorID)", count: n) + let seed = "\(gameID.uuidString)-\(authorID)" let reservedHues = palette.filter { reserved.contains($0.id) }.map(\.hue) // Pass 1: clear of every reserved hue (distance 0 covers exact matches). - for step in 0..<n { - let candidate = palette[(base + step) % n] - if reservedHues.allSatisfy({ hueDistance($0, candidate.hue) > similarHueThreshold }) { - return candidate - } + let spaced = palette.filter { candidate in + reservedHues.allSatisfy { hueDistance($0, candidate.hue) > similarHueThreshold } } + if let pick = pick(from: spaced, seed: seed) { return pick } + // Pass 2: palette too tight for hue spacing — avoid exact matches only. - for step in 0..<n { - let candidate = palette[(base + step) % n] - if !reserved.contains(candidate.id) { return candidate } - } + let free = palette.filter { !reserved.contains($0.id) } + if let pick = pick(from: free, seed: seed) { return pick } + // Pass 3: everything reserved — reuse the hashed base colour. - return palette[base] + return palette[stableIndex(for: seed, count: palette.count)] + } + + /// Deterministically pick one colour from `candidates` by hashing `seed` + /// into the set. Returns `nil` when there is nothing to choose from. + private static func pick(from candidates: [PlayerColor], seed: String) -> PlayerColor? { + guard !candidates.isEmpty else { return nil } + return candidates[stableIndex(for: seed, count: candidates.count)] } }