commit 8b6d81274d17a76339e39f874eac2c0c4741e3b1
parent 93ebd202cf3df9b55009848c09105092944d063d
Author: Michael Camilleri <[email protected]>
Date: Fri, 8 May 2026 23:33:07 +0900
Make roster always present
Diffstat:
4 files changed, 46 insertions(+), 28 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -321,7 +321,7 @@ private struct PuzzleDisplayView: View {
var body: some View {
Group {
- if let session {
+ if let session, let roster {
PuzzleView(
session: session,
shareController: shareController,
@@ -345,7 +345,6 @@ private struct PuzzleDisplayView: View {
await pollOpenSharedPuzzle()
}
.task(id: gameID) {
- let hadRoster = roster != nil
session = nil
roster = nil
loadError = nil
@@ -353,22 +352,19 @@ private struct PuzzleDisplayView: View {
do {
let (game, mutator) = try store.loadGame(id: gameID)
let newSession = PlayerSession(game: game, mutator: mutator)
+ let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
+ roster = newRoster
+ await newRoster.refresh()
session = newSession
if mutator.isShared && preferences.isICloudSyncEnabled {
services.syncMonitor.note(
- "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: activating shared roster"
+ "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster"
)
await activateSharing(for: newSession)
} else {
- if hadRoster {
- services.syncMonitor.note(
- "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: cleared stale roster for local game"
- )
- } else {
- services.syncMonitor.note(
- "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: local game loaded without roster"
- )
- }
+ services.syncMonitor.note(
+ "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster"
+ )
await services.presencePublisher.clear()
}
} catch {
@@ -413,9 +409,15 @@ private struct PuzzleDisplayView: View {
/// again if a previously-solo game becomes shared mid-session.
private func activateSharing(for session: PlayerSession) async {
Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
- if roster == nil {
- roster = services.makePlayerRoster(for: gameID, preferences: preferences)
+ let activeRoster: PlayerRoster
+ if let roster {
+ activeRoster = roster
+ } else {
+ let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
+ roster = newRoster
+ activeRoster = newRoster
}
+ await activeRoster.refresh()
// Fan out the local user's name to every shared/joined game's zone
// before any presence write — otherwise the partner sees "Player"
// until we happen to rename ourselves and trigger NameBroadcaster's
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -46,6 +46,7 @@ final class PlayerRoster {
private let selectionFreshnessWindow: TimeInterval = 60
private(set) var entries: [Entry] = []
+ private(set) var localAuthorID: String?
private let gameID: UUID
private let colorStore: GamePlayerColorStore
@@ -131,10 +132,12 @@ final class PlayerRoster {
// self vs. remote, so the only safe answer is an empty roster. The
// next refresh (after AuthorIdentity populates) will do the real work.
guard let localAuthorID = authorIdentity.currentID else {
+ self.localAuthorID = nil
entries = []
remoteSelections = [:]
return
}
+ self.localAuthorID = localAuthorID
// Pull Core Data fields off a background context.
let ctx = persistence.container.newBackgroundContext()
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -3,7 +3,7 @@ import SwiftUI
struct PuzzleView: View {
@Bindable var session: PlayerSession
var shareController: ShareController? = nil
- var roster: PlayerRoster? = nil
+ let roster: PlayerRoster
var onComplete: (() -> Void)? = nil
var onResign: (() throws -> Void)? = nil
var onDelete: (() throws -> Void)? = nil
@@ -432,7 +432,7 @@ struct PuzzleView: View {
private struct PuzzleScoreboard: View {
@Bindable var session: PlayerSession
- let roster: PlayerRoster?
+ let roster: PlayerRoster
@Environment(PlayerPreferences.self) private var preferences
private struct Score: Identifiable {
@@ -523,11 +523,11 @@ private struct PuzzleScoreboard: View {
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[square.letterAuthorID, default: 0] += 1
+ counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1
}
}
- let entries = roster?.entries ?? []
+ let entries = roster.entries
let usesLocalFallback = entries.isEmpty
let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) })
let rosterAuthorIDs = Set(entries.map(\.authorID))
@@ -576,6 +576,14 @@ private struct PuzzleScoreboard: View {
}
}
+ private func normalizedAuthorID(_ authorID: String?) -> String? {
+ guard let authorID,
+ let localAuthorID = roster.localAuthorID,
+ authorID == localAuthorID
+ else { return authorID }
+ return nil
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Players")
@@ -616,7 +624,7 @@ private struct PuzzleScoreboard: View {
private struct PuzzleToolbarModifier: ViewModifier {
let session: PlayerSession
- let roster: PlayerRoster?
+ let roster: PlayerRoster
let shareController: ShareController?
let isSolved: Bool
let canResign: Bool
@@ -713,7 +721,7 @@ private struct PuzzleToolbarModifier: ViewModifier {
@ViewBuilder
private var playerRosterSection: some View {
Section {
- if let roster, !roster.entries.isEmpty {
+ if !roster.entries.isEmpty {
ForEach(roster.entries) { entry in
Button {} label: {
Label {
@@ -743,9 +751,7 @@ private struct PuzzleToolbarModifier: ViewModifier {
ForEach(PlayerColor.palette) { color in
Button {
preferences.color = color
- if let roster {
- Task { await roster.reassignOnLocalColorChange(newColor: color) }
- }
+ Task { await roster.reassignOnLocalColorChange(newColor: color) }
} label: {
Label {
Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name)
@@ -801,14 +807,13 @@ private struct PuzzleToolbarModifier: ViewModifier {
private struct PuzzleLifecycleModifier: ViewModifier {
let session: PlayerSession
- let roster: PlayerRoster?
+ let roster: PlayerRoster
@Binding var hasSolved: Bool
let onCompletionStateChanged: (Game.CompletionState) -> Void
func body(content: Content) -> some View {
content
.task {
- guard let roster else { return }
await roster.refresh()
}
.onAppear {
diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift
@@ -2,7 +2,7 @@ import SwiftUI
struct SuccessPanel: View {
let session: PlayerSession
- let roster: PlayerRoster?
+ let roster: PlayerRoster
@Environment(PlayerPreferences.self) private var preferences
private struct Contribution: Identifiable {
@@ -50,11 +50,11 @@ struct SuccessPanel: View {
let entry = square.entry
guard !entry.isEmpty else { continue }
if cell.solution != nil, !cell.accepts(entry) { continue }
- counts[square.letterAuthorID, default: 0] += 1
+ counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1
}
}
- let entries = roster?.entries ?? []
+ let entries = roster.entries
let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) })
let hasRemotePlayers = entries.contains { !$0.isLocal }
let usesLocalFallback = entries.isEmpty
@@ -103,6 +103,14 @@ struct SuccessPanel: View {
}
}
+ private func normalizedAuthorID(_ authorID: String?) -> String? {
+ guard let authorID,
+ let localAuthorID = roster.localAuthorID,
+ authorID == localAuthorID
+ else { return authorID }
+ return nil
+ }
+
var body: some View {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .center, spacing: 8) {