commit 6c5e01a904c300003688a51cbd304622d9b0851d
parent 750e205c61860950eda4ad54b38b33bdefa7d8ae
Author: Michael Camilleri <[email protected]>
Date: Sat, 25 Jul 2026 00:45:24 +0900
Separate Chronicle replacement from blocked-game visibility
A completed game could appear twice in the Game List when friend-block
reconciliation cleared the same isHidden flag used to suppress its live
Game after Chronicle replacement.
This commit gives Chronicle supersession its own persisted visibility
flag and makes the Game List exclude either hiding reason. Legacy rows
transfer their Chronicle state before block visibility is recalculated,
while trimming a materialised Chronicle reveals any retained live Game.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
8 files changed, 101 insertions(+), 13 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -26,6 +26,7 @@
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isHidden" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="isSupersededByChronicle" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="journalUploaded" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastReadOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO" renamingIdentifier="lastSeenOtherMoveAt"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -460,6 +460,12 @@ final class GameSummaryCache {
}
extension GameEntity {
+ /// The Game List excludes both games hidden by the local block table and
+ /// live games whose visible representation is a materialized Chronicle.
+ static var visibleInGameListPredicate: NSPredicate {
+ NSPredicate(format: "isHidden == NO AND isSupersededByChronicle == NO")
+ }
+
/// Writes the derived puzzle data that `GameSummary` (and the library
/// list) needs into the entity, so the list path never has to call
/// `XD.parse` on every Core Data save. Block layout is encoded as one
@@ -483,8 +489,9 @@ extension GameEntity {
/// Re-derives local game-list hiding from the synced block table.
///
/// `isHidden` is intentionally local-only: block/unblock owns the durable
- /// account-wide fact, and games are hidden when any known
- /// collaborator on that game is currently blocked.
+ /// account-wide fact, and games are hidden when any known collaborator on
+ /// that game is currently blocked. Chronicle replacement is tracked
+ /// independently by `isSupersededByChronicle`.
@discardableResult
static func reconcileBlockedFriendHiddenGames(
forAuthorIDs authorIDs: Set<String>,
@@ -518,15 +525,40 @@ extension GameEntity {
) -> Int {
var changed = 0
for game in games {
+ var didChange = false
+ // Builds that predate `isSupersededByChronicle` used `isHidden`
+ // for Chronicle replacement too. Preserve that reason before
+ // block reconciliation clears the legacy combined flag.
+ if !game.isSupersededByChronicle,
+ hasMaterializedChronicle(for: game) {
+ game.isSupersededByChronicle = true
+ didChange = true
+ }
let authors = collaboratorAuthorIDs(for: game)
let shouldHide = !blockedAuthorIDs.isDisjoint(with: authors)
- guard game.isHidden != shouldHide else { continue }
- game.isHidden = shouldHide
- changed += 1
+ if game.isHidden != shouldHide {
+ game.isHidden = shouldHide
+ didChange = true
+ }
+ if didChange { changed += 1 }
}
return changed
}
+ private static func hasMaterializedChronicle(for game: GameEntity) -> Bool {
+ guard !isMaterializedArchive(game),
+ let gameID = game.id,
+ let ctx = game.managedObjectContext
+ else { return false }
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(
+ format: "id == %@",
+ Archive.archiveGameID(for: gameID) as CVarArg
+ )
+ request.fetchLimit = 1
+ return (try? ctx.count(for: request)) == 1
+ }
+
private static func blockedAuthorIDs(in ctx: NSManagedObjectContext) -> Set<String> {
let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
blockedReq.predicate = NSPredicate(format: "isBlocked == YES")
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -898,7 +898,7 @@ enum Archive {
entity.archiveParticipants = payload.wasShared
? payload.participants.map(\.authorID).sorted().joined(separator: ",")
: nil
- entity.isHidden = false
+ entity.isSupersededByChronicle = false
// Pending Chronicles carry no partial journal, but remain visibly
// retryable until reconciliation either captures every device or the
// retention deadline turns them into a terminal no-replay fallback.
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -743,7 +743,20 @@ final class GameArchiver {
cutoff as NSDate,
"chronicle-"
)
- for game in (try? ctx.fetch(req)) ?? [] { ctx.delete(game) }
+ for game in (try? ctx.fetch(req)) ?? [] {
+ if let name = game.ckRecordName,
+ let originalID = Archive.originalGameID(fromName: name) {
+ let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ liveReq.predicate = NSPredicate(
+ format: "id == %@",
+ originalID as CVarArg
+ )
+ liveReq.fetchLimit = 1
+ (try? ctx.fetch(liveReq).first)?
+ .isSupersededByChronicle = false
+ }
+ ctx.delete(game)
+ }
if ctx.hasChanges { try? ctx.save() }
}
}
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -677,7 +677,7 @@ extension SyncEngine {
)
liveReq.fetchLimit = 1
if let live = try? ctx.fetch(liveReq).first {
- live.isHidden = true
+ live.isSupersededByChronicle = true
}
return materialized.id
}
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -19,7 +19,7 @@ struct GameListView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FetchRequest(
sortDescriptors: [],
- predicate: NSPredicate(format: "isHidden == NO"),
+ predicate: GameEntity.visibleInGameListPredicate,
animation: .default
)
private var games: FetchedResults<GameEntity>
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -838,7 +838,8 @@ struct ArchiveTests {
engine.applyPreferredArchiveRecord(record, in: ctx)
}
#expect(result == Archive.archiveGameID(for: original))
- #expect(live.isHidden)
+ #expect(!live.isHidden)
+ #expect(live.isSupersededByChronicle)
let archiveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
archiveReq.predicate = NSPredicate(
@@ -847,6 +848,7 @@ struct ArchiveTests {
)
let archive = try #require(ctx.fetch(archiveReq).first)
#expect(!archive.isHidden)
+ #expect(!archive.isSupersededByChronicle)
let liveSummary = try #require(GameSummary(entity: live))
let archiveSummary = try #require(GameSummary(entity: archive))
@@ -855,6 +857,47 @@ struct ArchiveTests {
#expect(archiveSummary.listID == original)
}
+ @Test("block reconciliation preserves legacy Chronicle replacement")
+ func blockReconciliationPreservesChronicleReplacement() throws {
+ let persistence = makeTestPersistence()
+ let engine = try makeSyncEngine(persistence)
+ let ctx = persistence.viewContext
+ let original = UUID()
+
+ let live = GameEntity(context: ctx)
+ live.id = original
+ live.title = "Live"
+ live.puzzleSource = source
+ live.createdAt = Date()
+ live.updatedAt = Date()
+ live.databaseScope = 1
+ live.ckRecordName = "game-\(original.uuidString)"
+
+ _ = try withArchiveRecord(
+ from: sampleSnapshot(originalGameID: original)
+ ) { record in
+ engine.applyPreferredArchiveRecord(record, in: ctx)
+ }
+
+ // Reproduce the on-disk state written by the build that overloaded
+ // `isHidden` for both blocking and Chronicle replacement.
+ live.isHidden = true
+ live.isSupersededByChronicle = false
+
+ #expect(GameEntity.reconcileBlockedFriendHiddenGames(
+ forGameIDs: [original],
+ in: ctx
+ ) == 1)
+ #expect(!live.isHidden)
+ #expect(live.isSupersededByChronicle)
+
+ let visibleReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ visibleReq.predicate = GameEntity.visibleInGameListPredicate
+ let visible = try ctx.fetch(visibleReq)
+ #expect(visible.count == 1)
+ #expect(visible.first?.id == Archive.archiveGameID(for: original))
+ }
+
@Test("applier materializes when the original is absent")
func applierMaterializesWhenAbsent() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift
@@ -53,10 +53,9 @@ struct FriendModelTests {
}
try ctx.save()
- // Exactly the predicate used by GameListView's games fetch — block hides
- // a collaborator's games rather than leaving them, and unblock reveals.
+ // Exactly the predicate used by GameListView's games fetch.
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "isHidden == NO")
+ req.predicate = GameEntity.visibleInGameListPredicate
#expect(try ctx.fetch(req).map { $0.title } == ["visible"])
}