commit 961f6b8a7565dd5f36958d01b4195c31a9650de0
parent 0d9f9f1730ae5597a4485d192ee65582b015ef4b
Author: Michael Camilleri <[email protected]>
Date: Sat, 11 Apr 2026 19:14:38 +0900
Add CloudKit-based sync engine
This commit adds a CloudKit-based sync engine that supports cross-device and
(eventually) collaborative play. It includes an AppDelegate for remote
notifications, a SyncEngine that handles push/fetch/bootstrap and a refactored
persistence layer. Cell mutations are moved to a dedicated GameMutator and
per-cell state is captured in a Square value type. In addition, unit tests are
added to cover GameMutator, PendingChange helpers and RecordSerializer.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Diffstat:
23 files changed, 2011 insertions(+), 129 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -7,52 +7,118 @@
objects = {
/* Begin PBXBuildFile section */
+ 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; };
+ 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
+ 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; };
+ 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; };
+ 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */; };
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
+ AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
+ C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */; };
+ CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; };
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; };
D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; };
+ D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */; };
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; };
+ ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.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 */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ F0122CF3E216720C4437CE6A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 9167165F088B7698D1319D3C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 7708D1C8A0145D43BD15DEB7;
+ remoteInfo = Crossmate;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXFileReference section */
+ 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
+ 43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; };
+ 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangeTests.swift; sourceTree = "<group>"; };
64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; };
+ 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
+ 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; };
+ 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializerTests.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>"; };
9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+ 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; };
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; };
+ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; };
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
+ D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; };
+ DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; };
+ E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChangePayload.swift; sourceTree = "<group>"; };
+ E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PendingChange+Helpers.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>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
+ 01B07D8724DEA04C3E74558E /* Support */ = {
+ isa = PBXGroup;
+ children = (
+ F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */,
+ );
+ name = Support;
+ path = Tests/Support;
+ sourceTree = "<group>";
+ };
+ 074C2962E79CAE6C0EA6431A /* Sync */ = {
+ isa = PBXGroup;
+ children = (
+ E512D95B4518EE3DE6E350C0 /* PendingChangePayload.swift */,
+ 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
+ 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
+ 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */,
+ );
+ path = Sync;
+ sourceTree = "<group>";
+ };
12BCF7948BC2C200C647C279 /* Products */ = {
isa = PBXGroup;
children = (
+ D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */,
B689A7138429641E61E9E558 /* Crossmate.app */,
);
name = Products;
sourceTree = "<group>";
};
+ 212DB6FCF46C41F81C41D232 /* Unit */ = {
+ isa = PBXGroup;
+ children = (
+ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */,
+ 5F9D7D0F3C61B2D6B8DAF0C5 /* PendingChangeTests.swift */,
+ 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
+ );
+ name = Unit;
+ path = Tests/Unit;
+ sourceTree = "<group>";
+ };
41DB2417FF67A47FE6890256 /* Models */ = {
isa = PBXGroup;
children = (
@@ -61,6 +127,7 @@
DB55FC337CF72C650373210A /* PlayerColor.swift */,
20B331CC55827FEF3420ABCE /* PlayerSession.swift */,
64C8064F04FC6177D987ACA2 /* Puzzle.swift */,
+ DB851649DE78AAAC5A928C52 /* Square.swift */,
B9031A1574C21866940F6A2C /* XD.swift */,
F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */,
);
@@ -70,7 +137,9 @@
565DBAFC8DB2589B3F0AF90E /* Persistence */ = {
isa = PBXGroup;
children = (
+ 43DC132D49361C56DE79C13E /* GameMutator.swift */,
93EE5BA78566EDED68D846AB /* GameStore.swift */,
+ E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */,
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */,
);
path = Persistence;
@@ -79,11 +148,13 @@
5770CE69DB2B0B7462FACE53 /* Crossmate */ = {
isa = PBXGroup;
children = (
+ 7B3E1A382B24A7803701D947 /* Crossmate.entitlements */,
14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */,
9447F0FE34C63810C6F1D8BE /* Info.plist */,
41DB2417FF67A47FE6890256 /* Models */,
565DBAFC8DB2589B3F0AF90E /* Persistence */,
F53443E4827221C62DB7AA36 /* Resources */,
+ 074C2962E79CAE6C0EA6431A /* Sync */,
84445EA9CACB6AAAEDE6965F /* Views */,
);
path = Crossmate;
@@ -104,6 +175,8 @@
isa = PBXGroup;
children = (
5770CE69DB2B0B7462FACE53 /* Crossmate */,
+ 01B07D8724DEA04C3E74558E /* Support */,
+ 212DB6FCF46C41F81C41D232 /* Unit */,
12BCF7948BC2C200C647C279 /* Products */,
);
sourceTree = "<group>";
@@ -137,6 +210,24 @@
productReference = B689A7138429641E61E9E558 /* Crossmate.app */;
productType = "com.apple.product-type.application";
};
+ C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 0C7AF110B3697D116B91817A /* Build configuration list for PBXNativeTarget "Crossmate Unit Tests" */;
+ buildPhases = (
+ 931E2DAAD4EC47B06F7AB60A /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */,
+ );
+ name = "Crossmate Unit Tests";
+ packageProductDependencies = (
+ );
+ productName = "Crossmate Unit Tests";
+ productReference = D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -150,6 +241,10 @@
DevelopmentTeam = 7TD7PZBNXP;
ProvisioningStyle = Automatic;
};
+ C38EBD1A6B9D37EF81FF3511 = {
+ DevelopmentTeam = 7TD7PZBNXP;
+ ProvisioningStyle = Automatic;
+ };
};
};
buildConfigurationList = 9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */;
@@ -167,6 +262,7 @@
projectRoot = "";
targets = (
7708D1C8A0145D43BD15DEB7 /* Crossmate */,
+ C38EBD1A6B9D37EF81FF3511 /* Crossmate Unit Tests */,
);
};
/* End PBXProject section */
@@ -183,6 +279,17 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+ 931E2DAAD4EC47B06F7AB60A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
+ 83639982D028AA8459BE748F /* PendingChangeTests.swift in Sources */,
+ ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
+ 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
C17B62906BBF281D006D8DC2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -192,20 +299,35 @@
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */,
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */,
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
+ 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */,
D58980B92C99122C368D4216 /* GameStore.swift in Sources */,
765B50552B13175F91A25EA1 /* GridView.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
+ D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */,
+ C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */,
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */,
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */,
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */,
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */,
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
+ CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
+ AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
+ 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */,
+ F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
7FFEACFC672925A0968ACC1C /* XD.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ 42035D5EEE61A5D459E1D46D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 7708D1C8A0145D43BD15DEB7 /* Crossmate */;
+ targetProxy = F0122CF3E216720C4437CE6A /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
209C1E6D178C7EF962FC85A5 /* Release */ = {
isa = XCBuildConfiguration;
@@ -270,10 +392,47 @@
};
name = Release;
};
+ 42CA2E441989D32BE123F48A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate.unittests;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Crossmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Crossmate";
+ };
+ name = Debug;
+ };
+ 642190659D822637293D1645 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GENERATE_INFOPLIST_FILE = YES;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = net.inqk.crossmate.unittests;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Crossmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Crossmate";
+ };
+ name = Release;
+ };
8BC97916898B0BF1E6951C48 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = Crossmate/Info.plist;
@@ -291,6 +450,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = Crossmate/Crossmate.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
INFOPLIST_FILE = Crossmate/Info.plist;
@@ -377,6 +537,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
+ 0C7AF110B3697D116B91817A /* Build configuration list for PBXNativeTarget "Crossmate Unit Tests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 42CA2E441989D32BE123F48A /* Debug */,
+ 642190659D822637293D1645 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
9A436EF03A8593C66A18A832 /* Build configuration list for PBXProject "Crossmate" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme b/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme
@@ -39,7 +39,21 @@
</BuildableReference>
</MacroExpansion>
<Testables>
+ <TestableReference
+ skipped = "NO"
+ parallelizable = "YES"
+ testExecutionOrdering = "random">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "C38EBD1A6B9D37EF81FF3511"
+ BuildableName = "Crossmate Unit Tests.xctest"
+ BlueprintName = "Crossmate Unit Tests"
+ ReferencedContainer = "container:Crossmate.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
</Testables>
+ <CommandLineArguments>
+ </CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>aps-environment</key>
+ <string>development</string>
+ <key>com.apple.developer.icloud-container-identifiers</key>
+ <array>
+ <string>iCloud.net.inqk.crossmate</string>
+ </array>
+ <key>com.apple.developer.icloud-services</key>
+ <array>
+ <string>CloudKit</string>
+ </array>
+</dict>
+</plist>
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -1,19 +1,64 @@
+import CloudKit
import SwiftUI
@main
struct CrossmateApp: App {
- @State private var store = GameStore(persistence: PersistenceController())
+ @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
+
+ @State private var store: GameStore
+ @State private var syncEngine: SyncEngine
+ private let persistence: PersistenceController
+
+ init() {
+ let persistence = PersistenceController()
+ let store = GameStore(persistence: persistence)
+ let engine = SyncEngine(
+ container: CKContainer(identifier: "iCloud.net.inqk.crossmate"),
+ persistence: persistence
+ )
+ self.persistence = persistence
+ self._store = State(initialValue: store)
+ self._syncEngine = State(initialValue: engine)
+ }
var body: some Scene {
WindowGroup {
- RootView(store: store)
+ RootView(store: store, syncEngine: syncEngine, appDelegate: appDelegate)
}
}
}
+// MARK: - App Delegate
+
+final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable {
+ var onRemoteNotification: (() async -> Void)?
+
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
+ ) -> Bool {
+ application.registerForRemoteNotifications()
+ return true
+ }
+
+ func application(
+ _ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any]
+ ) async -> UIBackgroundFetchResult {
+ await onRemoteNotification?()
+ return .newData
+ }
+}
+
+// MARK: - Root View
+
struct RootView: View {
let store: GameStore
+ let syncEngine: SyncEngine
+ let appDelegate: AppDelegate
+ @Environment(\.scenePhase) private var scenePhase
+ @State private var syncBootstrapped = false
@State private var session: PlayerSession?
@State private var loadError: String?
@@ -36,12 +81,46 @@ struct RootView: View {
.navigationBarTitleDisplayMode(.inline)
}
.task {
+ guard !syncBootstrapped else { return }
+ syncBootstrapped = true
+
+ // Wire app delegate → sync engine fetch
+ appDelegate.onRemoteNotification = { [syncEngine] in
+ try? await syncEngine.fetchChanges()
+ }
+
+ // Wire sync engine → store refresh
+ await syncEngine.setOnRemoteChangesApplied { [store] in
+ store.refreshFromRemote()
+ }
+
+ // Bootstrap and initial sync
+ try? await syncEngine.bootstrap()
+ try? await syncEngine.fetchChanges()
+ try? await syncEngine.pushChanges()
+
+ // Load the current game
do {
- let game = try store.loadOrCreateCurrentGame()
- session = PlayerSession(game: game)
+ let (game, mutator) = try store.loadOrCreateCurrentGame()
+ session = PlayerSession(game: game, mutator: mutator)
+
+ // Wire mutator → sync engine push
+ mutator.onLocalMutation = { [syncEngine] in
+ Task {
+ try? await syncEngine.pushChanges()
+ }
+ }
} catch {
loadError = String(describing: error)
}
}
+ .onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .active {
+ Task {
+ try? await syncEngine.fetchChanges()
+ try? await syncEngine.pushChanges()
+ }
+ }
+ }
}
}
diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist
@@ -24,6 +24,10 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
+ <key>UIBackgroundModes</key>
+ <array>
+ <string>remote-notification</string>
+ </array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -1,8 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="NO" userDefinedModelVersionIdentifier="">
<entity name="GameEntity" representedClassName="GameEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="ckRecordName" optional="YES" attributeType="String"/>
+ <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
+ <attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
+ <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="puzzleSource" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
@@ -10,10 +15,33 @@
</entity>
<entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class">
<attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="ckRecordName" optional="YES" attributeType="String"/>
+ <attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
<attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="letter" attributeType="String" defaultValueString=""/>
+ <attribute name="letterAuthorID" optional="YES" attributeType="String"/>
<attribute name="markKind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="cells" inverseEntity="GameEntity"/>
</entity>
+ <entity name="SyncStateEntity" representedClassName="SyncStateEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+ <attribute name="privateDatabaseToken" optional="YES" attributeType="Binary"/>
+ <attribute name="privateZoneToken" optional="YES" attributeType="Binary"/>
+ <attribute name="subscriptionCreated" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="zoneCreated" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ </entity>
+ <entity name="PendingChangeEntity" representedClassName="PendingChangeEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
+ <attribute name="payload" attributeType="String"/>
+ <attribute name="recordName" attributeType="String"/>
+ <attribute name="recordType" attributeType="String"/>
+ <fetchIndex name="byRecordName">
+ <fetchIndexElement property="recordName" type="Binary" order="ascending"/>
+ </fetchIndex>
+ </entity>
</model>
diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift
@@ -10,17 +10,12 @@ import Observation
@Observable
final class Game {
let puzzle: Puzzle
- var entries: [[String]]
- var marks: [[CellMark]]
+ var squares: [[Square]]
init(puzzle: Puzzle) {
self.puzzle = puzzle
- self.entries = Array(
- repeating: Array(repeating: "", count: puzzle.width),
- count: puzzle.height
- )
- self.marks = Array(
- repeating: Array(repeating: .none, count: puzzle.width),
+ self.squares = Array(
+ repeating: Array(repeating: Square(), count: puzzle.width),
count: puzzle.height
)
}
@@ -33,18 +28,18 @@ final class Game {
func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) {
let cell = puzzle.cells[row][col]
guard !cell.isBlock else { return }
- guard !marks[row][col].isRevealed else { return }
- entries[row][col] = letter.uppercased()
- marks[row][col] = pencil ? .pencil(checkedWrong: false) : .none
+ guard !squares[row][col].mark.isRevealed else { return }
+ squares[row][col].entry = letter.uppercased()
+ squares[row][col].mark = pencil ? .pencil(checkedWrong: false) : .none
}
/// Clears the entry and mark at `(row, col)`. No-op on revealed cells.
func clearLetter(atRow row: Int, atCol col: Int) {
let cell = puzzle.cells[row][col]
guard !cell.isBlock else { return }
- guard !marks[row][col].isRevealed else { return }
- entries[row][col] = ""
- marks[row][col] = .none
+ guard !squares[row][col].mark.isRevealed else { return }
+ squares[row][col].entry = ""
+ squares[row][col].mark = .none
}
// MARK: - Check / Reveal / Clear
@@ -57,16 +52,16 @@ final class Game {
for cell in cells {
guard !cell.isBlock else { continue }
guard let solution = cell.solution else { continue }
- let entry = entries[cell.row][cell.col]
+ let entry = squares[cell.row][cell.col].entry
guard !entry.isEmpty else { continue }
- guard !marks[cell.row][cell.col].isRevealed else { continue }
+ guard !squares[cell.row][cell.col].mark.isRevealed else { continue }
let isWrong = entry != solution.uppercased()
- switch marks[cell.row][cell.col] {
+ switch squares[cell.row][cell.col].mark {
case .pencil:
- marks[cell.row][cell.col] = .pencil(checkedWrong: isWrong)
+ squares[cell.row][cell.col].mark = .pencil(checkedWrong: isWrong)
case .none, .pen:
- marks[cell.row][cell.col] = isWrong ? .pen(checkedWrong: true) : .none
+ squares[cell.row][cell.col].mark = isWrong ? .pen(checkedWrong: true) : .none
case .revealed:
break // unreachable; guarded above
}
@@ -83,8 +78,8 @@ final class Game {
for cell in cells {
guard !cell.isBlock else { continue }
guard let solution = cell.solution else { continue }
- entries[cell.row][cell.col] = solution.uppercased()
- marks[cell.row][cell.col] = .revealed
+ squares[cell.row][cell.col].entry = solution.uppercased()
+ squares[cell.row][cell.col].mark = .revealed
}
}
@@ -98,9 +93,9 @@ final class Game {
func clearCells(_ cells: [Puzzle.Cell]) {
for cell in cells {
guard !cell.isBlock else { continue }
- guard !marks[cell.row][cell.col].isRevealed else { continue }
- entries[cell.row][cell.col] = ""
- marks[cell.row][cell.col] = .none
+ guard !squares[cell.row][cell.col].mark.isRevealed else { continue }
+ squares[cell.row][cell.col].entry = ""
+ squares[cell.row][cell.col].mark = .none
}
}
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -10,6 +10,7 @@ import Observation
@Observable
final class PlayerSession {
let game: Game
+ let mutator: GameMutator
var selectedRow: Int
var selectedCol: Int
var direction: Puzzle.Direction = .across
@@ -17,15 +18,16 @@ final class PlayerSession {
/// Rebus mode lets the player type a multi-character value into a single
/// cell (e.g. "STAR" or "♥"). While active, keyboard input accumulates in
- /// `rebusBuffer` rather than going straight to `Game.entries`; on commit
+ /// `rebusBuffer` rather than going straight to `Game.squares`; on commit
/// the buffer is written to the cell and the cursor advances.
var isRebusActive: Bool = false
var rebusBuffer: String = ""
var puzzle: Puzzle { game.puzzle }
- init(game: Game) {
+ init(game: Game, mutator: GameMutator) {
self.game = game
+ self.mutator = mutator
let puzzle = game.puzzle
// Start at the first across clue. Fall back to the first down clue if
@@ -118,37 +120,37 @@ final class PlayerSession {
func checkSquare() {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- game.checkCells([cell])
+ mutator.checkCells([cell], origin: .local)
}
func checkCurrentWord() {
- game.checkCells(currentWordCells())
+ mutator.checkCells(currentWordCells(), origin: .local)
}
func checkPuzzle() {
- game.checkPuzzle()
+ mutator.checkCells(puzzle.cells.flatMap { $0 }, origin: .local)
}
func revealSquare() {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- game.revealCells([cell])
+ mutator.revealCells([cell], origin: .local)
}
func revealCurrentWord() {
- game.revealCells(currentWordCells())
+ mutator.revealCells(currentWordCells(), origin: .local)
}
func revealPuzzle() {
- game.revealPuzzle()
+ mutator.revealCells(puzzle.cells.flatMap { $0 }, origin: .local)
}
func clearCurrentWord() {
- game.clearCells(currentWordCells())
+ mutator.clearCells(currentWordCells(), origin: .local)
}
func clearPuzzle() {
- game.clearPuzzle()
+ mutator.clearCells(puzzle.cells.flatMap { $0 }, origin: .local)
}
private func currentClueNumber() -> Int? {
@@ -171,7 +173,7 @@ final class PlayerSession {
func enter(_ letter: String) {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- game.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode)
+ mutator.setLetter(letter, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode, origin: .local)
advance()
}
@@ -181,12 +183,12 @@ final class PlayerSession {
// tunnels past them to the previous editable cell. `clearLetter`
// itself no-ops on revealed cells, so calling it unconditionally
// after retreat is safe.
- let currentMark = game.marks[selectedRow][selectedCol]
- let currentEmpty = game.entries[selectedRow][selectedCol].isEmpty
+ let currentMark = game.squares[selectedRow][selectedCol].mark
+ let currentEmpty = game.squares[selectedRow][selectedCol].entry.isEmpty
if currentEmpty || currentMark.isRevealed {
retreat()
}
- game.clearLetter(atRow: selectedRow, atCol: selectedCol)
+ mutator.clearLetter(atRow: selectedRow, atCol: selectedCol, origin: .local)
}
// MARK: - Rebus
@@ -194,7 +196,7 @@ final class PlayerSession {
func startRebus() {
let cell = puzzle.cells[selectedRow][selectedCol]
guard !cell.isBlock else { return }
- rebusBuffer = game.entries[selectedRow][selectedCol]
+ rebusBuffer = game.squares[selectedRow][selectedCol].entry
isRebusActive = true
}
@@ -211,7 +213,7 @@ final class PlayerSession {
let value = rebusBuffer
isRebusActive = false
rebusBuffer = ""
- game.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode)
+ mutator.setLetter(value, atRow: selectedRow, atCol: selectedCol, pencil: isPencilMode, origin: .local)
advance()
}
diff --git a/Crossmate/Models/Square.swift b/Crossmate/Models/Square.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+/// Per-cell state for one square in the grid. Combines the letter entry and
+/// mark (previously parallel arrays on `Game`) with sync bookkeeping fields.
+struct Square: Sendable {
+ var entry: String = ""
+ var mark: CellMark = .none
+ var updatedAt: Date?
+ var letterAuthorID: String?
+}
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -0,0 +1,231 @@
+import CoreData
+import Foundation
+
+/// Unified mutation processor that sits between `PlayerSession` (or the sync
+/// engine) and `Game`. Every mutation flows through here so that:
+///
+/// 1. Timestamps are captured (local) or accepted (remote) for LWW.
+/// 2. The in-memory `Game` is updated.
+/// 3. Changed cells are persisted to Core Data.
+/// 4. For local mutations, a `PendingChangeEntity` is enqueued for upload.
+///
+/// All methods are `@MainActor` because `Game` is `@MainActor`.
+@MainActor
+final class GameMutator {
+ private let game: Game
+ private let gameEntity: GameEntity
+ private let context: NSManagedObjectContext
+
+ /// Wired to SyncEngine in Phase E to trigger a push after local mutations.
+ var onLocalMutation: (() -> Void)?
+
+ enum Origin {
+ case local
+ case remote(timestamp: Date, authorID: String?)
+ }
+
+ init(game: Game, gameEntity: GameEntity, context: NSManagedObjectContext) {
+ self.game = game
+ self.gameEntity = gameEntity
+ self.context = context
+ }
+
+ // MARK: - Single-cell mutations
+
+ func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, origin: Origin) {
+ let now = timestamp(for: origin)
+ guard shouldApply(row: row, col: col, incomingTimestamp: now) else { return }
+
+ game.setLetter(letter, atRow: row, atCol: col, pencil: pencil)
+ game.squares[row][col].updatedAt = now
+ game.squares[row][col].letterAuthorID = authorID(for: origin)
+
+ persistCell(atRow: row, atCol: col)
+ enqueueIfLocal(origin: origin, row: row, col: col)
+ }
+
+ func clearLetter(atRow row: Int, atCol col: Int, origin: Origin) {
+ let now = timestamp(for: origin)
+ guard shouldApply(row: row, col: col, incomingTimestamp: now) else { return }
+
+ game.clearLetter(atRow: row, atCol: col)
+ game.squares[row][col].updatedAt = now
+ game.squares[row][col].letterAuthorID = nil
+
+ persistCell(atRow: row, atCol: col)
+ enqueueIfLocal(origin: origin, row: row, col: col)
+ }
+
+ // MARK: - Bulk mutations
+
+ func checkCells(_ cells: [Puzzle.Cell], origin: Origin) {
+ let now = timestamp(for: origin)
+ var changed: [Puzzle.Cell] = []
+
+ for cell in cells {
+ guard !cell.isBlock else { continue }
+ guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue }
+ changed.append(cell)
+ }
+
+ guard !changed.isEmpty else { return }
+ game.checkCells(changed)
+
+ for cell in changed {
+ game.squares[cell.row][cell.col].updatedAt = now
+ persistCell(atRow: cell.row, atCol: cell.col)
+ enqueueIfLocal(origin: origin, row: cell.row, col: cell.col)
+ }
+ }
+
+ func revealCells(_ cells: [Puzzle.Cell], origin: Origin) {
+ let now = timestamp(for: origin)
+ var changed: [Puzzle.Cell] = []
+
+ for cell in cells {
+ guard !cell.isBlock else { continue }
+ guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue }
+ changed.append(cell)
+ }
+
+ guard !changed.isEmpty else { return }
+ game.revealCells(changed)
+
+ for cell in changed {
+ game.squares[cell.row][cell.col].updatedAt = now
+ persistCell(atRow: cell.row, atCol: cell.col)
+ enqueueIfLocal(origin: origin, row: cell.row, col: cell.col)
+ }
+ }
+
+ func clearCells(_ cells: [Puzzle.Cell], origin: Origin) {
+ let now = timestamp(for: origin)
+ var changed: [Puzzle.Cell] = []
+
+ for cell in cells {
+ guard !cell.isBlock else { continue }
+ guard shouldApply(row: cell.row, col: cell.col, incomingTimestamp: now) else { continue }
+ changed.append(cell)
+ }
+
+ guard !changed.isEmpty else { return }
+ game.clearCells(changed)
+
+ for cell in changed {
+ game.squares[cell.row][cell.col].updatedAt = now
+ game.squares[cell.row][cell.col].letterAuthorID = nil
+ persistCell(atRow: cell.row, atCol: cell.col)
+ enqueueIfLocal(origin: origin, row: cell.row, col: cell.col)
+ }
+ }
+
+ // MARK: - Helpers
+
+ private func timestamp(for origin: Origin) -> Date {
+ switch origin {
+ case .local:
+ return Date()
+ case .remote(let timestamp, _):
+ return timestamp
+ }
+ }
+
+ private func authorID(for origin: Origin) -> String? {
+ switch origin {
+ case .local:
+ return nil // Will be set to the local user's CKRecord.ID in Phase E
+ case .remote(_, let authorID):
+ return authorID
+ }
+ }
+
+ /// LWW gate: returns `true` if the incoming timestamp is newer than (or
+ /// equal to) the cell's current `updatedAt`. Cells that have never been
+ /// stamped always accept the incoming value.
+ private func shouldApply(row: Int, col: Int, incomingTimestamp: Date) -> Bool {
+ guard let existing = game.squares[row][col].updatedAt else { return true }
+ return incomingTimestamp >= existing
+ }
+
+ // MARK: - Core Data persistence
+
+ private func persistCell(atRow row: Int, atCol col: Int) {
+ let square = game.squares[row][col]
+ guard let cellEntity = findCellEntity(row: row, col: col) else { return }
+
+ cellEntity.letter = square.entry
+ let (kind, wrong) = encodeMark(square.mark)
+ cellEntity.markKind = kind
+ cellEntity.checkedWrong = wrong
+ cellEntity.updatedAt = square.updatedAt
+ cellEntity.letterAuthorID = square.letterAuthorID
+
+ gameEntity.updatedAt = Date()
+ saveContext()
+ }
+
+ private func findCellEntity(row: Int, col: Int) -> CellEntity? {
+ let cellEntities = (gameEntity.cells as? Set<CellEntity>) ?? []
+ return cellEntities.first { Int($0.row) == row && Int($0.col) == col }
+ }
+
+ private func saveContext() {
+ guard context.hasChanges else { return }
+ do {
+ try context.save()
+ } catch {
+ print("GameMutator: failed to save context: \(error)")
+ }
+ }
+
+ private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) {
+ switch mark {
+ case .none:
+ return (0, false)
+ case .pen(let wrong):
+ return (1, wrong)
+ case .pencil(let wrong):
+ return (2, wrong)
+ case .revealed:
+ return (3, false)
+ }
+ }
+
+ // MARK: - Outbox
+
+ private func enqueueIfLocal(origin: Origin, row: Int, col: Int) {
+ guard case .local = origin else { return }
+
+ let square = game.squares[row][col]
+ guard let gameID = gameEntity.id else { return }
+ let recordName = RecordSerializer.recordName(forCellInGame: gameID, row: row, col: col)
+
+ let (markKind, checkedWrong) = encodeMark(square.mark)
+ let payload = PendingChangePayload(
+ recordType: .cell,
+ recordName: recordName,
+ letter: square.entry,
+ markKind: markKind,
+ checkedWrong: checkedWrong,
+ updatedAt: square.updatedAt,
+ letterAuthorID: square.letterAuthorID,
+ parentGameRecordName: RecordSerializer.recordName(forGameID: gameID)
+ )
+
+ do {
+ let jsonData = try JSONEncoder().encode(payload)
+ let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}"
+ PendingChangeEntity.upsert(
+ recordName: recordName,
+ recordType: "Cell",
+ payload: jsonString,
+ in: context
+ )
+ saveContext()
+ } catch {
+ print("GameMutator: failed to encode pending change: \(error)")
+ }
+
+ onLocalMutation?()
+ }
+}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -3,23 +3,18 @@ import Foundation
import Observation
/// Repository over the local Core Data store. Owns the lifecycle of the
-/// "current game" — loading it on launch (or seeding from `sample.xd` on
-/// first run) and write-throughing every in-memory mutation back to the
-/// persistent store.
-///
-/// Persistence is whole-game write-through: any change to `Game.entries`
-/// or `Game.marks` triggers a save of every cell. For puzzle-sized data
-/// (a few hundred cells at most) the cost is negligible, and it lets us
-/// skip per-cell dirty tracking entirely. If write throughput ever shows
-/// up as a problem, debouncing or per-cell tracking can be layered on
-/// without changing the call sites.
+/// current game — loading it on launch (or seeding from `sample.xd` on
+/// first run). Persistence of individual cell mutations is handled by
+/// `GameMutator`.
@MainActor
+@Observable
final class GameStore {
- private let persistence: PersistenceController
+ let persistence: PersistenceController
private var context: NSManagedObjectContext { persistence.viewContext }
- private var currentGame: Game?
- private var currentEntity: GameEntity?
+ private(set) var currentGame: Game?
+ private(set) var currentMutator: GameMutator?
+ private(set) var currentEntity: GameEntity?
init(persistence: PersistenceController) {
self.persistence = persistence
@@ -30,10 +25,11 @@ final class GameStore {
case persistedSourceMissing
}
- /// Returns the single current game, creating it from `sample.xd` on
- /// first launch. Subsequent launches rehydrate the in-memory `Game`
- /// from the stored `CellEntity` rows so any prior progress is restored.
- func loadOrCreateCurrentGame() throws -> Game {
+ /// Returns the single current game and its mutator, creating from
+ /// `sample.xd` on first launch. Subsequent launches rehydrate the
+ /// in-memory `Game` from the stored `CellEntity` rows so any prior
+ /// progress is restored.
+ func loadOrCreateCurrentGame() throws -> (Game, GameMutator) {
let entity: GameEntity
let puzzle: Puzzle
@@ -51,11 +47,13 @@ final class GameStore {
let game = Game(puzzle: puzzle)
restore(game: game, from: entity)
+ let mutator = GameMutator(game: game, gameEntity: entity, context: context)
+
currentGame = game
+ currentMutator = mutator
currentEntity = entity
- startObserving(game)
- return game
+ return (game, mutator)
}
// MARK: - Loading
@@ -76,12 +74,14 @@ final class GameStore {
let puzzle = Puzzle(xd: xd)
let now = Date()
+ let gameID = UUID()
let entity = GameEntity(context: context)
- entity.id = UUID()
+ entity.id = gameID
entity.title = puzzle.title
entity.puzzleSource = source
entity.createdAt = now
entity.updatedAt = now
+ entity.ckRecordName = "game-\(gameID.uuidString)"
for row in puzzle.cells {
for cell in row where !cell.isBlock {
@@ -91,6 +91,7 @@ final class GameStore {
cellEntity.letter = ""
cellEntity.markKind = 0
cellEntity.checkedWrong = false
+ cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
cellEntity.game = entity
}
}
@@ -105,86 +106,49 @@ final class GameStore {
let r = Int(cellEntity.row)
let c = Int(cellEntity.col)
guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue }
- game.entries[r][c] = cellEntity.letter ?? ""
- game.marks[r][c] = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong)
+ game.squares[r][c].entry = cellEntity.letter ?? ""
+ game.squares[r][c].mark = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong)
+ game.squares[r][c].updatedAt = cellEntity.updatedAt
+ game.squares[r][c].letterAuthorID = cellEntity.letterAuthorID
}
}
- // MARK: - Observation
-
- /// Registers a one-shot observation on the game's mutable state. The
- /// callback fires once on the next change, persists, and re-registers.
- /// `withObservationTracking` is intentionally one-shot; the re-arm in
- /// `persistCurrent` is what makes this run for the lifetime of the game.
- private func startObserving(_ game: Game) {
- withObservationTracking {
- // Touch the properties we care about so the tracker knows to
- // watch them. Mutating an element of `entries` or `marks` is a
- // setter call on the array property under the hood, which
- // @Observable picks up.
- _ = game.entries
- _ = game.marks
- } onChange: { [weak self] in
- Task { @MainActor [weak self] in
- self?.persistCurrent()
- }
- }
- }
+ // MARK: - Remote refresh
- private func persistCurrent() {
+ /// Re-reads CellEntity values from Core Data into the in-memory Game.
+ /// Called on the MainActor after the sync engine applies remote changes
+ /// to the background context (which merges into viewContext via
+ /// `automaticallyMergesChangesFromParent`).
+ func refreshFromRemote() {
guard let game = currentGame, let entity = currentEntity else { return }
+ // Fault the relationship to pick up merged background changes
+ context.refresh(entity, mergeChanges: true)
+
let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
for cellEntity in cellEntities {
let r = Int(cellEntity.row)
let c = Int(cellEntity.col)
guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue }
- let newLetter = game.entries[r][c]
- let (kind, wrong) = encodeMark(game.marks[r][c])
- if cellEntity.letter != newLetter {
- cellEntity.letter = newLetter
- }
- if cellEntity.markKind != kind {
- cellEntity.markKind = kind
- }
- if cellEntity.checkedWrong != wrong {
- cellEntity.checkedWrong = wrong
- }
- }
- entity.updatedAt = Date()
-
- if context.hasChanges {
- do {
- try context.save()
- } catch {
- // Surfacing this to the user properly is a v1 problem; for
- // now log so we notice in development.
- print("GameStore: failed to save context: \(error)")
+
+ let remoteTimestamp = cellEntity.updatedAt
+ let remoteAuthorID = cellEntity.letterAuthorID
+ let remoteLetter = cellEntity.letter ?? ""
+ let remoteMark = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong)
+
+ // Only apply if the remote timestamp is newer (LWW)
+ let localTimestamp = game.squares[r][c].updatedAt
+ if let remote = remoteTimestamp, (localTimestamp == nil || remote >= localTimestamp!) {
+ game.squares[r][c].entry = remoteLetter
+ game.squares[r][c].mark = remoteMark
+ game.squares[r][c].updatedAt = remoteTimestamp
+ game.squares[r][c].letterAuthorID = remoteAuthorID
}
}
-
- startObserving(game)
}
// MARK: - CellMark coding
- /// Maps `CellMark` onto two storage columns: a small integer kind and a
- /// boolean for the orthogonal `checkedWrong` dimension. `.revealed`
- /// always stores `checkedWrong = false` since revealed cells can't be
- /// wrong; the column is ignored on decode for that case.
- private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) {
- switch mark {
- case .none:
- return (0, false)
- case .pen(let wrong):
- return (1, wrong)
- case .pencil(let wrong):
- return (2, wrong)
- case .revealed:
- return (3, false)
- }
- }
-
private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark {
switch kind {
case 1: return .pen(checkedWrong: checkedWrong)
diff --git a/Crossmate/Persistence/PendingChange+Helpers.swift b/Crossmate/Persistence/PendingChange+Helpers.swift
@@ -0,0 +1,40 @@
+import CoreData
+import Foundation
+
+extension PendingChangeEntity {
+ /// Upserts a pending change for the given record name. If a row with the
+ /// same `recordName` already exists, its payload and timestamp are updated
+ /// (coalescing rapid edits into a single outbox entry). Otherwise a new
+ /// row is inserted.
+ static func upsert(
+ recordName: String,
+ recordType: String,
+ payload: String,
+ in context: NSManagedObjectContext
+ ) {
+ let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
+ request.predicate = NSPredicate(format: "recordName == %@", recordName)
+ request.fetchLimit = 1
+
+ let entity: PendingChangeEntity
+ if let existing = try? context.fetch(request).first {
+ entity = existing
+ } else {
+ entity = PendingChangeEntity(context: context)
+ entity.recordName = recordName
+ entity.recordType = recordType
+ }
+
+ entity.payload = payload
+ entity.createdAt = Date()
+ }
+
+ /// Fetches up to `limit` pending changes ordered by creation time
+ /// (oldest first).
+ static func drain(limit: Int, in context: NSManagedObjectContext) -> [PendingChangeEntity] {
+ let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
+ request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
+ request.fetchLimit = limit
+ return (try? context.fetch(request)) ?? []
+ }
+}
diff --git a/Crossmate/Sync/PendingChangePayload.swift b/Crossmate/Sync/PendingChangePayload.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+/// Codable snapshot stored as JSON in `PendingChangeEntity.payload`. Captures
+/// everything the upload loop needs to build a `CKRecord` without going back
+/// to Core Data or the in-memory model.
+struct PendingChangePayload: Codable {
+ enum RecordType: String, Codable {
+ case game = "Game"
+ case cell = "Cell"
+ }
+
+ let recordType: RecordType
+ let recordName: String
+
+ // Cell fields (nil when recordType == .game)
+ var letter: String?
+ var markKind: Int16?
+ var checkedWrong: Bool?
+ var updatedAt: Date?
+ var letterAuthorID: String?
+ var parentGameRecordName: String?
+
+ // Game fields (nil when recordType == .cell)
+ var title: String?
+ var puzzleSource: String?
+ var completedAt: Date?
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -0,0 +1,209 @@
+import CloudKit
+import CoreData
+import Foundation
+
+/// Pure-function helpers for converting between the app's Core Data / in-memory
+/// models and CloudKit `CKRecord` objects. Stateless — all context is passed in.
+enum RecordSerializer {
+ static let zoneName = "CrossmateZone"
+
+ // MARK: - Record names
+
+ static func recordName(forGameID gameID: UUID) -> String {
+ "game-\(gameID.uuidString)"
+ }
+
+ static func recordName(forCellInGame gameID: UUID, row: Int, col: Int) -> String {
+ "cell-\(gameID.uuidString)-\(row)-\(col)"
+ }
+
+ // MARK: - Zone
+
+ static func zone() -> CKRecordZone {
+ CKRecordZone(zoneName: zoneName)
+ }
+
+ static func zoneID() -> CKRecordZone.ID {
+ CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
+ }
+
+ // MARK: - Building CKRecords from payloads
+
+ static func gameRecord(
+ from payload: PendingChangePayload,
+ zone: CKRecordZone.ID,
+ systemFields: Data?
+ ) -> CKRecord {
+ let record = restoreOrCreate(
+ recordType: "Game",
+ recordName: payload.recordName,
+ zone: zone,
+ systemFields: systemFields
+ )
+
+ record["title"] = payload.title as CKRecordValue?
+ record["completedAt"] = payload.completedAt as CKRecordValue?
+
+ // Puzzle source is stored as a CKAsset (written to a temp file)
+ // so it doesn't count against the per-record field size limit.
+ if let source = payload.puzzleSource {
+ let tempURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("xd")
+ try? source.write(to: tempURL, atomically: true, encoding: .utf8)
+ record["puzzleSource"] = CKAsset(fileURL: tempURL)
+ }
+
+ return record
+ }
+
+ static func cellRecord(
+ from payload: PendingChangePayload,
+ zone: CKRecordZone.ID,
+ systemFields: Data?
+ ) -> CKRecord {
+ let record = restoreOrCreate(
+ recordType: "Cell",
+ recordName: payload.recordName,
+ zone: zone,
+ systemFields: systemFields
+ )
+
+ record["letter"] = payload.letter as CKRecordValue?
+ record["markKind"] = (payload.markKind ?? 0) as CKRecordValue
+ record["checkedWrong"] = (payload.checkedWrong ?? false) as CKRecordValue
+ record["updatedAt"] = payload.updatedAt as CKRecordValue?
+ record["letterAuthorID"] = payload.letterAuthorID as CKRecordValue?
+
+ if let parentName = payload.parentGameRecordName {
+ let parentID = CKRecord.ID(recordName: parentName, zoneID: zone)
+ record.parent = CKRecord.Reference(recordID: parentID, action: .none)
+ }
+
+ return record
+ }
+
+ // MARK: - Applying incoming CKRecords to Core Data
+
+ static func applyGameRecord(
+ _ record: CKRecord,
+ to context: NSManagedObjectContext
+ ) -> GameEntity {
+ let recordName = record.recordID.recordName
+ let entity = fetchOrCreate(
+ entityName: "GameEntity",
+ recordName: recordName,
+ in: context
+ ) as! GameEntity
+
+ entity.ckRecordName = recordName
+ entity.ckSystemFields = encodeSystemFields(of: record)
+ entity.title = record["title"] as? String ?? entity.title
+ entity.completedAt = record["completedAt"] as? Date
+
+ if let asset = record["puzzleSource"] as? CKAsset,
+ let fileURL = asset.fileURL,
+ let source = try? String(contentsOf: fileURL, encoding: .utf8) {
+ entity.puzzleSource = source
+ }
+
+ return entity
+ }
+
+ static func applyCellRecord(
+ _ record: CKRecord,
+ to context: NSManagedObjectContext,
+ game: GameEntity
+ ) -> CellEntity {
+ let recordName = record.recordID.recordName
+ let entity = fetchOrCreateCell(
+ recordName: recordName,
+ in: context,
+ game: game
+ )
+
+ entity.ckRecordName = recordName
+ entity.ckSystemFields = encodeSystemFields(of: record)
+ entity.letter = record["letter"] as? String ?? ""
+ entity.markKind = record["markKind"] as? Int16 ?? 0
+ entity.checkedWrong = record["checkedWrong"] as? Bool ?? false
+ entity.updatedAt = record["updatedAt"] as? Date
+ entity.letterAuthorID = record["letterAuthorID"] as? String
+
+ return entity
+ }
+
+ // MARK: - System fields encode/decode
+
+ static func encodeSystemFields(of record: CKRecord) -> Data? {
+ let coder = NSKeyedArchiver(requiringSecureCoding: true)
+ record.encodeSystemFields(with: coder)
+ coder.finishEncoding()
+ return coder.encodedData
+ }
+
+ static func decodeRecord(from data: Data) -> CKRecord? {
+ guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
+ coder.requiresSecureCoding = true
+ let record = CKRecord(coder: coder)
+ coder.finishDecoding()
+ return record
+ }
+
+ // MARK: - Private helpers
+
+ /// Restores a `CKRecord` from archived system fields (preserving the
+ /// server change tag) or creates a fresh one if no archive is available.
+ private static func restoreOrCreate(
+ recordType: String,
+ recordName: String,
+ zone: CKRecordZone.ID,
+ systemFields: Data?
+ ) -> CKRecord {
+ if let data = systemFields, let restored = decodeRecord(from: data) {
+ return restored
+ }
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
+ return CKRecord(recordType: recordType, recordID: recordID)
+ }
+
+ private static func fetchOrCreate(
+ entityName: String,
+ recordName: String,
+ in context: NSManagedObjectContext
+ ) -> NSManagedObject {
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
+ request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ request.fetchLimit = 1
+ if let existing = try? context.fetch(request).first {
+ return existing
+ }
+ return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context)
+ }
+
+ private static func fetchOrCreateCell(
+ recordName: String,
+ in context: NSManagedObjectContext,
+ game: GameEntity
+ ) -> CellEntity {
+ // Try to find by ckRecordName first
+ let cells = (game.cells as? Set<CellEntity>) ?? []
+ if let existing = cells.first(where: { $0.ckRecordName == recordName }) {
+ return existing
+ }
+
+ // Parse row/col from record name (format: "cell-<uuid>-<row>-<col>")
+ let parts = recordName.split(separator: "-")
+ if parts.count >= 2,
+ let row = Int16(parts[parts.count - 2]),
+ let col = Int16(parts[parts.count - 1]),
+ let existing = cells.first(where: { $0.row == row && $0.col == col }) {
+ return existing
+ }
+
+ // Create new
+ let entity = CellEntity(context: context)
+ entity.game = game
+ return entity
+ }
+}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -0,0 +1,474 @@
+import CloudKit
+import CoreData
+import Foundation
+
+/// Owns the CloudKit container, custom zone, and sync lifecycle. All CloudKit
+/// operations run on this actor's serial executor, keeping token reads and
+/// writes race-free.
+actor SyncEngine {
+ let container: CKContainer
+ let privateDatabase: CKDatabase
+ let persistence: PersistenceController
+
+ private let zoneID: CKRecordZone.ID
+
+ /// Called on the MainActor after remote changes have been applied to
+ /// Core Data. Wired up in CrossmateApp to call GameStore.refreshFromRemote.
+ private var onRemoteChangesApplied: (@MainActor @Sendable () -> Void)?
+
+ func setOnRemoteChangesApplied(_ callback: @MainActor @Sendable @escaping () -> Void) {
+ onRemoteChangesApplied = callback
+ }
+
+ init(container: CKContainer, persistence: PersistenceController) {
+ self.container = container
+ self.privateDatabase = container.privateCloudDatabase
+ self.persistence = persistence
+ self.zoneID = RecordSerializer.zoneID()
+ }
+
+ // MARK: - Bootstrap
+
+ /// Ensures the custom zone exists in the private database. Idempotent —
+ /// safe to call on every launch. Skips the network call if the zone has
+ /// already been created (tracked via `SyncStateEntity`).
+ func bootstrap() async throws {
+ let context = persistence.container.newBackgroundContext()
+ let alreadyCreated: Bool = context.performAndWait {
+ SyncStateEntity.current(in: context).zoneCreated
+ }
+
+ guard !alreadyCreated else { return }
+
+ let zone = RecordSerializer.zone()
+ let operation = CKModifyRecordZonesOperation(
+ recordZonesToSave: [zone],
+ recordZoneIDsToDelete: nil
+ )
+ operation.qualityOfService = .utility
+
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ operation.modifyRecordZonesResultBlock = { result in
+ switch result {
+ case .success:
+ continuation.resume()
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+ privateDatabase.add(operation)
+ }
+
+ context.performAndWait {
+ SyncStateEntity.current(in: context).zoneCreated = true
+ try? context.save()
+ }
+
+ // Create the database subscription for silent push notifications
+ try await createSubscriptionIfNeeded(context: context)
+ }
+
+ /// Creates a `CKDatabaseSubscription` for silent push so that remote
+ /// changes trigger a fetch. Idempotent — skipped if already created.
+ private func createSubscriptionIfNeeded(context: NSManagedObjectContext) async throws {
+ let alreadyCreated: Bool = context.performAndWait {
+ SyncStateEntity.current(in: context).subscriptionCreated
+ }
+ guard !alreadyCreated else { return }
+
+ let subscription = CKDatabaseSubscription(subscriptionID: "private-changes")
+ let notificationInfo = CKSubscription.NotificationInfo()
+ notificationInfo.shouldSendContentAvailable = true
+ subscription.notificationInfo = notificationInfo
+
+ let operation = CKModifySubscriptionsOperation(
+ subscriptionsToSave: [subscription],
+ subscriptionIDsToDelete: nil
+ )
+ operation.qualityOfService = .utility
+
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ operation.modifySubscriptionsResultBlock = { result in
+ switch result {
+ case .success:
+ continuation.resume()
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+ privateDatabase.add(operation)
+ }
+
+ context.performAndWait {
+ SyncStateEntity.current(in: context).subscriptionCreated = true
+ try? context.save()
+ }
+ }
+
+ // MARK: - Push
+
+ /// Drains the `PendingChangeEntity` outbox and pushes records to CloudKit.
+ /// Loops until the outbox is empty, processing up to 400 records per batch
+ /// (the CloudKit per-operation limit).
+ func pushChanges() async throws {
+ let context = persistence.container.newBackgroundContext()
+
+ while true {
+ let batch: [(recordName: String, recordType: String, payload: PendingChangePayload, systemFields: Data?)] =
+ context.performAndWait {
+ let pending = PendingChangeEntity.drain(limit: 400, in: context)
+ guard !pending.isEmpty else { return [] }
+
+ return pending.compactMap { entity in
+ guard let payloadJSON = entity.payload,
+ let recordName = entity.recordName,
+ let recordType = entity.recordType,
+ let data = payloadJSON.data(using: .utf8),
+ let payload = try? JSONDecoder().decode(PendingChangePayload.self, from: data)
+ else { return nil }
+
+ // Look up ckSystemFields from the corresponding entity
+ let systemFields = self.lookupSystemFields(
+ recordName: recordName,
+ recordType: recordType,
+ in: context
+ )
+
+ return (recordName, recordType, payload, systemFields)
+ }
+ }
+
+ guard !batch.isEmpty else { break }
+
+ // Build CKRecords
+ let records: [CKRecord] = batch.map { item in
+ switch item.payload.recordType {
+ case .game:
+ return RecordSerializer.gameRecord(
+ from: item.payload,
+ zone: zoneID,
+ systemFields: item.systemFields
+ )
+ case .cell:
+ return RecordSerializer.cellRecord(
+ from: item.payload,
+ zone: zoneID,
+ systemFields: item.systemFields
+ )
+ }
+ }
+
+ // Push via CKModifyRecordsOperation
+ let perRecordResults = try await pushRecords(records)
+
+ // Process results on the background context
+ context.performAndWait {
+ for (recordID, result) in perRecordResults {
+ let recordName = recordID.recordName
+ switch result {
+ case .success(let savedRecord):
+ // Write ckSystemFields back to the entity
+ self.writeBackSystemFields(
+ record: savedRecord,
+ recordName: recordName,
+ in: context
+ )
+ // Delete the PendingChange row
+ self.deletePendingChange(recordName: recordName, in: context)
+
+ case .failure(let error):
+ self.handlePushError(
+ error: error,
+ recordName: recordName,
+ in: context
+ )
+ }
+ }
+ try? context.save()
+ }
+ }
+ }
+
+ // MARK: - Fetch
+
+ /// Fetches remote changes using a two-step process:
+ /// 1. `CKFetchDatabaseChangesOperation` to discover which zones changed.
+ /// 2. `CKFetchRecordZoneChangesOperation` to pull changed records from
+ /// our zone.
+ /// Applies incoming records to Core Data, persists change tokens, then
+ /// hops to MainActor to refresh the in-memory Game.
+ func fetchChanges() async throws {
+ let context = persistence.container.newBackgroundContext()
+
+ // Step 1: Fetch database-level changes to discover changed zones
+ let databaseToken: CKServerChangeToken? = context.performAndWait {
+ SyncStateEntity.current(in: context).decodedPrivateDatabaseToken
+ }
+
+ let changedZoneIDs = try await fetchDatabaseChanges(token: databaseToken, context: context)
+
+ // Only proceed if our zone has changes
+ guard changedZoneIDs.contains(zoneID) else { return }
+
+ // Step 2: Fetch zone-level changes
+ let zoneToken: CKServerChangeToken? = context.performAndWait {
+ SyncStateEntity.current(in: context).decodedPrivateZoneToken
+ }
+
+ let incomingRecords = try await fetchZoneChanges(token: zoneToken, context: context)
+
+ guard !incomingRecords.isEmpty else { return }
+
+ // Step 3: Apply incoming records to Core Data
+ context.performAndWait {
+ self.applyIncomingRecords(incomingRecords, in: context)
+ try? context.save()
+ }
+
+ // Step 4: Hop to MainActor to refresh the in-memory Game
+ if let onRemoteChangesApplied {
+ await onRemoteChangesApplied()
+ }
+ }
+
+ // MARK: - Fetch helpers
+
+ private func fetchDatabaseChanges(
+ token: CKServerChangeToken?,
+ context: NSManagedObjectContext
+ ) async throws -> [CKRecordZone.ID] {
+ let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken: token)
+ operation.qualityOfService = .utility
+
+ var changedZoneIDs: [CKRecordZone.ID] = []
+
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecordZone.ID], Error>) in
+ operation.recordZoneWithIDChangedBlock = { zoneID in
+ changedZoneIDs.append(zoneID)
+ }
+
+ operation.fetchDatabaseChangesResultBlock = { result in
+ switch result {
+ case .success(let (serverToken, _)):
+ // Persist the database token
+ context.performAndWait {
+ SyncStateEntity.current(in: context).decodedPrivateDatabaseToken = serverToken
+ try? context.save()
+ }
+ continuation.resume(returning: changedZoneIDs)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ privateDatabase.add(operation)
+ }
+ }
+
+ private func fetchZoneChanges(
+ token: CKServerChangeToken?,
+ context: NSManagedObjectContext
+ ) async throws -> [CKRecord] {
+ let options = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
+ options.previousServerChangeToken = token
+
+ let operation = CKFetchRecordZoneChangesOperation(
+ recordZoneIDs: [zoneID],
+ configurationsByRecordZoneID: [zoneID: options]
+ )
+ operation.qualityOfService = .utility
+
+ var incomingRecords: [CKRecord] = []
+
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord], Error>) in
+ operation.recordWasChangedBlock = { _, result in
+ if case .success(let record) = result {
+ incomingRecords.append(record)
+ }
+ }
+
+ operation.recordZoneFetchResultBlock = { _, result in
+ switch result {
+ case .success(let (serverToken, _, _)):
+ // Persist the zone token
+ context.performAndWait {
+ SyncStateEntity.current(in: context).decodedPrivateZoneToken = serverToken
+ try? context.save()
+ }
+ case .failure(let error):
+ print("SyncEngine: zone fetch error: \(error)")
+ }
+ }
+
+ operation.fetchRecordZoneChangesResultBlock = { result in
+ switch result {
+ case .success:
+ continuation.resume(returning: incomingRecords)
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ privateDatabase.add(operation)
+ }
+ }
+
+ private nonisolated func applyIncomingRecords(
+ _ records: [CKRecord],
+ in context: NSManagedObjectContext
+ ) {
+ // First pass: apply Game records so parent entities exist for cells
+ for record in records where record.recordType == "Game" {
+ let _ = RecordSerializer.applyGameRecord(record, to: context)
+ }
+
+ // Second pass: apply Cell records
+ for record in records where record.recordType == "Cell" {
+ guard let parentRef = record.parent else { continue }
+ let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameRequest.predicate = NSPredicate(format: "ckRecordName == %@", parentRef.recordID.recordName)
+ gameRequest.fetchLimit = 1
+ guard let gameEntity = try? context.fetch(gameRequest).first else { continue }
+ let _ = RecordSerializer.applyCellRecord(record, to: context, game: gameEntity)
+ }
+ }
+
+ // MARK: - Push helpers
+
+ private func pushRecords(_ records: [CKRecord]) async throws -> [CKRecord.ID: Result<CKRecord, Error>] {
+ let operation = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: nil)
+ operation.savePolicy = .changedKeys
+ operation.isAtomic = false
+ operation.qualityOfService = .utility
+
+ var perRecordResults: [CKRecord.ID: Result<CKRecord, Error>] = [:]
+
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CKRecord.ID: Result<CKRecord, Error>], Error>) in
+ operation.perRecordSaveBlock = { recordID, result in
+ perRecordResults[recordID] = result
+ }
+
+ operation.modifyRecordsResultBlock = { result in
+ switch result {
+ case .success:
+ continuation.resume(returning: perRecordResults)
+ case .failure(let error):
+ // If the overall operation failed but we got per-record
+ // results, return those so we can handle partial success.
+ if !perRecordResults.isEmpty {
+ continuation.resume(returning: perRecordResults)
+ } else {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+
+ privateDatabase.add(operation)
+ }
+ }
+
+ private nonisolated func lookupSystemFields(
+ recordName: String,
+ recordType: String,
+ in context: NSManagedObjectContext
+ ) -> Data? {
+ let entityName = recordType == "Game" ? "GameEntity" : "CellEntity"
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
+ request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ request.fetchLimit = 1
+ request.propertiesToFetch = ["ckSystemFields"]
+ guard let entity = try? context.fetch(request).first else { return nil }
+ return entity.value(forKey: "ckSystemFields") as? Data
+ }
+
+ private nonisolated func writeBackSystemFields(
+ record: CKRecord,
+ recordName: String,
+ in context: NSManagedObjectContext
+ ) {
+ let entityName = record.recordType == "Game" ? "GameEntity" : "CellEntity"
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
+ request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
+ request.fetchLimit = 1
+ guard let entity = try? context.fetch(request).first else { return }
+
+ entity.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields")
+ entity.setValue(Date(), forKey: "lastSyncedAt")
+ }
+
+ private nonisolated func deletePendingChange(recordName: String, in context: NSManagedObjectContext) {
+ let request = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
+ request.predicate = NSPredicate(format: "recordName == %@", recordName)
+ request.fetchLimit = 1
+ if let entity = try? context.fetch(request).first {
+ context.delete(entity)
+ }
+ }
+
+ private nonisolated func handlePushError(
+ error: Error,
+ recordName: String,
+ in context: NSManagedObjectContext
+ ) {
+ guard let ckError = error as? CKError else {
+ print("SyncEngine: non-CK error pushing \(recordName): \(error)")
+ return
+ }
+
+ switch ckError.code {
+ case .serverRecordChanged:
+ // The server has a newer version. Extract the server record and
+ // merge using LWW on our `updatedAt` field.
+ guard let serverRecord = ckError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else {
+ print("SyncEngine: serverRecordChanged but no server record for \(recordName)")
+ return
+ }
+
+ // Read the pending change to get our local values
+ let pendingRequest = NSFetchRequest<PendingChangeEntity>(entityName: "PendingChangeEntity")
+ pendingRequest.predicate = NSPredicate(format: "recordName == %@", recordName)
+ pendingRequest.fetchLimit = 1
+ guard let pending = try? context.fetch(pendingRequest).first,
+ let payloadJSON = pending.payload,
+ let data = payloadJSON.data(using: .utf8),
+ let localPayload = try? JSONDecoder().decode(PendingChangePayload.self, from: data)
+ else { return }
+
+ let serverUpdatedAt = serverRecord["updatedAt"] as? Date ?? .distantPast
+ let localUpdatedAt = localPayload.updatedAt ?? .distantPast
+
+ if localUpdatedAt >= serverUpdatedAt {
+ // Local wins — re-enqueue with the server record's system
+ // fields so the next push succeeds.
+ let systemFields = RecordSerializer.encodeSystemFields(of: serverRecord)
+ writeBackSystemFields(record: serverRecord, recordName: recordName, in: context)
+
+ // The pending change stays in the outbox; next loop iteration
+ // will pick it up with the updated system fields.
+ _ = systemFields
+ } else {
+ // Server wins — drop our pending change and apply the server
+ // record to Core Data.
+ context.delete(pending)
+ if serverRecord.recordType == "Game" {
+ let _ = RecordSerializer.applyGameRecord(serverRecord, to: context)
+ } else {
+ // Find the parent game for the cell
+ if let parentRef = serverRecord.parent {
+ let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameRequest.predicate = NSPredicate(format: "ckRecordName == %@", parentRef.recordID.recordName)
+ gameRequest.fetchLimit = 1
+ if let gameEntity = try? context.fetch(gameRequest).first {
+ let _ = RecordSerializer.applyCellRecord(serverRecord, to: context, game: gameEntity)
+ }
+ }
+ }
+ }
+
+ default:
+ // For other errors (network, throttle, etc.), leave the pending
+ // change in the outbox for the next push attempt.
+ print("SyncEngine: error pushing \(recordName): \(ckError)")
+ }
+ }
+}
diff --git a/Crossmate/Sync/SyncState+Helpers.swift b/Crossmate/Sync/SyncState+Helpers.swift
@@ -0,0 +1,40 @@
+import CloudKit
+import CoreData
+import Foundation
+
+extension SyncStateEntity {
+ /// Returns the singleton sync-state row, creating it on first access.
+ static func current(in context: NSManagedObjectContext) -> SyncStateEntity {
+ let request = NSFetchRequest<SyncStateEntity>(entityName: "SyncStateEntity")
+ request.predicate = NSPredicate(format: "id == 0")
+ request.fetchLimit = 1
+
+ if let existing = try? context.fetch(request).first {
+ return existing
+ }
+
+ let entity = SyncStateEntity(context: context)
+ entity.id = 0
+ return entity
+ }
+
+ // MARK: - Change token coding
+
+ var decodedPrivateDatabaseToken: CKServerChangeToken? {
+ get { privateDatabaseToken.flatMap(Self.decodeToken) }
+ set { privateDatabaseToken = newValue.flatMap(Self.encodeToken) }
+ }
+
+ var decodedPrivateZoneToken: CKServerChangeToken? {
+ get { privateZoneToken.flatMap(Self.decodeToken) }
+ set { privateZoneToken = newValue.flatMap(Self.encodeToken) }
+ }
+
+ private static func encodeToken(_ token: CKServerChangeToken) -> Data? {
+ try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true)
+ }
+
+ private static func decodeToken(_ data: Data) -> CKServerChangeToken? {
+ try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data)
+ }
+}
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -14,8 +14,8 @@ struct GridView: View {
let c = index % width
CellView(
cell: session.puzzle.cells[r][c],
- entry: session.game.entries[r][c],
- mark: session.game.marks[r][c],
+ entry: session.game.squares[r][c].entry,
+ mark: session.game.squares[r][c].mark,
isSelected: session.selectedRow == r && session.selectedCol == c,
isHighlighted: session.isInCurrentWord(row: r, col: c),
specialKind: session.puzzle.specialKind
diff --git a/Scripts/test-unit.sh b/Scripts/test-unit.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+set -euo pipefail
+
+MAJOR="${1:-26}"
+RUNTIME=$(xcrun simctl list runtimes available \
+ | grep "iOS ${MAJOR}\." \
+ | sed 's/.*iOS \([0-9.]*\).*/\1/' \
+ | sort -t. -k1,1n -k2,2n \
+ | tail -1)
+
+if [ -z "$RUNTIME" ]; then
+ echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
+ exit 1
+fi
+
+DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "iPhone" \
+ | head -1 \
+ | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/')
+
+if [ -z "$DEVICE" ]; then
+ echo "No available iPhone simulator found for iOS ${RUNTIME}." >&2
+ exit 1
+fi
+
+echo "Using ${DEVICE}, iOS ${RUNTIME}"
+xcodebuild test \
+ -scheme "Crossmate" \
+ -destination "platform=iOS Simulator,name=${DEVICE},OS=${RUNTIME}" \
+ -only-testing:"Crossmate Unit Tests" \
+ 2>&1
diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift
@@ -0,0 +1,67 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Creates a fresh PersistenceController with in-memory storage for isolated testing.
+@MainActor
+func makeTestPersistence() -> PersistenceController {
+ PersistenceController(inMemory: true)
+}
+
+/// 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).
+@MainActor
+func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceController) {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ let source = """
+ Title: Test Puzzle
+ Author: Test
+
+
+ ABC
+ D#E
+ FGH
+
+
+ A1. Across 1 ~ ABC
+ A4. Across 4 ~ DE
+ A5. Across 5 ~ FGH
+ D1. Down 1 ~ ADF
+ D2. Down 2 ~ BG
+ D3. Down 3 ~ CEH
+ """
+
+ let xd = try XD.parse(source)
+ let puzzle = Puzzle(xd: xd)
+ let game = Game(puzzle: puzzle)
+
+ let gameID = UUID()
+ let entity = GameEntity(context: context)
+ entity.id = gameID
+ entity.title = puzzle.title
+ entity.puzzleSource = source
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.ckRecordName = "game-\(gameID.uuidString)"
+
+ for row in puzzle.cells {
+ for cell in row where !cell.isBlock {
+ let cellEntity = CellEntity(context: context)
+ cellEntity.row = Int16(cell.row)
+ cellEntity.col = Int16(cell.col)
+ cellEntity.letter = ""
+ cellEntity.markKind = 0
+ cellEntity.checkedWrong = false
+ cellEntity.ckRecordName = "cell-\(gameID.uuidString)-\(cell.row)-\(cell.col)"
+ cellEntity.game = entity
+ }
+ }
+
+ try context.save()
+
+ let mutator = GameMutator(game: game, gameEntity: entity, context: context)
+ return (game, mutator, entity, persistence)
+}
diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift
@@ -0,0 +1,213 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("GameMutator", .serialized)
+@MainActor
+struct GameMutatorTests {
+
+ // MARK: - Basic mutations
+
+ @Test("setLetter writes entry and mark to game")
+ func setLetterWritesToGame() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][0].mark == .none)
+ #expect(game.squares[0][0].updatedAt != nil)
+ }
+
+ @Test("setLetter in pencil mode sets pencil mark")
+ func setLetterPencilMode() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("B", atRow: 0, atCol: 1, pencil: true, origin: .local)
+
+ #expect(game.squares[0][1].entry == "B")
+ #expect(game.squares[0][1].mark == .pencil(checkedWrong: false))
+ }
+
+ @Test("clearLetter clears entry and mark")
+ func clearLetterClearsEntry() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
+ mutator.clearLetter(atRow: 0, atCol: 0, origin: .local)
+
+ #expect(game.squares[0][0].entry == "")
+ #expect(game.squares[0][0].mark == .none)
+ #expect(game.squares[0][0].letterAuthorID == nil)
+ }
+
+ // MARK: - LWW
+
+ @Test("Remote mutation with newer timestamp overwrites local")
+ func remoteNewerOverwritesLocal() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ let earlier = Date(timeIntervalSinceNow: -10)
+ let later = Date(timeIntervalSinceNow: 10)
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: earlier, authorID: "user1"))
+ mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: later, authorID: "user2"))
+
+ #expect(game.squares[0][0].entry == "B")
+ #expect(game.squares[0][0].letterAuthorID == "user2")
+ }
+
+ @Test("Remote mutation with older timestamp is rejected")
+ func remoteOlderIsRejected() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ let earlier = Date(timeIntervalSinceNow: -10)
+ let later = Date(timeIntervalSinceNow: 10)
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: later, authorID: "user1"))
+ mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: earlier, authorID: "user2"))
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][0].letterAuthorID == "user1")
+ }
+
+ @Test("Remote mutation with equal timestamp is accepted")
+ func remoteEqualTimestampAccepted() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ let t = Date()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: t, authorID: "user1"))
+ mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: t, authorID: "user2"))
+
+ #expect(game.squares[0][0].entry == "B")
+ }
+
+ // MARK: - Persistence
+
+ @Test("setLetter persists to CellEntity")
+ func setLetterPersistsToCellEntity() throws {
+ let (_, mutator, entity, persistence) = try makeTestGame()
+ let context = persistence.viewContext
+
+ mutator.setLetter("X", atRow: 0, atCol: 2, pencil: false, origin: .local)
+
+ let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
+ let cell = cellEntities.first { $0.row == 0 && $0.col == 2 }
+
+ #expect(cell?.letter == "X")
+ #expect(cell?.updatedAt != nil)
+ }
+
+ // MARK: - PendingChange outbox
+
+ @Test("Local mutation enqueues a PendingChange")
+ func localMutationEnqueuesPendingChange() throws {
+ let (_, mutator, _, persistence) = try makeTestGame()
+ let context = persistence.viewContext
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
+
+ let pending = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(pending.count == 1)
+ #expect(pending[0].recordType == "Cell")
+ #expect(pending[0].recordName?.contains("cell-") == true)
+ }
+
+ @Test("Remote mutation does not enqueue a PendingChange")
+ func remoteMutationDoesNotEnqueue() throws {
+ let (_, mutator, _, persistence) = try makeTestGame()
+ let context = persistence.viewContext
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: Date(), authorID: "user1"))
+
+ let pending = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(pending.isEmpty)
+ }
+
+ @Test("Multiple local mutations to same cell coalesce into one PendingChange")
+ func localMutationsCoalesce() throws {
+ let (_, mutator, _, persistence) = try makeTestGame()
+ let context = persistence.viewContext
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
+ mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false, origin: .local)
+ mutator.setLetter("C", atRow: 0, atCol: 0, pencil: false, origin: .local)
+
+ let pending = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(pending.count == 1)
+
+ // The payload should reflect the latest value
+ let data = pending[0].payload!.data(using: .utf8)!
+ let payload = try JSONDecoder().decode(PendingChangePayload.self, from: data)
+ #expect(payload.letter == "C")
+ }
+
+ // MARK: - onLocalMutation callback
+
+ @Test("onLocalMutation fires on local mutation")
+ func onLocalMutationFires() throws {
+ let (_, mutator, _, _) = try makeTestGame()
+ var callCount = 0
+ mutator.onLocalMutation = { callCount += 1 }
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, origin: .local)
+
+ #expect(callCount == 1)
+ }
+
+ @Test("onLocalMutation does not fire on remote mutation")
+ func onLocalMutationSilentForRemote() throws {
+ let (_, mutator, _, _) = try makeTestGame()
+ var callCount = 0
+ mutator.onLocalMutation = { callCount += 1 }
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false,
+ origin: .remote(timestamp: Date(), authorID: "user1"))
+
+ #expect(callCount == 0)
+ }
+
+ // MARK: - Bulk mutations
+
+ @Test("checkCells marks wrong entries via mutator")
+ func checkCellsMarksWrong() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ // Cell (0,0) has solution "A", enter "Z"
+ mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false, origin: .local)
+ mutator.checkCells([game.puzzle.cells[0][0]], origin: .local)
+
+ #expect(game.squares[0][0].mark == .pen(checkedWrong: true))
+ }
+
+ @Test("revealCells sets entry to solution and marks revealed")
+ func revealCellsSetsAnswer() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.revealCells([game.puzzle.cells[0][0]], origin: .local)
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][0].mark == .revealed)
+ }
+
+ @Test("clearCells clears non-revealed cells")
+ func clearCellsClearsNonRevealed() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("X", atRow: 0, atCol: 0, pencil: false, origin: .local)
+ mutator.clearCells([game.puzzle.cells[0][0]], origin: .local)
+
+ #expect(game.squares[0][0].entry == "")
+ #expect(game.squares[0][0].mark == .none)
+ }
+}
diff --git a/Tests/Unit/PendingChangeTests.swift b/Tests/Unit/PendingChangeTests.swift
@@ -0,0 +1,133 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("PendingChange+Helpers", .serialized)
+@MainActor
+struct PendingChangeTests {
+
+ @Test("Upsert creates a new row")
+ func upsertCreatesNew() {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ PendingChangeEntity.upsert(
+ recordName: "cell-1",
+ recordType: "Cell",
+ payload: "{\"letter\":\"A\"}",
+ in: context
+ )
+ try? context.save()
+
+ let results = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(results.count == 1)
+ #expect(results[0].recordName == "cell-1")
+ #expect(results[0].payload == "{\"letter\":\"A\"}")
+ }
+
+ @Test("Upsert with same recordName updates existing row")
+ func upsertUpdatesExisting() {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ PendingChangeEntity.upsert(
+ recordName: "cell-1",
+ recordType: "Cell",
+ payload: "{\"letter\":\"A\"}",
+ in: context
+ )
+ try? context.save()
+
+ PendingChangeEntity.upsert(
+ recordName: "cell-1",
+ recordType: "Cell",
+ payload: "{\"letter\":\"B\"}",
+ in: context
+ )
+ try? context.save()
+
+ let results = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(results.count == 1)
+ #expect(results[0].payload == "{\"letter\":\"B\"}")
+ }
+
+ @Test("Upsert with different recordNames creates separate rows")
+ func upsertDifferentNames() {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ PendingChangeEntity.upsert(
+ recordName: "cell-1",
+ recordType: "Cell",
+ payload: "{}",
+ in: context
+ )
+ PendingChangeEntity.upsert(
+ recordName: "cell-2",
+ recordType: "Cell",
+ payload: "{}",
+ in: context
+ )
+ try? context.save()
+
+ let results = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(results.count == 2)
+ }
+
+ @Test("Drain returns oldest first")
+ func drainReturnsOldestFirst() throws {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ PendingChangeEntity.upsert(
+ recordName: "cell-old",
+ recordType: "Cell",
+ payload: "{}",
+ in: context
+ )
+ // Ensure different timestamps
+ Thread.sleep(forTimeInterval: 0.01)
+ PendingChangeEntity.upsert(
+ recordName: "cell-new",
+ recordType: "Cell",
+ payload: "{}",
+ in: context
+ )
+ try context.save()
+
+ let results = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(results.count == 2)
+ #expect(results[0].recordName == "cell-old")
+ #expect(results[1].recordName == "cell-new")
+ }
+
+ @Test("Drain respects limit")
+ func drainRespectsLimit() {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ for i in 0..<5 {
+ PendingChangeEntity.upsert(
+ recordName: "cell-\(i)",
+ recordType: "Cell",
+ payload: "{}",
+ in: context
+ )
+ }
+ try? context.save()
+
+ let results = PendingChangeEntity.drain(limit: 3, in: context)
+ #expect(results.count == 3)
+ }
+
+ @Test("Drain returns empty array when no pending changes")
+ func drainEmptyWhenNone() {
+ let persistence = makeTestPersistence()
+ let context = persistence.viewContext
+
+ let results = PendingChangeEntity.drain(limit: 10, in: context)
+ #expect(results.isEmpty)
+ }
+}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -0,0 +1,112 @@
+import CloudKit
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("RecordSerializer")
+struct RecordSerializerTests {
+
+ // MARK: - Record name generation
+
+ @Test("Game record name uses expected format")
+ func gameRecordNameFormat() {
+ let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
+ let name = RecordSerializer.recordName(forGameID: id)
+ #expect(name == "game-12345678-1234-1234-1234-123456789ABC")
+ }
+
+ @Test("Cell record name uses expected format")
+ func cellRecordNameFormat() {
+ let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
+ let name = RecordSerializer.recordName(forCellInGame: id, row: 3, col: 7)
+ #expect(name == "cell-12345678-1234-1234-1234-123456789ABC-3-7")
+ }
+
+ @Test("Record names are deterministic")
+ func recordNamesAreDeterministic() {
+ let id = UUID()
+ let a = RecordSerializer.recordName(forGameID: id)
+ let b = RecordSerializer.recordName(forGameID: id)
+ #expect(a == b)
+
+ let c = RecordSerializer.recordName(forCellInGame: id, row: 0, col: 0)
+ let d = RecordSerializer.recordName(forCellInGame: id, row: 0, col: 0)
+ #expect(c == d)
+ }
+
+ // MARK: - Zone
+
+ @Test("Zone name is CrossmateZone")
+ func zoneNameIsCorrect() {
+ let zone = RecordSerializer.zone()
+ #expect(zone.zoneID.zoneName == "CrossmateZone")
+ }
+
+ // MARK: - System fields round-trip
+
+ @Test("Encode and decode system fields preserves record type and zone")
+ func systemFieldsRoundTrip() {
+ let zoneID = RecordSerializer.zoneID()
+ let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID)
+ let original = CKRecord(recordType: "Cell", recordID: recordID)
+
+ let encoded = RecordSerializer.encodeSystemFields(of: original)
+ #expect(encoded != nil)
+
+ let decoded = RecordSerializer.decodeRecord(from: encoded!)
+ #expect(decoded != nil)
+ #expect(decoded?.recordType == "Cell")
+ #expect(decoded?.recordID.zoneID.zoneName == "CrossmateZone")
+ #expect(decoded?.recordID.recordName == "test-record")
+ }
+}
+
+@Suite("PendingChangePayload")
+struct PendingChangePayloadTests {
+
+ @Test("Cell payload round-trips through JSON")
+ func cellPayloadRoundTrip() throws {
+ let now = Date()
+ let original = PendingChangePayload(
+ recordType: .cell,
+ recordName: "cell-uuid-0-1",
+ letter: "A",
+ markKind: 2,
+ checkedWrong: false,
+ updatedAt: now,
+ letterAuthorID: "user123",
+ parentGameRecordName: "game-uuid"
+ )
+
+ let data = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(PendingChangePayload.self, from: data)
+
+ #expect(decoded.recordType == .cell)
+ #expect(decoded.recordName == "cell-uuid-0-1")
+ #expect(decoded.letter == "A")
+ #expect(decoded.markKind == 2)
+ #expect(decoded.checkedWrong == false)
+ #expect(decoded.letterAuthorID == "user123")
+ #expect(decoded.parentGameRecordName == "game-uuid")
+ }
+
+ @Test("Game payload round-trips through JSON")
+ func gamePayloadRoundTrip() throws {
+ let original = PendingChangePayload(
+ recordType: .game,
+ recordName: "game-uuid",
+ title: "My Puzzle",
+ puzzleSource: "Title: Test\n\nAB\nCD",
+ completedAt: nil
+ )
+
+ let data = try JSONEncoder().encode(original)
+ let decoded = try JSONDecoder().decode(PendingChangePayload.self, from: data)
+
+ #expect(decoded.recordType == .game)
+ #expect(decoded.title == "My Puzzle")
+ #expect(decoded.puzzleSource == "Title: Test\n\nAB\nCD")
+ #expect(decoded.completedAt == nil)
+ }
+}
diff --git a/project.yml b/project.yml
@@ -29,6 +29,8 @@ targets:
CFBundleVersion: "1"
ITSAppUsesNonExemptEncryption: false
LSRequiresIPhoneOS: true
+ UIBackgroundModes:
+ - remote-notification
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
@@ -37,9 +39,25 @@ targets:
settings:
PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate
INFOPLIST_FILE: Crossmate/Info.plist
+ CODE_SIGN_ENTITLEMENTS: Crossmate/Crossmate.entitlements
TARGETED_DEVICE_FAMILY: "1"
CODE_SIGN_STYLE: Automatic
+ Crossmate Unit Tests:
+ type: bundle.unit-test
+ platform: iOS
+ sources:
+ - Tests/Unit
+ - Tests/Support
+ dependencies:
+ - target: Crossmate
+ settings:
+ PRODUCT_BUNDLE_IDENTIFIER: net.inqk.crossmate.unittests
+ CODE_SIGN_STYLE: Automatic
+ TEST_HOST: $(BUILT_PRODUCTS_DIR)/Crossmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Crossmate
+ BUNDLE_LOADER: $(TEST_HOST)
+ GENERATE_INFOPLIST_FILE: YES
+
schemes:
Crossmate:
build:
@@ -47,6 +65,12 @@ schemes:
Crossmate: all
run:
config: Debug
+ test:
+ config: Debug
+ targets:
+ - name: Crossmate Unit Tests
+ parallelizable: true
+ randomExecutionOrder: true
profile:
config: Release
analyze: