crossmate

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

commit 122cc21b2b95ef939cb18ac5fd02d37b93a574c0
parent 89bdb89447837912e1f48d8633f2977f7a4e088d
Author: Michael Camilleri <[email protected]>
Date:   Fri,  8 May 2026 12:31:04 +0900

Rewrite sync approach

This commit introduces a completely reworked sync approach for game
activity. The previous approach involved recording the individual moves
each player made. These were synchronised to each player and device. In
a large game, this could create numerous records. There was a plan to
rationalise these at various points (e.g. end of game) but there were
synchronisation issues with this as well as potential loss of useful
information.

The new approach is for each player and device to have its own moves
log. Moves made by that player on that device update only that log. The
log is shared between players who use a reconciliation process to
produce the overall game state. The current state of each individual log
is 'cached' in the log so that a user can quickly reproduce the current
game state.

In conjunction with this, a new version of the database container has
been added (iCloud.net.inqk.crossmate.v3).

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 52++++++++++++++++++++++++++++------------------------
MCrossmate/Crossmate.entitlements | 12++++++------
MCrossmate/CrossmateApp.swift | 4++--
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 40+++++++++++-----------------------------
MCrossmate/Models/PlayerRoster.swift | 10+++++-----
MCrossmate/Persistence/GameMutator.swift | 16++++++++--------
MCrossmate/Persistence/GameStore.swift | 201++++++++++++++++++++++++++++++++++++-------------------------------------------
DCrossmate/Persistence/SnapshotService.swift | 181-------------------------------------------------------------------------------
MCrossmate/Services/AppServices.swift | 53+++++++++++++++++------------------------------------
ACrossmate/Sync/GridStateMerger.swift | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DCrossmate/Sync/MoveBuffer.swift | 293-------------------------------------------------------------------------------
DCrossmate/Sync/MoveLog.swift | 133-------------------------------------------------------------------------------
ACrossmate/Sync/Moves.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Sync/MovesUpdater.swift | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/PresencePublisher.swift | 4++--
MCrossmate/Sync/RecordSerializer.swift | 209+++++++++++++++++++++++++++++++++++--------------------------------------------
MCrossmate/Sync/SyncEngine.swift | 374++++++++++++++++++++++---------------------------------------------------------
MTests/Support/TestHelpers.swift | 16++++++++++------
MTests/Unit/GameMutatorTests.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MTests/Unit/GamePlayerColorStoreTests.swift | 2+-
MTests/Unit/GameStoreUnseenMovesTests.swift | 205+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
ATests/Unit/GridStateMergerTests.swift | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DTests/Unit/MoveBufferTests.swift | 350-------------------------------------------------------------------------------
DTests/Unit/MoveLogTests.swift | 262-------------------------------------------------------------------------------
ATests/Unit/MovesUpdaterTests.swift | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/PlayerRosterTests.swift | 29+++++++++++++++--------------
ATests/Unit/RecordSerializerMovesTests.swift | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DTests/Unit/SnapshotServiceTests.swift | 199-------------------------------------------------------------------------------
MTests/Unit/Sync/AuthorIdentityTests.swift | 2+-
ATests/Unit/Sync/MovesInboundTests.swift | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/Sync/ShareRoutingTests.swift | 102+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
MTests/Unit/Sync/ZoneOrphaningTests.swift | 2+-
32 files changed, 1898 insertions(+), 2210 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 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 */; }; - 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */; }; 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; @@ -27,32 +26,31 @@ 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; - 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */; }; 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; }; 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; }; + 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; - 6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; - 7693606B3D06FCD27A50C239 /* SnapshotService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC9461795ED6A4E10AC71AE /* SnapshotService.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; }; - 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7422F19AA1F1692A98E3602 /* MoveLog.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; + 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */; }; 8B356C953DA0FAF149C3391A /* Puzzles in Resources */ = {isa = PBXBuildFile; fileRef = BA67C509B467132D1B7510A4 /* Puzzles */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; + 91703E54DB4679C1911BF994 /* Moves.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86470163BFF956F3DE438506 /* Moves.swift */; }; 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; 978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; }; @@ -61,12 +59,14 @@ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; }; AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; }; AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; + AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; }; B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; }; BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; }; BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; }; + C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */; }; C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; @@ -84,11 +84,12 @@ D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */; }; DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.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 */; }; E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; - F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */; }; + ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; }; F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; @@ -110,6 +111,7 @@ 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; 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>"; }; + 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; 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>"; }; @@ -118,13 +120,13 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; - 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotServiceTests.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; 3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; }; 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; }; 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; }; 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; }; + 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerMovesTests.swift; sourceTree = "<group>"; }; 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentityTests.swift; sourceTree = "<group>"; }; 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; }; 462CE0FD356F6137C9BFD30F /* ImportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportService.swift; sourceTree = "<group>"; }; @@ -134,8 +136,6 @@ 4AF633D73818BD59F759FAC4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDAcceptTests.swift; sourceTree = "<group>"; }; - 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; }; - 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; @@ -143,14 +143,16 @@ 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStore.swift; sourceTree = "<group>"; }; 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; }; 6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; }; + 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMergerTests.swift; sourceTree = "<group>"; }; 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; }; 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; }; - 7BC9461795ED6A4E10AC71AE /* SnapshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotService.swift; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; + 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdater.swift; sourceTree = "<group>"; }; 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.swift; sourceTree = "<group>"; }; + 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; }; 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; @@ -159,6 +161,7 @@ 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; + 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; }; @@ -174,7 +177,6 @@ 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>"; }; BA67C509B467132D1B7510A4 /* Puzzles */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Puzzles; sourceTree = SOURCE_ROOT; }; - BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBufferTests.swift; sourceTree = "<group>"; }; BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverter.swift; sourceTree = "<group>"; }; BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; @@ -194,8 +196,8 @@ EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStoreTests.swift; sourceTree = "<group>"; }; ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthServiceTests.swift; sourceTree = "<group>"; }; + EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; - F7422F19AA1F1692A98E3602 /* MoveLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLog.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PUZToXDConverterTests.swift; sourceTree = "<group>"; }; @@ -215,8 +217,9 @@ isa = PBXGroup; children = ( B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */, - 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */, - F7422F19AA1F1692A98E3602 /* MoveLog.swift */, + 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */, + 86470163BFF956F3DE438506 /* Moves.swift */, + 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */, 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, @@ -241,8 +244,8 @@ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */, D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */, - BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */, - 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */, + 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */, + 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */, 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */, 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */, ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */, @@ -252,8 +255,8 @@ FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */, B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */, C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, + 443BF6DF77C8226313EE9564 /* RecordSerializerMovesTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, - 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */, 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, ); @@ -290,7 +293,6 @@ 43DC132D49361C56DE79C13E /* GameMutator.swift */, 93EE5BA78566EDED68D846AB /* GameStore.swift */, ACC295195602B3DDF7BB3895 /* PersistenceController.swift */, - 7BC9461795ED6A4E10AC71AE /* SnapshotService.swift */, ); path = Persistence; sourceTree = "<group>"; @@ -359,6 +361,7 @@ children = ( 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, + EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */, A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */, @@ -498,8 +501,9 @@ 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */, 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */, 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */, - 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */, - 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */, + AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */, + DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */, + C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */, C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */, AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */, 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */, @@ -510,9 +514,9 @@ 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */, 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, + 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, - 6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, 9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */, @@ -543,6 +547,7 @@ DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */, 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, + ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */, @@ -551,8 +556,8 @@ 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, - F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */, - 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */, + 91703E54DB4679C1911BF994 /* Moves.swift in Sources */, + 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */, 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */, FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */, D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */, @@ -578,7 +583,6 @@ CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */, - 7693606B3D06FCD27A50C239 /* SnapshotService.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */, 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */, diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -4,13 +4,9 @@ <dict> <key>aps-environment</key> <string>development</string> - <key>com.apple.security.application-groups</key> - <array> - <string>group.net.inqk.crossmate</string> - </array> <key>com.apple.developer.icloud-container-identifiers</key> <array> - <string>iCloud.net.inqk.crossmate.v2</string> + <string>iCloud.net.inqk.crossmate.v3</string> <string>iCloud.net.inqk.crossmate</string> </array> <key>com.apple.developer.icloud-services</key> @@ -20,10 +16,14 @@ </array> <key>com.apple.developer.ubiquity-container-identifiers</key> <array> - <string>iCloud.net.inqk.crossmate.v2</string> + <string>iCloud.net.inqk.crossmate.v3</string> <string>iCloud.net.inqk.crossmate</string> </array> <key>com.apple.developer.ubiquity-kvstore-identifier</key> <string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.inqk.crossmate</string> + </array> </dict> </plist> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -346,11 +346,11 @@ private struct PuzzleDisplayView: View { .onDisappear { NotificationState.clearActivePuzzleID(if: gameID) let presence = services.presencePublisher - let moveBuffer = services.moveBuffer + let movesUpdater = services.movesUpdater let exitedID = gameID Task { await presence.clear() - await moveBuffer.noteSessionEnded(gameID: exitedID) + await movesUpdater.noteSessionEnded(gameID: exitedID) } } } diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -16,17 +16,15 @@ <attribute name="gridWidth" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> - <attribute name="lamportHighWater" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> - <attribute name="lastSeenOtherMoveLamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="lastSeenOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> - <attribute name="latestOtherMoveLamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="latestOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="puzzleSource" attributeType="String"/> <attribute name="title" attributeType="String"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/> - <relationship name="moves" toMany="YES" deletionRule="Cascade" destinationEntity="MoveEntity" inverseName="game" inverseEntity="MoveEntity"/> + <relationship name="moves" toMany="YES" deletionRule="Cascade" destinationEntity="MovesEntity" inverseName="game" inverseEntity="MovesEntity"/> <relationship name="players" toMany="YES" deletionRule="Cascade" destinationEntity="PlayerEntity" inverseName="game" inverseEntity="PlayerEntity"/> - <relationship name="snapshots" toMany="YES" deletionRule="Cascade" destinationEntity="SnapshotEntity" inverseName="game" inverseEntity="SnapshotEntity"/> </entity> <entity name="PlayerEntity" representedClassName="PlayerEntity" syncable="YES" codeGenerationType="class"> <attribute name="authorID" attributeType="String"/> @@ -43,34 +41,18 @@ <fetchIndexElement property="authorID" type="Binary" order="ascending"/> </fetchIndex> </entity> - <entity name="MoveEntity" representedClassName="MoveEntity" syncable="YES" codeGenerationType="class"> - <attribute name="authorID" optional="YES" attributeType="String"/> - <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <entity name="MovesEntity" representedClassName="MovesEntity" syncable="YES" codeGenerationType="class"> + <attribute name="authorID" attributeType="String"/> + <attribute name="cells" attributeType="Binary"/> <attribute name="ckRecordName" attributeType="String"/> <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> - <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> - <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> - <attribute name="lamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> - <attribute name="letter" attributeType="String" defaultValueString=""/> - <attribute name="markKind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> - <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="deviceID" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="moves" inverseEntity="GameEntity"/> - <fetchIndex name="byGameAndLamport"> + <fetchIndex name="byGameAndAuthorAndDevice"> <fetchIndexElement property="game" type="Binary" order="ascending"/> - <fetchIndexElement property="lamport" type="Binary" order="ascending"/> - </fetchIndex> - </entity> - <entity name="SnapshotEntity" representedClassName="SnapshotEntity" syncable="YES" codeGenerationType="class"> - <attribute name="ckRecordName" attributeType="String"/> - <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/> - <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> - <attribute name="gridState" attributeType="Binary"/> - <attribute name="needsPruning" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> - <attribute name="upToLamport" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> - <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="snapshots" inverseEntity="GameEntity"/> - <fetchIndex name="byGameAndLamport"> - <fetchIndexElement property="game" type="Binary" order="ascending"/> - <fetchIndexElement property="upToLamport" type="Binary" order="ascending"/> + <fetchIndexElement property="authorID" type="Binary" order="ascending"/> + <fetchIndexElement property="deviceID" type="Binary" order="ascending"/> </fetchIndex> </entity> <entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class"> diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -171,11 +171,11 @@ final class PlayerRoster { )) } } - let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - moveReq.predicate = NSPredicate(format: "game == %@", entity) - let moveEntities = (try? ctx.fetch(moveReq)) ?? [] + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate(format: "game == %@", entity) + let movesEntities = (try? ctx.fetch(movesReq)) ?? [] let authorIDs = Array( - Set(moveEntities.compactMap { $0.authorID }) + Set(movesEntities.compactMap { $0.authorID }) .subtracting([localAuthorID, ""]) ) return ( @@ -231,7 +231,7 @@ final class PlayerRoster { // Diagnostic — surfaces the inputs that drive the entries list so we // can tell whether a "ghost" authorID came from a stale PlayerEntity, - // a stray MoveEntity, or the share's participant list. Trim noisy + // a stray MovesEntity, or the share's participant list. Trim noisy // re-entries by only emitting when the signature actually changes. if let tracer { let participantIDs: [String] = share?.participants.compactMap { diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -2,8 +2,8 @@ import Foundation /// Unified mutation processor that sits between `PlayerSession` and `Game`. /// Every mutation flows through here so that the in-memory `Game` stays -/// up-to-date for immediate UI feedback, and a corresponding `Move` is emitted -/// to `MoveBuffer` for durable persistence and CloudKit sync. +/// up-to-date for immediate UI feedback, and a corresponding cell update is +/// emitted to `MovesUpdater` for durable persistence and CloudKit sync. /// /// Remote changes no longer flow through here — they arrive via replay from /// the sync engine, which writes directly to `CellEntity` and notifies the @@ -15,7 +15,7 @@ import Foundation final class GameMutator { private let game: Game let gameID: UUID - private let moveBuffer: MoveBuffer? + private let movesUpdater: MovesUpdater? private let authorIDProvider: (@MainActor () -> String?)? /// `true` when the current user owns the CloudKit zone for this game. @@ -34,7 +34,7 @@ final class GameMutator { init( game: Game, gameID: UUID, - moveBuffer: MoveBuffer?, + movesUpdater: MovesUpdater?, authorIDProvider: (@MainActor () -> String?)? = nil, isOwned: Bool = true, isShared: Bool = false, @@ -42,7 +42,7 @@ final class GameMutator { ) { self.game = game self.gameID = gameID - self.moveBuffer = moveBuffer + self.movesUpdater = movesUpdater self.authorIDProvider = authorIDProvider self.isOwned = isOwned self.isShared = isShared @@ -93,7 +93,7 @@ final class GameMutator { // MARK: - Helpers private func emitMove(atRow row: Int, atCol col: Int) { - guard let moveBuffer, !isAccessRevoked else { return } + guard let movesUpdater, !isAccessRevoked else { return } let square = game.squares[row][col] let (markKind, checkedWrong) = encodeMark(square.mark) let id = gameID @@ -101,11 +101,11 @@ final class GameMutator { // The cell's `letterAuthorID` is the canonical author for the square — // it may differ from the acting user when a same-letter write or a // reveal-of-correct preserved the original author. The acting user is - // still passed separately so MoveBuffer can fire session pings. + // still passed separately so MovesUpdater can fire session pings. let cellAuthorID = square.letterAuthorID let actingAuthorID = authorIDProvider?() Task { - await moveBuffer.enqueue( + await movesUpdater.enqueue( gameID: id, row: row, col: col, letter: letter, diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -108,8 +108,21 @@ struct GameSummary: Identifiable, Equatable { self.isOwned = entity.databaseScope == 0 self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 self.isAccessRevoked = entity.isAccessRevoked - self.hasUnseenOtherMoves = self.isShared - && entity.latestOtherMoveLamport > entity.lastSeenOtherMoveLamport + self.hasUnseenOtherMoves = Self.computeHasUnseen( + isShared: self.isShared, + latest: entity.latestOtherMoveAt, + lastSeen: entity.lastSeenOtherMoveAt + ) + } + + fileprivate static func computeHasUnseen( + isShared: Bool, + latest: Date?, + lastSeen: Date? + ) -> Bool { + guard isShared, let latest else { return false } + guard let lastSeen else { return true } + return latest > lastSeen } } @@ -126,7 +139,7 @@ struct GameCloudDeletion: Sendable, Equatable { /// every Core Data save (i.e., every keystroke), but only the active /// entity's fields actually change. The cache key intentionally uses fast /// scalar/string fields so a hit never has to fault the `cells` -/// relationship; `MoveBuffer` bumps `updatedAt` atomically with cell +/// relationship; `MovesUpdater` bumps `updatedAt` atomically with cell /// writes, so it acts as a faithful proxy for "thumbnail might have /// changed". @MainActor @@ -134,8 +147,8 @@ final class GameSummaryCache { private struct Key: Equatable { let updatedAt: Date? let completedAt: Date? - let latestOther: Int64 - let lastSeenOther: Int64 + let latestOther: Date? + let lastSeenOther: Date? let scope: Int16 let shareName: String? let revoked: Bool @@ -146,8 +159,8 @@ final class GameSummaryCache { let key = Key( updatedAt: entity.updatedAt, completedAt: entity.completedAt, - latestOther: entity.latestOtherMoveLamport, - lastSeenOther: entity.lastSeenOtherMoveLamport, + latestOther: entity.latestOtherMoveAt, + lastSeenOther: entity.lastSeenOtherMoveAt, scope: entity.databaseScope, shareName: entity.ckShareRecordName, revoked: entity.isAccessRevoked @@ -198,7 +211,7 @@ final class GameStore { private(set) var currentMutator: GameMutator? private(set) var currentEntity: GameEntity? - private let moveBuffer: MoveBuffer + private let movesUpdater: MovesUpdater /// Returns the current iCloud author ID, or nil while the first /// `userRecordID()` lookup is still pending. The inner Optional reflects @@ -217,14 +230,14 @@ final class GameStore { init( persistence: PersistenceController, - moveBuffer: MoveBuffer, + movesUpdater: MovesUpdater, authorIDProvider: @escaping @MainActor () -> String?, onGameCreated: @escaping (String) -> Void, onGameUpdated: @escaping (String) -> Void, onGameDeleted: @escaping (GameCloudDeletion) -> Void ) { self.persistence = persistence - self.moveBuffer = moveBuffer + self.movesUpdater = movesUpdater self.authorIDProvider = authorIDProvider self.onGameCreated = onGameCreated self.onGameUpdated = onGameUpdated @@ -246,11 +259,10 @@ final class GameStore { restore(game: game, from: entity) } - /// Replays the move log for each game ID and updates the `CellEntity` - /// cache so that list thumbnails reflect local edits immediately after a - /// `MoveBuffer` flush, without waiting for the next sync cycle. The work - /// runs on a background context so the main actor isn't pinned while - /// fetching and replaying potentially-long move logs. + /// Merges every device's `MovesEntity` rows for each game ID and updates + /// the `CellEntity` cache so that list thumbnails reflect local edits + /// immediately after a `MovesUpdater` flush, without waiting for the next + /// sync cycle. Runs on a background context to keep the main actor free. func replayCellCaches(for gameIDs: Set<UUID>) async { let bgCtx = persistence.container.newBackgroundContext() bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump @@ -261,36 +273,11 @@ final class GameStore { req.fetchLimit = 1 guard let entity = try? bgCtx.fetch(req).first else { continue } - let snapReq = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - snapReq.predicate = NSPredicate(format: "game == %@", entity) - let snapshots: [Snapshot] = ((try? bgCtx.fetch(snapReq)) ?? []).compactMap { se in - guard let data = se.gridState, - let grid = try? MoveLog.decodeGridState(data) else { return nil } - return Snapshot( - gameID: gameID, - upToLamport: se.upToLamport, - grid: grid, - createdAt: se.createdAt ?? Date() - ) - } - - let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - moveReq.predicate = NSPredicate(format: "game == %@", entity) - let moves: [Move] = ((try? bgCtx.fetch(moveReq)) ?? []).map { me in - Move( - gameID: gameID, - lamport: me.lamport, - row: Int(me.row), - col: Int(me.col), - letter: me.letter ?? "", - markKind: me.markKind, - checkedWrong: me.checkedWrong, - authorID: me.authorID, - createdAt: me.createdAt ?? Date() - ) - } - - let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate(format: "game == %@", entity) + let values: [MovesValue] = ((try? bgCtx.fetch(movesReq)) ?? []) + .compactMap { Self.movesValue(from: $0) } + let grid = GridStateMerger.merge(values) Self.applyCellCache(to: entity, from: grid, in: bgCtx) } if bgCtx.hasChanges { @@ -299,29 +286,37 @@ final class GameStore { } } - /// Records the newest synced move authored by someone other than the - /// current iCloud user. The list badge is local UI state, not CloudKit - /// state, so it lives on `GameEntity` and is advanced from the sync - /// callback after incoming moves have already been persisted. - func noteIncomingOtherMoves(_ moves: [Move], currentAuthorID: String?) { - guard let currentAuthorID, !moves.isEmpty else { return } - - var latestByGame: [UUID: Int64] = [:] - for move in moves { - guard let authorID = move.authorID, authorID != currentAuthorID else { continue } - latestByGame[move.gameID] = max(latestByGame[move.gameID] ?? 0, move.lamport) - } - guard !latestByGame.isEmpty else { return } - - for (gameID, latestLamport) in latestByGame { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let entity = try? context.fetch(req).first else { continue } + /// Updates `latestOtherMoveAt` for each game whose Moves record was just + /// updated by another iCloud user, driving the unread-badge heuristic. + /// `gameIDs` are the games that received an inbound `Moves` record in the + /// most recent sync batch; for each, we scan the now-persisted + /// `MovesEntity` rows and pick the latest `updatedAt` whose row is owned + /// by a different `authorID` than the local user. If the game is currently + /// open, `lastSeenOtherMoveAt` is advanced in lockstep so the badge + /// doesn't appear for activity the user is already watching. + func noteIncomingMovesUpdate(gameIDs: Set<UUID>, currentAuthorID: String?) { + guard let currentAuthorID, !gameIDs.isEmpty else { return } + + for gameID in gameIDs { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let entity = try? context.fetch(gameReq).first else { continue } + + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate( + format: "game == %@ AND authorID != %@", + entity, + currentAuthorID + ) + let rows = (try? context.fetch(movesReq)) ?? [] + guard let latest = rows.compactMap(\.updatedAt).max() else { continue } - entity.latestOtherMoveLamport = max(entity.latestOtherMoveLamport, latestLamport) + if (entity.latestOtherMoveAt ?? .distantPast) < latest { + entity.latestOtherMoveAt = latest + } if currentEntity?.id == gameID { - entity.lastSeenOtherMoveLamport = entity.latestOtherMoveLamport + entity.lastSeenOtherMoveAt = entity.latestOtherMoveAt } } @@ -450,7 +445,7 @@ final class GameStore { if let ckName = entity.ckRecordName { onGameUpdated(ckName) } - Task { await moveBuffer.flush() } + Task { await movesUpdater.flush() } // Clean up current references currentGame = nil @@ -472,7 +467,7 @@ final class GameStore { if let ckName = entity.ckRecordName { onGameUpdated(ckName) } - Task { await moveBuffer.flush() } + Task { await movesUpdater.flush() } } // MARK: - Reset @@ -537,10 +532,11 @@ final class GameStore { private func markOtherMovesSeen(for entity: GameEntity) { let isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1 - guard isShared, - entity.lastSeenOtherMoveLamport < entity.latestOtherMoveLamport else { return } - entity.lastSeenOtherMoveLamport = entity.latestOtherMoveLamport - try? context.save() + guard isShared, let latest = entity.latestOtherMoveAt else { return } + if (entity.lastSeenOtherMoveAt ?? .distantPast) < latest { + entity.lastSeenOtherMoveAt = latest + try? context.save() + } } private func seedFromSample() throws -> (GameEntity, Puzzle) { @@ -571,42 +567,11 @@ final class GameStore { } private func restore(game: Game, from entity: GameEntity) { - let snapshotRequest = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - snapshotRequest.predicate = NSPredicate(format: "game == %@", entity) - let snapshotEntities = (try? context.fetch(snapshotRequest)) ?? [] - - let moveRequest = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - moveRequest.predicate = NSPredicate(format: "game == %@", entity) - let moveEntities = (try? context.fetch(moveRequest)) ?? [] - - guard let gameID = entity.id else { return } - - let snapshots: [Snapshot] = snapshotEntities.compactMap { se in - guard let data = se.gridState, - let grid = try? MoveLog.decodeGridState(data) else { return nil } - return Snapshot( - gameID: gameID, - upToLamport: se.upToLamport, - grid: grid, - createdAt: se.createdAt ?? Date() - ) - } - - let moves: [Move] = moveEntities.map { me in - Move( - gameID: gameID, - lamport: me.lamport, - row: Int(me.row), - col: Int(me.col), - letter: me.letter ?? "", - markKind: me.markKind, - checkedWrong: me.checkedWrong, - authorID: me.authorID, - createdAt: me.createdAt ?? Date() - ) - } - - let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) + let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesRequest.predicate = NSPredicate(format: "game == %@", entity) + let movesEntities = (try? context.fetch(movesRequest)) ?? [] + let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } + let grid = GridStateMerger.merge(values) for (position, cell) in grid { let r = position.row @@ -626,6 +591,24 @@ final class GameStore { try? context.save() } + /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row + /// is missing required fields. + fileprivate nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { + guard let gameID = entity.game?.id, + let authorID = entity.authorID, + let deviceID = entity.deviceID, + let updatedAt = entity.updatedAt + else { return nil } + let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] + return MovesValue( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt + ) + } + /// Reconciles a `GameEntity`'s `CellEntity` cache against `grid` inside /// `ctx`. Caller is responsible for saving `ctx`. Used from both the /// main-context `updateCellCache` and the background-context @@ -700,7 +683,7 @@ final class GameStore { return GameMutator( game: game, gameID: gameID, - moveBuffer: moveBuffer, + movesUpdater: movesUpdater, authorIDProvider: authorIDProvider, isOwned: entity.databaseScope == 0, isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, diff --git a/Crossmate/Persistence/SnapshotService.swift b/Crossmate/Persistence/SnapshotService.swift @@ -1,181 +0,0 @@ -import CoreData -import Foundation - -/// Owns the snapshot-creation and move-pruning lifecycle for local -/// (single-user) games. Lives outside `GameStore` so that -/// `MoveBuffer.afterFlush` can run snapshot work without depending on -/// `GameStore`, which lets `GameStore` accept its collaborators as required -/// init parameters. -@MainActor -final class SnapshotService { - let persistence: PersistenceController - private var context: NSManagedObjectContext { persistence.viewContext } - - init(persistence: PersistenceController) { - self.persistence = persistence - } - - /// Checks each game in `gameIDs` and writes a `SnapshotEntity` if the - /// game is complete (has `completedAt` set) or has 200 or more moves not - /// yet covered by an existing snapshot. Moves folded into a local - /// compaction snapshot are pruned only after CloudKit has confirmed that - /// snapshot save. Returns the `ckRecordName` of each new snapshot for - /// enqueueing, plus any move deletions made for previously-durable - /// snapshots. - func createSnapshotsIfNeeded( - for gameIDs: Set<UUID> - ) async -> (snapshotNames: [String], prunedMoveNames: [String]) { - let bgCtx = persistence.container.newBackgroundContext() - bgCtx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - return await bgCtx.perform { - var snapshotNames: [String] = [] - let prunedMoveNames = Self.pruneDurableSnapshots(in: bgCtx) - - for gameID in gameIDs { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - req.fetchLimit = 1 - guard let entity = try? bgCtx.fetch(req).first else { continue } - guard !Self.usesSharedSync(entity) else { continue } - - let allMoves = (entity.moves as? Set<MoveEntity>) ?? [] - let allSnapshots = (entity.snapshots as? Set<SnapshotEntity>) ?? [] - let latestCoveredLamport = allSnapshots.map(\.upToLamport).max() ?? 0 - let uncoveredCount = allMoves.filter { $0.lamport > latestCoveredLamport }.count - let highWater = entity.lamportHighWater - - let shouldSnapshot = (entity.completedAt != nil || uncoveredCount >= 200) - && highWater > latestCoveredLamport - guard shouldSnapshot else { continue } - - let snapshots: [Snapshot] = allSnapshots.compactMap { se in - guard let data = se.gridState, - let grid = try? MoveLog.decodeGridState(data) else { return nil } - return Snapshot( - gameID: gameID, - upToLamport: se.upToLamport, - grid: grid, - createdAt: se.createdAt ?? Date() - ) - } - let moves: [Move] = allMoves.map { me in - Move( - gameID: gameID, - lamport: me.lamport, - row: Int(me.row), - col: Int(me.col), - letter: me.letter ?? "", - markKind: me.markKind, - checkedWrong: me.checkedWrong, - authorID: me.authorID, - createdAt: me.createdAt ?? Date() - ) - } - let grid = MoveLog.replay(snapshot: MoveLog.latestSnapshot(from: snapshots), moves: moves) - - let snapshotEntity = SnapshotEntity(context: bgCtx) - snapshotEntity.game = entity - snapshotEntity.upToLamport = highWater - snapshotEntity.createdAt = Date() - let ckName = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: highWater) - snapshotEntity.ckRecordName = ckName - snapshotEntity.gridState = try? MoveLog.encodeGridState(grid) - snapshotEntity.needsPruning = true - - snapshotNames.append(ckName) - } - if bgCtx.hasChanges { - try? bgCtx.save() - } - return (snapshotNames, prunedMoveNames) - } - } - - /// Deletes moves covered by local compaction snapshots after there is - /// durable evidence that the snapshot exists in CloudKit. Passing names - /// is used directly from CKSyncEngine's saved-record callback; omitting - /// names performs crash recovery by looking for saved snapshots with - /// written-back system fields. - func pruneMoves( - ckRecordNames: Set<String>? = nil - ) -> [String] { - let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - if let ckRecordNames { - guard !ckRecordNames.isEmpty else { return [] } - req.predicate = NSPredicate( - format: "needsPruning == YES AND ckRecordName IN %@", - Array(ckRecordNames) - ) - } else { - req.predicate = NSPredicate( - format: "needsPruning == YES AND ckSystemFields != nil" - ) - } - - let snapshots = (try? context.fetch(req)) ?? [] - var prunedMoveNames: [String] = [] - for snapshot in snapshots { - guard let game = snapshot.game else { - snapshot.needsPruning = false - continue - } - guard !Self.usesSharedSync(game) else { - snapshot.needsPruning = false - continue - } - - let covered = ((game.moves as? Set<MoveEntity>) ?? []) - .filter { $0.lamport <= snapshot.upToLamport } - for move in covered { - if let name = move.ckRecordName { - prunedMoveNames.append(name) - } - context.delete(move) - } - snapshot.needsPruning = false - } - - if context.hasChanges { - try? context.save() - } - return prunedMoveNames - } - - /// Background-context equivalent of `pruneMoves(ckRecordNames: nil)` — - /// drops moves covered by snapshots whose CloudKit system fields prove - /// the snapshot is durable. Caller is responsible for saving `ctx`. - fileprivate nonisolated static func pruneDurableSnapshots( - in ctx: NSManagedObjectContext - ) -> [String] { - let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - req.predicate = NSPredicate( - format: "needsPruning == YES AND ckSystemFields != nil" - ) - let snapshots = (try? ctx.fetch(req)) ?? [] - var prunedMoveNames: [String] = [] - for snapshot in snapshots { - guard let game = snapshot.game else { - snapshot.needsPruning = false - continue - } - guard !Self.usesSharedSync(game) else { - snapshot.needsPruning = false - continue - } - let covered = ((game.moves as? Set<MoveEntity>) ?? []) - .filter { $0.lamport <= snapshot.upToLamport } - for move in covered { - if let name = move.ckRecordName { - prunedMoveNames.append(name) - } - ctx.delete(move) - } - snapshot.needsPruning = false - } - return prunedMoveNames - } - - fileprivate nonisolated static func usesSharedSync(_ game: GameEntity) -> Bool { - game.databaseScope == 1 || game.ckShareRecordName != nil - } -} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -12,8 +12,7 @@ final class AppServices { let driveMonitor: DriveMonitor let nytFetcher: NYTPuzzleFetcher let inputMonitor: InputMonitor - let moveBuffer: MoveBuffer - let snapshotService: SnapshotService + let movesUpdater: MovesUpdater let presencePublisher: PresencePublisher let identity: AuthorIdentity let shareController: ShareController @@ -23,7 +22,7 @@ final class AppServices { let preferences: PlayerPreferences - private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") private var started = false private var syncStarted = false private(set) var nameBroadcaster: NameBroadcaster? @@ -46,28 +45,14 @@ final class AppServices { let identity = AuthorIdentity() self.identity = identity - let snapshotService = SnapshotService(persistence: persistence) - self.snapshotService = snapshotService - - let moveBuffer = MoveBuffer( + let movesUpdater = MovesUpdater( debounceInterval: .milliseconds(1500), persistence: persistence, - sink: { moves in - let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } - guard isEnabled else { return } - await syncEngine.enqueueMoves(moves) - }, - afterFlush: { gameIDs in + writerAuthorIDProvider: { await MainActor.run { identity.currentID } }, + sink: { gameIDs in let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } - - let result = await snapshotService.createSnapshotsIfNeeded(for: gameIDs) - - for name in result.snapshotNames { - await syncEngine.enqueueSnapshot(ckRecordName: name) - } - - await syncEngine.enqueueDeleteRecords(result.prunedMoveNames) + await syncEngine.enqueueMoves(gameIDs: gameIDs) }, sessionPingSink: { [preferences] gameID, authorID in guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } @@ -81,7 +66,7 @@ final class AppServices { ) } ) - self.moveBuffer = moveBuffer + self.movesUpdater = movesUpdater let colorStore = GamePlayerColorStore() self.colorStore = colorStore @@ -92,7 +77,7 @@ final class AppServices { let store = GameStore( persistence: persistence, - moveBuffer: moveBuffer, + movesUpdater: movesUpdater, authorIDProvider: { identity.currentID }, onGameCreated: { [preferences, syncEngine] ckRecordName in Task { @@ -157,10 +142,13 @@ final class AppServices { syncMonitor.note(message) } - await syncEngine.setOnRemoteMoves { [store, identity] moves in - store.noteIncomingOtherMoves(moves, currentAuthorID: identity.currentID) + await syncEngine.setOnRemoteMovesUpdated { [store, identity] gameIDs in + store.noteIncomingMovesUpdate( + gameIDs: gameIDs, + currentAuthorID: identity.currentID + ) if let currentID = store.currentEntity?.id, - moves.contains(where: { $0.gameID == currentID }) { + gameIDs.contains(currentID) { store.refreshCurrentGame() } } @@ -183,13 +171,6 @@ final class AppServices { store.handleRemoteRemoval(gameID: gameID) } - await syncEngine.setOnSnapshotsSaved { [snapshotService, syncEngine] names in - let prunedMoveNames = snapshotService.pruneMoves( - ckRecordNames: Set(names) - ) - await syncEngine.enqueueDeleteRecords(prunedMoveNames) - } - cloudService.onShareJoined = { [weak self] gameID in guard let self else { return } guard self.preferences.isICloudSyncEnabled, @@ -245,7 +226,7 @@ final class AppServices { } func syncOnForeground() async { - await moveBuffer.flush() + await movesUpdater.flush() guard await ensureICloudSyncStarted() else { return } let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() if recoveredMoveCount > 0 { @@ -261,11 +242,11 @@ final class AppServices { } func syncOnBackground() async { - await moveBuffer.flush() + await movesUpdater.flush() } func syncOpenSharedPuzzle() async { - await moveBuffer.flush() + await movesUpdater.flush() guard await ensureICloudSyncStarted() else { return } await syncMonitor.run("open-puzzle fetch") { try await syncEngine.fetchChanges(source: "open-puzzle poll") diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Reduces every `(author, device)` `MovesValue` for a single game into one +/// `GridState`. Per-cell last-writer-wins on wall-clock `updatedAt`; ties are +/// broken first by the writing user's `authorID` (lex-min wins), then by +/// `deviceID`, so the output is deterministic regardless of input order. The +/// merged `GridCell.authorID` is the *cell-level* preserved author from the +/// winning entry — not the parent record's author — so reveal-of-correct and +/// same-letter rewrites can hand off authorship without losing it. +enum GridStateMerger { + + static func merge(_ moves: [MovesValue]) -> GridState { + var winners: [GridPosition: Winner] = [:] + for view in moves { + for (position, cell) in view.cells { + let candidate = Winner( + cell: cell, + writerAuthorID: view.authorID, + deviceID: view.deviceID + ) + if let current = winners[position] { + if shouldReplace(current: current, with: candidate) { + winners[position] = candidate + } + } else { + winners[position] = candidate + } + } + } + + var grid: GridState = [:] + for (position, winner) in winners { + grid[position] = GridCell( + letter: winner.cell.letter, + markKind: winner.cell.markKind, + checkedWrong: winner.cell.checkedWrong, + authorID: winner.cell.authorID + ) + } + return grid + } + + private struct Winner { + var cell: TimestampedCell + var writerAuthorID: String + var deviceID: String + } + + private static func shouldReplace(current: Winner, with candidate: Winner) -> Bool { + if candidate.cell.updatedAt != current.cell.updatedAt { + return candidate.cell.updatedAt > current.cell.updatedAt + } + if candidate.writerAuthorID != current.writerAuthorID { + return candidate.writerAuthorID < current.writerAuthorID + } + return candidate.deviceID < current.deviceID + } +} diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift @@ -1,293 +0,0 @@ -import CoreData -import Foundation - -/// In-memory staging area for cell edits. Collapses rapid same-cell edits -/// down to one move, assigns lamports at flush time from the game's -/// `lamportHighWater`, writes `MoveEntity` rows, and updates the local -/// `CellEntity` cache in a single background transaction. Flushed moves are -/// handed to an injected sink — wired to `CKSyncEngine` in production, -/// stubbed in tests. -/// -/// Flush triggers: -/// - trailing-edge debounce (the user has stopped typing); -/// - cell change (focus moves to a different cell, so the previous cell's -/// final value gets a lamport before new edits start accumulating); -/// - explicit `flush()` (app background, game completion, tests). -actor MoveBuffer { - private struct Key: Hashable { - let gameID: UUID - let row: Int - let col: Int - } - - private struct Pending { - var letter: String - var markKind: Int16 - var checkedWrong: Bool - var authorID: String? - var enqueuedAt: Date - } - - private let debounceInterval: Duration - private let sessionPingStaleInterval: TimeInterval - private let persistence: PersistenceController - private let sink: @Sendable ([Move]) async -> Void - private let afterFlush: (@Sendable (Set<UUID>) async -> Void)? - private let sessionPingSink: (@Sendable (UUID, String) async -> Void)? - - private var buffer: [Key: Pending] = [:] - /// Insertion order so that lamports within a single flush are assigned - /// in the order edits were made rather than whatever the dictionary - /// happens to iterate. - private var order: [Key] = [] - /// The cell most recently enqueued. A subsequent enqueue targeting a - /// different cell flushes first; subsequent enqueues for the same cell - /// replace the pending value without flushing. - private var lastCell: Key? - private var debounceTask: Task<Void, Never>? - /// Per-game timestamp of the last session ping fired. The first - /// `enqueue` for a game with no entry — or one stale beyond - /// `sessionPingStaleInterval` — counts as a new session and fires a ping. - private var lastSessionPingAt: [UUID: Date] = [:] - - init( - debounceInterval: Duration = .milliseconds(1500), - sessionPingStaleInterval: TimeInterval = 20 * 60, - persistence: PersistenceController, - sink: @escaping @Sendable ([Move]) async -> Void, - afterFlush: (@Sendable (Set<UUID>) async -> Void)? = nil, - sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil - ) { - self.debounceInterval = debounceInterval - self.sessionPingStaleInterval = sessionPingStaleInterval - self.persistence = persistence - self.sink = sink - self.afterFlush = afterFlush - self.sessionPingSink = sessionPingSink - } - - /// Registers a cell edit. If the edit targets a different cell than the - /// previous enqueue, the previous cell is flushed first so the resulting - /// lamport order matches the user's editing order. - /// - /// `authorID` is the cell-effective author that gets persisted on the - /// `MoveEntity` and `CellEntity`. `actingAuthorID` is the user who - /// performed the action — usually the same value, but a same-letter - /// rewrite or a reveal-of-correct preserves the cell's original author - /// while the acting user is the typist. Session pings fire on the acting - /// user so presence reflects who is actually live in the game. - func enqueue( - gameID: UUID, - row: Int, - col: Int, - letter: String, - markKind: Int16, - checkedWrong: Bool, - authorID: String?, - actingAuthorID: String? = nil - ) async { - let key = Key(gameID: gameID, row: row, col: col) - - if let lastCell, lastCell != key { - await performFlush() - } - - if buffer[key] == nil { - order.append(key) - } - buffer[key] = Pending( - letter: letter, - markKind: markKind, - checkedWrong: checkedWrong, - authorID: authorID, - enqueuedAt: Date() - ) - lastCell = key - scheduleDebounce() - - let pingAuthorID = actingAuthorID ?? authorID - if let pingAuthorID, !pingAuthorID.isEmpty { - await maybeFireSessionPing(gameID: gameID, authorID: pingAuthorID) - } - } - - /// Resets session-ping tracking for `gameID`. Called from the puzzle - /// view's teardown so re-entry counts as a fresh session. - func noteSessionEnded(gameID: UUID) { - lastSessionPingAt.removeValue(forKey: gameID) - } - - private func maybeFireSessionPing(gameID: UUID, authorID: String) async { - let now = Date() - if let last = lastSessionPingAt[gameID], - now.timeIntervalSince(last) < sessionPingStaleInterval { - return - } - lastSessionPingAt[gameID] = now - if let sessionPingSink { - await sessionPingSink(gameID, authorID) - } - } - - /// Flushes any pending edits immediately and cancels the debounce. Safe - /// to call when the buffer is empty. - func flush() async { - debounceTask?.cancel() - debounceTask = nil - await performFlush() - } - - private func scheduleDebounce() { - debounceTask?.cancel() - let interval = debounceInterval - debounceTask = Task { [weak self] in - try? await Task.sleep(for: interval) - if Task.isCancelled { return } - await self?.debouncedFlush() - } - } - - private func debouncedFlush() async { - debounceTask = nil - await performFlush() - } - - private func performFlush() async { - guard !buffer.isEmpty else { return } - - let snapshot = buffer - let snapshotOrder = order - buffer.removeAll(keepingCapacity: true) - order.removeAll(keepingCapacity: true) - lastCell = nil - - let moves = persistAndAssignLamports( - snapshot: snapshot, - order: snapshotOrder - ) - guard !moves.isEmpty else { return } - await sink(moves) - if let afterFlush { - await afterFlush(Set(moves.map { $0.gameID })) - } - } - - /// Allocates lamports from each game's `lamportHighWater`, writes - /// `MoveEntity` rows, and bumps the high-water — all inside a single - /// background-context transaction so a crash can't leave the high-water - /// out of sync with the written moves. - private func persistAndAssignLamports( - snapshot: [Key: Pending], - order: [Key] - ) -> [Move] { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - var moves: [Move] = [] - var gamesByID: [UUID: GameEntity] = [:] - var cellsByGameID: [UUID: [GridPosition: CellEntity]] = [:] - - for key in order { - guard let pending = snapshot[key] else { continue } - - let game: GameEntity - if let cached = gamesByID[key.gameID] { - game = cached - } else { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "id == %@", key.gameID as CVarArg) - request.fetchLimit = 1 - guard let found = try? context.fetch(request).first else { continue } - gamesByID[key.gameID] = found - game = found - } - if cellsByGameID[key.gameID] == nil { - cellsByGameID[key.gameID] = Self.cellCacheMap(for: game) - } - - let lamport = game.lamportHighWater + 1 - game.lamportHighWater = lamport - if game.updatedAt.map({ $0 < pending.enqueuedAt }) ?? true { - game.updatedAt = pending.enqueuedAt - } - - let entity = MoveEntity(context: context) - entity.game = game - entity.lamport = lamport - entity.row = Int16(key.row) - entity.col = Int16(key.col) - entity.letter = pending.letter - entity.markKind = pending.markKind - entity.checkedWrong = pending.checkedWrong - entity.authorID = pending.authorID - entity.createdAt = pending.enqueuedAt - entity.ckRecordName = RecordSerializer.recordName( - forMoveInGame: key.gameID, - lamport: lamport - ) - - Self.updateCellCache( - for: game, - key: key, - pending: pending, - cells: &cellsByGameID[key.gameID, default: [:]], - in: context - ) - - moves.append(Move( - gameID: key.gameID, - lamport: lamport, - row: key.row, - col: key.col, - letter: pending.letter, - markKind: pending.markKind, - checkedWrong: pending.checkedWrong, - authorID: pending.authorID, - createdAt: pending.enqueuedAt - )) - } - - if context.hasChanges { - do { - try context.save() - } catch { - print("MoveBuffer: failed to save context: \(error)") - } - } - return moves - } - } - - private nonisolated static func cellCacheMap(for game: GameEntity) -> [GridPosition: CellEntity] { - let cellEntities = (game.cells as? Set<CellEntity>) ?? [] - var cells: [GridPosition: CellEntity] = [:] - cells.reserveCapacity(cellEntities.count) - for cell in cellEntities { - cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell - } - return cells - } - - private nonisolated static func updateCellCache( - for game: GameEntity, - key: Key, - pending: Pending, - cells: inout [GridPosition: CellEntity], - in context: NSManagedObjectContext - ) { - let position = GridPosition(row: key.row, col: key.col) - let cell: CellEntity - if let existing = cells[position] { - cell = existing - } else { - cell = CellEntity(context: context) - cell.game = game - cell.row = Int16(key.row) - cell.col = Int16(key.col) - cells[position] = cell - } - cell.letter = pending.letter - cell.markKind = pending.markKind - cell.checkedWrong = pending.checkedWrong - cell.letterAuthorID = pending.authorID - } -} diff --git a/Crossmate/Sync/MoveLog.swift b/Crossmate/Sync/MoveLog.swift @@ -1,133 +0,0 @@ -import Foundation - -/// A single cell-state mutation in the move log. Append-only: a move is -/// never rewritten, only superseded by a later move at the same position. -struct Move: Equatable, Sendable { - let gameID: UUID - let lamport: Int64 - let row: Int - let col: Int - let letter: String - let markKind: Int16 - let checkedWrong: Bool - let authorID: String? - let createdAt: Date -} - -/// A compacted grid state at a lamport boundary. Every move with -/// `lamport <= upToLamport` is already folded into `grid`, so those moves -/// can be deleted once the snapshot has been durably stored. -struct Snapshot: Equatable, Sendable { - let gameID: UUID - let upToLamport: Int64 - let grid: GridState - let createdAt: Date -} - -/// A touched cell in the grid. Cells the user has never entered a letter -/// or mark into are absent from `GridState` entirely. -struct GridCell: Equatable, Sendable, Codable { - var letter: String - var markKind: Int16 - var checkedWrong: Bool - var authorID: String? -} - -struct GridPosition: Hashable, Sendable, Codable { - let row: Int - let col: Int -} - -typealias GridState = [GridPosition: GridCell] - -enum MoveLog { - /// Deterministic replay: fold `moves` on top of an optional base - /// `snapshot` to produce the current grid state. Moves with - /// `lamport <= snapshot.upToLamport` are skipped — they are already - /// folded into the snapshot. Input order doesn't matter; the function - /// sorts by lamport internally. - static func replay(snapshot: Snapshot?, moves: [Move]) -> GridState { - var grid: GridState = snapshot?.grid ?? [:] - let cutoff: Int64 = snapshot?.upToLamport ?? 0 - let ordered = moves - .filter { $0.lamport > cutoff } - .sorted { - if $0.lamport != $1.lamport { return $0.lamport < $1.lamport } - if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt } - return ($0.authorID ?? "") < ($1.authorID ?? "") - } - - for move in ordered { - let position = GridPosition(row: move.row, col: move.col) - grid[position] = GridCell( - letter: move.letter, - markKind: move.markKind, - checkedWrong: move.checkedWrong, - authorID: move.authorID - ) - } - return grid - } - - /// Returns the most recent snapshot by `upToLamport`, or `nil` if the - /// input is empty. Ties are broken by `createdAt` so two snapshots at - /// the same lamport don't produce a nondeterministic winner. - static func latestSnapshot(from snapshots: [Snapshot]) -> Snapshot? { - snapshots.max { lhs, rhs in - if lhs.upToLamport != rhs.upToLamport { - return lhs.upToLamport < rhs.upToLamport - } - return lhs.createdAt < rhs.createdAt - } - } - - /// Wire format for `Snapshot.grid`. A flat array of entries keeps the - /// encoding `JSONEncoder`-friendly (dicts keyed by `GridPosition` - /// would need a custom encoder) and round-trips cleanly. - struct GridStatePayload: Codable, Equatable { - struct Entry: Codable, Equatable { - let row: Int - let col: Int - let letter: String - let markKind: Int16 - let checkedWrong: Bool - let authorID: String? - } - let entries: [Entry] - } - - static func encodeGridState(_ grid: GridState) throws -> Data { - let entries = grid - .map { position, cell in - GridStatePayload.Entry( - row: position.row, - col: position.col, - letter: cell.letter, - markKind: cell.markKind, - checkedWrong: cell.checkedWrong, - authorID: cell.authorID - ) - } - // Sort for determinism so identical grids encode to identical - // bytes — matters for tests and for diffing snapshots. - .sorted { lhs, rhs in - lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col - } - return try JSONEncoder().encode(GridStatePayload(entries: entries)) - } - - static func decodeGridState(_ data: Data) throws -> GridState { - let payload = try JSONDecoder().decode(GridStatePayload.self, from: data) - var grid: GridState = [:] - for entry in payload.entries { - let position = GridPosition(row: entry.row, col: entry.col) - grid[position] = GridCell( - letter: entry.letter, - markKind: entry.markKind, - checkedWrong: entry.checkedWrong, - authorID: entry.authorID - ) - } - return grid - } -} diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift @@ -0,0 +1,99 @@ +import Foundation + +/// A row/column coordinate inside a puzzle grid. Also used as the dictionary +/// key for `GridState` and `MovesValue.cells`. +struct GridPosition: Hashable, Sendable, Codable { + let row: Int + let col: Int +} + +/// Per-cell state in the merged grid — the result of `GridStateMerger.merge`. +/// `authorID` is the *preserved* cell author from the winning entry. +struct GridCell: Equatable, Sendable, Codable { + var letter: String + var markKind: Int16 + var checkedWrong: Bool + var authorID: String? +} + +/// The merged grid for a single game: only cells that have ever been touched +/// appear; untouched cells are absent rather than carrying empty values. +typealias GridState = [GridPosition: GridCell] + +/// One device's contribution to a game: every cell this `(authorID, deviceID)` +/// pair has ever touched, with the wall-clock timestamp of each touch. Merging +/// across all `MovesValue`s for a game reconstructs the current grid via per-cell +/// last-writer-wins on `TimestampedCell.updatedAt`. +struct MovesValue: Equatable, Sendable { + let gameID: UUID + let authorID: String + let deviceID: String + var cells: [GridPosition: TimestampedCell] + var updatedAt: Date +} + +/// A single cell touch within a `MovesValue`. The cell-level `authorID` is the +/// *preserved* author for the square — it can differ from the parent record's +/// `authorID` (which is the iCloud user who wrote this row) when a reveal-of- +/// correct or a same-letter rewrite preserves the original author of the +/// letter. The merger uses cell-level `authorID` when populating `GridCell`. +struct TimestampedCell: Equatable, Sendable { + var letter: String + var markKind: Int16 + var checkedWrong: Bool + var updatedAt: Date + var authorID: String? +} + +enum MovesCodec { + /// Wire format for `MovesValue.cells`. Each entry's `authorID` is the + /// preserved cell-level author — distinct from the parent record's + /// authorID, which identifies the iCloud user who wrote the record. + struct Payload: Codable, Equatable { + struct Entry: Codable, Equatable { + let row: Int + let col: Int + let letter: String + let markKind: Int16 + let checkedWrong: Bool + let updatedAt: Date + let authorID: String? + } + let entries: [Entry] + } + + static func encode(_ cells: [GridPosition: TimestampedCell]) throws -> Data { + let entries = cells + .map { position, cell in + Payload.Entry( + row: position.row, + col: position.col, + letter: cell.letter, + markKind: cell.markKind, + checkedWrong: cell.checkedWrong, + updatedAt: cell.updatedAt, + authorID: cell.authorID + ) + } + .sorted { lhs, rhs in + lhs.row != rhs.row ? lhs.row < rhs.row : lhs.col < rhs.col + } + return try JSONEncoder().encode(Payload(entries: entries)) + } + + static func decode(_ data: Data) throws -> [GridPosition: TimestampedCell] { + let payload = try JSONDecoder().decode(Payload.self, from: data) + var cells: [GridPosition: TimestampedCell] = [:] + for entry in payload.entries { + let position = GridPosition(row: entry.row, col: entry.col) + cells[position] = TimestampedCell( + letter: entry.letter, + markKind: entry.markKind, + checkedWrong: entry.checkedWrong, + updatedAt: entry.updatedAt, + authorID: entry.authorID + ) + } + return cells + } +} diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -0,0 +1,320 @@ +import CoreData +import Foundation + +/// In-memory staging area for cell edits. Coalesces rapid same-cell edits down +/// to one per-cell entry, on flush merges them into the local device's +/// `MovesEntity` row (per-cell wall-clock LWW), updates the local `CellEntity` +/// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine +/// can enqueue Moves records for upload. +/// +/// Flush triggers: +/// - trailing-edge debounce (the user has stopped typing); +/// - cell change (focus moves to a different cell); +/// - explicit `flush()` (app background, game completion, tests). +actor MovesUpdater { + private struct Key: Hashable { + let gameID: UUID + let row: Int + let col: Int + } + + private struct Pending { + var letter: String + var markKind: Int16 + var checkedWrong: Bool + var authorID: String? + var enqueuedAt: Date + } + + private let debounceInterval: Duration + private let sessionPingStaleInterval: TimeInterval + private let persistence: PersistenceController + private let writerAuthorIDProvider: @Sendable () async -> String? + private let sink: @Sendable (Set<UUID>) async -> Void + private let sessionPingSink: (@Sendable (UUID, String) async -> Void)? + + private var buffer: [Key: Pending] = [:] + /// Cell most recently enqueued. A subsequent enqueue targeting a different + /// cell flushes first so `updatedAt` ordering in the merged grid matches + /// editing order for cells whose wall clocks are within the same tick. + private var lastCell: Key? + private var debounceTask: Task<Void, Never>? + /// Per-game timestamp of the last session ping fired. The first `enqueue` + /// for a game with no entry — or one stale beyond + /// `sessionPingStaleInterval` — counts as a new session and fires a ping. + private var lastSessionPingAt: [UUID: Date] = [:] + + init( + debounceInterval: Duration = .milliseconds(1500), + sessionPingStaleInterval: TimeInterval = 20 * 60, + persistence: PersistenceController, + writerAuthorIDProvider: @escaping @Sendable () async -> String?, + sink: @escaping @Sendable (Set<UUID>) async -> Void, + sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil + ) { + self.debounceInterval = debounceInterval + self.sessionPingStaleInterval = sessionPingStaleInterval + self.persistence = persistence + self.writerAuthorIDProvider = writerAuthorIDProvider + self.sink = sink + self.sessionPingSink = sessionPingSink + } + + /// Registers a cell edit. `authorID` is the cell-effective author that + /// gets persisted into the merged grid — it may differ from the acting + /// (writer) user when a same-letter rewrite or a reveal-of-correct + /// preserves the original author. The acting user is still passed + /// separately so session pings fire on the typist. + func enqueue( + gameID: UUID, + row: Int, + col: Int, + letter: String, + markKind: Int16, + checkedWrong: Bool, + authorID: String?, + actingAuthorID: String? = nil + ) async { + let key = Key(gameID: gameID, row: row, col: col) + + if let lastCell, lastCell != key { + await performFlush() + } + + buffer[key] = Pending( + letter: letter, + markKind: markKind, + checkedWrong: checkedWrong, + authorID: authorID, + enqueuedAt: Date() + ) + lastCell = key + scheduleDebounce() + + let pingAuthorID = actingAuthorID ?? authorID + if let pingAuthorID, !pingAuthorID.isEmpty { + await maybeFireSessionPing(gameID: gameID, authorID: pingAuthorID) + } + } + + /// Resets session-ping tracking for `gameID`. Called from the puzzle + /// view's teardown so re-entry counts as a fresh session. + func noteSessionEnded(gameID: UUID) { + lastSessionPingAt.removeValue(forKey: gameID) + } + + /// Flushes any pending edits immediately and cancels the debounce. Safe + /// to call when the buffer is empty. + func flush() async { + debounceTask?.cancel() + debounceTask = nil + await performFlush() + } + + private func maybeFireSessionPing(gameID: UUID, authorID: String) async { + let now = Date() + if let last = lastSessionPingAt[gameID], + now.timeIntervalSince(last) < sessionPingStaleInterval { + return + } + lastSessionPingAt[gameID] = now + if let sessionPingSink { + await sessionPingSink(gameID, authorID) + } + } + + private func scheduleDebounce() { + debounceTask?.cancel() + let interval = debounceInterval + debounceTask = Task { [weak self] in + try? await Task.sleep(for: interval) + if Task.isCancelled { return } + await self?.debouncedFlush() + } + } + + private func debouncedFlush() async { + debounceTask = nil + await performFlush() + } + + private func performFlush() async { + guard !buffer.isEmpty else { return } + // The parent record name embeds the writer's authorID — without it we + // can't address the row at all. Drop the flush; subsequent enqueues + // will hit this path again once identity resolves. + guard let writerAuthorID = await writerAuthorIDProvider(), + !writerAuthorID.isEmpty + else { + buffer.removeAll(keepingCapacity: true) + lastCell = nil + return + } + + let snapshot = buffer + buffer.removeAll(keepingCapacity: true) + lastCell = nil + + let affected = persistAndMerge( + snapshot: snapshot, + writerAuthorID: writerAuthorID + ) + guard !affected.isEmpty else { return } + await sink(affected) + } + + /// Merges buffered edits into the local device's `MovesEntity` row per + /// game. Idempotent: existing per-cell entries are kept if they have a + /// later `updatedAt` than the new edit (defensive against wall-clock + /// regressions). + private func persistAndMerge( + snapshot: [Key: Pending], + writerAuthorID: String + ) -> Set<UUID> { + let context = persistence.container.newBackgroundContext() + return context.performAndWait { + var byGame: [UUID: [(Key, Pending)]] = [:] + for (key, pending) in snapshot { + byGame[key.gameID, default: []].append((key, pending)) + } + + var affected = Set<UUID>() + for (gameID, edits) in byGame { + guard let game = Self.fetchGame(gameID: gameID, in: context) else { continue } + + let movesEntity = Self.ensureMovesEntity( + for: gameID, + writerAuthorID: writerAuthorID, + game: game, + in: context + ) + + var existing: [GridPosition: TimestampedCell] = [:] + if let data = movesEntity.cells, !data.isEmpty { + existing = (try? MovesCodec.decode(data)) ?? [:] + } + + var maxUpdatedAt = movesEntity.updatedAt ?? .distantPast + var cellCacheMap = Self.cellCacheMap(for: game) + + for (key, pending) in edits { + let position = GridPosition(row: key.row, col: key.col) + let newCell = TimestampedCell( + letter: pending.letter, + markKind: pending.markKind, + checkedWrong: pending.checkedWrong, + updatedAt: pending.enqueuedAt, + authorID: pending.authorID + ) + if let current = existing[position], + current.updatedAt > newCell.updatedAt { + continue + } + existing[position] = newCell + if newCell.updatedAt > maxUpdatedAt { + maxUpdatedAt = newCell.updatedAt + } + + Self.updateCellCache( + for: game, + key: key, + pending: pending, + cells: &cellCacheMap, + in: context + ) + } + + movesEntity.cells = (try? MovesCodec.encode(existing)) ?? Data() + movesEntity.updatedAt = maxUpdatedAt + if game.updatedAt.map({ $0 < maxUpdatedAt }) ?? true { + game.updatedAt = maxUpdatedAt + } + affected.insert(gameID) + } + + if context.hasChanges { + do { + try context.save() + } catch { + print("MovesUpdater: failed to save context: \(error)") + return [] + } + } + return affected + } + } + + private nonisolated static func fetchGame( + gameID: UUID, + in ctx: NSManagedObjectContext + ) -> GameEntity? { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + return try? ctx.fetch(req).first + } + + private nonisolated static func ensureMovesEntity( + for gameID: UUID, + writerAuthorID: String, + game: GameEntity, + in ctx: NSManagedObjectContext + ) -> MovesEntity { + let deviceID = RecordSerializer.localDeviceID + let recordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: writerAuthorID, + deviceID: deviceID + ) + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + req.fetchLimit = 1 + if let existing = try? ctx.fetch(req).first { + return existing + } + let entity = MovesEntity(context: ctx) + entity.game = game + entity.ckRecordName = recordName + entity.authorID = writerAuthorID + entity.deviceID = deviceID + entity.cells = Data() + entity.updatedAt = Date() + return entity + } + + private nonisolated static func cellCacheMap( + for game: GameEntity + ) -> [GridPosition: CellEntity] { + let cellEntities = (game.cells as? Set<CellEntity>) ?? [] + var cells: [GridPosition: CellEntity] = [:] + cells.reserveCapacity(cellEntities.count) + for cell in cellEntities { + cells[GridPosition(row: Int(cell.row), col: Int(cell.col))] = cell + } + return cells + } + + private nonisolated static func updateCellCache( + for game: GameEntity, + key: Key, + pending: Pending, + cells: inout [GridPosition: CellEntity], + in context: NSManagedObjectContext + ) { + let position = GridPosition(row: key.row, col: key.col) + let cell: CellEntity + if let existing = cells[position] { + cell = existing + } else { + cell = CellEntity(context: context) + cell.game = game + cell.row = Int16(key.row) + cell.col = Int16(key.col) + cells[position] = cell + } + cell.letter = pending.letter + cell.markKind = pending.markKind + cell.checkedWrong = pending.checkedWrong + cell.letterAuthorID = pending.authorID + } +} diff --git a/Crossmate/Sync/PresencePublisher.swift b/Crossmate/Sync/PresencePublisher.swift @@ -4,8 +4,8 @@ import Foundation /// Debounced writer for the local player's cursor selection. Updates the /// `PlayerEntity` row for `(gameID, authorID)` with the new `selRow`/`selCol`/ /// `selDir` and asks the sync engine to push the Player record. Cursor edits -/// don't go through `MoveBuffer` because they aren't moves — there's no -/// Lamport clock, no replay, and last-writer-wins is the right semantics. +/// don't go through `MovesUpdater` because they aren't cell edits — they live +/// on `PlayerEntity` with last-writer-wins semantics. actor PresencePublisher { private let debounceInterval: Duration private let persistence: PersistenceController diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -32,16 +32,15 @@ enum RecordSerializer { "game-\(gameID.uuidString)" } - /// Includes `localDeviceID` so two devices with the same Lamport value - /// produce distinct record names and can coexist in CloudKit without a - /// write-write conflict. - static func recordName(forMoveInGame gameID: UUID, lamport: Int64) -> String { - "move-\(gameID.uuidString)-\(lamport)-\(localDeviceID)" - } - - /// Includes `localDeviceID` for the same reason as move records. - static func recordName(forSnapshotInGame gameID: UUID, upToLamport: Int64) -> String { - "snapshot-\(gameID.uuidString)-\(upToLamport)-\(localDeviceID)" + /// One Moves record per `(game, authorID, deviceID)`. Each device only + /// writes to its own slot, so there are no write-write conflicts on the + /// `cells` field. + static func recordName( + forMovesInGame gameID: UUID, + authorID: String, + deviceID: String + ) -> String { + "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)" } /// One player record per (game, author). Each participant only ever @@ -74,52 +73,29 @@ enum RecordSerializer { CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName) } - // MARK: - Move / Snapshot record building + // MARK: - Moves record building - static func moveRecord( - from move: Move, - zone: CKRecordZone.ID, - systemFields: Data? - ) -> CKRecord { - let moveName = recordName(forMoveInGame: move.gameID, lamport: move.lamport) - let record = restoreOrCreate( - recordType: "Move", - recordName: moveName, - zone: zone, - systemFields: systemFields - ) - - record["lamport"] = move.lamport as CKRecordValue - record["row"] = Int64(move.row) as CKRecordValue - record["col"] = Int64(move.col) as CKRecordValue - record["letter"] = move.letter as CKRecordValue - record["markKind"] = Int64(move.markKind) as CKRecordValue - record["checkedWrong"] = move.checkedWrong as CKRecordValue - record["authorID"] = move.authorID as CKRecordValue? - record["createdAt"] = move.createdAt as CKRecordValue - - return record - } - - static func snapshotRecord( - from snapshot: Snapshot, + static func movesRecord( + from view: MovesValue, zone: CKRecordZone.ID, systemFields: Data? ) throws -> CKRecord { - let snapshotName = recordName( - forSnapshotInGame: snapshot.gameID, - upToLamport: snapshot.upToLamport + let movesName = recordName( + forMovesInGame: view.gameID, + authorID: view.authorID, + deviceID: view.deviceID ) let record = restoreOrCreate( - recordType: "Snapshot", - recordName: snapshotName, + recordType: "Moves", + recordName: movesName, zone: zone, systemFields: systemFields ) - record["upToLamport"] = snapshot.upToLamport as CKRecordValue - record["createdAt"] = snapshot.createdAt as CKRecordValue - record["gridState"] = try MoveLog.encodeGridState(snapshot.grid) as CKRecordValue + record["authorID"] = view.authorID as CKRecordValue + record["deviceID"] = view.deviceID as CKRecordValue + record["updatedAt"] = view.updatedAt as CKRecordValue + record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue return record } @@ -254,96 +230,57 @@ enum RecordSerializer { return (gameID, authorPart) } - /// Parses an incoming `Move` CKRecord into a value type without - /// touching Core Data. Returns `nil` if required fields are missing or - /// the record name doesn't match the `move-<gameUUID>-<lamport>` shape. - static func parseMoveRecord(_ record: CKRecord) -> Move? { - guard record.recordType == "Move" else { return nil } - guard let (gameID, _) = parseMoveRecordName(record.recordID.recordName) else { - return nil - } - guard let lamport = record["lamport"] as? Int64 else { return nil } - let row = (record["row"] as? Int64).map(Int.init) ?? 0 - let col = (record["col"] as? Int64).map(Int.init) ?? 0 - let letter = record["letter"] as? String ?? "" - let markKind = Int16((record["markKind"] as? Int64) ?? 0) - let checkedWrong = (record["checkedWrong"] as? Bool) ?? false - let authorID = record["authorID"] as? String - let createdAt = record["createdAt"] as? Date ?? record.creationDate ?? Date() - - return Move( - gameID: gameID, - lamport: lamport, - row: row, - col: col, - letter: letter, - markKind: markKind, - checkedWrong: checkedWrong, - authorID: authorID, - createdAt: createdAt - ) - } - - /// Parses an incoming `Snapshot` CKRecord into a value type. Returns - /// `nil` if required fields are missing or the grid state payload - /// fails to decode. - static func parseSnapshotRecord(_ record: CKRecord) -> Snapshot? { - guard record.recordType == "Snapshot" else { return nil } - guard let (gameID, _) = parseSnapshotRecordName(record.recordID.recordName) else { - return nil - } - guard let upToLamport = record["upToLamport"] as? Int64, - let data = record["gridState"] as? Data, - let grid = try? MoveLog.decodeGridState(data) + /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil` + /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>` + /// shape or the cells payload fails to decode. + static func parseMovesRecord(_ record: CKRecord) -> MovesValue? { + guard record.recordType == "Moves" else { return nil } + guard let (gameID, authorID, deviceID) = parseMovesRecordName( + record.recordID.recordName + ) else { return nil } + guard let data = record["cells"] as? Data, + let cells = try? MovesCodec.decode(data) else { return nil } - - let createdAt = record["createdAt"] as? Date ?? record.creationDate ?? Date() - return Snapshot( + let updatedAt = record["updatedAt"] as? Date + ?? record.modificationDate + ?? Date() + return MovesValue( gameID: gameID, - upToLamport: upToLamport, - grid: grid, - createdAt: createdAt + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt ) } - private static func parseMoveRecordName(_ name: String) -> (UUID, Int64)? { - parseGameScopedRecordName(name, prefix: "move-") - } - - private static func parseSnapshotRecordName(_ name: String) -> (UUID, Int64)? { - parseGameScopedRecordName(name, prefix: "snapshot-") - } - - private static func parseGameScopedRecordName( - _ name: String, - prefix: String - ) -> (UUID, Int64)? { + /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts. + /// `deviceID` is the suffix after the final `-`; `authorID` may itself + /// contain dashes (e.g. CloudKit user record names with no dashes today, + /// but we don't want to assume). + static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? { + let prefix = "moves-" guard name.hasPrefix(prefix) else { return nil } let rest = name.dropFirst(prefix.count) - // Format: `<36-char UUID>-<lamport>-<deviceID>`. - // UUIDs are always 36 chars (8-4-4-4-12), so slice by fixed length. let uuidLength = 36 guard rest.count > uuidLength, rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" else { return nil } let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) guard let gameID = UUID(uuidString: uuidPart) else { return nil } - // After the UUID dash: `<lamport>-<deviceID>`. The device suffix is - // required — records without it are from an older build and can't be - // parsed. let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) let tail = rest[afterUUID...] - guard let nextDash = tail.firstIndex(of: "-") else { return nil } - let lamportPart = tail[tail.startIndex..<nextDash] - guard let lamport = Int64(lamportPart) else { return nil } - return (gameID, lamport) + guard let lastDash = tail.lastIndex(of: "-") else { return nil } + let authorID = String(tail[tail.startIndex..<lastDash]) + let deviceID = String(tail[tail.index(after: lastDash)...]) + guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } + return (gameID, authorID, deviceID) } // MARK: - Applying incoming CKRecords to Core Data /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if - /// none exists yet. Move, Snapshot, and Player records can arrive in a - /// different fetch batch than the Game record that created the zone — on + /// none exists yet. Moves and Player records can arrive in a different + /// fetch batch than the Game record that created the zone — on /// a fresh device CKSyncEngine paginates the initial pull and there is no /// guarantee that Game comes first. Without this stub the parent lookup /// fails, the inbound record is dropped, but CKSyncEngine still advances @@ -434,6 +371,46 @@ enum RecordSerializer { return entity } + /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight + /// off the record so any forward-compat fields the encoder added are + /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the + /// record is fresher. + static func applyMovesRecord( + _ record: CKRecord, + value: MovesValue, + to ctx: NSManagedObjectContext + ) { + let ckName = record.recordID.recordName + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) + req.fetchLimit = 1 + + let entity: MovesEntity + if let existing = try? ctx.fetch(req).first { + entity = existing + } else { + let game = ensureGameEntity( + forGameID: value.gameID, + zoneID: record.recordID.zoneID, + in: ctx + ) + entity = MovesEntity(context: ctx) + entity.game = game + } + + entity.ckRecordName = ckName + entity.ckSystemFields = encodeSystemFields(of: record) + entity.authorID = value.authorID + entity.deviceID = value.deviceID + entity.updatedAt = value.updatedAt + entity.cells = (record["cells"] as? Data) ?? Data() + + if let game = entity.game, + game.updatedAt.map({ $0 < value.updatedAt }) ?? true { + game.updatedAt = value.updatedAt + } + } + // MARK: - System fields encode/decode static func encodeSystemFields(of record: CKRecord) -> Data? { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -50,9 +50,9 @@ struct Ping: Sendable { /// This actor's job is to: /// /// - Start and persist each engine's state across launches. -/// - Translate outbound edits (from `MoveBuffer`) into pending record zone +/// - Translate outbound edits (from `MovesUpdater`) into pending record zone /// changes that CKSyncEngine will batch and send. -/// - Apply incoming `Move`, `Snapshot`, and `Game` records to Core Data and +/// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and /// replay them onto the `CellEntity` cache. /// - Notify the main actor so the in-memory `Game` stays current. actor SyncEngine { @@ -86,20 +86,19 @@ actor SyncEngine { /// actually wired up end-to-end. private var loggedFirstSharedPushPayload = false - private var onRemoteMoves: (@MainActor @Sendable ([Move]) async -> Void)? + private var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? private var onPings: (@MainActor @Sendable ([Ping]) async -> Void)? private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? - private var onSnapshotsSaved: (@MainActor @Sendable ([String]) async -> Void)? private var tracer: (@MainActor @Sendable (String) -> Void)? func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { tracer = t } - func setOnRemoteMoves(_ cb: @MainActor @Sendable @escaping ([Move]) async -> Void) { - onRemoteMoves = cb + func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { + onRemoteMovesUpdated = cb } func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) { @@ -118,10 +117,6 @@ actor SyncEngine { onGameRemoved = cb } - func setOnSnapshotsSaved(_ cb: @MainActor @Sendable @escaping ([String]) async -> Void) { - onSnapshotsSaved = cb - } - init(container: CKContainer, persistence: PersistenceController) { self.container = container self.persistence = persistence @@ -162,84 +157,62 @@ actor SyncEngine { // MARK: - Outbound - /// Registers Move records as pending sends. Called by the `MoveBuffer` - /// sink after Lamports are assigned and `MoveEntity` rows are persisted. - /// Groups moves by game and routes each group to the correct engine. - func enqueueMoves(_ moves: [Move]) { - guard !moves.isEmpty else { return } - let grouped = Dictionary(grouping: moves, by: \.gameID) + /// Registers each game's local-device Moves record as a pending save. + /// Called by the `MovesUpdater` sink after the device's `MovesEntity` row + /// has been merged and persisted. Routes per-game to the correct engine. + func enqueueMoves(gameIDs: Set<UUID>) { + guard !gameIDs.isEmpty else { return } let ctx = persistence.container.newBackgroundContext() - for (gameID, gameMoves) in grouped { - guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } - let engine = info.scope == 1 ? sharedEngine : privateEngine - guard let engine else { continue } - let existingPendingNames = Set(engine.state.pendingRecordZoneChanges.compactMap { - if case .saveRecord(let id) = $0 { return id.recordName } - return nil - }) - let changes: [CKSyncEngine.PendingRecordZoneChange] = gameMoves.compactMap { move in - let name = RecordSerializer.recordName(forMoveInGame: move.gameID, lamport: move.lamport) - guard !existingPendingNames.contains(name) else { return nil } - return .saveRecord(CKRecord.ID(recordName: name, zoneID: info.zoneID)) - } - if !changes.isEmpty { - engine.state.add(pendingRecordZoneChanges: changes) + ctx.performAndWait { + for gameID in gameIDs { + guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } + let engine = info.scope == 1 ? sharedEngine : privateEngine + guard let engine else { continue } + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate( + format: "game.id == %@ AND deviceID == %@", + gameID as CVarArg, + RecordSerializer.localDeviceID + ) + req.fetchLimit = 1 + guard let entity = try? ctx.fetch(req).first, + let recordName = entity.ckRecordName + else { continue } + let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) + let already = engine.state.pendingRecordZoneChanges.contains { + if case .saveRecord(let id) = $0 { return id.recordName == recordName } + return false + } + guard !already else { continue } + engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } } } - /// Re-enqueues locally persisted moves that do not yet have CloudKit - /// system fields. This covers crash/relaunch recovery and any save that - /// reached Core Data before CKSyncEngine recorded the pending change. + /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit + /// system fields. Covers crash/relaunch recovery and any save that reached + /// Core Data before CKSyncEngine recorded the pending change. @discardableResult func enqueueUnconfirmedMoves() -> Int { let ctx = persistence.container.newBackgroundContext() - let moves: [Move] = ctx.performAndWait { - let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - req.predicate = NSPredicate(format: "ckSystemFields == nil") - req.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] + let gameIDs: Set<UUID> = ctx.performAndWait { + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate( + format: "ckSystemFields == nil AND deviceID == %@", + RecordSerializer.localDeviceID + ) let entities = (try? ctx.fetch(req)) ?? [] - return entities.compactMap { entity in - guard let gameID = entity.game?.id else { return nil } - return Move( - gameID: gameID, - lamport: entity.lamport, - row: Int(entity.row), - col: Int(entity.col), - letter: entity.letter ?? "", - markKind: entity.markKind, - checkedWrong: entity.checkedWrong, - authorID: entity.authorID, - createdAt: entity.createdAt ?? Date() - ) - } + return Set(entities.compactMap { $0.game?.id }) } - enqueueMoves(moves) - return moves.count + enqueueMoves(gameIDs: gameIDs) + return gameIDs.count } /// Registers record deletions as pending sends. Extracts the game UUID /// from the record name and routes to the correct engine. - func enqueueDeleteRecords(_ ckRecordNames: [String]) { - guard !ckRecordNames.isEmpty else { return } - let ctx = persistence.container.newBackgroundContext() - let grouped = Dictionary(grouping: ckRecordNames) { name -> UUID? in - gameID(fromRecordName: name) - } - for (gameIDOpt, names) in grouped { - guard let gameID = gameIDOpt, - let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } - let engine = info.scope == 1 ? sharedEngine : privateEngine - guard let engine else { continue } - engine.state.add(pendingRecordZoneChanges: names.map { name in - .deleteRecord(CKRecord.ID(recordName: name, zoneID: info.zoneID)) - }) - } - } - /// Registers the game's CloudKit zone for deletion. Each game owns its /// own zone, so this removes all remote records for the puzzle, including - /// moves, snapshots, player records, session pings, and share metadata. + /// moves, player records, session pings, and share metadata. func enqueueDeleteGame(_ deletion: GameCloudDeletion) { let zoneID = CKRecordZone.ID( zoneName: deletion.ckZoneName, @@ -250,18 +223,6 @@ actor SyncEngine { engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) } - /// Registers a Snapshot record as a pending send. Parses the game UUID - /// from the record name and routes to the correct engine. - func enqueueSnapshot(ckRecordName: String) { - guard let gameID = gameID(fromRecordName: ckRecordName) else { return } - let ctx = persistence.container.newBackgroundContext() - guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } - let engine = info.scope == 1 ? sharedEngine : privateEngine - guard let engine else { return } - let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID) - engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) - } - /// Registers a Ping record as a pending send. Pings cover session-start, /// join, win, check, and reveal events; sender-only state — the payload is /// stashed in `pendingPings` and only used to build the outgoing @@ -527,15 +488,13 @@ actor SyncEngine { } /// Extracts the game UUID from any of our record name formats: - /// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`, `player-<UUID>-…`, - /// `ping-<UUID>-…`. + /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`. private nonisolated func gameID(fromRecordName name: String) -> UUID? { if name.hasPrefix("game-") { return UUID(uuidString: String(name.dropFirst("game-".count))) } let prefix: String - if name.hasPrefix("move-") { prefix = "move-" } - else if name.hasPrefix("snapshot-") { prefix = "snapshot-" } + if name.hasPrefix("moves-") { prefix = "moves-" } else if name.hasPrefix("player-") { prefix = "player-" } else if name.hasPrefix("ping-") { prefix = "ping-" } else { return nil } @@ -600,46 +559,26 @@ actor SyncEngine { recordID: recordID, includePuzzleSource: entity.ckSystemFields == nil ) - } else if name.hasPrefix("move-") { - let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", name) - req.fetchLimit = 1 - guard let entity = try? ctx.fetch(req).first, - let gameID = entity.game?.id - else { return nil } - let move = Move( - gameID: gameID, - lamport: entity.lamport, - row: Int(entity.row), - col: Int(entity.col), - letter: entity.letter ?? "", - markKind: entity.markKind, - checkedWrong: entity.checkedWrong, - authorID: entity.authorID, - createdAt: entity.createdAt ?? Date() - ) - return RecordSerializer.moveRecord( - from: move, - zone: zoneID, - systemFields: entity.ckSystemFields - ) - } else if name.hasPrefix("snapshot-") { - let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") + } else if name.hasPrefix("moves-") { + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") req.predicate = NSPredicate(format: "ckRecordName == %@", name) req.fetchLimit = 1 guard let entity = try? ctx.fetch(req).first, let gameID = entity.game?.id, - let gridData = entity.gridState, - let grid = try? MoveLog.decodeGridState(gridData) + let authorID = entity.authorID, + let deviceID = entity.deviceID, + let updatedAt = entity.updatedAt else { return nil } - let snapshot = Snapshot( + let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] + let value = MovesValue( gameID: gameID, - upToLamport: entity.upToLamport, - grid: grid, - createdAt: entity.createdAt ?? Date() + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt ) - return try? RecordSerializer.snapshotRecord( - from: snapshot, + return try? RecordSerializer.movesRecord( + from: value, zone: zoneID, systemFields: entity.ckSystemFields ) @@ -682,49 +621,6 @@ actor SyncEngine { // MARK: - Incoming record application - private nonisolated func applyMoveRecord( - _ record: CKRecord, - move: Move, - in ctx: NSManagedObjectContext - ) { - let ckName = record.recordID.recordName - let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) - req.fetchLimit = 1 - - let entity: MoveEntity - if let existing = try? ctx.fetch(req).first { - entity = existing - } else { - let game = RecordSerializer.ensureGameEntity( - forGameID: move.gameID, - zoneID: record.recordID.zoneID, - in: ctx - ) - entity = MoveEntity(context: ctx) - entity.game = game - } - - entity.ckRecordName = ckName - entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record) - entity.lamport = move.lamport - entity.row = Int16(move.row) - entity.col = Int16(move.col) - entity.letter = move.letter - entity.markKind = move.markKind - entity.checkedWrong = move.checkedWrong - entity.authorID = move.authorID - entity.createdAt = move.createdAt - - if let game = entity.game, move.lamport > game.lamportHighWater { - game.lamportHighWater = move.lamport - } - if let game = entity.game, - game.updatedAt.map({ $0 < move.createdAt }) ?? true { - game.updatedAt = move.createdAt - } - } - private nonisolated func applyPlayerRecord( _ record: CKRecord, in ctx: NSManagedObjectContext @@ -785,39 +681,9 @@ actor SyncEngine { } } - private nonisolated func applySnapshotRecord( - _ record: CKRecord, - snapshot: Snapshot, - in ctx: NSManagedObjectContext - ) { - let ckName = record.recordID.recordName - let req = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) - req.fetchLimit = 1 - - let entity: SnapshotEntity - if let existing = try? ctx.fetch(req).first { - entity = existing - } else { - let game = RecordSerializer.ensureGameEntity( - forGameID: snapshot.gameID, - zoneID: record.recordID.zoneID, - in: ctx - ) - entity = SnapshotEntity(context: ctx) - entity.game = game - } - - entity.ckRecordName = ckName - entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: record) - entity.upToLamport = snapshot.upToLamport - entity.createdAt = snapshot.createdAt - entity.gridState = try? MoveLog.encodeGridState(snapshot.grid) - } - - /// Replays all persisted moves and snapshots for `gameID` onto the - /// `CellEntity` cache. Must be called inside a `performAndWait` block on - /// the same context. + /// Merges every device's `MovesEntity` row for `gameID` and reconciles the + /// `CellEntity` cache against the resulting grid. Must be called inside a + /// `performAndWait` block on the same context. private nonisolated func replayCellCache( for gameID: UUID, in ctx: NSManagedObjectContext @@ -827,44 +693,11 @@ actor SyncEngine { gameReq.fetchLimit = 1 guard let game = try? ctx.fetch(gameReq).first else { return } - let moveReq = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - moveReq.predicate = NSPredicate(format: "game == %@", game) - let moveEntities = (try? ctx.fetch(moveReq)) ?? [] - let moves: [Move] = moveEntities.compactMap { e in - guard let gID = e.game?.id else { return nil } - return Move( - gameID: gID, - lamport: e.lamport, - row: Int(e.row), - col: Int(e.col), - letter: e.letter ?? "", - markKind: e.markKind, - checkedWrong: e.checkedWrong, - authorID: e.authorID, - createdAt: e.createdAt ?? Date() - ) - } - - let snapReq = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - snapReq.predicate = NSPredicate(format: "game == %@", game) - let snapEntities = (try? ctx.fetch(snapReq)) ?? [] - let snapshots: [Snapshot] = snapEntities.compactMap { e in - guard let gID = e.game?.id, - let data = e.gridState, - let grid = try? MoveLog.decodeGridState(data) - else { return nil } - return Snapshot( - gameID: gID, - upToLamport: e.upToLamport, - grid: grid, - createdAt: e.createdAt ?? Date() - ) - } - - let gridState = MoveLog.replay( - snapshot: MoveLog.latestSnapshot(from: snapshots), - moves: moves - ) + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate(format: "game == %@", game) + let movesEntities = (try? ctx.fetch(movesReq)) ?? [] + let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) } + let gridState = GridStateMerger.merge(values) let existingCells = (game.cells as? Set<CellEntity>) ?? [] var byPosition: [GridPosition: CellEntity] = [:] @@ -896,6 +729,25 @@ actor SyncEngine { } } + /// Hydrates a `MovesValue` from a `MovesEntity`. Returns `nil` if the row + /// is missing required fields (e.g. an unpopulated stub from a partial + /// fetch). + private nonisolated static func movesValue(from entity: MovesEntity) -> MovesValue? { + guard let gameID = entity.game?.id, + let authorID = entity.authorID, + let deviceID = entity.deviceID, + let updatedAt = entity.updatedAt + else { return nil } + let cells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] + return MovesValue( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt + ) + } + // MARK: - Event handlers private func handleFetchedDatabaseChanges( @@ -999,8 +851,8 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let (newMoves, affectedGameIDs, pings): ([Move], Set<UUID>, [Ping]) = ctx.performAndWait { - var moves: [Move] = [] + let (movesUpdatedGameIDs, affectedGameIDs, pings): (Set<UUID>, Set<UUID>, [Ping]) = ctx.performAndWait { + var movesUpdated = Set<UUID>() var affected = Set<UUID>() var pings: [Ping] = [] for mod in event.modifications { @@ -1009,16 +861,11 @@ actor SyncEngine { case "Game": let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope) if let id = entity.id { affected.insert(id) } - case "Move": - if let move = RecordSerializer.parseMoveRecord(record) { - self.applyMoveRecord(record, move: move, in: ctx) - moves.append(move) - affected.insert(move.gameID) - } - case "Snapshot": - if let snapshot = RecordSerializer.parseSnapshotRecord(record) { - self.applySnapshotRecord(record, snapshot: snapshot, in: ctx) - affected.insert(snapshot.gameID) + case "Moves": + if let value = RecordSerializer.parseMovesRecord(record) { + RecordSerializer.applyMovesRecord(record, value: value, to: ctx) + movesUpdated.insert(value.gameID) + affected.insert(value.gameID) } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { @@ -1043,7 +890,7 @@ actor SyncEngine { affected.insert(id) } } - for gameID in Set(moves.map { $0.gameID }) { + for gameID in movesUpdated { self.replayCellCache(for: gameID, in: ctx) } // CKSyncEngine advances its change token whenever the delegate @@ -1063,11 +910,11 @@ actor SyncEngine { ) } } - return (moves, affected, pings) + return (movesUpdated, affected, pings) } - if let onRemoteMoves, !newMoves.isEmpty { - await onRemoteMoves(newMoves) + if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { + await onRemoteMovesUpdated(movesUpdatedGameIDs) } if let onPings, !pings.isEmpty { await onPings(pings) @@ -1115,18 +962,15 @@ actor SyncEngine { ) { let name = recordID.recordName let entityName: String - if name.hasPrefix("move-") { - entityName = "MoveEntity" - } else if name.hasPrefix("snapshot-") { - entityName = "SnapshotEntity" + if name.hasPrefix("moves-") { + entityName = "MovesEntity" } else if name.hasPrefix("player-") { entityName = "PlayerEntity" } else if name.hasPrefix("game-") { entityName = "GameEntity" } else { switch recordType { - case "Move": entityName = "MoveEntity" - case "Snapshot": entityName = "SnapshotEntity" + case "Moves": entityName = "MovesEntity" case "Player": entityName = "PlayerEntity" case "Game": entityName = "GameEntity" default: return @@ -1158,17 +1002,11 @@ actor SyncEngine { } let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - let (savedSnapshotNames, failureMessages, orphanedZones): - ([String], [String], Set<CKRecordZone.ID>) = ctx.performAndWait { - var snapshotNames: [String] = [] + let (failureMessages, orphanedZones): ([String], Set<CKRecordZone.ID>) = ctx.performAndWait { var messages: [String] = [] var orphaned = Set<CKRecordZone.ID>() for record in event.savedRecords { self.writeBackSystemFields(record: record, in: ctx) - let name = record.recordID.recordName - if name.hasPrefix("snapshot-") { - snapshotNames.append(name) - } } for failure in event.failedRecordSaves { let name = failure.record.recordID.recordName @@ -1198,14 +1036,11 @@ actor SyncEngine { ) } } - return (snapshotNames, messages, orphaned) + return (messages, orphaned) } if !orphanedZones.isEmpty { await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate) } - if !savedSnapshotNames.isEmpty, let onSnapshotsSaved { - await onSnapshotsSaved(savedSnapshotNames) - } for message in failureMessages { await trace(message) } @@ -1324,8 +1159,7 @@ actor SyncEngine { ) { let name = record.recordID.recordName let entityName: String - if name.hasPrefix("move-") { entityName = "MoveEntity" } - else if name.hasPrefix("snapshot-") { entityName = "SnapshotEntity" } + if name.hasPrefix("moves-") { entityName = "MovesEntity" } else if name.hasPrefix("player-") { entityName = "PlayerEntity" } else if name.hasPrefix("game-") { entityName = "GameEntity" } else { return } diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift @@ -10,20 +10,24 @@ func makeTestPersistence() -> PersistenceController { } /// Builds a `GameStore` wired with no-op collaborators for tests that don't -/// exercise the sync/identity/buffer paths. Override only what the test needs. +/// exercise the sync/identity/updater paths. Override only what the test needs. @MainActor func makeTestStore( persistence: PersistenceController, - moveBuffer: MoveBuffer? = nil, + movesUpdater: MovesUpdater? = nil, authorIDProvider: @escaping @MainActor () -> String? = { nil }, onGameCreated: @escaping (String) -> Void = { _ in }, onGameUpdated: @escaping (String) -> Void = { _ in }, onGameDeleted: @escaping (GameCloudDeletion) -> Void = { _ in } ) -> GameStore { - let buffer = moveBuffer ?? MoveBuffer(persistence: persistence, sink: { _ in }) + let updater = movesUpdater ?? MovesUpdater( + persistence: persistence, + writerAuthorIDProvider: { nil }, + sink: { _ in } + ) return GameStore( persistence: persistence, - moveBuffer: buffer, + movesUpdater: updater, authorIDProvider: authorIDProvider, onGameCreated: onGameCreated, onGameUpdated: onGameUpdated, @@ -33,7 +37,7 @@ func makeTestStore( /// Creates a Game, GameEntity, and GameMutator backed by an in-memory store. /// The puzzle is a minimal 3x3 grid with a single block at (1,1). -/// `moveBuffer` is nil — tests that need emission verify via MoveBuffer's own suite. +/// `movesUpdater` is nil — tests that need emission verify via MovesUpdater's own suite. @MainActor func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceController) { let persistence = makeTestPersistence() @@ -72,6 +76,6 @@ func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceControl try context.save() - let mutator = GameMutator(game: game, gameID: gameID, moveBuffer: nil) + let mutator = GameMutator(game: game, gameID: gameID, movesUpdater: nil) return (game, mutator, entity, persistence) } diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -197,96 +197,134 @@ struct GameMutatorTests { @Test("Same-letter rewrite emits a move carrying the preserved author, not the acting user") func sameLetterEmitsMoveWithPreservedAuthor() async throws { - let (game, capture, buffer, _) = try makeMutatorWithBuffer(actingAuthorID: "bob") + let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob") - // Alice already entered "A" — seed via Game directly so the buffer + // Alice already entered "A" — seed via Game directly so the updater // only sees Bob's subsequent action. game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice") let mutator = makeMutator( game: game, - buffer: buffer, + updater: updater, gameID: capture.gameID, actingAuthorID: "bob" ) mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) try await Task.sleep(for: .milliseconds(50)) - await buffer.flush() + await updater.flush() - let moves = await capture.collector.allMoves - #expect(moves.count == 1) - #expect(moves.first?.letter == "A") - #expect(moves.first?.authorID == "alice") + let affected = await capture.collector.allGameIDs + #expect(affected.contains(capture.gameID)) + let cell = try cellFromMoves( + persistence: persistence, + gameID: capture.gameID, + row: 0, + col: 0 + ) + #expect(cell?.letter == "A") + #expect(cell?.authorID == "alice") #expect(game.squares[0][0].letterAuthorID == "alice") } @Test("Reveal of an already-correct cell emits a move carrying the preserved author") func revealCorrectEmitsMoveWithPreservedAuthor() async throws { - let (game, capture, buffer, _) = try makeMutatorWithBuffer(actingAuthorID: "bob") + let (game, capture, updater, persistence) = try makeMutatorWithUpdater(actingAuthorID: "bob") // Cell (0,0)'s solution is "A". Alice has already filled it in. game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice") let mutator = makeMutator( game: game, - buffer: buffer, + updater: updater, gameID: capture.gameID, actingAuthorID: "bob" ) mutator.revealCells([game.puzzle.cells[0][0]]) try await Task.sleep(for: .milliseconds(50)) - await buffer.flush() + await updater.flush() - let moves = await capture.collector.allMoves - #expect(moves.count == 1) - #expect(moves.first?.authorID == "alice") + let affected = await capture.collector.allGameIDs + #expect(affected.contains(capture.gameID)) + let cell = try cellFromMoves( + persistence: persistence, + gameID: capture.gameID, + row: 0, + col: 0 + ) + #expect(cell?.authorID == "alice") #expect(game.squares[0][0].letterAuthorID == "alice") // The cell was already correct, so it is not locked into `.revealed`. #expect(game.squares[0][0].mark == .none) } - // MARK: - Test scaffolding for buffer-emission tests + // MARK: - Test scaffolding for emission tests - actor MoveCollector { - private(set) var allMoves: [Move] = [] - func append(_ moves: [Move]) { allMoves.append(contentsOf: moves) } + actor GameIDCollector { + private(set) var allGameIDs: Set<UUID> = [] + func append(_ ids: Set<UUID>) { allGameIDs.formUnion(ids) } } - struct BufferHarness { - let collector: MoveCollector + struct UpdaterHarness { + let collector: GameIDCollector let gameID: UUID } - /// Builds a `Game` plus a `MoveBuffer` whose sink is captured. Returns - /// the game (so callers can seed state via `Game.setLetter` directly), - /// the capture harness, the buffer, and the persistence controller. - private func makeMutatorWithBuffer( + /// Builds a `Game` plus a `MovesUpdater` whose sink is captured. Returns + /// the game, the capture harness, the updater, and the persistence + /// controller (so the test can read back the persisted MovesEntity). + private func makeMutatorWithUpdater( actingAuthorID: String - ) throws -> (Game, BufferHarness, MoveBuffer, PersistenceController) { + ) throws -> (Game, UpdaterHarness, MovesUpdater, PersistenceController) { let (game, _, entity, persistence) = try makeTestGame() - let collector = MoveCollector() - let buffer = MoveBuffer( + let collector = GameIDCollector() + let updater = MovesUpdater( debounceInterval: .seconds(10), persistence: persistence, + writerAuthorIDProvider: { actingAuthorID }, sink: { await collector.append($0) } ) let gameID = entity.id ?? UUID() - return (game, BufferHarness(collector: collector, gameID: gameID), buffer, persistence) + return (game, UpdaterHarness(collector: collector, gameID: gameID), updater, persistence) } private func makeMutator( game: Game, - buffer: MoveBuffer, + updater: MovesUpdater, gameID: UUID, actingAuthorID: String ) -> GameMutator { GameMutator( game: game, gameID: gameID, - moveBuffer: buffer, + movesUpdater: updater, authorIDProvider: { actingAuthorID } ) } + + /// Reads back the per-cell entry at `(row, col)` from the local-device + /// MovesEntity for `gameID`. Used by emission tests to verify what got + /// persisted into the cells blob. + private func cellFromMoves( + persistence: PersistenceController, + gameID: UUID, + row: Int, + col: Int + ) throws -> TimestampedCell? { + let ctx = persistence.viewContext + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate( + format: "game.id == %@ AND deviceID == %@", + gameID as CVarArg, + RecordSerializer.localDeviceID + ) + req.fetchLimit = 1 + ctx.refreshAllObjects() + guard let entity = try ctx.fetch(req).first, + let data = entity.cells + else { return nil } + let cells = try MovesCodec.decode(data) + return cells[GridPosition(row: row, col: col)] + } } diff --git a/Tests/Unit/GamePlayerColorStoreTests.swift b/Tests/Unit/GamePlayerColorStoreTests.swift @@ -170,7 +170,7 @@ struct GamePlayerColorStoreTests { // Use the production factory so this test fails if the real wiring // ever drops the colour-cleanup branch. - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") let syncEngine = SyncEngine(container: container, persistence: persistence) let store = makeTestStore( persistence: persistence, diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -4,98 +4,17 @@ import Testing @testable import Crossmate -@Suite("GameStore unseen moves", .serialized) +/// Pins down the Date-based unread-badge heuristic on `GameStore`. A shared +/// game gains an unread badge when another author's `MovesEntity` row has a +/// later `updatedAt` than the local user's last open. +@Suite("GameStore unread badge", .serialized) @MainActor struct GameStoreUnseenMovesTests { - @Test("Other-author moves mark a shared game unread") - func otherAuthorMoveMarksSharedGameUnread() throws { - let (store, gameID) = try makeStoreWithSharedGame() - - store.noteIncomingOtherMoves([ - Move( - gameID: gameID, - lamport: 3, - row: 0, - col: 0, - letter: "A", - markKind: 0, - checkedWrong: false, - authorID: "other", - createdAt: Date() - ) - ], currentAuthorID: "me") - - let entity = try #require(try fetchGame(store: store, gameID: gameID)) - let summary = try #require(GameSummary(entity: entity)) - #expect(entity.latestOtherMoveLamport == 3) - #expect(entity.lastSeenOtherMoveLamport == 0) - #expect(summary.hasUnseenOtherMoves) - } - - @Test("Own moves do not mark a shared game unread") - func ownMoveDoesNotMarkSharedGameUnread() throws { - let (store, gameID) = try makeStoreWithSharedGame() - - store.noteIncomingOtherMoves([ - Move( - gameID: gameID, - lamport: 4, - row: 0, - col: 0, - letter: "A", - markKind: 0, - checkedWrong: false, - authorID: "me", - createdAt: Date() - ) - ], currentAuthorID: "me") - - let entity = try #require(try fetchGame(store: store, gameID: gameID)) - let summary = try #require(GameSummary(entity: entity)) - #expect(entity.latestOtherMoveLamport == 0) - #expect(!summary.hasUnseenOtherMoves) - } - - @Test("Opening a game marks other-author moves seen") - func openingGameMarksOtherMovesSeen() throws { - let (store, gameID) = try makeStoreWithSharedGame() - let entity = try #require(try fetchGame(store: store, gameID: gameID)) - entity.latestOtherMoveLamport = 5 - try store.persistence.viewContext.save() - - _ = try store.loadGame(id: gameID) - #expect(entity.lastSeenOtherMoveLamport == 5) - let summary = try #require(GameSummary(entity: entity)) - #expect(!summary.hasUnseenOtherMoves) - } + private static let localAuthorID = "local-author" + private static let otherAuthorID = "other-author" - private func makeStoreWithSharedGame() throws -> (GameStore, UUID) { - let persistence = makeTestPersistence() - let store = makeTestStore(persistence: persistence) - let context = persistence.viewContext - let gameID = UUID() - let entity = GameEntity(context: context) - entity.id = gameID - entity.title = "Shared Test" - entity.puzzleSource = Self.source - entity.createdAt = Date() - entity.updatedAt = Date() - entity.ckRecordName = "game-\(gameID.uuidString)" - entity.ckShareRecordName = "share-\(gameID.uuidString)" - entity.databaseScope = 0 - try context.save() - return (store, gameID) - } - - private func fetchGame(store: GameStore, gameID: UUID) throws -> GameEntity? { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - request.fetchLimit = 1 - return try store.persistence.viewContext.fetch(request).first - } - - private static let source = """ + private static let sharedPuzzleSource = """ Title: Test Puzzle Author: Test @@ -112,4 +31,114 @@ struct GameStoreUnseenMovesTests { D2. Down 2 ~ BG D3. Down 3 ~ CEH """ + + private func makeSharedGame( + in ctx: NSManagedObjectContext + ) throws -> (GameEntity, UUID) { + let gameID = UUID() + let xd = try XD.parse(Self.sharedPuzzleSource) + let puzzle = Puzzle(xd: xd) + + let entity = GameEntity(context: ctx) + entity.id = gameID + entity.title = "Shared" + entity.puzzleSource = Self.sharedPuzzleSource + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + entity.ckZoneName = "game-\(gameID.uuidString)" + entity.ckZoneOwnerName = "_someOtherUser" + entity.databaseScope = 1 + // Pre-populate the cached summary fields so `GameSummary.init?` takes + // the fast path and doesn't have to re-parse XD. + entity.populateCachedSummaryFields(from: puzzle) + try ctx.save() + return (entity, gameID) + } + + private func addMovesRow( + for entity: GameEntity, + gameID: UUID, + authorID: String, + updatedAt: Date, + in ctx: NSManagedObjectContext + ) throws { + let row = MovesEntity(context: ctx) + row.game = entity + row.authorID = authorID + row.deviceID = "test-\(authorID)" + row.cells = Data() + row.updatedAt = updatedAt + row.ckRecordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: authorID, + deviceID: "test-\(authorID)" + ) + try ctx.save() + } + + @Test("Other-author Moves update marks the shared game unread") + func otherAuthorMoveMarksSharedGameUnread() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) + let updatedAt = Date(timeIntervalSinceNow: -10) + try addMovesRow( + for: entity, + gameID: gameID, + authorID: Self.otherAuthorID, + updatedAt: updatedAt, + in: persistence.viewContext + ) + + store.noteIncomingMovesUpdate( + gameIDs: [gameID], + currentAuthorID: Self.localAuthorID + ) + + let summary = try #require(GameSummary(entity: entity)) + #expect(entity.latestOtherMoveAt == updatedAt) + #expect(entity.lastSeenOtherMoveAt == nil) + #expect(summary.hasUnseenOtherMoves) + } + + @Test("Own Moves update does not mark the shared game unread") + func ownMoveDoesNotMarkSharedGameUnread() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let (entity, gameID) = try makeSharedGame(in: persistence.viewContext) + try addMovesRow( + for: entity, + gameID: gameID, + authorID: Self.localAuthorID, + updatedAt: Date(), + in: persistence.viewContext + ) + + store.noteIncomingMovesUpdate( + gameIDs: [gameID], + currentAuthorID: Self.localAuthorID + ) + + let summary = try #require(GameSummary(entity: entity)) + #expect(entity.latestOtherMoveAt == nil) + #expect(!summary.hasUnseenOtherMoves) + } + + @Test("Opening a game advances lastSeenOtherMoveAt to latestOtherMoveAt") + func openingGameMarksOtherMovesSeen() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let (entity, _) = try makeSharedGame(in: ctx) + let latest = Date(timeIntervalSinceNow: -10) + entity.latestOtherMoveAt = latest + try ctx.save() + + _ = try store.loadGame(id: entity.id!) + + #expect(entity.lastSeenOtherMoveAt == latest) + let summary = try #require(GameSummary(entity: entity)) + #expect(!summary.hasUnseenOtherMoves) + } } diff --git a/Tests/Unit/GridStateMergerTests.swift b/Tests/Unit/GridStateMergerTests.swift @@ -0,0 +1,229 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("GridStateMerger.merge") +struct GridStateMergerTests { + + private let gameID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + + private func view( + author: String, + device: String, + cells: [(row: Int, col: Int, letter: String, updatedAt: Date)], + cellAuthor: String? = nil + ) -> MovesValue { + let resolvedCellAuthor = cellAuthor ?? author + var dict: [GridPosition: TimestampedCell] = [:] + for entry in cells { + dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( + letter: entry.letter, + markKind: 0, + checkedWrong: false, + updatedAt: entry.updatedAt, + authorID: resolvedCellAuthor + ) + } + return MovesValue( + gameID: gameID, + authorID: author, + deviceID: device, + cells: dict, + updatedAt: cells.map(\.updatedAt).max() ?? .distantPast + ) + } + + @Test("Empty input produces an empty grid") + func emptyInputs() { + #expect(GridStateMerger.merge([]).isEmpty) + } + + @Test("Single MovesValue is reproduced cell-for-cell") + func singleViewPassesThrough() { + let v = view( + author: "alice", + device: "d1", + cells: [ + (0, 0, "A", Date(timeIntervalSince1970: 1)), + (1, 2, "B", Date(timeIntervalSince1970: 2)), + ] + ) + let grid = GridStateMerger.merge([v]) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") + #expect(grid[GridPosition(row: 1, col: 2)]?.letter == "B") + } + + @Test("Later updatedAt wins for the same cell across devices") + func laterTimestampWins() { + let earlier = view( + author: "alice", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] + ) + let later = view( + author: "bob", + device: "d2", + cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] + ) + let grid = GridStateMerger.merge([earlier, later]) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B") + #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "bob") + } + + @Test("Input order does not affect the merged result") + func inputOrderIndependent() { + let earlier = view( + author: "alice", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] + ) + let later = view( + author: "bob", + device: "d2", + cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] + ) + let forward = GridStateMerger.merge([earlier, later]) + let reversed = GridStateMerger.merge([later, earlier]) + #expect(forward == reversed) + } + + @Test("Equal updatedAt: lexicographically smaller authorID wins") + func authorTieBreak() { + let same = Date(timeIntervalSince1970: 5) + let bob = view( + author: "bob", + device: "d1", + cells: [(0, 0, "B", same)] + ) + let alice = view( + author: "alice", + device: "d2", + cells: [(0, 0, "A", same)] + ) + let grid = GridStateMerger.merge([bob, alice]) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") + } + + @Test("Equal updatedAt and author: smaller deviceID wins") + func deviceTieBreak() { + let same = Date(timeIntervalSince1970: 5) + let onPhone = view( + author: "alice", + device: "phone", + cells: [(0, 0, "P", same)] + ) + let onIpad = view( + author: "alice", + device: "ipad", + cells: [(0, 0, "I", same)] + ) + let grid = GridStateMerger.merge([onPhone, onIpad]) + #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "I") + } + + @Test("Cells touched by only one device are all present in the merged grid") + func disjointCellsCoexist() { + let alice = view( + author: "alice", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] + ) + let bob = view( + author: "bob", + device: "d2", + cells: [(1, 1, "B", Date(timeIntervalSince1970: 1))] + ) + let grid = GridStateMerger.merge([alice, bob]) + #expect(grid.count == 2) + #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") + #expect(grid[GridPosition(row: 1, col: 1)]?.authorID == "bob") + } + + @Test("Cell-level authorID is preserved when the winning entry's parent writer differs") + func cellLevelAuthorWins() { + // Bob's device (writer) writes a cell whose preserved authorID is alice + // — mirrors the "reveal-of-correct" / "same-letter rewrite" behaviors + // where bob's mutation hands authorship back to alice. + let preserved = view( + author: "bob", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 2))], + cellAuthor: "alice" + ) + let grid = GridStateMerger.merge([preserved]) + #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "alice") + } + + @Test("Cleared letter (empty string) still wins if its updatedAt is latest") + func clearingMoveWins() { + let written = view( + author: "alice", + device: "d1", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] + ) + let cleared = view( + author: "alice", + device: "d2", + cells: [(0, 0, "", Date(timeIntervalSince1970: 2))] + ) + let grid = GridStateMerger.merge([written, cleared]) + let cell = grid[GridPosition(row: 0, col: 0)] + #expect(cell?.letter == "") + #expect(cell != nil) + } +} + +@Suite("MovesCodec round-trip") +struct MovesCodecTests { + + @Test("Round-trip preserves all cell fields including per-cell authorID") + func roundTrip() throws { + let cells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", + markKind: 2, + checkedWrong: true, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + authorID: "alice" + ), + GridPosition(row: 4, col: 7): TimestampedCell( + letter: "", + markKind: 0, + checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1_700_000_500), + authorID: nil + ), + ] + let data = try MovesCodec.encode(cells) + let decoded = try MovesCodec.decode(data) + #expect(decoded == cells) + } + + @Test("Encoded entries are sorted in row-major, col-minor order") + func encodingIsDeterministic() throws { + let cells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 2, col: 1): TimestampedCell( + letter: "X", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1), + authorID: "alice" + ), + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 2), + authorID: "bob" + ), + ] + let payload = try JSONDecoder().decode( + MovesCodec.Payload.self, + from: MovesCodec.encode(cells) + ) + #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [ + GridPosition(row: 0, col: 0), + GridPosition(row: 2, col: 1), + ]) + #expect(payload.entries.map(\.authorID) == ["bob", "alice"]) + } +} diff --git a/Tests/Unit/MoveBufferTests.swift b/Tests/Unit/MoveBufferTests.swift @@ -1,350 +0,0 @@ -import CoreData -import Foundation -import Testing - -@testable import Crossmate - -@Suite("MoveBuffer", .serialized) -@MainActor -struct MoveBufferTests { - - /// Thread-safe collector for moves emitted by the buffer under test. - actor Capture { - private(set) var flushes: [[Move]] = [] - var allMoves: [Move] { flushes.flatMap { $0 } } - var flushCount: Int { flushes.count } - func append(_ moves: [Move]) { flushes.append(moves) } - } - - /// Builds a `PersistenceController` backed by an in-memory store and - /// seeds a single `GameEntity`, returning its id so tests can target it. - private func makePersistenceWithGame( - lamportHighWater: Int64 = 0 - ) throws -> (PersistenceController, UUID) { - let persistence = makeTestPersistence() - let context = persistence.viewContext - let gameID = UUID() - let entity = GameEntity(context: context) - entity.id = gameID - entity.title = "Test" - entity.puzzleSource = "" - entity.createdAt = Date() - entity.updatedAt = Date() - entity.ckRecordName = "game-\(gameID.uuidString)" - entity.lamportHighWater = lamportHighWater - try context.save() - return (persistence, gameID) - } - - @Test("Same-cell enqueues coalesce to one move carrying the latest value") - func coalescesSameCell() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let buffer = MoveBuffer( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { await capture.append($0) } - ) - - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.flush() - - let moves = await capture.allMoves - #expect(moves.count == 1) - #expect(moves.first?.letter == "C") - #expect(moves.first?.lamport == 1) - } - - @Test("Enqueuing a different cell persists and enqueues the previous cell") - func cellChangeFlushesPrevious() async throws { - // A long debounce makes this test insensitive to timer jitter: - // only the cell-change trigger (and the final explicit flush) can - // fire. - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let buffer = MoveBuffer( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { await capture.append($0) } - ) - - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: nil) - - // The cell-change flush allocates Lamport 1, writes the prior cell - // durably, and immediately hands it to the sink. - #expect(await capture.flushCount == 1) - let persistedBeforeFinalFlush = fetchMoveValues(gameID: gameID, persistence: persistence) - #expect(persistedBeforeFinalFlush.count == 1) - #expect(persistedBeforeFinalFlush.first?.letter == "A") - #expect(persistedBeforeFinalFlush.first?.lamport == 1) - - await buffer.flush() - - let flushes = await capture.flushes - #expect(flushes.count == 2) - #expect(flushes.map { $0.map(\.letter) } == [["A"], ["B"]]) - #expect(flushes.map { $0.map(\.lamport) } == [[1], [2]]) - } - - @Test("Lamports are allocated from GameEntity.lamportHighWater and bump it") - func lamportsUseGameHighWater() async throws { - let (persistence, gameID) = try makePersistenceWithGame(lamportHighWater: 10) - let capture = Capture() - let buffer = MoveBuffer( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { await capture.append($0) } - ) - - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.flush() - - let moves = await capture.allMoves - #expect(moves.first?.lamport == 11) - - // Verify the bump landed in Core Data by reading the game back from - // a fresh context — the writer used a background context, so we - // need to read from the same underlying store. - let highWater = fetchHighWater(gameID: gameID, persistence: persistence) - #expect(highWater == 11) - } - - @Test("Flushed moves bump the parent game's updatedAt timestamp") - func flushedMovesUpdateGameTimestamp() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let before = try #require(fetchUpdatedAt(gameID: gameID, persistence: persistence)) - let buffer = MoveBuffer( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { _ in } - ) - - try await Task.sleep(for: .milliseconds(10)) - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "X", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.flush() - - let after = try #require(fetchUpdatedAt(gameID: gameID, persistence: persistence)) - #expect(after > before) - } - - @Test("Debounce coalesces rapid same-cell enqueues into one flush") - func debounceCoalescesRapidEnqueues() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let buffer = MoveBuffer( - debounceInterval: .milliseconds(80), - persistence: persistence, - sink: { await capture.append($0) } - ) - - for letter in ["A", "B", "C", "D", "E"] { - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: letter, markKind: 0, checkedWrong: false, authorID: nil) - try await Task.sleep(for: .milliseconds(20)) - } - try await Task.sleep(for: .milliseconds(250)) - - let count = await capture.flushCount - #expect(count == 1) - let moves = await capture.allMoves - #expect(moves.first?.letter == "E") - } - - @Test("Explicit flush fires immediately and cancels any pending debounce") - func flushCancelsDebounce() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let capture = Capture() - let buffer = MoveBuffer( - debounceInterval: .seconds(5), - persistence: persistence, - sink: { await capture.append($0) } - ) - - await buffer.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) - await buffer.flush() - let afterFlush = await capture.flushCount - #expect(afterFlush == 1) - - // The debounce should have been cancelled by flush, so waiting past - // the original interval must not add a second call. - try await Task.sleep(for: .milliseconds(200)) - let later = await capture.flushCount - #expect(later == 1) - } - - @Test("Flush on an empty buffer does not invoke the sink") - func emptyFlushDoesNothing() async throws { - let (persistence, _) = try makePersistenceWithGame() - let capture = Capture() - let buffer = MoveBuffer( - debounceInterval: .milliseconds(50), - persistence: persistence, - sink: { await capture.append($0) } - ) - - await buffer.flush() - - let count = await capture.flushCount - #expect(count == 0) - } - - @Test("MoveEntity rows are written with the enqueued fields") - func persistsMoveEntity() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let buffer = MoveBuffer( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { _ in } - ) - - await buffer.enqueue( - gameID: gameID, row: 2, col: 3, - letter: "Q", markKind: 1, checkedWrong: true, authorID: "alice" - ) - await buffer.flush() - - let moves = fetchMoveValues(gameID: gameID, persistence: persistence) - #expect(moves.count == 1) - let m = moves.first - #expect(m?.letter == "Q") - #expect(m?.row == 2) - #expect(m?.col == 3) - #expect(m?.markKind == 1) - #expect(m?.checkedWrong == true) - #expect(m?.authorID == "alice") - #expect(m?.lamport == 1) - #expect(m?.ckRecordName == "move-\(gameID.uuidString)-1-\(RecordSerializer.localDeviceID)") - } - - @Test("Flush updates cell cache with the latest local cell values") - func flushUpdatesCellCache() async throws { - let (persistence, gameID) = try makePersistenceWithGame() - let buffer = MoveBuffer( - debounceInterval: .seconds(10), - persistence: persistence, - sink: { _ in } - ) - - await buffer.enqueue( - gameID: gameID, - row: 2, - col: 3, - letter: "Q", - markKind: 1, - checkedWrong: true, - authorID: "alice" - ) - await buffer.enqueue( - gameID: gameID, - row: 2, - col: 3, - letter: "R", - markKind: 2, - checkedWrong: false, - authorID: "bob" - ) - await buffer.flush() - - let cells = fetchCellValues(gameID: gameID, persistence: persistence) - #expect(cells.count == 1) - let cell = cells.first - #expect(cell?.row == 2) - #expect(cell?.col == 3) - #expect(cell?.letter == "R") - #expect(cell?.markKind == 2) - #expect(cell?.checkedWrong == false) - #expect(cell?.authorID == "bob") - } - - // MARK: - Helpers - - /// Reads the game's lamport high-water from a fresh background context. - private func fetchHighWater(gameID: UUID, persistence: PersistenceController) -> Int64 { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - request.fetchLimit = 1 - return (try? context.fetch(request).first?.lamportHighWater) ?? 0 - } - } - - private func fetchUpdatedAt(gameID: UUID, persistence: PersistenceController) -> Date? { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - request.fetchLimit = 1 - return try? context.fetch(request).first?.updatedAt - } - } - - /// Extracts `MoveEntity` field values inside the background context so - /// no `NSManagedObject` escapes its owning context. - struct MoveValues { - let letter: String - let row: Int16 - let col: Int16 - let markKind: Int16 - let checkedWrong: Bool - let authorID: String? - let lamport: Int64 - let ckRecordName: String? - } - - struct CellValues { - let letter: String - let row: Int16 - let col: Int16 - let markKind: Int16 - let checkedWrong: Bool - let authorID: String? - } - - private func fetchMoveValues(gameID: UUID, persistence: PersistenceController) -> [MoveValues] { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) - request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] - guard let entities = try? context.fetch(request) else { return [] } - return entities.map { - MoveValues( - letter: $0.letter ?? "", - row: $0.row, - col: $0.col, - markKind: $0.markKind, - checkedWrong: $0.checkedWrong, - authorID: $0.authorID, - lamport: $0.lamport, - ckRecordName: $0.ckRecordName - ) - } - } - } - - private func fetchCellValues(gameID: UUID, persistence: PersistenceController) -> [CellValues] { - let context = persistence.container.newBackgroundContext() - return context.performAndWait { - let request = NSFetchRequest<CellEntity>(entityName: "CellEntity") - request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) - request.sortDescriptors = [ - NSSortDescriptor(key: "row", ascending: true), - NSSortDescriptor(key: "col", ascending: true) - ] - guard let entities = try? context.fetch(request) else { return [] } - return entities.map { - CellValues( - letter: $0.letter ?? "", - row: $0.row, - col: $0.col, - markKind: $0.markKind, - checkedWrong: $0.checkedWrong, - authorID: $0.letterAuthorID - ) - } - } - } -} diff --git a/Tests/Unit/MoveLogTests.swift b/Tests/Unit/MoveLogTests.swift @@ -1,262 +0,0 @@ -import CloudKit -import Foundation -import Testing - -@testable import Crossmate - -@Suite("MoveLog.replay") -struct MoveLogReplayTests { - - private let gameID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! - - private func move( - lamport: Int64, - row: Int, - col: Int, - letter: String, - author: String? = nil - ) -> Move { - Move( - gameID: gameID, - lamport: lamport, - row: row, - col: col, - letter: letter, - markKind: 0, - checkedWrong: false, - authorID: author, - createdAt: Date(timeIntervalSince1970: TimeInterval(lamport)) - ) - } - - @Test("Empty inputs produce an empty grid") - func emptyInputs() { - let grid = MoveLog.replay(snapshot: nil, moves: []) - #expect(grid.isEmpty) - } - - @Test("Moves without a snapshot are applied in lamport order") - func movesOnly() { - let grid = MoveLog.replay( - snapshot: nil, - moves: [ - move(lamport: 1, row: 0, col: 0, letter: "A"), - move(lamport: 2, row: 0, col: 1, letter: "B"), - ] - ) - #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") - #expect(grid[GridPosition(row: 0, col: 1)]?.letter == "B") - } - - @Test("Later lamport for the same cell overwrites the earlier one") - func laterMoveWins() { - let grid = MoveLog.replay( - snapshot: nil, - moves: [ - move(lamport: 1, row: 0, col: 0, letter: "A"), - move(lamport: 2, row: 0, col: 0, letter: "B"), - ] - ) - #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "B") - } - - @Test("Moves are reordered by lamport even when input is shuffled") - func inputOrderIndependent() { - let grid = MoveLog.replay( - snapshot: nil, - moves: [ - move(lamport: 3, row: 0, col: 0, letter: "C"), - move(lamport: 1, row: 0, col: 0, letter: "A"), - move(lamport: 2, row: 0, col: 0, letter: "B"), - ] - ) - #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "C") - } - - @Test("Empty-letter move clears the cell's letter but retains the slot") - func clearingMovePreservesSlot() { - let grid = MoveLog.replay( - snapshot: nil, - moves: [ - move(lamport: 1, row: 0, col: 0, letter: "A", author: "alice"), - move(lamport: 2, row: 0, col: 0, letter: "", author: "alice"), - ] - ) - let cell = grid[GridPosition(row: 0, col: 0)] - #expect(cell?.letter == "") - #expect(cell?.authorID == "alice") - } - - @Test("Snapshot seeds the grid and moves past its cutoff are applied") - func snapshotBaseAndTail() { - let snapshot = Snapshot( - gameID: gameID, - upToLamport: 5, - grid: [ - GridPosition(row: 0, col: 0): GridCell( - letter: "A", - markKind: 0, - checkedWrong: false, - authorID: nil - ) - ], - createdAt: Date() - ) - let grid = MoveLog.replay( - snapshot: snapshot, - moves: [ - move(lamport: 6, row: 1, col: 0, letter: "B"), - ] - ) - #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "A") - #expect(grid[GridPosition(row: 1, col: 0)]?.letter == "B") - } - - @Test("Moves at or below the snapshot cutoff are skipped") - func snapshotCutoffSkipsOlderMoves() { - let snapshot = Snapshot( - gameID: gameID, - upToLamport: 5, - grid: [ - GridPosition(row: 0, col: 0): GridCell( - letter: "Z", - markKind: 0, - checkedWrong: false, - authorID: nil - ) - ], - createdAt: Date() - ) - let grid = MoveLog.replay( - snapshot: snapshot, - moves: [ - // Lamport 3 is already folded into the snapshot; it must - // not re-apply on top and revert the cell. - move(lamport: 3, row: 0, col: 0, letter: "A"), - ] - ) - #expect(grid[GridPosition(row: 0, col: 0)]?.letter == "Z") - } - - @Test("Latest snapshot picks the highest upToLamport") - func latestSnapshotSelection() { - let a = Snapshot(gameID: gameID, upToLamport: 10, grid: [:], createdAt: Date()) - let b = Snapshot(gameID: gameID, upToLamport: 50, grid: [:], createdAt: Date()) - let c = Snapshot(gameID: gameID, upToLamport: 25, grid: [:], createdAt: Date()) - #expect(MoveLog.latestSnapshot(from: [a, b, c]) == b) - } -} - -@Suite("MoveLog grid state codec") -struct GridStateCodecTests { - - @Test("Round-trip preserves all cell fields") - func roundTripPreservesFields() throws { - let grid: GridState = [ - GridPosition(row: 0, col: 0): GridCell( - letter: "A", markKind: 2, checkedWrong: true, authorID: "alice" - ), - GridPosition(row: 4, col: 7): GridCell( - letter: "", markKind: 0, checkedWrong: false, authorID: nil - ), - ] - let data = try MoveLog.encodeGridState(grid) - let decoded = try MoveLog.decodeGridState(data) - #expect(decoded == grid) - } - - @Test("Encoding sorts entries in row-major, col-minor order regardless of dictionary iteration order") - func encodingSortsEntries() throws { - // Dictionary iteration order is unspecified; encoding must sort. - let grid: GridState = [ - GridPosition(row: 2, col: 1): GridCell( - letter: "X", markKind: 0, checkedWrong: false, authorID: nil - ), - GridPosition(row: 0, col: 0): GridCell( - letter: "A", markKind: 0, checkedWrong: false, authorID: nil - ), - ] - let payload = try JSONDecoder().decode( - MoveLog.GridStatePayload.self, - from: MoveLog.encodeGridState(grid) - ) - #expect(payload.entries.map { GridPosition(row: $0.row, col: $0.col) } == [ - GridPosition(row: 0, col: 0), - GridPosition(row: 2, col: 1), - ]) - } -} - -@Suite("RecordSerializer Move/Snapshot") -struct RecordSerializerMoveSnapshotTests { - - private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! - private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) } - - @Test("Move record name uses the expected format") - func moveRecordNameFormat() { - let name = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 42) - #expect(name == "move-\(gameID.uuidString)-42-\(RecordSerializer.localDeviceID)") - } - - @Test("Snapshot record name uses the expected format") - func snapshotRecordNameFormat() { - let name = RecordSerializer.recordName(forSnapshotInGame: gameID, upToLamport: 100) - #expect(name == "snapshot-\(gameID.uuidString)-100-\(RecordSerializer.localDeviceID)") - } - - @Test("Move record round-trips through CKRecord fields") - func moveRecordRoundTrip() { - let move = Move( - gameID: gameID, - lamport: 17, - row: 3, - col: 5, - letter: "Q", - markKind: 1, - checkedWrong: true, - authorID: "alice", - createdAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - let record = RecordSerializer.moveRecord( - from: move, - zone: zoneID, - systemFields: nil - ) - let parsed = RecordSerializer.parseMoveRecord(record) - #expect(parsed == move) - } - - @Test("Snapshot record round-trips through CKRecord fields") - func snapshotRecordRoundTrip() throws { - let grid: GridState = [ - GridPosition(row: 0, col: 0): GridCell( - letter: "A", markKind: 0, checkedWrong: false, authorID: "alice" - ), - GridPosition(row: 1, col: 2): GridCell( - letter: "B", markKind: 2, checkedWrong: true, authorID: nil - ), - ] - let snapshot = Snapshot( - gameID: gameID, - upToLamport: 42, - grid: grid, - createdAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - let record = try RecordSerializer.snapshotRecord( - from: snapshot, - zone: zoneID, - systemFields: nil - ) - let parsed = RecordSerializer.parseSnapshotRecord(record) - #expect(parsed == snapshot) - } - -@Test("Parsing rejects records with the wrong record type") - func parseRejectsWrongRecordType() { - let zoneID = RecordSerializer.zoneID(for: gameID) - let recordID = CKRecord.ID(recordName: RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), zoneID: zoneID) - let record = CKRecord(recordType: "Cell", recordID: recordID) - #expect(RecordSerializer.parseMoveRecord(record) == nil) - } -} diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -0,0 +1,255 @@ +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +@Suite("MovesUpdater", .serialized) +@MainActor +struct MovesUpdaterTests { + + /// Thread-safe collector for sink fan-outs (Set<UUID> per flush). + actor Capture { + private(set) var flushes: [Set<UUID>] = [] + var allGameIDs: Set<UUID> { flushes.reduce(into: Set()) { $0.formUnion($1) } } + var flushCount: Int { flushes.count } + func append(_ ids: Set<UUID>) { flushes.append(ids) } + } + + private static let writerAuthorID = "alice" + + private func makePersistenceWithGame() throws -> (PersistenceController, UUID) { + let persistence = makeTestPersistence() + let context = persistence.viewContext + let gameID = UUID() + let entity = GameEntity(context: context) + entity.id = gameID + entity.title = "Test" + entity.puzzleSource = "" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = "game-\(gameID.uuidString)" + try context.save() + return (persistence, gameID) + } + + private func makeUpdater( + persistence: PersistenceController, + capture: Capture, + debounce: Duration = .seconds(10), + writerAuthorID: String? = MovesUpdaterTests.writerAuthorID + ) -> MovesUpdater { + MovesUpdater( + debounceInterval: debounce, + persistence: persistence, + writerAuthorIDProvider: { writerAuthorID }, + sink: { await capture.append($0) } + ) + } + + private func movesEntity( + gameID: UUID, + persistence: PersistenceController + ) -> MovesEntity? { + let ctx = persistence.viewContext + ctx.refreshAllObjects() + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate( + format: "game.id == %@ AND deviceID == %@", + gameID as CVarArg, + RecordSerializer.localDeviceID + ) + req.fetchLimit = 1 + return try? ctx.fetch(req).first + } + + private func decodedCells( + gameID: UUID, + persistence: PersistenceController + ) throws -> [GridPosition: TimestampedCell] { + guard let entity = movesEntity(gameID: gameID, persistence: persistence), + let data = entity.cells + else { return [:] } + return try MovesCodec.decode(data) + } + + @Test("Same-cell enqueues coalesce; latest value lands in MovesEntity") + func coalescesSameCell() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater(persistence: persistence, capture: capture) + + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "C", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.flush() + + let cells = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cells.count == 1) + #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "C") + } + + @Test("Enqueuing a different cell flushes the previous cell first") + func cellChangeFlushesPrevious() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater(persistence: persistence, capture: capture) + + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + + // Cell-change triggered the first flush. + #expect(await capture.flushCount == 1) + let cellsAfterFirst = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cellsAfterFirst[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(cellsAfterFirst[GridPosition(row: 0, col: 1)] == nil) + + await updater.flush() + + let cellsAfterFinal = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cellsAfterFinal.count == 2) + #expect(cellsAfterFinal[GridPosition(row: 0, col: 1)]?.letter == "B") + #expect(await capture.flushCount == 2) + } + + @Test("Debounce coalesces rapid same-cell enqueues into one flush") + func debounceCoalescesRapidEnqueues() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater( + persistence: persistence, + capture: capture, + debounce: .milliseconds(50) + ) + + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + try await Task.sleep(for: .milliseconds(200)) + + #expect(await capture.flushCount == 1) + let cells = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "B") + } + + @Test("Flush on an empty buffer does nothing") + func emptyFlushDoesNothing() async throws { + let (persistence, _) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater(persistence: persistence, capture: capture) + + await updater.flush() + + #expect(await capture.flushCount == 0) + } + + @Test("Cell-level authorID is persisted into the cells blob") + func persistsCellAuthor() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + // Writer is bob; preserved cell-author is alice (e.g. reveal-of-correct). + let updater = makeUpdater( + persistence: persistence, + capture: capture, + writerAuthorID: "bob" + ) + + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.flush() + + let cells = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cells[GridPosition(row: 0, col: 0)]?.authorID == "alice") + // Parent record's authorID is the writer (bob), distinct from cell author. + let entity = try #require(movesEntity(gameID: gameID, persistence: persistence)) + #expect(entity.authorID == "bob") + } + + @Test("Two games produce two distinct MovesEntity rows") + func multiGameMultiRow() async throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let g1 = UUID(), g2 = UUID() + for id in [g1, g2] { + let e = GameEntity(context: ctx) + e.id = id + e.title = "T" + e.puzzleSource = "" + e.createdAt = Date() + e.updatedAt = Date() + e.ckRecordName = "game-\(id.uuidString)" + } + try ctx.save() + + let capture = Capture() + let updater = makeUpdater(persistence: persistence, capture: capture) + + await updater.enqueue(gameID: g1, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.enqueue(gameID: g2, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.flush() + + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + req.predicate = NSPredicate(format: "deviceID == %@", RecordSerializer.localDeviceID) + let rows = try ctx.fetch(req) + #expect(rows.count == 2) + #expect(Set(rows.compactMap { $0.game?.id }) == [g1, g2]) + #expect(await capture.allGameIDs == [g1, g2]) + } + + @Test("Subsequent flush merges into the existing MovesEntity row") + func subsequentFlushMerges() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater(persistence: persistence, capture: capture) + + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.flush() + let firstObjectID = try #require(movesEntity(gameID: gameID, persistence: persistence)).objectID + + await updater.enqueue(gameID: gameID, row: 1, col: 1, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") + await updater.flush() + + let entity = try #require(movesEntity(gameID: gameID, persistence: persistence)) + #expect(entity.objectID == firstObjectID) + let cells = try decodedCells(gameID: gameID, persistence: persistence) + #expect(cells.count == 2) + #expect(cells[GridPosition(row: 0, col: 0)]?.letter == "A") + #expect(cells[GridPosition(row: 1, col: 1)]?.letter == "B") + } + + @Test("Flush updates the local CellEntity cache for immediate UI feedback") + func flushUpdatesCellCache() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater(persistence: persistence, capture: capture) + + await updater.enqueue(gameID: gameID, row: 2, col: 3, letter: "Q", markKind: 1, checkedWrong: true, authorID: "alice") + await updater.flush() + + let ctx = persistence.viewContext + ctx.refreshAllObjects() + let req = NSFetchRequest<CellEntity>(entityName: "CellEntity") + req.predicate = NSPredicate(format: "game.id == %@ AND row == 2 AND col == 3", gameID as CVarArg) + let cells = try ctx.fetch(req) + #expect(cells.count == 1) + #expect(cells.first?.letter == "Q") + #expect(cells.first?.markKind == 1) + #expect(cells.first?.checkedWrong == true) + #expect(cells.first?.letterAuthorID == "alice") + } + + @Test("Flush is dropped silently when the writer authorID is nil") + func dropsFlushWithoutWriter() async throws { + let (persistence, gameID) = try makePersistenceWithGame() + let capture = Capture() + let updater = makeUpdater( + persistence: persistence, + capture: capture, + writerAuthorID: nil + ) + + await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: nil) + await updater.flush() + + #expect(await capture.flushCount == 0) + #expect(movesEntity(gameID: gameID, persistence: persistence) == nil) + } +} diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -37,19 +37,20 @@ struct PlayerRosterTests { req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) guard let game = try? ctx.fetch(req).first else { return } for (i, authorID) in authorIDs.enumerated() { - let move = MoveEntity(context: ctx) - move.game = game - move.authorID = authorID - move.lamport = Int64(i + 1) - move.row = 0 - move.col = Int16(i) - move.letter = "" - move.markKind = 0 - move.checkedWrong = false - move.createdAt = Date() - move.ckRecordName = RecordSerializer.recordName( - forMoveInGame: gameID, - lamport: Int64(i + 1) + // Each MovesEntity row is per-(game, author, device); for the + // roster's purposes the per-cell content is irrelevant — only the + // `authorID` matters. Synthesise a unique deviceID per row. + let deviceID = "test-device-\(i)" + let entity = MovesEntity(context: ctx) + entity.game = game + entity.authorID = authorID + entity.deviceID = deviceID + entity.cells = Data() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: authorID, + deviceID: deviceID ) } try? ctx.save() @@ -86,7 +87,7 @@ struct PlayerRosterTests { let prefs = preferences ?? PlayerPreferences( local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! ) - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") return PlayerRoster( gameID: gameID, colorStore: store, diff --git a/Tests/Unit/RecordSerializerMovesTests.swift b/Tests/Unit/RecordSerializerMovesTests.swift @@ -0,0 +1,79 @@ +import CloudKit +import Foundation +import Testing + +@testable import Crossmate + +@Suite("RecordSerializer Moves") +struct RecordSerializerMovesTests { + + private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! + private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) } + + @Test("Moves record name uses the expected format") + func movesRecordNameFormat() { + let name = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: "alice", + deviceID: "deadbeef" + ) + #expect(name == "moves-\(gameID.uuidString)-alice-deadbeef") + } + + @Test("Moves record name parses back into its three parts") + func movesRecordNameRoundTrip() { + let name = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: "alice", + deviceID: "deadbeef" + ) + let parsed = RecordSerializer.parseMovesRecordName(name) + #expect(parsed?.0 == gameID) + #expect(parsed?.1 == "alice") + #expect(parsed?.2 == "deadbeef") + } + + @Test("Moves record round-trips through CKRecord fields") + func movesRecordRoundTrip() throws { + let cells: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + authorID: "alice" + ), + GridPosition(row: 1, col: 2): TimestampedCell( + letter: "B", markKind: 2, checkedWrong: true, + updatedAt: Date(timeIntervalSince1970: 1_700_000_500), + authorID: nil + ), + ] + let view = MovesValue( + gameID: gameID, + authorID: "alice", + deviceID: "deadbeef", + cells: cells, + updatedAt: Date(timeIntervalSince1970: 1_700_000_500) + ) + let record = try RecordSerializer.movesRecord( + from: view, + zone: zoneID, + systemFields: nil + ) + let parsed = RecordSerializer.parseMovesRecord(record) + #expect(parsed == view) + } + + @Test("Parsing rejects records with the wrong record type") + func parseRejectsWrongRecordType() { + let recordID = CKRecord.ID( + recordName: RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: "alice", + deviceID: "deadbeef" + ), + zoneID: zoneID + ) + let record = CKRecord(recordType: "Move", recordID: recordID) + #expect(RecordSerializer.parseMovesRecord(record) == nil) + } +} diff --git a/Tests/Unit/SnapshotServiceTests.swift b/Tests/Unit/SnapshotServiceTests.swift @@ -1,199 +0,0 @@ -import CoreData -import Foundation -import Testing - -@testable import Crossmate - -@Suite("SnapshotService", .serialized) -@MainActor -struct SnapshotServiceTests { - @Test("Creating a compaction snapshot keeps covered moves until the snapshot is saved") - func snapshotCreationDoesNotImmediatelyPruneMoves() async throws { - let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 2) - - let result = await service.createSnapshotsIfNeeded(for: [gameID]) - service.persistence.viewContext.refreshAllObjects() - - #expect(result.snapshotNames.count == 1) - #expect(result.prunedMoveNames.isEmpty) - #expect(fetchMoveNames(service: service, gameID: gameID).count == 2) - #expect(fetchPendingPruneSnapshotNames(service: service) == result.snapshotNames) - } - - @Test("Saved local compaction snapshots prune their covered moves") - func savedSnapshotPrunesCoveredMoves() async throws { - let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 2) - let result = await service.createSnapshotsIfNeeded(for: [gameID]) - service.persistence.viewContext.refreshAllObjects() - - let pruned = service.pruneMoves( - ckRecordNames: Set(result.snapshotNames) - ) - - #expect(Set(pruned) == Set([ - RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), - RecordSerializer.recordName(forMoveInGame: gameID, lamport: 2) - ])) - #expect(fetchMoveNames(service: service, gameID: gameID).isEmpty) - #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) - } - - @Test("Durable pending snapshots are pruned on a later pass") - func durablePendingSnapshotPrunesOnRecoveryPass() async throws { - let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 1) - let result = await service.createSnapshotsIfNeeded(for: [gameID]) - service.persistence.viewContext.refreshAllObjects() - try markSnapshotSaved(service: service, ckRecordName: result.snapshotNames[0]) - - let pruned = service.pruneMoves() - - #expect(pruned == [ - RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1) - ]) - #expect(fetchMoveNames(service: service, gameID: gameID).isEmpty) - #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) - } - - @Test("Remote snapshots are not used to prune local moves") - func remoteSnapshotDoesNotPruneLocalMoves() throws { - let (service, gameID) = try makeServiceWithCompletedGame(moveCount: 1) - let context = service.persistence.viewContext - let game = try #require(try fetchGame(service: service, gameID: gameID)) - let snapshot = SnapshotEntity(context: context) - snapshot.game = game - snapshot.ckRecordName = "snapshot-\(gameID.uuidString)-1-remote" - snapshot.ckSystemFields = Data([1]) - snapshot.createdAt = Date() - snapshot.gridState = try MoveLog.encodeGridState([:]) - snapshot.upToLamport = 1 - snapshot.needsPruning = false - try context.save() - - let pruned = service.pruneMoves() - - #expect(pruned.isEmpty) - #expect(fetchMoveNames(service: service, gameID: gameID).count == 1) - } - - @Test("Shared games do not create scalar-Lamport snapshots") - func sharedGamesDoNotCreateSnapshots() async throws { - let (service, gameID) = try makeServiceWithCompletedGame( - moveCount: 2, - configure: { game in - game.databaseScope = 1 - } - ) - - let result = await service.createSnapshotsIfNeeded(for: [gameID]) - service.persistence.viewContext.refreshAllObjects() - - #expect(result.snapshotNames.isEmpty) - #expect(result.prunedMoveNames.isEmpty) - #expect(fetchMoveNames(service: service, gameID: gameID).count == 2) - #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) - } - - @Test("Shared games do not prune moves from existing scalar snapshots") - func sharedGamesDoNotPruneExistingSnapshots() throws { - let (service, gameID) = try makeServiceWithCompletedGame( - moveCount: 1, - configure: { game in - game.ckShareRecordName = "share-test" - } - ) - let context = service.persistence.viewContext - let game = try #require(try fetchGame(service: service, gameID: gameID)) - let snapshot = SnapshotEntity(context: context) - snapshot.game = game - snapshot.ckRecordName = RecordSerializer.recordName( - forSnapshotInGame: gameID, - upToLamport: 1 - ) - snapshot.ckSystemFields = Data([1]) - snapshot.createdAt = Date() - snapshot.gridState = try MoveLog.encodeGridState([:]) - snapshot.upToLamport = 1 - snapshot.needsPruning = true - try context.save() - - let pruned = service.pruneMoves() - - #expect(pruned.isEmpty) - #expect(fetchMoveNames(service: service, gameID: gameID).count == 1) - #expect(fetchPendingPruneSnapshotNames(service: service).isEmpty) - } - - private func makeServiceWithCompletedGame( - moveCount: Int, - configure: (GameEntity) -> Void = { _ in } - ) throws -> (SnapshotService, UUID) { - let persistence = makeTestPersistence() - let service = SnapshotService(persistence: persistence) - let context = persistence.viewContext - let gameID = UUID() - let game = GameEntity(context: context) - game.id = gameID - game.title = "Test" - game.puzzleSource = "" - game.createdAt = Date() - game.updatedAt = Date() - game.completedAt = Date() - game.ckRecordName = "game-\(gameID.uuidString)" - game.lamportHighWater = Int64(moveCount) - configure(game) - - for lamport in 1...moveCount { - let move = MoveEntity(context: context) - move.game = game - move.lamport = Int64(lamport) - move.row = 0 - move.col = Int16(lamport - 1) - move.letter = "\(lamport)" - move.markKind = 0 - move.checkedWrong = false - move.createdAt = Date() - move.ckRecordName = RecordSerializer.recordName( - forMoveInGame: gameID, - lamport: Int64(lamport) - ) - } - - try context.save() - return (service, gameID) - } - - private func fetchGame(service: SnapshotService, gameID: UUID) throws -> GameEntity? { - let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") - request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) - request.fetchLimit = 1 - return try service.persistence.viewContext.fetch(request).first - } - - private func fetchMoveNames(service: SnapshotService, gameID: UUID) -> [String] { - let request = NSFetchRequest<MoveEntity>(entityName: "MoveEntity") - request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) - request.sortDescriptors = [NSSortDescriptor(key: "lamport", ascending: true)] - return ((try? service.persistence.viewContext.fetch(request)) ?? []) - .compactMap(\.ckRecordName) - } - - private func fetchPendingPruneSnapshotNames(service: SnapshotService) -> [String] { - let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - request.predicate = NSPredicate(format: "needsPruning == YES") - return ((try? service.persistence.viewContext.fetch(request)) ?? []) - .compactMap(\.ckRecordName) - .sorted() - } - - private func markSnapshotSaved( - service: SnapshotService, - ckRecordName: String - ) throws { - let request = NSFetchRequest<SnapshotEntity>(entityName: "SnapshotEntity") - request.predicate = NSPredicate(format: "ckRecordName == %@", ckRecordName) - request.fetchLimit = 1 - let snapshot = try #require(service.persistence.viewContext.fetch(request).first) - snapshot.ckSystemFields = Data([1]) - try service.persistence.viewContext.save() - } -} diff --git a/Tests/Unit/Sync/AuthorIdentityTests.swift b/Tests/Unit/Sync/AuthorIdentityTests.swift @@ -29,7 +29,7 @@ struct AuthorIdentityTests { let identity = AuthorIdentity(storage: makeIsolatedStorage()) // Using the default CKContainer without a signed-in account will fail. // The contract is: on failure, currentID stays nil (or unchanged). - let containerWithNoAccount = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let containerWithNoAccount = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") await identity.refresh(using: containerWithNoAccount) // On a simulator/CI with no account, this should stay nil. // We can only assert it doesn't crash; value depends on sim state. diff --git a/Tests/Unit/Sync/MovesInboundTests.swift b/Tests/Unit/Sync/MovesInboundTests.swift @@ -0,0 +1,219 @@ +import CloudKit +import CoreData +import Foundation +import Testing + +@testable import Crossmate + +/// Pins down `RecordSerializer.applyMovesRecord` — the inbound persistence path +/// that turns a `Moves` CKRecord into a `MovesEntity` row. +@Suite("RecordSerializer.applyMovesRecord") +@MainActor +struct MovesInboundTests { + + private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")! + + private func record( + in ctx: NSManagedObjectContext, + authorID: String, + deviceID: String, + cells: [GridPosition: TimestampedCell], + updatedAt: Date + ) throws -> (CKRecord, MovesValue) { + let value = MovesValue( + gameID: gameID, + authorID: authorID, + deviceID: deviceID, + cells: cells, + updatedAt: updatedAt + ) + let record = try RecordSerializer.movesRecord( + from: value, + zone: RecordSerializer.zoneID(for: gameID), + systemFields: nil + ) + return (record, value) + } + + private func fetchAll(_ ctx: NSManagedObjectContext) -> [MovesEntity] { + let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + return (try? ctx.fetch(req)) ?? [] + } + + @Test("Persists a MovesEntity with the record's fields") + func persistsEntity() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + let (rec, value) = try record( + in: ctx, + authorID: "alice", + deviceID: "deadbeef", + cells: [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + + RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) + + let rows = fetchAll(ctx) + #expect(rows.count == 1) + let entity = try #require(rows.first) + #expect(entity.ckRecordName == rec.recordID.recordName) + #expect(entity.authorID == "alice") + #expect(entity.deviceID == "deadbeef") + #expect(entity.updatedAt == value.updatedAt) + #expect(entity.ckSystemFields != nil) + // `cells` should be the verbatim record blob — decode round-trip recovers + // the same cells we encoded. + let decoded = try MovesCodec.decode(entity.cells ?? Data()) + #expect(decoded == value.cells) + } + + @Test("Re-applying the same record updates the existing row in place") + func reapplyUpdatesSameRow() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let cells1: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + authorID: "alice" + ), + ] + let (rec1, value1) = try record( + in: ctx, + authorID: "alice", + deviceID: "d1", + cells: cells1, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) + let firstID = try #require(fetchAll(ctx).first?.objectID) + + // A later update from the same device should land on the same row + // (matched by ckRecordName), not create a duplicate. + let cells2: [GridPosition: TimestampedCell] = [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "B", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1_700_000_500), + authorID: "alice" + ), + GridPosition(row: 1, col: 1): TimestampedCell( + letter: "C", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1_700_000_500), + authorID: "alice" + ), + ] + let (rec2, value2) = try record( + in: ctx, + authorID: "alice", + deviceID: "d1", + cells: cells2, + updatedAt: Date(timeIntervalSince1970: 1_700_000_500) + ) + RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) + + let rows = fetchAll(ctx) + #expect(rows.count == 1) + #expect(rows.first?.objectID == firstID) + #expect(rows.first?.updatedAt == value2.updatedAt) + let decoded = try MovesCodec.decode(rows.first?.cells ?? Data()) + #expect(decoded == cells2) + } + + @Test("Two devices for the same game produce two distinct rows") + func twoDevicesYieldTwoRows() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let (rec1, value1) = try record( + in: ctx, + authorID: "alice", + deviceID: "phone", + cells: [ + GridPosition(row: 0, col: 0): TimestampedCell( + letter: "A", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 1), + authorID: "alice" + ), + ], + updatedAt: Date(timeIntervalSince1970: 1) + ) + let (rec2, value2) = try record( + in: ctx, + authorID: "alice", + deviceID: "ipad", + cells: [ + GridPosition(row: 1, col: 1): TimestampedCell( + letter: "B", markKind: 0, checkedWrong: false, + updatedAt: Date(timeIntervalSince1970: 2), + authorID: "alice" + ), + ], + updatedAt: Date(timeIntervalSince1970: 2) + ) + RecordSerializer.applyMovesRecord(rec1, value: value1, to: ctx) + RecordSerializer.applyMovesRecord(rec2, value: value2, to: ctx) + + let rows = fetchAll(ctx) + #expect(rows.count == 2) + #expect(Set(rows.compactMap(\.deviceID)) == ["phone", "ipad"]) + } + + @Test("Creates a stub GameEntity if none exists yet (lazy parent)") + func lazyParentStub() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let (rec, value) = try record( + in: ctx, + authorID: "alice", + deviceID: "d1", + cells: [:], + updatedAt: Date(timeIntervalSince1970: 1) + ) + RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) + + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + let games = try ctx.fetch(gameReq) + #expect(games.count == 1) + #expect(games.first?.title == "") + // The Moves row should be parented to the new stub. + let movesRows = fetchAll(ctx) + #expect(movesRows.count == 1) + #expect(movesRows.first?.game?.objectID == games.first?.objectID) + } + + @Test("Bumps the parent game's updatedAt when the record is fresher") + func bumpsGameUpdatedAt() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + // Pre-create the game with an old timestamp so the bump is observable. + let game = GameEntity(context: ctx) + game.id = gameID + game.ckRecordName = "game-\(gameID.uuidString)" + game.title = "" + game.puzzleSource = "" + game.createdAt = Date(timeIntervalSince1970: 0) + game.updatedAt = Date(timeIntervalSince1970: 0) + try ctx.save() + + let (rec, value) = try record( + in: ctx, + authorID: "alice", + deviceID: "d1", + cells: [:], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + RecordSerializer.applyMovesRecord(rec, value: value, to: ctx) + + #expect(game.updatedAt == value.updatedAt) + } +} diff --git a/Tests/Unit/Sync/ShareRoutingTests.swift b/Tests/Unit/Sync/ShareRoutingTests.swift @@ -14,6 +14,8 @@ import Testing @MainActor struct ShareRoutingTests { + private static let writerAuthorID = "alice" + private func makeEngineWithGames() async throws -> (SyncEngine, UUID, UUID) { let persistence = makeTestPersistence() let ctx = persistence.viewContext @@ -28,6 +30,7 @@ struct ShareRoutingTests { privateEntity.ckRecordName = "game-\(privateID.uuidString)" privateEntity.ckZoneName = "game-\(privateID.uuidString)" privateEntity.databaseScope = 0 + Self.seedMovesEntity(for: privateEntity, gameID: privateID, in: ctx) let sharedID = UUID() let sharedEntity = GameEntity(context: ctx) @@ -40,27 +43,40 @@ struct ShareRoutingTests { sharedEntity.ckZoneName = "game-\(sharedID.uuidString)" sharedEntity.ckZoneOwnerName = "_someOtherUser" sharedEntity.databaseScope = 1 + Self.seedMovesEntity(for: sharedEntity, gameID: sharedID, in: ctx) try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") let engine = SyncEngine(container: container, persistence: persistence) await engine.start() return (engine, privateID, sharedID) } - private func move(in gameID: UUID, lamport: Int64, letter: String) -> Move { - Move( - gameID: gameID, - lamport: lamport, - row: 0, col: 0, - letter: letter, - markKind: 0, - checkedWrong: false, - authorID: nil, - createdAt: Date() + /// Inserts a confirmed (system-fields-bearing — simulated by setting non-nil + /// `ckSystemFields`-equivalent state) MovesEntity row for the local device. + /// `enqueueMoves` looks up rows by `(game.id, deviceID == localDeviceID)`. + private static func seedMovesEntity( + for game: GameEntity, + gameID: UUID, + in ctx: NSManagedObjectContext, + confirmed: Bool = false + ) { + let entity = MovesEntity(context: ctx) + entity.game = game + entity.authorID = writerAuthorID + entity.deviceID = RecordSerializer.localDeviceID + entity.cells = Data() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: writerAuthorID, + deviceID: RecordSerializer.localDeviceID ) + if confirmed { + entity.ckSystemFields = Data([0xff]) + } } private func makeEngineWithPersistedMove() async throws -> (SyncEngine, UUID) { @@ -78,65 +94,64 @@ struct ShareRoutingTests { game.ckZoneName = "game-\(gameID.uuidString)" game.databaseScope = 0 - let move = MoveEntity(context: ctx) - move.game = game - move.lamport = 1 - move.row = 0 - move.col = 0 - move.letter = "A" - move.markKind = 0 - move.checkedWrong = false - move.createdAt = Date() - move.ckRecordName = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1) + // Unconfirmed: ckSystemFields is nil so enqueueUnconfirmedMoves picks it up. + Self.seedMovesEntity(for: game, gameID: gameID, in: ctx) try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") let engine = SyncEngine(container: container, persistence: persistence) await engine.start() return (engine, gameID) } + private func movesRecordName(for gameID: UUID) -> String { + RecordSerializer.recordName( + forMovesInGame: gameID, + authorID: Self.writerAuthorID, + deviceID: RecordSerializer.localDeviceID + ) + } + @Test("Private-game moves land on the private engine only") func privateMoveEnqueue() async throws { let (engine, privateID, _) = try await makeEngineWithGames() - await engine.enqueueMoves([move(in: privateID, lamport: 1, letter: "A")]) + await engine.enqueueMoves(gameIDs: [privateID]) let privateNames = await engine.pendingSaveRecordNames(scope: .private) let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) - #expect(privateNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") }) + #expect(privateNames.contains(movesRecordName(for: privateID))) #expect(sharedNames.isEmpty) } @Test("Shared-game moves land on the shared engine only") func sharedMoveEnqueue() async throws { let (engine, _, sharedID) = try await makeEngineWithGames() - await engine.enqueueMoves([move(in: sharedID, lamport: 1, letter: "B")]) + await engine.enqueueMoves(gameIDs: [sharedID]) let privateNames = await engine.pendingSaveRecordNames(scope: .private) let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) - #expect(sharedNames.contains { $0.hasPrefix("move-\(sharedID.uuidString)") }) + #expect(sharedNames.contains(movesRecordName(for: sharedID))) #expect(privateNames.isEmpty) } @Test("Mixed-scope batch fans out to the correct engines") func mixedScopeEnqueue() async throws { let (engine, privateID, sharedID) = try await makeEngineWithGames() - await engine.enqueueMoves([ - move(in: privateID, lamport: 1, letter: "A"), - move(in: sharedID, lamport: 1, letter: "B"), - ]) + await engine.enqueueMoves(gameIDs: [privateID, sharedID]) let privateNames = await engine.pendingSaveRecordNames(scope: .private) let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) - #expect(privateNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") }) - #expect(!privateNames.contains { $0.hasPrefix("move-\(sharedID.uuidString)") }) - #expect(sharedNames.contains { $0.hasPrefix("move-\(sharedID.uuidString)") }) - #expect(!sharedNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") }) + let privateMoves = movesRecordName(for: privateID) + let sharedMoves = movesRecordName(for: sharedID) + #expect(privateNames.contains(privateMoves)) + #expect(!privateNames.contains(sharedMoves)) + #expect(sharedNames.contains(sharedMoves)) + #expect(!sharedNames.contains(privateMoves)) } @Test("Deleting a private game queues its zone for private CloudKit deletion") @@ -157,7 +172,7 @@ struct ShareRoutingTests { game.databaseScope = 0 try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") let engine = SyncEngine(container: container, persistence: persistence) await engine.start() @@ -197,7 +212,7 @@ struct ShareRoutingTests { game.databaseScope = 1 try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") let engine = SyncEngine(container: container, persistence: persistence) await engine.start() @@ -222,7 +237,7 @@ struct ShareRoutingTests { func unknownGameIsDropped() async throws { let (engine, _, _) = try await makeEngineWithGames() let orphan = UUID() - await engine.enqueueMoves([move(in: orphan, lamport: 1, letter: "Z")]) + await engine.enqueueMoves(gameIDs: [orphan]) let privateNames = await engine.pendingSaveRecordNames(scope: .private) let sharedNames = await engine.pendingSaveRecordNames(scope: .shared) @@ -231,26 +246,25 @@ struct ShareRoutingTests { #expect(sharedNames.isEmpty) } - @Test("Duplicate move enqueues keep one pending save") + @Test("Duplicate enqueue keeps one pending save") func duplicateMoveEnqueueIsDeduped() async throws { let (engine, privateID, _) = try await makeEngineWithGames() - let move = move(in: privateID, lamport: 1, letter: "A") - await engine.enqueueMoves([move]) - await engine.enqueueMoves([move]) + await engine.enqueueMoves(gameIDs: [privateID]) + await engine.enqueueMoves(gameIDs: [privateID]) - let recordName = RecordSerializer.recordName(forMoveInGame: privateID, lamport: 1) + let recordName = movesRecordName(for: privateID) let privateNames = await engine.pendingSaveRecordNames(scope: .private) #expect(privateNames.filter { $0 == recordName }.count == 1) } - @Test("Unconfirmed persisted moves are re-enqueued") + @Test("Unconfirmed persisted Moves rows are re-enqueued") func unconfirmedPersistedMovesAreReEnqueued() async throws { let (engine, gameID) = try await makeEngineWithPersistedMove() let count = await engine.enqueueUnconfirmedMoves() - let recordName = RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1) + let recordName = movesRecordName(for: gameID) let privateNames = await engine.pendingSaveRecordNames(scope: .private) #expect(count == 1) #expect(privateNames.contains(recordName)) diff --git a/Tests/Unit/Sync/ZoneOrphaningTests.swift b/Tests/Unit/Sync/ZoneOrphaningTests.swift @@ -16,7 +16,7 @@ struct ZoneOrphaningTests { private func makeEngine( persistence: PersistenceController ) async -> SyncEngine { - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") + let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") let engine = SyncEngine(container: container, persistence: persistence) await engine.start() return engine