crossmate

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

commit 7015ff2f7af074959b4cab56de3c15b5a38a0138
parent 33156f795eed31049d1401c838b27af6a41e983e
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 20:12:46 +0900

Consolidate puzzle grid stats

This commit gives the live scoreboard and Success Panel a shared
PuzzleGridStats pass for fill, reveal, and contribution counts. The
previous views each walked the grid and rebuilt near-identical author
mapping logic, which made the render paths more expensive and left the
two summaries easy to drift apart.

Now both views consume the same participant-count model while preserving
their different display rules: the live scoreboard still counts filled
unrevealed squares, and the Success Panel can still read replay frames
and ignore incorrect entries.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
ACrossmate/Views/Puzzle/PuzzleGridStats.swift | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Puzzle/PuzzleScoreboard.swift | 169++++++++++++++++++++-----------------------------------------------------------
MCrossmate/Views/Puzzle/SuccessPanel.swift | 125++++++++++++++++---------------------------------------------------------------
4 files changed, 220 insertions(+), 226 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; }; 51E6F7F2FC52C2AA87B9DB45 /* PeerPresenceGraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E8592B1CB1336E63498706 /* PeerPresenceGraceTests.swift */; }; 521E877D28502B917C89B66D /* AccountPushCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */; }; + 5264996E0D72A805B20985E8 /* PuzzleGridStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A7390848B1943C80142934 /* PuzzleGridStats.swift */; }; 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */; }; 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; }; 5E89D1F8FDFE56395997281A /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09C81EFA0B7776CB9713CD63 /* NewGameSheet.swift */; }; @@ -283,6 +284,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>"; }; + 23A7390848B1943C80142934 /* PuzzleGridStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGridStats.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>"; }; @@ -712,6 +714,7 @@ E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */, 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */, 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */, + 23A7390848B1943C80142934 /* PuzzleGridStats.swift */, ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */, F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */, A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */, @@ -1159,6 +1162,7 @@ 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */, + 5264996E0D72A805B20985E8 /* PuzzleGridStats.swift in Sources */, D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */, 082B9BAADE3AFA54EFE30E19 /* PuzzleModifiers.swift in Sources */, 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */, diff --git a/Crossmate/Views/Puzzle/PuzzleGridStats.swift b/Crossmate/Views/Puzzle/PuzzleGridStats.swift @@ -0,0 +1,148 @@ +import Foundation + +struct PuzzleGridStats { + struct CellState { + let entry: String + let mark: CellMark + let authorID: String? + + init(entry: String, mark: CellMark, authorID: String?) { + self.entry = entry + self.mark = mark + self.authorID = authorID + } + + init(_ state: JournalCellState) { + self.entry = state.letter + self.mark = state.mark + self.authorID = state.cellAuthorID + } + } + + struct ParticipantCount: Identifiable { + let authorID: String? + let name: String + let color: PlayerColor? + let count: Int + + var id: String { authorID ?? "unattributed" } + } + + let fillableCellCount: Int + let filledCellCount: Int + let revealedSquareCount: Int + let countsByAuthorID: [String?: Int] + + init( + puzzle: Puzzle, + hasRemotePlayers: Bool, + localAuthorID: String?, + countsIncorrectEntries: Bool = true, + stateAt: (Int, Int) -> CellState + ) { + var fillableCellCount = 0 + var filledCellCount = 0 + var revealedSquareCount = 0 + var countsByAuthorID: [String?: Int] = [:] + + for r in 0..<puzzle.height { + for c in 0..<puzzle.width { + let cell = puzzle.cells[r][c] + guard !cell.isBlock else { continue } + fillableCellCount += 1 + + let state = stateAt(r, c) + if !state.entry.isEmpty { + filledCellCount += 1 + } + if state.mark.isRevealed { + revealedSquareCount += 1 + continue + } + guard !state.entry.isEmpty else { continue } + if !countsIncorrectEntries, cell.solution != nil, !cell.accepts(state.entry) { + continue + } + let authorID = Self.normalizedAuthorID( + state.authorID, + hasRemotePlayers: hasRemotePlayers, + localAuthorID: localAuthorID + ) + countsByAuthorID[authorID, default: 0] += 1 + } + } + + self.fillableCellCount = fillableCellCount + self.filledCellCount = filledCellCount + self.revealedSquareCount = revealedSquareCount + self.countsByAuthorID = countsByAuthorID + } + + func participantCounts( + entries: [PlayerRoster.Entry], + localName: String, + localColor: PlayerColor, + includesUnattributedLocalWhenNoRemote: Bool = false + ) -> [ParticipantCount] { + let usesLocalFallback = entries.isEmpty + let entryByAuthorID = Dictionary( + entries.map { ($0.authorID, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let rosterAuthorIDs = Set(entries.map(\.authorID)) + let hasRemotePlayers = entries.contains { !$0.isLocal } + + let rosterCounts: [ParticipantCount] + if usesLocalFallback { + rosterCounts = [ + ParticipantCount( + authorID: nil, + name: localName, + color: localColor, + count: countsByAuthorID[nil] ?? 0 + ) + ] + } else { + rosterCounts = entries.map { entry in + ParticipantCount( + authorID: entry.authorID, + name: entry.name, + color: entry.color, + count: countsByAuthorID[entry.authorID] ?? 0 + ) + } + } + + let extraCounts = countsByAuthorID.compactMap { authorID, count -> ParticipantCount? in + if let authorID, rosterAuthorIDs.contains(authorID) { + return nil + } + if authorID == nil && usesLocalFallback { + return nil + } + if let authorID, let entry = entryByAuthorID[authorID] { + return ParticipantCount(authorID: authorID, name: entry.name, color: entry.color, count: count) + } + if authorID == nil && includesUnattributedLocalWhenNoRemote && !hasRemotePlayers { + return ParticipantCount(authorID: nil, name: localName, color: localColor, count: count) + } + if authorID == nil { + return nil + } + return ParticipantCount(authorID: authorID, name: "Player", color: nil, count: count) + } + + return rosterCounts + extraCounts + } + + private static func normalizedAuthorID( + _ authorID: String?, + hasRemotePlayers: Bool, + localAuthorID: String? + ) -> String? { + guard let authorID else { + return hasRemotePlayers ? nil : localAuthorID + } + return authorID + } +} diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift @@ -27,163 +27,77 @@ struct PuzzleScoreboard: View { case horizontal } - private struct Score: Identifiable { - let authorID: String? - let name: String - let color: PlayerColor? - let filledCount: Int - - var id: String { authorID ?? "unattributed" } - } - - private var fillableCellCount: Int { - session.puzzle.cells.reduce(0) { count, row in - count + row.filter { !$0.isBlock }.count + private typealias Score = PuzzleGridStats.ParticipantCount + + private var gridStats: PuzzleGridStats { + let hasRemotePlayers = roster.entries.contains { !$0.isLocal } + return PuzzleGridStats( + puzzle: session.puzzle, + hasRemotePlayers: hasRemotePlayers, + localAuthorID: roster.localAuthorID + ) { r, c in + let square = session.game.squares[r][c] + return PuzzleGridStats.CellState( + entry: square.entry, + mark: square.mark, + authorID: square.letterAuthorID + ) } } - private var filledCellCount: Int { - var count = 0 - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - guard !session.puzzle.cells[r][c].isBlock else { continue } - if !session.game.squares[r][c].entry.isEmpty { - count += 1 - } - } - } - return count + private func remainingCount(for stats: PuzzleGridStats) -> Int { + max(0, stats.fillableCellCount - stats.filledCellCount) } - private var revealedSquareCount: Int { - var count = 0 - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - guard !session.puzzle.cells[r][c].isBlock else { continue } - if session.game.squares[r][c].mark.isRevealed { - count += 1 - } - } - } - return count - } - - private var remainingCount: Int { - max(0, fillableCellCount - filledCellCount) - } - - private var remainingPhrase: String { - switch remainingCount { + private func remainingPhrase(for stats: PuzzleGridStats) -> String { + switch remainingCount(for: stats) { case 0: return "no squares to go" case 1: return "1 square to go" default: - return "\(remainingCount) squares to go" + return "\(remainingCount(for: stats)) squares to go" } } - private var revealedPhrase: String { - switch revealedSquareCount { + private func revealedPhrase(for stats: PuzzleGridStats) -> String { + switch stats.revealedSquareCount { case 0: return "No squares revealed" case 1: return "1 square revealed" default: - return "\(revealedSquareCount) squares revealed" + return "\(stats.revealedSquareCount) squares revealed" } } - private var progressText: String { - if revealedSquareCount > 0 { - return "\(revealedPhrase), \(remainingPhrase)" + private func progressText(for stats: PuzzleGridStats) -> String { + if stats.revealedSquareCount > 0 { + return "\(revealedPhrase(for: stats)), \(remainingPhrase(for: stats))" } - switch remainingCount { + switch remainingCount(for: stats) { case 0: return "No squares to go" case 1: return "1 square to go" default: - return "\(remainingCount) squares to go" + return "\(remainingCount(for: stats)) squares to go" } } - private var scores: [Score] { - var counts: [String?: Int] = [:] - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - guard !session.puzzle.cells[r][c].isBlock else { continue } - let square = session.game.squares[r][c] - guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } - counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 - } - } - - let entries = roster.entries - let usesLocalFallback = entries.isEmpty - let entryByAuthorID = Dictionary( - entries.map { ($0.authorID, $0) }, - uniquingKeysWith: { first, _ in first } - ) - let rosterAuthorIDs = Set(entries.map(\.authorID)) - - let rosterScores: [Score] - if usesLocalFallback { - rosterScores = [ - Score( - authorID: nil, - name: preferences.name, - color: preferences.color, - filledCount: counts[nil] ?? 0 - ) - ] - } else { - rosterScores = entries.map { entry in - Score( - authorID: entry.authorID, - name: entry.name, - color: entry.color, - filledCount: counts[entry.authorID] ?? 0 - ) - } - } - - let extraScores = counts.compactMap { authorID, count -> Score? in - if let authorID, rosterAuthorIDs.contains(authorID) { - return nil - } - if authorID == nil && usesLocalFallback { - return nil - } - if let authorID, let entry = entryByAuthorID[authorID] { - return Score(authorID: authorID, name: entry.name, color: entry.color, filledCount: count) - } - if authorID == nil { - // A `nil` author key only arises with remote players present - // (see `normalizedAuthorID`): an authorless square, e.g. a cell - // sealed to the solution at completion before its author's - // letter arrived. It belongs to no player, so drop it rather - // than tallying an "Unattributed" entry. - return nil - } - return Score(authorID: authorID, name: "Player", color: nil, filledCount: count) - } - + private func scores(for stats: PuzzleGridStats) -> [Score] { return ParticipantSummaries.sortedByScore( - rosterScores + extraScores, - score: \.filledCount, + stats.participantCounts( + entries: roster.entries, + localName: preferences.name, + localColor: preferences.color + ), + score: \.count, name: \.name, id: \.id ) } - private func normalizedAuthorID(_ authorID: String?) -> String? { - guard let authorID else { - return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID - } - return authorID - } - private var showsNudgeButton: Bool { onNudge != nil && !session.mutator.isCompleted @@ -218,7 +132,9 @@ struct PuzzleScoreboard: View { } private var verticalBody: some View { - VStack(alignment: .leading, spacing: 12) { + let stats = gridStats + let scores = scores(for: stats) + return VStack(alignment: .leading, spacing: 12) { verticalHeading VStack(alignment: .leading, spacing: 6) { @@ -226,7 +142,7 @@ struct PuzzleScoreboard: View { scoreRow(score) } - Text(progressText) + Text(progressText(for: stats)) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 10) @@ -239,6 +155,7 @@ struct PuzzleScoreboard: View { } private var horizontalBody: some View { + let scores = scores(for: gridStats) // The heading/nudge capsule pins to the leading edge and the score // chips flow after it, wrapping leading-to-trailing onto new lines. // The whole [heading | chips] group hugs its content and centres in @@ -252,7 +169,7 @@ struct PuzzleScoreboard: View { // `.bottom` scroll anchor rests a short strip (the common 2–3 player // case) against the bottom of the band, matching the title/credits // pages, yet still scrolls up from the bottom once the rows overflow. - ScrollView(.vertical, showsIndicators: false) { + return ScrollView(.vertical, showsIndicators: false) { HStack(alignment: .center, spacing: 18) { playersHeading FlowLayout(alignment: .leading, spacing: 18, lineSpacing: 8) { @@ -429,7 +346,7 @@ struct PuzzleScoreboard: View { Text(score.name) .font(.subheadline) .lineLimit(1) - Text("\(score.filledCount)") + Text("\(score.count)") .font(.subheadline.monospacedDigit().weight(.semibold)) } .accessibilityElement(children: .combine) @@ -444,7 +361,7 @@ struct PuzzleScoreboard: View { .font(.subheadline) .lineLimit(1) Spacer(minLength: 8) - Text("\(score.filledCount)") + Text("\(score.count)") .font(.subheadline.monospacedDigit().weight(.semibold)) } .accessibilityElement(children: .combine) diff --git a/Crossmate/Views/Puzzle/SuccessPanel.swift b/Crossmate/Views/Puzzle/SuccessPanel.swift @@ -15,38 +15,31 @@ struct SuccessPanel: View { /// present the first time the sheet opens. @State private var sharePreview: SharePreviewItem? - private struct Contribution: Identifiable { - let authorID: String? - let name: String - let color: PlayerColor? - let count: Int + private typealias Contribution = PuzzleGridStats.ParticipantCount - var id: String { authorID ?? "unattributed" } - } - - private var revealedSquareCount: Int { + private var gridStats: PuzzleGridStats { let replayCells = replay?.frame?.cells - var count = 0 - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - let cell = session.puzzle.cells[r][c] - guard !cell.isBlock else { continue } - if displayedCellState(row: r, col: c, replayCells: replayCells).mark.isRevealed { - count += 1 - } - } + let hasRemotePlayers = roster.entries.contains { !$0.isLocal } + return PuzzleGridStats( + puzzle: session.puzzle, + hasRemotePlayers: hasRemotePlayers, + localAuthorID: roster.localAuthorID, + countsIncorrectEntries: false + ) { r, c in + PuzzleGridStats.CellState( + displayedCellState(row: r, col: c, replayCells: replayCells) + ) } - return count } - private var revealedSquaresText: String { - switch revealedSquareCount { + private func revealedSquaresText(for stats: PuzzleGridStats) -> String { + switch stats.revealedSquareCount { case 0: return "No squares revealed" case 1: return "1 square revealed" default: - return "\(revealedSquareCount) squares revealed" + return "\(stats.revealedSquareCount) squares revealed" } } @@ -158,76 +151,13 @@ struct SuccessPanel: View { return Image(uiImage: uiImage) } - private var contributions: [Contribution] { - let replayCells = replay?.frame?.cells - var counts: [String?: Int] = [:] - for r in 0..<session.puzzle.height { - for c in 0..<session.puzzle.width { - let cell = session.puzzle.cells[r][c] - guard !cell.isBlock else { continue } - let state = displayedCellState(row: r, col: c, replayCells: replayCells) - guard !state.mark.isRevealed else { continue } - let entry = state.letter - guard !entry.isEmpty else { continue } - if cell.solution != nil, !cell.accepts(entry) { continue } - counts[normalizedAuthorID(state.cellAuthorID), default: 0] += 1 - } - } - - let entries = roster.entries - let entryByAuthorID = Dictionary( - entries.map { ($0.authorID, $0) }, - uniquingKeysWith: { first, _ in first } + private func contributions(for stats: PuzzleGridStats) -> [Contribution] { + stats.participantCounts( + entries: roster.entries, + localName: preferences.name, + localColor: preferences.color, + includesUnattributedLocalWhenNoRemote: true ) - let hasRemotePlayers = entries.contains { !$0.isLocal } - let usesLocalFallback = entries.isEmpty - let rosterContributions: [Contribution] - if usesLocalFallback { - rosterContributions = [ - Contribution( - authorID: nil, - name: preferences.name, - color: preferences.color, - count: counts[nil] ?? 0 - ) - ] - } else { - rosterContributions = entries.map { entry in - Contribution( - authorID: entry.authorID, - name: entry.name, - color: entry.color, - count: counts[entry.authorID] ?? 0 - ) - } - } - let rosterAuthorIDs = Set(entries.map(\.authorID)) - - let countedContributions = counts.compactMap { authorID, count -> Contribution? in - if let authorID, rosterAuthorIDs.contains(authorID) { - return nil - } - if authorID == nil && usesLocalFallback { - return nil - } - if let authorID, let entry = entryByAuthorID[authorID] { - return Contribution(authorID: authorID, name: entry.name, color: entry.color, count: count) - } - if authorID == nil && !hasRemotePlayers { - return Contribution(authorID: nil, name: preferences.name, color: preferences.color, count: count) - } - if authorID == nil { - // A `nil` author key only arises with remote players present - // (see `normalizedAuthorID`): an authorless square, e.g. a cell - // sealed to the solution at completion before its author's - // letter arrived. It belongs to no player, so drop it rather - // than surfacing a phantom "Player". - return nil - } - return Contribution(authorID: authorID, name: "Player", color: nil, count: count) - } - - return (rosterContributions + countedContributions) .sorted { if $0.count != $1.count { return $0.count > $1.count } return $0.name < $1.name @@ -252,13 +182,6 @@ struct SuccessPanel: View { ) } - private func normalizedAuthorID(_ authorID: String?) -> String? { - guard let authorID else { - return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID - } - return authorID - } - var body: some View { VStack(spacing: 0) { if let replay { @@ -323,7 +246,9 @@ struct SuccessPanel: View { } private var scoreboard: some View { - HStack(alignment: .center, spacing: 16) { + let stats = gridStats + let contributions = contributions(for: stats) + return HStack(alignment: .center, spacing: 16) { VStack(alignment: .center, spacing: 8) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 44)) @@ -379,7 +304,7 @@ struct SuccessPanel: View { if let finishedInText { Text(finishedInText) } - Text(revealedSquaresText) + Text(revealedSquaresText(for: stats)) } .font(.footnote) .foregroundStyle(.secondary)