commit 5a248e38bc1e6c397a5f9a9aaa5826a127a6aeac
parent 6756becfdf92f1eca0a34803922693415c36e12d
Author: Michael Camilleri <[email protected]>
Date: Wed, 6 May 2026 09:07:39 +0900
Preserve cell author on same-letter rewrites and reveal-of-correct
Prior to this commit, every call to Game.setLetter unconditionally overwrote
the square's letterAuthorID with the acting user, and GameMutator.emitMove
attached the acting user's authorID to every emitted move. As a result, typing
across a crossing word that was already filled in by a collaborator
reattributed each shared square to the second typist, and tapping Reveal on a
correctly-filled square broadcast a move authored by the revealer even though
Game.revealCells already left the cell's contents untouched.
This commit makes Game.setLetter preserve letterAuthorID when the new letter
matches the existing entry (after the same case-normalisation the entry write
already does) so the mark can still toggle between pen and pencil but the
author is left alone. GameMutator.emitMove now reads the cell-effective author
from square.letterAuthorID rather than from the authorIDProvider, so any
preservation done by Game flows through to the persisted MoveEntity and
CellEntity.
To keep session-ping presence accurate, MoveBuffer.enqueue gains an optional
actingAuthorID parameter; the cell-effective authorID is still what gets
written to MoveEntity/CellEntity, but maybeFireSessionPing now fires on the
acting user.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
4 files changed, 172 insertions(+), 8 deletions(-)
diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift
@@ -31,15 +31,21 @@ final class Game {
/// Writes `letter` into `(row, col)`. If `pencil` is true the cell is
/// marked `.pencil(checkedWrong: false)`; otherwise the mark is cleared
/// to `.none`. Revealed cells are locked — writes are silently ignored.
+ /// When the new letter matches the existing entry, `letterAuthorID` is
+ /// preserved — typing over an existing answer (common when filling a
+ /// crossing word) shouldn't reattribute the square to the second typist.
func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, authorID: String? = nil) {
let cell = puzzle.cells[row][col]
guard !cell.isBlock else { return }
guard !squares[row][col].mark.isRevealed else { return }
let oldEntry = squares[row][col].entry
- squares[row][col].entry = letter.uppercased()
+ let newEntry = letter.uppercased()
+ squares[row][col].entry = newEntry
squares[row][col].mark = pencil ? .pencil(checkedWrong: false) : .none
- squares[row][col].letterAuthorID = authorID
- noteEntryChange(from: oldEntry, to: squares[row][col].entry, for: cell)
+ if newEntry != oldEntry {
+ squares[row][col].letterAuthorID = authorID
+ }
+ noteEntryChange(from: oldEntry, to: newEntry, for: cell)
}
/// Clears the entry and mark at `(row, col)`. No-op on revealed cells.
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -98,7 +98,12 @@ final class GameMutator {
let (markKind, checkedWrong) = encodeMark(square.mark)
let id = gameID
let letter = square.entry
- let authorID = authorIDProvider?()
+ // The cell's `letterAuthorID` is the canonical author for the square —
+ // it may differ from the acting user when a same-letter write or a
+ // reveal-of-correct preserved the original author. The acting user is
+ // still passed separately so MoveBuffer can fire session pings.
+ let cellAuthorID = square.letterAuthorID
+ let actingAuthorID = authorIDProvider?()
Task {
await moveBuffer.enqueue(
gameID: id,
@@ -106,7 +111,8 @@ final class GameMutator {
letter: letter,
markKind: markKind,
checkedWrong: checkedWrong,
- authorID: authorID
+ authorID: cellAuthorID,
+ actingAuthorID: actingAuthorID
)
}
}
diff --git a/Crossmate/Sync/MoveBuffer.swift b/Crossmate/Sync/MoveBuffer.swift
@@ -69,6 +69,13 @@ actor MoveBuffer {
/// Registers a cell edit. If the edit targets a different cell than the
/// previous enqueue, the previous cell is flushed first so the resulting
/// lamport order matches the user's editing order.
+ ///
+ /// `authorID` is the cell-effective author that gets persisted on the
+ /// `MoveEntity` and `CellEntity`. `actingAuthorID` is the user who
+ /// performed the action — usually the same value, but a same-letter
+ /// rewrite or a reveal-of-correct preserves the cell's original author
+ /// while the acting user is the typist. Session pings fire on the acting
+ /// user so presence reflects who is actually live in the game.
func enqueue(
gameID: UUID,
row: Int,
@@ -76,7 +83,8 @@ actor MoveBuffer {
letter: String,
markKind: Int16,
checkedWrong: Bool,
- authorID: String?
+ authorID: String?,
+ actingAuthorID: String? = nil
) async {
let key = Key(gameID: gameID, row: row, col: col)
@@ -97,8 +105,9 @@ actor MoveBuffer {
lastCell = key
scheduleDebounce()
- if let authorID, !authorID.isEmpty {
- await maybeFireSessionPing(gameID: gameID, authorID: authorID)
+ let pingAuthorID = actingAuthorID ?? authorID
+ if let pingAuthorID, !pingAuthorID.isEmpty {
+ await maybeFireSessionPing(gameID: gameID, authorID: pingAuthorID)
}
}
diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift
@@ -146,4 +146,147 @@ struct GameMutatorTests {
mutator.clearCells(game.puzzle.cells.flatMap { $0 })
#expect(game.completionState == .solved)
}
+
+ // MARK: - Author preservation
+
+ @Test("setLetter preserves the existing author when the letter is unchanged")
+ func setLetterSameLetterPreservesAuthor() throws {
+ let (game, _, _, _) = try makeTestGame()
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ }
+
+ @Test("setLetter normalises case before deciding whether the letter changed")
+ func setLetterSameLetterCaseInsensitivePreservesAuthor() throws {
+ let (game, _, _, _) = try makeTestGame()
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ game.setLetter("a", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
+
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ }
+
+ @Test("setLetter overwrites the author when the letter changes")
+ func setLetterDifferentLetterOverwritesAuthor() throws {
+ let (game, _, _, _) = try makeTestGame()
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ game.setLetter("B", atRow: 0, atCol: 0, pencil: false, authorID: "bob")
+
+ #expect(game.squares[0][0].entry == "B")
+ #expect(game.squares[0][0].letterAuthorID == "bob")
+ }
+
+ @Test("revealCells preserves the existing author for cells already correct")
+ func revealCellsPreservesAuthorOnCorrect() throws {
+ let (game, _, _, _) = try makeTestGame()
+ // Cell (0,0) has solution "A".
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ game.revealCells([game.puzzle.cells[0][0]])
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ }
+
+ // MARK: - Move emission carries the cell-effective author
+
+ @Test("Same-letter rewrite emits a move carrying the preserved author, not the acting user")
+ func sameLetterEmitsMoveWithPreservedAuthor() async throws {
+ let (game, capture, buffer, _) = try makeMutatorWithBuffer(actingAuthorID: "bob")
+
+ // Alice already entered "A" — seed via Game directly so the buffer
+ // only sees Bob's subsequent action.
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ let mutator = makeMutator(
+ game: game,
+ buffer: buffer,
+ gameID: capture.gameID,
+ actingAuthorID: "bob"
+ )
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+
+ try await Task.sleep(for: .milliseconds(50))
+ await buffer.flush()
+
+ let moves = await capture.collector.allMoves
+ #expect(moves.count == 1)
+ #expect(moves.first?.letter == "A")
+ #expect(moves.first?.authorID == "alice")
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ }
+
+ @Test("Reveal of an already-correct cell emits a move carrying the preserved author")
+ func revealCorrectEmitsMoveWithPreservedAuthor() async throws {
+ let (game, capture, buffer, _) = try makeMutatorWithBuffer(actingAuthorID: "bob")
+
+ // Cell (0,0)'s solution is "A". Alice has already filled it in.
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ let mutator = makeMutator(
+ game: game,
+ buffer: buffer,
+ gameID: capture.gameID,
+ actingAuthorID: "bob"
+ )
+ mutator.revealCells([game.puzzle.cells[0][0]])
+
+ try await Task.sleep(for: .milliseconds(50))
+ await buffer.flush()
+
+ let moves = await capture.collector.allMoves
+ #expect(moves.count == 1)
+ #expect(moves.first?.authorID == "alice")
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ // The cell was already correct, so it is not locked into `.revealed`.
+ #expect(game.squares[0][0].mark == .none)
+ }
+
+ // MARK: - Test scaffolding for buffer-emission tests
+
+ actor MoveCollector {
+ private(set) var allMoves: [Move] = []
+ func append(_ moves: [Move]) { allMoves.append(contentsOf: moves) }
+ }
+
+ struct BufferHarness {
+ let collector: MoveCollector
+ let gameID: UUID
+ }
+
+ /// Builds a `Game` plus a `MoveBuffer` whose sink is captured. Returns
+ /// the game (so callers can seed state via `Game.setLetter` directly),
+ /// the capture harness, the buffer, and the persistence controller.
+ private func makeMutatorWithBuffer(
+ actingAuthorID: String
+ ) throws -> (Game, BufferHarness, MoveBuffer, PersistenceController) {
+ let (game, _, entity, persistence) = try makeTestGame()
+ let collector = MoveCollector()
+ let buffer = MoveBuffer(
+ debounceInterval: .seconds(10),
+ persistence: persistence,
+ sink: { await collector.append($0) }
+ )
+ let gameID = entity.id ?? UUID()
+ return (game, BufferHarness(collector: collector, gameID: gameID), buffer, persistence)
+ }
+
+ private func makeMutator(
+ game: Game,
+ buffer: MoveBuffer,
+ gameID: UUID,
+ actingAuthorID: String
+ ) -> GameMutator {
+ GameMutator(
+ game: game,
+ gameID: gameID,
+ moveBuffer: buffer,
+ authorIDProvider: { actingAuthorID }
+ )
+ }
}