commit 2d908a908c5b8cf0488d917156fedcb6b5df452c
parent c627e574ad0468e9b15eede6ae281929b28f1c60
Author: Michael Camilleri <[email protected]>
Date: Fri, 5 Jun 2026 15:05:21 +0900
Log remote peer presence transitions
A co-solve log could show what edits were made but never when a
remote player stopped being present: the only existing signal was
the engagement teardown line, which is cached-lease-derived,
engagement-gated and bounces on reconnect.
PlayerRoster now emits a tracer line on each non-local peer's
present<->absent edge — the same Player.readAt lease gate that shows and
hides their cursor (remoteSelections) and drives the leaseExpiryTask
recompute. A departure prints the lapsed lease and how long ago it
expired, so the log can answer 'when did the peer leave?' directly. It
is deduped via lastPresentAuthors so the interim and post-share
applyRoster of one refresh, plus the lease-expiry refresh, log a
transition only once.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
1 file changed, 47 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -60,6 +60,13 @@ final class PlayerRoster {
/// when the lease lapses — the same heuristic that gates engagement.
private var remoteReadAt: [String: Date] = [:]
+ /// The non-local authorIDs whose lease last read as present, so each
+ /// present↔absent edge is logged once rather than on every refresh. This
+ /// is the same gate that shows/hides a peer's cursor, so the log records
+ /// *when* a remote player actually left — the one thing a co-solve log was
+ /// previously blind to.
+ private var lastPresentAuthors: Set<String> = []
+
var remoteSelections: [String: RemoteSelection] {
var merged = persistedRemoteSelections
let colorByAuthor = Dictionary(
@@ -335,6 +342,46 @@ final class PlayerRoster {
}
persistedRemoteSelections = tracks
remoteReadAt = fetched.readAtByAuthor
+ logPresenceTransitions()
+ }
+
+ /// Emits a tracer line on each non-local peer's present↔absent edge — the
+ /// same `readAt`-lease gate that drives their cursor (`remoteSelections`)
+ /// and the `leaseExpiryTask` recompute. Deduped via `lastPresentAuthors`,
+ /// so the interim and post-share `applyRoster` of one refresh, plus the
+ /// lease-expiry refresh, log a transition only once. A departure prints the
+ /// lapsed lease and how long ago it expired, so the log can answer "when
+ /// did the peer leave?" instead of leaving it to inference.
+ private func logPresenceTransitions() {
+ guard let tracer else { return }
+ let now = Date()
+ let present = Set(remoteReadAt.keys.filter {
+ PeerPresence.isPresent(readAt: remoteReadAt[$0], asOf: now)
+ })
+ guard present != lastPresentAuthors else { return }
+ let nameByAuthor = Dictionary(
+ uniqueKeysWithValues: entries.filter { !$0.isLocal }.map { ($0.authorID, $0.name) }
+ )
+ let prefix = "PlayerRoster[\(gameID.uuidString.prefix(8))]"
+ func describe(_ authorID: String) -> String {
+ "\(authorID.prefix(8)) (\(nameByAuthor[authorID] ?? "?"))"
+ }
+ func iso(_ date: Date) -> String {
+ ISO8601DateFormatter().string(from: date)
+ }
+ for authorID in present.subtracting(lastPresentAuthors).sorted() {
+ let lease = remoteReadAt[authorID]
+ let until = lease.map(iso) ?? "—"
+ let secs = lease.map { Int($0.timeIntervalSince(now)) } ?? 0
+ tracer("\(prefix): peer \(describe(authorID)) present (lease until \(until), +\(secs)s)")
+ }
+ for authorID in lastPresentAuthors.subtracting(present).sorted() {
+ let lease = remoteReadAt[authorID]
+ let lapsed = lease.map(iso) ?? "—"
+ let ago = lease.map { Int(now.timeIntervalSince($0)) } ?? 0
+ tracer("\(prefix): peer \(describe(authorID)) no longer present (lease \(lapsed) lapsed \(ago)s ago)")
+ }
+ lastPresentAuthors = present
}
/// Schedules a single recompute at the soonest future peer lease expiry.