commit 1835d68e6c8bcba1e6df4f392e2a41997b2790ac
parent 5e688df84884a75c038e09b923b2910c210a5fbb
Author: Michael Camilleri <[email protected]>
Date: Mon, 11 May 2026 01:42:54 +0900
Use publisher as a consistent name
Diffstat:
14 files changed, 768 insertions(+), 768 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -12,7 +12,6 @@
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */; };
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */; };
- 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */; };
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; };
17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; };
@@ -23,10 +22,12 @@
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 */; };
+ 309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */; };
31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4EBC0F07FF815274C028CA /* XDAcceptTests.swift */; };
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 */; };
+ 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.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 */; };
@@ -41,7 +42,6 @@
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.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 */; };
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 */; };
@@ -63,6 +63,7 @@
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 */; };
+ B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; };
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; };
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; };
@@ -74,15 +75,14 @@
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; };
- CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */; };
CCF3867C32C3F36E4F69A59E /* DebuggingMonitors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */; };
+ CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */; };
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; };
D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
- 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 */; };
@@ -112,11 +112,11 @@
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>"; };
+ 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.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>"; };
- 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisher.swift; sourceTree = "<group>"; };
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
@@ -134,13 +134,13 @@
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; };
47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; };
- 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
+ 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
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>"; };
@@ -148,6 +148,7 @@
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>"; };
+ 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.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>"; };
@@ -164,11 +165,9 @@
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>"; };
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
- ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresencePublisherTests.swift; sourceTree = "<group>"; };
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
@@ -203,6 +202,7 @@
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>"; };
+ FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisherTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -222,7 +222,7 @@
14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */,
86470163BFF956F3DE438506 /* Moves.swift */,
7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */,
- 19D51F4A1CAEB849A09EC2C1 /* PresencePublisher.swift */,
+ 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
5C74683332956B0D1CA37589 /* ShareController.swift */,
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
@@ -248,13 +248,13 @@
D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
- 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */,
47532AED239AEF476D8E9206 /* NotificationStateTests.swift */,
ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
+ 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */,
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */,
+ FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */,
46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */,
- ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */,
FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */,
B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */,
C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */,
@@ -395,10 +395,10 @@
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
6BDD06460A76D4AF31077732 /* InputMonitor.swift */,
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
- 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */,
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */,
+ 71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */,
B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */,
);
path = Services;
@@ -509,13 +509,13 @@
C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */,
C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */,
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
- 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */,
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */,
C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */,
014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */,
+ CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */,
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */,
+ 309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */,
00F2108848ADC7B4BF3AA0AE /* PlayerSessionNavigationTests.swift in Sources */,
- 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */,
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */,
7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */,
89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */,
@@ -567,17 +567,17 @@
D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */,
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */,
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */,
- CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */,
DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */,
6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */,
CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
+ 3C54AE4AA04342CCF5705B20 /* PlayerNamePublisher.swift in Sources */,
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */,
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */,
17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */,
+ B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */,
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
- D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */,
40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -394,12 +394,12 @@ private struct PuzzleDisplayView: View {
openPuzzleFollowUpTask?.cancel()
openPuzzleFollowUpTask = nil
NotificationState.clearActivePuzzleID(if: gameID)
- let presence = services.presencePublisher
+ let selectionPublisher = services.playerSelectionPublisher
let movesUpdater = services.movesUpdater
let exitedID = gameID
Task {
await movesUpdater.flush()
- await presence.clear()
+ await selectionPublisher.clear()
await movesUpdater.noteSessionEnded(gameID: exitedID)
}
}
@@ -422,7 +422,7 @@ private struct PuzzleDisplayView: View {
services.syncMonitor.note(
"PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster"
)
- await services.presencePublisher.clear()
+ await services.playerSelectionPublisher.clear()
}
}
@@ -434,7 +434,7 @@ private struct PuzzleDisplayView: View {
}
}
- /// Initialises shared-game state (roster, presence, name broadcast) for
+ /// Initialises shared-game state (roster, selection publishing, name broadcast) for
/// the open session. Called when the puzzle first appears as shared, and
/// again if a previously-solo game becomes shared mid-session.
private func activateSharing(for session: PlayerSession, refreshRoster: Bool = true) async {
@@ -451,19 +451,19 @@ private struct PuzzleDisplayView: View {
await activeRoster.refresh()
}
// Fan out the local user's name to every shared/joined game's zone
- // before any presence write — otherwise the partner sees "Player"
- // until we happen to rename ourselves and trigger NameBroadcaster's
+ // before any selection publish — otherwise the partner sees "Player"
+ // until we happen to rename ourselves and trigger PlayerNamePublisher's
// observer. Idempotent if the name has already been broadcast.
- await services.nameBroadcaster?.broadcastName()
+ await services.playerNamePublisher?.broadcastName()
guard let authorID = services.identity.currentID else { return }
- let presence = services.presencePublisher
- await presence.begin(
+ let selectionPublisher = services.playerSelectionPublisher
+ await selectionPublisher.begin(
gameID: gameID,
authorID: authorID,
currentName: preferences.name
)
session.onSelectionChanged = { selection in
- Task { await presence.publish(selection) }
+ Task { await selectionPublisher.publish(selection) }
}
let syncEngine = services.syncEngine
let identity = services.identity
@@ -489,7 +489,7 @@ private struct PuzzleDisplayView: View {
col: session.selectedCol,
direction: session.direction
)
- await presence.publish(initial)
+ await selectionPublisher.publish(initial)
}
private func pollOpenSharedPuzzle() async {
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -23,7 +23,7 @@ final class PlayerSession {
var isPencilMode: Bool = false
/// Optional sink fired whenever the cursor moves. Wired to
- /// `PresencePublisher` from the puzzle view so the local selection is
+ /// `PlayerSelectionPublisher` from the puzzle view so the local selection is
/// debounced + pushed to CloudKit, and the peer can render the outline.
/// Unset for solo (non-shared) games.
var onSelectionChanged: ((PlayerSelection) -> Void)?
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -670,7 +670,7 @@ final class GameStore {
/// Flips the active game's mutator to shared after `ShareController`
/// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster,
- /// starts publishing presence) without requiring the user to re-open.
+ /// starts publishing the local selection) without requiring the user to re-open.
func markShared(gameID: UUID) {
guard currentEntity?.id == gameID else { return }
currentMutator?.isShared = true
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -13,7 +13,7 @@ final class AppServices {
let nytFetcher: NYTPuzzleFetcher
let inputMonitor: InputMonitor
let movesUpdater: MovesUpdater
- let presencePublisher: PresencePublisher
+ let playerSelectionPublisher: PlayerSelectionPublisher
let identity: AuthorIdentity
let shareController: ShareController
let colorStore: GamePlayerColorStore
@@ -25,7 +25,7 @@ final class AppServices {
private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3")
private var started = false
private var syncStarted = false
- private(set) var nameBroadcaster: NameBroadcaster?
+ private(set) var playerNamePublisher: PlayerNamePublisher?
private var isReadyForShareAcceptance = false
private var isProcessingShareAcceptanceQueue = false
private var pendingShareMetadatas: [CKShare.Metadata] = []
@@ -107,7 +107,7 @@ final class AppServices {
self.shareController.onShareSaved = { [weak store] gameID in
store?.markShared(gameID: gameID)
}
- self.presencePublisher = PresencePublisher(
+ self.playerSelectionPublisher = PlayerSelectionPublisher(
persistence: persistence,
sink: { gameID, authorID in
let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled }
@@ -192,10 +192,10 @@ final class AppServices {
)
}
- // NameBroadcaster fans out name changes to all shared/joined games.
+ // PlayerNamePublisher fans out name changes to all shared/joined games.
// PuzzleDisplayView also calls `broadcastName()` when a shared puzzle
// is opened, which covers first-sync-after-share-create / accept.
- nameBroadcaster = NameBroadcaster(
+ playerNamePublisher = PlayerNamePublisher(
preferences: preferences,
persistence: persistence,
authorIdentity: identity,
diff --git a/Crossmate/Services/NameBroadcaster.swift b/Crossmate/Services/NameBroadcaster.swift
@@ -1,133 +0,0 @@
-import CoreData
-import Foundation
-import Observation
-
-/// Observes `PlayerPreferences.name` and writes a per-(game, author)
-/// `PlayerEntity` for every shared or joined game when the name changes,
-/// so remote participants see the updated display name within one sync cycle.
-///
-/// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
-/// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
-/// a single rename should produce exactly one fan-out.
-@MainActor
-final class NameBroadcaster {
- private let preferences: PlayerPreferences
- private let persistence: PersistenceController
- private let authorIdentity: AuthorIdentity
- private let enqueuePlayerRecord: (UUID, String) async -> Void
-
- private var debounceTask: Task<Void, Never>?
- private var observationTask: Task<Void, Never>?
-
- /// Testing-only observer fired after each fan-out completes. Receives the
- /// name that was just broadcast. Production callers should leave this nil.
- var onFanOutForTesting: ((String) -> Void)?
-
- init(
- preferences: PlayerPreferences,
- persistence: PersistenceController,
- authorIdentity: AuthorIdentity,
- enqueuePlayerRecord: @escaping (UUID, String) async -> Void
- ) {
- self.preferences = preferences
- self.persistence = persistence
- self.authorIdentity = authorIdentity
- self.enqueuePlayerRecord = enqueuePlayerRecord
- startObserving()
- }
-
- private func startObserving() {
- observationTask = Task { [weak self] in
- guard let self else { return }
- while !Task.isCancelled {
- await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
- withObservationTracking {
- _ = self.preferences.name
- } onChange: {
- cont.resume()
- }
- }
- guard !Task.isCancelled else { break }
- self.scheduleDebounce()
- }
- }
- }
-
- private func scheduleDebounce() {
- debounceTask?.cancel()
- debounceTask = Task { [weak self] in
- do {
- try await Task.sleep(for: .milliseconds(250))
- } catch {
- return
- }
- guard let self, !Task.isCancelled else { return }
- await self.fanOut(newName: self.preferences.name)
- }
- }
-
- /// Writes the local user's name into the `PlayerEntity` row for every
- /// shared or joined game and asks the sync engine to push each one. Called
- /// directly on game-share creation/accept so the partner sees a name on
- /// the very first sync.
- func broadcastName() async {
- await fanOut(newName: preferences.name)
- }
-
- private func fanOut(newName: String) async {
- guard let authorID = authorIdentity.currentID else { return }
-
- let ctx = persistence.container.newBackgroundContext()
- ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let touchedGameIDs = Self.upsertPlayerRecords(
- in: ctx,
- authorID: authorID,
- name: newName
- )
-
- for gameID in touchedGameIDs {
- await enqueuePlayerRecord(gameID, authorID)
- }
-
- onFanOutForTesting?(newName)
- }
-
- /// Background-context work — main-actor isolation does not apply here.
- private nonisolated static func upsertPlayerRecords(
- in ctx: NSManagedObjectContext,
- authorID: String,
- name: String
- ) -> [UUID] {
- ctx.performAndWait {
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(
- format: "ckShareRecordName != nil OR databaseScope == 1"
- )
- let games = (try? ctx.fetch(req)) ?? []
- var ids: [UUID] = []
- let now = Date()
- for game in games {
- guard let gameID = game.id else { continue }
- let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
- let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
- lookup.fetchLimit = 1
-
- let entity: PlayerEntity
- if let existing = try? ctx.fetch(lookup).first {
- entity = existing
- } else {
- entity = PlayerEntity(context: ctx)
- entity.game = game
- entity.ckRecordName = recordName
- entity.authorID = authorID
- }
- entity.name = name
- entity.updatedAt = now
- ids.append(gameID)
- }
- if ctx.hasChanges { try? ctx.save() }
- return ids
- }
- }
-}
diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift
@@ -0,0 +1,133 @@
+import CoreData
+import Foundation
+import Observation
+
+/// Observes `PlayerPreferences.name` and writes a per-(game, author)
+/// `PlayerEntity` for every shared or joined game when the name changes,
+/// so remote participants see the updated display name within one sync cycle.
+///
+/// Debounces at 250 ms: `PlayerPreferences` writes to both `UserDefaults` and
+/// `NSUbiquitousKeyValueStore`, which can echo back as a second setter call;
+/// a single rename should produce exactly one fan-out.
+@MainActor
+final class PlayerNamePublisher {
+ private let preferences: PlayerPreferences
+ private let persistence: PersistenceController
+ private let authorIdentity: AuthorIdentity
+ private let enqueuePlayerRecord: (UUID, String) async -> Void
+
+ private var debounceTask: Task<Void, Never>?
+ private var observationTask: Task<Void, Never>?
+
+ /// Testing-only observer fired after each fan-out completes. Receives the
+ /// name that was just broadcast. Production callers should leave this nil.
+ var onFanOutForTesting: ((String) -> Void)?
+
+ init(
+ preferences: PlayerPreferences,
+ persistence: PersistenceController,
+ authorIdentity: AuthorIdentity,
+ enqueuePlayerRecord: @escaping (UUID, String) async -> Void
+ ) {
+ self.preferences = preferences
+ self.persistence = persistence
+ self.authorIdentity = authorIdentity
+ self.enqueuePlayerRecord = enqueuePlayerRecord
+ startObserving()
+ }
+
+ private func startObserving() {
+ observationTask = Task { [weak self] in
+ guard let self else { return }
+ while !Task.isCancelled {
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
+ withObservationTracking {
+ _ = self.preferences.name
+ } onChange: {
+ cont.resume()
+ }
+ }
+ guard !Task.isCancelled else { break }
+ self.scheduleDebounce()
+ }
+ }
+ }
+
+ private func scheduleDebounce() {
+ debounceTask?.cancel()
+ debounceTask = Task { [weak self] in
+ do {
+ try await Task.sleep(for: .milliseconds(250))
+ } catch {
+ return
+ }
+ guard let self, !Task.isCancelled else { return }
+ await self.fanOut(newName: self.preferences.name)
+ }
+ }
+
+ /// Writes the local user's name into the `PlayerEntity` row for every
+ /// shared or joined game and asks the sync engine to push each one. Called
+ /// directly on game-share creation/accept so the partner sees a name on
+ /// the very first sync.
+ func broadcastName() async {
+ await fanOut(newName: preferences.name)
+ }
+
+ private func fanOut(newName: String) async {
+ guard let authorID = authorIdentity.currentID else { return }
+
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ let touchedGameIDs = Self.upsertPlayerRecords(
+ in: ctx,
+ authorID: authorID,
+ name: newName
+ )
+
+ for gameID in touchedGameIDs {
+ await enqueuePlayerRecord(gameID, authorID)
+ }
+
+ onFanOutForTesting?(newName)
+ }
+
+ /// Background-context work — main-actor isolation does not apply here.
+ private nonisolated static func upsertPlayerRecords(
+ in ctx: NSManagedObjectContext,
+ authorID: String,
+ name: String
+ ) -> [UUID] {
+ ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "ckShareRecordName != nil OR databaseScope == 1"
+ )
+ let games = (try? ctx.fetch(req)) ?? []
+ var ids: [UUID] = []
+ let now = Date()
+ for game in games {
+ guard let gameID = game.id else { continue }
+ let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
+ let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ lookup.fetchLimit = 1
+
+ let entity: PlayerEntity
+ if let existing = try? ctx.fetch(lookup).first {
+ entity = existing
+ } else {
+ entity = PlayerEntity(context: ctx)
+ entity.game = game
+ entity.ckRecordName = recordName
+ entity.authorID = authorID
+ }
+ entity.name = name
+ entity.updatedAt = now
+ ids.append(gameID)
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ return ids
+ }
+ }
+}
diff --git a/Crossmate/Sync/PlayerSelectionPublisher.swift b/Crossmate/Sync/PlayerSelectionPublisher.swift
@@ -0,0 +1,162 @@
+import CoreData
+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 `MovesUpdater` because they aren't cell edits — they live
+/// on `PlayerEntity` with last-writer-wins semantics.
+actor PlayerSelectionPublisher {
+ private let debounceInterval: Duration
+ private let persistence: PersistenceController
+ private let sink: @Sendable (UUID, String) async -> Void
+
+ private var pending: PlayerSelection?
+ private var lastPublished: PlayerSelection?
+ private var debounceTask: Task<Void, Never>?
+ private var gameID: UUID?
+ private var authorID: String?
+ /// The local user's display name at session start. Used as a fallback
+ /// when `write` has to insert a fresh `PlayerEntity` row before
+ /// `PlayerNamePublisher` has fanned out — without it the row would have no
+ /// name and the SyncEngine would refuse to build a CKRecord, dropping
+ /// the cursor on the floor.
+ private var fallbackName: String = ""
+
+ init(
+ debounceInterval: Duration = .milliseconds(300),
+ persistence: PersistenceController,
+ sink: @escaping @Sendable (UUID, String) async -> Void
+ ) {
+ self.debounceInterval = debounceInterval
+ self.persistence = persistence
+ self.sink = sink
+ }
+
+ /// Starts publishing for a new puzzle session. Resets dedupe state so the
+ /// first selection from the new session always flushes. `currentName` is
+ /// the local user's display name at the time the puzzle was opened — used
+ /// only when no PlayerEntity row exists yet for this (game, author).
+ func begin(gameID: UUID, authorID: String, currentName: String) {
+ self.gameID = gameID
+ self.authorID = authorID
+ fallbackName = currentName
+ pending = nil
+ lastPublished = nil
+ debounceTask?.cancel()
+ debounceTask = nil
+ }
+
+ /// Registers a new selection. Coalesces with any prior pending value and
+ /// schedules a trailing-edge flush. Repeated identical selections are
+ /// dropped.
+ func publish(_ selection: PlayerSelection) {
+ guard gameID != nil, authorID != nil else { return }
+ if pending == selection || (pending == nil && lastPublished == selection) {
+ return
+ }
+ pending = selection
+ scheduleDebounce()
+ }
+
+ /// Records a "no selection" — used on puzzle teardown so the peer's
+ /// outline disappears promptly instead of waiting for staleness.
+ func clear() {
+ guard gameID != nil, authorID != nil else { return }
+ pending = nil
+ debounceTask?.cancel()
+ debounceTask = nil
+ Task { await self.flushClear() }
+ }
+
+ /// Flushes any pending selection immediately and cancels the debounce.
+ 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 let gameID, let authorID, let selection = pending else { return }
+ if selection == lastPublished { return }
+ pending = nil
+ lastPublished = selection
+ await write(gameID: gameID, authorID: authorID, selection: selection)
+ await sink(gameID, authorID)
+ }
+
+ private func flushClear() async {
+ guard let gameID, let authorID else { return }
+ if lastPublished == nil { return }
+ lastPublished = nil
+ await write(gameID: gameID, authorID: authorID, selection: nil)
+ await sink(gameID, authorID)
+ }
+
+ private func write(
+ gameID: UUID,
+ authorID: String,
+ selection: PlayerSelection?
+ ) async {
+ let context = persistence.container.newBackgroundContext()
+ context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ let now = Date()
+ let fallbackName = self.fallbackName
+ context.performAndWait {
+ let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ req.fetchLimit = 1
+ let entity: PlayerEntity
+ if let existing = try? context.fetch(req).first {
+ entity = existing
+ // Don't overwrite a name that PlayerNamePublisher has set; only
+ // backfill if it's missing or empty so the outgoing record is
+ // never `name=""`.
+ if (entity.name ?? "").isEmpty, !fallbackName.isEmpty {
+ entity.name = fallbackName
+ }
+ } else {
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ guard let game = try? context.fetch(gameReq).first else { return }
+ entity = PlayerEntity(context: context)
+ entity.game = game
+ entity.ckRecordName = recordName
+ entity.authorID = authorID
+ if !fallbackName.isEmpty {
+ entity.name = fallbackName
+ }
+ }
+ entity.updatedAt = now
+ if let selection {
+ entity.selRow = NSNumber(value: Int64(selection.row))
+ entity.selCol = NSNumber(value: Int64(selection.col))
+ entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
+ } else {
+ entity.selRow = nil
+ entity.selCol = nil
+ entity.selDir = nil
+ }
+ if context.hasChanges {
+ try? context.save()
+ }
+ }
+ }
+}
diff --git a/Crossmate/Sync/PresencePublisher.swift b/Crossmate/Sync/PresencePublisher.swift
@@ -1,162 +0,0 @@
-import CoreData
-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 `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
- private let sink: @Sendable (UUID, String) async -> Void
-
- private var pending: PlayerSelection?
- private var lastPublished: PlayerSelection?
- private var debounceTask: Task<Void, Never>?
- private var gameID: UUID?
- private var authorID: String?
- /// The local user's display name at session start. Used as a fallback
- /// when `write` has to insert a fresh `PlayerEntity` row before
- /// `NameBroadcaster` has fanned out — without it the row would have no
- /// name and the SyncEngine would refuse to build a CKRecord, dropping
- /// the cursor on the floor.
- private var fallbackName: String = ""
-
- init(
- debounceInterval: Duration = .milliseconds(300),
- persistence: PersistenceController,
- sink: @escaping @Sendable (UUID, String) async -> Void
- ) {
- self.debounceInterval = debounceInterval
- self.persistence = persistence
- self.sink = sink
- }
-
- /// Starts publishing for a new puzzle session. Resets dedupe state so the
- /// first selection from the new session always flushes. `currentName` is
- /// the local user's display name at the time the puzzle was opened — used
- /// only when no PlayerEntity row exists yet for this (game, author).
- func begin(gameID: UUID, authorID: String, currentName: String) {
- self.gameID = gameID
- self.authorID = authorID
- fallbackName = currentName
- pending = nil
- lastPublished = nil
- debounceTask?.cancel()
- debounceTask = nil
- }
-
- /// Registers a new selection. Coalesces with any prior pending value and
- /// schedules a trailing-edge flush. Repeated identical selections are
- /// dropped.
- func publish(_ selection: PlayerSelection) {
- guard gameID != nil, authorID != nil else { return }
- if pending == selection || (pending == nil && lastPublished == selection) {
- return
- }
- pending = selection
- scheduleDebounce()
- }
-
- /// Records a "no selection" — used on puzzle teardown so the peer's
- /// outline disappears promptly instead of waiting for staleness.
- func clear() {
- guard gameID != nil, authorID != nil else { return }
- pending = nil
- debounceTask?.cancel()
- debounceTask = nil
- Task { await self.flushClear() }
- }
-
- /// Flushes any pending selection immediately and cancels the debounce.
- 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 let gameID, let authorID, let selection = pending else { return }
- if selection == lastPublished { return }
- pending = nil
- lastPublished = selection
- await write(gameID: gameID, authorID: authorID, selection: selection)
- await sink(gameID, authorID)
- }
-
- private func flushClear() async {
- guard let gameID, let authorID else { return }
- if lastPublished == nil { return }
- lastPublished = nil
- await write(gameID: gameID, authorID: authorID, selection: nil)
- await sink(gameID, authorID)
- }
-
- private func write(
- gameID: UUID,
- authorID: String,
- selection: PlayerSelection?
- ) async {
- let context = persistence.container.newBackgroundContext()
- context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let now = Date()
- let fallbackName = self.fallbackName
- context.performAndWait {
- let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
- let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
- req.fetchLimit = 1
- let entity: PlayerEntity
- if let existing = try? context.fetch(req).first {
- entity = existing
- // Don't overwrite a name that NameBroadcaster has set; only
- // backfill if it's missing or empty so the outgoing record is
- // never `name=""`.
- if (entity.name ?? "").isEmpty, !fallbackName.isEmpty {
- entity.name = fallbackName
- }
- } else {
- let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- gameReq.fetchLimit = 1
- guard let game = try? context.fetch(gameReq).first else { return }
- entity = PlayerEntity(context: context)
- entity.game = game
- entity.ckRecordName = recordName
- entity.authorID = authorID
- if !fallbackName.isEmpty {
- entity.name = fallbackName
- }
- }
- entity.updatedAt = now
- if let selection {
- entity.selRow = NSNumber(value: Int64(selection.row))
- entity.selCol = NSNumber(value: Int64(selection.col))
- entity.selDir = NSNumber(value: Int64(selection.direction.rawValue))
- } else {
- entity.selRow = nil
- entity.selCol = nil
- entity.selDir = nil
- }
- if context.hasChanges {
- try? context.save()
- }
- }
- }
-}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -401,7 +401,7 @@ actor SyncEngine {
)
}
- /// Registers a Player record as a pending send. Used by `NameBroadcaster`
+ /// Registers a Player record as a pending send. Used by `PlayerNamePublisher`
/// when the local user renames; one record per (game, authorID), so
/// participants only ever write their own slot.
func enqueuePlayerRecord(gameID: UUID, authorID: String) {
@@ -1061,7 +1061,7 @@ actor SyncEngine {
let localUpdatedAt = entity.updatedAt
let incomingIsFresher = localUpdatedAt.map { updatedAt >= $0 } ?? true
guard incomingIsFresher else { return }
- // An empty `name` is what older builds shipped from PresencePublisher
+ // An empty `name` is what older builds shipped from the selection publisher
// before the fix; treat it as "no information" rather than letting it
// clobber a previously-resolved name.
if !renderedName.isEmpty {
diff --git a/Tests/Unit/NameBroadcasterTests.swift b/Tests/Unit/NameBroadcasterTests.swift
@@ -1,160 +0,0 @@
-import CoreData
-import Foundation
-import Testing
-
-@testable import Crossmate
-
-@Suite("NameBroadcaster", .serialized)
-@MainActor
-struct NameBroadcasterTests {
-
- // MARK: - Helpers
-
- /// Creates a persistence store with one game that qualifies for fan-out
- /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it).
- private func makeSharedGame() throws -> (PersistenceController, UUID) {
- let p = makeTestPersistence()
- let ctx = p.viewContext
- let gameID = UUID()
- let entity = GameEntity(context: ctx)
- entity.id = gameID
- entity.title = "Shared Test"
- entity.puzzleSource = ""
- entity.createdAt = Date()
- entity.updatedAt = Date()
- entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
- entity.ckShareRecordName = "share-\(UUID().uuidString)"
- try ctx.save()
- return (p, gameID)
- }
-
- private func makeNonSharedGame() throws -> (PersistenceController, UUID) {
- let p = makeTestPersistence()
- let ctx = p.viewContext
- let gameID = UUID()
- let entity = GameEntity(context: ctx)
- entity.id = gameID
- entity.title = "Solo Test"
- entity.puzzleSource = ""
- entity.createdAt = Date()
- entity.updatedAt = Date()
- entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
- // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out.
- try ctx.save()
- return (p, gameID)
- }
-
- private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? {
- let ctx = persistence.container.newBackgroundContext()
- return ctx.performAndWait {
- let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- req.predicate = NSPredicate(format: "authorID == %@", authorID)
- req.fetchLimit = 1
- return (try? ctx.fetch(req).first)?.name
- }
- }
-
- private func makeBroadcaster(
- preferences: PlayerPreferences,
- persistence: PersistenceController,
- authorID: String
- ) -> NameBroadcaster {
- NameBroadcaster(
- preferences: preferences,
- persistence: persistence,
- authorIdentity: AuthorIdentity(testing: authorID),
- enqueuePlayerRecord: { _, _ in }
- )
- }
-
- // MARK: - Tests
-
- @Test("broadcastName writes a PlayerEntity for shared/joined games")
- func broadcastNameWritesPlayerEntityForSharedGame() async throws {
- let (persistence, _) = try makeSharedGame()
- let prefs = PlayerPreferences(
- local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
- )
- prefs.name = "Alice"
- let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: persistence,
- authorID: "_local"
- )
-
- await broadcaster.broadcastName()
-
- #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice")
- }
-
- @Test("broadcastName is a no-op for non-shared games")
- func broadcastNameSkipsNonSharedGames() async throws {
- let (persistence, _) = try makeNonSharedGame()
- let prefs = PlayerPreferences(
- local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
- )
- prefs.name = "Alice"
- let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: persistence,
- authorID: "_local"
- )
-
- await broadcaster.broadcastName()
-
- #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil)
- }
-
- @Test("Debounce coalesces two rapid name changes into one fan-out with the final name")
- func debounceCoalescesPair() async throws {
- let (persistence, _) = try makeSharedGame()
- let prefs = PlayerPreferences(
- local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
- )
- let broadcaster = makeBroadcaster(
- preferences: prefs,
- persistence: persistence,
- authorID: "_local"
- )
-
- let spy = FanOutSpy()
- broadcaster.onFanOutForTesting = { name in spy.record(name) }
-
- // Allow the observation task to make its first withObservationTracking
- // registration before we mutate any values.
- await Task.yield()
-
- // First change — debounce timer #1 starts.
- prefs.name = "Alice"
- await Task.yield() // observation task → scheduleDebounce
-
- // Second change before timer fires — must cancel #1, start #2.
- prefs.name = "Bob"
- await Task.yield() // observation task → cancel #1, start #2
-
- // Poll until the (single, hopefully) fan-out fires. Loose deadline so
- // CI scheduler jitter doesn't fail the test.
- let deadline = Date().addingTimeInterval(2.0)
- while spy.count == 0 && Date() < deadline {
- try await Task.sleep(for: .milliseconds(20))
- }
-
- // Grace period to catch a stray second fan-out from an uncancelled
- // timer — the bug we're guarding against would surface here as count==2.
- try await Task.sleep(for: .milliseconds(150))
-
- #expect(spy.count == 1, "two rapid changes should debounce into one fan-out")
- #expect(spy.names.last == "Bob", "the final name should win")
- #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob")
-
- // Keep broadcaster alive until assertions are done.
- withExtendedLifetime(broadcaster) {}
- }
-}
-
-@MainActor
-private final class FanOutSpy {
- private(set) var names: [String] = []
- var count: Int { names.count }
- func record(_ name: String) { names.append(name) }
-}
diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift
@@ -0,0 +1,160 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("PlayerNamePublisher", .serialized)
+@MainActor
+struct PlayerNamePublisherTests {
+
+ // MARK: - Helpers
+
+ /// Creates a persistence store with one game that qualifies for fan-out
+ /// (it has a `ckShareRecordName`, so `upsertPlayerRecords` will visit it).
+ private func makeSharedGame() throws -> (PersistenceController, UUID) {
+ let p = makeTestPersistence()
+ let ctx = p.viewContext
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Shared Test"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
+ entity.ckShareRecordName = "share-\(UUID().uuidString)"
+ try ctx.save()
+ return (p, gameID)
+ }
+
+ private func makeNonSharedGame() throws -> (PersistenceController, UUID) {
+ let p = makeTestPersistence()
+ let ctx = p.viewContext
+ let gameID = UUID()
+ let entity = GameEntity(context: ctx)
+ entity.id = gameID
+ entity.title = "Solo Test"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID)
+ // No ckShareRecordName and databaseScope == 0 → not picked up by fan-out.
+ try ctx.save()
+ return (p, gameID)
+ }
+
+ private func fetchPlayerName(authorID: String, in persistence: PersistenceController) -> String? {
+ let ctx = persistence.container.newBackgroundContext()
+ return ctx.performAndWait {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(format: "authorID == %@", authorID)
+ req.fetchLimit = 1
+ return (try? ctx.fetch(req).first)?.name
+ }
+ }
+
+ private func makeBroadcaster(
+ preferences: PlayerPreferences,
+ persistence: PersistenceController,
+ authorID: String
+ ) -> PlayerNamePublisher {
+ PlayerNamePublisher(
+ preferences: preferences,
+ persistence: persistence,
+ authorIdentity: AuthorIdentity(testing: authorID),
+ enqueuePlayerRecord: { _, _ in }
+ )
+ }
+
+ // MARK: - Tests
+
+ @Test("broadcastName writes a PlayerEntity for shared/joined games")
+ func broadcastNameWritesPlayerEntityForSharedGame() async throws {
+ let (persistence, _) = try makeSharedGame()
+ let prefs = PlayerPreferences(
+ local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
+ )
+ prefs.name = "Alice"
+ let broadcaster = makeBroadcaster(
+ preferences: prefs,
+ persistence: persistence,
+ authorID: "_local"
+ )
+
+ await broadcaster.broadcastName()
+
+ #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Alice")
+ }
+
+ @Test("broadcastName is a no-op for non-shared games")
+ func broadcastNameSkipsNonSharedGames() async throws {
+ let (persistence, _) = try makeNonSharedGame()
+ let prefs = PlayerPreferences(
+ local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
+ )
+ prefs.name = "Alice"
+ let broadcaster = makeBroadcaster(
+ preferences: prefs,
+ persistence: persistence,
+ authorID: "_local"
+ )
+
+ await broadcaster.broadcastName()
+
+ #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil)
+ }
+
+ @Test("Debounce coalesces two rapid name changes into one fan-out with the final name")
+ func debounceCoalescesPair() async throws {
+ let (persistence, _) = try makeSharedGame()
+ let prefs = PlayerPreferences(
+ local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")!
+ )
+ let broadcaster = makeBroadcaster(
+ preferences: prefs,
+ persistence: persistence,
+ authorID: "_local"
+ )
+
+ let spy = FanOutSpy()
+ broadcaster.onFanOutForTesting = { name in spy.record(name) }
+
+ // Allow the observation task to make its first withObservationTracking
+ // registration before we mutate any values.
+ await Task.yield()
+
+ // First change — debounce timer #1 starts.
+ prefs.name = "Alice"
+ await Task.yield() // observation task → scheduleDebounce
+
+ // Second change before timer fires — must cancel #1, start #2.
+ prefs.name = "Bob"
+ await Task.yield() // observation task → cancel #1, start #2
+
+ // Poll until the (single, hopefully) fan-out fires. Loose deadline so
+ // CI scheduler jitter doesn't fail the test.
+ let deadline = Date().addingTimeInterval(2.0)
+ while spy.count == 0 && Date() < deadline {
+ try await Task.sleep(for: .milliseconds(20))
+ }
+
+ // Grace period to catch a stray second fan-out from an uncancelled
+ // timer — the bug we're guarding against would surface here as count==2.
+ try await Task.sleep(for: .milliseconds(150))
+
+ #expect(spy.count == 1, "two rapid changes should debounce into one fan-out")
+ #expect(spy.names.last == "Bob", "the final name should win")
+ #expect(fetchPlayerName(authorID: "_local", in: persistence) == "Bob")
+
+ // Keep broadcaster alive until assertions are done.
+ withExtendedLifetime(broadcaster) {}
+ }
+}
+
+@MainActor
+private final class FanOutSpy {
+ private(set) var names: [String] = []
+ var count: Int { names.count }
+ func record(_ name: String) { names.append(name) }
+}
diff --git a/Tests/Unit/PlayerSelectionPublisherTests.swift b/Tests/Unit/PlayerSelectionPublisherTests.swift
@@ -0,0 +1,277 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("PlayerSelectionPublisher", .serialized)
+@MainActor
+struct PlayerSelectionPublisherTests {
+
+ /// Captures the `(gameID, authorID)` sink fan-outs.
+ actor Capture {
+ private(set) var notifications: [(UUID, String)] = []
+ var count: Int { notifications.count }
+ func append(_ gameID: UUID, _ authorID: String) {
+ notifications.append((gameID, authorID))
+ }
+ }
+
+ 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)
+ }
+
+ @Test("Publish + flush writes the PlayerEntity row and notifies the sink")
+ func publishWritesEntity() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 1)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selRow == 3)
+ #expect(values?.selCol == 4)
+ #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
+ #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice")
+ }
+
+ @Test("Repeated identical selections don't refire the sink")
+ func dedupesIdenticalSelections() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .milliseconds(40),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ let selection = PlayerSelection(row: 0, col: 0, direction: .across)
+ await publisher.publish(selection)
+ try await Task.sleep(for: .milliseconds(120))
+ await publisher.publish(selection)
+ await publisher.publish(selection)
+ try await Task.sleep(for: .milliseconds(120))
+
+ let count = await capture.count
+ #expect(count == 1)
+ }
+
+ @Test("Rapid distinct selections coalesce into a single trailing flush")
+ func debounceCoalescesRapidPublishes() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .milliseconds(80),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ for col in 0...4 {
+ await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across))
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try await Task.sleep(for: .milliseconds(250))
+
+ let count = await capture.count
+ #expect(count == 1)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selCol == 4)
+ }
+
+ @Test("Clear nils the selection fields and notifies the sink")
+ func clearWritesNilSelection() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down))
+ await publisher.flush()
+ await publisher.clear()
+ // clear() spawns a Task internally — give it a moment to land.
+ try await Task.sleep(for: .milliseconds(50))
+
+ let count = await capture.count
+ #expect(count == 2)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values != nil)
+ #expect(values?.selRow == nil)
+ #expect(values?.selCol == nil)
+ #expect(values?.selDir == nil)
+ }
+
+ @Test("Clear is a no-op when nothing has been published yet")
+ func clearWithoutPriorPublishIsNoOp() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.clear()
+ try await Task.sleep(for: .milliseconds(50))
+
+ let count = await capture.count
+ #expect(count == 0)
+ }
+
+ @Test("Publish without begin is silently ignored")
+ func publishBeforeBeginIsDropped() async throws {
+ let (persistence, _) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .milliseconds(40),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 0)
+ }
+
+ @Test("Updates an existing PlayerEntity rather than creating a duplicate")
+ func updatesExistingEntity() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync).
+ let context = persistence.viewContext
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ let game = try #require(try context.fetch(gameReq).first)
+ let preexisting = PlayerEntity(context: context)
+ preexisting.game = game
+ preexisting.authorID = "alice"
+ preexisting.name = "Alice"
+ preexisting.updatedAt = Date()
+ preexisting.ckRecordName = "player-\(gameID.uuidString)-alice"
+ try context.save()
+
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { _, _ in }
+ )
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across))
+ await publisher.flush()
+
+ let rows = fetchAllPlayers(gameID: gameID, persistence: persistence)
+ #expect(rows.count == 1)
+ #expect(rows.first?.name == "Alice") // pre-existing field preserved
+ #expect(rows.first?.selRow == 5)
+ #expect(rows.first?.selCol == 6)
+ }
+
+ @Test("Publishing again after clear writes the new selection")
+ func publishAfterClear() async throws {
+ let (persistence, gameID) = try makePersistenceWithGame()
+ let capture = Capture()
+ let publisher = PlayerSelectionPublisher(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { id, author in await capture.append(id, author) }
+ )
+
+ await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
+ await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across))
+ await publisher.flush()
+ await publisher.clear()
+ try await Task.sleep(for: .milliseconds(50))
+ await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down))
+ await publisher.flush()
+
+ let count = await capture.count
+ #expect(count == 3)
+ let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
+ #expect(values?.selRow == 2)
+ #expect(values?.selCol == 2)
+ #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
+ }
+
+ // MARK: - Helpers
+
+ struct PlayerValues {
+ let name: String?
+ let selRow: Int64?
+ let selCol: Int64?
+ let selDir: Int64?
+ let ckRecordName: String?
+ }
+
+ private func fetchPlayer(
+ gameID: UUID,
+ authorID: String,
+ persistence: PersistenceController
+ ) -> PlayerValues? {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ request.predicate = NSPredicate(
+ format: "game.id == %@ AND authorID == %@",
+ gameID as CVarArg,
+ authorID
+ )
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return nil }
+ return PlayerValues(
+ name: entity.name,
+ selRow: entity.selRow?.int64Value,
+ selCol: entity.selCol?.int64Value,
+ selDir: entity.selDir?.int64Value,
+ ckRecordName: entity.ckRecordName
+ )
+ }
+ }
+
+ private func fetchAllPlayers(
+ gameID: UUID,
+ persistence: PersistenceController
+ ) -> [PlayerValues] {
+ let context = persistence.container.newBackgroundContext()
+ return context.performAndWait {
+ let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
+ guard let entities = try? context.fetch(request) else { return [] }
+ return entities.map {
+ PlayerValues(
+ name: $0.name,
+ selRow: $0.selRow?.int64Value,
+ selCol: $0.selCol?.int64Value,
+ selDir: $0.selDir?.int64Value,
+ ckRecordName: $0.ckRecordName
+ )
+ }
+ }
+ }
+}
diff --git a/Tests/Unit/PresencePublisherTests.swift b/Tests/Unit/PresencePublisherTests.swift
@@ -1,277 +0,0 @@
-import CoreData
-import Foundation
-import Testing
-
-@testable import Crossmate
-
-@Suite("PresencePublisher", .serialized)
-@MainActor
-struct PresencePublisherTests {
-
- /// Captures the `(gameID, authorID)` sink fan-outs.
- actor Capture {
- private(set) var notifications: [(UUID, String)] = []
- var count: Int { notifications.count }
- func append(_ gameID: UUID, _ authorID: String) {
- notifications.append((gameID, authorID))
- }
- }
-
- 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)
- }
-
- @Test("Publish + flush writes the PlayerEntity row and notifies the sink")
- func publishWritesEntity() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .seconds(10),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- await publisher.publish(PlayerSelection(row: 3, col: 4, direction: .down))
- await publisher.flush()
-
- let count = await capture.count
- #expect(count == 1)
- let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
- #expect(values?.selRow == 3)
- #expect(values?.selCol == 4)
- #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
- #expect(values?.ckRecordName == "player-\(gameID.uuidString)-alice")
- }
-
- @Test("Repeated identical selections don't refire the sink")
- func dedupesIdenticalSelections() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .milliseconds(40),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- let selection = PlayerSelection(row: 0, col: 0, direction: .across)
- await publisher.publish(selection)
- try await Task.sleep(for: .milliseconds(120))
- await publisher.publish(selection)
- await publisher.publish(selection)
- try await Task.sleep(for: .milliseconds(120))
-
- let count = await capture.count
- #expect(count == 1)
- }
-
- @Test("Rapid distinct selections coalesce into a single trailing flush")
- func debounceCoalescesRapidPublishes() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .milliseconds(80),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- for col in 0...4 {
- await publisher.publish(PlayerSelection(row: 0, col: col, direction: .across))
- try await Task.sleep(for: .milliseconds(20))
- }
- try await Task.sleep(for: .milliseconds(250))
-
- let count = await capture.count
- #expect(count == 1)
- let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
- #expect(values?.selCol == 4)
- }
-
- @Test("Clear nils the selection fields and notifies the sink")
- func clearWritesNilSelection() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .seconds(10),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- await publisher.publish(PlayerSelection(row: 1, col: 2, direction: .down))
- await publisher.flush()
- await publisher.clear()
- // clear() spawns a Task internally — give it a moment to land.
- try await Task.sleep(for: .milliseconds(50))
-
- let count = await capture.count
- #expect(count == 2)
- let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
- #expect(values != nil)
- #expect(values?.selRow == nil)
- #expect(values?.selCol == nil)
- #expect(values?.selDir == nil)
- }
-
- @Test("Clear is a no-op when nothing has been published yet")
- func clearWithoutPriorPublishIsNoOp() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .seconds(10),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- await publisher.clear()
- try await Task.sleep(for: .milliseconds(50))
-
- let count = await capture.count
- #expect(count == 0)
- }
-
- @Test("Publish without begin is silently ignored")
- func publishBeforeBeginIsDropped() async throws {
- let (persistence, _) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .milliseconds(40),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.publish(PlayerSelection(row: 0, col: 0, direction: .across))
- await publisher.flush()
-
- let count = await capture.count
- #expect(count == 0)
- }
-
- @Test("Updates an existing PlayerEntity rather than creating a duplicate")
- func updatesExistingEntity() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- // Pre-seed a PlayerEntity (e.g. from a name broadcast or remote sync).
- let context = persistence.viewContext
- let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- let game = try #require(try context.fetch(gameReq).first)
- let preexisting = PlayerEntity(context: context)
- preexisting.game = game
- preexisting.authorID = "alice"
- preexisting.name = "Alice"
- preexisting.updatedAt = Date()
- preexisting.ckRecordName = "player-\(gameID.uuidString)-alice"
- try context.save()
-
- let publisher = PresencePublisher(
- debounceInterval: .seconds(10),
- persistence: persistence,
- sink: { _, _ in }
- )
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- await publisher.publish(PlayerSelection(row: 5, col: 6, direction: .across))
- await publisher.flush()
-
- let rows = fetchAllPlayers(gameID: gameID, persistence: persistence)
- #expect(rows.count == 1)
- #expect(rows.first?.name == "Alice") // pre-existing field preserved
- #expect(rows.first?.selRow == 5)
- #expect(rows.first?.selCol == 6)
- }
-
- @Test("Publishing again after clear writes the new selection")
- func publishAfterClear() async throws {
- let (persistence, gameID) = try makePersistenceWithGame()
- let capture = Capture()
- let publisher = PresencePublisher(
- debounceInterval: .seconds(10),
- persistence: persistence,
- sink: { id, author in await capture.append(id, author) }
- )
-
- await publisher.begin(gameID: gameID, authorID: "alice", currentName: "Alice")
- await publisher.publish(PlayerSelection(row: 1, col: 1, direction: .across))
- await publisher.flush()
- await publisher.clear()
- try await Task.sleep(for: .milliseconds(50))
- await publisher.publish(PlayerSelection(row: 2, col: 2, direction: .down))
- await publisher.flush()
-
- let count = await capture.count
- #expect(count == 3)
- let values = fetchPlayer(gameID: gameID, authorID: "alice", persistence: persistence)
- #expect(values?.selRow == 2)
- #expect(values?.selCol == 2)
- #expect(values?.selDir == Int64(Puzzle.Direction.down.rawValue))
- }
-
- // MARK: - Helpers
-
- struct PlayerValues {
- let name: String?
- let selRow: Int64?
- let selCol: Int64?
- let selDir: Int64?
- let ckRecordName: String?
- }
-
- private func fetchPlayer(
- gameID: UUID,
- authorID: String,
- persistence: PersistenceController
- ) -> PlayerValues? {
- let context = persistence.container.newBackgroundContext()
- return context.performAndWait {
- let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- request.predicate = NSPredicate(
- format: "game.id == %@ AND authorID == %@",
- gameID as CVarArg,
- authorID
- )
- request.fetchLimit = 1
- guard let entity = try? context.fetch(request).first else { return nil }
- return PlayerValues(
- name: entity.name,
- selRow: entity.selRow?.int64Value,
- selCol: entity.selCol?.int64Value,
- selDir: entity.selDir?.int64Value,
- ckRecordName: entity.ckRecordName
- )
- }
- }
-
- private func fetchAllPlayers(
- gameID: UUID,
- persistence: PersistenceController
- ) -> [PlayerValues] {
- let context = persistence.container.newBackgroundContext()
- return context.performAndWait {
- let request = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
- request.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
- guard let entities = try? context.fetch(request) else { return [] }
- return entities.map {
- PlayerValues(
- name: $0.name,
- selRow: $0.selRow?.int64Value,
- selCol: $0.selCol?.int64Value,
- selDir: $0.selDir?.int64Value,
- ckRecordName: $0.ckRecordName
- )
- }
- }
- }
-}