crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit e17a351feb32304960f8e47ec18d979a784c1e68
parent 7b68f66437ad9e56f3c409b83559d3211278d9c4
Author: Michael Camilleri <[email protected]>
Date:   Mon, 11 May 2026 03:55:37 +0900

Add Crossmate version to puzzle data structures

This commit adds a Crossmate version number (set to 1 if absent) for
parsed puzzles so that it's possible to communicate to the system when
it needs to reparse puzzle data.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmake/Sources/Crossmake/main.swift | 1+
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 2++
MCrossmate/Models/PuzzleCatalog.swift | 22+++++++++++++++++++++-
MCrossmate/Models/XD.swift | 13+++++++++++++
MCrossmate/Persistence/GameStore.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
MCrossmate/Persistence/PersistenceController.swift | 6+++---
MCrossmate/Services/NYTToXDConverter.swift | 1+
MCrossmate/Services/PUZToXDConverter.swift | 1+
MCrossmate/Sync/RecordSerializer.swift | 1+
MPuzzles/Bundled/Crossmate-0001.xd | 1+
MPuzzles/Bundled/Crossmate-0002.xd | 1+
MPuzzles/Bundled/Crossmate-0003.xd | 1+
MPuzzles/Bundled/Crossmate-0004.xd | 1+
MPuzzles/Bundled/Crossmate-0005.xd | 1+
MPuzzles/Bundled/Crossmate-0006.xd | 1+
MPuzzles/Bundled/Crossmate-0007.xd | 1+
MPuzzles/Bundled/Crossmate-0008.xd | 1+
MPuzzles/Bundled/Crossmate-0009.xd | 1+
MPuzzles/Bundled/Crossmate-0010.xd | 1+
MPuzzles/Debug/garden.xd | 1+
MPuzzles/Debug/morning.xd | 1+
MPuzzles/Debug/sample.xd | 1+
MTests/Unit/GameStoreUnseenMovesTests.swift | 20++++++++++++++++++++
MTests/Unit/MovesUpdaterTests.swift | 14+++++++++++++-
MTests/Unit/PuzzleCatalogTests.swift | 2++
MTests/Unit/XDAcceptTests.swift | 33+++++++++++++++++++++++++++++++++
26 files changed, 187 insertions(+), 19 deletions(-)

diff --git a/Crossmake/Sources/Crossmake/main.swift b/Crossmake/Sources/Crossmake/main.swift @@ -640,6 +640,7 @@ func exportXD(state: PuzzleState, black: [Bool], slots: [Slot], options: Options let numbers = clueNumberGrid(black: black) var lines: [String] = [ "Title: \(options.title)", + "CmVer: 1", "Author: \(options.author)", "Publisher: Crossmake", "Date: \(ISO8601DateFormatter().string(from: Date()).prefix(10))", diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -19,6 +19,8 @@ <attribute name="lastSeenOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="latestOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="puzzleResourceID" optional="YES" attributeType="String"/> + <attribute name="puzzleCmVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="puzzleSource" attributeType="String"/> <attribute name="title" attributeType="String"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> diff --git a/Crossmate/Models/PuzzleCatalog.swift b/Crossmate/Models/PuzzleCatalog.swift @@ -7,6 +7,7 @@ struct PuzzleCatalog { let id: String // resource name (e.g. "sample") let title: String // parsed from the XD metadata let source: String // raw XD text + let cmVersion: Int // Crossmate version parser version } static func bundledPuzzles() -> [Entry] { @@ -17,6 +18,25 @@ struct PuzzleCatalog { puzzles(in: "Puzzles/Debug") } + static func source( + matchingResourceID resourceID: String?, + title: String? + ) -> Entry? { + let entries = bundledPuzzles() + debugPuzzles() + + if let resourceID { + return entries.first { $0.id == resourceID } + } else if let title { + return entries.first { $0.title == title } + } else { + return nil + } + } + + static func resourceID(matching source: String) -> String? { + (bundledPuzzles() + debugPuzzles()).first { $0.source == source }?.id + } + private static func puzzles(in subdirectory: String) -> [Entry] { guard let directoryURL = Bundle.main.resourceURL? .appendingPathComponent(subdirectory, isDirectory: true) else { @@ -33,7 +53,7 @@ struct PuzzleCatalog { let xd = try? XD.parse(source) else { return nil } - return Entry(id: name, title: xd.title ?? name, source: source) + return Entry(id: name, title: xd.title ?? name, source: source, cmVersion: xd.cmVersion) } .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } } diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -4,11 +4,14 @@ import Foundation /// full specification. Supports just enough of the format to parse our /// bundled puzzles: metadata, grid (with rebus), and across/down clues. struct XD: Sendable { + static let currentCmVersion = 1 + let title: String? let publisher: String? let author: String? let copyright: String? let date: Date? + let cmVersion: Int let specialKind: Puzzle.Special? let width: Int let height: Int @@ -118,6 +121,7 @@ struct XD: Sendable { author: metadata["Author"], copyright: metadata["Copyright"], date: parseDateHeader(metadata["Date"]), + cmVersion: parseCmVersionHeader(metadata["CmVer"]), specialKind: specialKind, width: width, height: height, @@ -230,6 +234,15 @@ struct XD: Sendable { return Calendar(identifier: .gregorian).date(from: comps) } + /// Parses a Crossmate `CmVer:` header. Missing versions are version 1 + /// so legacy sources establish the baseline content version. + private static func parseCmVersionHeader(_ value: String?) -> Int { + guard let value else { return 1 } + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard let version = Int(trimmed), version >= 1 else { return 1 } + return version + } + /// Parses a `Relatives:` header value into groups of cross-referenced /// clues. Groups are separated by `;`, tokens within a group by `,`, and /// each token is `{number}{A|D}` (e.g. `17A`, `57A`). This is a Crossmate diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -336,12 +336,7 @@ final class GameStore { guard let entity = try context.fetch(request).first else { throw LoadError.gameNotFound } - guard let source = entity.puzzleSource else { - throw LoadError.persistedSourceMissing - } - - let xd = try XD.parse(source) - let puzzle = Puzzle(xd: xd) + let puzzle = try preparePuzzleForLoad(from: entity) let game = Game(puzzle: puzzle) restore(game: game, from: entity) @@ -357,13 +352,32 @@ final class GameStore { // MARK: - Duplicate detection - /// Returns the ID of an existing game whose stored `puzzleSource` - /// matches `source` exactly, or nil if no such game exists. + /// Returns the ID of an existing game for the same source. Exact source + /// matches win, then catalog resource ID/title matches catch older stored + /// copies of a packaged puzzle. func findGameID(matching source: String) -> UUID? { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "puzzleSource == %@", source) request.fetchLimit = 1 - return (try? context.fetch(request).first?.id) + if let exact = try? context.fetch(request).first?.id { + return exact + } + + guard let xd = try? XD.parse(source) else { return nil } + let resourceID = PuzzleCatalog.resourceID(matching: source) + let fallback = NSFetchRequest<GameEntity>(entityName: "GameEntity") + if let resourceID { + fallback.predicate = NSPredicate(format: "puzzleResourceID == %@", resourceID) + fallback.fetchLimit = 1 + if let match = try? context.fetch(fallback).first?.id { + return match + } + } + + guard resourceID != nil, let title = xd.title else { return nil } + fallback.predicate = NSPredicate(format: "title == %@", title) + fallback.fetchLimit = 1 + return (try? context.fetch(fallback).first?.id) } /// Returns joined CloudKit-share games that have a usable puzzle payload. @@ -391,6 +405,8 @@ final class GameStore { entity.id = gameID entity.title = puzzle.title entity.puzzleSource = source + entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) entity.createdAt = now entity.updatedAt = now entity.ckRecordName = "game-\(gameID.uuidString)" @@ -499,11 +515,7 @@ final class GameStore { if let existing = try fetchCurrentEntity() { entity = existing - guard let source = existing.puzzleSource else { - throw LoadError.persistedSourceMissing - } - let xd = try XD.parse(source) - puzzle = Puzzle(xd: xd) + puzzle = try preparePuzzleForLoad(from: existing) } else { (entity, puzzle) = try seedFromSample() } @@ -554,6 +566,8 @@ final class GameStore { entity.id = gameID entity.title = puzzle.title entity.puzzleSource = source + entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) entity.createdAt = now entity.updatedAt = now entity.ckRecordName = "game-\(gameID.uuidString)" @@ -566,6 +580,41 @@ final class GameStore { return (entity, puzzle) } + private func preparePuzzleForLoad(from entity: GameEntity) throws -> Puzzle { + guard let source = entity.puzzleSource else { + throw LoadError.persistedSourceMissing + } + + let currentVersion = Int64(XD.currentCmVersion) + if entity.puzzleCmVersion != currentVersion { + let catalogSource = PuzzleCatalog.source( + matchingResourceID: entity.puzzleResourceID, + title: try? XD.parse(source).title + ) + let nextSource = catalogSource?.source ?? source + let nextXD = try XD.parse(nextSource) + let puzzle = Puzzle(xd: nextXD) + entity.title = puzzle.title + entity.puzzleSource = nextSource + entity.puzzleCmVersion = currentVersion + if let catalogSource { + entity.puzzleResourceID = catalogSource.id + } else if entity.puzzleResourceID == nil { + entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: nextSource) + } + entity.populateCachedSummaryFields(from: puzzle) + try context.save() + return puzzle + } + + if entity.puzzleResourceID == nil { + entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) + if context.hasChanges { try context.save() } + } + + return Puzzle(xd: try XD.parse(source)) + } + private func restore(game: Game, from entity: GameEntity) { let movesRequest = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") movesRequest.predicate = NSPredicate(format: "game == %@", entity) diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -43,9 +43,9 @@ final class PersistenceController { } } - /// One-shot pass for `GameEntity` rows created before - /// `populateCachedSummaryFields` was wired into the creation paths. Runs - /// off the main thread, no-ops on every subsequent launch. + /// One-shot pass for `GameEntity` rows created before cached summary + /// fields were wired into the creation paths. Runs off the main thread, + /// no-ops on every subsequent launch. private func backfillCachedSummaryFields() { let bg = container.newBackgroundContext() bg.perform { diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -183,6 +183,7 @@ enum NYTToXDConverter { // Metadata section var metadata: [String] = [] metadata.append("Title: \(title(forPublicationDate: publicationDate))") + metadata.append("CmVer: \(XD.currentCmVersion)") metadata.append("Publisher: New York Times") if !publicationDate.isEmpty { metadata.append("Date: \(publicationDate)") diff --git a/Crossmate/Services/PUZToXDConverter.swift b/Crossmate/Services/PUZToXDConverter.swift @@ -88,6 +88,7 @@ enum PUZToXDConverter { var metadata: [String] = [] if !title.isEmpty { metadata.append("Title: \(title)") } + metadata.append("CmVer: \(XD.currentCmVersion)") if !author.isEmpty { metadata.append("Author: \(author)") } if !copyright.isEmpty { metadata.append("Copyright: \(copyright)") } if !rebusEntries.isEmpty { diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -364,6 +364,7 @@ enum RecordSerializer { let source = try? String(contentsOf: fileURL, encoding: .utf8) { entity.puzzleSource = source if let xd = try? XD.parse(source) { + entity.puzzleCmVersion = Int64(XD.currentCmVersion) entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) } } diff --git a/Puzzles/Bundled/Crossmate-0001.xd b/Puzzles/Bundled/Crossmate-0001.xd @@ -1,4 +1,5 @@ Title: Crossmake #1 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0002.xd b/Puzzles/Bundled/Crossmate-0002.xd @@ -1,4 +1,5 @@ Title: Crossmake #2 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0003.xd b/Puzzles/Bundled/Crossmate-0003.xd @@ -1,4 +1,5 @@ Title: Crossmake #3 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0004.xd b/Puzzles/Bundled/Crossmate-0004.xd @@ -1,4 +1,5 @@ Title: Crossmake #4 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0005.xd b/Puzzles/Bundled/Crossmate-0005.xd @@ -1,4 +1,5 @@ Title: Crossmake #5 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0006.xd b/Puzzles/Bundled/Crossmate-0006.xd @@ -1,4 +1,5 @@ Title: Crossmake #6 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0007.xd b/Puzzles/Bundled/Crossmate-0007.xd @@ -1,4 +1,5 @@ Title: Crossmake #7 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0008.xd b/Puzzles/Bundled/Crossmate-0008.xd @@ -1,4 +1,5 @@ Title: Crossmake #8 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0009.xd b/Puzzles/Bundled/Crossmate-0009.xd @@ -1,4 +1,5 @@ Title: Crossmake #9 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Bundled/Crossmate-0010.xd b/Puzzles/Bundled/Crossmate-0010.xd @@ -1,4 +1,5 @@ Title: Crossmake #10 +CmVer: 1 Author: Crossmake Publisher: Crossmake Date: 2026-04-29 diff --git a/Puzzles/Debug/garden.xd b/Puzzles/Debug/garden.xd @@ -1,4 +1,5 @@ Title: Garden Party +CmVer: 1 Author: Crossmate Copyright: Public domain test puzzle diff --git a/Puzzles/Debug/morning.xd b/Puzzles/Debug/morning.xd @@ -1,4 +1,5 @@ Title: Morning Routine +CmVer: 1 Author: Crossmate Copyright: Public domain test puzzle diff --git a/Puzzles/Debug/sample.xd b/Puzzles/Debug/sample.xd @@ -1,4 +1,5 @@ Title: Crossmate Demo +CmVer: 1 Author: Crossmate Copyright: Public domain test puzzle diff --git a/Tests/Unit/GameStoreUnseenMovesTests.swift b/Tests/Unit/GameStoreUnseenMovesTests.swift @@ -141,4 +141,24 @@ struct GameStoreUnseenMovesTests { let summary = try #require(GameSummary(entity: entity)) #expect(!summary.hasUnseenOtherMoves) } + + @Test("Opening a stale CmVer game reparses source and records current CmVer") + func openingStaleCmVerGameReparsesSource() throws { + let persistence = makeTestPersistence() + let store = makeTestStore(persistence: persistence) + let ctx = persistence.viewContext + let (entity, _) = try makeSharedGame(in: ctx) + entity.puzzleCmVersion = 0 + entity.gridWidth = 0 + entity.gridHeight = 0 + entity.blockMask = nil + try ctx.save() + + _ = try store.loadGame(id: entity.id!) + + #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion)) + #expect(entity.gridWidth == 3) + #expect(entity.gridHeight == 3) + #expect(entity.blockMask?.count == 9) + } } diff --git a/Tests/Unit/MovesUpdaterTests.swift b/Tests/Unit/MovesUpdaterTests.swift @@ -80,6 +80,18 @@ struct MovesUpdaterTests { return try MovesCodec.decode(data) } + private func waitForFlushCount( + _ expected: Int, + capture: Capture, + timeout: Duration = .seconds(1) + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while await capture.flushCount != expected, + ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + } + @Test("Same-cell enqueues coalesce; latest value lands in MovesEntity") func coalescesSameCell() async throws { let (persistence, gameID) = try makePersistenceWithGame() @@ -131,7 +143,7 @@ struct MovesUpdaterTests { await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "A", markKind: 0, checkedWrong: false, authorID: "alice") await updater.enqueue(gameID: gameID, row: 0, col: 0, letter: "B", markKind: 0, checkedWrong: false, authorID: "alice") - try await Task.sleep(for: .milliseconds(200)) + try await waitForFlushCount(1, capture: capture) #expect(await capture.flushCount == 1) let cells = try decodedCells(gameID: gameID, persistence: persistence) diff --git a/Tests/Unit/PuzzleCatalogTests.swift b/Tests/Unit/PuzzleCatalogTests.swift @@ -11,6 +11,7 @@ struct PuzzleCatalogTests { #expect(puzzles.count == 10) #expect(puzzles.map(\.title).contains("Crossmake #1")) #expect(puzzles.map(\.title).contains("Crossmake #10")) + #expect(puzzles.allSatisfy { $0.cmVersion == XD.currentCmVersion }) #expect(puzzles.map(\.id) == [ "Crossmate-0001", "Crossmate-0002", @@ -30,5 +31,6 @@ struct PuzzleCatalogTests { let puzzles = PuzzleCatalog.debugPuzzles() #expect(puzzles.count == 3) + #expect(puzzles.allSatisfy { $0.cmVersion == XD.currentCmVersion }) } } diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -6,6 +6,39 @@ import Testing @Suite("XD Accept metadata") @MainActor struct XDAcceptTests { + @Test("Missing CmVer defaults to one") + func missingCmVerDefaultsToOne() throws { + let xd = try XD.parse(""" + Title: Versionless + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """) + + #expect(xd.cmVersion == 1) + } + + @Test("Explicit positive CmVer is parsed") + func explicitCmVerIsParsed() throws { + let xd = try XD.parse(""" + Title: Versioned + CmVer: 3 + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """) + + #expect(xd.cmVersion == 3) + } + @Test("Clue metadata is parsed generically") func clueMetadataParsesGenerically() throws { let source = """