commit cecfd1cf4b2d1a21796c881e36305cb8d779cac1
parent f632593477be4a6b5db3dfa0f0819a4f2b996910
Author: Michael Camilleri <[email protected]>
Date: Sun, 26 Jul 2026 20:32:45 +0900
Give collaborators a colour seat that survives roster churn
A collaborator's colour could change from one launch to the next, and a
completed game could come back in different colours after an update.
Colours were handed out by position: remoteParticipants zipped the
sorted authorIDs against the curated companion set, so a colour belonged
to an index in whatever participant set the caller had discovered rather
than to a person. That set is assembled from durable Player and Moves
rows, a Chronicle's cached cells and replay journal, and a CKShare fetch
that can simply fail, so any difference in timing or reachability
re-coloured everyone. Nothing persisted the result, so every launch
derived it afresh.
This commit gives each collaborator a seat. PlayerColor.seating keeps an
append-only list of authorIDs per game, persisted locally in
GameEntity.colorSeats, and assignedColors derives a colour from the seat
alone. Newcomers are appended and existing seats are never renumbered,
so a participant missing from one pass keeps their slot and nobody else
claims their colour. Seating is pure and recomputed rather than merely
read, so a caller running ahead of the persisted list still lands on the
seat the next write records.
Because a seat does not depend on which subset a caller found, the Game
List strip and the grid now resolve the same person to the same colour
despite deriving participants differently. GameStore owns the single
writer, PlayerRoster contributes the share participants it alone
fetches, and a new SyncEngine callback seats games the user never opens.
colorSeats joins the summary cache key, which nothing else the key
observes would have invalidated.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
9 files changed, 322 insertions(+), 66 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -13,6 +13,7 @@
<attribute name="archiveAcknowledgedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="archiveGameID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="archiveParticipants" optional="YES" attributeType="String"/>
+ <attribute name="colorSeats" optional="YES" attributeType="String"/>
<attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="completedBy" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift
@@ -21,6 +21,7 @@ enum ParticipantSummaries {
localAuthorID: String?,
localName: String,
localColor: PlayerColor,
+ colorSeats: [String] = [],
scoreByAuthorID: [String: Int] = [:],
additionalAuthorIDs: [String] = []
) -> [GameParticipantSummary] {
@@ -31,6 +32,7 @@ enum ParticipantSummaries {
nicknamesByAuthor: nicknamesByAuthor,
localAuthorID: localAuthorID,
localColor: localColor,
+ colorSeats: colorSeats,
scoreByAuthorID: scoreByAuthorID,
additionalAuthorIDs: additionalAuthorIDs
)
@@ -53,6 +55,7 @@ enum ParticipantSummaries {
nicknamesByAuthor: [String: String],
localAuthorID: String?,
localColor: PlayerColor,
+ colorSeats: [String] = [],
scoreByAuthorID: [String: Int] = [:],
additionalAuthorIDs: [String] = []
) -> [GameParticipantSummary] {
@@ -66,20 +69,26 @@ enum ParticipantSummaries {
}
// Hand the collaborators a curated, perceptually-spaced colour set
- // anchored on the local user's (stable) colour: walk them in
- // sorted-authorID order so the assignment is stable, and let
- // `assignedCompanions` pick one game-specific option from the table.
- // The set is derived from `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.
- let sortedAuthorIDs = authorIDs.sorted()
- let colors = PlayerColor.assignedCompanions(
- forSortedAuthorIDs: sortedAuthorIDs,
+ // anchored on the local user's (stable) colour. Colours come from each
+ // author's *seat* (`PlayerColor.seating`), not from their position in
+ // whatever set this caller managed to discover, so a participant source
+ // that is slow, partial, or simply failed can no longer re-colour
+ // everyone. Seating is recomputed here rather than merely read: it is
+ // pure, so an author discovered before the persisted seating catches up
+ // still lands on the seat the next write will record.
+ //
+ // The set of colours is derived from `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.
+ let seating = PlayerColor.seating(existing: colorSeats, discovered: authorIDs)
+ let colorByAuthor = PlayerColor.assignedColors(
inGame: gameID,
- anchor: localColor
+ anchor: localColor,
+ forSeating: seating
)
var summaries: [GameParticipantSummary] = []
- for (authorID, color) in zip(sortedAuthorIDs, colors) {
+ for authorID in authorIDs.sorted() {
+ guard let color = colorByAuthor[authorID] else { continue }
let name = nicknamesByAuthor[authorID]
?? namesByAuthor[authorID]
?? "Waiting for player…"
diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift
@@ -103,44 +103,105 @@ extension PlayerColor {
palette.first { $0.id == id } ?? .blue
}
- /// Colours for the `authorIDs` collaborators in `gameID`, given the local
- /// user's `anchor` colour. `authorIDs` is taken in the caller's stable
- /// (sorted) order, and the returned array is aligned to it.
+ /// The seating for a game: the collaborators that have been given a colour
+ /// slot, in slot order. A collaborator's *seat* is their index here, and a
+ /// seat is what a colour is actually derived from — never the caller's
+ /// position in whatever set it happened to discover.
///
- /// Picks one curated option from `companionTable` for the anchor — seeded
- /// by `gameID`, so the set varies between games — then rotates the handout
- /// order by a second game-derived offset. That keeps each game's companion
- /// set vetted while letting one-collaborator games use any colour in the
- /// selected option instead of always taking the first slot. Any slots a
- /// game needs beyond the option's length (or an anchor with no table
- /// entry, which shouldn't happen for palette colours) fall back to the
- /// legacy hue-spacing `assignedColor`, threaded so the surplus colours
- /// stay distinct from the anchor and each other.
- static func assignedCompanions(
- forSortedAuthorIDs authorIDs: [String],
+ /// The rule is append-only. `existing` keeps its order, and any author in
+ /// `discovered` that has no seat yet is appended (sorted, so simultaneous
+ /// newcomers land in a stable order). Nobody is ever removed or renumbered.
+ ///
+ /// Never removing is the point. A game's participants are assembled from
+ /// several sources that populate at different times and with different
+ /// reliability — durable Player/Moves rows, a Chronicle's cached cells and
+ /// replay journal, and a `CKShare` fetch that can simply fail. Binding
+ /// colours to a *position* in that set meant every wobble in it re-coloured
+ /// everyone. Holding the seat keeps a colour attached to a person: a peer
+ /// who drops out of the set keeps their slot, so nothing else claims their
+ /// colour and they get the same one back when they return.
+ ///
+ /// Pure and deterministic, so a caller that has not yet persisted the
+ /// result derives the same seating a later write will make durable — there
+ /// is no window in which a reader and the writer disagree.
+ static func seating(existing: [String], discovered: Set<String>) -> [String] {
+ var seats = existing.filter { !$0.isEmpty }
+ let seated = Set(seats)
+ for authorID in discovered.subtracting(seated).sorted() where !authorID.isEmpty {
+ seats.append(authorID)
+ }
+ return seats
+ }
+
+ /// The curated companion option for `gameID`, rotated. Seeded by `gameID`
+ /// so a game's colour set is vetted yet varies game to game, and rotated by
+ /// a second game-derived offset so a one-collaborator game can use any
+ /// colour in the option rather than always taking the first slot.
+ private static func rotatedCompanionOption(
inGame gameID: UUID,
anchor: PlayerColor
- ) -> [PlayerColor] {
- let count = authorIDs.count
- guard count > 0 else { return [] }
-
- var colors: [PlayerColor] = []
- if let options = companionTable[anchor.id], !options.isEmpty {
- let gameSeed = gameID.uuidString
- let option = options[stableIndex(for: "\(gameSeed)-option", count: options.count)]
- let offset = stableIndex(for: "\(gameSeed)-offset", count: option.count)
- let rotatedOption = option.indices.map { option[($0 + offset) % option.count] }
- colors = rotatedOption.prefix(count).map { color(for: $0) }
- }
+ ) -> [String] {
+ guard let options = companionTable[anchor.id], !options.isEmpty else { return [] }
+ let gameSeed = gameID.uuidString
+ let option = options[stableIndex(for: "\(gameSeed)-option", count: options.count)]
+ guard !option.isEmpty else { return [] }
+ let offset = stableIndex(for: "\(gameSeed)-offset", count: option.count)
+ return option.indices.map { option[($0 + offset) % option.count] }
+ }
- if colors.count < count {
- var taken = Set(colors.map(\.id))
- taken.insert(anchor.id)
- for authorID in authorIDs[colors.count..<count] {
- let color = assignedColor(forAuthorID: authorID, inGame: gameID, reserved: taken)
- taken.insert(color.id)
- colors.append(color)
+ /// Seating as persisted in `GameEntity.colorSeats` — comma-joined, the same
+ /// shape as `archiveParticipants` and on the same assumption that an
+ /// authorID never contains a comma (CloudKit record names and the
+ /// `local-<UUID>` fallback are both comma-free).
+ ///
+ /// Deliberately local-only: no CKRecord field mirrors it. A game's colours
+ /// are anchored on the *viewing* user's own colour, so two participants
+ /// never see the same assignment and there is nothing to agree on across
+ /// accounts — only across one account's devices, which converge because
+ /// they seat the same authorIDs in the same order.
+ static func decodeSeating(_ stored: String?) -> [String] {
+ (stored ?? "").split(separator: ",").map(String.init).filter { !$0.isEmpty }
+ }
+
+ static func encodeSeating(_ seating: [String]) -> String? {
+ let seats = seating.filter { !$0.isEmpty }
+ return seats.isEmpty ? nil : seats.joined(separator: ",")
+ }
+
+ /// The colour for every seated collaborator in `gameID`, keyed by authorID,
+ /// given the local user's `anchor` colour.
+ ///
+ /// Walks the seating in seat order so each colour is a function of the seat
+ /// alone. Seats within the curated option take its colours; seats past it
+ /// fall back to the legacy hue-spacing `assignedColor`, threaded through
+ /// `taken` so the surplus stays distinct from the anchor and each other.
+ ///
+ /// Because seats are stable, so is the result: a caller that discovered
+ /// only part of the seating still resolves the authors it *did* find to the
+ /// same colours as a caller that found all of them. That is what keeps the
+ /// Game List strip and the in-game grid agreeing even though they derive
+ /// participants differently.
+ static func assignedColors(
+ inGame gameID: UUID,
+ anchor: PlayerColor,
+ forSeating seating: [String]
+ ) -> [String: PlayerColor] {
+ let option = rotatedCompanionOption(inGame: gameID, anchor: anchor)
+ var taken: Set<String> = [anchor.id]
+ var colors: [String: PlayerColor] = [:]
+ for (seat, authorID) in seating.enumerated() {
+ let color: PlayerColor
+ if seat < option.count {
+ color = self.color(for: option[seat])
+ } else {
+ color = assignedColor(
+ forAuthorID: "seat-\(seat)",
+ inGame: gameID,
+ reserved: taken
+ )
}
+ taken.insert(color.id)
+ colors[authorID] = color
}
return colors
}
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -49,6 +49,11 @@ final class PlayerRoster {
/// everywhere the roster surfaces it (players menu, cursor chips,
/// presence traces).
var nicknamesByAuthor: [String: String] = [:]
+ /// The game's persisted colour seating (`GameEntity.colorSeats`). Read
+ /// alongside everything else so a colour is derived from the seat a
+ /// collaborator already holds rather than from their position in this
+ /// particular fetch's participant set.
+ var colorSeats: [String] = []
var moveAuthorIDs: [String] = []
var rawSelections: [RawSelection] = []
var presenceUntilByAuthor: [String: Date] = [:]
@@ -133,6 +138,11 @@ final class PlayerRoster {
private let container: CKContainer
private let engagementStore: EngagementStore
private let tracer: (@MainActor @Sendable (String) -> Void)?
+ /// Records colour seats for the participants this roster discovered.
+ /// A closure rather than a `GameStore` reference so the single writer stays
+ /// on the store's main context — two contexts appending to the same seating
+ /// could clobber each other.
+ private let seatColors: (@MainActor @Sendable (UUID, Set<String>) -> Void)?
private let isStaticPreview: Bool
private var cachedShare: CKShare?
@@ -151,7 +161,8 @@ final class PlayerRoster {
persistence: PersistenceController,
container: CKContainer,
engagementStore: EngagementStore = EngagementStore(),
- tracer: (@MainActor @Sendable (String) -> Void)? = nil
+ tracer: (@MainActor @Sendable (String) -> Void)? = nil,
+ seatColors: (@MainActor @Sendable (UUID, Set<String>) -> Void)? = nil
) {
self.gameID = gameID
self.authorIdentity = authorIdentity
@@ -160,6 +171,7 @@ final class PlayerRoster {
self.container = container
self.engagementStore = engagementStore
self.tracer = tracer
+ self.seatColors = seatColors
self.isStaticPreview = false
startObserving()
}
@@ -178,6 +190,7 @@ final class PlayerRoster {
self.container = CloudContainer.container
self.engagementStore = EngagementStore()
self.tracer = nil
+ self.seatColors = nil
self.isStaticPreview = true
self.localAuthorID = "marketing-local"
var entries = [
@@ -353,6 +366,7 @@ final class PlayerRoster {
namesMap: namesMap,
playerAuthorIDs: playerAuthorIDs,
nicknamesByAuthor: nicknamesByAuthor,
+ colorSeats: PlayerColor.decodeSeating(entity.colorSeats),
moveAuthorIDs: authorIDs,
rawSelections: selections,
presenceUntilByAuthor: presenceUntilByAuthor,
@@ -408,20 +422,21 @@ final class PlayerRoster {
}
}
- // 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(
+ // Assign each collaborator a colour for this game. Colours come from
+ // each author's seat, de-conflicted against the local user's stable
+ // colour, so they are distinct within the game but deliberately vary
+ // from game to game — see `ParticipantSummaries.remoteParticipants`.
+ let participants = ParticipantSummaries.remoteParticipants(
gameID: gameID,
namesByAuthor: fetched.namesMap,
moveAuthorIDs: fetched.moveAuthorIDs,
nicknamesByAuthor: fetched.nicknamesByAuthor,
localAuthorID: localAuthorID,
localColor: preferences.color,
+ colorSeats: fetched.colorSeats,
additionalAuthorIDs: fetched.playerAuthorIDs + shareAuthorIDs
- ).map { participant in
+ )
+ let remoteEntries = participants.map { participant in
Entry(
authorID: participant.authorID,
name: participant.name,
@@ -430,6 +445,13 @@ final class PlayerRoster {
)
}
+ // Persist the seats these colours were derived from. This is the only
+ // place share participants are ever seen, so without this a peer known
+ // only to the `CKShare` would be re-seated from scratch on every launch
+ // whose share fetch happened to fail. Additive and idempotent, so the
+ // pre-share pass and the post-share pass can both call it.
+ seatColors?(gameID, Set(participants.map(\.authorID)))
+
let localEntry = Entry(
authorID: localAuthorID,
name: preferences.name,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -313,6 +313,7 @@ struct GameSummary: Identifiable, Equatable {
localAuthorID: localAuthorID,
localName: localName,
localColor: localColor,
+ colorSeats: PlayerColor.decodeSeating(entity.colorSeats),
scoreByAuthorID: scoreByAuthorID,
additionalAuthorIDs: playerAuthorIDs
+ archivedContributorAuthorIDs(entity)
@@ -386,6 +387,12 @@ final class GameSummaryCache {
let localAuthorID: String?
let localName: String
let localColorID: String
+ /// Load-bearing, not defensive. `ensureColorSeats` touches nothing else
+ /// this key observes — not `updatedAt`, not `playersSignature`, not
+ /// `movesAuthorIDs` — so without it a seat write would never invalidate
+ /// and the strip would serve pre-seat colours for the rest of the
+ /// session while the grid used the new ones.
+ let colorSeats: String?
let playersSignature: [String]
let movesAuthorIDs: [String]
let nicknamesSignature: [String]
@@ -415,6 +422,7 @@ final class GameSummaryCache {
localAuthorID: localAuthorID,
localName: localName,
localColorID: localColor.id,
+ colorSeats: entity.colorSeats,
playersSignature: Self.playersSignature(for: entity),
movesAuthorIDs: Self.movesAuthorIDs(for: entity),
nicknamesSignature: Self.nicknamesSignature(in: entity.managedObjectContext)
@@ -1573,6 +1581,55 @@ final class GameStore {
saveContext("remapAuthorID")
}
+ /// Records a colour seat for any collaborator in `gameID` that does not yet
+ /// have one, so their colour survives the participant set churning.
+ ///
+ /// Additive and idempotent: `PlayerColor.seating` only ever appends, so
+ /// this is safe to call from anywhere, in any order, with any *subset* of
+ /// the game's participants. That matters because no single caller sees them
+ /// all — `GameStore` reads the durable Core Data sources, while only
+ /// `PlayerRoster` fetches the `CKShare`. Each contributes what it knows and
+ /// the seating converges.
+ ///
+ /// Writes on the main context rather than a background one so concurrent
+ /// callers serialise naturally and cannot clobber each other's appends; the
+ /// payload is a short string, so there is nothing to move off-main.
+ @discardableResult
+ func ensureColorSeats(for gameID: UUID, discovered: Set<String>) -> [String] {
+ guard let entity = fetchGameEntity(id: gameID) else { return [] }
+ var candidates = discovered
+ candidates.remove(CKCurrentUserDefaultName)
+ candidates.remove("")
+ if let localAuthorID = authorIDProvider() {
+ candidates.remove(localAuthorID)
+ }
+
+ let existing = PlayerColor.decodeSeating(entity.colorSeats)
+ let seating = PlayerColor.seating(existing: existing, discovered: candidates)
+ guard seating != existing else { return existing }
+ entity.colorSeats = PlayerColor.encodeSeating(seating)
+ saveContext("ensureColorSeats")
+ return seating
+ }
+
+ /// The durable collaborator identities `GameStore` can see for `gameID`,
+ /// used to seat contributors for games the user never opens. Share
+ /// participants are deliberately absent — only `PlayerRoster` fetches the
+ /// `CKShare`, and it seats those itself once the fetch lands.
+ func seatKnownContributors(for gameID: UUID) {
+ guard let entity = fetchGameEntity(id: gameID) else { return }
+ var discovered = Set(playerEntities(for: entity).compactMap(\.authorID))
+ discovered.formUnion(((entity.moves as? Set<MovesEntity>) ?? []).compactMap(\.authorID))
+ discovered.formUnion(((entity.cells as? Set<CellEntity>) ?? []).compactMap(\.letterAuthorID))
+ discovered.formUnion(
+ ((entity.journal as? Set<JournalEntity>) ?? []).compactMap(\.sourceAuthorID)
+ )
+ if let completedBy = entity.completedBy {
+ discovered.insert(completedBy)
+ }
+ ensureColorSeats(for: gameID, discovered: discovered)
+ }
+
/// Marks a game as completed after a normal win. Returns whether the
/// entity changed; no-ops if already marked.
/// Triggers a buffer flush so the completion snapshot is created promptly
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1269,6 +1269,14 @@ final class AppServices {
}
}
+ // Seat any newly-visible contributor so the Game List strip renders the
+ // same colours as the grid, including for games that are never opened.
+ await syncEngine.setOnRosterRelevant { [store] gameIDs in
+ for gameID in gameIDs {
+ store.seatKnownContributors(for: gameID)
+ }
+ }
+
await syncEngine.setOnCompletionRecordsSaved { [weak self] records in
await self?.sessions.noteCompletionRecordsSaved(records)
}
@@ -1971,7 +1979,10 @@ final class AppServices {
persistence: persistence,
container: ckContainer,
engagementStore: engagementStore,
- tracer: { [syncMonitor] message in syncMonitor.note(message) }
+ tracer: { [syncMonitor] message in syncMonitor.note(message) },
+ seatColors: { [weak store] gameID, authorIDs in
+ store?.ensureColorSeats(for: gameID, discovered: authorIDs)
+ }
)
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -290,6 +290,12 @@ actor SyncEngine {
/// be rebuilt without waiting for the next cold-launch reconciliation.
private var onReplayJournalsSynced:
(@MainActor @Sendable (Set<UUID>) async -> Void)?
+ /// Fires alongside `.playerRosterShouldRefresh` so a game's colour seating
+ /// is recorded even when the user never opens it. Only `PlayerRoster`
+ /// observes that notification, and only for the game on screen; the Game
+ /// List strip needs the same seats to render matching colours.
+ private var onRosterRelevant:
+ (@MainActor @Sendable (Set<UUID>) async -> Void)?
private var onCompletionRecordsSaved: (@MainActor @Sendable ([UUID: Set<CompletionDurableRecordKind>]) async -> Void)?
/// Fires with the game ID of a shared zone that just appeared locally —
/// the user joined the game here or on a sibling device. Drives cleanup
@@ -402,6 +408,12 @@ actor SyncEngine {
onReplayJournalsSynced = cb
}
+ func setOnRosterRelevant(
+ _ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void
+ ) {
+ onRosterRelevant = cb
+ }
+
func setOnCompletionRecordsSaved(
_ cb: @MainActor @Sendable @escaping ([UUID: Set<CompletionDurableRecordKind>]) async -> Void
) {
@@ -2171,6 +2183,7 @@ actor SyncEngine {
object: nil,
userInfo: ["gameIDs": effects.rosterRelevant]
)
+ await onRosterRelevant?(effects.rosterRelevant)
}
await notifyReplayJournalsSynced(effects.journalsSynced)
}
diff --git a/Tests/Unit/PlayerColorTests.swift b/Tests/Unit/PlayerColorTests.swift
@@ -18,11 +18,11 @@ struct PlayerColorTests {
]
let assigned = examples.map { uuidString, _ in
- PlayerColor.assignedCompanions(
- forSortedAuthorIDs: ["_B"],
+ PlayerColor.assignedColors(
inGame: UUID(uuidString: uuidString)!,
- anchor: .blue
- ).first?.id
+ anchor: .blue,
+ forSeating: ["_B"]
+ )["_B"]?.id
}
#expect(assigned == examples.map(\.1))
@@ -30,12 +30,94 @@ struct PlayerColorTests {
@Test("Companion option rotation wraps within the curated set")
func companionOptionRotationWrapsWithinCuratedSet() {
- let colors = PlayerColor.assignedCompanions(
- forSortedAuthorIDs: ["_B", "_C", "_D"],
+ let seating = ["_B", "_C", "_D"]
+ let colors = PlayerColor.assignedColors(
inGame: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!,
- anchor: .blue
- ).map(\.id)
+ anchor: .blue,
+ forSeating: seating
+ )
- #expect(colors == ["yellow", "green", "red"])
+ #expect(seating.map { colors[$0]?.id } == ["yellow", "green", "red"])
+ }
+
+ // MARK: - Seating
+
+ @Test("Seating appends newcomers without renumbering existing seats")
+ func seatingAppendsWithoutRenumbering() {
+ let existing = ["_bob", "_carol"]
+ // "_alice" sorts before both, so a positional scheme would push Bob and
+ // Carol along by one. Seats hold instead.
+ let seating = PlayerColor.seating(
+ existing: existing,
+ discovered: ["_alice", "_bob", "_carol"]
+ )
+
+ #expect(seating == ["_bob", "_carol", "_alice"])
+ }
+
+ @Test("A participant missing from this pass keeps their seat")
+ func seatingRetainsAbsentParticipants() {
+ // The CKShare fetch failed, so Carol is not in `discovered`. Her seat
+ // must survive, or Alice inherits her colour on the next pass.
+ let seating = PlayerColor.seating(
+ existing: ["_bob", "_carol"],
+ discovered: ["_bob"]
+ )
+
+ #expect(seating == ["_bob", "_carol"])
+ }
+
+ @Test("Seating is idempotent and orders simultaneous newcomers stably")
+ func seatingIsIdempotentAndDeterministic() {
+ let once = PlayerColor.seating(existing: [], discovered: ["_carol", "_alice", "_bob"])
+ let twice = PlayerColor.seating(existing: once, discovered: ["_carol", "_alice", "_bob"])
+
+ #expect(once == ["_alice", "_bob", "_carol"])
+ #expect(twice == once)
+ }
+
+ @Test("A partial view of the seating resolves the same colours")
+ func partialSeatingResolvesSameColors() {
+ // The Game List strip never fetches the CKShare, so it can discover a
+ // subset of the grid's participants. Both must colour Bob identically.
+ let gameID = UUID(uuidString: "00000000-0000-0000-0000-000000000004")!
+ let full = PlayerColor.assignedColors(
+ inGame: gameID,
+ anchor: .blue,
+ forSeating: ["_alice", "_bob"]
+ )
+ let partial = PlayerColor.assignedColors(
+ inGame: gameID,
+ anchor: .blue,
+ forSeating: PlayerColor.seating(
+ existing: ["_alice", "_bob"],
+ discovered: ["_bob"]
+ )
+ )
+
+ #expect(full["_bob"]?.id == partial["_bob"]?.id)
+ }
+
+ @Test("Seats past the curated option stay distinct from the anchor")
+ func surplusSeatsRemainDistinct() {
+ let seating = ["_a", "_b", "_c", "_d", "_e"]
+ let colors = PlayerColor.assignedColors(
+ inGame: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!,
+ anchor: .blue,
+ forSeating: seating
+ )
+
+ let ids = seating.compactMap { colors[$0]?.id }
+ #expect(ids.count == seating.count)
+ #expect(!ids.contains(PlayerColor.blue.id))
+ }
+
+ @Test("Seating survives a persistence round trip")
+ func seatingEncodingRoundTrips() {
+ let seating = ["_alice", "_bob"]
+
+ #expect(PlayerColor.decodeSeating(PlayerColor.encodeSeating(seating)) == seating)
+ #expect(PlayerColor.encodeSeating([]) == nil)
+ #expect(PlayerColor.decodeSeating(nil) == [])
}
}
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -167,11 +167,11 @@ struct PlayerRosterTests {
// 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.assignedCompanions(
- forSortedAuthorIDs: ["_B"],
+ let expected = PlayerColor.assignedColors(
inGame: gameID,
- anchor: PlayerColor.color(for: localColorID)
- ).first
+ anchor: PlayerColor.color(for: localColorID),
+ forSeating: ["_B"]
+ )["_B"]
#expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id)
return roster.entries.first { $0.authorID == "_B" }?.color.id
}