commit b55539b241a7d1967ce2dc5e012e2003a950ceae
parent 513adfa1c71a2344e7d09a278fc0449fb2cf91d9
Author: Michael Camilleri <[email protected]>
Date: Thu, 4 Jun 2026 07:51:43 +0900
Restore observed cell state when undoing local edits
Undo previously recovered a move's before-state by following this device's
local `prevSeqAtCell` journal link. That works for solo solving, but it loses
state that arrived from a collaborator: if Alice's letter was visible only via
the merged grid and Bob overwrote it, Bob's local journal had no prior row for
that cell, so undo restored an empty square instead of Alice's letter.
This commit records the cell state observed immediately before each new
local journal row and use that as the undo baseline, falling back to the
old previous-link derivation for older rows. GameMutator now threads its
already-captured pre-edit state into the journal for single-cell edits,
bulk gestures, and undo/redo rows, so undo remains a fresh forward
mutation while restoring the right letter, mark and cell author.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 95 insertions(+), 5 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -122,6 +122,9 @@
<entity name="JournalEntity" representedClassName="JournalEntity" syncable="YES" codeGenerationType="class">
<attribute name="actingAuthorID" optional="YES" attributeType="String"/>
<attribute name="batchID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="beforeCellAuthorID" optional="YES" attributeType="String"/>
+ <attribute name="beforeLetter" optional="YES" attributeType="String"/>
+ <attribute name="beforeMarkCode" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<attribute name="cellAuthorID" optional="YES" attributeType="String"/>
<attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="dir" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -82,14 +82,26 @@ final class GameMutator {
guard !isCompleted else { return }
let before = cellState(atRow: row, atCol: col)
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
- emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col), direction: direction)
+ emitMove(
+ atRow: row,
+ atCol: col,
+ beforeState: before,
+ journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col),
+ direction: direction
+ )
}
func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
guard !isCompleted else { return }
let before = cellState(atRow: row, atCol: col)
game.clearLetter(atRow: row, atCol: col)
- emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col), direction: direction)
+ emitMove(
+ atRow: row,
+ atCol: col,
+ beforeState: before,
+ journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col),
+ direction: direction
+ )
}
// MARK: - Bulk mutations
@@ -131,6 +143,7 @@ final class GameMutator {
emitMove(
atRow: cell.row,
atCol: cell.col,
+ beforeState: priorState,
journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col),
batchID: batch
)
@@ -222,7 +235,14 @@ final class GameMutator {
authorID: restore.restoreTo.cellAuthorID,
atRow: row, atCol: col
)
- emitMove(atRow: row, atCol: col, journalKind: kind, batchID: batch, targetSeq: restore.targetSeq)
+ emitMove(
+ atRow: row,
+ atCol: col,
+ beforeState: current,
+ journalKind: kind,
+ batchID: batch,
+ targetSeq: restore.targetSeq
+ )
appliedAny = true
}
}
@@ -268,6 +288,7 @@ final class GameMutator {
private func emitMove(
atRow row: Int,
atCol col: Int,
+ beforeState: JournalCellState? = nil,
journalKind: JournalKind? = nil,
batchID: UUID? = nil,
targetSeq: Int64? = nil,
@@ -291,6 +312,7 @@ final class GameMutator {
movesJournal?.record(
gameID: id,
position: GridPosition(row: row, col: col),
+ beforeState: beforeState,
state: JournalCellState(letter: letter, mark: square.mark, cellAuthorID: cellAuthorID),
actingAuthorID: actingAuthorID,
kind: journalKind,
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -50,6 +50,12 @@ struct JournalValue: Equatable, Sendable {
let seq: Int64
let timestamp: Date
let position: GridPosition
+ /// The cell state observed immediately before this local touch. New local
+ /// rows carry this so undo can restore a collaborator's pre-existing
+ /// letter even though that letter is not in this device's local journal.
+ /// Older rows and replay-upload rows may be nil and fall back to
+ /// `prevSeqAtCell`.
+ let beforeState: JournalCellState?
let state: JournalCellState
let actingAuthorID: String?
let kind: JournalKind
@@ -61,6 +67,32 @@ struct JournalValue: Equatable, Sendable {
/// for `.input` entries; `nil` for clears, help gestures, and undo/redo
/// rows (whose cursor direction comes from the input op they reverse).
let direction: Puzzle.Direction?
+
+ init(
+ seq: Int64,
+ timestamp: Date,
+ position: GridPosition,
+ beforeState: JournalCellState? = nil,
+ state: JournalCellState,
+ actingAuthorID: String?,
+ kind: JournalKind,
+ targetSeq: Int64?,
+ batchID: UUID?,
+ prevSeqAtCell: Int64?,
+ direction: Puzzle.Direction? = nil
+ ) {
+ self.seq = seq
+ self.timestamp = timestamp
+ self.position = position
+ self.beforeState = beforeState
+ self.state = state
+ self.actingAuthorID = actingAuthorID
+ self.kind = kind
+ self.targetSeq = targetSeq
+ self.batchID = batchID
+ self.prevSeqAtCell = prevSeqAtCell
+ self.direction = direction
+ }
}
/// One cell to rewrite as part of an undo or redo, with the guard value the
@@ -139,6 +171,7 @@ final class MovesJournal {
func record(
gameID: UUID,
position: GridPosition,
+ beforeState: JournalCellState? = nil,
state: JournalCellState,
actingAuthorID: String?,
kind: JournalKind,
@@ -151,6 +184,7 @@ final class MovesJournal {
seq: nextSeq,
timestamp: Date(),
position: position,
+ beforeState: beforeState,
state: state,
actingAuthorID: actingAuthorID,
kind: kind,
@@ -198,7 +232,7 @@ final class MovesJournal {
let restores = op.entries.map { entry in
JournalRestore(
position: entry.position,
- restoreTo: beforeState(of: entry),
+ restoreTo: observedBeforeState(of: entry),
expectedCurrent: entry.state,
targetSeq: entry.seq
)
@@ -215,7 +249,7 @@ final class MovesJournal {
JournalRestore(
position: entry.position,
restoreTo: entry.state,
- expectedCurrent: beforeState(of: entry),
+ expectedCurrent: observedBeforeState(of: entry),
targetSeq: entry.seq
)
}
@@ -256,6 +290,10 @@ final class MovesJournal {
return value.state
}
+ private func observedBeforeState(of entry: JournalValue) -> JournalCellState {
+ entry.beforeState ?? beforeState(of: entry)
+ }
+
/// Groups the log into operations (a bulk gesture or one undo/redo op is a
/// single operation; each single-cell edit is its own), then runs the stack
/// machine. `live` holds applied undoable operations newest-last; `redo`
@@ -395,6 +433,9 @@ final class MovesJournal {
entity.timestamp = value.timestamp
entity.row = Int16(value.position.row)
entity.col = Int16(value.position.col)
+ entity.beforeLetter = value.beforeState?.letter
+ entity.beforeMarkCode = value.beforeState.map { NSNumber(value: $0.mark.code) }
+ entity.beforeCellAuthorID = value.beforeState?.cellAuthorID
entity.letter = value.state.letter
entity.markCode = value.state.mark.code
entity.cellAuthorID = value.state.cellAuthorID
@@ -414,6 +455,13 @@ final class MovesJournal {
seq: entity.seq,
timestamp: entity.timestamp ?? .distantPast,
position: GridPosition(row: Int(entity.row), col: Int(entity.col)),
+ beforeState: entity.beforeLetter.map {
+ JournalCellState(
+ letter: $0,
+ mark: CellMark(code: entity.beforeMarkCode?.int16Value ?? 0),
+ cellAuthorID: entity.beforeCellAuthorID
+ )
+ },
state: JournalCellState(
letter: entity.letter ?? "",
mark: CellMark(code: entity.markCode),
@@ -529,6 +577,7 @@ enum JournalCodec {
seq: entry.seq,
timestamp: entry.timestamp,
position: GridPosition(row: entry.row, col: entry.col),
+ beforeState: nil,
state: JournalCellState(
letter: entry.letter,
mark: CellMark(code: entry.markCode),
diff --git a/Tests/Unit/MovesJournalTests.swift b/Tests/Unit/MovesJournalTests.swift
@@ -54,6 +54,22 @@ struct MovesJournalTests {
#expect(game.squares[0][0].entry == "")
}
+ @Test("undo restores a collaborator letter that was overwritten locally")
+ func undoRestoresObservedCollaboratorLetter() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ // Remote changes bypass this device's journal, but they are visible in
+ // the live grid by the time the local overwrite happens.
+ game.setLetter("A", atRow: 0, atCol: 0, pencil: false, authorID: "alice")
+
+ mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false)
+ #expect(game.squares[0][0].entry == "B")
+
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][0].letterAuthorID == "alice")
+ }
+
@Test("undo walks back across multiple cells in order")
func undoWalksBackMultipleCells() throws {
let (game, mutator, _, _) = try makeTestGame()