crossmate

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

commit 254f2a35bc9a14807197582d8c1847b359547c74
parent 3f2f636955bf590e0aaac38d3ee2eb2c4a36d010
Author: Michael Camilleri <[email protected]>
Date:   Mon, 27 Jul 2026 08:16:25 +0900

Preserve collaborator colours across Chronicle replacement

A completed shared game could recolour its collaborators when its
Chronicle replaced the live Game because the Chronicle's derived UUID
seeded a different companion palette. Persisting colour seats avoided
that symptom but added local model and sync state for an identity
mismatch at the replacement boundary.

This commit restores deterministic colour assignment and uses the
original game's UUID when deriving colours for a materialised Chronicle.
The live Game and Chronicle now produce the same colours in the Game
List and player roster without storing colour seats.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1-
MCrossmate/Models/ParticipantSummaries.swift | 31+++++++++++--------------------
MCrossmate/Models/PlayerColor.swift | 127+++++++++++++++++++++----------------------------------------------------------
MCrossmate/Models/PlayerRoster.swift | 48+++++++++++++++++-------------------------------
MCrossmate/Persistence/GameStore.swift | 63+++++----------------------------------------------------------
MCrossmate/Services/AppServices.swift | 13+------------
MCrossmate/Sync/SyncEngine.swift | 13-------------
MTests/Unit/ArchiveTests.swift | 37+++++++++++++++++++++++++++++++++----
MTests/Unit/PlayerColorTests.swift | 100++++++++-----------------------------------------------------------------------
MTests/Unit/PlayerRosterTests.swift | 8++++----
10 files changed, 113 insertions(+), 328 deletions(-)

diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -13,7 +13,6 @@ <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,7 +21,6 @@ enum ParticipantSummaries { localAuthorID: String?, localName: String, localColor: PlayerColor, - colorSeats: [String] = [], scoreByAuthorID: [String: Int] = [:], additionalAuthorIDs: [String] = [] ) -> [GameParticipantSummary] { @@ -32,7 +31,6 @@ enum ParticipantSummaries { nicknamesByAuthor: nicknamesByAuthor, localAuthorID: localAuthorID, localColor: localColor, - colorSeats: colorSeats, scoreByAuthorID: scoreByAuthorID, additionalAuthorIDs: additionalAuthorIDs ) @@ -55,7 +53,6 @@ enum ParticipantSummaries { nicknamesByAuthor: [String: String], localAuthorID: String?, localColor: PlayerColor, - colorSeats: [String] = [], scoreByAuthorID: [String: Int] = [:], additionalAuthorIDs: [String] = [] ) -> [GameParticipantSummary] { @@ -69,26 +66,20 @@ enum ParticipantSummaries { } // Hand the collaborators a curated, perceptually-spaced colour set - // 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( + // 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, inGame: gameID, - anchor: localColor, - forSeating: seating + anchor: localColor ) var summaries: [GameParticipantSummary] = [] - for authorID in authorIDs.sorted() { - guard let color = colorByAuthor[authorID] else { continue } + for (authorID, color) in zip(sortedAuthorIDs, colors) { let name = nicknamesByAuthor[authorID] ?? namesByAuthor[authorID] ?? "Waiting for player…" diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -103,105 +103,44 @@ extension PlayerColor { palette.first { $0.id == id } ?? .blue } - /// 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. + /// 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 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( + /// 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], inGame gameID: UUID, anchor: PlayerColor - ) -> [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] } - } - - /// 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: ",") - } + ) -> [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) } + } - /// 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 - ) + 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) } - taken.insert(color.id) - colors[authorID] = color } return colors } diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -38,6 +38,11 @@ final class PlayerRoster { /// A struct rather than a tuple so the empty-game path, the populated /// return, and the consumers stay in sync by name rather than position. private struct FetchedRoster { + /// The stable game identity used to seed collaborator colours. A + /// materialized Chronicle has its own derived entity ID, but must keep + /// using the original live game's ID or its palette changes at the + /// live-game → Chronicle boundary. + var colorGameID: UUID? var databaseScope: Int16 = 0 var ckShareRecordName: String? var ckZoneName: String? @@ -49,11 +54,6 @@ 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] = [:] @@ -138,11 +138,6 @@ 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? @@ -161,8 +156,7 @@ final class PlayerRoster { persistence: PersistenceController, container: CKContainer, engagementStore: EngagementStore = EngagementStore(), - tracer: (@MainActor @Sendable (String) -> Void)? = nil, - seatColors: (@MainActor @Sendable (UUID, Set<String>) -> Void)? = nil + tracer: (@MainActor @Sendable (String) -> Void)? = nil ) { self.gameID = gameID self.authorIdentity = authorIdentity @@ -171,7 +165,6 @@ final class PlayerRoster { self.container = container self.engagementStore = engagementStore self.tracer = tracer - self.seatColors = seatColors self.isStaticPreview = false startObserving() } @@ -190,7 +183,6 @@ 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 = [ @@ -359,6 +351,9 @@ final class PlayerRoster { nicknamesByAuthor[aid] = nickname } return FetchedRoster( + colorGameID: entity.ckRecordName.flatMap( + Archive.originalGameID(fromName:) + ) ?? entity.id, databaseScope: entity.databaseScope, ckShareRecordName: entity.ckShareRecordName, ckZoneName: entity.ckZoneName, @@ -366,7 +361,6 @@ final class PlayerRoster { namesMap: namesMap, playerAuthorIDs: playerAuthorIDs, nicknamesByAuthor: nicknamesByAuthor, - colorSeats: PlayerColor.decodeSeating(entity.colorSeats), moveAuthorIDs: authorIDs, rawSelections: selections, presenceUntilByAuthor: presenceUntilByAuthor, @@ -422,21 +416,20 @@ final class PlayerRoster { } } - // 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, + // 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: fetched.colorGameID ?? gameID, namesByAuthor: fetched.namesMap, moveAuthorIDs: fetched.moveAuthorIDs, nicknamesByAuthor: fetched.nicknamesByAuthor, localAuthorID: localAuthorID, localColor: preferences.color, - colorSeats: fetched.colorSeats, additionalAuthorIDs: fetched.playerAuthorIDs + shareAuthorIDs - ) - let remoteEntries = participants.map { participant in + ).map { participant in Entry( authorID: participant.authorID, name: participant.name, @@ -445,13 +438,6 @@ 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 @@ -233,7 +233,11 @@ struct GameSummary: Identifiable, Equatable { || isArchivedSharedGame(entity) self.isAccessRevoked = entity.isAccessRevoked self.allParticipants = Self.computeParticipants( - gameID: id, + // A Chronicle's entity ID is deliberately distinct from the live + // game's ID. Colours are game-seeded, so use the shared list + // identity (the original ID for a Chronicle) to keep the same + // collaborator colours across materialization. + gameID: self.listID, entity: entity, localAuthorID: localAuthorID, localName: localName, @@ -313,7 +317,6 @@ struct GameSummary: Identifiable, Equatable { localAuthorID: localAuthorID, localName: localName, localColor: localColor, - colorSeats: PlayerColor.decodeSeating(entity.colorSeats), scoreByAuthorID: scoreByAuthorID, additionalAuthorIDs: playerAuthorIDs + archivedContributorAuthorIDs(entity) @@ -387,12 +390,6 @@ 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] @@ -422,7 +419,6 @@ 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) @@ -1593,55 +1589,6 @@ 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,14 +1269,6 @@ 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) } @@ -1979,10 +1971,7 @@ final class AppServices { persistence: persistence, container: ckContainer, engagementStore: engagementStore, - tracer: { [syncMonitor] message in syncMonitor.note(message) }, - seatColors: { [weak store] gameID, authorIDs in - store?.ensureColorSeats(for: gameID, discovered: authorIDs) - } + tracer: { [syncMonitor] message in syncMonitor.note(message) } ) } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -290,12 +290,6 @@ 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 @@ -408,12 +402,6 @@ 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 ) { @@ -2205,7 +2193,6 @@ actor SyncEngine { object: nil, userInfo: ["gameIDs": effects.rosterRelevant] ) - await onRosterRelevant?(effects.rosterRelevant) } await notifyReplayJournalsSynced(effects.journalsSynced) } diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -648,7 +648,7 @@ struct ArchiveTests { func staleLegacyProjectionRecoversParticipantsAndCellAuthors() async throws { let persistence = makeTestPersistence() let ctx = persistence.viewContext - let original = UUID() + let original = UUID(uuidString: "00000000-0000-0000-0000-000000000004")! let package = try Archive.recordPackage( from: sampleSnapshot(originalGameID: original), formatVersion: 1 @@ -702,6 +702,12 @@ struct ArchiveTests { ) await roster.preload() #expect(Set(roster.entries.map(\.authorID)) == ["alice", "bob"]) + let expectedBobColor = PlayerColor.assignedCompanions( + forSortedAuthorIDs: ["bob"], + inGame: original, + anchor: preferences.color + ).first + #expect(roster.entries.first { $0.authorID == "bob" }?.color == expectedBobColor) } @Test("archive retry window expires 14 days after completion") @@ -914,7 +920,7 @@ struct ArchiveTests { let persistence = makeTestPersistence() let engine = try makeSyncEngine(persistence) let ctx = persistence.viewContext - let original = UUID() + let original = UUID(uuidString: "00000000-0000-0000-0000-000000000004")! let live = GameEntity(context: ctx) live.id = original @@ -924,6 +930,17 @@ struct ArchiveTests { live.updatedAt = Date() live.databaseScope = 1 live.ckRecordName = "game-\(original.uuidString)" + for (authorID, name) in [("alice", "Alice"), ("bob", "Bob")] { + let player = PlayerEntity(context: ctx) + player.game = live + player.ckRecordName = RecordSerializer.recordName( + forPlayerInGame: original, + authorID: authorID + ) + player.authorID = authorID + player.name = name + player.updatedAt = Date() + } try ctx.save() let result = try withArchiveRecord( @@ -944,11 +961,23 @@ struct ArchiveTests { #expect(!archive.isHidden) #expect(!archive.isSupersededByChronicle) - let liveSummary = try #require(GameSummary(entity: live)) - let archiveSummary = try #require(GameSummary(entity: archive)) + let liveSummary = try #require(GameSummary( + entity: live, + localAuthorID: "alice", + localColor: .blue + )) + let archiveSummary = try #require(GameSummary( + entity: archive, + localAuthorID: "alice", + localColor: .blue + )) #expect(liveSummary.id != archiveSummary.id) #expect(liveSummary.listID == original) #expect(archiveSummary.listID == original) + #expect( + liveSummary.allParticipants.first { $0.authorID == "bob" }?.color + == archiveSummary.allParticipants.first { $0.authorID == "bob" }?.color + ) } @Test("block reconciliation preserves legacy Chronicle replacement") 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.assignedColors( + PlayerColor.assignedCompanions( + forSortedAuthorIDs: ["_B"], inGame: UUID(uuidString: uuidString)!, - anchor: .blue, - forSeating: ["_B"] - )["_B"]?.id + anchor: .blue + ).first?.id } #expect(assigned == examples.map(\.1)) @@ -30,94 +30,12 @@ struct PlayerColorTests { @Test("Companion option rotation wraps within the curated set") func companionOptionRotationWrapsWithinCuratedSet() { - let seating = ["_B", "_C", "_D"] - let colors = PlayerColor.assignedColors( + let colors = PlayerColor.assignedCompanions( + forSortedAuthorIDs: ["_B", "_C", "_D"], inGame: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!, - anchor: .blue, - forSeating: seating - ) + anchor: .blue + ).map(\.id) - #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) == []) + #expect(colors == ["yellow", "green", "red"]) } } 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.assignedColors( + let expected = PlayerColor.assignedCompanions( + forSortedAuthorIDs: ["_B"], inGame: gameID, - anchor: PlayerColor.color(for: localColorID), - forSeating: ["_B"] - )["_B"] + anchor: PlayerColor.color(for: localColorID) + ).first #expect(roster.entries.first { $0.authorID == "_B" }?.color.id == expected?.id) return roster.entries.first { $0.authorID == "_B" }?.color.id }