crossmate

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

commit 380b4a1220f8b6a07b9d01473d66629ca660c7de
parent 550bb4daa964c57afc62c9200a2c25bddaaf0f0a
Author: Michael Camilleri <[email protected]>
Date:   Wed, 15 Jul 2026 16:58:43 +0900

Order shared moves with logical ticks

A friend's clock could be ahead of another device, causing its letter to
keep winning the per-cell merge and reappear after another friend
replaced it. This commit moves sync version 2 games to logical ticks, so
each edit advances the largest revision the device has observed and
concurrent ties still resolve deterministically without depending on
wall time.

Owned version 1 games now upgrade automatically when opened, while
participants adopt the owner's version even when the puzzle is already
open. Timestamp-only moves from older apps remain accepted as
best-effort input: their timestamps seed the logical counter, allowing
the next modern edit to supersede them without excluding friends who
have not updated. Ticks travel inside existing Moves cell payloads and
realtime edits, with live-message authentication covering the new value.

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

Diffstat:
MCrossmate/Persistence/GameMutator.swift | 36++++++++++++++++++++++++++++++++----
MCrossmate/Persistence/GameStore.swift | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCrossmate/Services/AppServices.swift | 4++++
MCrossmate/Sync/EngagementMessageAuthenticator.swift | 6+++++-
MCrossmate/Sync/GameSyncVersion.swift | 15++++++++++-----
MCrossmate/Sync/GridStateMerger.swift | 12+++++++-----
MCrossmate/Sync/Moves.swift | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MCrossmate/Sync/MovesUpdater.swift | 19+++++++++++--------
MCrossmate/Sync/RecordSerializer.swift | 10+++++-----
MTests/Unit/GameMutatorTests.swift | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/GameStoreCompletionLockTests.swift | 42++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/GridStateMergerTests.swift | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MTests/Unit/RecordSerializerTests.swift | 1+
MTests/Unit/Sync/EngagementMessageAuthenticatorTests.swift | 27+++++++++++++++++++++++++++
14 files changed, 393 insertions(+), 39 deletions(-)

diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -44,10 +44,16 @@ final class GameMutator { /// read-only banner. var isAccessRevoked: Bool - /// Whether this app implements the immutable sync protocol selected by the - /// game. Unsupported games remain viewable but every mutation is blocked. + /// Whether this app implements the sync protocol selected by the game. + /// Unsupported games remain viewable but every mutation is blocked. var isSyncSupported: Bool + /// Version 2 allocates a new Lamport tick for every emitted cell mutation. + /// `logicalTick` tracks the largest value observed anywhere in the game, + /// including timestamp-derived values from legacy clients. + private var usesLogicalTicks: Bool + private var logicalTick: Int64 + /// Set to `true` once the game is completed (won or resigned). A completed /// game is terminal and read-only: every mutating entry point below becomes /// a no-op, so the grid can't be edited or "re-solved" after the fact. Set @@ -70,6 +76,8 @@ final class GameMutator { isShared: Bool = false, isAccessRevoked: Bool = false, isSyncSupported: Bool = true, + syncVersion: Int64 = GameSyncVersion.current, + initialLogicalTick: Int64 = 0, isCompleted: Bool = false ) { self.game = game @@ -83,10 +91,21 @@ final class GameMutator { self.isShared = isShared self.isAccessRevoked = isAccessRevoked self.isSyncSupported = isSyncSupported + self.usesLogicalTicks = GameSyncVersion.normalized(syncVersion) >= GameSyncVersion.logicalTicks + self.logicalTick = max(0, initialLogicalTick) self.isCompleted = isCompleted prefetchJournal() } + /// Refreshes protocol state after an inbound Game/Moves record. A game can + /// advance from legacy to logical ticks while it is open, and every remote + /// revision must raise the local Lamport floor before the next edit. + func updateSyncVersion(_ version: Int64, observedLogicalTick: Int64) { + isSyncSupported = GameSyncVersion.supports(version) + usesLogicalTicks = GameSyncVersion.normalized(version) >= GameSyncVersion.logicalTicks + logicalTick = max(logicalTick, observedLogicalTick) + } + // MARK: - Single-cell mutations func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) { @@ -358,6 +377,7 @@ final class GameMutator { // persisted as the cell's `updatedAt` on flush, which is how // `restore` later recognises the edit has landed and retires the flag. let enqueuedAt = Date() + let tick = nextLogicalTick() game.squares[row][col].enqueuedAt = enqueuedAt if let actingAuthorID, !actingAuthorID.isEmpty { let edit = RealtimeCellEdit( @@ -369,7 +389,8 @@ final class GameMutator { letter: letter, mark: mark, updatedAt: enqueuedAt, - cellAuthorID: cellAuthorID + cellAuthorID: cellAuthorID, + tick: tick ) if batchBroadcastBuffer != nil { batchBroadcastBuffer?.append(edit) @@ -384,8 +405,15 @@ final class GameMutator { letter: letter, mark: mark, authorID: cellAuthorID, - enqueuedAt: enqueuedAt + enqueuedAt: enqueuedAt, + tick: tick ) } } + + private func nextLogicalTick() -> Int64? { + guard usesLogicalTicks else { return nil } + if logicalTick < Int64.max { logicalTick += 1 } + return logicalTick + } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -666,7 +666,7 @@ final class GameStore { /// been written into Core Data by the sync engine. func refreshCurrentGame() { guard let game = currentGame, let entity = currentEntity else { return } - currentMutator?.isSyncSupported = GameSyncVersion.supports(entity.syncVersion) + refreshCurrentSyncState() // On this path the SyncEngine's inbound fetch has already replayed // the CellEntity cache atomically with the inbound MovesEntity // (see SyncEngine.replayCellCache); rewriting it here would do the @@ -675,6 +675,17 @@ final class GameStore { restore(game: game, from: entity, updateCache: false) } + /// Refreshes only the active mutator's protocol/counter state. Game record + /// changes use this lightweight path so a participant whose puzzle is + /// already open adopts an owner upgrade before either player types again. + func refreshCurrentSyncState() { + guard let entity = currentEntity else { return } + currentMutator?.updateSyncVersion( + entity.syncVersion, + observedLogicalTick: maximumLogicalTick(for: entity) + ) + } + /// Merges every device's `MovesEntity` rows for each game ID and updates /// the `CellEntity` cache so that list thumbnails reflect local edits /// immediately after a `MovesUpdater` flush, without waiting for the next @@ -979,9 +990,11 @@ final class GameStore { letter: edit.letter, mark: edit.mark, updatedAt: clampedUpdatedAt, - authorID: edit.cellAuthorID + authorID: edit.cellAuthorID, + tick: edit.tick ) - if let current = cells[position], current.updatedAt > incoming.updatedAt { + if let current = cells[position], + current.compareRevision(to: incoming) == .orderedDescending { continue } cells[position] = incoming @@ -1155,6 +1168,7 @@ final class GameStore { guard let entity = try context.fetch(request).first else { throw LoadError.gameNotFound } + try upgradeOwnedGameSyncVersionIfNeeded(entity) let puzzle = try preparePuzzleForLoad(from: entity) let game = Game(puzzle: puzzle) restore(game: game, from: entity) @@ -1809,6 +1823,7 @@ final class GameStore { if let existing = try fetchCurrentEntity() { entity = existing + try upgradeOwnedGameSyncVersionIfNeeded(existing) puzzle = try preparePuzzleForLoad(from: existing) } else { (entity, puzzle) = try seedFromSample() @@ -2914,10 +2929,47 @@ final class GameStore { isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, isAccessRevoked: entity.isAccessRevoked, isSyncSupported: GameSyncVersion.supports(entity.syncVersion), + syncVersion: entity.syncVersion, + initialLogicalTick: maximumLogicalTick(for: entity), isCompleted: entity.completedAt != nil ) } + /// Advances an owned legacy game when its owner opens it. Participants + /// adopt the owner's Game record and never select a protocol themselves. + /// Marking the row pending protects the chosen version from a stale fetch + /// until SyncEngine confirms the owner-authoritative save. + private func upgradeOwnedGameSyncVersionIfNeeded(_ entity: GameEntity) throws { + let version = GameSyncVersion.normalized(entity.syncVersion) + guard entity.databaseScope == DatabaseScope.private.rawValue, + version < GameSyncVersion.current, + GameSyncVersion.supports(version) + else { return } + + entity.syncVersion = GameSyncVersion.current + entity.hasPendingSave = true + try context.save() + if let recordName = entity.ckRecordName { + onGameUpdated(recordName) + } + } + + /// Highest modern tick or timestamp-derived legacy value currently known + /// for the game. This is the Lamport floor used by the next local edit. + private func maximumLogicalTick(for entity: GameEntity) -> Int64 { + let moves = (entity.moves as? Set<MovesEntity>) ?? [] + var maximum: Int64 = 0 + for row in moves { + guard let data = row.cells, + let cells = try? MovesCodec.decode(data) + else { continue } + for cell in cells.values { + maximum = max(maximum, cell.logicalValue) + } + } + return maximum + } + private func fetchGameEntity(id gameID: UUID) -> GameEntity? { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -981,6 +981,10 @@ final class AppServices { } await syncEngine.setOnGameVisibilityCandidates { [weak self] gameIDs in + if let currentID = self?.store.currentEntity?.id, + gameIDs.contains(currentID) { + self?.store.refreshCurrentSyncState() + } let hiddenChanges = await self?.store.reconcileBlockedFriendHiddenGames( forGameIDs: gameIDs ) ?? 0 diff --git a/Crossmate/Sync/EngagementMessageAuthenticator.swift b/Crossmate/Sync/EngagementMessageAuthenticator.swift @@ -43,6 +43,9 @@ enum EngagementMessageAuthenticator { let mark: Int16 let updatedAtMillis: Int64 let cellAuthorID: String + /// Omitted for legacy edits, preserving their v1 canonical bytes. + /// Present logical ticks are authenticated end-to-end. + let tick: Int64? } private struct CanonicalSelection: Encodable { @@ -76,7 +79,8 @@ enum EngagementMessageAuthenticator { letter: edit.letter, mark: edit.mark.code, updatedAtMillis: millis(edit.updatedAt), - cellAuthorID: edit.cellAuthorID ?? "" + cellAuthorID: edit.cellAuthorID ?? "", + tick: edit.tick ) } diff --git a/Crossmate/Sync/GameSyncVersion.swift b/Crossmate/Sync/GameSyncVersion.swift @@ -1,17 +1,22 @@ import Foundation /// Version of the rules used to encode and merge a collaborative game's -/// durable state. A game keeps the version it was created with; app releases -/// may support more than one version so existing games remain playable. +/// durable state. Owners may advance an existing game to a newer version; +/// app releases may support more than one version during that transition. enum GameSyncVersion { /// Games created before the field existed are version 1. static let legacy: Int64 = 1 - /// Version assigned to newly-created games by this app release. - static let current: Int64 = legacy + /// Per-cell logical ticks, with timestamp-only cells accepted as legacy + /// input from older clients. + static let logicalTicks: Int64 = 2 + + /// Version assigned to newly-created games by this app release. Owned + /// legacy games advance to this version when their owner opens them. + static let current: Int64 = logicalTicks /// Exact protocol versions this app can safely read and edit. - static let supported: Set<Int64> = [legacy] + static let supported: Set<Int64> = [legacy, logicalTicks] /// Core Data test fixtures and pre-migration rows can surface zero before /// the default is materialised. Treat every non-positive value as legacy, diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift @@ -1,9 +1,10 @@ import Foundation /// Reduces every `(author, device)` `MovesValue` for a single game into one -/// `GridState`. Per-cell last-writer-wins on wall-clock `updatedAt`; ties are -/// broken first by the writing user's `authorID` (lex-min wins), then by -/// `deviceID`, so the output is deterministic regardless of input order. The +/// `GridState`. Sync-version-2 cells use logical ticks; timestamp-only legacy +/// cells are projected into the same ordering space for best-effort mixed-app +/// play. Ties are broken first by the writing user's `authorID` (lex-min wins), +/// then by `deviceID`, so output is deterministic regardless of input order. The /// merged `GridCell.authorID` is the *cell-level* preserved author from the /// winning entry — not the parent record's author — so reveal-of-correct and /// same-letter rewrites can hand off authorship without losing it. @@ -76,8 +77,9 @@ enum GridStateMerger { } private static func shouldReplace(current: Winner, with candidate: Winner) -> Bool { - if candidate.cell.updatedAt != current.cell.updatedAt { - return candidate.cell.updatedAt > current.cell.updatedAt + let revisionOrder = candidate.cell.compareRevision(to: current.cell) + if revisionOrder != .orderedSame { + return revisionOrder == .orderedDescending } if candidate.writerAuthorID != current.writerAuthorID { return candidate.writerAuthorID < current.writerAuthorID diff --git a/Crossmate/Sync/Moves.swift b/Crossmate/Sync/Moves.swift @@ -39,9 +39,8 @@ struct GridCell: Equatable, Sendable, Codable { typealias GridState = [GridPosition: GridCell] /// One device's contribution to a game: every cell this `(authorID, deviceID)` -/// pair has ever touched, with the wall-clock timestamp of each touch. Merging -/// across all `MovesValue`s for a game reconstructs the current grid via per-cell -/// last-writer-wins on `TimestampedCell.updatedAt`. +/// pair has ever touched, with its logical revision and display/event time. +/// Merging every `MovesValue` reconstructs the grid by per-cell revision. struct MovesValue: Equatable, Sendable { let gameID: UUID let authorID: String @@ -60,6 +59,50 @@ struct TimestampedCell: Equatable, Sendable { var mark: CellMark var updatedAt: Date var authorID: String? + /// Lamport-style per-game counter. Absent on records written by clients + /// before sync version 2; those timestamps are projected into the same + /// ordering space so mixed-version games remain best-effort playable. + var tick: Int64? + + init( + letter: String, + mark: CellMark, + updatedAt: Date, + authorID: String? = nil, + tick: Int64? = nil + ) { + self.letter = letter + self.mark = mark + self.updatedAt = updatedAt + self.authorID = authorID + self.tick = tick.flatMap { $0 > 0 ? $0 : nil } + } + + /// Value used to seed a modern writer's next logical tick. Milliseconds + /// preserve the ordering scale used by a legacy wall-clock write without + /// making modern-to-modern comparisons depend on either device's clock. + var logicalValue: Int64 { + if let tick { return tick } + let milliseconds = updatedAt.timeIntervalSince1970 * 1_000 + guard milliseconds.isFinite else { return 0 } + if milliseconds >= Double(Int64.max) { return Int64.max } + if milliseconds <= 0 { return 0 } + return Int64(milliseconds.rounded(.down)) + } + + /// Compares cell revisions. Two legacy cells retain their full Date + /// precision; any comparison involving a modern cell uses the shared + /// logical value. + func compareRevision(to other: TimestampedCell) -> ComparisonResult { + if tick == nil, other.tick == nil { + if updatedAt < other.updatedAt { return .orderedAscending } + if updatedAt > other.updatedAt { return .orderedDescending } + return .orderedSame + } + if logicalValue < other.logicalValue { return .orderedAscending } + if logicalValue > other.logicalValue { return .orderedDescending } + return .orderedSame + } } struct RealtimeCellEdit: Codable, Equatable, Sendable { @@ -72,6 +115,7 @@ struct RealtimeCellEdit: Codable, Equatable, Sendable { var mark: CellMark var updatedAt: Date var cellAuthorID: String? + var tick: Int64? = nil } enum MovesCodec { @@ -92,9 +136,10 @@ enum MovesCodec { let mark: CellMark let updatedAt: Date let authorID: String? + let tick: Int64? enum CodingKeys: String, CodingKey { - case row, col, letter, markCode, updatedAt, authorID + case row, col, letter, markCode, updatedAt, authorID, tick // Legacy keys — read-only, for records written before cutover. case markKind, checkedRight, checkedWrong } @@ -105,7 +150,8 @@ enum MovesCodec { letter: String, mark: CellMark, updatedAt: Date, - authorID: String? + authorID: String?, + tick: Int64? ) { self.row = row self.col = col @@ -113,6 +159,7 @@ enum MovesCodec { self.mark = mark self.updatedAt = updatedAt self.authorID = authorID + self.tick = tick } init(from decoder: Decoder) throws { @@ -122,6 +169,7 @@ enum MovesCodec { letter = try c.decode(String.self, forKey: .letter) updatedAt = try c.decode(Date.self, forKey: .updatedAt) authorID = try? c.decode(String.self, forKey: .authorID) + tick = (try? c.decode(Int64.self, forKey: .tick)).flatMap { $0 > 0 ? $0 : nil } mark = try Self.decodeMark(from: c) } @@ -133,6 +181,7 @@ enum MovesCodec { try c.encode(mark.code, forKey: .markCode) try c.encode(updatedAt, forKey: .updatedAt) try c.encodeIfPresent(authorID, forKey: .authorID) + try c.encodeIfPresent(tick, forKey: .tick) } /// Prefers the single `markCode`; falls back to the legacy triple @@ -168,7 +217,8 @@ enum MovesCodec { letter: cell.letter, mark: cell.mark, updatedAt: cell.updatedAt, - authorID: cell.authorID + authorID: cell.authorID, + tick: cell.tick ) } .sorted { lhs, rhs in @@ -190,7 +240,8 @@ enum MovesCodec { letter: entry.letter, mark: entry.mark, updatedAt: entry.updatedAt, - authorID: entry.authorID + authorID: entry.authorID, + tick: entry.tick ) } return cells diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -4,7 +4,7 @@ import Foundation /// In-memory staging area for cell edits. Coalesces rapid edits — same-cell /// rewrites collapse to the latest value, cross-cell edits accumulate as /// distinct entries — and on flush merges them into the local device's -/// `MovesEntity` row (per-cell wall-clock LWW), updates the local `CellEntity` +/// `MovesEntity` row (per-cell logical revision), updates the local `CellEntity` /// cache, and hands the affected `gameIDs` to the injected sink so SyncEngine /// can enqueue Moves records for upload. /// @@ -29,6 +29,7 @@ actor MovesUpdater { var mark: CellMark var authorID: String? var enqueuedAt: Date + var tick: Int64? } private let debounceInterval: Duration @@ -74,14 +75,16 @@ actor MovesUpdater { letter: String, mark: CellMark, authorID: String?, - enqueuedAt: Date = Date() + enqueuedAt: Date = Date(), + tick: Int64? = nil ) async { let key = Key(gameID: gameID, row: row, col: col) buffer[key] = Pending( letter: letter, mark: mark, authorID: authorID, - enqueuedAt: enqueuedAt + enqueuedAt: enqueuedAt, + tick: tick ) scheduleDebounce() } @@ -138,9 +141,8 @@ actor MovesUpdater { } /// Merges buffered edits into the local device's `MovesEntity` row per - /// game. Idempotent: existing per-cell entries are kept if they have a - /// later `updatedAt` than the new edit (defensive against wall-clock - /// regressions). + /// game. Idempotent: existing per-cell entries are kept when their logical + /// revision is later than the new edit. private func persistAndMerge( snapshot: [Key: Pending], writerAuthorID: String @@ -186,10 +188,11 @@ actor MovesUpdater { letter: pending.letter, mark: pending.mark, updatedAt: pending.enqueuedAt, - authorID: pending.authorID + authorID: pending.authorID, + tick: pending.tick ) if let current = existing[position], - current.updatedAt > newCell.updatedAt { + current.compareRevision(to: newCell) == .orderedDescending { continue } existing[position] = newCell diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -1032,10 +1032,10 @@ enum RecordSerializer { databaseScope == .private ? nil : record.recordID.zoneID.ownerName entity.databaseScope = databaseScope.rawValue - // The owner chooses an immutable protocol when creating the game. - // Participants never write this field, so they can adopt it even while - // an unrelated metadata save is pending. An owner with a pending local - // save keeps its chosen value until that save round-trips. + // The owner chooses and may advance the game's protocol. Participants + // never write this field, so they can adopt it even while an unrelated + // metadata save is pending. An owner with a pending local save keeps + // its chosen value until that save round-trips. if databaseScope == .shared || !entity.hasPendingSave { entity.syncVersion = GameSyncVersion.normalized( (record["syncVersion"] as? Int64) ?? GameSyncVersion.legacy @@ -1323,7 +1323,7 @@ enum RecordSerializer { var cells = existing for (position, incomingCell) in incoming { if let existingCell = cells[position], - existingCell.updatedAt >= incomingCell.updatedAt { + existingCell.compareRevision(to: incomingCell) != .orderedAscending { continue } cells[position] = incomingCell diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -42,6 +42,67 @@ struct GameMutatorTests { #expect(game.squares[0][0].letterAuthorID == nil) } + @Test("A version 2 edit advances the largest observed logical tick") + func versionTwoEditAdvancesLogicalTick() async throws { + let (game, capture, updater, persistence) = try makeMutatorWithUpdater( + actingAuthorID: "bob" + ) + let mutator = GameMutator( + game: game, + gameID: capture.gameID, + movesUpdater: updater, + authorIDProvider: { "bob" }, + syncVersion: GameSyncVersion.logicalTicks, + initialLogicalTick: 2_000_000 + ) + + mutator.setLetter("M", atRow: 0, atCol: 0, pencil: false) + try await waitForEmittedGameID( + capture.gameID, + updater: updater, + collector: capture.collector + ) + + let persisted = try cellFromMoves( + persistence: persistence, + gameID: capture.gameID, + row: 0, + col: 0 + ) + let cell = try #require(persisted) + #expect(cell.tick == 2_000_001) + } + + @Test("A version 1 edit remains timestamp-only") + func versionOneEditHasNoLogicalTick() async throws { + let (game, capture, updater, persistence) = try makeMutatorWithUpdater( + actingAuthorID: "bob" + ) + let mutator = GameMutator( + game: game, + gameID: capture.gameID, + movesUpdater: updater, + authorIDProvider: { "bob" }, + syncVersion: GameSyncVersion.legacy + ) + + mutator.setLetter("N", atRow: 0, atCol: 0, pencil: false) + try await waitForEmittedGameID( + capture.gameID, + updater: updater, + collector: capture.collector + ) + + let persisted = try cellFromMoves( + persistence: persistence, + gameID: capture.gameID, + row: 0, + col: 0 + ) + let cell = try #require(persisted) + #expect(cell.tick == nil) + } + @Test("A completed mutator rejects every mutation") func completedMutatorIsReadOnly() throws { let (game, _, _, persistence) = try makeTestGame() diff --git a/Tests/Unit/GameStoreCompletionLockTests.swift b/Tests/Unit/GameStoreCompletionLockTests.swift @@ -144,6 +144,48 @@ struct GameStoreCompletionLockTests { #expect(game.completionState != .solved) } + @Test("Opening an owned legacy game upgrades it to the current sync version") + func ownerOpenUpgradesSyncVersion() throws { + let persistence = makeTestPersistence() + var enqueuedRecordNames: [String] = [] + let store = makeTestStore( + persistence: persistence, + onGameUpdated: { enqueuedRecordNames.append($0) } + ) + let ctx = persistence.viewContext + let (entity, gameID) = try makeGame(completed: false, in: ctx) + entity.databaseScope = DatabaseScope.private.rawValue + entity.syncVersion = GameSyncVersion.legacy + try ctx.save() + + _ = try store.loadGame(id: gameID) + + #expect(entity.syncVersion == GameSyncVersion.current) + #expect(entity.hasPendingSave) + #expect(enqueuedRecordNames == ["game-\(gameID.uuidString)"]) + } + + @Test("Opening a participant legacy game waits for the owner's upgrade") + func participantOpenDoesNotUpgradeSyncVersion() throws { + let persistence = makeTestPersistence() + var enqueuedRecordNames: [String] = [] + let store = makeTestStore( + persistence: persistence, + onGameUpdated: { enqueuedRecordNames.append($0) } + ) + let ctx = persistence.viewContext + let (entity, gameID) = try makeGame(completed: false, in: ctx) + entity.databaseScope = DatabaseScope.shared.rawValue + entity.syncVersion = GameSyncVersion.legacy + try ctx.save() + + _ = try store.loadGame(id: gameID) + + #expect(entity.syncVersion == GameSyncVersion.legacy) + #expect(!entity.hasPendingSave) + #expect(enqueuedRecordNames.isEmpty) + } + @Test("Completion delivery waits for both Game and Moves, then journal") func completionDeliveryGates() { var progress = CompletionDeliveryProgress(resigned: false) diff --git a/Tests/Unit/GridStateMergerTests.swift b/Tests/Unit/GridStateMergerTests.swift @@ -71,6 +71,72 @@ struct GridStateMergerTests { #expect(grid[GridPosition(row: 0, col: 0)]?.authorID == "bob") } + @Test("A higher logical tick wins despite an older wall clock") + func logicalTickWinsAcrossClockSkew() { + let position = GridPosition(row: 0, col: 0) + let legacyDate = Date(timeIntervalSince1970: 2_000) + let legacy = MovesValue( + gameID: gameID, + authorID: "alice", + deviceID: "legacy", + cells: [position: TimestampedCell( + letter: "N", + mark: .none, + updatedAt: legacyDate, + authorID: "alice" + )], + updatedAt: legacyDate + ) + let modern = MovesValue( + gameID: gameID, + authorID: "bob", + deviceID: "modern", + cells: [position: TimestampedCell( + letter: "M", + mark: .none, + updatedAt: Date(timeIntervalSince1970: 1), + authorID: "bob", + tick: 2_000_001 + )], + updatedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(GridStateMerger.merge([legacy, modern])[position]?.letter == "M") + } + + @Test("Logical ticks, not wall time, order modern cells") + func logicalTicksIgnoreModernWallClock() { + let position = GridPosition(row: 0, col: 0) + let lowerTick = MovesValue( + gameID: gameID, + authorID: "alice", + deviceID: "fast-clock", + cells: [position: TimestampedCell( + letter: "N", + mark: .none, + updatedAt: Date(timeIntervalSince1970: 9_999), + authorID: "alice", + tick: 40 + )], + updatedAt: Date(timeIntervalSince1970: 9_999) + ) + let higherTick = MovesValue( + gameID: gameID, + authorID: "bob", + deviceID: "slow-clock", + cells: [position: TimestampedCell( + letter: "M", + mark: .none, + updatedAt: Date(timeIntervalSince1970: 1), + authorID: "bob", + tick: 41 + )], + updatedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(GridStateMerger.merge([lowerTick, higherTick])[position]?.letter == "M") + } + @Test("Input order does not affect the merged result") func inputOrderIndependent() { let earlier = view( @@ -227,7 +293,8 @@ struct MovesCodecTests { letter: "A", mark: .pencil(checked: .wrong), updatedAt: Date(timeIntervalSince1970: 1_700_000_000), - authorID: "alice" + authorID: "alice", + tick: 42 ), GridPosition(row: 4, col: 7): TimestampedCell( letter: "", @@ -265,4 +332,11 @@ struct MovesCodecTests { ]) #expect(payload.entries.map(\.authorID) == ["bob", "alice"]) } + + @Test("Legacy payloads decode without a logical tick") + func legacyPayloadHasNoTick() throws { + let data = Data(#"{"entries":[{"row":0,"col":0,"letter":"N","markCode":0,"updatedAt":0}]}"#.utf8) + let decoded = try MovesCodec.decode(data) + #expect(decoded[GridPosition(row: 0, col: 0)]?.tick == nil) + } } diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -632,6 +632,7 @@ struct RecordSerializerTests { // Owner (databaseScope == 0): title and share marker are written. let ownerEntity = makeGame(databaseScope: 0) + ownerEntity.syncVersion = GameSyncVersion.current let ownerRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: ownerEntity.ckRecordName!)) RecordSerializer.populateGameRecord(ownerRecord, from: ownerEntity, includePuzzleSource: false) #expect(ownerRecord["title"] as? String == "Joining\u{2026}") diff --git a/Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift b/Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift @@ -157,6 +157,33 @@ struct EngagementMessageAuthenticatorTests { } } + @Test("a tampered logical tick no longer verifies") + func tamperedTickDropped() async throws { + try await Self.withAliceBobKey { _ in + var signed = Self.cellEdit(authorID: Self.alice) + signed.tick = 41 + let tags = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdit: signed), + senderAuthorID: Self.alice, + recipientAuthorIDs: [Self.bob] + ) + let authentic = EngagementMessage( + cellEdit: signed, + senderAuthorID: Self.alice, + auth: tags + ) + #expect(Self.verify(authentic, localAuthorID: Self.bob)) + var tampered = signed + tampered.tick = 42 + let received = EngagementMessage( + cellEdit: tampered, + senderAuthorID: Self.alice, + auth: tags + ) + #expect(!Self.verify(received, localAuthorID: Self.bob)) + } + } + @Test("with no key for the sender, an otherwise valid tag is dropped") func missingKeyDropped() async throws { // No directory entry and no local key are installed for the pair.