crossmate

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

commit 48f53a82c081df468ef64e0ee18f692d6c2a656d
parent c36457c8cbfcddca0e359b95c2f87e09c040d72e
Author: Michael Camilleri <[email protected]>
Date:   Fri, 26 Jun 2026 17:46:54 +0900

Preserve solve-clock time across freshens, completion, and the archive

The solve clock the previous commit added could lose or undercount its
recorded time in three situations that commit did not cover.

A direct freshen fetches Player records with a restricted desiredKeys set
that omitted timeLog, so the returned record carried no clock data and
RecordApplier adopted that absence as nil — erasing a peer's or a sibling
device's intervals locally until the next full sync re-delivered them. The
four Player key lists in CloudQuery now request timeLog, and RecordApplier
touches the field only when the fetched record actually carries it: the log
only ever grows, so an absent field can never mean 'cleared', and a partial
fetch is left to leave the local value alone.

The clock sealed its open session only on leave, so between a win and the
solver next backgrounding or navigating away, co-players and sibling devices
held nothing newer than an older heartbeat and undercounted the final
stretch. Completion now seals the session at the game's own completedAt and
ships the Player record — for a win, an observed solve, or a resignation —
so the final time converges everywhere at the finish. The local display was
already correct, since solveTime bounds the union at completedAt.

An archived game showed no time at all: the per-player timeLog records the
clock lives on do not survive into the archive, so the materialised game had
nothing to union. The Archive record now carries the frozen final seconds in
solveSeconds, materialise stamps them onto the rebuilt game's local
finalSolveSeconds, and PlayerRoster.solveTime returns that for an archive. An
archive written before the field omits the 'Finished in …' line rather than
reading 0:00.

This also adds a solveSeconds Int(64) field to the Archive record type
for capturing in archived games.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 5+++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 1+
MCrossmate/Models/PlayerRoster.swift | 9+++++++--
MCrossmate/Persistence/GameStore.swift | 14++++++++++----
MCrossmate/Services/SessionCoordinator.swift | 16++++++++++++++++
MCrossmate/Sync/Archive.swift | 38++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/CloudQuery.swift | 8++++----
MCrossmate/Sync/RecordApplier.swift | 29++++++++++++++++-------------
MCrossmate/Views/Puzzle/SuccessPanel.swift | 13+++++++++----
MTests/Unit/ArchiveTests.swift | 4++++
10 files changed, 110 insertions(+), 27 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -689,6 +689,10 @@ private struct PuzzleDisplayView: View { ? store.markCompleted(id: gameID) : store.markCompletedFromObservedSolvedState(id: gameID) if changed { + // Seal the solve clock at the finish so peers and + // sibling devices get the final time at once, not + // only when this device next leaves the puzzle. + services.sessions.noteClockCompleted(gameID: gameID) if notifyPeers { Task { await services.sessions.sendCompletionPings(gameID: gameID, resigned: false) @@ -712,6 +716,7 @@ private struct PuzzleDisplayView: View { }, onResign: { try store.resignGame(id: gameID) + services.sessions.noteClockCompleted(gameID: gameID) Task { await services.sessions.sendCompletionPings(gameID: gameID, resigned: true) } diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -14,6 +14,7 @@ <attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="completedBy" optional="YES" attributeType="String"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="finalSolveSeconds" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="engagement" optional="YES" attributeType="String"/> <attribute name="notification" optional="YES" attributeType="String"/> <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -111,8 +111,13 @@ final class PlayerRoster { 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 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) + } + let asOf = game?.completedAt.map { min(now, $0) } ?? now let playerRequest = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") playerRequest.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1651,13 +1651,19 @@ final class GameStore { } } - /// Whether `gameID` is finished (won or resigned). The clock stops opening - /// new sessions once this is true. - func isGameCompleted(gameID: UUID) -> Bool { + /// The completion instant for `gameID` (win or resign), or `nil` while it is + /// unfinished. Used to seal the clock at the moment of the finish. + func completedAt(forGame gameID: UUID) -> Date? { 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 + return (try? context.fetch(request).first)?.completedAt + } + + /// Whether `gameID` is finished (won or resigned). The clock stops opening + /// new sessions once this is true. + func isGameCompleted(gameID: UUID) -> Bool { + completedAt(forGame: gameID) != nil } /// Reads, mutates, and re-persists the local author's `Player.timeLog`, diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -657,6 +657,22 @@ final class SessionCoordinator { enqueueClockIfSynced(gameID: gameID, reason: "clockSeal") } + /// Seals the local solve session at the instant the game finished — win, + /// observed solve, or resign — and ships it, so peers and sibling devices + /// converge on the final time straight away rather than only when this device + /// next leaves the puzzle. Sealing at the game's own completedAt keeps the + /// final interval identical to what the display already freezes to. A no-op + /// when no session is open. + func noteClockCompleted(gameID: UUID) { + let finishedAt = store.completedAt(forGame: gameID) ?? Date() + guard store.sealClockSession( + gameID: gameID, + authorID: localClockAuthorID, + at: finishedAt + ) else { return } + enqueueClockIfSynced(gameID: gameID, reason: "clockComplete") + } + /// 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/Archive.swift b/Crossmate/Sync/Archive.swift @@ -143,6 +143,13 @@ enum Archive { let puzzleSource: String let completedAt: Date let completedBy: String? + /// The frozen solve-clock value in whole seconds (active solving time, the + /// union across all players) at the moment the game finished. Captured + /// here because the per-player `Player.timeLog` records it lives on do not + /// survive into the archive, so the materialised game would otherwise read + /// zero. Whole seconds — the clock is only ever shown at second + /// resolution. + let solveSeconds: Int let cells: [Cell] /// The full move log, kept *per contributing device* (not flattened) so /// the materialized game replays exactly as the live one does: the replay @@ -186,11 +193,30 @@ enum Archive { puzzleSource: source, completedAt: completedAt, completedBy: entity.completedBy, + solveSeconds: solveSeconds(forGameID: gameID, asOf: completedAt, in: ctx), cells: cells, journal: localDeviceJournals(forGameID: gameID, in: ctx) ) } + /// The union of every player's solve-clock intervals for `gameID`, frozen at + /// `asOf` (the completion instant). Mirrors `PlayerRoster.solveTime` but reads + /// from the supplied background context for the archive snapshot. + private static func solveSeconds( + forGameID gameID: UUID, + asOf: Date, + in ctx: NSManagedObjectContext + ) -> Int { + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) + let logs = ((try? ctx.fetch(req)) ?? []).map { TimeLog.decode($0.timeLog) } + return Int(TimeLog.accumulatedSeconds( + forLogs: logs, + localDeviceID: RecordSerializer.localDeviceID, + asOf: asOf + )) + } + /// Groups the local `JournalEntity` rows for a game into per-device logs. /// Own rows (`sourceDeviceID == nil`) form one log keyed to this device; peer /// rows cached for replay carry their own source key. @@ -237,6 +263,7 @@ enum Archive { puzzleSource: snapshot.puzzleSource, completedAt: snapshot.completedAt, completedBy: snapshot.completedBy, + solveSeconds: snapshot.solveSeconds, cells: snapshot.cells, journal: byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } ) @@ -262,6 +289,7 @@ enum Archive { if let completedBy = snapshot.completedBy { record["completedBy"] = completedBy as CKRecordValue } + record["solveSeconds"] = Int64(snapshot.solveSeconds) as CKRecordValue record["puzzleSource"] = try asset(for: Data(snapshot.puzzleSource.utf8), ext: "xd") record["cells"] = try asset(for: try encodeCells(snapshot.cells), ext: "json") @@ -287,6 +315,9 @@ enum Archive { let puzzleSource: String let completedAt: Date let completedBy: String? + /// The frozen solve time in whole seconds, or `nil` for archives written + /// before the field existed (their materialised game simply shows no time). + let solveSeconds: Int? let cells: [Cell] let journal: [DeviceJournal] } @@ -303,6 +334,7 @@ enum Archive { puzzleSource: snapshot.puzzleSource, completedAt: snapshot.completedAt, completedBy: snapshot.completedBy, + solveSeconds: snapshot.solveSeconds, cells: snapshot.cells, journal: snapshot.journal ) @@ -336,6 +368,7 @@ enum Archive { puzzleSource: puzzleSource, completedAt: completedAt, completedBy: record["completedBy"] as? String, + solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init), cells: cells, journal: journal ) @@ -377,6 +410,11 @@ enum Archive { entity.puzzleSource = payload.puzzleSource entity.completedAt = payload.completedAt entity.completedBy = payload.completedBy + // The frozen solve time the live clock reached; `PlayerRoster.solveTime` + // returns this for a materialised archive, which has no `timeLog` rows. + if let solveSeconds = payload.solveSeconds { + entity.finalSolveSeconds = NSNumber(value: solveSeconds) + } entity.createdAt = payload.completedAt entity.updatedAt = payload.completedAt entity.archivedAt = payload.completedAt diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -408,7 +408,7 @@ extension SyncEngine { database: database, zoneID: zone.zoneID, since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "pushAddress"] + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] ) let activities = playerRecords.compactMap { record in Session.parseRecord(record, puzzleTitle: zone.title) @@ -582,7 +582,7 @@ extension SyncEngine { database: database, zoneID: zoneID, since: nil, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "pushAddress"] + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] ) let (m, p) = try await (moves, players) return PerZoneResult(records: games + m + p, hasGame: true) @@ -689,7 +689,7 @@ extension SyncEngine { database: database, zoneID: info.zoneID, since: since, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "pushAddress"] + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] ) (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) } catch { @@ -777,7 +777,7 @@ extension SyncEngine { database: database, zoneID: zoneID, since: nil, - desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "pushAddress"] + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir", "readAt", "readThrough", "sessionSnapshot", "timeLog", "pushAddress"] ) (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) } catch { diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -281,26 +281,29 @@ 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 { + // `timeLog` is the device-keyed solve-time log. Touch it only when the + // fetched record actually carries the field. A partial fetch (CloudQuery + // restricts `desiredKeys`) or an older record omits it, and because the + // log only ever grows, an absent field never means "cleared" — adopting + // nil there would erase a peer's or a sibling's intervals until the next + // full sync re-delivered them. Applied outside the `updatedAt` freshness + // guard below: like the read cursor, a sibling's leave-write can ship a + // stale `updatedAt`. + if record.allKeys().contains("timeLog") { + let incomingTimeLog = RecordSerializer.parsePlayerTimeLog(from: record) + if authorID == localAuthorID { + // Our own record echoing back from a sibling: merge by device, + // keeping this device's own slot (we are its sole writer, and the + // sibling's copy may have dropped a session we have open now). 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 } - } 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 diff --git a/Crossmate/Views/Puzzle/SuccessPanel.swift b/Crossmate/Views/Puzzle/SuccessPanel.swift @@ -49,9 +49,12 @@ struct SuccessPanel: View { /// 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()))" + /// here anyway. `nil` when there is no time to show — an archive made before + /// the clock existed — so the line is omitted rather than reading 0:00. + private var finishedInText: String? { + let seconds = roster.solveTime() + guard seconds > 0 else { return nil } + return "Finished in \(TimeLog.clockString(seconds))" } private var contributions: [Contribution] { @@ -239,7 +242,9 @@ struct SuccessPanel: View { } VStack(spacing: 4) { - Text(finishedInText) + if let finishedInText { + Text(finishedInText) + } Text(revealedSquaresText) } .font(.footnote) diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -71,6 +71,7 @@ struct ArchiveTests { puzzleSource: source, completedAt: Date(timeIntervalSince1970: 1_700_001_000), completedBy: "alice", + solveSeconds: 743, cells: [ .init(row: 0, col: 0, letter: "A", markCode: 0, letterAuthorID: "alice"), .init(row: 0, col: 1, letter: "B", markCode: 0, letterAuthorID: "bob"), @@ -133,6 +134,7 @@ struct ArchiveTests { #expect(payload.title == snapshot.title) #expect(payload.completedAt == snapshot.completedAt) #expect(payload.completedBy == "alice") + #expect(payload.solveSeconds == snapshot.solveSeconds) #expect(payload.puzzleSource == source) #expect(payload.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) } == snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) }) @@ -151,6 +153,7 @@ struct ArchiveTests { title: "T", puzzleSource: source, completedAt: Date(timeIntervalSince1970: 1), completedBy: nil, + solveSeconds: 0, cells: [], journal: [DeviceJournal(key: aliceKey, entries: [ journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"), @@ -244,6 +247,7 @@ struct ArchiveTests { #expect(game.ckZoneOwnerName == nil) // owned #expect(game.completedAt == payload.completedAt) #expect(game.completedBy == "alice") + #expect(game.finalSolveSeconds?.int64Value == 743) #expect(((game.cells as? Set<CellEntity>) ?? []).count == 3) let journalRows = (game.journal as? Set<JournalEntity>) ?? []