TimeLog.swift (10161B)
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 now 77 ) 78 if boundedEnd > openStart { 79 slot.intervals = Self.inserting( 80 Interval(start: openStart, end: boundedEnd), 81 into: slot.intervals 82 ) 83 } 84 slot.openStart = now 85 } 86 } else { 87 slot.openStart = now 88 } 89 slot.beatAt = now 90 devices[deviceID] = slot 91 } 92 93 /// Seals `deviceID`'s open session into a sealed interval and clears the open 94 /// marker. No-op (but still beats) if nothing is open. 95 mutating func seal(deviceID: String, at now: Date) { 96 var slot = devices[deviceID] ?? DeviceLog() 97 if let start = slot.openStart, now > start { 98 slot.intervals = Self.inserting(Interval(start: start, end: now), into: slot.intervals) 99 } 100 slot.openStart = nil 101 slot.beatAt = now 102 devices[deviceID] = slot 103 } 104 105 /// Refreshes `deviceID`'s liveness heartbeat. A no-op unless a session is 106 /// open — a heartbeat only means "I'm still in my current sitting," so it 107 /// neither starts a session nor creates a bare slot. 108 mutating func beat(deviceID: String, at now: Date) { 109 guard var slot = devices[deviceID], slot.openStart != nil else { return } 110 slot.beatAt = max(slot.beatAt ?? now, now) 111 devices[deviceID] = slot 112 } 113 114 /// Adopts another author's whole device map. Used when applying an inbound 115 /// record for a *different* author (no local slots to protect). 116 mutating func adoptAll(from other: TimeLog) { 117 devices = other.devices 118 } 119 120 /// Merges an inbound copy of the *local* author's record: adopts every device 121 /// slot except this device's own, which the local writer owns and must not 122 /// have clobbered by a sibling's stale copy. 123 mutating func merge(inbound: TimeLog, preservingDevice deviceID: String) { 124 var merged = inbound.devices 125 if let mine = devices[deviceID] { 126 merged[deviceID] = mine 127 } 128 devices = merged 129 } 130 131 // MARK: - Accumulation (union across all players) 132 133 /// Total active solving time across `logs` (every player's record for the 134 /// game) as of `now`: the length of the union of all sealed intervals plus 135 /// each open session extrapolated to a bounded end. `localDeviceID`'s own 136 /// open session is trusted live to `now`; every other open session is capped 137 /// at its last heartbeat plus `openGrace`. All open sessions are additionally 138 /// capped at `maxSessionCap` from their start. 139 static func accumulatedSeconds( 140 forLogs logs: [TimeLog], 141 localDeviceID: String, 142 asOf now: Date = Date() 143 ) -> TimeInterval { 144 var intervals: [Interval] = [] 145 for log in logs { 146 for (deviceID, slot) in log.devices { 147 intervals.append(contentsOf: slot.intervals) 148 guard let start = slot.openStart else { continue } 149 let hardCap = start.addingTimeInterval(maxSessionCap) 150 let liveEnd: Date 151 if deviceID == localDeviceID { 152 liveEnd = now 153 } else { 154 liveEnd = min(now, (slot.beatAt ?? start).addingTimeInterval(openGrace)) 155 } 156 let end = min(liveEnd, hardCap) 157 if end > start { 158 intervals.append(Interval(start: start, end: end)) 159 } 160 } 161 } 162 return unionSeconds(intervals) 163 } 164 165 /// Length of the union of `intervals` (overlaps merged, then summed). 166 static func unionSeconds(_ intervals: [Interval]) -> TimeInterval { 167 let sorted = intervals 168 .filter { $0.end > $0.start } 169 .sorted { $0.start < $1.start } 170 guard let first = sorted.first else { return 0 } 171 var total: TimeInterval = 0 172 var curStart = first.start 173 var curEnd = first.end 174 for iv in sorted.dropFirst() { 175 if iv.start <= curEnd { 176 curEnd = max(curEnd, iv.end) 177 } else { 178 total += curEnd.timeIntervalSince(curStart) 179 curStart = iv.start 180 curEnd = iv.end 181 } 182 } 183 total += curEnd.timeIntervalSince(curStart) 184 return total 185 } 186 187 /// Inserts `interval` into a device's own (disjoint) interval list, keeping it 188 /// sorted by start and coalescing any that touch or overlap — defensive, since 189 /// a single device's sessions don't normally overlap. 190 private static func inserting(_ interval: Interval, into list: [Interval]) -> [Interval] { 191 var merged = unionMerged(list + [interval]) 192 merged.sort { $0.start < $1.start } 193 return merged 194 } 195 196 /// The merged (overlap-collapsed) intervals themselves, not just their length. 197 private static func unionMerged(_ intervals: [Interval]) -> [Interval] { 198 let sorted = intervals 199 .filter { $0.end > $0.start } 200 .sorted { $0.start < $1.start } 201 guard let first = sorted.first else { return [] } 202 var result: [Interval] = [] 203 var cur = first 204 for iv in sorted.dropFirst() { 205 if iv.start <= cur.end { 206 cur.end = max(cur.end, iv.end) 207 } else { 208 result.append(cur) 209 cur = iv 210 } 211 } 212 result.append(cur) 213 return result 214 } 215 216 // MARK: - Codec 217 218 static func encode(_ log: TimeLog) -> Data { 219 (try? JSONEncoder().encode(log)) ?? Data() 220 } 221 222 /// Parse-tolerant: a missing/empty/old-format payload decodes to an empty 223 /// log (contributes zero), so the clock is safe before the schema deploy. 224 static func decode(_ data: Data?) -> TimeLog { 225 guard let data, !data.isEmpty, 226 let log = try? JSONDecoder().decode(TimeLog.self, from: data) 227 else { return TimeLog() } 228 return log 229 } 230 231 // MARK: - Display 232 233 /// A solve duration as `M:SS`, growing to `H:MM:SS` once past an hour. 234 /// Shared by the live header clock and the finish panel so both read alike. 235 static func clockString(_ seconds: TimeInterval) -> String { 236 let total = max(0, Int(seconds)) 237 let hours = total / 3600 238 let minutes = (total % 3600) / 60 239 let secs = total % 60 240 if hours > 0 { 241 return String(format: "%d:%02d:%02d", hours, minutes, secs) 242 } 243 return String(format: "%d:%02d", minutes, secs) 244 } 245 }