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:
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)