crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit c36457c8cbfcddca0e359b95c2f87e09c040d72e
parent 9fb76f64c1b0134e7ab842f2f19763bcef5e7971
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 17:09:43 +0900

Add a synced solve-time clock

The puzzle header gains a clock that counts active solving time — time
spent with the puzzle open, with idle and overnight gaps excluded — on
its own page between the scoreboard and the credits, ticking up live
under 'Solving time'. The Success Panel reports the same figure once the
game ends, frozen at completion as 'Finished in …'. In a shared game the
clock is the union of every player's active intervals, so two people
solving at once count as one stretch of time rather than two, and a
player who joins a session already in progress sees the elapsed time
straight away instead of watching it jump when a co-solver leaves.

Each device records its own play intervals into a new timeLog field on
its Player record, keyed by device so an account's siblings never
clobber one another; the displayed figure is the length of the union of
those intervals across all players, with an open session extrapolated to
now and a periodic heartbeat keeping a co-solver's still-open session
from being capped. The session is opened and sealed from the puzzle
open/leave lifecycle rather than the presence lease — which runs only
for shared games — so a solo game accrues time too. When no iCloud
identity has resolved, a solo game on a device not signed in, the write
falls back to CKCurrentUserDefaultName and stays local, so the clock
still counts without syncing.

This adds a timeLog Bytes field to the Player record type. A record
without it reads as a zero contribution, so an out-of-date collaborator
who never writes the field is omitted from the union rather than
breaking it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/CrossmateApp.swift | 14++++++++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Models/PlayerRoster.swift | 26++++++++++++++++++++++++++
ACrossmate/Models/TimeLog.swift | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Persistence/GameStore.swift | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/SessionCoordinator.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordApplier.swift | 21+++++++++++++++++++++
MCrossmate/Sync/RecordBuilder.swift | 1+
MCrossmate/Sync/RecordSerializer.swift | 18++++++++++++++++++
MCrossmate/Views/Puzzle/PuzzleHeader.swift | 30++++++++++++++++++++++++++++++
MCrossmate/Views/Puzzle/SuccessPanel.swift | 22+++++++++++++++++-----
ATests/Unit/TimeLogTests.swift | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 786 insertions(+), 5 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -153,6 +153,7 @@ AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; B00743DAF8F46F14CE13E909 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 298A9C54A1CC753E860E174E /* FriendsView.swift */; }; + B0170C8927EDD2E43F849204 /* TimeLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFFB5B2EFBB62B7021AC2FC2 /* TimeLog.swift */; }; B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */; }; B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */; }; B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; }; @@ -204,6 +205,7 @@ EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; + F15591B48E4155CB19C1F084 /* TimeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */; }; F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF6E3F3558E128E7A482A61 /* PushRequestAuthenticator.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; @@ -388,6 +390,7 @@ ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleHeader.swift; sourceTree = "<group>"; }; AF3B7E191D571FD800A4D719 /* LastUpdatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastUpdatedView.swift; sourceTree = "<group>"; }; + AFFB5B2EFBB62B7021AC2FC2 /* TimeLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLog.swift; sourceTree = "<group>"; }; B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; }; B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; }; B09D52DB46731E92C3E9297C /* EngagementStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementStore.swift; sourceTree = "<group>"; }; @@ -413,6 +416,7 @@ C06E2CC3A77CB306BD2DF867 /* LogScrubberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScrubberTests.swift; sourceTree = "<group>"; }; C2C9D3E7FCE2D42C5B7E3856 /* PushPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayload.swift; sourceTree = "<group>"; }; C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; }; + C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLogTests.swift; sourceTree = "<group>"; }; C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipStoreTests.swift; sourceTree = "<group>"; }; C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; }; CA49DA9E0ADEB11A920787FA /* SessionPushPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPushPlanner.swift; sourceTree = "<group>"; }; @@ -583,6 +587,7 @@ 5990D989AD745211A18848E4 /* ShareLinkRouteTests.swift */, 057F2B8B8A894D08BB801219 /* ShareLinkShortenerTests.swift */, 0C190EA5717C291B3F2AE46C /* SyncMonitorTests.swift */, + C7F35A7BFE52279BC24677F5 /* TimeLogTests.swift */, C8D6991C1EBAB2C64D9DF669 /* TipStoreTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, F9FE0A9624DB87758F3D1768 /* XDMarkupTests.swift */, @@ -616,6 +621,7 @@ E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */, 50EE8A159CC553623F6F7DE4 /* ReplayControls.swift */, DB851649DE78AAAC5A928C52 /* Square.swift */, + AFFB5B2EFBB62B7021AC2FC2 /* TimeLog.swift */, 8D4A76B233E16B7C5A248EB7 /* TipStore.swift */, B9031A1574C21866940F6A2C /* XD.swift */, EAC61E2582D94B1E6EC67136 /* XDFileType.swift */, @@ -1024,6 +1030,7 @@ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 025377AF80D45967CE910423 /* SyncMonitorTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, + F15591B48E4155CB19C1F084 /* TimeLogTests.swift in Sources */, 9AD5700398B1C1F29A3A75F6 /* TipStoreTests.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, 786813F3418C32EFBF296220 /* XDMarkupTests.swift in Sources */, @@ -1159,6 +1166,7 @@ DDC7994B951A3A7B836B36F6 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */, + B0170C8927EDD2E43F849204 /* TimeLog.swift in Sources */, 35D97436772257DAD3936ECB /* TipStore.swift in Sources */, 3C54B672A9FCA98C0A304470 /* TipsArchive.swift in Sources */, 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -883,6 +883,20 @@ private struct PuzzleDisplayView: View { } } } + .task(id: session?.mutator.isShared == true) { + // Solve-clock liveness heartbeat. Only for a shared game on screen: + // a co-solver extrapolates this device's open session toward now only + // as far as its last beat, so a continuous sitting must keep beating + // or it would be briefly capped on their clock. Solo games skip it — + // the local clock already extrapolates to now and no peer is watching. + // Cancelled on leave or gameID change. + guard session?.mutator.isShared == true else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(SessionCoordinator.clockHeartbeatInterval)) + guard !Task.isCancelled else { break } + services.sessions.noteClockHeartbeat(gameID: gameID) + } + } .onChange(of: session?.mutator.isShared) { oldValue, newValue in // Fire only on a definite `false → true` transition — that's the // mid-session share-create case. Initial loads of an already-shared diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -53,6 +53,7 @@ <attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="selRow" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="sessionSnapshot" optional="YES" attributeType="Binary"/> + <attribute name="timeLog" optional="YES" attributeType="Binary"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="players" inverseEntity="GameEntity"/> <fetchIndex name="byGameAndAuthor"> diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -98,6 +98,32 @@ final class PlayerRoster { private(set) var entries: [Entry] = [] private(set) var localAuthorID: String? + /// Active solve time across every player in this game as of `now` — the + /// length of the union of all devices' play intervals, so simultaneous + /// co-solving counts once and disjoint play sums. In-progress sessions are + /// extrapolated to `now`, so the puzzle clock ticks between syncs simply by + /// re-reading this with a fresh date. Once the game is finished the union is + /// 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 completedAt = (try? context.fetch(gameRequest).first)?.completedAt + let asOf = 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) } + return TimeLog.accumulatedSeconds( + forLogs: logs, + localDeviceID: RecordSerializer.localDeviceID, + asOf: asOf + ) + } + private let gameID: UUID private let authorIdentity: AuthorIdentity private let preferences: PlayerPreferences diff --git a/Crossmate/Models/TimeLog.swift b/Crossmate/Models/TimeLog.swift @@ -0,0 +1,244 @@ +import Foundation + +/// Per-device record of active solving time for one player in one game, encoded +/// into the `Player` record's `timeLog` field. +/// +/// The displayed game clock is the length of the **union** of every player's +/// intervals across the whole game — simultaneous play counts once, disjoint +/// play sums, never double-counted. An in-progress session (`openStart` set) +/// extrapolates to "now" so the clock ticks live and a co-solver who joins +/// mid-session immediately sees time that already reflects everyone present. +/// +/// Slots are keyed by `deviceID` (not merely per-author) so a single account's +/// two devices never last-writer-wins clobber each other's history: each device +/// only ever mutates its own slot, and a reader unions across them. +struct TimeLog: Codable, Equatable { + var devices: [String: DeviceLog] + + init(devices: [String: DeviceLog] = [:]) { + self.devices = devices + } + + /// One device's contribution: its sealed (disjoint, sorted) intervals plus, + /// while a session is open, the start of that session and the last liveness + /// write. `beatAt` bounds how far a peer extrapolates an `openStart` it can't + /// see close — a force-quit/crash leaves `openStart` set forever, so a stale + /// beat caps the open interval rather than letting it run to infinity. + struct DeviceLog: Codable, Equatable { + var intervals: [Interval] + var openStart: Date? + var beatAt: Date? + + init(intervals: [Interval] = [], openStart: Date? = nil, beatAt: Date? = nil) { + self.intervals = intervals + self.openStart = openStart + self.beatAt = beatAt + } + } + + struct Interval: Codable, Equatable { + var start: Date + var end: Date + } + + // MARK: - Config + + /// How long after a peer's last `beatAt` its still-open session keeps + /// extrapolating to "now". Sized to comfortably exceed the heartbeat cadence + /// (`SessionCoordinator.clockHeartbeatInterval`) plus sync propagation, so a + /// live peer mid-session is never prematurely cut off, while a crashed one + /// stops accruing within a few minutes. + static let openGrace: TimeInterval = 4 * 60 + + /// Hard ceiling on a single open session. A never-sealed session (the app was + /// killed without a clean leave) can't inflate the clock past this. + static let maxSessionCap: TimeInterval = 6 * 60 * 60 + + // MARK: - Single-device mutation (local writer only) + + /// Opens a session for `deviceID`. Idempotent across resumes within one app + /// run — only the first call of a sitting stamps `openStart`; later ones just + /// refresh the heartbeat. + /// + /// `reconcileStale` is set on the first open of a game since app launch. A + /// session still open at that point was never sealed — the previous run was + /// force-quit or crashed — so it is banked bounded at its last heartbeat + /// (mirroring how a peer bounds it) rather than letting the dead gap until + /// `now` count, then a fresh session starts. Within a run it is left false, so + /// an ordinary resume keeps the continuing sitting intact. + mutating func open(deviceID: String, at now: Date, reconcileStale: Bool = false) { + var slot = devices[deviceID] ?? DeviceLog() + if let openStart = slot.openStart { + if reconcileStale { + let boundedEnd = min( + (slot.beatAt ?? openStart).addingTimeInterval(Self.openGrace), + openStart.addingTimeInterval(Self.maxSessionCap) + ) + if boundedEnd > openStart { + slot.intervals = Self.inserting( + Interval(start: openStart, end: boundedEnd), + into: slot.intervals + ) + } + slot.openStart = now + } + } else { + slot.openStart = now + } + slot.beatAt = now + devices[deviceID] = slot + } + + /// Seals `deviceID`'s open session into a sealed interval and clears the open + /// marker. No-op (but still beats) if nothing is open. + mutating func seal(deviceID: String, at now: Date) { + var slot = devices[deviceID] ?? DeviceLog() + if let start = slot.openStart, now > start { + slot.intervals = Self.inserting(Interval(start: start, end: now), into: slot.intervals) + } + slot.openStart = nil + slot.beatAt = now + devices[deviceID] = slot + } + + /// Refreshes `deviceID`'s liveness heartbeat. A no-op unless a session is + /// open — a heartbeat only means "I'm still in my current sitting," so it + /// neither starts a session nor creates a bare slot. + mutating func beat(deviceID: String, at now: Date) { + guard var slot = devices[deviceID], slot.openStart != nil else { return } + slot.beatAt = max(slot.beatAt ?? now, now) + devices[deviceID] = slot + } + + /// Adopts another author's whole device map. Used when applying an inbound + /// record for a *different* author (no local slots to protect). + mutating func adoptAll(from other: TimeLog) { + devices = other.devices + } + + /// Merges an inbound copy of the *local* author's record: adopts every device + /// slot except this device's own, which the local writer owns and must not + /// have clobbered by a sibling's stale copy. + mutating func merge(inbound: TimeLog, preservingDevice deviceID: String) { + var merged = inbound.devices + if let mine = devices[deviceID] { + merged[deviceID] = mine + } + devices = merged + } + + // MARK: - Accumulation (union across all players) + + /// Total active solving time across `logs` (every player's record for the + /// game) as of `now`: the length of the union of all sealed intervals plus + /// each open session extrapolated to a bounded end. `localDeviceID`'s own + /// open session is trusted live to `now`; every other open session is capped + /// at its last heartbeat plus `openGrace`. All open sessions are additionally + /// capped at `maxSessionCap` from their start. + static func accumulatedSeconds( + forLogs logs: [TimeLog], + localDeviceID: String, + asOf now: Date = Date() + ) -> TimeInterval { + var intervals: [Interval] = [] + for log in logs { + for (deviceID, slot) in log.devices { + intervals.append(contentsOf: slot.intervals) + guard let start = slot.openStart else { continue } + let hardCap = start.addingTimeInterval(maxSessionCap) + let liveEnd: Date + if deviceID == localDeviceID { + liveEnd = now + } else { + liveEnd = min(now, (slot.beatAt ?? start).addingTimeInterval(openGrace)) + } + let end = min(liveEnd, hardCap) + if end > start { + intervals.append(Interval(start: start, end: end)) + } + } + } + return unionSeconds(intervals) + } + + /// Length of the union of `intervals` (overlaps merged, then summed). + static func unionSeconds(_ intervals: [Interval]) -> TimeInterval { + let sorted = intervals + .filter { $0.end > $0.start } + .sorted { $0.start < $1.start } + guard let first = sorted.first else { return 0 } + var total: TimeInterval = 0 + var curStart = first.start + var curEnd = first.end + for iv in sorted.dropFirst() { + if iv.start <= curEnd { + curEnd = max(curEnd, iv.end) + } else { + total += curEnd.timeIntervalSince(curStart) + curStart = iv.start + curEnd = iv.end + } + } + total += curEnd.timeIntervalSince(curStart) + return total + } + + /// Inserts `interval` into a device's own (disjoint) interval list, keeping it + /// sorted by start and coalescing any that touch or overlap — defensive, since + /// a single device's sessions don't normally overlap. + private static func inserting(_ interval: Interval, into list: [Interval]) -> [Interval] { + var merged = unionMerged(list + [interval]) + merged.sort { $0.start < $1.start } + return merged + } + + /// The merged (overlap-collapsed) intervals themselves, not just their length. + private static func unionMerged(_ intervals: [Interval]) -> [Interval] { + let sorted = intervals + .filter { $0.end > $0.start } + .sorted { $0.start < $1.start } + guard let first = sorted.first else { return [] } + var result: [Interval] = [] + var cur = first + for iv in sorted.dropFirst() { + if iv.start <= cur.end { + cur.end = max(cur.end, iv.end) + } else { + result.append(cur) + cur = iv + } + } + result.append(cur) + return result + } + + // MARK: - Codec + + static func encode(_ log: TimeLog) -> Data { + (try? JSONEncoder().encode(log)) ?? Data() + } + + /// Parse-tolerant: a missing/empty/old-format payload decodes to an empty + /// log (contributes zero), so the clock is safe before the schema deploy. + static func decode(_ data: Data?) -> TimeLog { + guard let data, !data.isEmpty, + let log = try? JSONDecoder().decode(TimeLog.self, from: data) + else { return TimeLog() } + return log + } + + // MARK: - Display + + /// A solve duration as `M:SS`, growing to `H:MM:SS` once past an hour. + /// Shared by the live header clock and the finish panel so both read alike. + static func clockString(_ seconds: TimeInterval) -> String { + let total = max(0, Int(seconds)) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let secs = total % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%d:%02d", minutes, secs) + } +} diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1615,6 +1615,92 @@ final class GameStore { saveContext("setSessionSnapshot") } + // MARK: - Solve-time clock + + /// Opens a solve session for the local device on `gameID` (idempotent across + /// resumes within one sitting). `reconcileStale` — set on the first open of + /// this game since launch — banks a session left dangling by a previous + /// run's crash rather than counting the dead gap. Returns `true` if the + /// stored log changed, so the caller can decide whether to enqueue a sync. + @discardableResult + func openClockSession( + gameID: UUID, + authorID: String, + reconcileStale: Bool = false, + at now: Date = Date() + ) -> Bool { + mutateTimeLog(gameID: gameID, authorID: authorID) { + $0.open(deviceID: RecordSerializer.localDeviceID, at: now, reconcileStale: reconcileStale) + } + } + + /// Seals the local device's open solve session into a sealed interval. + @discardableResult + func sealClockSession(gameID: UUID, authorID: String, at now: Date = Date()) -> Bool { + mutateTimeLog(gameID: gameID, authorID: authorID) { + $0.seal(deviceID: RecordSerializer.localDeviceID, at: now) + } + } + + /// Refreshes the local device's liveness heartbeat so a peer keeps + /// extrapolating an open session toward now. + @discardableResult + func beatClockSession(gameID: UUID, authorID: String, at now: Date = Date()) -> Bool { + mutateTimeLog(gameID: gameID, authorID: authorID) { + $0.beat(deviceID: RecordSerializer.localDeviceID, at: now) + } + } + + /// Whether `gameID` is finished (won or resigned). The clock stops opening + /// new sessions once this is true. + func isGameCompleted(gameID: UUID) -> Bool { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + return (try? context.fetch(request).first)?.completedAt != nil + } + + /// Reads, mutates, and re-persists the local author's `Player.timeLog`, + /// creating a stub `PlayerEntity` if none exists (works for solo games — no + /// `isShared` gate). `updatedAt` is left untouched on an existing row: the + /// `timeLog` field is adopted on apply regardless of LWW freshness (see + /// `RecordApplier`), so it need not win the selection's `updatedAt` race. + /// Returns `true` when the encoded log actually changed. + @discardableResult + private func mutateTimeLog( + gameID: UUID, + authorID: String, + _ change: (inout TimeLog) -> Void + ) -> Bool { + let entity: PlayerEntity + if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { + entity = existing + } else { + let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameRequest.fetchLimit = 1 + guard let game = try? context.fetch(gameRequest).first else { return false } + entity = PlayerEntity(context: context) + entity.game = game + entity.authorID = authorID + entity.ckRecordName = RecordSerializer.recordName( + forPlayerInGame: gameID, + authorID: authorID + ) + entity.updatedAt = Date() + } + var log = TimeLog.decode(entity.timeLog) + change(&log) + // Nothing to record — e.g. a heartbeat with no open session. Avoid + // writing (and shipping) an empty `{"devices":{}}` blob. + guard !log.devices.isEmpty else { return false } + let encoded = TimeLog.encode(log) + guard entity.timeLog != encoded else { return false } + entity.timeLog = encoded + saveContext("updateTimeLog") + return true + } + /// Stamps the local author's Player record for `gameID` with the address /// derived from `secret` (see `RecordSerializer.deriveGameAddress`), so it /// ships on the Player-record write the puzzle-open burst is already making. diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -27,6 +27,11 @@ final class SessionCoordinator { /// settled grid rather than a half-synced snapshot; cancelled if the user /// leaves before it elapses. static let sessionSummaryBannerDelay: TimeInterval = 3 + /// Cadence of the solve-clock liveness heartbeat for a shared game on screen. + /// Kept well under `TimeLog.openGrace` so a co-solver's continuous sitting + /// is never briefly capped on peers' clocks; only fires for shared games, so + /// solo play makes no extra Player writes. + static let clockHeartbeatInterval: TimeInterval = 90 private let persistence: PersistenceController private let store: GameStore @@ -50,6 +55,12 @@ final class SessionCoordinator { /// which at worst allows one extra nudge. private var lastNudge: [UUID: Date] = [:] + /// Games whose solve clock has been opened at least once this app run. The + /// first open of a game reconciles any session left dangling by a previous + /// run's crash; later opens (resumes) continue the same sitting. Cleared by a + /// relaunch, which is exactly when the next first-open should reconcile again. + private var clockSessionsOpenedThisLaunch: Set<UUID> = [] + init( persistence: PersistenceController, store: GameStore, @@ -548,6 +559,7 @@ final class SessionCoordinator { /// minutes' time. No-op if nothing was accumulated. private func handlePuzzleOpened(gameID: UUID) { logLocalPauseDiagnostics(for: gameID) + openClockSession(gameID: gameID) // Defer the banner so the open's `.appeared` grid freshen can land peer // moves first; otherwise it would diff against a half-synced grid and // under-report. The baseline is not touched here — it advances only on @@ -566,6 +578,7 @@ final class SessionCoordinator { /// stamps the baseline. private func handlePuzzleLeft(gameID: UUID) { sessions[gameID]?.cancelPendingSummaryBanner() + sealClockSession(gameID: gameID) gameViewedStore.advance(Date(), forGame: gameID) guard let authorID = identity.currentID, !authorID.isEmpty, let viewedAt = gameViewedStore.lastViewed(forGame: gameID), @@ -582,6 +595,68 @@ final class SessionCoordinator { Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot", drain: false) } } + /// Opens (or, on resume, refreshes the heartbeat of) the local device's + /// solve-time session and ships it on the Player record. Skipped once the + /// game is finished — a solved puzzle's clock is frozen, so revisiting it + /// must not start accruing again. Runs for solo games too: the Player record + /// rides the same private-zone sync that already carries solo Moves across + /// the owner's devices. + private func openClockSession(gameID: UUID) { + guard !store.isGameCompleted(gameID: gameID) else { return } + // The first open of a game since launch reconciles a session left + // dangling by a previous run's force-quit/crash; a resume within this run + // continues the same sitting. `open` also refreshes the heartbeat, so a + // resume re-arms liveness without a separate beat. + let firstOpenThisLaunch = clockSessionsOpenedThisLaunch.insert(gameID).inserted + guard store.openClockSession( + gameID: gameID, + authorID: localClockAuthorID, + reconcileStale: firstOpenThisLaunch + ) else { return } + enqueueClockIfSynced(gameID: gameID, reason: "clockOpen") + } + + /// Periodic liveness heartbeat for the open solve session, ticked by the + /// puzzle host while a *shared* game is on screen. Refreshes `beatAt` and + /// ships it so a co-solver keeps extrapolating this still-open session toward + /// now — without it, a continuous sitting longer than `TimeLog.openGrace` + /// would be capped on peers' clocks and only catch up when this device leaves. + /// A no-op once finished or when no session is open. + func noteClockHeartbeat(gameID: UUID) { + guard !store.isGameCompleted(gameID: gameID) else { return } + guard store.beatClockSession(gameID: gameID, authorID: localClockAuthorID) else { return } + enqueueClockIfSynced(gameID: gameID, reason: "clockBeat") + } + + /// The author key for the local player's clock writes. Falls back to the + /// CloudKit owner placeholder when no iCloud identity has resolved yet — a + /// solo game on a device not signed into iCloud (or before the async + /// `userRecordID` fetch lands) — so the clock still accumulates locally. The + /// placeholder is the same value the roster and push planner already exclude + /// from peer logic, and such writes are never enqueued for sync (see + /// `enqueueClockIfSynced`). + private var localClockAuthorID: String { + identity.currentID ?? CKCurrentUserDefaultName + } + + /// Enqueues the local player's Player record for sync, but only once a real + /// iCloud identity is resolved — the placeholder-author local row must never + /// be uploaded. When `currentID` is set, the clock wrote under it, so this + /// ships the same record the write touched. + private func enqueueClockIfSynced(gameID: UUID, reason: String) { + guard let authorID = identity.currentID else { return } + let syncEngine = self.syncEngine + Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: reason, drain: false) } + } + + /// Seals the local device's open solve-time session on leave and ships it. + /// Allowed even after completion so an in-progress session at the moment of + /// the win is made durable (the display still freezes it at `completedAt`). + private func sealClockSession(gameID: UUID) { + guard store.sealClockSession(gameID: gameID, authorID: localClockAuthorID) else { return } + enqueueClockIfSynced(gameID: gameID, reason: "clockSeal") + } + /// Computes the receiver-side catch-up summary for `gameID` and, when a peer /// has unseen activity, posts (or replaces, by stable id) the "Puzzle /// Updated" banner. Read-only — the baseline advances on leave, not here — diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -281,6 +281,27 @@ extension SyncEngine { let incomingReadThrough = RecordSerializer.parsePlayerReadThrough(from: record) entity.readThrough = incomingReadThrough entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record) + // `timeLog` is the device-keyed solve-time log. Adopt it outside the + // `updatedAt` freshness guard below — like the read cursor, a sibling's + // leave-write can ship a stale `updatedAt`. For another author's record we + // take it wholesale; for our own record echoing back from a sibling we + // merge by device, keeping *this* device's slot (we are its sole writer, + // and the sibling's copy may have dropped a session we have open now). + let incomingTimeLog = RecordSerializer.parsePlayerTimeLog(from: record) + if authorID == localAuthorID { + // Merge only when there is something on either side; otherwise leave + // a nil field nil rather than minting an empty blob on every fetch. + if incomingTimeLog != nil || entity.timeLog != nil { + var local = TimeLog.decode(entity.timeLog) + local.merge( + inbound: TimeLog.decode(incomingTimeLog), + preservingDevice: RecordSerializer.localDeviceID + ) + entity.timeLog = local.devices.isEmpty ? nil : TimeLog.encode(local) + } + } else { + entity.timeLog = incomingTimeLog + } // Only surface the cursor when the row actually changed. A re-application // of values we already hold — e.g. a catch-up query snapshot racing this // device's in-flight lease save shares the old etag, so the freshness diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -136,6 +136,7 @@ extension SyncEngine { readAt: entity.game?.lastReadOtherMoveAt, readThrough: entity.game?.readThroughAt, sessionSnapshot: entity.sessionSnapshot, + timeLog: entity.timeLog, pushAddress: entity.pushAddress, zone: zoneID, systemFields: entity.ckSystemFields diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -442,6 +442,7 @@ enum RecordSerializer { readAt: Date? = nil, readThrough: Date? = nil, sessionSnapshot: Data? = nil, + timeLog: Data? = nil, pushAddress: String? = nil, zone: CKRecordZone.ID, systemFields: Data? @@ -486,6 +487,15 @@ enum RecordSerializer { } else { record["sessionSnapshot"] = nil } + // `timeLog` is the device-keyed solve-time log (encoded `TimeLog`): + // each device's active-play intervals plus its open session. Unlike the + // other LWW fields here, a device only ever mutates its own slot, so + // concurrent sibling writes converge by device once merged on apply. + if let timeLog, !timeLog.isEmpty { + record["timeLog"] = timeLog as CKRecordValue + } else { + record["timeLog"] = nil + } if let pushAddress, !pushAddress.isEmpty { record["pushAddress"] = pushAddress as CKRecordValue } else { @@ -542,6 +552,14 @@ enum RecordSerializer { record["sessionSnapshot"] as? Data } + /// Reads `timeLog` off an inbound Player record — the encoded `TimeLog` + /// of device-keyed solve-time intervals. Returns `nil` for records that + /// predate the field (or before the schema deploy), which the clock treats + /// as a zero contribution. + static func parsePlayerTimeLog(from record: CKRecord) -> Data? { + record["timeLog"] as? Data + } + /// Reads `pushAddress` off an inbound Player record — the per-(account, /// game) capability token a co-participant uses to address a push to this /// player for this game. Possession is gated by the share ACL (only diff --git a/Crossmate/Views/Puzzle/PuzzleHeader.swift b/Crossmate/Views/Puzzle/PuzzleHeader.swift @@ -32,6 +32,7 @@ struct PuzzleHeader: View { private enum Page: Hashable { case title case scoreboard + case clock case credits } @@ -61,6 +62,7 @@ struct PuzzleHeader: View { private var pages: [Page] { var result: [Page] = [.title] if showsScoreboard { result.append(.scoreboard) } + result.append(.clock) if hasCredits { result.append(.credits) } return result } @@ -192,6 +194,9 @@ struct PuzzleHeader: View { nudgeReadyAt: nudgeReadyAt ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + case .clock: + PuzzleClock(roster: roster) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) case .credits: PuzzleCredits(author: session.puzzle.author, copyright: copyrightLine) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) @@ -199,6 +204,31 @@ struct PuzzleHeader: View { } } +/// The solve-time clock page. Ticks once a second off `TimelineView`, re-reading +/// the union of every player's active intervals so it counts up live and shows a +/// co-solver's already-elapsed time the moment you join. Freezes automatically +/// once the game is solved (the roster bounds the union at `completedAt`). +private struct PuzzleClock: View { + let roster: PlayerRoster + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + VStack(spacing: 2) { + Text(TimeLog.clockString(roster.solveTime(asOf: context.date))) + .font(.headline) + .monospacedDigit() + .contentTransition(.numericText()) + Text("Solving Time") + .font(.footnote) + .foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal) + } + } +} + private struct PuzzleTitle: View { let title: String let subtitle: String? diff --git a/Crossmate/Views/Puzzle/SuccessPanel.swift b/Crossmate/Views/Puzzle/SuccessPanel.swift @@ -45,6 +45,15 @@ struct SuccessPanel: View { } } + /// Total active solve time, frozen at completion (`roster.solveTime` bounds + /// the union at `completedAt`). "Finished" rather than "Solved" so it reads + /// correctly for a resignation too — and because an offline solo win carries + /// no `completedBy`, so a win and a resign aren't reliably distinguishable + /// here anyway. + private var finishedInText: String { + "Finished in \(TimeLog.clockString(roster.solveTime()))" + } + private var contributions: [Contribution] { let replayCells = replay?.frame?.cells var counts: [String?: Int] = [:] @@ -229,11 +238,14 @@ struct SuccessPanel: View { } } - Text(revealedSquaresText) - .font(.footnote) - .foregroundStyle(.secondary) - .padding(.top, 16) - .frame(maxWidth: .infinity, alignment: .center) + VStack(spacing: 4) { + Text(finishedInText) + Text(revealedSquaresText) + } + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 16) + .frame(maxWidth: .infinity, alignment: .center) } } .scrollIndicators(.hidden) diff --git a/Tests/Unit/TimeLogTests.swift b/Tests/Unit/TimeLogTests.swift @@ -0,0 +1,245 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("TimeLog union accumulation") +struct TimeLogTests { + + /// Reference instant; offsets are seconds from here for readability. + private let t0 = Date(timeIntervalSinceReferenceDate: 100_000) + private func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) } + + /// A log holding a single device's sealed intervals, given as (start, end) + /// second-offsets. + private func sealed(_ device: String, _ spans: [(TimeInterval, TimeInterval)]) -> TimeLog { + let intervals = spans.map { TimeLog.Interval(start: at($0.0), end: at($0.1)) } + return TimeLog(devices: [device: .init(intervals: intervals)]) + } + + // MARK: - Solo, single device + + @Test("Open then seal accumulates the elapsed span") + func openSealAccumulates() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + log.seal(deviceID: "alice-phone", at: at(120)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(200) + ) + #expect(total == 120) + } + + @Test("Reopening after a sealed session adds the second span") + func reopenAdds() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + log.seal(deviceID: "alice-phone", at: at(60)) + log.open(deviceID: "alice-phone", at: at(300)) + log.seal(deviceID: "alice-phone", at: at(330)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(400) + ) + #expect(total == 90) + } + + @Test("A re-open while already open is idempotent — the first start wins") + func reopenWhileOpenIdempotent() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + log.open(deviceID: "alice-phone", at: at(50)) // renewal, not a new session + log.seal(deviceID: "alice-phone", at: at(120)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(200) + ) + #expect(total == 120) + } + + // MARK: - Multi-device, same author + + @Test("Disjoint sessions on two of one account's devices sum") + func multiDeviceDisjointSums() { + let phone = sealed("alice-phone", [(0, 100)]) + let pad = sealed("alice-pad", [(200, 250)]) + // Both slots live on the same author's record in practice; modelled as one + // log with two device keys. + let log = TimeLog(devices: phone.devices.merging(pad.devices) { a, _ in a }) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(300) + ) + #expect(total == 150) + } + + @Test("Overlapping sessions on two devices collapse to the union, not the sum") + func multiDeviceOverlapDedups() { + let log = TimeLog(devices: [ + "alice-phone": .init(intervals: [.init(start: at(0), end: at(100))]), + "alice-pad": .init(intervals: [.init(start: at(60), end: at(160))]), + ]) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(300) + ) + // Union [0,160] = 160, not 100 + 100 = 200. + #expect(total == 160) + } + + // MARK: - Multiplayer + + @Test("Simultaneous co-solving is counted once") + func multiplayerSimultaneousCountedOnce() { + let alice = sealed("alice-phone", [(0, 600)]) + let bob = sealed("bob-phone", [(0, 600)]) + let total = TimeLog.accumulatedSeconds( + forLogs: [alice, bob], localDeviceID: "carol-phone", asOf: at(1000) + ) + #expect(total == 600) + } + + @Test("A handoff between players sums the disjoint spans") + func multiplayerHandoffSums() { + let alice = sealed("alice-phone", [(0, 600)]) + let bob = sealed("bob-phone", [(1200, 1500)]) + let total = TimeLog.accumulatedSeconds( + forLogs: [alice, bob], localDeviceID: "carol-phone", asOf: at(2000) + ) + #expect(total == 900) + } + + @Test("Partial overlap across players counts the union span") + func multiplayerPartialOverlap() { + let alice = sealed("alice-phone", [(0, 100)]) + let bob = sealed("bob-phone", [(80, 200)]) + let carol = sealed("carol-phone", [(300, 350)]) + let total = TimeLog.accumulatedSeconds( + forLogs: [alice, bob, carol], localDeviceID: "alice-phone", asOf: at(400) + ) + // Union [0,200] (=200) + [300,350] (=50) = 250. + #expect(total == 250) + } + + // MARK: - Live / in-progress sessions + + @Test("The local device's open session is trusted live to now") + func localOpenExtrapolatesToNow() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(45) + ) + #expect(total == 45) + } + + @Test("A peer's open session extrapolates only to its last beat plus grace") + func peerOpenBoundedByBeat() { + var bob = TimeLog() + bob.open(deviceID: "bob-phone", at: at(0)) + bob.beat(deviceID: "bob-phone", at: at(100)) // last heartbeat at 100 + // We (alice) read it far in the future; bob never sealed. + let total = TimeLog.accumulatedSeconds( + forLogs: [bob], localDeviceID: "alice-phone", asOf: at(10_000) + ) + // Capped at beat(100) + grace(180) = 280. + #expect(total == 100 + TimeLog.openGrace) + } + + @Test("A never-sealed local session is capped at maxSessionCap") + func localOpenCappedBySessionCap() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", + asOf: at(TimeLog.maxSessionCap + 10_000) + ) + #expect(total == TimeLog.maxSessionCap) + } + + @Test("A heartbeat with no open session is a no-op (no bare slot)") + func beatWithoutOpenIsNoOp() { + var log = TimeLog() + log.beat(deviceID: "alice-phone", at: at(0)) + #expect(log.devices.isEmpty) + // After sealing, a later beat must not resurrect an open session. + log.open(deviceID: "alice-phone", at: at(10)) + log.seal(deviceID: "alice-phone", at: at(20)) + log.beat(deviceID: "alice-phone", at: at(30)) + #expect(log.devices["alice-phone"]?.openStart == nil) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(100) + ) + #expect(total == 10) + } + + @Test("A crashed session is banked bounded on the next launch's first open") + func staleSessionReconciledOnReopen() { + var log = TimeLog() + // Sitting one: opened at 0, last heartbeat at 100, then force-quit — never + // sealed, so `openStart` persists. + log.open(deviceID: "alice-phone", at: at(0)) + log.beat(deviceID: "alice-phone", at: at(100)) + // A long time later, the first open since launch reconciles it. + log.open(deviceID: "alice-phone", at: at(200_000), reconcileStale: true) + log.seal(deviceID: "alice-phone", at: at(200_060)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(300_000) + ) + // Crashed sitting banks bounded at beat(100) + grace, NOT the dead gap to + // 200_000; plus the fresh 60s sitting. + #expect(total == 100 + TimeLog.openGrace + 60) + } + + @Test("A resume (not reconciled) keeps one continuous session, no split") + func resumeKeepsContinuousSession() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + // An in-run `.active` re-fire (e.g. Control Center) — reconcileStale stays + // false, so the sitting is not split. + log.open(deviceID: "alice-phone", at: at(600)) + log.seal(deviceID: "alice-phone", at: at(900)) + let total = TimeLog.accumulatedSeconds( + forLogs: [log], localDeviceID: "alice-phone", asOf: at(1000) + ) + #expect(total == 900) + } + + // MARK: - Merge discipline + + @Test("Merging an inbound copy preserves the local device's own open slot") + func mergePreservesLocalSlot() { + // Local state: my phone has a live open session. + var local = TimeLog() + local.open(deviceID: "alice-phone", at: at(500)) + // Inbound sibling copy: a stale view of my phone (closed) plus my pad's work. + let inbound = TimeLog(devices: [ + "alice-phone": .init(intervals: [.init(start: at(0), end: at(50))]), + "alice-pad": .init(intervals: [.init(start: at(100), end: at(200))]), + ]) + local.merge(inbound: inbound, preservingDevice: "alice-phone") + // My phone's live open session is kept; the pad slot is adopted; the stale + // phone history from the sibling is discarded in favour of my own slot. + #expect(local.devices["alice-phone"]?.openStart == at(500)) + #expect(local.devices["alice-pad"]?.intervals.count == 1) + } + + // MARK: - Codec tolerance + + @Test("Decoding nil or empty data yields an empty log") + func decodeToleratesEmpty() { + #expect(TimeLog.decode(nil).devices.isEmpty) + #expect(TimeLog.decode(Data()).devices.isEmpty) + } + + @Test("Encode then decode round-trips") + func codecRoundTrips() { + var log = TimeLog() + log.open(deviceID: "alice-phone", at: at(0)) + log.seal(deviceID: "alice-phone", at: at(120)) + let restored = TimeLog.decode(TimeLog.encode(log)) + #expect(restored == log) + } + + @Test("An empty game has a zero clock") + func emptyIsZero() { + #expect(TimeLog.accumulatedSeconds(forLogs: [], localDeviceID: "x") == 0) + #expect(TimeLog.accumulatedSeconds(forLogs: [TimeLog()], localDeviceID: "x") == 0) + } +}