commit ad58c7f003eb953fa1c8cf24651eab69d7b12b45
parent 9d21b809d4771521ef6c4b4c6ff68cb299f036f0
Author: Michael Camilleri <[email protected]>
Date: Sun, 31 May 2026 23:38:29 +0900
Add replay support to Success Panels
The cross-device replay data layer merged every device's journal but had
no UI. This commit surfaces it: a scrubber inside the success banner,
above the victory image and scoreboard, whose thumb starts at the far
right (the finished grid) and rewinds the puzzle grid above as it's
dragged left.
ReplayController is the banner's view-model: it loads the replay once
the panel appears and exposes a scrub position, the per-cell grid
override GridView renders, and the playhead cell to highlight. GridView
gains an optional replay overlay — when set it draws each cell's
historical letter, mark, and author colour instead of the live Game,
suppresses selection, and ignores taps; default nil leaves normal play
untouched. At rest (head fully right) the override is nil, so the live
finished grid shows with no recomputation.
Loading is local-first and account-independent. If no other device wrote
into the game — judged from the per-device MovesEntity rows — this
device's journal alone reconstructs the grid, so replays show instantly
with no CloudKit and no signed-in account. Only when another device
contributed does it fall back to the merged fetch, which gates on every
contributor's journal being present and shows a 'waiting to sync
history' state until they are.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
14 files changed, 707 insertions(+), 31 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -24,8 +24,10 @@
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; };
1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
+ 20DC2123F5D488A258409AC4 /* ReplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D53FA98B65B381FF09EB02F9 /* ReplayController.swift */; };
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */; };
267ED5B329F05A30430B73A0 /* EngagementHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */; };
+ 2A8FB9C020B2072659C24C8E /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */; };
2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */; };
2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
@@ -45,6 +47,7 @@
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; };
4C874B69A9D9187490BC2C42 /* FriendAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */; };
+ 4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */; };
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; };
@@ -87,6 +90,7 @@
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
+ 9EC3CD7BAD8A4B9F1B3A5C97 /* ReplayControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */; };
A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; };
A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; };
A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; };
@@ -182,9 +186,11 @@
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMarkCodec.swift; sourceTree = "<group>"; };
+ 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreContributingDevicesTests.swift; sourceTree = "<group>"; };
0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
+ 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactSlider.swift; sourceTree = "<group>"; };
0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; };
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckResult.swift; sourceTree = "<group>"; };
11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; };
@@ -267,6 +273,7 @@
9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; };
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; };
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; };
+ A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControllerTests.swift; sourceTree = "<group>"; };
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; };
A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; };
A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; };
@@ -301,6 +308,7 @@
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
+ D53FA98B65B381FF09EB02F9 /* ReplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayController.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
@@ -376,6 +384,7 @@
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */,
60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */,
BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ 09EA25E2AE98ACD029EC0129 /* GameStoreContributingDevicesTests.swift */,
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */,
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
@@ -401,6 +410,7 @@
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */,
443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
+ A1A05DD7C0602C2BFAF2EF2F /* ReplayControllerTests.swift */,
8C040D5EBC73B1ED47C2C9D4 /* SessionPushPlannerTests.swift */,
4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */,
ABB371EF2574E95782CB05FD /* Sync */,
@@ -481,6 +491,7 @@
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */,
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */,
+ 0CE803B2DF05DDF457B27FE2 /* CompactSlider.swift */,
434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */,
F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */,
EE3412F437AABD2988B6976D /* FriendPickerView.swift */,
@@ -496,6 +507,7 @@
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */,
+ D53FA98B65B381FF09EB02F9 /* ReplayController.swift */,
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */,
B23A692318044351247606DF /* SuccessPanel.swift */,
);
@@ -718,6 +730,7 @@
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */,
85A798525FE1DC98210A9E82 /* GameCursorStoreTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 4D7BF839BA71E1BF0AE9BCE9 /* GameStoreContributingDevicesTests.swift in Sources */,
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
@@ -747,6 +760,7 @@
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
+ 9EC3CD7BAD8A4B9F1B3A5C97 /* ReplayControllerTests.swift in Sources */,
AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */,
07A46496EE0B12FD526F36FB /* SessionPushPlannerTests.swift in Sources */,
BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
@@ -776,6 +790,7 @@
CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */,
E16A8FE849A8E8BCC0F32280 /* CloudZones.swift in Sources */,
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */,
+ 2A8FB9C020B2072659C24C8E /* CompactSlider.swift in Sources */,
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */,
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */,
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */,
@@ -838,6 +853,7 @@
D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */,
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
+ 20DC2123F5D488A258409AC4 /* ReplayController.swift in Sources */,
5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */,
B48DE7079BE2F31D2367C5F7 /* SessionPushPlanner.swift in Sources */,
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -450,7 +450,25 @@ private struct PuzzleDisplayView: View {
await services.sendCompletionPings(gameID: gameID, resigned: true)
}
},
- onDelete: { try store.deleteGame(id: gameID) }
+ onDelete: { try store.deleteGame(id: gameID) },
+ loadReplay: {
+ // Local-first: if no *other* device wrote into this
+ // game, this device's journal is the whole history, so
+ // replay needs no CloudKit. `contributingDevices` reads
+ // the per-device MovesEntity rows — the device-level
+ // signal the author-keyed roster can't give, so it sees
+ // this account's own second device, not just other
+ // people. Any other contributor → merged fetch, which
+ // gates on every contributing device's journal.
+ let entries = store.localJournalEntries(for: gameID)
+ let localDeviceID = RecordSerializer.localDeviceID
+ let otherDevices = store.contributingDevices(for: gameID)
+ .filter { $0.deviceID != localDeviceID }
+ if otherDevices.isEmpty {
+ return .ready(ReplayTimeline(merging: [entries]))
+ }
+ return await services.loadReplay(gameID: gameID)
+ }
)
} else if let loadError {
ContentUnavailableView(
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -673,6 +673,14 @@ final class GameStore {
return DeviceJournal(key: key, entries: movesJournal.recordedEntries(gameID: gameID))
}
+ /// This device's journal entries for a game, independent of iCloud identity.
+ /// The journal is recorded locally as the player types, so it exists even
+ /// with no signed-in account — the local replay path uses this directly
+ /// rather than `localReplaySource`, which needs an authorID to form a key.
+ func localJournalEntries(for gameID: UUID) -> [JournalValue] {
+ movesJournal.recordedEntries(gameID: gameID)
+ }
+
// MARK: - Engagement room
/// The shared live-engagement room creds for `gameID` (an encoded
@@ -1130,6 +1138,26 @@ final class GameStore {
return GridStateMerger.mergeWithProvenance(values).values.map(\.cell)
}
+ /// Every `(author, device)` that has written a `MovesEntity` for `gameID` —
+ /// i.e. every device whose grid letters are present locally. Peers' and this
+ /// account's *other* devices' Moves sync in as their own per-device rows, so
+ /// this is the authoritative local answer to "did anyone else contribute"
+ /// (the roster can't tell, being keyed by author alone). Replay needs a
+ /// journal from each; when the set is just this device the local journal
+ /// already explains the whole grid and no CloudKit fetch is needed.
+ func contributingDevices(for gameID: UUID) -> Set<JournalDeviceKey> {
+ let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
+ request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
+ let entities = (try? context.fetch(request)) ?? []
+ var devices: Set<JournalDeviceKey> = []
+ for entity in entities {
+ guard let authorID = entity.authorID, !authorID.isEmpty,
+ let deviceID = entity.deviceID, !deviceID.isEmpty else { continue }
+ devices.insert(JournalDeviceKey(authorID: authorID, deviceID: deviceID))
+ }
+ return devices
+ }
+
/// Distinct authorIDs that have written a `MovesEntity` for `gameID`,
/// with `excluding` filtered out. The session-summary banner uses this
/// to enumerate peers whose activity it should diff.
diff --git a/Crossmate/Persistence/JournalReplay.swift b/Crossmate/Persistence/JournalReplay.swift
@@ -41,17 +41,33 @@ enum JournalReplayResult: Sendable, Equatable {
/// 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]
+ /// Replay steps in order. Each step is one scrub increment: either a single
+ /// cell touch, or every cell of one batched gesture grouped by `batchID` —
+ /// a bulk reveal/check/clear, or an undo/redo op — so e.g. "reveal puzzle"
+ /// rewinds as a single event rather than cell by cell.
+ 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.
+ /// Every entry in replay order, flattened across steps — for inspection.
+ var entries: [JournalValue] { steps.flatMap { $0 } }
+
+ /// Merges each device's log into one timeline ordered by `timestamp`, then
+ /// coalesces runs of same-`batchID` entries into one step. 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)
+ let sorted = logs.flatMap { $0 }.sorted(by: ReplayTimeline.precedes)
+ var grouped: [[JournalValue]] = []
+ for entry in sorted {
+ if let batchID = entry.batchID, grouped.last?.first?.batchID == batchID {
+ grouped[grouped.count - 1].append(entry)
+ } else {
+ grouped.append([entry])
+ }
+ }
+ steps = grouped
}
private static func precedes(_ a: JournalValue, _ b: JournalValue) -> Bool {
@@ -71,11 +87,20 @@ struct ReplayTimeline: Sendable, Equatable {
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
+ for entry in steps[index] {
+ grid[entry.position] = entry.state
+ }
}
return grid
}
+
+ /// The single cell a step changed — the replay playhead. `nil` for a
+ /// batched gesture, which has no single focus to highlight.
+ func focus(ofStep index: Int) -> GridPosition? {
+ guard steps.indices.contains(index) else { return nil }
+ let step = steps[index]
+ return step.count == 1 ? step.first?.position : nil
+ }
}
/// Composes a `JournalReplayFetch` with this device's *live* journal into a
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -15,6 +15,9 @@ struct BatchEffects {
/// this device uploads its journal for replay even though it didn't run the
/// local completion path.
var completedTransitions = Set<UUID>()
+ /// Games for which an inbound `Journal` record landed — wakes a waiting
+ /// finish-banner replay to re-check completeness.
+ var journalsSynced = Set<UUID>()
}
extension SyncEngine {
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -24,6 +24,12 @@ extension Notification.Name {
/// `Set<UUID>`. `PlayerRoster` observes this to refresh in response
/// to remote name updates and new participants joining.
static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
+
+ /// Posted by `SyncEngine` when an inbound `Journal` record lands for one or
+ /// more games. `userInfo["gameIDs"]` is a `Set<UUID>`. The finish-banner
+ /// replay observes this to re-check completeness the moment a contributor's
+ /// journal syncs, instead of polling.
+ static let replayJournalDidSync = Notification.Name("replayJournalDidSync")
}
@@ -1254,11 +1260,15 @@ actor SyncEngine {
}
case "Journal":
// 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
+ // delegate — the replay loader (`fetchReplay`) pulls them on
+ // demand with a plain CKQuery. But the record landing is the
+ // signal a contributor finished and uploaded, so note the
+ // game to wake a waiting replay banner (posted below).
+ if let (gid, _, _) = RecordSerializer.parseJournalRecordName(
+ record.recordID.recordName
+ ) {
+ effects.journalsSynced.insert(gid)
+ }
default:
break
}
@@ -1340,6 +1350,13 @@ actor SyncEngine {
userInfo: ["gameIDs": effects.affected]
)
}
+ if !effects.journalsSynced.isEmpty {
+ NotificationCenter.default.post(
+ name: .replayJournalDidSync,
+ object: nil,
+ userInfo: ["gameIDs": effects.journalsSynced]
+ )
+ }
}
diff --git a/Crossmate/Views/CompactSlider.swift b/Crossmate/Views/CompactSlider.swift
@@ -0,0 +1,126 @@
+import SwiftUI
+import UIKit
+
+/// A compact `UISlider` wrapped for SwiftUI. SwiftUI's `Slider` renders a fixed
+/// ~28pt system thumb that ignores `.controlSize`; this draws a small white
+/// rounded-rectangle thumb (with a hairline border and soft shadow for depth)
+/// over a thin track whose filled portion takes the player's colour. Reports
+/// integer positions (one per scrub step).
+struct CompactSlider: UIViewRepresentable {
+ @Binding var value: Int
+ let range: ClosedRange<Int>
+ var trackHeight: CGFloat = 1
+ var thumbSize = CGSize(width: 14, height: 14)
+ var thumbCornerRadius: CGFloat = 4
+ /// Filled (minimum) track colour — pass the player's colour. Opaque.
+ var fillColor: UIColor = .systemGray
+ /// Unfilled (maximum) track colour. Neutral so it reads behind any fill.
+ var trackColor: UIColor = .systemGray4
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ func makeUIView(context: Context) -> UISlider {
+ let slider = UISlider()
+ // A custom track image controls the track's thickness (the default is
+ // ~3–4pt). Rendered as a template so the tint colours below drive its
+ // colour (and adapt to light/dark).
+ let track = Self.trackImage(height: trackHeight)
+ slider.setMinimumTrackImage(track, for: .normal)
+ slider.setMaximumTrackImage(track, for: .normal)
+ slider.minimumTrackTintColor = fillColor
+ slider.maximumTrackTintColor = trackColor
+ let thumb = Self.thumbImage(size: thumbSize, cornerRadius: thumbCornerRadius)
+ slider.setThumbImage(thumb, for: .normal)
+ slider.setThumbImage(thumb, for: .highlighted)
+ slider.accessibilityLabel = "Replay position"
+ slider.addTarget(
+ context.coordinator,
+ action: #selector(Coordinator.valueChanged(_:)),
+ for: .valueChanged
+ )
+ // Don't let the small thumb dictate width; fill the row instead.
+ slider.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ slider.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ return slider
+ }
+
+ func updateUIView(_ slider: UISlider, context: Context) {
+ context.coordinator.parent = self
+ slider.minimumValue = Float(range.lowerBound)
+ slider.maximumValue = Float(range.upperBound)
+ // Keep the fill in sync if the player's colour changes.
+ slider.minimumTrackTintColor = fillColor
+ // Don't fight the user's finger: only sync the thumb to `value` when no
+ // drag is in progress.
+ if !slider.isTracking {
+ slider.setValue(Float(value), animated: false)
+ }
+ }
+
+ /// A white rounded-rectangle thumb with a hairline border and a soft drop
+ /// shadow. The image is padded so the shadow isn't clipped; `UISlider`
+ /// centres it on the track, so the padding reads as a slightly larger touch
+ /// target.
+ private static func thumbImage(size: CGSize, cornerRadius: CGFloat) -> UIImage {
+ let shadowBlur: CGFloat = 1.5
+ let shadowOffset = CGSize(width: 0, height: 0.5)
+ let pad = shadowBlur + max(abs(shadowOffset.width), abs(shadowOffset.height)) + 1
+ let imageSize = CGSize(width: size.width + pad * 2, height: size.height + pad * 2)
+ return UIGraphicsImageRenderer(size: imageSize).image { ctx in
+ let cg = ctx.cgContext
+ let rect = CGRect(x: pad, y: pad, width: size.width, height: size.height)
+ let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
+
+ cg.saveGState()
+ cg.setShadow(
+ offset: shadowOffset,
+ blur: shadowBlur,
+ color: UIColor.black.withAlphaComponent(0.3).cgColor
+ )
+ UIColor.white.setFill()
+ path.fill()
+ cg.restoreGState()
+
+ // Hairline border so a white thumb stays defined on a light panel.
+ let borderWidth: CGFloat = 0.5
+ let inset = borderWidth / 2
+ let borderPath = UIBezierPath(
+ roundedRect: rect.insetBy(dx: inset, dy: inset),
+ cornerRadius: max(0, cornerRadius - inset)
+ )
+ UIColor.black.withAlphaComponent(0.12).setStroke()
+ borderPath.lineWidth = borderWidth
+ borderPath.stroke()
+ }
+ }
+
+ /// A `height`-tall capsule, resizable along its width, as an
+ /// `.alwaysTemplate` image so the slider's track tint colours drive its
+ /// colour. The image's height becomes the rendered track thickness.
+ private static func trackImage(height: CGFloat) -> UIImage {
+ let radius = height / 2
+ let size = CGSize(width: height, height: height)
+ let image = UIGraphicsImageRenderer(size: size).image { _ in
+ UIColor.black.setFill() // colour irrelevant: tinted as a template
+ UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: radius).fill()
+ }
+ let inset = max(0, radius - 0.5)
+ return image
+ .resizableImage(
+ withCapInsets: UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset),
+ resizingMode: .stretch
+ )
+ .withRenderingMode(.alwaysTemplate)
+ }
+
+ @MainActor
+ final class Coordinator: NSObject {
+ var parent: CompactSlider
+ init(_ parent: CompactSlider) { self.parent = parent }
+
+ @objc func valueChanged(_ slider: UISlider) {
+ let rounded = Int(slider.value.rounded())
+ if rounded != parent.value { parent.value = rounded }
+ }
+ }
+}
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -4,26 +4,39 @@ struct GridView: View {
@Bindable var session: PlayerSession
let roster: PlayerRoster
let showsSharedAnnotations: Bool
+ /// When non-nil, the grid renders this reconstructed history (finish-banner
+ /// replay) instead of the live `Game`: each touched cell shows the
+ /// after-state at the current scrub position, blanks elsewhere. Live
+ /// selection, word highlight, and taps are suppressed; the `replayCursor`
+ /// cell is highlighted as the playhead. `nil` (the default) leaves normal
+ /// play untouched.
+ var replayCells: [GridPosition: JournalCellState]? = nil
+ var replayCursor: GridPosition? = nil
private let spacing: CGFloat = 1
+ private var isReplaying: Bool { replayCells != nil }
+
var body: some View {
let width = session.puzzle.width
let height = session.puzzle.height
- let tintByCell: [GridPosition: Color] = showsSharedAnnotations ? remoteTrackTints() : [:]
- let authorTintByID: [String: Color] = showsSharedAnnotations
+ let tintByCell: [GridPosition: Color] =
+ (showsSharedAnnotations && !isReplaying) ? remoteTrackTints() : [:]
+ // Author colours are shown for shared live play and for any replay
+ // (the rewind reads as coloured per author, matching the scoreboard).
+ let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying)
? Dictionary(
uniqueKeysWithValues: roster.entries.map { ($0.authorID, $0.color.tint) }
)
: [:]
- let relatedCells = session.puzzle.relatedCells(
+ let relatedCells = isReplaying ? [] : session.puzzle.relatedCells(
atRow: session.selectedRow,
col: session.selectedCol,
direction: session.direction
)
let patternPalette = CrossRefPattern.allCases
let cellGroups = session.puzzle.cellGroups
- let currentWordCells = Set(session.puzzle.wordCells(
+ let currentWordCells: Set<GridPosition> = isReplaying ? [] : Set(session.puzzle.wordCells(
atRow: session.selectedRow,
col: session.selectedCol,
direction: session.direction
@@ -34,11 +47,19 @@ struct GridView: View {
let c = index % width
let pos = GridPosition(row: r, col: c)
let square = session.game.squares[r][c]
+ // During replay the cell's letter/mark/author come from the
+ // reconstructed history, not the live square.
+ let replayCell = replayCells?[pos]
+ let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry
+ let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark
+ let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID
CellView(
cell: session.puzzle.cells[r][c],
- entry: square.entry,
- mark: square.mark,
- isSelected: session.selectedRow == r && session.selectedCol == c,
+ entry: entry,
+ mark: mark,
+ isSelected: isReplaying
+ ? replayCursor == pos
+ : (session.selectedRow == r && session.selectedCol == c),
isHighlighted: currentWordCells.contains(pos),
crossRefPattern: cellGroups[pos].map {
patternPalette[$0 % patternPalette.count]
@@ -48,12 +69,13 @@ struct GridView: View {
gridSpacing: spacing,
isRelatedToFocus: relatedCells.contains(pos),
remoteWordTint: tintByCell[pos],
- authorTint: square.entry.isEmpty
+ authorTint: entry.isEmpty
? nil
- : square.letterAuthorID.flatMap { authorTintByID[$0] }
+ : letterAuthorID.flatMap { authorTintByID[$0] }
)
.equatable()
.onTapGesture {
+ guard !isReplaying else { return }
session.select(row: r, col: c)
}
}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -29,6 +29,9 @@ struct PuzzleView: View {
var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil
var onResign: (() throws -> Void)? = nil
var onDelete: (() throws -> Void)? = nil
+ /// Loads the finished game's merged journal for the finish-banner replay
+ /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it.
+ var loadReplay: () async -> JournalReplayResult = { .unavailable }
@Environment(InputMonitor.self) private var inputMonitor
@Environment(PlayerPreferences.self) private var preferences
@Environment(AnnouncementCenter.self) private var announcements
@@ -45,6 +48,7 @@ struct PuzzleView: View {
@State private var destructiveActionError: String?
@State private var isShowingShareSheet = false
@State private var hasSolved = false
+ @State private var replay = ReplayController()
@State private var padLayout: PadLayout?
@Environment(\.engagementStatus) private var engagementStatus
@@ -290,7 +294,9 @@ struct PuzzleView: View {
GridView(
session: session,
roster: roster,
- showsSharedAnnotations: session.mutator.isShared
+ showsSharedAnnotations: session.mutator.isShared,
+ replayCells: replay.gridOverride,
+ replayCursor: replay.cursor
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -325,7 +331,12 @@ struct PuzzleView: View {
ZStack(alignment: .top) {
if isSolved {
ControlsView(height: controlsPanelHeight) {
- SuccessPanel(session: session, roster: roster)
+ SuccessPanel(
+ session: session,
+ roster: roster,
+ replay: replay,
+ loadReplay: loadReplay
+ )
}
.transition(.move(edge: .bottom))
} else if showsCustomKeyboard {
diff --git a/Crossmate/Views/ReplayController.swift b/Crossmate/Views/ReplayController.swift
@@ -0,0 +1,93 @@
+import Foundation
+import Observation
+
+/// View-model for the finish-banner replay scrubber. Loads a finished game's
+/// merged journal (Phase 2b) once the banner appears, then exposes a scrub
+/// position and the per-cell grid override that `GridView` renders while the
+/// user drags back through history.
+///
+/// Held as `@State` by `PuzzleView` so the position survives re-renders; it is
+/// `.idle` (no override, live grid) until a solved game's banner triggers
+/// `load`.
+@MainActor
+@Observable
+final class ReplayController {
+ enum Status: Equatable {
+ case idle
+ case loading
+ case ready(ReplayTimeline)
+ case waiting(missing: Int)
+ case unavailable
+ }
+
+ private(set) var status: Status = .idle
+
+ /// Bumped by `retry()` so a `.task(id:)` re-fires `load` — the only way to
+ /// re-check after a `.waiting` result without polling.
+ private(set) var reloadToken = 0
+
+ /// Scrub position in `0...timeline.count`. Resting at `count` shows the
+ /// finished grid (override is `nil`, so the live `Game` renders); dragging
+ /// left rewinds. Set to `count` when a timeline loads so the head starts
+ /// hard-right at the end of the game.
+ var position: Int = 0
+
+ var timeline: ReplayTimeline? {
+ if case .ready(let timeline) = status { return timeline }
+ return nil
+ }
+
+ /// A scrubber is offered only for a ready, non-empty timeline.
+ var isScrubbable: Bool {
+ (timeline?.count ?? 0) > 0
+ }
+
+ /// The grid to render at the current position, or `nil` to show the live
+ /// finished grid (head at the far right, or nothing loaded). Recomputed per
+ /// scrub tick — `state(through:)` is O(position), trivial for one game.
+ var gridOverride: [GridPosition: JournalCellState]? {
+ guard let timeline, position < timeline.count else { return nil }
+ return timeline.state(through: position)
+ }
+
+ /// The cell changed by the most recently applied step — the replay
+ /// playhead, highlighted so the eye can follow the rewind. Tracks
+ /// `gridOverride`: `nil` at rest (head at the far right, live grid shown),
+ /// non-nil only while actively scrubbed back.
+ var cursor: GridPosition? {
+ guard let timeline, position > 0, position < timeline.count else { return nil }
+ return timeline.focus(ofStep: position - 1)
+ }
+
+ /// Loads the replay via the caller's loader. Idempotent for an in-flight or
+ /// ready load; a `.waiting` / `.unavailable` result stays retryable, so
+ /// `retry()` (driven by the inbound-journal sync signal, or the manual
+ /// button) can re-check when a contributor's journal finally syncs.
+ func load(_ loader: () async -> JournalReplayResult) async {
+ switch status {
+ case .loading, .ready:
+ return
+ case .idle, .waiting, .unavailable:
+ break
+ }
+ status = .loading
+ let result = await loader()
+ switch result {
+ case .ready(let timeline):
+ status = .ready(timeline)
+ position = timeline.count
+ case .waiting(let missing):
+ status = .waiting(missing: missing)
+ case .unavailable:
+ status = .unavailable
+ }
+ }
+
+ /// Drops back to `.idle` so the next `load` re-checks. Triggered by the
+ /// inbound-journal sync signal (a contributor's journal arrived) and by the
+ /// manual "Check again" affordance.
+ func retry() {
+ status = .idle
+ reloadToken += 1
+ }
+}
diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift
@@ -3,6 +3,11 @@ import SwiftUI
struct SuccessPanel: View {
let session: PlayerSession
let roster: PlayerRoster
+ /// Drives the finish-banner replay scrubber and the grid override above.
+ var replay: ReplayController? = nil
+ /// Loads the merged journal (Phase 2b). Called once when the banner appears
+ /// (and again on "check again" while waiting on a peer's upload).
+ var loadReplay: (() async -> JournalReplayResult)? = nil
@Environment(PlayerPreferences.self) private var preferences
private struct Contribution: Identifiable {
@@ -111,6 +116,37 @@ struct SuccessPanel: View {
}
var body: some View {
+ VStack(spacing: 0) {
+ if let replay {
+ ReplayScrubber(replay: replay, fillColor: preferences.color.tint)
+ }
+ scoreboard
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // Drive the load from the panel itself, not the scrubber subview: the
+ // scrubber renders `EmptyView` until a timeline loads, and SwiftUI skips
+ // a non-rendering view's `.task`. Keyed on `reloadToken` so "check
+ // again" re-fires it.
+ .task(id: replay?.reloadToken) {
+ guard let replay, let loadReplay else { return }
+ await replay.load(loadReplay)
+ }
+ // A contributor's journal just synced — re-check completeness so a
+ // waiting scrubber flips to ready without polling. Same async-sequence
+ // idiom PlayerRoster uses for `.playerRosterShouldRefresh`.
+ .task {
+ let gameID = session.mutator.gameID
+ for await note in NotificationCenter.default.notifications(named: .replayJournalDidSync) {
+ guard let replay, case .waiting = replay.status,
+ let gameIDs = note.userInfo?["gameIDs"] as? Set<UUID>,
+ gameIDs.contains(gameID)
+ else { continue }
+ replay.retry()
+ }
+ }
+ }
+
+ private var scoreboard: some View {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .center, spacing: 8) {
Image(systemName: "checkmark.seal.fill")
@@ -183,3 +219,77 @@ struct SuccessPanel: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+
+/// The replay scrubber that sits atop the finish banner. The thumb starts at
+/// the far right (the finished grid) and dragging left rewinds the puzzle grid
+/// above through its move history. Disabled with a sync caption while a
+/// contributing device's journal is still missing (strict completeness).
+private struct ReplayScrubber: View {
+ @Bindable var replay: ReplayController
+ /// The local player's accent colour, for the filled track.
+ var fillColor: Color
+
+ /// Shared height for every scrubber state (slider, waiting, loading) so the
+ /// banner doesn't change height as it loads, and captions sit vertically
+ /// centred in the same space the slider occupies.
+ private static let rowHeight: CGFloat = 24
+
+ var body: some View {
+ Group {
+ switch replay.status {
+ case .ready(let timeline) where timeline.count > 0:
+ slider(count: timeline.count)
+ case .waiting:
+ waiting
+ case .loading:
+ caption {
+ ProgressView().controlSize(.small)
+ Text("Loading replay…")
+ }
+ case .ready, .idle, .unavailable:
+ // Nothing to replay (empty log) or no reachable history:
+ // leave the banner as just the scoreboard.
+ EmptyView()
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.top, 6)
+ }
+
+ private func slider(count: Int) -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: "clock.arrow.circlepath")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ CompactSlider(value: $replay.position, range: 0...count, fillColor: UIColor(fillColor))
+ Text("\(replay.position)/\(count)")
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.secondary)
+ .frame(minWidth: 48, alignment: .trailing)
+ }
+ .frame(height: Self.rowHeight)
+ }
+
+ private var waiting: some View {
+ caption {
+ Image(systemName: "arrow.triangle.2.circlepath")
+ .font(.footnote)
+ if case .waiting(let missing) = replay.status {
+ Text("Waiting for \(missing) device\(missing == 1 ? "" : "s") to sync history")
+ .lineLimit(1)
+ }
+ Spacer(minLength: 8)
+ Button("Check again") { replay.retry() }
+ .font(.caption.weight(.semibold))
+ .buttonStyle(.borderless)
+ }
+ }
+
+ private func caption<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
+ HStack(spacing: 8, content: content)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .frame(height: Self.rowHeight)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/Tests/Unit/GameStoreContributingDevicesTests.swift b/Tests/Unit/GameStoreContributingDevicesTests.swift
@@ -0,0 +1,95 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// `GameStore.contributingDevices` enumerates the `(author, device)` pairs that
+/// wrote a `MovesEntity` for a game — the local, device-level signal replay uses
+/// to decide whether the local journal alone covers the grid (no other device →
+/// no CloudKit). It must distinguish this account's own second device, which the
+/// author-keyed roster cannot.
+@Suite("GameStore.contributingDevices", .isolatedNotificationState)
+@MainActor
+struct GameStoreContributingDevicesTests {
+
+ private static let puzzleSource = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ AB
+ CD
+ """
+
+ private func makeGame(in ctx: NSManagedObjectContext) throws -> (GameEntity, UUID) {
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Test"
+ entity.puzzleSource = Self.puzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.ckZoneName = "game-\(gameID.uuidString)"
+ entity.databaseScope = 1
+ try ctx.save()
+ return (entity, gameID)
+ }
+
+ private func addMoves(
+ for entity: GameEntity,
+ gameID: UUID,
+ authorID: String,
+ deviceID: String,
+ in ctx: NSManagedObjectContext
+ ) throws {
+ let row = MovesEntity(context: ctx)
+ row.game = entity
+ row.authorID = authorID
+ row.deviceID = deviceID
+ row.cells = try MovesCodec.encode([:])
+ row.updatedAt = Date()
+ row.ckRecordName = RecordSerializer.recordName(
+ forMovesInGame: gameID, authorID: authorID, deviceID: deviceID
+ )
+ try ctx.save()
+ }
+
+ @Test("empty when nobody has written")
+ func emptyWhenNoMoves() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (_, gameID) = try makeGame(in: persistence.viewContext)
+
+ #expect(store.contributingDevices(for: gameID).isEmpty)
+ }
+
+ @Test("one author across two devices yields two device keys")
+ func sameAuthorTwoDevices() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeGame(in: persistence.viewContext)
+
+ try addMoves(for: entity, gameID: gameID, authorID: "alice", deviceID: "iphone", in: persistence.viewContext)
+ try addMoves(for: entity, gameID: gameID, authorID: "alice", deviceID: "ipad", in: persistence.viewContext)
+
+ let devices = store.contributingDevices(for: gameID)
+ #expect(devices == [
+ JournalDeviceKey(authorID: "alice", deviceID: "iphone"),
+ JournalDeviceKey(authorID: "alice", deviceID: "ipad"),
+ ])
+ }
+
+ @Test("distinct authors are kept separate")
+ func distinctAuthors() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let (entity, gameID) = try makeGame(in: persistence.viewContext)
+
+ try addMoves(for: entity, gameID: gameID, authorID: "alice", deviceID: "iphone", in: persistence.viewContext)
+ try addMoves(for: entity, gameID: gameID, authorID: "bob", deviceID: "iphone", in: persistence.viewContext)
+
+ #expect(store.contributingDevices(for: gameID).count == 2)
+ }
+}
diff --git a/Tests/Unit/JournalReplayTests.swift b/Tests/Unit/JournalReplayTests.swift
@@ -17,7 +17,8 @@ struct JournalReplayTests {
letter: String,
mark: CellMark = .none,
author: String = "author",
- kind: JournalKind = .input
+ kind: JournalKind = .input,
+ batchID: UUID? = nil
) -> JournalValue {
JournalValue(
seq: seq,
@@ -27,7 +28,7 @@ struct JournalReplayTests {
actingAuthorID: author,
kind: kind,
targetSeq: nil,
- batchID: nil,
+ batchID: batchID,
prevSeqAtCell: nil,
direction: nil
)
@@ -47,7 +48,7 @@ struct JournalReplayTests {
let timeline = ReplayTimeline(merging: [[a, b], [c]])
- #expect(timeline.steps.map(\.state.letter) == ["A", "C", "B"])
+ #expect(timeline.entries.map(\.state.letter) == ["A", "C", "B"])
}
@Test("same-timestamp ties break deterministically regardless of fetch order")
@@ -60,7 +61,7 @@ struct JournalReplayTests {
let forward = ReplayTimeline(merging: [[x], [y]])
let reversed = ReplayTimeline(merging: [[y], [x]])
- #expect(forward.steps.map(\.state.letter) == ["Y", "X"])
+ #expect(forward.entries.map(\.state.letter) == ["Y", "X"])
#expect(forward == reversed)
}
@@ -119,6 +120,25 @@ struct JournalReplayTests {
#expect(timeline.state(through: 2)[pos(0, 0)]?.mark == .pen(checked: .wrong))
}
+ @Test("a batched gesture (e.g. reveal puzzle) is a single replay step")
+ func batchedGestureIsOneStep() {
+ let batch = UUID()
+ let reveals = [
+ entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", kind: .reveal, batchID: batch),
+ entry(seq: 1, at: 10, row: 0, col: 1, letter: "B", kind: .reveal, batchID: batch),
+ entry(seq: 2, at: 10, row: 1, col: 0, letter: "C", kind: .reveal, batchID: batch),
+ ]
+ // A single letter typed earlier is its own step; the reveal is one more.
+ let typed = entry(seq: 0, at: 5, row: 2, col: 2, letter: "Z")
+ let timeline = ReplayTimeline(merging: [[typed], reveals])
+
+ #expect(timeline.count == 2) // typed, then the whole reveal
+ #expect(timeline.state(through: 1).count == 1)
+ #expect(timeline.state(through: 2).count == 4) // reveal lands all three at once
+ #expect(timeline.focus(ofStep: 0) == pos(2, 2)) // single cell has a playhead
+ #expect(timeline.focus(ofStep: 1) == nil) // batch has none
+ }
+
// MARK: - Completeness gate
private let d1 = JournalDeviceKey(authorID: "a1", deviceID: "dev1")
@@ -142,7 +162,7 @@ struct JournalReplayTests {
return
}
#expect(timeline.count == 2)
- #expect(timeline.steps.map(\.state.letter) == ["A", "B"])
+ #expect(timeline.entries.map(\.state.letter) == ["A", "B"])
}
@Test("waiting reports how many expected devices haven't uploaded")
diff --git a/Tests/Unit/ReplayControllerTests.swift b/Tests/Unit/ReplayControllerTests.swift
@@ -0,0 +1,92 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Derived scrub state on `ReplayController` — the glue between a loaded
+/// `ReplayTimeline` and the grid override the finish banner renders.
+@Suite("Replay controller")
+@MainActor
+struct ReplayControllerTests {
+
+ private func entry(seq: Int64, at seconds: TimeInterval, row: Int, col: Int, letter: String) -> JournalValue {
+ JournalValue(
+ seq: seq,
+ timestamp: Date(timeIntervalSince1970: seconds),
+ position: GridPosition(row: row, col: col),
+ state: JournalCellState(letter: letter, mark: .none, cellAuthorID: "a"),
+ actingAuthorID: "a",
+ kind: .input,
+ targetSeq: nil,
+ batchID: nil,
+ prevSeqAtCell: nil,
+ direction: nil
+ )
+ }
+
+ private func timeline() -> ReplayTimeline {
+ ReplayTimeline(merging: [[
+ entry(seq: 0, at: 10, row: 0, col: 0, letter: "A"),
+ entry(seq: 1, at: 20, row: 0, col: 1, letter: "B"),
+ ]])
+ }
+
+ @Test("a ready load parks the head at the end with no override")
+ func readyParksAtEnd() async {
+ let controller = ReplayController()
+ let tl = timeline()
+ await controller.load { .ready(tl) }
+
+ #expect(controller.isScrubbable)
+ #expect(controller.position == 2) // hard-right = finished grid
+ #expect(controller.gridOverride == nil) // at rest, render the live grid
+ #expect(controller.cursor == nil)
+ }
+
+ @Test("scrubbing left reconstructs the grid and the playhead")
+ func scrubbingRewinds() async {
+ let controller = ReplayController()
+ let tl = timeline()
+ await controller.load { .ready(tl) }
+
+ controller.position = 1
+ #expect(controller.gridOverride?[GridPosition(row: 0, col: 0)]?.letter == "A")
+ #expect(controller.gridOverride?[GridPosition(row: 0, col: 1)] == nil)
+ #expect(controller.cursor == GridPosition(row: 0, col: 0))
+
+ controller.position = 0
+ #expect(controller.gridOverride?.isEmpty == true)
+ #expect(controller.cursor == nil)
+ }
+
+ @Test("a waiting load is not scrubbable")
+ func waitingNotScrubbable() async {
+ let controller = ReplayController()
+ await controller.load { .waiting(missing: 2) }
+
+ #expect(!controller.isScrubbable)
+ #expect(controller.gridOverride == nil)
+ if case .waiting(let missing) = controller.status {
+ #expect(missing == 2)
+ } else {
+ Issue.record("expected .waiting, got \(controller.status)")
+ }
+ }
+
+ @Test("load is idempotent once ready, but retry re-opens it")
+ func retryReloads() async {
+ let controller = ReplayController()
+ await controller.load { .ready(timeline()) }
+
+ // A second load while ready is a no-op (loader must not run).
+ await controller.load {
+ Issue.record("loader ran while already ready")
+ return .unavailable
+ }
+ #expect(controller.isScrubbable)
+
+ controller.retry()
+ await controller.load { .waiting(missing: 1) }
+ #expect(!controller.isScrubbable)
+ }
+}