commit 3eac7a3ecace999445bbf690b467831b51276709
parent e697be1ef4f3ba8c061bb62f346836113ca347a3
Author: Michael Camilleri <[email protected]>
Date: Sun, 31 May 2026 06:20:15 +0900
Build the cross-device replay data layer
Every device already uploads its move journal at completion, but the
records were write-only: inbound Journal records are ignored and nothing
ever read them back, so replay could only have shown the local device's
own letters. This commit adds the read side — fetch every device's
journal, merge it and reconstruct the grid at any point in time.
SyncEngine.fetchReplay runs two zone-scoped CKQuerys against a finished
game's zone: Journal records (decoding each entries asset) and Moves
records (keys only, to enumerate the devices that wrote grid state). It
is a plain query, so it never disturbs the sync engine's change token.
JournalReplay.swift holds the pure core: ReplayTimeline merges the logs
by timestamp (ties broken on actingAuthorID then seq, so fetch order
can't change the result) and state(through:) folds the first n steps
last-write-per-cell. Forward replay needs only each entry's after-state,
so the device-local seq/prevSeqAtCell links go unused and undo, check
and reveal rows replay naturally as timestamped restores.
ReplayAssembler enforces strict completeness: it overlays this device's
live log over any uploaded copy of itself, then yields a timeline only
once a journal is present for every expected device, else .waiting so
the scrubber can stay disabled. AppServices.loadReplay composes the
fetch, the local source and the assembler.
Strict completeness needs late devices to upload, or a game finished
while a contributor was away would wait forever. Completion learned via
sync used to set completedAt without uploading the local journal;
applyGameRecord now signals the not-completed → completed transition and
both inbound apply paths enqueue this device's journal upload, so any
contributing device converges once it next syncs. A device that wrote
cells and is then permanently gone still blocks that game's replay.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
9 files changed, 459 insertions(+), 7 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */; };
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; };
+ 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */; };
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
@@ -95,6 +96,7 @@
AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */; };
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; };
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
+ B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */; };
B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; };
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; };
@@ -191,6 +193,7 @@
1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; };
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
+ 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; };
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalUploadTests.swift; sourceTree = "<group>"; };
@@ -243,6 +246,7 @@
86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; };
87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; };
88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+ 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplayTests.swift; sourceTree = "<group>"; };
8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; };
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStore.swift; sourceTree = "<group>"; };
8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementCoordinator.swift; sourceTree = "<group>"; };
@@ -367,6 +371,7 @@
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
+ 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */,
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */,
78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
@@ -426,6 +431,7 @@
43DC132D49361C56DE79C13E /* GameMutator.swift */,
93EE5BA78566EDED68D846AB /* GameStore.swift */,
CF3D29B227D2B0E699423C48 /* Journal.swift */,
+ 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */,
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */,
);
path = Persistence;
@@ -702,6 +708,7 @@
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
+ 0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */,
6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */,
@@ -780,6 +787,7 @@
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */,
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */,
9502840161DB88BB6BB409D5 /* Journal.swift in Sources */,
+ B5F78A55C9BCCD24E44D865F /* JournalReplay.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -663,6 +663,16 @@ final class GameStore {
}
}
+ /// This device's live journal for a game, tagged with its device key. The
+ /// replay assembler overlays this over any uploaded copy of ourselves: the
+ /// in-memory log is the session's authoritative copy and may be fresher than
+ /// what's round-tripped to CloudKit. `nil` until the local author is known.
+ func localReplaySource(gameID: UUID) -> DeviceJournal? {
+ guard let authorID = authorIDProvider(), !authorID.isEmpty else { return nil }
+ let key = JournalDeviceKey(authorID: authorID, deviceID: RecordSerializer.localDeviceID)
+ return DeviceJournal(key: key, entries: movesJournal.recordedEntries(gameID: gameID))
+ }
+
// MARK: - Engagement room
/// The shared live-engagement room creds for `gameID` (an encoded
diff --git a/Crossmate/Persistence/JournalReplay.swift b/Crossmate/Persistence/JournalReplay.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+/// Identifies one device's journal within a game: the `(authorID, deviceID)`
+/// pair that names a `Journal` (and `Moves`) record. Faithful replay needs one
+/// journal per device that ever wrote grid state.
+struct JournalDeviceKey: Hashable, Sendable {
+ let authorID: String
+ let deviceID: String
+}
+
+/// One device's decoded move log, tagged with the device it came from.
+struct DeviceJournal: Sendable {
+ let key: JournalDeviceKey
+ let entries: [JournalValue]
+}
+
+/// What `SyncEngine.fetchReplay` pulls from a finished game's zone: every
+/// device's uploaded journal, plus the set of devices that wrote grid state
+/// (derived from the `Moves` record names). Replay is only faithful once a
+/// journal is present for every expected device — see `ReplayAssembler`.
+struct JournalReplayFetch: Sendable {
+ let journals: [DeviceJournal]
+ let expectedDevices: Set<JournalDeviceKey>
+}
+
+/// The outcome of assembling a replay. `.waiting` means at least one
+/// contributing device hasn't uploaded its journal yet (the scrubber stays
+/// disabled until it does); `.unavailable` means the game's zone can't be
+/// reached (unknown locally, or access revoked).
+enum JournalReplayResult: Sendable, Equatable {
+ case ready(ReplayTimeline)
+ case waiting(missing: Int)
+ case unavailable
+}
+
+/// A merged, chronological replay of a finished game, reconstructed from every
+/// device's journal. Forward replay needs only each entry's *after-state*, so
+/// the device-local `seq` / `prevSeqAtCell` links (which exist for undo
+/// derivation) are ignored here — undo, check, and reveal rows replay
+/// naturally because each already carries the cell state it produced and the
+/// wall-clock instant it happened. Pure and `Sendable`: no CloudKit, no Core
+/// Data, so it can be unit-tested directly.
+struct ReplayTimeline: Sendable, Equatable {
+ /// Every recorded cell touch across all devices, ordered by wall-clock time.
+ let steps: [JournalValue]
+
+ var count: Int { steps.count }
+
+ /// Merges each device's log into one timeline ordered by `timestamp`. Ties
+ /// break deterministically on `(actingAuthorID, seq)` so two devices that
+ /// touch cells at the same instant always replay in the same order,
+ /// regardless of the order the journals were fetched in.
+ init(merging logs: [[JournalValue]]) {
+ steps = logs.flatMap { $0 }.sorted(by: ReplayTimeline.precedes)
+ }
+
+ private static func precedes(_ a: JournalValue, _ b: JournalValue) -> Bool {
+ if a.timestamp != b.timestamp { return a.timestamp < b.timestamp }
+ let lhs = a.actingAuthorID ?? ""
+ let rhs = b.actingAuthorID ?? ""
+ if lhs != rhs { return lhs < rhs }
+ return a.seq < b.seq
+ }
+
+ /// The grid after applying the first `count` steps: each touched position
+ /// mapped to the after-state of its most recent touch. Positions never
+ /// touched are absent (render as blank). `count` is clamped to
+ /// `0...self.count`, so `state(through: 0)` is the empty starting grid and
+ /// `state(through: count)` is the finished puzzle.
+ func state(through count: Int) -> [GridPosition: JournalCellState] {
+ let upper = min(max(count, 0), steps.count)
+ var grid: [GridPosition: JournalCellState] = [:]
+ for index in 0..<upper {
+ let step = steps[index]
+ grid[step.position] = step.state
+ }
+ return grid
+ }
+}
+
+/// Composes a `JournalReplayFetch` with this device's *live* journal into a
+/// gated result. Pure, so the completeness rule is unit-testable without
+/// CloudKit.
+///
+/// Two reasons the live local log is overlaid rather than trusting the fetched
+/// copy of ourselves: the completion upload may not have round-tripped yet, and
+/// even once it has, the in-memory log is the session's authoritative copy.
+///
+/// Strict completeness: a timeline is produced only when a journal is present
+/// for every expected device; otherwise the caller is told how many are still
+/// missing so the UI can wait.
+enum ReplayAssembler {
+ static func assemble(
+ fetch: JournalReplayFetch,
+ localKey: JournalDeviceKey,
+ localEntries: [JournalValue]
+ ) -> JournalReplayResult {
+ // Index fetched journals by device, then overlay this device's live
+ // log (fresher than any uploaded copy of itself).
+ var byDevice: [JournalDeviceKey: [JournalValue]] = [:]
+ for journal in fetch.journals {
+ byDevice[journal.key] = journal.entries
+ }
+ if !localEntries.isEmpty {
+ byDevice[localKey] = localEntries
+ }
+
+ // Expected = every device that wrote grid state. The local device is
+ // implicitly expected when it has a live log, in case its own Moves
+ // record query raced the fetch.
+ var expected = fetch.expectedDevices
+ if !localEntries.isEmpty {
+ expected.insert(localKey)
+ }
+
+ let present = Set(byDevice.keys)
+ let missing = expected.subtracting(present)
+ guard missing.isEmpty else {
+ return .waiting(missing: missing.count)
+ }
+ return .ready(ReplayTimeline(merging: Array(byDevice.values)))
+ }
+}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -2437,6 +2437,27 @@ final class AppServices {
}
}
+ /// Loads a finished game's replay: fetches every device's journal from
+ /// CloudKit, overlays this device's live log, and gates on strict
+ /// completeness. `.ready` carries a merged timeline; `.waiting(missing:)`
+ /// means some contributing device hasn't uploaded its journal yet (the
+ /// scrubber stays disabled until it does); `.unavailable` means the game's
+ /// zone can't be reached. The fetch is a plain CKQuery, so it's safe to call
+ /// from the UI when the finish banner appears.
+ func loadReplay(gameID: UUID) async -> JournalReplayResult {
+ // `try?` flattens both the thrown error and the method's own optional
+ // (nil = zone unknown / access revoked) into a single `.unavailable`.
+ guard let fetch = try? await syncEngine.fetchReplay(forGameID: gameID) else {
+ return .unavailable
+ }
+ let local = store.localReplaySource(gameID: gameID)
+ return ReplayAssembler.assemble(
+ fetch: fetch,
+ localKey: local?.key ?? JournalDeviceKey(authorID: "", deviceID: ""),
+ localEntries: local?.entries ?? []
+ )
+ }
+
/// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can
/// drive the exact same closure that production wires up — keeps the
/// cursor-cleanup branch from drifting silently. (Friend colours need no
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -809,4 +809,70 @@ extension SyncEngine {
index = end
}
}
+
+ /// Fetches every device's uploaded `Journal` record for a finished game,
+ /// together with the set of devices that wrote grid state (from the `Moves`
+ /// record names), so the caller can gate replay on completeness. A plain
+ /// zone-scoped `CKQuery` — it reads on demand and does **not** disturb the
+ /// sync engine's change token (inbound `Journal` records stay ignored in the
+ /// delegate path). Returns `nil` when the game's zone isn't known locally or
+ /// access has been revoked; the caller surfaces that as `.unavailable`.
+ func fetchReplay(forGameID gameID: UUID) async throws -> JournalReplayFetch? {
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx), !info.isAccessRevoked else {
+ return nil
+ }
+ let database = info.scope == 1
+ ? container.sharedCloudDatabase
+ : container.privateCloudDatabase
+
+ // Expected device set: every device that wrote grid state owns a Moves
+ // record named `moves-<game>-<author>-<device>`. Keys only.
+ let movesRecords = try await queryRecords(
+ type: "Moves",
+ database: database,
+ zoneID: info.zoneID,
+ predicate: NSPredicate(value: true),
+ desiredKeys: []
+ )
+ var expected = Set<JournalDeviceKey>()
+ for record in movesRecords {
+ if let (_, authorID, deviceID) =
+ RecordSerializer.parseMovesRecordName(record.recordID.recordName) {
+ expected.insert(JournalDeviceKey(authorID: authorID, deviceID: deviceID))
+ }
+ }
+
+ // Present journals: decode each device's `entries` asset into its log.
+ let journalRecords = try await queryRecords(
+ type: "Journal",
+ database: database,
+ zoneID: info.zoneID,
+ predicate: NSPredicate(value: true),
+ desiredKeys: ["entries"]
+ )
+ var journals: [DeviceJournal] = []
+ for record in journalRecords {
+ guard let (_, authorID, deviceID) =
+ RecordSerializer.parseJournalRecordName(record.recordID.recordName),
+ let asset = record["entries"] as? CKAsset,
+ let url = asset.fileURL
+ else { continue }
+ do {
+ let entries = try JournalCodec.decode(Data(contentsOf: url))
+ journals.append(
+ DeviceJournal(
+ key: JournalDeviceKey(authorID: authorID, deviceID: deviceID),
+ entries: entries
+ )
+ )
+ } catch {
+ await trace(
+ "fetchReplay: journal decode failed for " +
+ "\(record.recordID.recordName): \(describe(error))"
+ )
+ }
+ }
+ return JournalReplayFetch(journals: journals, expectedDevices: expected)
+ }
}
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -11,6 +11,10 @@ struct BatchEffects {
var engagementChanged = Set<UUID>()
var removed = Set<UUID>()
var readCursors: [(UUID, Date)] = []
+ /// Games that just transitioned to completed via an inbound Game record, so
+ /// this device uploads its journal for replay even though it didn't run the
+ /// local completion path.
+ var completedTransitions = Set<UUID>()
}
extension SyncEngine {
@@ -32,7 +36,8 @@ extension SyncEngine {
record,
to: ctx,
databaseScope: scopeValue,
- onEngagementChange: { effects.engagementChanged.insert($0) }
+ onEngagementChange: { effects.engagementChanged.insert($0) },
+ onCompletedTransition: { effects.completedTransitions.insert($0) }
)
if let id = entity.id { effects.affected.insert(id) }
case "Moves":
@@ -105,6 +110,14 @@ extension SyncEngine {
if let onIncomingReadCursor, !effects.readCursors.isEmpty {
await onIncomingReadCursor(effects.readCursors)
}
+ // A game just learned it's complete via sync: upload this device's
+ // journal (no-op if it logged nothing) so replay can converge. The
+ // enqueue defers its CKSyncEngine drain via `sendChangesDetached`.
+ if let localAuthorID, !localAuthorID.isEmpty {
+ for id in effects.completedTransitions {
+ enqueueJournalUpload(gameID: id, authorID: localAuthorID)
+ }
+ }
let pingDeletedGameIDs = Set(deletions.compactMap { deletion -> UUID? in
deletion.0.recordName.hasPrefix("ping-")
? gameID(fromRecordName: deletion.0.recordName) : nil
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -512,7 +512,8 @@ enum RecordSerializer {
_ record: CKRecord,
to context: NSManagedObjectContext,
databaseScope: Int16 = 0,
- onEngagementChange: ((UUID) -> Void)? = nil
+ onEngagementChange: ((UUID) -> Void)? = nil,
+ onCompletedTransition: ((UUID) -> Void)? = nil
) -> GameEntity {
let recordName = record.recordID.recordName
let entity = fetchOrCreate(
@@ -569,8 +570,18 @@ enum RecordSerializer {
}
entity.title = record["title"] as? String ?? entity.title
+ // Capture the prior completion state before overwriting it: a
+ // not-completed → completed transition learned purely via sync (this
+ // device wasn't present when the puzzle was finished) does NOT run the
+ // local completion path, so it never uploads this device's journal.
+ // Replay's strict completeness would then wait on it forever. Signal
+ // the transition so the caller can enqueue the upload.
+ let wasCompleted = entity.completedAt != nil
entity.completedAt = record["completedAt"] as? Date
entity.completedBy = record["completedBy"] as? String
+ if !wasCompleted, entity.completedAt != nil, let id = entity.id {
+ onCompletedTransition?(id)
+ }
// Owner-side share marker — set on the device that created the share
// and round-tripped via the Game record so other owner-devices learn
// the game is shared. On participant devices `databaseScope == 1`
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -914,6 +914,10 @@ actor SyncEngine {
let ctx = persistence.container.newBackgroundContext()
guard let info = zoneInfo(forGameID: gameID, in: ctx),
!info.isAccessRevoked else { return }
+ // A device that logged nothing produces no JournalEntity rows, so the
+ // build-time reap drops the save before it reaches CloudKit — an empty
+ // journal never uploads, so it can't add a phantom device key to
+ // replay's present set. No need to guard the enqueue itself.
let engine = info.scope == 1 ? sharedEngine : privateEngine
guard let engine else { return }
let recordName = RecordSerializer.recordName(
@@ -1200,7 +1204,8 @@ actor SyncEngine {
record,
to: ctx,
databaseScope: scope,
- onEngagementChange: { effects.engagementChanged.insert($0) }
+ onEngagementChange: { effects.engagementChanged.insert($0) },
+ onCompletedTransition: { effects.completedTransitions.insert($0) }
)
if let id = entity.id { effects.affected.insert(id) }
case "Moves":
@@ -1248,10 +1253,11 @@ actor SyncEngine {
effects.removed.insert(gid)
}
case "Journal":
- // Phase 2 upload-only: a collaborator's journal asset is
- // not applied to Core Data here. The replay viewer (Phase
- // 2b) fetches journals on demand; this case marks that
- // omission as deliberate rather than a missing apply path.
+ // Journals are never applied to Core Data from the sync
+ // delegate. The replay loader (`fetchReplay`) pulls every
+ // device's journal on demand with a plain CKQuery when a
+ // finished game's replay is opened, so streaming peers'
+ // logs into the local store here would be wasted work.
break
default:
break
@@ -1311,6 +1317,15 @@ actor SyncEngine {
for id in effects.removed {
if let cb = onGameRemoved { await cb(id) }
}
+ // A game just learned it's complete via sync: upload this device's
+ // journal (no-op if it logged nothing) so replay can converge. The
+ // enqueue defers its CKSyncEngine drain via `sendChangesDetached`, so
+ // it never awaits sync from inside this delegate callback.
+ if let localAuthorID, !localAuthorID.isEmpty {
+ for id in effects.completedTransitions {
+ enqueueJournalUpload(gameID: id, authorID: localAuthorID)
+ }
+ }
let pingDeletedGameIDs = Set(event.deletions.compactMap { deletion -> UUID? in
deletion.recordID.recordName.hasPrefix("ping-")
? gameID(fromRecordName: deletion.recordID.recordName) : nil
diff --git a/Tests/Unit/JournalReplayTests.swift b/Tests/Unit/JournalReplayTests.swift
@@ -0,0 +1,185 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Cross-device replay reconstruction (`ReplayTimeline`) and the strict
+/// completeness gate (`ReplayAssembler`). Both are pure value types, so these
+/// tests build `JournalValue`s directly — no Core Data, no CloudKit.
+@Suite("Journal replay")
+struct JournalReplayTests {
+
+ private func entry(
+ seq: Int64,
+ at seconds: TimeInterval,
+ row: Int,
+ col: Int,
+ letter: String,
+ mark: CellMark = .none,
+ author: String = "author",
+ kind: JournalKind = .input
+ ) -> JournalValue {
+ JournalValue(
+ seq: seq,
+ timestamp: Date(timeIntervalSince1970: seconds),
+ position: GridPosition(row: row, col: col),
+ state: JournalCellState(letter: letter, mark: mark, cellAuthorID: author),
+ actingAuthorID: author,
+ kind: kind,
+ targetSeq: nil,
+ batchID: nil,
+ prevSeqAtCell: nil
+ )
+ }
+
+ private func pos(_ row: Int, _ col: Int) -> GridPosition {
+ GridPosition(row: row, col: col)
+ }
+
+ // MARK: - Merge ordering
+
+ @Test("merge orders every device's entries by timestamp")
+ func mergeOrdersByTimestamp() {
+ let a = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A")
+ let b = entry(seq: 1, at: 30, row: 0, col: 1, letter: "B")
+ let c = entry(seq: 0, at: 20, row: 1, col: 0, letter: "C", author: "other")
+
+ let timeline = ReplayTimeline(merging: [[a, b], [c]])
+
+ #expect(timeline.steps.map(\.state.letter) == ["A", "C", "B"])
+ }
+
+ @Test("same-timestamp ties break deterministically regardless of fetch order")
+ func tiesAreDeterministic() {
+ // Same instant, different acting authors: the tiebreak orders on
+ // author then seq, so fetch order can't change the result.
+ let x = entry(seq: 5, at: 10, row: 0, col: 0, letter: "X", author: "zzz")
+ let y = entry(seq: 0, at: 10, row: 0, col: 1, letter: "Y", author: "aaa")
+
+ let forward = ReplayTimeline(merging: [[x], [y]])
+ let reversed = ReplayTimeline(merging: [[y], [x]])
+
+ #expect(forward.steps.map(\.state.letter) == ["Y", "X"])
+ #expect(forward == reversed)
+ }
+
+ // MARK: - State reconstruction
+
+ @Test("state(through:) replays last-write-per-cell across devices")
+ func stateReconstructsGrid() {
+ let a = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "d1")
+ let c = entry(seq: 0, at: 20, row: 0, col: 1, letter: "C", author: "d2")
+ let b = entry(seq: 1, at: 30, row: 0, col: 0, letter: "B", author: "d1") // overwrites A
+
+ let timeline = ReplayTimeline(merging: [[a, b], [c]])
+ #expect(timeline.count == 3)
+
+ #expect(timeline.state(through: 0).isEmpty)
+
+ let afterFirst = timeline.state(through: 1)
+ #expect(afterFirst[pos(0, 0)]?.letter == "A")
+ #expect(afterFirst[pos(0, 1)] == nil)
+
+ let afterSecond = timeline.state(through: 2)
+ #expect(afterSecond[pos(0, 0)]?.letter == "A")
+ #expect(afterSecond[pos(0, 1)]?.letter == "C")
+
+ let afterThird = timeline.state(through: 3)
+ #expect(afterThird[pos(0, 0)]?.letter == "B")
+ #expect(afterThird[pos(0, 1)]?.letter == "C")
+
+ // count clamps to the available range.
+ #expect(timeline.state(through: 99) == afterThird)
+ #expect(timeline.state(through: -5).isEmpty)
+ }
+
+ @Test("undo rows replay as a normal timestamped restore")
+ func undoReplaysAsRestore() {
+ let typed = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", kind: .input)
+ // An undo carries the restored after-state (empty here) and its own time.
+ let undone = entry(seq: 1, at: 20, row: 0, col: 0, letter: "", kind: .undo)
+
+ let timeline = ReplayTimeline(merging: [[typed, undone]])
+
+ #expect(timeline.state(through: 1)[pos(0, 0)]?.letter == "A")
+ #expect(timeline.state(through: 2)[pos(0, 0)]?.letter == "")
+ }
+
+ @Test("check/reveal marks surface during replay")
+ func marksSurface() {
+ let typed = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A",
+ mark: .pen(checked: nil), kind: .input)
+ let checked = entry(seq: 1, at: 20, row: 0, col: 0, letter: "A",
+ mark: .pen(checked: .wrong), kind: .check)
+
+ let timeline = ReplayTimeline(merging: [[typed, checked]])
+
+ #expect(timeline.state(through: 1)[pos(0, 0)]?.mark == .pen(checked: nil))
+ #expect(timeline.state(through: 2)[pos(0, 0)]?.mark == .pen(checked: .wrong))
+ }
+
+ // MARK: - Completeness gate
+
+ private let d1 = JournalDeviceKey(authorID: "a1", deviceID: "dev1")
+ private let d2 = JournalDeviceKey(authorID: "a1", deviceID: "dev2")
+ private let d3 = JournalDeviceKey(authorID: "a2", deviceID: "dev3")
+
+ @Test("ready once a journal is present for every expected device")
+ func readyWhenComplete() {
+ let peer = entry(seq: 0, at: 20, row: 0, col: 1, letter: "B", author: "a1")
+ let mine = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
+
+ // d2's journal arrives over the network; d1 (local) supplies its own.
+ let fetch = JournalReplayFetch(
+ journals: [DeviceJournal(key: d2, entries: [peer])],
+ expectedDevices: [d1, d2]
+ )
+ let result = ReplayAssembler.assemble(fetch: fetch, localKey: d1, localEntries: [mine])
+
+ guard case .ready(let timeline) = result else {
+ Issue.record("expected .ready, got \(result)")
+ return
+ }
+ #expect(timeline.count == 2)
+ #expect(timeline.steps.map(\.state.letter) == ["A", "B"])
+ }
+
+ @Test("waiting reports how many expected devices haven't uploaded")
+ func waitingWhenIncomplete() {
+ let peer = entry(seq: 0, at: 20, row: 0, col: 1, letter: "B", author: "a1")
+ let mine = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
+
+ // d3 contributed (in expected) but its journal is absent.
+ let fetch = JournalReplayFetch(
+ journals: [DeviceJournal(key: d2, entries: [peer])],
+ expectedDevices: [d1, d2, d3]
+ )
+ let result = ReplayAssembler.assemble(fetch: fetch, localKey: d1, localEntries: [mine])
+
+ #expect(result == .waiting(missing: 1))
+ }
+
+ @Test("local live log overrides a stale uploaded copy of itself")
+ func localOverlayWins() {
+ let stale = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
+ let fresh1 = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", author: "a1")
+ let fresh2 = entry(seq: 1, at: 15, row: 0, col: 1, letter: "B", author: "a1")
+
+ // The fetch already contains d1's own (stale, 1-entry) upload.
+ let fetch = JournalReplayFetch(
+ journals: [DeviceJournal(key: d1, entries: [stale])],
+ expectedDevices: [d1]
+ )
+ let result = ReplayAssembler.assemble(
+ fetch: fetch,
+ localKey: d1,
+ localEntries: [fresh1, fresh2]
+ )
+
+ guard case .ready(let timeline) = result else {
+ Issue.record("expected .ready, got \(result)")
+ return
+ }
+ #expect(timeline.count == 2) // live two-entry log, not the stale one
+ }
+}