TimeLog.swift (10136B)
1 import Foundation 2 3 /// Per-device record of active solving time for one player in one game, encoded 4 /// into the `Player` record's `timeLog` field. 5 /// 6 /// The displayed game clock is the length of the **union** of every player's 7 /// intervals across the whole game — simultaneous play counts once, disjoint 8 /// play sums, never double-counted. An in-progress session (`openStart` set) 9 /// extrapolates to "now" so the clock ticks live and a co-solver who joins 10 /// mid-session immediately sees time that already reflects everyone present. 11 /// 12 /// Slots are keyed by `deviceID` (not merely per-author) so a single account's 13 /// two devices never last-writer-wins clobber each other's history: each device 14 /// only ever mutates its own slot, and a reader unions across them. 15 struct TimeLog: Codable, Equatable { 16 var devices: [String: DeviceLog] 17 18 init(devices: [String: DeviceLog] = [:]) { 19 self.devices = devices 20 } 21 22 /// One device's contribution: its sealed (disjoint, sorted) intervals plus, 23 /// while a session is open, the start of that session and the last liveness 24 /// write. `beatAt` bounds how far a peer extrapolates an `openStart` it can't 25 /// see close — a force-quit/crash leaves `openStart` set forever, so a stale 26 /// beat caps the open interval rather than letting it run to infinity. 27 struct DeviceLog: Codable, Equatable { 28 var intervals: [Interval] 29 var openStart: Date? 30 var beatAt: Date? 31 32 init(intervals: [Interval] = [], openStart: Date? = nil, beatAt: Date? = nil) { 33 self.intervals = intervals 34 self.openStart = openStart 35 self.beatAt = beatAt 36 } 37 } 38 39 struct Interval: Codable, Equatable { 40 var start: Date 41 var end: Date 42 } 43 44 // MARK: - Config 45 46 /// How long after a peer's last `beatAt` its still-open session keeps 47 /// extrapolating to "now". Sized to comfortably exceed the heartbeat cadence 48 /// (`SessionCoordinator.clockHeartbeatInterval`) plus sync propagation, so a 49 /// live peer mid-session is never prematurely cut off, while a crashed one 50 /// stops accruing within a few minutes. 51 static let openGrace: TimeInterval = 4 * 60 52 53 /// Hard ceiling on a single open session. A never-sealed session (the app was 54 /// killed without a clean leave) can't inflate the clock past this. 55 static let maxSessionCap: TimeInterval = 6 * 60 * 60 56 57 // MARK: - Single-device mutation (local writer only) 58 59 /// Opens a session for `deviceID`. Idempotent across resumes within one app 60 /// run — only the first call of a sitting stamps `openStart`; later ones just 61 /// refresh the heartbeat. 62 /// 63 /// `reconcileStale` is set on the first open of a game since app launch. A 64 /// session still open at that point was never sealed — the previous run was 65 /// force-quit or crashed — so it is banked bounded at its last heartbeat 66 /// (mirroring how a peer bounds it) rather than letting the dead gap until 67 /// `now` count, then a fresh session starts. Within a run it is left false, so 68 /// an ordinary resume keeps the continuing sitting intact. 69 mutating func open(deviceID: String, at now: Date, reconcileStale: Bool = false) { 70 var slot = devices[deviceID] ?? DeviceLog() 71 if let openStart = slot.openStart { 72 if reconcileStale { 73 let boundedEnd = min( 74 (slot.beatAt ?? openStart).addingTimeInterval(Self.openGrace), 75 openStart.addingTimeInterval(Self.maxSessionCap) 76 ) 77 if boundedEnd > openStart { 78 slot.intervals = Self.inserting( 79 Interval(start: openStart, end: boundedEnd), 80 into: slot.intervals 81 ) 82 } 83 slot.openStart = now 84 } 85 } else { 86 slot.openStart = now 87 } 88 slot.beatAt = now 89 devices[deviceID] = slot 90 } 91 92 /// Seals `deviceID`'s open session into a sealed interval and clears the open 93 /// marker. No-op (but still beats) if nothing is open. 94 mutating func seal(deviceID: String, at now: Date) { 95 var slot = devices[deviceID] ?? DeviceLog() 96 if let start = slot.openStart, now > start { 97 slot.intervals = Self.inserting(Interval(start: start, end: now), into: slot.intervals) 98 } 99 slot.openStart = nil 100 slot.beatAt = now 101 devices[deviceID] = slot 102 } 103 104 /// Refreshes `deviceID`'s liveness heartbeat. A no-op unless a session is 105 /// open — a heartbeat only means "I'm still in my current sitting," so it 106 /// neither starts a session nor creates a bare slot. 107 mutating func beat(deviceID: String, at now: Date) { 108 guard var slot = devices[deviceID], slot.openStart != nil else { return } 109 slot.beatAt = max(slot.beatAt ?? now, now) 110 devices[deviceID] = slot 111 } 112 113 /// Adopts another author's whole device map. Used when applying an inbound 114 /// record for a *different* author (no local slots to protect). 115 mutating func adoptAll(from other: TimeLog) { 116 devices = other.devices 117 } 118 119 /// Merges an inbound copy of the *local* author's record: adopts every device 120 /// slot except this device's own, which the local writer owns and must not 121 /// have clobbered by a sibling's stale copy. 122 mutating func merge(inbound: TimeLog, preservingDevice deviceID: String) { 123 var merged = inbound.devices 124 if let mine = devices[deviceID] { 125 merged[deviceID] = mine 126 } 127 devices = merged 128 } 129 130 // MARK: - Accumulation (union across all players) 131 132 /// Total active solving time across `logs` (every player's record for the 133 /// game) as of `now`: the length of the union of all sealed intervals plus 134 /// each open session extrapolated to a bounded end. `localDeviceID`'s own 135 /// open session is trusted live to `now`; every other open session is capped 136 /// at its last heartbeat plus `openGrace`. All open sessions are additionally 137 /// capped at `maxSessionCap` from their start. 138 static func accumulatedSeconds( 139 forLogs logs: [TimeLog], 140 localDeviceID: String, 141 asOf now: Date = Date() 142 ) -> TimeInterval { 143 var intervals: [Interval] = [] 144 for log in logs { 145 for (deviceID, slot) in log.devices { 146 intervals.append(contentsOf: slot.intervals) 147 guard let start = slot.openStart else { continue } 148 let hardCap = start.addingTimeInterval(maxSessionCap) 149 let liveEnd: Date 150 if deviceID == localDeviceID { 151 liveEnd = now 152 } else { 153 liveEnd = min(now, (slot.beatAt ?? start).addingTimeInterval(openGrace)) 154 } 155 let end = min(liveEnd, hardCap) 156 if end > start { 157 intervals.append(Interval(start: start, end: end)) 158 } 159 } 160 } 161 return unionSeconds(intervals) 162 } 163 164 /// Length of the union of `intervals` (overlaps merged, then summed). 165 static func unionSeconds(_ intervals: [Interval]) -> TimeInterval { 166 let sorted = intervals 167 .filter { $0.end > $0.start } 168 .sorted { $0.start < $1.start } 169 guard let first = sorted.first else { return 0 } 170 var total: TimeInterval = 0 171 var curStart = first.start 172 var curEnd = first.end 173 for iv in sorted.dropFirst() { 174 if iv.start <= curEnd { 175 curEnd = max(curEnd, iv.end) 176 } else { 177 total += curEnd.timeIntervalSince(curStart) 178 curStart = iv.start 179 curEnd = iv.end 180 } 181 } 182 total += curEnd.timeIntervalSince(curStart) 183 return total 184 } 185 186 /// Inserts `interval` into a device's own (disjoint) interval list, keeping it 187 /// sorted by start and coalescing any that touch or overlap — defensive, since 188 /// a single device's sessions don't normally overlap. 189 private static func inserting(_ interval: Interval, into list: [Interval]) -> [Interval] { 190 var merged = unionMerged(list + [interval]) 191 merged.sort { $0.start < $1.start } 192 return merged 193 } 194 195 /// The merged (overlap-collapsed) intervals themselves, not just their length. 196 private static func unionMerged(_ intervals: [Interval]) -> [Interval] { 197 let sorted = intervals 198 .filter { $0.end > $0.start } 199 .sorted { $0.start < $1.start } 200 guard let first = sorted.first else { return [] } 201 var result: [Interval] = [] 202 var cur = first 203 for iv in sorted.dropFirst() { 204 if iv.start <= cur.end { 205 cur.end = max(cur.end, iv.end) 206 } else { 207 result.append(cur) 208 cur = iv 209 } 210 } 211 result.append(cur) 212 return result 213 } 214 215 // MARK: - Codec 216 217 static func encode(_ log: TimeLog) -> Data { 218 (try? JSONEncoder().encode(log)) ?? Data() 219 } 220 221 /// Parse-tolerant: a missing/empty/old-format payload decodes to an empty 222 /// log (contributes zero), so the clock is safe before the schema deploy. 223 static func decode(_ data: Data?) -> TimeLog { 224 guard let data, !data.isEmpty, 225 let log = try? JSONDecoder().decode(TimeLog.self, from: data) 226 else { return TimeLog() } 227 return log 228 } 229 230 // MARK: - Display 231 232 /// A solve duration as `M:SS`, growing to `H:MM:SS` once past an hour. 233 /// Shared by the live header clock and the finish panel so both read alike. 234 static func clockString(_ seconds: TimeInterval) -> String { 235 let total = max(0, Int(seconds)) 236 let hours = total / 3600 237 let minutes = (total % 3600) / 60 238 let secs = total % 60 239 if hours > 0 { 240 return String(format: "%d:%02d:%02d", hours, minutes, secs) 241 } 242 return String(format: "%d:%02d", minutes, secs) 243 } 244 }