commit 9f285f3280df751270cfb225671d7fd76cf6dddd
parent 70aa522b5e93119b2f42f89931a10e68996f4911
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 03:20:16 +0900
Cache PlayerRoster solve times for rendering
This commit removes the synchronous Core Data fetches from
PlayerRoster.solveTime. The puzzle header and Success Panel can ask for
the clock during timeline-driven rendering, and the previous path
fetched the GameEntity and PlayerEntity rows from viewContext on every
read.
PlayerRoster.refresh now snapshots the final solve seconds, completion
date, and decoded player time logs while it is already reading roster
state on a background context. solveTime then reads only that cached
snapshot, preserving the materialised archive clock and completed-game
freeze without touching Core Data from the render path.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
2 files changed, 100 insertions(+), 16 deletions(-)
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -51,6 +51,9 @@ final class PlayerRoster {
var moveAuthorIDs: [String] = []
var rawSelections: [RawSelection] = []
var readAtByAuthor: [String: Date] = [:]
+ var finalSolveSeconds: Int64?
+ var completedAt: Date?
+ var timeLogs: [TimeLog] = []
}
/// Last-known peer cursor tracks keyed by `authorID`, from the synced
@@ -64,6 +67,9 @@ final class PlayerRoster {
/// peer who pauses keeps their cursor and a departed peer's cursor clears
/// when the lease lapses — the same heuristic that gates engagement.
private var remoteReadAt: [String: Date] = [:]
+ private var finalSolveSeconds: Int64?
+ private var completedAt: Date?
+ private var timeLogs: [TimeLog] = []
/// The non-local authorIDs whose lease last read as present, so each
/// present↔absent edge is logged once rather than on every refresh. This
@@ -106,24 +112,14 @@ final class PlayerRoster {
/// bounded at `completedAt`, freezing the displayed value at the win.
func solveTime(asOf now: Date = Date()) -> TimeInterval {
guard !isStaticPreview else { return 0 }
- let context = persistence.container.viewContext
-
- let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- gameRequest.fetchLimit = 1
- let game = try? context.fetch(gameRequest).first
// A materialised archive has no `timeLog` rows to union — it carries the
// frozen final time (whole seconds) the live clock reached.
- if let stored = game?.finalSolveSeconds {
- return TimeInterval(stored.int64Value)
+ if let finalSolveSeconds {
+ return TimeInterval(finalSolveSeconds)
}
- let asOf = game?.completedAt.map { min(now, $0) } ?? now
-
- let playerRequest = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- playerRequest.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
- let logs = ((try? context.fetch(playerRequest)) ?? []).map { TimeLog.decode($0.timeLog) }
+ let asOf = completedAt.map { min(now, $0) } ?? now
return TimeLog.accumulatedSeconds(
- forLogs: logs,
+ forLogs: timeLogs,
localDeviceID: RecordSerializer.localDeviceID,
asOf: asOf
)
@@ -245,6 +241,9 @@ final class PlayerRoster {
entries = []
persistedRemoteSelections = [:]
remoteReadAt = [:]
+ finalSolveSeconds = nil
+ completedAt = nil
+ timeLogs = []
leaseExpiryTask?.cancel()
leaseExpiryTask = nil
return
@@ -268,7 +267,9 @@ final class PlayerRoster {
var namesMap: [String: String] = [:]
var selections: [RawSelection] = []
var readAtByAuthor: [String: Date] = [:]
+ var timeLogs: [TimeLog] = []
for nr in nameEntities {
+ timeLogs.append(TimeLog.decode(nr.timeLog))
guard let aid = nr.authorID, !aid.isEmpty else { continue }
if let name = nr.name, !name.isEmpty {
namesMap[aid] = name
@@ -318,7 +319,10 @@ final class PlayerRoster {
nicknamesByAuthor: nicknamesByAuthor,
moveAuthorIDs: authorIDs,
rawSelections: selections,
- readAtByAuthor: readAtByAuthor
+ readAtByAuthor: readAtByAuthor,
+ finalSolveSeconds: entity.finalSolveSeconds?.int64Value,
+ completedAt: entity.completedAt,
+ timeLogs: timeLogs
)
}
@@ -418,6 +422,9 @@ final class PlayerRoster {
}
persistedRemoteSelections = tracks
remoteReadAt = fetched.readAtByAuthor
+ finalSolveSeconds = fetched.finalSolveSeconds
+ completedAt = fetched.completedAt
+ timeLogs = fetched.timeLogs
logPresenceTransitions()
}
diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift
@@ -63,7 +63,8 @@ struct PlayerRosterTests {
persistence: PersistenceController,
selection: PlayerSelection? = nil,
updatedAt: Date = Date(),
- readAt: Date? = Date().addingTimeInterval(600)
+ readAt: Date? = Date().addingTimeInterval(600),
+ timeLog: TimeLog? = nil
) {
let ctx = persistence.viewContext
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -76,6 +77,9 @@ struct PlayerRosterTests {
player.ckRecordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
player.updatedAt = updatedAt
player.readAt = readAt
+ if let timeLog {
+ player.timeLog = TimeLog.encode(timeLog)
+ }
if let selection {
player.selRow = NSNumber(value: selection.row)
player.selCol = NSNumber(value: selection.col)
@@ -84,6 +88,23 @@ struct PlayerRosterTests {
try? ctx.save()
}
+ private func updateGame(
+ gameID: UUID,
+ persistence: PersistenceController,
+ completedAt: Date? = nil,
+ finalSolveSeconds: Int64? = nil
+ ) {
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ guard let game = try? ctx.fetch(req).first else { return }
+ game.completedAt = completedAt
+ if let finalSolveSeconds {
+ game.finalSolveSeconds = NSNumber(value: finalSolveSeconds)
+ }
+ try? ctx.save()
+ }
+
private func makeRoster(
gameID: UUID,
persistence: PersistenceController,
@@ -291,4 +312,60 @@ struct PlayerRosterTests {
#expect(selection?.row == 1, "a present peer keeps their cursor even when the selection is old")
#expect(selection?.col == 2)
}
+
+ @Test("Solve time is read from the refreshed roster snapshot")
+ func solveTimeUsesRefreshedSnapshot() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let start = Date(timeIntervalSinceReferenceDate: 1_000)
+ var log = TimeLog()
+ log.open(deviceID: RecordSerializer.localDeviceID, at: start)
+ addPlayerEntity(
+ authorID: "_Local",
+ name: "Me",
+ gameID: gameID,
+ persistence: persistence,
+ timeLog: log
+ )
+
+ let roster = makeRoster(gameID: gameID, persistence: persistence)
+ #expect(roster.solveTime(asOf: start.addingTimeInterval(60)) == 0)
+
+ await roster.refresh()
+
+ #expect(roster.solveTime(asOf: start.addingTimeInterval(60)) == 60)
+ #expect(roster.solveTime(asOf: start.addingTimeInterval(90)) == 90)
+ }
+
+ @Test("Solve time freezes at completion")
+ func solveTimeFreezesAtCompletion() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let start = Date(timeIntervalSinceReferenceDate: 2_000)
+ let completedAt = start.addingTimeInterval(75)
+ var log = TimeLog()
+ log.open(deviceID: RecordSerializer.localDeviceID, at: start)
+ addPlayerEntity(
+ authorID: "_Local",
+ name: "Me",
+ gameID: gameID,
+ persistence: persistence,
+ timeLog: log
+ )
+ updateGame(gameID: gameID, persistence: persistence, completedAt: completedAt)
+
+ let roster = makeRoster(gameID: gameID, persistence: persistence)
+ await roster.refresh()
+
+ #expect(roster.solveTime(asOf: start.addingTimeInterval(300)) == 75)
+ }
+
+ @Test("Materialized archive solve time uses final seconds")
+ func materializedArchiveSolveTimeUsesFinalSeconds() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ updateGame(gameID: gameID, persistence: persistence, finalSolveSeconds: 743)
+
+ let roster = makeRoster(gameID: gameID, persistence: persistence)
+ await roster.refresh()
+
+ #expect(roster.solveTime(asOf: Date(timeIntervalSinceReferenceDate: 3_000)) == 743)
+ }
}