commit e3455001e4e6c7f8735ae69f4a4a46da10e170de
parent 95333a9ce12e3d2115ed59666fbbcce4d6a5546f
Author: Michael Camilleri <[email protected]>
Date: Fri, 19 Jun 2026 00:33:28 +0900
Retire the per-peer Moves-snapshot baseline
Unifying the catch-up banner and away-change highlights onto a single
'last viewed' cutoff removed the last callers of the older path that
diffed a peer's current Moves snapshot against a locally stored
baseline. That left GameStore.movesSnapshot, lastMovesSnapshot and
setLastMovesSnapshot — together with the LocalMovesSnapshot value they
passed around — defined but unreachable.
This commit deletes those three methods and the LocalMovesSnapshot
struct, and drops the lastMovesSnapshotData attribute from
PlayerEntity. That attribute was local to each device and excluded
from the CloudKit serialiser, so removing it is an automatic lightweight
migration with no schema impact on the synced store. The
sessionSnapshot field is untouched: it still carries the
SeenBaseline the unified path ships across an account's own devices.
The doc comments on setSessionSnapshot and parsePlayerSessionSnapshot
that named the old per-peer payload are reworded so they no longer refer
to the deleted type and the Xcode project is regenerated to drop the
removed file from the app target.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
5 files changed, 12 insertions(+), 148 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -35,6 +35,7 @@
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; };
1A1A8A9AB36D02E2A5A9ED28 /* GameViewedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */; };
1AAFF86B40CBBFF1EC9ADF9F /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */; };
+ 1D08DDEDEF5433912CC6D4DB /* GameViewedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF9C2FEF0D3584864DFC967 /* PuzzleNotificationText+GameEntity.swift */; };
2571BA6482B3E896A80FF393 /* CompactSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B024B2FFB11E51E9724BBE23 /* CompactSlider.swift */; };
@@ -79,7 +80,6 @@
5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; };
5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */; };
61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; };
- 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */; };
6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */; };
689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */; };
6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; };
@@ -325,7 +325,6 @@
74C8886A66F0877858A67D62 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
78919F44C3035C48410FC894 /* GamePushCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePushCredentials.swift; sourceTree = "<group>"; };
78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesJournalTests.swift; sourceTree = "<group>"; };
- 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; };
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; };
7A7BD8DFDB41BFBA694A0933 /* SessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCoordinator.swift; sourceTree = "<group>"; };
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
@@ -349,6 +348,7 @@
92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameDirectoryTests.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>"; };
+ 9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewedStoreTests.swift; sourceTree = "<group>"; };
9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; };
978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenterTests.swift; sourceTree = "<group>"; };
@@ -516,6 +516,7 @@
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */,
+ 9406C27662147CD3C0783644 /* GameViewedStoreTests.swift */,
BF7062403AC9CFB4FF04BBF3 /* GridSilhouetteTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */,
@@ -568,7 +569,6 @@
8D2AD5021F1AF0DB44FA4540 /* GameCursorStore.swift */,
B9AE0F26E602A9246F5C6ABF /* GameViewedStore.swift */,
E25A040EA4DC9672C895A7AC /* InviteEntity+DisplayName.swift */,
- 7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */,
FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */,
DB55FC337CF72C650373210A /* PlayerColor.swift */,
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */,
@@ -934,6 +934,7 @@
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */,
+ 1D08DDEDEF5433912CC6D4DB /* GameViewedStoreTests.swift in Sources */,
085B70680087464B8A7BA3EE /* GridSilhouetteTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */,
@@ -1051,7 +1052,6 @@
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
CF1DC343A5D3110EDFA703AB /* LastUpdatedView.swift in Sources */,
8D8A9F70731C98DD00BE1DA5 /* Layouts.swift in Sources */,
- 66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */,
AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */,
91703E54DB4679C1911BF994 /* Moves.swift in Sources */,
4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -43,7 +43,6 @@
<attribute name="authorID" attributeType="String"/>
<attribute name="ckRecordName" attributeType="String"/>
<attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
- <attribute name="lastMovesSnapshotData" optional="YES" attributeType="Binary"/>
<attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="notifiedThrough" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pushAddress" optional="YES" attributeType="String"/>
diff --git a/Crossmate/Models/LocalMovesSnapshot.swift b/Crossmate/Models/LocalMovesSnapshot.swift
@@ -1,25 +0,0 @@
-import Foundation
-
-/// A point-in-time view of an author's merged Moves state, partitioned by
-/// whether each cell currently holds a letter. Diffed against a later snapshot
-/// to derive net adds and net clears for the in-app catch-up banner. Stored
-/// per peer as the local "what I've seen" baseline, advanced on leave, and
-/// shipped across the account's devices (keyed by peer) on
-/// `Player.sessionSnapshot` so siblings converge.
-struct LocalMovesSnapshot: Equatable, Sendable, Codable {
- /// Positions where the Moves row currently has a non-empty letter.
- let filled: Set<GridPosition>
- /// Positions where the Moves row currently has an explicit empty entry
- /// (cells the user cleared, including ones a peer had filled).
- let cleared: Set<GridPosition>
-
- static let empty = LocalMovesSnapshot(filled: [], cleared: [])
-
- func encoded() throws -> Data {
- try JSONEncoder().encode(self)
- }
-
- static func decode(_ data: Data) throws -> LocalMovesSnapshot {
- try JSONDecoder().decode(LocalMovesSnapshot.self, from: data)
- }
-}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1398,122 +1398,12 @@ final class GameStore {
)
}
- /// Snapshot of `authorID`'s `MovesEntity` contribution for `gameID`,
- /// partitioned into cells currently holding a letter vs cells explicitly
- /// emptied. Pass `deviceID: nil` to merge every device the author has
- /// touched the puzzle from (the receiver-banner case — Bob wants Alice's
- /// across-devices total). Pass an explicit `deviceID` to scope to one row
- /// (the sender-push case — each device's session covers only its own
- /// edits, so overlapping iPad/iPhone sessions don't double-count).
- ///
- /// Reads MovesEntity directly, not the derived `CellEntity` cache: the
- /// cache attributes by current author, so a peer overwriting one of the
- /// author's cells would flip ownership and a CellEntity-based diff would
- /// falsely count a "clear" the author never made. The Moves row is the
- /// authoritative log of what each device wrote.
- ///
- /// `filled` is restricted to cells whose preserved per-cell `authorID` is
- /// `authorID`: a check writes a peer's letter into the checker's row, so a
- /// row that merely holds someone else's checked letter must not count as
- /// this author's fill.
- func movesSnapshot(
- for gameID: UUID,
- by authorID: String,
- on deviceID: String?
- ) -> LocalMovesSnapshot {
- let request = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
- if let deviceID {
- request.predicate = NSPredicate(
- format: "game.id == %@ AND authorID == %@ AND deviceID == %@",
- gameID as CVarArg,
- authorID,
- deviceID
- )
- } else {
- request.predicate = NSPredicate(
- format: "game.id == %@ AND authorID == %@",
- gameID as CVarArg,
- authorID
- )
- }
- let entities = (try? context.fetch(request)) ?? []
- let values: [MovesValue] = entities.compactMap { Self.movesValue(from: $0) }
- guard !values.isEmpty else { return .empty }
- // Per-device rows can disagree on a cell (Alice on iPad wrote A, then
- // on iPhone wrote B). Merge with the same LWW logic the rest of the
- // app uses so the snapshot reflects the cell's winning state.
- let grid = GridStateMerger.merge(values)
- var filled: Set<GridPosition> = []
- var cleared: Set<GridPosition> = []
- for (position, cell) in grid {
- if cell.letter.isEmpty {
- cleared.insert(position)
- } else if cell.authorID == authorID {
- // A check carries the cell's letter into the checker's Moves
- // row but preserves the letter's original `authorID`. Attribute
- // a fill only when the winning letter is this author's, so
- // checking a peer's letter isn't miscounted as this author
- // filling it. A non-empty cell authored by someone else is
- // neither this author's fill nor their clear.
- filled.insert(position)
- }
- }
- return LocalMovesSnapshot(filled: filled, cleared: cleared)
- }
-
- /// Decoded `lastMovesSnapshotData` for `(gameID, authorID)`, or `nil` if
- /// no PlayerEntity row exists yet or no baseline has been written. Used
- /// by the in-app session-summary banner to diff a peer's current Moves
- /// snapshot against what was current the last time the local user opened
- /// the puzzle.
- func lastMovesSnapshot(for gameID: UUID, by authorID: String) -> LocalMovesSnapshot? {
- guard let entity = fetchPlayerEntity(gameID: gameID, authorID: authorID),
- let data = entity.lastMovesSnapshotData
- else { return nil }
- return try? LocalMovesSnapshot.decode(data)
- }
-
- /// Persists `snapshot` as the new baseline for `(gameID, authorID)`. Stays
- /// local to this device — `lastMovesSnapshotData` is excluded from the
- /// CloudKit serializer so each device tracks its own "last consumed" state
- /// independently. Creates a stub PlayerEntity if none exists yet (Moves
- /// records can race ahead of Player records during sync), keyed by the
- /// deterministic `ckRecordName` so the real Player record, when it
- /// arrives, updates this row rather than creating a duplicate. No-op if
- /// the GameEntity is missing.
- func setLastMovesSnapshot(
- _ snapshot: LocalMovesSnapshot,
- for gameID: UUID,
- by authorID: String
- ) {
- let entity: PlayerEntity
- if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) {
- entity = existing
- } else {
- let gameRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- gameRequest.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- gameRequest.fetchLimit = 1
- guard let game = try? context.fetch(gameRequest).first else { return }
- entity = PlayerEntity(context: context)
- entity.game = game
- entity.authorID = authorID
- entity.ckRecordName = RecordSerializer.recordName(
- forPlayerInGame: gameID,
- authorID: authorID
- )
- entity.updatedAt = Date()
- }
- entity.lastMovesSnapshotData = try? snapshot.encoded()
- saveContext("setLastMovesSnapshot")
- }
-
- /// Persists `data` (an encoded `[peerAuthorID: LocalMovesSnapshot]`) onto
- /// this account's own `Player.sessionSnapshot` for `gameID` — the per-peer
- /// "what I've seen" baseline, shipped on the Player record so sibling
- /// devices adopt it rather than recomputing from their own view. Unlike
- /// `lastMovesSnapshotData` this *is* serialized to CloudKit. Creates a stub
- /// PlayerEntity if none exists yet, keyed by the deterministic
- /// `ckRecordName`. No-op if the GameEntity is missing.
+ /// Persists `data` (an encoded `SeenBaseline`) onto this account's own
+ /// `Player.sessionSnapshot` for `gameID` — the "last viewed" cutoff,
+ /// shipped on the Player record so sibling devices adopt it rather than
+ /// recomputing from their own view. Creates a stub PlayerEntity if none
+ /// exists yet, keyed by the deterministic `ckRecordName`. No-op if the
+ /// GameEntity is missing.
func setSessionSnapshot(_ data: Data?, gameID: UUID, authorID: String) {
let entity: PlayerEntity
if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) {
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -534,8 +534,8 @@ enum RecordSerializer {
/// latest view time rather than recomputing the catch-up baseline from its
/// own (possibly stale) view. Returns `nil` on older records or when the
/// account has not yet left a game with peers. (Pre-unification builds wrote
- /// a `[peerAuthorID: LocalMovesSnapshot]` map here, which simply fails to
- /// decode as a `SeenBaseline` and is ignored — a per-device fallback.)
+ /// a per-peer Moves-snapshot map here, which simply fails to decode as a
+ /// `SeenBaseline` and is ignored — a per-device fallback.)
static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? {
record["sessionSnapshot"] as? Data
}