crossmate

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

commit 10fbe622f7edc421ff6aa49d9969f9e8268a1067
parent 73b3b587c1924aeb7ea72092d9ba9d5f91d16272
Author: Michael Camilleri <[email protected]>
Date:   Wed,  8 Jul 2026 09:12:13 +0900

Split the puzzle version into converter and parser versions

A single stamp — the `CmVer:` header — stood for two unrelated things:
the version of the source→XD conversion, which decides whether a puzzles
from an external provider game are re-fetched and re-converted, and the
version of the XD→Core Data processing, which decides whether a game is
reparsed and its cached summary fields refreshed. The two were one
number held in two places — a header inside the source and a field on
the game entity — and that split is why a structurally-diverged puzzle
from an external source re-fetched from the network on every open,
flashing 'Updating puzzle…' each time: the re-fetch guard reads the
source header, but the path meant to stop it retrying wrote the entity
field, so the guard never saw the change.

This commit gives the two concepts their own counters,
currentConverterVersion and currentParserVersion. The converter version
stays in the source header, renamed from `CmVer:` to `ConVer:`; the
parser still reads the legacy token, so sources persisted or synced
before the rename keep working. The parser version stays on the entity
as puzzleParserVersion, its Core Data attribute carrying a
renamingIdentifier so existing games migrate in place. Puzzles generated
in-house — Crossmake output and the bundled and marketing puzzles — no
longer carry a converter version at all, since there is no external
source to re-convert.

On a structural divergence the upgrader now advances the kept source's
`ConVer:` header via stampConverterVersion instead of stamping the
entity, leaving the grid — and the player's in-progress moves —
untouched. That stops the re-fetch loop, and because a converter-only
bump no longer disturbs the parser version, the cached fields still
refresh whenever the parser itself changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmake/Sources/Fillmake/main.swift | 7++++---
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 2+-
MCrossmate/Models/XD.swift | 57++++++++++++++++++++++++++++++++++++++++++++++++++-------
MCrossmate/Persistence/GameStore.swift | 39+++++++++++++++++++++++----------------
MCrossmate/Services/AppServices.swift | 2+-
MCrossmate/Services/NYTPuzzleUpgrader.swift | 24+++++++++++++-----------
MCrossmate/Services/NYTToXDConverter.swift | 2+-
MCrossmate/Services/PUZToXDConverter.swift | 2+-
MCrossmate/Sync/RecordSerializer.swift | 2+-
MCrossmate/Views/MarketingScreenshots.swift | 1-
MPuzzles/cm-starter/cm-starter-0001.xd | 1-
MPuzzles/cm-starter/cm-starter-0002.xd | 1-
MPuzzles/cm-starter/cm-starter-0003.xd | 1-
MPuzzles/cm-starter/cm-starter-0004.xd | 1-
MPuzzles/cm-starter/cm-starter-0005.xd | 1-
MPuzzles/cm-starter/cm-starter-0006.xd | 1-
MPuzzles/cm-starter/cm-starter-0007.xd | 1-
MPuzzles/cm-starter/cm-starter-0008.xd | 1-
MPuzzles/cm-starter/cm-starter-0009.xd | 1-
MPuzzles/cm-starter/cm-starter-0010.xd | 1-
MPuzzles/debug/garden.xd | 1-
MPuzzles/debug/morning.xd | 1-
MPuzzles/debug/sample.xd | 1-
MScripts/nyt-to-xd.sh | 8++++----
MShared/PushPayload.swift | 8++++----
MTests/Unit/GameStoreUnreadMovesTests.swift | 8++++----
MTests/Unit/NYTToXDConverterTests.swift | 9+++++++++
MTests/Unit/PushPayloadTests.swift | 2+-
MTests/Unit/XDAcceptTests.swift | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
29 files changed, 217 insertions(+), 75 deletions(-)

diff --git a/Crossmake/Sources/Fillmake/main.swift b/Crossmake/Sources/Fillmake/main.swift @@ -1193,9 +1193,10 @@ func exportXD(state: PuzzleState, black: [Bool], slots: [Slot], options: Options let numbers = clueNumberGrid(black: black) var lines: [String] = [ "Title: \(options.title)", - // Keep in step with XD.currentCmVersion in the app; Crossmake is a - // standalone package and can't reference that symbol directly. - "CmVer: 7", + // No converter-version header: Crossmake generates puzzles rather than + // converting an external source, so there is nothing to re-convert. The + // app treats the absent header as version 1 and never acts on it for a + // non-NYT puzzle. "Revision: 1", "Bundle: cm-basic", "Author: \(options.author)", diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -31,7 +31,7 @@ <attribute name="readThroughAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="puzzleResourceID" optional="YES" attributeType="String"/> <attribute name="hasPushPending" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> - <attribute name="puzzleCmVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="puzzleParserVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" renamingIdentifier="puzzleCmVersion" usesScalarValueType="YES"/> <attribute name="puzzleSource" attributeType="String"/> <attribute name="replayCacheComplete" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="title" attributeType="String"/> diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -4,7 +4,20 @@ 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 = 8 + /// Version of the source→XD conversion (`NYTToXDConverter` / + /// `PUZToXDConverter`) that produced a persisted XD source. Written into the + /// `ConVer:` header and compared by `NYTPuzzleUpgrader` to decide whether an + /// owned NYT game should be re-fetched and re-converted. Only bumped when a + /// converter changes; puzzles generated in-house (Crossmake, bundled) carry + /// no converter version at all. + static let currentConverterVersion = 8 + + /// Version of the XD→Puzzle/Core Data processing (`XD.parse`, `Puzzle(xd:)`, + /// and the cached summary fields). Held on the game entity, never + /// serialized, and compared by `GameStore.preparePuzzleForLoad` to decide + /// whether a game must be reparsed and its cache refreshed. Bumped only when + /// that processing changes. + static let currentParserVersion = 8 /// Upper bound on a `.xd` source handed to `parse`. The largest puzzle we /// ship is ~3 KB, so this is ~80× real content — no genuine puzzle is ever @@ -47,7 +60,7 @@ struct XD: Sendable { let author: String? let copyright: String? let date: Date? - let cmVersion: Int + let converterVersion: Int let width: Int let height: Int let cells: [[Cell]] @@ -192,7 +205,12 @@ struct XD: Sendable { guard sections.count >= 3 else { throw ParseError.missingClues } let metadata = parseMetadata(sections[0]) - let cmVersion = parseCmVersionHeader(metadata.first("CmVer")) + // `ConVer:` is the current token; `CmVer:` is the legacy name kept for + // sources persisted or synced before the rename (and older peers still + // emit it). Read the new token first, fall back to the old. + let converterVersion = parseConverterVersionHeader( + metadata.first("ConVer") ?? metadata.first("CmVer") + ) let rebus = parseRebusHeader(metadata.first("Rebus")) let standardSpecial = parseStandardSpecialHeader(metadata.first("Special")) let relatives = parseRelativesHeader(metadata.first("Relatives")) @@ -219,7 +237,7 @@ struct XD: Sendable { author: metadata.first("Author"), copyright: metadata.first("Copyright"), date: parseDateHeader(metadata.first("Date")), - cmVersion: cmVersion, + converterVersion: converterVersion, width: width, height: height, cells: cells, @@ -424,15 +442,40 @@ struct XD: Sendable { return calendar.date(from: comps) } - /// Parses a Crossmate `CmVer:` header. Missing versions are version 1 so - /// older local fixtures remain identifiable as stale content. - private static func parseCmVersionHeader(_ value: String?) -> Int { + /// Parses a Crossmate converter-version header (`ConVer:`, or the legacy + /// `CmVer:`). A missing or unconverted source is version 1, which keeps + /// generated puzzles (no header) and older fixtures identifiable as + /// non-current — harmless, since only owned NYT games act on this value. + private static func parseConverterVersionHeader(_ 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 } + /// Returns `source` with its converter-version header set to `version`, + /// rewriting an existing `ConVer:`/`CmVer:` line in place, or inserting a + /// `ConVer:` line after the title if none is present. Only the metadata + /// block — the lines before the first blank line — is inspected, so the grid + /// and clues are never touched. Used by the upgrader to record that a + /// structurally-diverged NYT source has been evaluated at the current + /// converter version without disturbing the grid the player is solving. + static func settingConverterVersionHeader(in source: String, to version: Int) -> String { + var lines = source.components(separatedBy: "\n") + let newLine = "ConVer: \(version)" + for i in lines.indices { + let trimmed = lines[i].trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { break } // end of the metadata block + if trimmed.hasPrefix("ConVer:") || trimmed.hasPrefix("CmVer:") { + lines[i] = newLine + return lines.joined(separator: "\n") + } + } + // No existing header: insert after the first metadata line (the title). + lines.insert(newLine, at: lines.isEmpty ? 0 : 1) + return lines.joined(separator: "\n") + } + /// 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 @@ -1199,7 +1199,7 @@ final class GameStore { entity.id = gameID entity.title = puzzle.title entity.puzzleSource = source - entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleParserVersion = Int64(XD.currentParserVersion) entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) entity.createdAt = now entity.updatedAt = now @@ -1250,7 +1250,7 @@ final class GameStore { entity.id = gameID entity.title = puzzle.title entity.puzzleSource = source - entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleParserVersion = Int64(XD.currentParserVersion) entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) entity.createdAt = now entity.updatedAt = now @@ -1858,7 +1858,7 @@ final class GameStore { entity.id = gameID entity.title = puzzle.title entity.puzzleSource = source - entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleParserVersion = Int64(XD.currentParserVersion) entity.puzzleResourceID = PuzzleCatalog.resourceID(matching: source) entity.createdAt = now entity.updatedAt = now @@ -1877,8 +1877,8 @@ final class GameStore { throw LoadError.persistedSourceMissing } - let currentVersion = Int64(XD.currentCmVersion) - if entity.puzzleCmVersion != currentVersion { + let currentVersion = Int64(XD.currentParserVersion) + if entity.puzzleParserVersion != currentVersion { let catalogSource = PuzzleCatalog.source( matchingResourceID: entity.puzzleResourceID, title: try? XD.parse(source).title @@ -1888,7 +1888,7 @@ final class GameStore { let puzzle = Puzzle(xd: nextXD) entity.title = puzzle.title entity.puzzleSource = nextSource - entity.puzzleCmVersion = currentVersion + entity.puzzleParserVersion = currentVersion if let catalogSource { entity.puzzleResourceID = catalogSource.id } else if entity.puzzleResourceID == nil { @@ -2340,7 +2340,7 @@ final class GameStore { return PushPayload.Diagnostics( gridWidth: width, gridHeight: height, - cmVersion: Int(game.puzzleCmVersion), + parserVersion: Int(game.puzzleParserVersion), mergedCells: merged.count, inBounds: inBounds, playable: playable, @@ -2476,7 +2476,7 @@ final class GameStore { let puzzle = Puzzle(xd: parsed) entity.puzzleSource = newSource entity.title = puzzle.title - entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleParserVersion = Int64(XD.currentParserVersion) entity.hasPushPending = true entity.hasPendingSave = true entity.populateCachedSummaryFields(from: puzzle) @@ -2486,17 +2486,24 @@ final class GameStore { } } - /// Stamps the current CmVer onto a game without other changes. Used when - /// an attempted upgrade decided not to replace the source (e.g. structural - /// mismatch) but the caller still wants to retire the stale version so the - /// upgrade isn't reattempted every launch. - func bumpPuzzleCmVersion(for id: UUID) { + /// Records that a game's source has been evaluated at the current converter + /// version by rewriting its `ConVer:` header in place, leaving the grid (and + /// the player's moves) untouched. Used when an attempted NYT upgrade found a + /// structural divergence and kept the old source: advancing the header stops + /// `NYTPuzzleUpgrader.plan` from re-fetching the game on every open. The + /// parser version is deliberately left alone so `preparePuzzleForLoad` still + /// refreshes the cache if the parser has advanced. Local-only — only the + /// owning device acts on the converter version, so there is nothing to push. + func stampConverterVersion(for id: UUID) { let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") request.predicate = NSPredicate(format: "id == %@", id as CVarArg) request.fetchLimit = 1 - guard let entity = try? context.fetch(request).first else { return } - entity.puzzleCmVersion = Int64(XD.currentCmVersion) - saveContext("bumpPuzzleCmVersion") + guard let entity = try? context.fetch(request).first, + let source = entity.puzzleSource else { return } + entity.puzzleSource = XD.settingConverterVersionHeader( + in: source, to: XD.currentConverterVersion + ) + saveContext("stampConverterVersion") } private func restore(game: Game, from entity: GameEntity, updateCache: Bool = true) { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -170,7 +170,7 @@ enum DemoSeed { game.id = UUID() game.title = title game.puzzleSource = source - game.puzzleCmVersion = Int64(XD.currentCmVersion) + game.puzzleParserVersion = Int64(XD.currentParserVersion) game.puzzleResourceID = resourceID game.createdAt = now game.updatedAt = now diff --git a/Crossmate/Services/NYTPuzzleUpgrader.swift b/Crossmate/Services/NYTPuzzleUpgrader.swift @@ -1,7 +1,7 @@ import Foundation /// Re-fetches a NYT puzzle and runs the current `NYTToXDConverter` over it. -/// Used when `XD.currentCmVersion` advances and an existing game's stored +/// Used when `XD.currentConverterVersion` advances and an existing game's stored /// XD source predates a converter fix; the upgrader produces an XD string /// that can replace the persisted source provided the puzzle's grid hasn't /// changed underneath us. Only clue text, accepted variants, specials, and @@ -15,13 +15,14 @@ enum NYTPuzzleUpgrader { /// one; the persisted source can be replaced. case upgraded(newSource: String) /// The new XD's grid differs from the persisted one. Per the upgrade - /// policy, the caller should stamp the new CmVer but keep the old - /// source so the player's moves stay valid. `reason` describes the - /// first divergence found so diagnostics can pinpoint the cell. + /// policy, the caller should advance the kept source's converter-version + /// header but keep the old grid so the player's moves stay valid. + /// `reason` describes the first divergence found so diagnostics can + /// pinpoint the cell. case mismatched(reason: String) /// The fetch or a parse step failed. Caller should leave both the - /// source and the CmVer untouched so the upgrade is retried on the - /// next launch. + /// source and its converter version untouched so the upgrade is retried + /// on the next launch. case failed(Error) } @@ -53,7 +54,7 @@ enum NYTPuzzleUpgrader { } /// Describes a deferred NYT re-conversion for an owned game whose stored - /// `puzzleSource` predates `XD.currentCmVersion`. Built up-front (before + /// `puzzleSource` predates `XD.currentConverterVersion`. Built up-front (before /// `loadGame`) so the caller can show an "Updating puzzle…" state during /// the network round-trip. The bundled-catalog upgrade in /// `GameStore.preparePuzzleForLoad` runs synchronously and doesn't need @@ -76,7 +77,7 @@ enum NYTPuzzleUpgrader { guard let info = store.puzzleInfo(for: id), info.isOwned, let xd = try? XD.parse(info.source), - xd.cmVersion != XD.currentCmVersion, + xd.converterVersion != XD.currentConverterVersion, xd.publisher == "New York Times", let date = xd.date else { return nil } @@ -85,8 +86,9 @@ enum NYTPuzzleUpgrader { /// Runs the upgrader and applies the result against `store`: /// `.upgraded` swaps in the new source via `replacePuzzleSource`; - /// `.mismatched` stamps the new CmVer via `bumpPuzzleCmVersion` so we - /// don't retry every launch; `.failed` writes nothing, so the upgrade + /// `.mismatched` advances the kept source's converter-version header via + /// `stampConverterVersion` so `plan` stops re-fetching a permanently + /// diverged puzzle every launch; `.failed` writes nothing, so the upgrade /// is re-attempted next time the game is opened (covers transient /// network / auth failures). @MainActor @@ -105,7 +107,7 @@ enum NYTPuzzleUpgrader { case .upgraded(let newSource): store.replacePuzzleSource(id: plan.gameID, with: newSource) case .mismatched: - store.bumpPuzzleCmVersion(for: plan.gameID) + store.stampConverterVersion(for: plan.gameID) case .failed: break } diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift @@ -262,7 +262,7 @@ enum NYTToXDConverter { // Metadata section var metadata: [String] = [] metadata.append("Title: \(title)") - metadata.append("CmVer: \(XD.currentCmVersion)") + metadata.append("ConVer: \(XD.currentConverterVersion)") 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 @@ -110,7 +110,7 @@ enum PUZToXDConverter { var metadata: [String] = [] if !title.isEmpty { metadata.append("Title: \(title)") } - metadata.append("CmVer: \(XD.currentCmVersion)") + metadata.append("ConVer: \(XD.currentConverterVersion)") 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 @@ -934,7 +934,7 @@ enum RecordSerializer { entity.puzzleSource = source if let xd = try? XD.parse(source) { let puzzle = Puzzle(xd: xd) - entity.puzzleCmVersion = Int64(XD.currentCmVersion) + entity.puzzleParserVersion = Int64(XD.currentParserVersion) // The title is always derived from the puzzle content (there // is no custom game title), so trust the asset over the // record's `title` field — which was already applied above. diff --git a/Crossmate/Views/MarketingScreenshots.swift b/Crossmate/Views/MarketingScreenshots.swift @@ -247,7 +247,6 @@ private struct MarketingPuzzleSceneView: View { private static let source = """ Title: Crossmate Publisher: Collaborative Crossword App for iOS & iPadOS - CmVer: \(XD.currentCmVersion) Author: Crossmate diff --git a/Puzzles/cm-starter/cm-starter-0001.xd b/Puzzles/cm-starter/cm-starter-0001.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #1 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0002.xd b/Puzzles/cm-starter/cm-starter-0002.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #2 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0003.xd b/Puzzles/cm-starter/cm-starter-0003.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #3 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0004.xd b/Puzzles/cm-starter/cm-starter-0004.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #4 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0005.xd b/Puzzles/cm-starter/cm-starter-0005.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #5 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0006.xd b/Puzzles/cm-starter/cm-starter-0006.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #6 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0007.xd b/Puzzles/cm-starter/cm-starter-0007.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #7 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0008.xd b/Puzzles/cm-starter/cm-starter-0008.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #8 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0009.xd b/Puzzles/cm-starter/cm-starter-0009.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #9 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/cm-starter/cm-starter-0010.xd b/Puzzles/cm-starter/cm-starter-0010.xd @@ -1,5 +1,4 @@ Title: Crossmate Starter #10 -CmVer: 7 Revision: 1 Bundle: cm-starter Publisher: Michael Camilleri diff --git a/Puzzles/debug/garden.xd b/Puzzles/debug/garden.xd @@ -1,5 +1,4 @@ Title: Garden Party -CmVer: 7 Bundle: debug Author: Crossmate Copyright: Public domain test puzzle diff --git a/Puzzles/debug/morning.xd b/Puzzles/debug/morning.xd @@ -1,5 +1,4 @@ Title: Morning Routine -CmVer: 7 Bundle: debug Author: Crossmate Copyright: Public domain test puzzle diff --git a/Puzzles/debug/sample.xd b/Puzzles/debug/sample.xd @@ -1,5 +1,4 @@ Title: Crossmate Demo -CmVer: 7 Bundle: debug Author: Crossmate Copyright: Public domain test puzzle diff --git a/Scripts/nyt-to-xd.sh b/Scripts/nyt-to-xd.sh @@ -17,9 +17,9 @@ fetch_script="${repo_root}/Scripts/fetch-nyt.sh" # The converter pulls a couple of constants from the app target (XD). Mirror # just those from XD.swift so we don't need to compile the full app. Reading # from source keeps the stub in lockstep with the real values. -cm_version="$(sed -n 's/^[[:space:]]*static let currentCmVersion = \([0-9][0-9]*\).*/\1/p' "$xd_source" | head -n1)" -if [[ -z "$cm_version" ]]; then - echo "error: could not read currentCmVersion from $xd_source" >&2 +converter_version="$(sed -n 's/^[[:space:]]*static let currentConverterVersion = \([0-9][0-9]*\).*/\1/p' "$xd_source" | head -n1)" +if [[ -z "$converter_version" ]]; then + echo "error: could not read currentConverterVersion from $xd_source" >&2 exit 1 fi @@ -78,7 +78,7 @@ import Foundation // are read out of Crossmate/Models/XD.swift at script-start time so this stays // in sync with the canonical definitions. enum XD { - static let currentCmVersion = ${cm_version} + static let currentConverterVersion = ${converter_version} ${rebus_placeholders} } diff --git a/Shared/PushPayload.swift b/Shared/PushPayload.swift @@ -76,9 +76,9 @@ struct PushPayload: Codable, Sendable, Equatable { /// The grid geometry the sender currently holds for the puzzle. var gridWidth: Int? = nil var gridHeight: Int? = nil - /// The sender's converter version stamp for the puzzle — a mismatch - /// hints the two ends merged against different geometry. - var cmVersion: Int? = nil + /// The sender's parser version stamp for the puzzle — a mismatch hints + /// the two ends processed/cached the grid at different versions. + var parserVersion: Int? = nil /// Distinct positions in the sender's merged author Moves (the set the /// count path iterates). var mergedCells: Int? = nil @@ -114,7 +114,7 @@ struct PushPayload: Codable, Sendable, Equatable { + " sessionStart=\(iso(sessionStart))" + " recipientPresenceUntil=\(iso(recipientPresenceUntil))" + " grid=\(int(gridWidth))x\(int(gridHeight))" - + " cm=\(int(cmVersion))" + + " parserVer=\(int(parserVersion))" + " merged=\(int(mergedCells))" + " inBounds=\(int(inBounds))" + " playable=\(int(playable))" diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift @@ -629,13 +629,13 @@ struct GameStoreUnreadMovesTests { #expect(game.squares[0][0].entry == "Q") } - @Test("Opening a stale CmVer game reparses source and records current CmVer") - func openingStaleCmVerGameReparsesSource() throws { + @Test("Opening a stale parser-version game reparses source and records current version") + func openingStaleParserVersionGameReparsesSource() throws { let persistence = makeTestPersistence() let store = makeTestStore(persistence: persistence) let ctx = persistence.viewContext let (entity, _) = try makeSharedGame(in: ctx) - entity.puzzleCmVersion = 0 + entity.puzzleParserVersion = 0 entity.gridWidth = 0 entity.gridHeight = 0 entity.blockMask = nil @@ -643,7 +643,7 @@ struct GameStoreUnreadMovesTests { _ = try store.loadGame(id: entity.id!) - #expect(entity.puzzleCmVersion == Int64(XD.currentCmVersion)) + #expect(entity.puzzleParserVersion == Int64(XD.currentParserVersion)) #expect(entity.gridWidth == 3) #expect(entity.gridHeight == 3) #expect(entity.blockMask?.count == 9) diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift @@ -243,6 +243,15 @@ struct NYTToXDConverterTests { #expect(cell.accepts("W")) } + @Test("Converted output carries the current ConVer header, not legacy CmVer") + func emitsConVerHeader() throws { + let data = try singleRowPuzzleJSON(answers: ["A", "B", "C"]) + let xd = try NYTToXDConverter.convert(jsonData: data) + + #expect(header("ConVer", in: xd) == String(XD.currentConverterVersion)) + #expect(header("CmVer", in: xd) == nil) + } + @Test("Many distinct rebus fills never collide with reserved grid syntax") func manyRebusFillsAvoidReservedKeys() throws { // The 13 Schrödinger fills from the 2 Feb 2025 Sunday puzzle. Walking diff --git a/Tests/Unit/PushPayloadTests.swift b/Tests/Unit/PushPayloadTests.swift @@ -111,7 +111,7 @@ struct PushPayloadTests { recipientPresenceUntil: Date(timeIntervalSince1970: 1_000), gridWidth: 15, gridHeight: 15, - cmVersion: 3, + parserVersion: 3, mergedCells: 200, inBounds: 78, playable: 78, diff --git a/Tests/Unit/XDAcceptTests.swift b/Tests/Unit/XDAcceptTests.swift @@ -6,8 +6,8 @@ import Testing @Suite("XD Accept metadata") @MainActor struct XDAcceptTests { - @Test("Missing CmVer defaults to one") - func missingCmVerDefaultsToOne() throws { + @Test("Missing converter version defaults to one") + func missingConverterVersionDefaultsToOne() throws { let xd = try XD.parse(""" Title: Versionless @@ -19,13 +19,48 @@ struct XDAcceptTests { D1. Letter ~ A """) - #expect(xd.cmVersion == 1) + #expect(xd.converterVersion == 1) } - @Test("Explicit positive CmVer is parsed") - func explicitCmVerIsParsed() throws { + @Test("Explicit positive ConVer is parsed") + func explicitConVerIsParsed() throws { let xd = try XD.parse(""" Title: Versioned + ConVer: 3 + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """) + + #expect(xd.converterVersion == 3) + } + + @Test("Legacy CmVer header still parses as the converter version") + func legacyCmVerHeaderParses() throws { + let xd = try XD.parse(""" + Title: Legacy + CmVer: 3 + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """) + + #expect(xd.converterVersion == 3) + } + + @Test("ConVer takes precedence over a legacy CmVer when both are present") + func conVerWinsOverLegacyCmVer() throws { + let xd = try XD.parse(""" + Title: Both + ConVer: 8 CmVer: 3 @@ -36,7 +71,66 @@ struct XDAcceptTests { D1. Letter ~ A """) - #expect(xd.cmVersion == 3) + #expect(xd.converterVersion == 8) + } + + @Test("settingConverterVersionHeader rewrites an existing ConVer in place") + func stampRewritesExistingConVer() throws { + let source = """ + Title: Versioned + ConVer: 3 + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """ + let stamped = XD.settingConverterVersionHeader(in: source, to: 8) + + #expect(!stamped.contains("ConVer: 3")) + #expect(try XD.parse(stamped).converterVersion == 8) + #expect(try XD.parse(stamped).width == 1) // grid untouched + } + + @Test("settingConverterVersionHeader upgrades a legacy CmVer to ConVer") + func stampUpgradesLegacyCmVer() throws { + let source = """ + Title: Legacy + CmVer: 3 + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """ + let stamped = XD.settingConverterVersionHeader(in: source, to: 8) + + #expect(stamped.contains("ConVer: 8")) + #expect(!stamped.contains("CmVer:")) + #expect(try XD.parse(stamped).converterVersion == 8) + } + + @Test("settingConverterVersionHeader inserts a header when none is present") + func stampInsertsWhenAbsent() throws { + let source = """ + Title: Versionless + + + A + + + A1. Letter ~ A + D1. Letter ~ A + """ + let stamped = XD.settingConverterVersionHeader(in: source, to: 8) + let xd = try XD.parse(stamped) + + #expect(xd.converterVersion == 8) + #expect(xd.title == "Versionless") // metadata + grid intact } @Test("Rebus value escapes decode: \\space to a blank, \\\\ to a backslash")