crossmate

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

commit b1cbac41fe5e822a88850e9f5b96067e834e57f8
parent 26a9e40bdd50c0626baaa6207ad2abe14de4294e
Author: Michael Camilleri <[email protected]>
Date:   Thu, 18 Jun 2026 08:48:15 +0900

Order Game List players by puzzle score

This commit updates shared in-progress Game List rows so the participant
strip includes the local player's colour and reads from highest scorer
at the leading edge. The strip derives scores from the persisted cell
cache, excluding block and revealed cells to match the puzzle
scoreboard, while the popover continues to show the full player list.

The Game List and Puzzle Grid scoreboard now share the same
score-ordering rule without sharing their score sources. GameSummary
keeps only the full participant list and the strip-specific ordering,
avoiding the old remote-only participant value now that the strip needs
to include the local player.

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

Diffstat:
MCrossmate/Models/ParticipantSummaries.swift | 17+++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 60+++++++++++++++++++++++++++++++++++++-----------------------
MCrossmate/Views/GameList/GameCardView.swift | 9+++++----
MCrossmate/Views/Puzzle/PuzzleScoreboard.swift | 11++++++-----
MTests/Unit/GameSummaryThumbnailTests.swift | 42++++++++++++++++++++++++++++++++++++------
5 files changed, 101 insertions(+), 38 deletions(-)

diff --git a/Crossmate/Models/ParticipantSummaries.swift b/Crossmate/Models/ParticipantSummaries.swift @@ -78,4 +78,21 @@ enum ParticipantSummaries { private static func resolvedLocalName(_ name: String) -> String { name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Player" : name } + + static func sortedByScore<T>( + _ values: [T], + score: (T) -> Int, + name: (T) -> String, + id: (T) -> String + ) -> [T] { + values.sorted { + let lhsScore = score($0) + let rhsScore = score($1) + if lhsScore != rhsScore { return lhsScore > rhsScore } + let lhsName = name($0) + let rhsName = name($1) + if lhsName != rhsName { return lhsName < rhsName } + return id($0) < id($1) + } + } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -32,8 +32,8 @@ struct GameSummary: Identifiable, Equatable { let isShared: Bool let isAccessRevoked: Bool let hasUnreadOtherMoves: Bool - let participants: [GameParticipantSummary] let allParticipants: [GameParticipantSummary] + let stripParticipants: [GameParticipantSummary] init?( entity: GameEntity, @@ -91,10 +91,18 @@ struct GameSummary: Identifiable, Equatable { // game's thumbnail is full regardless of merge drift. let isCompleted = entity.completedAt != nil var filledSet: Set<Int> = [] + var scoreByAuthorID: [String: Int] = [:] if !isCompleted { let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] for ce in cellEntities where !(ce.letter ?? "").isEmpty { - filledSet.insert(Int(ce.row) * width + Int(ce.col)) + let index = Int(ce.row) * width + Int(ce.col) + filledSet.insert(index) + guard blocks.indices.contains(index), !blocks[index] else { continue } + guard !CellMark(code: ce.markCode).isRevealed, + let authorID = ce.letterAuthorID, + !authorID.isEmpty, + authorID != CKCurrentUserDefaultName else { continue } + scoreByAuthorID[authorID, default: 0] += 1 } } @@ -125,14 +133,17 @@ struct GameSummary: Identifiable, Equatable { self.isOwned = entity.databaseScope == 0 self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 self.isAccessRevoked = entity.isAccessRevoked - let participantLists = Self.computeParticipants( + let allParticipants = Self.computeParticipants( entity: entity, localAuthorID: localAuthorID, localName: localName, localColor: localColor ) - self.participants = participantLists.remote - self.allParticipants = participantLists.all + self.allParticipants = allParticipants + self.stripParticipants = Self.stripParticipants( + allParticipants, + scoreByAuthorID: scoreByAuthorID + ) self.hasUnreadOtherMoves = Self.computeHasUnread( isShared: self.isShared, completedAt: entity.completedAt, @@ -161,9 +172,9 @@ struct GameSummary: Identifiable, Equatable { localAuthorID: String?, localName: String, localColor: PlayerColor - ) -> (remote: [GameParticipantSummary], all: [GameParticipantSummary]) { + ) -> [GameParticipantSummary] { guard entity.ckShareRecordName != nil || entity.databaseScope == 1 else { - return ([], []) + return [] } var namesByAuthor: [String: String] = [:] @@ -184,22 +195,25 @@ struct GameSummary: Identifiable, Equatable { } let nicknames = friendNicknames(in: entity.managedObjectContext) - return ( - ParticipantSummaries.remoteParticipants( - namesByAuthor: namesByAuthor, - moveAuthorIDs: moveAuthorIDs, - nicknamesByAuthor: nicknames, - localAuthorID: localAuthorID, - localColor: localColor - ), - ParticipantSummaries.allParticipants( - namesByAuthor: namesByAuthor, - moveAuthorIDs: moveAuthorIDs, - nicknamesByAuthor: nicknames, - localAuthorID: localAuthorID, - localName: localName, - localColor: localColor - ) + return ParticipantSummaries.allParticipants( + namesByAuthor: namesByAuthor, + moveAuthorIDs: moveAuthorIDs, + nicknamesByAuthor: nicknames, + localAuthorID: localAuthorID, + localName: localName, + localColor: localColor + ) + } + + private static func stripParticipants( + _ participants: [GameParticipantSummary], + scoreByAuthorID: [String: Int] + ) -> [GameParticipantSummary] { + ParticipantSummaries.sortedByScore( + participants, + score: { scoreByAuthorID[$0.authorID] ?? 0 }, + name: \.name, + id: \.authorID ) } diff --git a/Crossmate/Views/GameList/GameCardView.swift b/Crossmate/Views/GameList/GameCardView.swift @@ -114,7 +114,7 @@ struct GameListThumbnailView: View { if showsParticipantStrip { SharedParticipantsButton( - stripParticipants: game.participants, + stripParticipants: game.stripParticipants, menuParticipants: game.allParticipants ) .frame(width: 60) @@ -197,13 +197,14 @@ struct SharedParticipantsButton: View { } private var accessibilityLabel: String { - if stripParticipants.isEmpty { + let remoteParticipants = menuParticipants.filter { !$0.isLocal } + if remoteParticipants.isEmpty { return "Shared puzzle" } - if stripParticipants.count == 1, let participant = stripParticipants.first { + if remoteParticipants.count == 1, let participant = remoteParticipants.first { return "Shared with \(participant.name)" } - return "Shared with \(stripParticipants.count) players" + return "Shared with \(remoteParticipants.count) players" } } diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift @@ -158,11 +158,12 @@ struct PuzzleScoreboard: View { return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) } - return (rosterScores + extraScores) - .sorted { - if $0.filledCount != $1.filledCount { return $0.filledCount > $1.filledCount } - return $0.name < $1.name - } + return ParticipantSummaries.sortedByScore( + rosterScores + extraScores, + score: \.filledCount, + name: \.name, + id: \.id + ) } private func normalizedAuthorID(_ authorID: String?) -> String? { diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift @@ -51,13 +51,17 @@ struct GameSummaryThumbnailTests { row: Int16, col: Int16, to entity: GameEntity, - in ctx: NSManagedObjectContext + in ctx: NSManagedObjectContext, + authorID: String? = nil, + mark: CellMark = .none ) { let cell = CellEntity(context: ctx) cell.game = entity cell.row = row cell.col = col cell.letter = letter + cell.letterAuthorID = authorID + cell.markCode = mark.code } private func addPlayer( @@ -156,13 +160,39 @@ struct GameSummaryThumbnailTests { localColor: .blue )) - #expect(summary.participants.map(\.authorID) == ["_Alice", "_Bob"]) - #expect(summary.participants.map(\.name) == ["Ace", "Waiting for player..."]) - #expect(summary.participants.allSatisfy { !$0.isLocal }) #expect(summary.allParticipants.map(\.authorID) == ["_Local", "_Alice", "_Bob"]) #expect(summary.allParticipants.map(\.name) == ["Local Player", "Ace", "Waiting for player..."]) #expect(summary.allParticipants.map(\.isLocal) == [true, false, false]) - #expect(!summary.participants.map(\.color.id).contains(PlayerColor.blue.id)) - #expect(Set(summary.participants.map(\.color.id)).count == 2) + let remoteParticipants = summary.allParticipants.filter { !$0.isLocal } + #expect(!remoteParticipants.map(\.color.id).contains(PlayerColor.blue.id)) + #expect(Set(remoteParticipants.map(\.color.id)).count == 2) + } + + @Test("Shared strip includes local player and orders by score") + func sharedStripIncludesLocalPlayerAndOrdersByScore() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let entity = try makeGame(in: ctx) + entity.ckShareRecordName = "cloudkit.zoneshare" + addPlayer(authorID: "_Local", name: "Local", to: entity, in: ctx) + addPlayer(authorID: "_Alice", name: "Alice", to: entity, in: ctx) + addPlayer(authorID: "_Bob", name: "Bob", to: entity, in: ctx) + addCell("A", row: 0, col: 0, to: entity, in: ctx, authorID: "_Alice") + addCell("B", row: 0, col: 1, to: entity, in: ctx, authorID: "_Local") + addCell("C", row: 0, col: 2, to: entity, in: ctx, authorID: "_Bob") + addCell("D", row: 1, col: 0, to: entity, in: ctx, authorID: "_Alice") + addCell("F", row: 2, col: 0, to: entity, in: ctx, authorID: "_Bob", mark: .revealed) + try ctx.save() + + let summary = try #require(GameSummary( + entity: entity, + localAuthorID: "_Local", + localName: "Local Player", + localColor: .blue + )) + + #expect(summary.stripParticipants.map(\.authorID) == ["_Alice", "_Bob", "_Local"]) + #expect(summary.stripParticipants.map(\.isLocal) == [false, false, true]) + #expect(summary.stripParticipants.map(\.color.id).contains(PlayerColor.blue.id)) } }