commit 7a8818904fad958c04b730a74cd46ac63b6c93d1
parent ab75fbd3d674bacfccb06cfe60cbde7a63d0ec56
Author: Michael Camilleri <[email protected]>
Date: Thu, 23 Jul 2026 18:54:28 +0900
Recover Chronicle rosters and cell colours
Chronicles already opened by the initial build could still look like
solo games. Their local projections lacked Player rows, and loading
without Moves rows could clear cached cell attribution even though the
replay journal remained complete.
This commit derives Chronicle participants from cached cells and
journals, and reconstructs a blanked final grid from the replay
timeline. Archived participant attribution is rendered separately from
live sharing state, so existing projections recover available player
entries and contributor colours without resuming share synchronisation.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
5 files changed, 157 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift
@@ -317,8 +317,21 @@ final class PlayerRoster {
let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
movesReq.predicate = NSPredicate(format: "game == %@", entity)
let movesEntities = (try? ctx.fetch(movesReq)) ?? []
+ var contributorAuthorIDs = Set(movesEntities.compactMap(\.authorID))
+ // Chronicles retain authorship in cached final cells and replay
+ // journals rather than live Moves rows. Reading all three sources
+ // also repairs projections materialised by the first Chronicle
+ // build, which did not create Player rows.
+ contributorAuthorIDs.formUnion(
+ ((entity.cells as? Set<CellEntity>) ?? [])
+ .compactMap(\.letterAuthorID)
+ )
+ contributorAuthorIDs.formUnion(
+ ((entity.journal as? Set<JournalEntity>) ?? [])
+ .compactMap(\.sourceAuthorID)
+ )
let authorIDs = Array(
- Set(movesEntities.compactMap { $0.authorID })
+ contributorAuthorIDs
.subtracting([localAuthorID, CKCurrentUserDefaultName, ""])
)
let nicknameReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -39,6 +39,15 @@ final class GameMutator {
/// react and build a roster without requiring the user to re-open.
var isShared: Bool
+ /// Whether persisted cell authorship should be rendered with participant
+ /// colours. A Chronicle can retain collaborative history after its live
+ /// share has been retired, so this is deliberately separate from
+ /// `isShared`, which continues to control network and share actions.
+ var showsPlayerAttribution: Bool {
+ isShared || hasArchivedPlayerAttribution
+ }
+ private let hasArchivedPlayerAttribution: Bool
+
/// Set to `true` when the owner has revoked the current user's access to
/// a shared game. Revocation makes the game read-only: every mutating
/// entry point below is a no-op (via `isEditable`) and `PuzzleView` shows
@@ -82,6 +91,7 @@ final class GameMutator {
onLocalCellEditBatch: (@MainActor ([RealtimeCellEdit]) -> Void)? = nil,
isOwned: Bool = true,
isShared: Bool = false,
+ showsPlayerAttribution: Bool? = nil,
isAccessRevoked: Bool = false,
isSyncSupported: Bool = true,
syncVersion: Int64 = GameSyncVersion.current,
@@ -97,6 +107,7 @@ final class GameMutator {
self.onLocalCellEditBatch = onLocalCellEditBatch
self.isOwned = isOwned
self.isShared = isShared
+ self.hasArchivedPlayerAttribution = showsPlayerAttribution ?? isShared
self.isAccessRevoked = isAccessRevoked
self.isSyncSupported = isSyncSupported
self.usesLogicalTicks = GameSyncVersion.normalized(syncVersion) >= GameSyncVersion.logicalTicks
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -18,8 +18,68 @@ private func isMaterializedArchive(_ entity: GameEntity) -> Bool {
entity.ckRecordName.flatMap(Archive.originalGameID(fromName:)) != nil
}
+/// Contributor identities retained by a materialised Chronicle. The cached
+/// cells and journals are included so projections created by the first
+/// Chronicle build repair themselves without downloading the record again.
+private func archivedContributorAuthorIDs(_ entity: GameEntity) -> [String] {
+ guard isMaterializedArchive(entity) else { return [] }
+ var authorIDs = Set(playerEntities(for: entity).compactMap(\.authorID))
+ authorIDs.formUnion(
+ ((entity.cells as? Set<CellEntity>) ?? []).compactMap(\.letterAuthorID)
+ )
+ authorIDs.formUnion(
+ ((entity.journal as? Set<JournalEntity>) ?? []).compactMap(\.sourceAuthorID)
+ )
+ if let completedBy = entity.completedBy {
+ authorIDs.insert(completedBy)
+ }
+ authorIDs.remove("")
+ authorIDs.remove(CKCurrentUserDefaultName)
+ return authorIDs.sorted()
+}
+
private func isArchivedSharedGame(_ entity: GameEntity) -> Bool {
- isMaterializedArchive(entity) && entity.archiveParticipants != nil
+ guard isMaterializedArchive(entity) else { return false }
+ return entity.archiveParticipants != nil
+ || archivedContributorAuthorIDs(entity).count > 1
+}
+
+/// The Chronicle's frozen grid. The first Chronicle build opened materialised
+/// rows through the live-Moves path and could consequently blank this cache.
+/// A complete replay journal still carries every after-state, so use its final
+/// frame only when no meaningful cached cell state remains.
+private func materializedArchiveGrid(_ entity: GameEntity) -> GridState? {
+ guard isMaterializedArchive(entity) else { return nil }
+ let cells = (entity.cells as? Set<CellEntity>) ?? []
+ let cached = Dictionary(
+ cells.map {
+ (
+ GridPosition(row: Int($0.row), col: Int($0.col)),
+ GridCell(
+ letter: $0.letter ?? "",
+ mark: CellMark(code: $0.markCode),
+ authorID: $0.letterAuthorID
+ )
+ )
+ },
+ uniquingKeysWith: { first, _ in first }
+ )
+ let hasMeaningfulCachedState = cached.values.contains {
+ !$0.letter.isEmpty || $0.mark != .none || $0.authorID != nil
+ }
+ guard !hasMeaningfulCachedState else { return cached }
+
+ let entries = ((entity.journal as? Set<JournalEntity>) ?? [])
+ .map(MovesJournal.value(from:))
+ guard !entries.isEmpty else { return cached }
+ let timeline = ReplayTimeline(merging: [entries])
+ return timeline.state(through: timeline.count).mapValues {
+ GridCell(
+ letter: $0.letter,
+ mark: $0.mark,
+ authorID: $0.cellAuthorID
+ )
+ }
}
/// Per-cell state for rendering a thumbnail. Plain value type so
@@ -247,6 +307,7 @@ struct GameSummary: Identifiable, Equatable {
localColor: localColor,
scoreByAuthorID: scoreByAuthorID,
additionalAuthorIDs: playerAuthorIDs
+ + archivedContributorAuthorIDs(entity)
)
}
@@ -2667,7 +2728,11 @@ final class GameStore {
movesRequest.predicate = NSPredicate(format: "game == %@", entity)
let movesEntities = (try? context.fetch(movesRequest)) ?? []
let values: [MovesValue] = movesEntities.compactMap { Self.movesValue(from: $0) }
- let grid = GridStateMerger.merge(values)
+ // A materialised Chronicle deliberately has no live Moves rows. Its
+ // final cells are the authoritative frozen grid, including the author
+ // attribution needed for participant colours.
+ let archiveGrid = materializedArchiveGrid(entity)
+ let grid = archiveGrid ?? GridStateMerger.merge(values)
// A completed game (won or resigned) is terminal; its grid is, by
// definition, the solution. The merge is watermarked at `completedAt`:
@@ -2681,7 +2746,8 @@ final class GameStore {
// Input is separately locked (`GameMutator.isCompleted`); the
// CellEntity cache still mirrors the raw (un-watermarked) merge.
if let completedAt = entity.completedAt {
- let sealedGrid = GridStateMerger.merge(values, notAfter: completedAt)
+ let sealedGrid = archiveGrid
+ ?? GridStateMerger.merge(values, notAfter: completedAt)
sealToSolution(game: game, mergedGrid: sealedGrid)
if updateCache {
updateCellCache(for: entity, from: grid)
@@ -2979,6 +3045,9 @@ final class GameStore {
},
isOwned: entity.databaseScope == 0,
isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
+ showsPlayerAttribution: entity.ckShareRecordName != nil
+ || entity.databaseScope == 1
+ || isArchivedSharedGame(entity),
isAccessRevoked: entity.isAccessRevoked,
isSyncSupported: GameSyncVersion.supports(entity.syncVersion),
syncVersion: entity.syncVersion,
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -472,7 +472,7 @@ struct PuzzleView: View {
session: session,
roster: roster,
revealConfirmation: revealConfirmation,
- showsSharedAnnotations: session.mutator.isShared,
+ showsSharedAnnotations: session.mutator.showsPlayerAttribution,
showsPeerCursors: !isSolved,
replayFrame: replay.frame
)
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -624,6 +624,65 @@ struct ArchiveTests {
#expect(await store.cachedRemoteJournals(forGameID: game.id!)?.count == 2)
}
+ @Test("a projection created before roster support repairs from cached Chronicle data")
+ func staleLegacyProjectionRecoversParticipantsAndCellAuthors() async throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let original = UUID()
+ let package = try Archive.recordPackage(
+ from: sampleSnapshot(originalGameID: original),
+ formatVersion: 1
+ )
+ defer {
+ for url in package.temporaryAssetFileURLs {
+ try? FileManager.default.removeItem(at: url)
+ }
+ }
+ let payload = try #require(Archive.payload(from: package.record))
+ let entity = try #require(Archive.materialize(payload, in: ctx))
+
+ // Reproduce the local projection written by the first Chronicle build:
+ // replay and final cells survived, but no roster marker or Player rows
+ // were materialised.
+ entity.archiveParticipants = nil
+ for player in (entity.players as? Set<PlayerEntity>) ?? [] {
+ ctx.delete(player)
+ }
+ for cell in (entity.cells as? Set<CellEntity>) ?? [] {
+ cell.letter = ""
+ cell.markCode = 0
+ cell.letterAuthorID = nil
+ }
+ try ctx.save()
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(summary.isShared)
+ #expect(Set(summary.allParticipants.map(\.authorID)) == ["alice", "bob"])
+
+ let store = makeTestStore(
+ persistence: persistence,
+ authorIDProvider: { "alice" }
+ )
+ let (game, mutator) = try store.loadGame(id: entity.id!)
+ #expect(!mutator.isShared)
+ #expect(mutator.showsPlayerAttribution)
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ #expect(game.squares[0][1].letterAuthorID == "bob")
+
+ let preferences = PlayerPreferences(
+ local: UserDefaults(suiteName: "archive-roster-\(UUID().uuidString)")!
+ )
+ let roster = PlayerRoster(
+ gameID: entity.id!,
+ authorIdentity: AuthorIdentity(testing: "alice"),
+ preferences: preferences,
+ persistence: persistence,
+ container: CloudContainer.container
+ )
+ await roster.preload()
+ #expect(Set(roster.entries.map(\.authorID)) == ["alice", "bob"])
+ }
+
@Test("archive retry window expires 14 days after completion")
func archiveRetryWindowExpiresAfterFourteenDays() {
let completedAt = Date(timeIntervalSince1970: 1_700_000_000)