crossmate

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

commit b934bdb6de0b62fdacc242c84a8cc06e0a8cd024
parent 9962445d25d802eae8526b4e18fb79103879d744
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 22:04:31 +0900

Rotate one-player collaborator colours within companion sets

With the curated companion table, a one-collaborator game always took the first
colour from the selected option. For a blue local player that meant only red or
green could appear, and red appeared in two of the three options even though
the full blue companion table included six distinct colours.

This commit keeps the curated option selection but adds a second game-derived
offset before assigning collaborators. A game still uses one vetted companion
set, preserving the perceptual spacing for multi-player games, while a
one-collaborator game can now land on any colour in that set. The new
PlayerColor tests pin both the full blue-anchor spread and the wrapped ordering
inside a selected set.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Models/PlayerColor.swift | 20+++++++++++++-------
ATests/Unit/PlayerColorTests.swift | 41+++++++++++++++++++++++++++++++++++++++++
3 files changed, 58 insertions(+), 7 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -128,6 +128,7 @@ 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3D29B227D2B0E699423C48 /* Journal.swift */; }; 95170ECF07E94E7581C2B66F /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; }; 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; }; + 95B4083F8BC2CA465077A662 /* PlayerColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; @@ -278,6 +279,7 @@ 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameArchiver.swift; sourceTree = "<group>"; }; 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; + 23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColorTests.swift; sourceTree = "<group>"; }; 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GameEntity+ContentKey.swift"; sourceTree = "<group>"; }; 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; @@ -568,6 +570,7 @@ D243575E32A8663B1AAF492A /* PeerChangeLedgerTests.swift */, D491B7232333AA8957732387 /* PendingEditFlagTests.swift */, 4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */, + 23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */, 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */, @@ -1008,6 +1011,7 @@ 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */, 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, 26DC22F88FA10C47BC06975E /* PersistenceRecoveryTests.swift in Sources */, + 95B4083F8BC2CA465077A662 /* PlayerColorTests.swift in Sources */, CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */, 7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -108,11 +108,14 @@ extension PlayerColor { /// (sorted) order, and the returned array is aligned to it. /// /// Picks one curated option from `companionTable` for the anchor — seeded - /// by `gameID`, so the set varies between games — and hands its companions - /// out in order. 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. + /// 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, @@ -123,8 +126,11 @@ extension PlayerColor { var colors: [PlayerColor] = [] if let options = companionTable[anchor.id], !options.isEmpty { - let option = options[stableIndex(for: gameID.uuidString, count: options.count)] - colors = option.prefix(count).map { color(for: $0) } + 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) } } if colors.count < count { diff --git a/Tests/Unit/PlayerColorTests.swift b/Tests/Unit/PlayerColorTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("PlayerColor") +struct PlayerColorTests { + + @Test("Single blue-anchor companion can rotate through every curated colour") + func singleBlueAnchorCompanionUsesEveryCuratedColor() { + let examples: [(String, String)] = [ + ("00000000-0000-0000-0000-000000000035", "red"), + ("00000000-0000-0000-0000-000000000004", "yellow"), + ("00000000-0000-0000-0000-000000000000", "green"), + ("00000000-0000-0000-0000-000000000010", "pink"), + ("00000000-0000-0000-0000-000000000001", "orange"), + ("00000000-0000-0000-0000-000000000009", "purple"), + ] + + let assigned = examples.map { uuidString, _ in + PlayerColor.assignedCompanions( + forSortedAuthorIDs: ["_B"], + inGame: UUID(uuidString: uuidString)!, + anchor: .blue + ).first?.id + } + + #expect(assigned == examples.map(\.1)) + } + + @Test("Companion option rotation wraps within the curated set") + func companionOptionRotationWrapsWithinCuratedSet() { + let colors = PlayerColor.assignedCompanions( + forSortedAuthorIDs: ["_B", "_C", "_D"], + inGame: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!, + anchor: .blue + ).map(\.id) + + #expect(colors == ["yellow", "green", "red"]) + } +}