crossmate

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

commit 26ebc118492f085f6c0abd9b263c279abb6586a3
parent 4d6481378b8ed6a353a5a86dbcec9e543d0215bf
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 08:43:28 +0900

Archive finished shared games to each player's private zone

A collaborator's shared game lives only in the owner's shared zone. When
the owner deletes it the collaborator's local copy survives (the zone
deletion flips isAccessRevoked rather than purging), but it is no longer
backed by any CloudKit zone, so a new device or a reinstall loses it.
This commit gives every participant a durable, cross-device copy: when a
shared game finishes, each player writes a self-contained snapshot —
final grid plus the full multi-author move journal — into a zone in
their own private database.

The snapshot is deliberately not a clone of the live multi-record game.
The live representation keys one Core Data entity to one CKRecord
identity tied to the shared zone, and the journal-upload path only
uploads this device's own rows, so it cannot reproduce the full journal
replay needs. Instead the Archive type folds everything into a single
Archive record (puzzleSource, the final cells, and one journal asset per
contributing device) under a deterministic archiveGameID derived from
the original, so every one of the player's devices agrees on it while it
stays distinct from the live original. GameArchiver writes it with a raw
private-DB operation; the inbound applier is inert where the live
original still exists and hydrates a standalone completed, owned game
where it doesn't (fresh install, or after the original is revoked). The
per-device journal rows are stored sourceDeviceID-tagged with
replayCacheComplete set, so the existing replay reader serves the full
timeline straight from Core Data with no shared zone to fetch.

Because peers upload their journals at their own completion — almost
never before the archiving device finishes — the archive converges
rather than writing once: each foreground freshen re-fetches the shared
zone, folds in whatever has since uploaded (and any peers a sibling
device already archived), and force-overwrites, skipping the write when
nothing new arrived. archivedAt is set only once a journal is present
for every device that wrote grid state, which is what stops the
reconcile sweep from reconsidering the game. On revocation the promotion
prefers the converged cloud copy over the local rebuild, falling back to
local data only when the archive is unreachable.

The completion archive runs inside the same background assertion as the
completion journal upload, after flushing both the cell buffer and the
journal queue, so the snapshot can't capture a stale grid or miss the
winning move.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 2++
MCrossmate/Persistence/GameStore.swift | 11+++++++++++
MCrossmate/Services/AppServices.swift | 37++++++++++++++++++++++++++++++++-----
ACrossmate/Sync/Archive.swift | 415+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/GameArchiver.swift | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/RecordApplier.swift | 29+++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 8++++++++
ATests/Unit/ArchiveTests.swift | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1114 insertions(+), 5 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34489D0864DF76AF436E391 /* NYTPuzzleUpgraderTests.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */; }; 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; }; @@ -94,6 +95,7 @@ 9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; }; A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; }; A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; + A65F99414F8CF6704567BB07 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C18E9B47668E008BE4CF86 /* Archive.swift */; }; A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; }; A7FA870D794CA00F7F3F05D2 /* EngagementHostEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400B3C87248F3FCA3F76400B /* EngagementHostEnvironment.swift */; }; A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; }; @@ -133,6 +135,7 @@ D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD63A9B20168F3B81AF4348F /* RecordApplier.swift */; }; + DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */; }; DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; @@ -205,6 +208,7 @@ 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 18C701DAE36000DE19F7CC95 /* EngagementHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementHost.swift; sourceTree = "<group>"; }; + 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameArchiver.swift; sourceTree = "<group>"; }; 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>"; }; @@ -282,6 +286,7 @@ 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>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; + A8C18E9B47668E008BE4CF86 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.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>"; }; ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; @@ -298,6 +303,7 @@ B766E872B12DC79ECCD80941 /* FriendModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendModelTests.swift; sourceTree = "<group>"; }; B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalogTests.swift; sourceTree = "<group>"; }; B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; }; + B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveTests.swift; sourceTree = "<group>"; }; BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; }; BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeReapTests.swift; sourceTree = "<group>"; }; BD63A9B20168F3B81AF4348F /* RecordApplier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordApplier.swift; sourceTree = "<group>"; }; @@ -351,6 +357,7 @@ 074C2962E79CAE6C0EA6431A /* Sync */ = { isa = PBXGroup; children = ( + A8C18E9B47668E008BE4CF86 /* Archive.swift */, B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */, 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */, 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */, @@ -358,6 +365,7 @@ 8FDE03B4A77A8095ED2C23AB /* EngagementCoordinator.swift */, E655698481325C92EF5C348B /* FriendController.swift */, 7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */, + 1B7539E0AD285C5A3AC3DDA2 /* GameArchiver.swift */, 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */, 86470163BFF956F3DE438506 /* Moves.swift */, 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */, @@ -388,6 +396,7 @@ isa = PBXGroup; children = ( 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */, + B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, 0E230B327585E1E3A2921C92 /* GameStoreCompletionLockTests.swift */, @@ -732,6 +741,7 @@ files = ( 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */, D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */, + DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, @@ -791,6 +801,7 @@ AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */, 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */, 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */, + A65F99414F8CF6704567BB07 /* Archive.swift in Sources */, 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */, AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, @@ -817,6 +828,7 @@ 886A451A134D88B9324D9A1C /* FriendPickerView.swift in Sources */, 2AF2550B08CE79F8615B3076 /* FriendZone.swift in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, + 5992AD4A06D7C6440825E9C6 /* GameArchiver.swift in Sources */, 128915DB37018EE4CC16C856 /* GameCursorStore.swift in Sources */, 818B1F2693962832BE14578E /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -9,6 +9,8 @@ <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> <attribute name="ckZoneName" optional="YES" attributeType="String"/> <attribute name="ckZoneOwnerName" optional="YES" attributeType="String"/> + <attribute name="archivedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="archiveGameID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="completedBy" optional="YES" attributeType="String"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -749,6 +749,17 @@ final class GameStore { await movesJournal.flush() } + /// Drains both the cell-write buffer and the journal queue so a reader on a + /// fresh background context sees the *finished* grid and this device's full + /// local log — rather than buffered-but-unpersisted state. The completion + /// archive snapshots Core Data directly, so it must run after this; the + /// winning move in particular is still in flight when `persistCompletion` + /// returns. + func flushCompletionWrites() async { + await movesUpdater.flush() + await movesJournal.flush() + } + /// 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 diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -97,6 +97,7 @@ final class AppServices { private var sessionAnnouncements = SessionAnnouncementLog() let shareController: ShareController let friendController: FriendController + let gameArchiver: GameArchiver let cursorStore: GameCursorStore let engagementStore: EngagementStore let cloudService: CloudService @@ -316,6 +317,13 @@ final class AppServices { syncMonitor: self.syncMonitor, eventLog: eventLog ) + self.gameArchiver = GameArchiver( + container: self.ckContainer, + persistence: persistence, + syncEngine: syncEngine, + syncMonitor: self.syncMonitor, + eventLog: eventLog + ) self.cloudService = CloudService( container: self.ckContainer, syncEngine: syncEngine, @@ -526,13 +534,18 @@ final class AppServices { await self.reconcilePushRegistration() } - await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements] gameID in + await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor, announcements, gameArchiver] gameID in store.markAccessRevoked(gameID: gameID) sessionMonitor.clearMovesSnapshots(for: gameID, by: nil) // Surface the revocation as a sticky, input-blocking banner on // the open puzzle, replacing the former AccessRevokedBanner // overlay. Game-scoped, so it only shows for this puzzle. announcements.post(.accessRevoked(gameID: gameID)) + // The owner deleted the shared zone. For a *finished* game, swap the + // revoked tombstone for a durable owned copy rebuilt from the + // private-zone archive (and from the still-present local data); + // in-progress games are left as revoked rows. + await gameArchiver.promoteRevoked(gameID: gameID) } await syncEngine.setOnGameRemoved { [weak self, store, sessionMonitor, announcements] gameID in @@ -1622,6 +1635,11 @@ final class AppServices { ) await refreshSnapshot() await reconcilePendingJournalUploads() + // Level-triggered backstop for the private-DB archive, mirroring the + // journal sweep above: re-attempts any completed participant game whose + // archive never landed — a completion the app was killed during, or one + // that predates this feature. + await gameArchiver.reconcileUnarchived() } /// Level-triggered backstop for replay journal uploads. The upload is @@ -1681,10 +1699,19 @@ final class AppServices { func beginCompletionJournalUpload(gameID: UUID, authorID: String) { ensureInBackground("journal-upload-\(gameID.uuidString)") { [weak self] in guard let self else { return } - await self.store.flushJournal() - if self.preferences.isICloudSyncEnabled { - await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID) - } + // Flush the cell buffer and journal queue first, so both the journal + // upload and the archive snapshot see the finished grid and full log + // (the winning move is still in flight when completion fires). The + // flush persists local entries regardless of iCloud; the cloud-bound + // steps below are gated on it. + await self.store.flushCompletionWrites() + guard self.preferences.isICloudSyncEnabled else { return } + await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID) + // Snapshot finished participant games to this user's private DB for + // cross-device durability. A no-op for owned games (already durable) + // and ones already archived; sequenced after the flush so it can't + // capture a stale grid or miss the final journal rows. + await self.gameArchiver.archiveIfNeeded(gameID: gameID) } } diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -0,0 +1,415 @@ +import CloudKit +import CoreData +import CryptoKit +import Foundation + +/// Serialization + materialization for the private-zone archive of a finished +/// shared game. +/// +/// When a participant (not the owner) finishes a shared game, that game's data +/// lives only in the owner's shared zone; if the owner later deletes it, the +/// participant keeps a local copy but has no CloudKit backing, so a new device +/// or reinstall loses it. To close that gap each participant writes a +/// self-contained snapshot — final grid + the full multi-author move journal — +/// into a zone in *their own* private database. A finished game is immutable +/// (`isCompleted` latches at completion), so the snapshot needs no +/// reconciliation: it is written once and only ever read back to rebuild a +/// standalone completed game on another device or after the original is revoked. +/// +/// The snapshot is deliberately *not* a clone of the live multi-record game. +/// The live representation keys one Core Data entity to one `CKRecord` identity +/// tied to the shared zone (`RecordBuilder`), and the journal-upload path only +/// uploads *this device's own* rows — so it cannot reproduce the full +/// multi-author journal replay needs. Instead everything is folded into a single +/// `Archive` record carrying `puzzleSource`, the final cells, and one +/// merged journal asset. +enum Archive { + static let recordType = "Archive" + + /// Namespace for deriving the archive's game id. A fixed random UUID used as + /// the v5 namespace so `archiveGameID(for:)` is stable across the + /// participant's own devices yet distinct from the original game id. + private static let namespace = UUID(uuidString: "1F8B0E2A-3C4D-5E6F-7A8B-9C0D1E2F3A4B")! + + // MARK: - Identity + + /// The deterministic game id of the archived copy. Derived from the original + /// game id so every one of the participant's devices computes the same value + /// (idempotent re-writes, last-writer-wins on a frozen record) while staying + /// distinct from `originalGameID` — the authoring device still holds the live + /// original under that id, and Core Data fetches it by `id`. + static func archiveGameID(for originalGameID: UUID) -> UUID { + var hasher = Insecure.SHA1() + hasher.update(data: withUnsafeBytes(of: namespace.uuid) { Data($0) }) + hasher.update(data: withUnsafeBytes(of: originalGameID.uuid) { Data($0) }) + let digest = Array(hasher.finalize()) + var bytes = Array(digest.prefix(16)) + // Stamp version (5) and RFC 4122 variant bits, like a real v5 UUID. + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + let uuid = ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + ) + return UUID(uuid: uuid) + } + + static func zoneID(forOriginalGameID gameID: UUID) -> CKRecordZone.ID { + CKRecordZone.ID( + zoneName: "archive-\(gameID.uuidString)", + ownerName: CKCurrentUserDefaultName + ) + } + + static func recordName(forOriginalGameID gameID: UUID) -> String { + "archive-\(gameID.uuidString)" + } + + /// The original game id encoded in an `archive-<UUID>` record/zone name, or + /// `nil` if the name doesn't match. + static func originalGameID(fromName name: String) -> UUID? { + guard name.hasPrefix("archive-") else { return nil } + return UUID(uuidString: String(name.dropFirst("archive-".count))) + } + + static func isArchiveZone(_ zoneName: String) -> Bool { + zoneName.hasPrefix("archive-") + } + + // MARK: - Final-grid wire format + + /// The final state of one cell, captured so the materialized game renders + /// (and its library thumbnail fills) without replaying the journal. + struct Cell: Codable, Equatable { + let row: Int16 + let col: Int16 + let letter: String + let markCode: Int16 + let letterAuthorID: String? + } + + private static func encodeCells(_ cells: [Cell]) throws -> Data { + try JSONEncoder().encode(cells.sorted { + ($0.row, $0.col) < ($1.row, $1.col) + }) + } + + private static func decodeCells(_ data: Data) throws -> [Cell] { + try JSONDecoder().decode([Cell].self, from: data) + } + + // MARK: - Per-device journal wire format + + /// One device's log on the wire: its `(authorID, deviceID)` key plus the same + /// `JournalCodec` payload the live `Journal` records use, so encoding fidelity + /// matches replay exactly. + private struct DeviceJournalWire: Codable { + let authorID: String + let deviceID: String + let entries: Data + } + + private static func encodeJournals(_ journals: [DeviceJournal]) throws -> Data { + let wire = try journals + .sorted { ($0.key.authorID, $0.key.deviceID) < ($1.key.authorID, $1.key.deviceID) } + .map { + DeviceJournalWire( + authorID: $0.key.authorID, + deviceID: $0.key.deviceID, + entries: try JournalCodec.encode($0.entries) + ) + } + return try JSONEncoder().encode(wire) + } + + private static func decodeJournals(_ data: Data) throws -> [DeviceJournal] { + try JSONDecoder().decode([DeviceJournalWire].self, from: data).map { + DeviceJournal( + key: JournalDeviceKey(authorID: $0.authorID, deviceID: $0.deviceID), + entries: (try? JournalCodec.decode($0.entries)) ?? [] + ) + } + } + + // MARK: - Snapshot taken from local Core Data + + /// Everything needed to build (or rebuild) the archive record, read off the + /// local game on a background context at archive time. + struct Snapshot { + let originalGameID: UUID + let title: String + let puzzleSource: String + let completedAt: Date + let completedBy: String? + 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 + /// assembler merges one log per device and gates on every expected device + /// being present. See `GameArchiver` for how peers' logs are gathered. + let journal: [DeviceJournal] + } + + /// Reads the local game's finished state, with the journal grouped by + /// contributing device. Returns `nil` if the game is not a completed game or + /// required fields are missing. The journal here is *local only* — this + /// device's own log plus any peer logs already cached for replay; + /// `GameArchiver` augments it with a `fetchReplay` of the shared zone while it + /// is still reachable. + static func snapshot( + forGameID gameID: UUID, + in ctx: NSManagedObjectContext + ) -> Snapshot? { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first, + let completedAt = entity.completedAt, + let source = entity.puzzleSource, !source.isEmpty + else { return nil } + + let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] + let cells = cellEntities.map { + Cell( + row: $0.row, + col: $0.col, + letter: $0.letter ?? "", + markCode: $0.markCode, + letterAuthorID: $0.letterAuthorID + ) + } + + return Snapshot( + originalGameID: gameID, + title: entity.title ?? "", + puzzleSource: source, + completedAt: completedAt, + completedBy: entity.completedBy, + cells: cells, + journal: localDeviceJournals(forGameID: gameID, in: ctx) + ) + } + + /// 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. + static func localDeviceJournals( + forGameID gameID: UUID, + in ctx: NSManagedObjectContext + ) -> [DeviceJournal] { + let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") + req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) + req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] + let rows = (try? ctx.fetch(req)) ?? [] + + var byKey: [JournalDeviceKey: [JournalValue]] = [:] + for row in rows { + let key: JournalDeviceKey + if let device = row.sourceDeviceID { + key = JournalDeviceKey(authorID: row.sourceAuthorID ?? "", deviceID: device) + } else { + // This device's own log: keyed to the local device, authored by + // whoever typed it (consistently the local user). + key = JournalDeviceKey( + authorID: row.actingAuthorID ?? "", + deviceID: RecordSerializer.localDeviceID + ) + } + byKey[key, default: []].append(MovesJournal.value(from: row)) + } + return byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } + } + + /// Merges peer logs (e.g. from a `fetchReplay`) into a snapshot's journal, + /// keeping the local copy of any device already present (it is the + /// authoritative, possibly-fresher log for this device). + static func merging( + _ snapshot: Snapshot, + peerJournals: [DeviceJournal] + ) -> Snapshot { + var byKey: [JournalDeviceKey: [JournalValue]] = [:] + for journal in peerJournals { byKey[journal.key] = journal.entries } + for journal in snapshot.journal { byKey[journal.key] = journal.entries } + return Snapshot( + originalGameID: snapshot.originalGameID, + title: snapshot.title, + puzzleSource: snapshot.puzzleSource, + completedAt: snapshot.completedAt, + completedBy: snapshot.completedBy, + cells: snapshot.cells, + journal: byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } + ) + } + + // MARK: - Record building + + /// Builds the freshly-minted `Archive` record for a snapshot. Write-once + /// and immutable, so — like `Ping`/`Journal` — there is no system-fields + /// archive: a re-write of an already-stored archive is a benign conflict the + /// save path treats as success. + static func record(from snapshot: Snapshot) throws -> CKRecord { + let zone = zoneID(forOriginalGameID: snapshot.originalGameID) + let recordID = CKRecord.ID( + recordName: recordName(forOriginalGameID: snapshot.originalGameID), + zoneID: zone + ) + let record = CKRecord(recordType: recordType, recordID: recordID) + record["originalGameID"] = snapshot.originalGameID.uuidString as CKRecordValue + record["archiveGameID"] = archiveGameID(for: snapshot.originalGameID).uuidString as CKRecordValue + record["title"] = snapshot.title as CKRecordValue + record["completedAt"] = snapshot.completedAt as CKRecordValue + if let completedBy = snapshot.completedBy { + record["completedBy"] = completedBy as CKRecordValue + } + + record["puzzleSource"] = try asset(for: Data(snapshot.puzzleSource.utf8), ext: "xd") + record["cells"] = try asset(for: try encodeCells(snapshot.cells), ext: "json") + record["journal"] = try asset(for: try encodeJournals(snapshot.journal), ext: "json") + return record + } + + private static func asset(for data: Data, ext: String) throws -> CKAsset { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(ext) + try data.write(to: url, options: .atomic) + return CKAsset(fileURL: url) + } + + // MARK: - Materialization + + /// The decoded payload of an inbound `Archive` record. + struct Payload { + let originalGameID: UUID + let archiveGameID: UUID + let title: String + let puzzleSource: String + let completedAt: Date + let completedBy: String? + let cells: [Cell] + let journal: [DeviceJournal] + } + + /// Builds the materialization payload directly from a local snapshot, + /// without round-tripping through CloudKit. Used to promote the archive on + /// revocation while still offline — the local game data is fully present, so + /// the cloud copy need not have landed back. + static func payload(from snapshot: Snapshot) -> Payload { + Payload( + originalGameID: snapshot.originalGameID, + archiveGameID: archiveGameID(for: snapshot.originalGameID), + title: snapshot.title, + puzzleSource: snapshot.puzzleSource, + completedAt: snapshot.completedAt, + completedBy: snapshot.completedBy, + cells: snapshot.cells, + journal: snapshot.journal + ) + } + + static func payload(from record: CKRecord) -> Payload? { + guard record.recordType == recordType, + let originalString = record["originalGameID"] as? String, + let originalGameID = UUID(uuidString: originalString), + let archiveString = record["archiveGameID"] as? String, + let archiveGameID = UUID(uuidString: archiveString), + let completedAt = record["completedAt"] as? Date + else { return nil } + + let puzzleSource = (record["puzzleSource"] as? CKAsset) + .flatMap { $0.fileURL } + .flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? "" + let cells = (record["cells"] as? CKAsset) + .flatMap { $0.fileURL } + .flatMap { try? Data(contentsOf: $0) } + .flatMap { try? decodeCells($0) } ?? [] + let journal = (record["journal"] as? CKAsset) + .flatMap { $0.fileURL } + .flatMap { try? Data(contentsOf: $0) } + .flatMap { try? decodeJournals($0) } ?? [] + + return Payload( + originalGameID: originalGameID, + archiveGameID: archiveGameID, + title: record["title"] as? String ?? "", + puzzleSource: puzzleSource, + completedAt: completedAt, + completedBy: record["completedBy"] as? String, + cells: cells, + journal: journal + ) + } + + /// Rebuilds a standalone completed, owned game from an archive payload, under + /// the derived `archiveGameID`. Idempotent: a second application of the same + /// (frozen) archive is a no-op once the row exists. The created row is never + /// enqueued for sync, so it pushes no Game/Moves/Player record — the + /// `Archive` record in the private zone remains its only cloud identity. + /// + /// Each contributing device's log is written as `sourceDeviceID`-tagged + /// `JournalEntity` rows and `replayCacheComplete` is set, so the existing + /// replay path (`GameStore.cachedRemoteJournals`) serves the full merged + /// timeline straight from Core Data — no shared zone to fetch from. + @discardableResult + static func materialize( + _ payload: Payload, + in ctx: NSManagedObjectContext + ) -> GameEntity? { + guard !payload.puzzleSource.isEmpty else { return nil } + let archiveID = payload.archiveGameID + + let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") + existing.predicate = NSPredicate(format: "id == %@", archiveID as CVarArg) + existing.fetchLimit = 1 + if let row = try? ctx.fetch(existing).first { return row } + + let entity = GameEntity(context: ctx) + entity.id = archiveID + // A sentinel record name: distinct from the `game-` form so no sync + // path mistakes the archive for a pushable Game record, while staying + // non-nil for code that fetches games by `ckRecordName`. + entity.ckRecordName = recordName(forOriginalGameID: payload.originalGameID) + entity.ckZoneName = zoneID(forOriginalGameID: payload.originalGameID).zoneName + entity.ckZoneOwnerName = nil + entity.databaseScope = 0 + entity.title = payload.title + entity.puzzleSource = payload.puzzleSource + entity.completedAt = payload.completedAt + entity.completedBy = payload.completedBy + entity.createdAt = payload.completedAt + entity.updatedAt = payload.completedAt + entity.archivedAt = payload.completedAt + entity.archiveGameID = archiveID + // Every contributor's log is captured below, so the replay cache is + // complete by construction — replay reads it locally, never the + // (now-gone) shared zone. + entity.replayCacheComplete = true + + for cell in payload.cells { + let row = CellEntity(context: ctx) + row.game = entity + row.row = cell.row + row.col = cell.col + row.letter = cell.letter + row.markCode = cell.markCode + row.letterAuthorID = cell.letterAuthorID + } + + // Each device's log is stored as `sourceDeviceID`-tagged rows so the + // replay reader treats every author — including the archiving user's own + // historical moves — as a cached contributor (the archived game has no + // *live* local journal to overlay). + for deviceJournal in payload.journal { + for value in deviceJournal.entries { + let row = JournalEntity(context: ctx) + row.game = entity + MovesJournal.assign(value, to: row, gameID: archiveID) + row.sourceAuthorID = deviceJournal.key.authorID + row.sourceDeviceID = deviceJournal.key.deviceID + } + } + + return entity + } +} diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -0,0 +1,239 @@ +import CloudKit +import CoreData +import Foundation + +/// Writes the private-zone archive of a finished shared game, and promotes it to +/// a normal completed game when the original is revoked. +/// +/// See `Archive` for the why. This type owns the side effects: it +/// creates the `archive-<gameID>` zone in the participant's *private* database +/// and saves the snapshot record there with a raw `CKModifyRecordsOperation` +/// (mirroring `FriendController`'s raw private-DB writes), rather than routing +/// through `CKSyncEngine`'s entity-driven push path — the archive is not backed +/// by a normal sync entity. Every device (including the author) then receives +/// the record back through the private engine's `fetchedRecordZoneChanges` and +/// applies it (`SyncEngine` `Archive` case): inert where the live original +/// still exists, hydrated into a completed owned game where it doesn't. +@MainActor +final class GameArchiver { + private let container: CKContainer + private let persistence: PersistenceController + private let syncEngine: SyncEngine + private let syncMonitor: SyncMonitor? + private let eventLog: EventLog? + + init( + container: CKContainer, + persistence: PersistenceController, + syncEngine: SyncEngine, + syncMonitor: SyncMonitor? = nil, + eventLog: EventLog? = nil + ) { + self.container = container + self.persistence = persistence + self.syncEngine = syncEngine + self.syncMonitor = syncMonitor + self.eventLog = eventLog + } + + // MARK: - Write + + /// Archives a just-finished participant game, refreshing the snapshot until + /// every contributing device's journal is captured. A no-op for owned games + /// (already durable in the owner's own private DB) and for games already + /// marked complete (`archivedAt != nil`). + /// + /// Convergence: at completion the peers' journals almost never exist yet — + /// they upload at *their* own completion — so the first pass usually captures + /// only this device's log. Each later call (driven by the reconcile sweep) + /// re-fetches the shared zone, folds in whatever has since uploaded, and + /// force-overwrites the archive. `archivedAt` is set only once the log is + /// complete (one journal per device that wrote grid state), which is what + /// stops the sweep from reconsidering the game. + func archiveIfNeeded(gameID: UUID) async { + let ctx = persistence.container.newBackgroundContext() + let local: Archive.Snapshot? = ctx.performAndWait { + guard shouldArchive(gameID: gameID, in: ctx) else { return nil } + return Archive.snapshot(forGameID: gameID, in: ctx) + } + guard let local else { return } + + // Gather every available copy of each device's log, newest-wins: + // this device's own local log (authoritative) over a freshly-fetched + // peer log over whatever a sibling device already folded into the cloud + // archive. + let fetch = try? await syncEngine.fetchReplay(forGameID: local.originalGameID) + let existing = await fetchArchivePayload(originalGameID: local.originalGameID) + var snapshot = local + if let existing { snapshot = Archive.merging(snapshot, peerJournals: existing.journal) } + if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) } + + // Complete = a journal for every device that wrote grid state. Unknown + // when the shared zone is unreachable (no fetch), so treat as incomplete + // and let a later sweep settle it. + let present = Set(snapshot.journal.map(\.key)) + let isComplete = fetch.map { $0.expectedDevices.subtracting(present).isEmpty } ?? false + + // Skip a redundant overwrite (and the push churn it causes) when the + // cloud archive already holds every device we'd write — only the + // completeness marker might still need flipping. + if let existing, present.isSubset(of: Set(existing.journal.map(\.key))) { + if isComplete { markArchived(originalGameID: local.originalGameID) } + return + } + await write(snapshot, markComplete: isComplete) + } + + /// Re-attempts (and converges) the archive for any completed participant game + /// not yet marked complete — covering a completion that happened while + /// offline, one whose peers hadn't uploaded their journals yet, or a game + /// completed before this feature shipped. Driven by the foreground freshen + /// sweep. + func reconcileUnarchived() async { + let ctx = persistence.container.newBackgroundContext() + let ids: [UUID] = ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "databaseScope == 1 AND completedAt != nil AND archivedAt == nil AND isAccessRevoked == NO" + ) + return ((try? ctx.fetch(req)) ?? []).compactMap(\.id) + } + for id in ids { + await archiveIfNeeded(gameID: id) + } + } + + private nonisolated func shouldArchive(gameID: UUID, in ctx: NSManagedObjectContext) -> Bool { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first else { return false } + return entity.databaseScope == 1 + && entity.completedAt != nil + && entity.archivedAt == nil + && !entity.isAccessRevoked + } + + /// Force-overwrites the archive record with `snapshot`. `markComplete` flips + /// the `archivedAt` marker that stops the reconcile sweep — set only when the + /// caller knows every contributing device is captured. + private func write(_ snapshot: Archive.Snapshot, markComplete: Bool) async { + let zoneID = Archive.zoneID(forOriginalGameID: snapshot.originalGameID) + do { + try await ensureZone(zoneID) + let record = try Archive.record(from: snapshot) + try await save(record) + if markComplete { markArchived(originalGameID: snapshot.originalGameID) } + } catch { + syncMonitor?.recordError("archive game", error) + eventLog?.note( + "GameArchiver: write failed for \(snapshot.originalGameID.uuidString) — \(error)", + level: "error" + ) + } + } + + /// Reads back the archive record from this user's private database — the + /// accumulated, possibly cross-device-converged copy. `nil` when it doesn't + /// exist yet or the database is unreachable. + private func fetchArchivePayload( + originalGameID: UUID + ) async -> Archive.Payload? { + let recordID = CKRecord.ID( + recordName: Archive.recordName(forOriginalGameID: originalGameID), + zoneID: Archive.zoneID(forOriginalGameID: originalGameID) + ) + guard let record = try? await container.privateCloudDatabase.record(for: recordID) else { + return nil + } + return Archive.payload(from: record) + } + + private func markArchived(originalGameID: UUID) { + let ctx = persistence.container.newBackgroundContext() + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", originalGameID as CVarArg) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first else { return } + entity.archivedAt = Date() + entity.archiveGameID = Archive.archiveGameID(for: originalGameID) + try? ctx.save() + } + } + + // MARK: - Promote on revocation + + /// Turns a revoked shared game into a durable, owned completed game, then + /// deletes the revoked original so the library shows a single completed game + /// rather than a dead tombstone. + /// + /// Prefers the cloud archive as the source: by the time an owner deletes, + /// the reconcile sweep has usually converged it to the *full* multi-author + /// log, whereas this device's local `JournalEntity` rows only hold its own + /// moves plus any peers it happened to cache for replay. Falls back to the + /// local data (and seeds a cloud copy from it) when the archive can't be + /// reached — e.g. a game that completed and was revoked entirely offline. + func promoteRevoked(gameID: UUID) async { + let ctx = persistence.container.newBackgroundContext() + let local: Archive.Snapshot? = ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + // Only finished games are archivable; an owner deleting an + // in-progress shared game leaves the revoked tombstone as-is. + guard (try? ctx.fetch(req).first)?.completedAt != nil else { return nil } + return Archive.snapshot(forGameID: gameID, in: ctx) + } + guard let local else { return } + + let payload: Archive.Payload + if let cloud = await fetchArchivePayload(originalGameID: gameID) { + payload = cloud + } else { + // No reachable cloud copy — back up the local data now (the private + // DB is still writable) and promote from it. + await write(local, markComplete: true) + payload = Archive.payload(from: local) + } + + let promoteCtx = persistence.container.newBackgroundContext() + promoteCtx.performAndWait { + _ = Archive.materialize(payload, in: promoteCtx) + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + if let original = try? promoteCtx.fetch(req).first { + promoteCtx.delete(original) + } + if promoteCtx.hasChanges { try? promoteCtx.save() } + } + } + + // MARK: - CloudKit helpers + + private func ensureZone(_ zoneID: CKRecordZone.ID) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in + let op = CKModifyRecordZonesOperation( + recordZonesToSave: [CKRecordZone(zoneID: zoneID)], + recordZoneIDsToDelete: nil + ) + op.qualityOfService = .utility + op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } + container.privateCloudDatabase.add(op) + } + } + + private func save(_ record: CKRecord) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in + let op = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) + // Force-overwrite: each convergence pass writes a fresh record (no + // change tag), and the archive is a frozen game, so last-writer-wins + // across this user's own devices is exactly right. + op.savePolicy = .allKeys + op.qualityOfService = .utility + op.modifyRecordsResultBlock = { result in cont.resume(with: result) } + container.privateCloudDatabase.add(op) + } + } +} diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -81,6 +81,10 @@ extension SyncEngine { ) effects.rosterRelevant.insert(gameID) } + case Archive.recordType: + if let id = self.applyArchiveRecord(record, in: ctx) { + effects.rosterRelevant.insert(id) + } default: break } @@ -352,6 +356,31 @@ extension SyncEngine { ) } + /// Applies an inbound `Archive` record. Inert while a live (non-revoked) + /// copy of the original game still exists on this device — the device already + /// holds the data, so surfacing a second row would duplicate it. On a device + /// without the original (fresh install / reinstall), it hydrates the snapshot + /// into a standalone completed, owned game. Returns the materialized game id + /// when a row was created, else `nil`. + @discardableResult + nonisolated func applyArchiveRecord( + _ record: CKRecord, + in ctx: NSManagedObjectContext + ) -> UUID? { + guard let payload = Archive.payload(from: record) else { return nil } + + let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + liveReq.predicate = NSPredicate( + format: "id == %@ AND isAccessRevoked == NO", + payload.originalGameID as CVarArg + ) + liveReq.fetchLimit = 1 + if (try? ctx.fetch(liveReq).first) != nil { return nil } + + let created = Archive.materialize(payload, in: ctx) + return created?.id + } + nonisolated func applyDeletion( recordID: CKRecord.ID, recordType: CKRecord.RecordType, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1334,6 +1334,14 @@ actor SyncEngine { ) { effects.journalsSynced.insert(gid) } + case Archive.recordType: + // A finished shared game this user archived to their own + // private DB. Inert where the live original still exists; + // hydrated into a standalone completed game on a device that + // lacks it (fresh install / after the original was revoked). + if let id = self.applyArchiveRecord(record, in: ctx) { + effects.rosterRelevant.insert(id) + } default: break } diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -0,0 +1,366 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// The private-zone archive of a finished shared game: deterministic identity, +/// the `Archive` record ↔ payload wire format, materialization into a +/// standalone completed game, idempotency, and the dedup-against-live-original +/// rule in the inbound applier. The CloudKit write itself (`GameArchiver`) and +/// the CKSyncEngine plumbing are exercised by the manual end-to-end check, not +/// here. +@MainActor +@Suite("Archive") +struct ArchiveTests { + + private let source = """ + Title: Test Puzzle + Author: Test + + + ABC + D#E + FGH + + + A1. Across 1 ~ ABC + A4. Across 4 ~ DE + A5. Across 5 ~ FGH + D1. Down 1 ~ ADF + D2. Down 2 ~ BG + D3. Down 3 ~ CEH + """ + + private func journalValue( + seq: Int64, + row: Int, + col: Int, + letter: String, + actingAuthorID: String? + ) -> JournalValue { + JournalValue( + seq: seq, + timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(seq)), + position: GridPosition(row: row, col: col), + state: JournalCellState(letter: letter, mark: .pen(checked: nil), cellAuthorID: actingAuthorID), + actingAuthorID: actingAuthorID, + kind: .input, + targetSeq: nil, + batchID: nil, + prevSeqAtCell: nil, + direction: .across + ) + } + + private func makeSyncEngine(_ persistence: PersistenceController) throws -> SyncEngine { + SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + persistence: persistence + ) + } + + private let aliceKey = JournalDeviceKey(authorID: "alice", deviceID: "deviceA") + private let bobKey = JournalDeviceKey(authorID: "bob", deviceID: "deviceB") + + private func sampleSnapshot(originalGameID: UUID) -> Archive.Snapshot { + Archive.Snapshot( + originalGameID: originalGameID, + title: "Test Puzzle", + puzzleSource: source, + completedAt: Date(timeIntervalSince1970: 1_700_001_000), + completedBy: "alice", + cells: [ + .init(row: 0, col: 0, letter: "A", markCode: 0, letterAuthorID: "alice"), + .init(row: 0, col: 1, letter: "B", markCode: 0, letterAuthorID: "bob"), + .init(row: 2, col: 2, letter: "H", markCode: 0, letterAuthorID: "alice"), + ], + journal: [ + DeviceJournal(key: aliceKey, entries: [ + journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"), + journalValue(seq: 1, row: 2, col: 2, letter: "H", actingAuthorID: "alice"), + ]), + DeviceJournal(key: bobKey, entries: [ + journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"), + ]), + ] + ) + } + + /// Normalizes per-device journals into a comparable, order-independent form. + private func normalized(_ journals: [DeviceJournal]) -> [JournalDeviceKey: [JournalValue]] { + Dictionary(uniqueKeysWithValues: journals.map { ($0.key, $0.entries) }) + } + + // MARK: - Identity + + @Test("archiveGameID is deterministic and distinct from the original") + func deterministicArchiveID() { + let original = UUID() + let a = Archive.archiveGameID(for: original) + let b = Archive.archiveGameID(for: original) + #expect(a == b) + #expect(a != original) + #expect(Archive.archiveGameID(for: UUID()) != a) + } + + @Test("zone and record names round-trip the original game id") + func nameParsing() { + let original = UUID() + let name = Archive.recordName(forOriginalGameID: original) + #expect(Archive.originalGameID(fromName: name) == original) + #expect(Archive.isArchiveZone( + Archive.zoneID(forOriginalGameID: original).zoneName + )) + #expect(Archive.originalGameID(fromName: "game-\(original.uuidString)") == nil) + } + + // MARK: - Wire format + + @Test("record ↔ payload round-trips every field, the grid, and the journal") + func recordRoundTrip() throws { + let original = UUID() + let snapshot = sampleSnapshot(originalGameID: original) + let record = try Archive.record(from: snapshot) + + #expect(record.recordType == Archive.recordType) + #expect(record.recordID.zoneID.zoneName == "archive-\(original.uuidString)") + + let payload = try #require(Archive.payload(from: record)) + #expect(payload.originalGameID == original) + #expect(payload.archiveGameID == Archive.archiveGameID(for: original)) + #expect(payload.title == snapshot.title) + #expect(payload.completedAt == snapshot.completedAt) + #expect(payload.completedBy == "alice") + #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) }) + #expect(normalized(payload.journal) == normalized(snapshot.journal)) + } + + // MARK: - Convergence merge + + @Test("merging unions peer devices and keeps the local copy of shared keys") + func mergingPriority() { + let original = UUID() + // Local snapshot has alice with one entry; a peer fetch has a *stale* + // alice (should be ignored) and a new bob. + let local = Archive.Snapshot( + originalGameID: original, + title: "T", puzzleSource: source, + completedAt: Date(timeIntervalSince1970: 1), + completedBy: nil, + cells: [], + journal: [DeviceJournal(key: aliceKey, entries: [ + journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"), + ])] + ) + let peers = [ + DeviceJournal(key: aliceKey, entries: []), // stale — must not win + DeviceJournal(key: bobKey, entries: [ + journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"), + ]), + ] + let merged = normalized(Archive.merging(local, peerJournals: peers).journal) + #expect(Set(merged.keys) == [aliceKey, bobKey]) + #expect(merged[aliceKey]?.count == 1) // local alice kept, not the empty peer copy + #expect(merged[bobKey]?.count == 1) // peer bob added + } + + // MARK: - Snapshot from Core Data + + @Test("snapshot reads a completed participant game's grid and journal") + func snapshotFromStore() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test Puzzle" + entity.puzzleSource = source + entity.createdAt = Date() + entity.updatedAt = Date() + entity.completedAt = Date(timeIntervalSince1970: 1_700_002_000) + entity.completedBy = "alice" + entity.databaseScope = 1 + entity.ckRecordName = "game-\(gameID.uuidString)" + + let cell = CellEntity(context: ctx) + cell.game = entity + cell.row = 0 + cell.col = 0 + cell.letter = "A" + cell.markCode = 0 + cell.letterAuthorID = "alice" + + let journalRow = JournalEntity(context: ctx) + journalRow.game = entity + MovesJournal.assign(journalValue(seq: 0, row: 0, col: 0, letter: "A", actingAuthorID: "alice"), + to: journalRow, gameID: gameID) + try ctx.save() + + let snapshot = try #require(Archive.snapshot(forGameID: gameID, in: ctx)) + #expect(snapshot.originalGameID == gameID) + #expect(snapshot.completedBy == "alice") + #expect(snapshot.cells.count == 1) + #expect(snapshot.cells.first?.letter == "A") + // The own log (sourceDeviceID == nil) becomes one per-device journal. + #expect(snapshot.journal.count == 1) + #expect(snapshot.journal.first?.entries.count == 1) + #expect(snapshot.journal.first?.key.deviceID == RecordSerializer.localDeviceID) + } + + @Test("snapshot is nil for an unfinished game") + func snapshotNilWhenIncomplete() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = source + entity.createdAt = Date() + entity.updatedAt = Date() + entity.databaseScope = 1 + try ctx.save() + #expect(Archive.snapshot(forGameID: gameID, in: ctx) == nil) + } + + // MARK: - Materialize + + @Test("materialize rebuilds a completed owned game with grid and journal") + func materializeBuildsGame() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let original = UUID() + let record = try Archive.record(from: sampleSnapshot(originalGameID: original)) + let payload = try #require(Archive.payload(from: record)) + + let game = try #require(Archive.materialize(payload, in: ctx)) + #expect(game.id == Archive.archiveGameID(for: original)) + #expect(game.databaseScope == 0) // owned + #expect(game.ckZoneOwnerName == nil) // owned + #expect(game.completedAt == payload.completedAt) + #expect(game.completedBy == "alice") + #expect(((game.cells as? Set<CellEntity>) ?? []).count == 3) + + let journalRows = (game.journal as? Set<JournalEntity>) ?? [] + #expect(journalRows.count == 3) // alice's 2 + bob's 1 + // Every row carries its original device key (not this device's own log), + // so the replay reader treats all authors as cached contributors. + #expect(journalRows.allSatisfy { $0.sourceDeviceID != nil }) + #expect(Set(journalRows.compactMap { $0.sourceDeviceID }) == ["deviceA", "deviceB"]) + // Complete by construction, so replay serves from Core Data with no + // shared-zone fetch. + #expect(game.replayCacheComplete) + + // The library can render it (owned + completed, parseable puzzle). + let summary = try #require(GameSummary(entity: game)) + #expect(summary.isOwned) + #expect(summary.completedAt != nil) + } + + @Test("a materialized archive replays the full multi-author timeline locally") + func materializedArchiveFeedsReplayCache() async throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let original = UUID() + let record = try Archive.record(from: sampleSnapshot(originalGameID: original)) + let payload = try #require(Archive.payload(from: record)) + + _ = Archive.materialize(payload, in: ctx) + try ctx.save() + + let archiveID = Archive.archiveGameID(for: original) + let cached = try #require(await store.cachedRemoteJournals(forGameID: archiveID)) + // Both contributors are served from the local cache (no shared zone). + #expect(Set(cached.map { $0.key }) == [aliceKey, bobKey]) + #expect(cached.reduce(0) { $0 + $1.entries.count } == 3) + } + + @Test("materialize is idempotent — a second application creates no duplicate") + func materializeIdempotent() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let original = UUID() + let record = try Archive.record(from: sampleSnapshot(originalGameID: original)) + let payload = try #require(Archive.payload(from: record)) + + _ = Archive.materialize(payload, in: ctx) + _ = Archive.materialize(payload, in: ctx) + try ctx.save() + + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", + Archive.archiveGameID(for: original) as CVarArg) + #expect(try ctx.count(for: req) == 1) + } + + // MARK: - Dedup in the inbound applier + + @Test("applier skips materialization while a live original exists") + func applierSkipsWhenOriginalLive() throws { + let persistence = makeTestPersistence() + let engine = try makeSyncEngine(persistence) + let ctx = persistence.viewContext + let original = UUID() + + // The live shared original this device is still playing. + let live = GameEntity(context: ctx) + live.id = original + live.title = "Live" + live.puzzleSource = source + live.createdAt = Date() + live.updatedAt = Date() + live.databaseScope = 1 + live.ckRecordName = "game-\(original.uuidString)" + try ctx.save() + + let record = try Archive.record(from: sampleSnapshot(originalGameID: original)) + let result = engine.applyArchiveRecord(record, in: ctx) + #expect(result == nil) + + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", + Archive.archiveGameID(for: original) as CVarArg) + #expect(try ctx.count(for: req) == 0) + } + + @Test("applier materializes when the original is absent") + func applierMaterializesWhenAbsent() throws { + let persistence = makeTestPersistence() + let engine = try makeSyncEngine(persistence) + let ctx = persistence.viewContext + let original = UUID() + + let record = try Archive.record(from: sampleSnapshot(originalGameID: original)) + let result = engine.applyArchiveRecord(record, in: ctx) + #expect(result == Archive.archiveGameID(for: original)) + } + + @Test("applier materializes when the original is revoked") + func applierMaterializesWhenRevoked() throws { + let persistence = makeTestPersistence() + let engine = try makeSyncEngine(persistence) + let ctx = persistence.viewContext + let original = UUID() + + let revoked = GameEntity(context: ctx) + revoked.id = original + revoked.title = "Revoked" + revoked.puzzleSource = source + revoked.createdAt = Date() + revoked.updatedAt = Date() + revoked.databaseScope = 1 + revoked.isAccessRevoked = true + revoked.ckRecordName = "game-\(original.uuidString)" + try ctx.save() + + let record = try Archive.record(from: sampleSnapshot(originalGameID: original)) + let result = engine.applyArchiveRecord(record, in: ctx) + #expect(result == Archive.archiveGameID(for: original)) + } +}