commit 8eb5441bae067f42ef9b10625a7d3cec9f959873
parent 82d7c9f64605e530cfd74a5f3513471534f998d7
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:
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"])
+ }
+}