commit 5d5aad03c598a6923b6c9b9ea14e7789ab8fd59c
parent 362b43d4f5756e85eda0a7312eb5817c06cf68c0
Author: Michael Camilleri <[email protected]>
Date: Thu, 11 Jun 2026 08:53:18 +0900
Derive a completed game's thumbnail from the completion latch
The library thumbnail is built from the CellEntity cache, which mirrors
the raw un-watermarked merge of every device's Moves rows. A completed
game's grid display is sealed to the solution against a merge
watermarked at completedAt (sealToSolution), but the cache is not — so
in the completion race, a clear stamped after the latch by a device that
hadn't yet learned of the win beats the winning letter on wall-clock LWW
every time the cache is replayed. The opened puzzle renders solved with
correct authorship while the thumbnail shows a permanent hole at the
winning square.
GameSummary now renders every non-block cell as filled when completedAt
is set, skipping the cell cache entirely. A completed game is terminal
and by invariant renders solved, and the thumbnail encodes only
block/empty/filled — no authorship — so the latch alone determines it.
Because thumbnails are derived at render time, existing divergent games
(including materialized archives, which always carry completedAt) heal
without any data migration. The cache stays a raw-merge mirror, which
the post-completion authorship re-attribution depends on.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 116 insertions(+), 4 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -70,6 +70,7 @@
712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; };
740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
+ 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; };
7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */; };
@@ -344,6 +345,7 @@
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>"; };
E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordEditorView.swift; sourceTree = "<group>"; };
+ E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSummaryThumbnailTests.swift; sourceTree = "<group>"; };
E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; };
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadTests.swift; sourceTree = "<group>"; };
@@ -422,6 +424,7 @@
122BC1863D12DE06388D5DA7 /* GameStoreMergedAuthorCellsTests.swift */,
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
+ E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */,
2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */,
@@ -781,6 +784,7 @@
262A9CE8C3CB93869190CFF1 /* GameStoreMergedAuthorCellsTests.swift in Sources */,
2C5A15054CCCBF9FD626AFBB /* GameStorePushAddressTests.swift in Sources */,
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
+ 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
0158184A413AE177F75B4150 /* JournalReplayTests.swift in Sources */,
6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */,
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -76,10 +76,19 @@ struct GameSummary: Identifiable, Equatable {
blocks = bs
}
- let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
+ // A completed game is terminal and always renders solved (restore
+ // seals it to the solution), but the CellEntity cache mirrors the raw
+ // un-watermarked merge, which can permanently lack a winning letter —
+ // a clear stamped after the completion latch beats it on LWW forever.
+ // Derive the thumbnail from the latch, not the cache, so a finished
+ // game's thumbnail is full regardless of merge drift.
+ let isCompleted = entity.completedAt != nil
var filledSet: Set<Int> = []
- for ce in cellEntities where !(ce.letter ?? "").isEmpty {
- filledSet.insert(Int(ce.row) * width + Int(ce.col))
+ if !isCompleted {
+ let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
+ for ce in cellEntities where !(ce.letter ?? "").isEmpty {
+ filledSet.insert(Int(ce.row) * width + Int(ce.col))
+ }
}
var thumbCells: [GameThumbnailCell] = []
@@ -89,7 +98,7 @@ struct GameSummary: Identifiable, Equatable {
let idx = r * width + c
if blocks[idx] {
thumbCells.append(.block)
- } else if filledSet.contains(idx) {
+ } else if isCompleted || filledSet.contains(idx) {
thumbCells.append(.filled)
} else {
thumbCells.append(.empty)
diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift
@@ -0,0 +1,99 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Pins down `GameSummary`'s thumbnail derivation. An in-progress game's
+/// thumbnail mirrors the `CellEntity` cache, but a completed game is terminal
+/// and always renders solved (restore seals it to the solution), so its
+/// thumbnail must come from the completion latch — the raw merge behind the
+/// cache can permanently lack the winning letter when a clear stamped after
+/// the latch wins LWW.
+@Suite("GameSummary thumbnail")
+@MainActor
+struct GameSummaryThumbnailTests {
+
+ private static let puzzleSource = """
+ 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
+ """
+
+ private func makeGame(in ctx: NSManagedObjectContext) throws -> GameEntity {
+ let xd = try XD.parse(Self.puzzleSource)
+ let puzzle = Puzzle(xd: xd)
+
+ let entity = GameEntity(context: ctx)
+ entity.id = UUID()
+ entity.title = "Thumbnail"
+ entity.puzzleSource = Self.puzzleSource
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ entity.populateCachedSummaryFields(from: puzzle)
+ return entity
+ }
+
+ private func addCell(
+ _ letter: String,
+ row: Int16,
+ col: Int16,
+ to entity: GameEntity,
+ in ctx: NSManagedObjectContext
+ ) {
+ let cell = CellEntity(context: ctx)
+ cell.game = entity
+ cell.row = row
+ cell.col = col
+ cell.letter = letter
+ }
+
+ @Test("In-progress thumbnail mirrors the CellEntity cache")
+ func inProgressThumbnailMirrorsCache() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let entity = try makeGame(in: ctx)
+ addCell("A", row: 0, col: 0, to: entity, in: ctx)
+ addCell("", row: 2, col: 2, to: entity, in: ctx)
+ try ctx.save()
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(summary.thumbnailCells == [
+ .filled, .empty, .empty,
+ .empty, .block, .empty,
+ .empty, .empty, .empty,
+ ])
+ }
+
+ @Test("Completed thumbnail is full even when the cache has a hole")
+ func completedThumbnailIgnoresCacheHoles() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let entity = try makeGame(in: ctx)
+ // The winning letter never reached the durable merge: its cell holds
+ // an empty tombstone and another square has no row at all.
+ addCell("A", row: 0, col: 0, to: entity, in: ctx)
+ addCell("", row: 2, col: 2, to: entity, in: ctx)
+ entity.completedAt = Date()
+ try ctx.save()
+
+ let summary = try #require(GameSummary(entity: entity))
+ #expect(summary.thumbnailCells == [
+ .filled, .filled, .filled,
+ .filled, .block, .filled,
+ .filled, .filled, .filled,
+ ])
+ }
+}