commit 6fe44e2331fe8c25fb69d74c3c4f2029b663a4c7
parent 008ff511015134a2ac84de5d8cedb4a83381d276
Author: Michael Camilleri <[email protected]>
Date: Sat, 30 May 2026 07:12:00 +0900
Add move journal with undo/redo
This commit introduces a local, append-only journal (JournalEntity) that
records every grid-changing move into Core Data, never synced. It powers
depth-unlimited undo/redo during play and is the substrate for game
replay later.
Every move is recorded so a game can be replayed faithfully, but only
the undoable subset is reversible: letter input (adds and single-cell
deletes) and the bulk clear gesture. Checks and reveals are logged for
replay but never offered as undo steps — they are help actions, not
edits to rewind. Undo applies a forward mutation through the normal
GameMutator path, so it syncs to collaborators like any other edit
rather than rewinding time. A per-cell prevSeqAtCell back-pointer
recovers the before-state in O(1) without scanning the log, and a
letter-only supersession guard skips cells a collaborator has changed
since, so undo never clobbers someone else's letter.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
9 files changed, 877 insertions(+), 51 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -77,6 +77,7 @@
8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; };
903681985C17FCB5F97773A9 /* OpenPuzzleBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */; };
91703E54DB4679C1911BF994 /* Moves.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86470163BFF956F3DE438506 /* Moves.swift */; };
+ 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3D29B227D2B0E699423C48 /* Journal.swift */; };
9582AA583F5EA008FFC82B64 /* ZoneOrphaningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */; };
9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; };
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */; };
@@ -127,10 +128,12 @@
E354A588DBA74627A9CD5591 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4FF046BF772646B5CA73F /* Presence.swift */; };
E632562D090D8BE907F28C53 /* NotificationStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47532AED239AEF476D8E9206 /* NotificationStateTests.swift */; };
E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; };
+ EB877F0E759810CE84425237 /* CellMarkCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */; };
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; };
ED6C21CD9F5AB286B69A02E4 /* GridStateMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */; };
F34EDFD45E2F5006807DDAC7 /* PuzzleCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8560440C548752EE93E0ED9 /* PuzzleCatalogTests.swift */; };
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; };
+ F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */; };
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; };
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; };
FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; };
@@ -170,6 +173,7 @@
/* Begin PBXFileReference section */
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTLoginView.swift; sourceTree = "<group>"; };
07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; };
+ 09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMarkCodec.swift; sourceTree = "<group>"; };
0BDC16DA7F762F4C5F4BED14 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; };
@@ -226,6 +230,7 @@
71DFD035381B6252DCD873C9 /* PlayerNamePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisher.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
+ 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesJournalTests.swift; sourceTree = "<group>"; };
7A3E1CB3BA66CA884A4CC985 /* LocalMovesSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMovesSnapshot.swift; sourceTree = "<group>"; };
7A4AFF292381C9B33C0F2CD6 /* FriendZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendZone.swift; sourceTree = "<group>"; };
7B3E1A382B24A7803701D947 /* Crossmate.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Crossmate.entitlements; sourceTree = "<group>"; };
@@ -278,6 +283,7 @@
CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
CE54EF557E8D808BAA20EA54 /* NYTPuzzleUpgrader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleUpgrader.swift; sourceTree = "<group>"; };
+ CF3D29B227D2B0E699423C48 /* Journal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Journal.swift; sourceTree = "<group>"; };
CFC4FF046BF772646B5CA73F /* Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = "<group>"; };
D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; };
D491B7232333AA8957732387 /* PendingEditFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingEditFlagTests.swift; sourceTree = "<group>"; };
@@ -359,6 +365,7 @@
9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */,
31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */,
6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */,
+ 78C92190C4A344EC319A0F88 /* MovesJournalTests.swift */,
9AF6157D97271205626E207C /* MovesUpdaterTests.swift */,
FEDD63AD5E33E2B0399780EF /* NotificationNavigationBrokerTests.swift */,
47532AED239AEF476D8E9206 /* NotificationStateTests.swift */,
@@ -387,6 +394,7 @@
isa = PBXGroup;
children = (
B135C285570F91181595B405 /* CellMark.swift */,
+ 09734570F81F9D1DAF4CC9FF /* CellMarkCodec.swift */,
0FD9A43789D0ED123F7A99B0 /* CheckResult.swift */,
B09D52DB46731E92C3E9297C /* EngagementStore.swift */,
465F2BB469EFE84CF3733398 /* Game.swift */,
@@ -414,6 +422,7 @@
children = (
43DC132D49361C56DE79C13E /* GameMutator.swift */,
93EE5BA78566EDED68D846AB /* GameStore.swift */,
+ CF3D29B227D2B0E699423C48 /* Journal.swift */,
ACC295195602B3DDF7BB3895 /* PersistenceController.swift */,
);
path = Persistence;
@@ -691,6 +700,7 @@
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */,
AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */,
DE90CC8BE23A0EFC4A32FFA5 /* MovesInboundTests.swift in Sources */,
+ F5F333B36654AEAF69A3C220 /* MovesJournalTests.swift in Sources */,
C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */,
C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */,
50C02D37A41D55CFA5D307E2 /* NYTPuzzleUpgraderTests.swift in Sources */,
@@ -731,6 +741,7 @@
AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */,
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */,
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */,
+ EB877F0E759810CE84425237 /* CellMarkCodec.swift in Sources */,
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */,
5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */,
E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */,
@@ -764,6 +775,7 @@
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */,
8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */,
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */,
+ 9502840161DB88BB6BB409D5 /* Journal.swift in Sources */,
F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */,
38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */,
66A2B6DAB2F132950789CA98 /* LocalMovesSnapshot.swift in Sources */,
diff --git a/Crossmate/Models/CellMarkCodec.swift b/Crossmate/Models/CellMarkCodec.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+/// Single source of truth for the `(markKind, checkedRight, checkedWrong)`
+/// triple that the Moves wire format, `CellEntity`, and `JournalEntity` all use
+/// to persist a `CellMark`. The pair of booleans encodes an optional
+/// `CheckResult`: (false, false) = nil, (true, false) = .right,
+/// (false, true) = .wrong. `markKind` is 0 none / 1 pen / 2 pencil / 3 revealed.
+enum CellMarkCodec {
+ static func encode(_ mark: CellMark) -> (kind: Int16, checkedRight: Bool, checkedWrong: Bool) {
+ switch mark {
+ case .none:
+ return (0, false, false)
+ case .pen(let check):
+ return (1, check == .right, check == .wrong)
+ case .pencil(let check):
+ return (2, check == .right, check == .wrong)
+ case .revealed:
+ return (3, false, false)
+ }
+ }
+
+ /// Inverse of `encode`. `checkedWrong` takes precedence if both somehow
+ /// ended up true (shouldn't happen — the invariant is enforced where marks
+ /// are constructed in `Game`, not here).
+ static func decode(kind: Int16, checkedRight: Bool, checkedWrong: Bool) -> CellMark {
+ let check: CheckResult?
+ if checkedWrong {
+ check = .wrong
+ } else if checkedRight {
+ check = .right
+ } else {
+ check = nil
+ }
+ switch kind {
+ case 1: return .pen(checked: check)
+ case 2: return .pencil(checked: check)
+ case 3: return .revealed
+ default: return .none
+ }
+ }
+
+ // MARK: - Single-value encoding
+
+ /// Losslessly maps the eight legal `CellMark` states to one `Int16`. Used
+ /// by `JournalEntity`, which models the whole mark as a single field rather
+ /// than the `markKind` + two-bool flattening the synced Moves format uses.
+ static func code(_ mark: CellMark) -> Int16 {
+ switch mark {
+ case .none: return 0
+ case .pen(nil): return 1
+ case .pen(.right): return 2
+ case .pen(.wrong): return 3
+ case .pencil(nil): return 4
+ case .pencil(.right): return 5
+ case .pencil(.wrong): return 6
+ case .revealed: return 7
+ }
+ }
+
+ /// Inverse of `code(_:)`. Unknown codes decode to `.none`.
+ static func mark(code: Int16) -> CellMark {
+ switch code {
+ case 1: return .pen(checked: nil)
+ case 2: return .pen(checked: .right)
+ case 3: return .pen(checked: .wrong)
+ case 4: return .pencil(checked: nil)
+ case 5: return .pencil(checked: .right)
+ case 6: return .pencil(checked: .wrong)
+ case 7: return .revealed
+ default: return .none
+ }
+ }
+}
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -29,6 +29,7 @@
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/>
+ <relationship name="journal" toMany="YES" deletionRule="Cascade" destinationEntity="JournalEntity" inverseName="game" inverseEntity="JournalEntity"/>
<relationship name="moves" toMany="YES" deletionRule="Cascade" destinationEntity="MovesEntity" inverseName="game" inverseEntity="MovesEntity"/>
<relationship name="players" toMany="YES" deletionRule="Cascade" destinationEntity="PlayerEntity" inverseName="game" inverseEntity="PlayerEntity"/>
</entity>
@@ -118,4 +119,24 @@
<fetchIndexElement property="status" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
+ <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="cellAuthorID" optional="YES" attributeType="String"/>
+ <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="gameID" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="kind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="letter" attributeType="String" defaultValueString=""/>
+ <attribute name="markCode" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="prevSeqAtCell" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
+ <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="seq" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="targetSeq" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/>
+ <attribute name="timestamp" attributeType="Date" usesScalarValueType="NO"/>
+ <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="journal" inverseEntity="GameEntity"/>
+ <fetchIndex name="byGameAndSeq">
+ <fetchIndexElement property="gameID" type="Binary" order="ascending"/>
+ <fetchIndexElement property="seq" type="Binary" order="ascending"/>
+ </fetchIndex>
+ </entity>
</model>
diff --git a/Crossmate/Models/Game.swift b/Crossmate/Models/Game.swift
@@ -61,6 +61,21 @@ final class Game {
noteEntryChange(from: oldEntry, to: "", for: cell)
}
+ /// Directly writes a cell's full state — entry, mark, and letter author —
+ /// used by undo/redo to restore a previously recorded value. Unlike
+ /// `setLetter`/`clearLetter` this deliberately has no revealed-cell lock:
+ /// reversing a reveal is a legitimate undo. Block cells are still rejected.
+ func applyCellState(_ letter: String, mark: CellMark, authorID: String?, atRow row: Int, atCol col: Int) {
+ let cell = puzzle.cells[row][col]
+ guard !cell.isBlock else { return }
+ let oldEntry = squares[row][col].entry
+ let newEntry = letter.uppercased()
+ squares[row][col].entry = newEntry
+ squares[row][col].mark = mark
+ squares[row][col].letterAuthorID = authorID
+ noteEntryChange(from: oldEntry, to: newEntry, for: cell)
+ }
+
// MARK: - Check / Reveal / Clear
/// For each non-empty, non-revealed target cell, compares the current
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -16,6 +16,7 @@ final class GameMutator {
private let game: Game
let gameID: UUID
private let movesUpdater: MovesUpdater?
+ private let movesJournal: MovesJournal?
private let authorIDProvider: (@MainActor () -> String?)?
private let onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)?
@@ -36,6 +37,7 @@ final class GameMutator {
game: Game,
gameID: UUID,
movesUpdater: MovesUpdater?,
+ movesJournal: MovesJournal? = nil,
authorIDProvider: (@MainActor () -> String?)? = nil,
onLocalCellEdit: (@MainActor (RealtimeCellEdit) -> Void)? = nil,
isOwned: Bool = true,
@@ -45,6 +47,7 @@ final class GameMutator {
self.game = game
self.gameID = gameID
self.movesUpdater = movesUpdater
+ self.movesJournal = movesJournal
self.authorIDProvider = authorIDProvider
self.onLocalCellEdit = onLocalCellEdit
self.isOwned = isOwned
@@ -55,48 +58,147 @@ final class GameMutator {
// MARK: - Single-cell mutations
func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) {
+ let before = cellState(atRow: row, atCol: col)
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
- emitMove(atRow: row, atCol: col)
+ emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col))
}
func clearLetter(atRow row: Int, atCol col: Int) {
+ let before = cellState(atRow: row, atCol: col)
game.clearLetter(atRow: row, atCol: col)
- emitMove(atRow: row, atCol: col)
+ emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col))
}
// MARK: - Bulk mutations
+ // Every gesture is journaled (so it can be replayed), but only `clear` is
+ // undoable among these — `check` and `reveal` are help actions, recorded
+ // but never offered as undo steps. Each bulk gesture is one undo/replay
+ // step via a shared batch ID; cells that didn't actually change record no
+ // entry.
+
func checkCells(_ cells: [Puzzle.Cell]) {
- let applicable = cells.filter { !$0.isBlock }
- guard !applicable.isEmpty else { return }
- game.checkCells(applicable)
- for cell in applicable {
- emitMove(atRow: cell.row, atCol: cell.col)
- }
+ applyBulk(cells, kind: .check) { game.checkCells($0) }
}
func revealCells(_ cells: [Puzzle.Cell]) {
- let applicable = cells.filter { !$0.isBlock }
- guard !applicable.isEmpty else { return }
- game.revealCells(applicable)
- for cell in applicable {
- emitMove(atRow: cell.row, atCol: cell.col)
- }
+ applyBulk(cells, kind: .reveal) { game.revealCells($0) }
}
func clearCells(_ cells: [Puzzle.Cell]) {
+ applyBulk(cells, kind: .clear) { game.clearCells($0) }
+ }
+
+ private func applyBulk(
+ _ cells: [Puzzle.Cell],
+ kind: JournalKind,
+ _ mutate: ([Puzzle.Cell]) -> Void
+ ) {
let applicable = cells.filter { !$0.isBlock }
guard !applicable.isEmpty else { return }
- game.clearCells(applicable)
- for cell in applicable {
- emitMove(atRow: cell.row, atCol: cell.col)
+ let before = applicable.map { cellState(atRow: $0.row, atCol: $0.col) }
+ mutate(applicable)
+ let batch = UUID()
+ for (cell, priorState) in zip(applicable, before) {
+ emitMove(
+ atRow: cell.row,
+ atCol: cell.col,
+ journalKind: self.kind(kind, ifChangedFrom: priorState, atRow: cell.row, atCol: cell.col),
+ batchID: batch
+ )
}
}
+ // MARK: - Undo / redo
+
+ /// `true` when there is a still-undoable move by this user. Reading these
+ /// is cheap (a derivation pass over the in-memory journal) and drives the
+ /// enabled state of the undo/redo controls.
+ var canUndo: Bool {
+ guard !isAccessRevoked, let movesJournal else { return false }
+ return movesJournal.canUndo(gameID: gameID)
+ }
+
+ var canRedo: Bool {
+ guard !isAccessRevoked, let movesJournal else { return false }
+ return movesJournal.canRedo(gameID: gameID)
+ }
+
+ /// Reverts the most recent still-standing move. Each restored cell is
+ /// applied as a fresh forward mutation (so it syncs like any edit) and
+ /// recorded as an `undo` row. Cells a collaborator has changed since are
+ /// skipped via the supersession guard; if a whole step was superseded it is
+ /// passed over so undo lands on the next still-standing move.
+ func undo() {
+ guard !isAccessRevoked, let movesJournal else { return }
+ while let plan = movesJournal.planUndo(gameID: gameID) {
+ if applyRestores(plan.restores, kind: .undo) { return }
+ movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
+ }
+ }
+
+ /// Re-applies the most recently undone move. Mirror of `undo()`.
+ func redo() {
+ guard !isAccessRevoked, let movesJournal else { return }
+ while let plan = movesJournal.planRedo(gameID: gameID) {
+ if applyRestores(plan.restores, kind: .redo) { return }
+ movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
+ }
+ }
+
+ /// Applies the surviving cells of a plan under one batch, returning whether
+ /// any cell was applied (a fully-superseded step applies nothing).
+ private func applyRestores(_ restores: [JournalRestore], kind: JournalKind) -> Bool {
+ let batch = UUID()
+ var appliedAny = false
+ for restore in restores {
+ let row = restore.position.row
+ let col = restore.position.col
+ let square = game.squares[row][col]
+ let current = JournalCellState(
+ letter: square.entry,
+ mark: square.mark,
+ cellAuthorID: square.letterAuthorID
+ )
+ guard current.letterMatches(restore.expectedCurrent) else { continue }
+ game.applyCellState(
+ restore.restoreTo.letter,
+ mark: restore.restoreTo.mark,
+ authorID: restore.restoreTo.cellAuthorID,
+ atRow: row, atCol: col
+ )
+ emitMove(atRow: row, atCol: col, journalKind: kind, batchID: batch, targetSeq: restore.targetSeq)
+ appliedAny = true
+ }
+ return appliedAny
+ }
+
+ /// The cell's current full state, for change detection and the
+ /// supersession guard.
+ private func cellState(atRow row: Int, atCol col: Int) -> JournalCellState {
+ let square = game.squares[row][col]
+ return JournalCellState(letter: square.entry, mark: square.mark, cellAuthorID: square.letterAuthorID)
+ }
+
+ /// Returns `kind` when the cell's state actually changed (letter or mark),
+ /// `nil` otherwise — a same-letter rewrite, a no-op write to a revealed
+ /// cell, or a check/clear that skipped a cell records nothing.
+ private func kind(_ kind: JournalKind, ifChangedFrom before: JournalCellState, atRow row: Int, atCol col: Int) -> JournalKind? {
+ let square = game.squares[row][col]
+ let changed = square.entry != before.letter || square.mark != before.mark
+ return changed ? kind : nil
+ }
+
// MARK: - Helpers
- private func emitMove(atRow row: Int, atCol col: Int) {
- guard let movesUpdater, !isAccessRevoked else { return }
+ private func emitMove(
+ atRow row: Int,
+ atCol col: Int,
+ journalKind: JournalKind? = nil,
+ batchID: UUID? = nil,
+ targetSeq: Int64? = nil
+ ) {
+ guard !isAccessRevoked else { return }
let square = game.squares[row][col]
let (markKind, checkedRight, checkedWrong) = encodeMark(square.mark)
let id = gameID
@@ -106,6 +208,23 @@ final class GameMutator {
// reveal-of-correct preserved the original author.
let cellAuthorID = square.letterAuthorID
let actingAuthorID = authorIDProvider?()
+
+ // Only letter adds/deletes (and undo/redo of them) are journaled;
+ // `journalKind == nil` means this move is not undoable (check/reveal,
+ // or a no-op write). Recording is independent of whether sync is wired.
+ if let journalKind {
+ movesJournal?.record(
+ gameID: id,
+ position: GridPosition(row: row, col: col),
+ state: JournalCellState(letter: letter, mark: square.mark, cellAuthorID: cellAuthorID),
+ actingAuthorID: actingAuthorID,
+ kind: journalKind,
+ targetSeq: targetSeq,
+ batchID: batchID
+ )
+ }
+
+ guard let movesUpdater else { return }
// Stamp the flag on the MainActor *before* the Task hops to the
// actor, atomically with the value the user just typed. While it's
// set, `GameStore.restore` won't overwrite this square from a remote
@@ -146,19 +265,8 @@ final class GameMutator {
}
/// Flattens `CellMark` into the (kind, checkedRight, checkedWrong) triple
- /// that the Moves wire format and Core Data persistence store. The pair
- /// of booleans is the on-disk encoding of an optional `CheckResult`:
- /// (false, false) = nil, (true, false) = .right, (false, true) = .wrong.
+ /// that the Moves wire format and Core Data persistence store.
private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedRight: Bool, checkedWrong: Bool) {
- switch mark {
- case .none:
- return (0, false, false)
- case .pen(let check):
- return (1, check == .right, check == .wrong)
- case .pencil(let check):
- return (2, check == .right, check == .wrong)
- case .revealed:
- return (3, false, false)
- }
+ CellMarkCodec.encode(mark)
}
}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -215,6 +215,7 @@ final class GameStore {
private(set) var currentEntity: GameEntity?
private let movesUpdater: MovesUpdater
+ private let movesJournal: MovesJournal
/// Returns the current iCloud author ID, or nil while the first
/// `userRecordID()` lookup is still pending. The inner Optional reflects
@@ -252,6 +253,10 @@ final class GameStore {
) {
self.persistence = persistence
self.movesUpdater = movesUpdater
+ // The journal needs nothing but the local store, so the store owns it
+ // rather than having callers thread it in (unlike MovesUpdater, which
+ // depends on identity + the sync sink and so is built in AppServices).
+ self.movesJournal = MovesJournal(persistence: persistence)
self.authorIDProvider = authorIDProvider
self.onGameCreated = onGameCreated
self.onGameUpdated = onGameUpdated
@@ -1365,6 +1370,7 @@ final class GameStore {
game: game,
gameID: gameID,
movesUpdater: movesUpdater,
+ movesJournal: movesJournal,
authorIDProvider: authorIDProvider,
onLocalCellEdit: { [weak self] edit in
self?.onLocalCellEdit?(edit)
@@ -1408,24 +1414,8 @@ final class GameStore {
// MARK: - CellMark coding
- /// Inverse of `GameMutator.encodeMark`. The (checkedRight, checkedWrong)
- /// pair on disk maps to an optional `CheckResult`; `checkedWrong` takes
- /// precedence if both somehow ended up true (shouldn't happen, but the
- /// invariant is enforced at the construction sites in `Game`, not here).
+ /// Inverse of `GameMutator.encodeMark`.
private func decodeMark(kind: Int16, checkedRight: Bool, checkedWrong: Bool) -> CellMark {
- let check: CheckResult?
- if checkedWrong {
- check = .wrong
- } else if checkedRight {
- check = .right
- } else {
- check = nil
- }
- switch kind {
- case 1: return .pen(checked: check)
- case 2: return .pencil(checked: check)
- case 3: return .revealed
- default: return .none
- }
+ CellMarkCodec.decode(kind: kind, checkedRight: checkedRight, checkedWrong: checkedWrong)
}
}
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -0,0 +1,375 @@
+import CoreData
+import Foundation
+
+/// Why a journal entry exists. Every grid-changing move is recorded so the
+/// whole game can be replayed (Phase 2). Letter `input` (adds/deletes) and
+/// `clear` (the bulk clear gesture) are undoable; `check`/`reveal` are recorded
+/// for replay but the undo/redo machine never offers them as steps — checking
+/// and revealing are "help" actions, not edits you rewind. `undo`/`redo` rows
+/// are the forward mutations produced by reversing or re-applying an undoable
+/// step; they are real grid changes (so they sync) but the stack machine treats
+/// them as stack operations.
+enum JournalKind: Int16, Sendable {
+ case input = 0
+ case check = 1
+ case reveal = 2
+ case clear = 3
+ case undo = 4
+ case redo = 5
+
+ /// Whether this kind is an undoable step (as opposed to a recorded-only
+ /// help gesture or a stack operation).
+ var isUndoable: Bool { self == .input || self == .clear }
+}
+
+/// The after-state of a single cell touch — what the cell became, not what it
+/// was. The "before" value is never stored; it is recovered by following
+/// `JournalValue.prevSeqAtCell`.
+struct JournalCellState: Equatable, Sendable {
+ var letter: String
+ var mark: CellMark
+ var cellAuthorID: String?
+
+ static let empty = JournalCellState(letter: "", mark: .none, cellAuthorID: nil)
+
+ /// Letter match for the supersession guard: undo only fires when the cell
+ /// still holds the letter the move produced. Only the letter is compared —
+ /// the journal logs letter adds/deletes, so a peer's check/reveal (a mark
+ /// change) shouldn't block undoing one's own letter, while a peer changing
+ /// the letter itself should.
+ func letterMatches(_ other: JournalCellState) -> Bool {
+ letter == other.letter
+ }
+}
+
+/// One recorded cell touch. Append-only and immutable once written.
+/// `prevSeqAtCell` points at the previous entry for the same `(row, col)`
+/// (any kind), or `nil` when the cell was empty before — that pointer is how a
+/// before-state is recovered in O(1) without scanning the log.
+struct JournalValue: Equatable, Sendable {
+ let seq: Int64
+ let timestamp: Date
+ let position: GridPosition
+ let state: JournalCellState
+ let actingAuthorID: String?
+ let kind: JournalKind
+ let targetSeq: Int64?
+ let batchID: UUID?
+ let prevSeqAtCell: Int64?
+}
+
+/// One cell to rewrite as part of an undo or redo, with the guard value the
+/// caller checks against the live grid before applying.
+struct JournalRestore: Equatable, Sendable {
+ let position: GridPosition
+ /// The value to write into the cell.
+ let restoreTo: JournalCellState
+ /// Skip this cell if the live grid no longer shows this — it means a
+ /// collaborator (or a later edit) changed it since.
+ let expectedCurrent: JournalCellState
+ /// The undoable entry being reversed / re-applied.
+ let targetSeq: Int64
+}
+
+/// The cells to restore for one undo/redo step, plus the step's identity so the
+/// caller can mark it consumed if every cell turned out to be superseded.
+struct JournalPlan: Equatable, Sendable {
+ let restores: [JournalRestore]
+ let stepID: Int64
+}
+
+/// Local, append-only log of every grid move, and the undo/redo derivation
+/// built on top of it. Local only — never synced in Phase 1. The current
+/// game's entries are held in memory (loaded lazily, so undo survives a
+/// relaunch) and each new entry is persisted on a background context; the
+/// in-memory list is authoritative for the session.
+@MainActor
+final class MovesJournal {
+ private let persistence: PersistenceController
+ private let backgroundContext: NSManagedObjectContext
+
+ private var loadedGameID: UUID?
+ private var entries: [JournalValue] = []
+ private var bySeq: [Int64: JournalValue] = [:]
+ private var lastSeqAtCell: [GridPosition: Int64] = [:]
+ private var nextSeq: Int64 = 0
+
+ /// Steps whose every cell was found superseded when the caller tried to
+ /// apply them, so they should be passed over. Transient (session-only): a
+ /// fully-superseded step has no grid effect to record, so without this the
+ /// stack would keep re-offering it and undo would be stuck. Cleared on load.
+ private var consumedUndoSteps: Set<Int64> = []
+ private var consumedRedoSteps: Set<Int64> = []
+
+ init(persistence: PersistenceController) {
+ self.persistence = persistence
+ self.backgroundContext = persistence.container.newBackgroundContext()
+ }
+
+ // MARK: - Recording
+
+ /// Appends a cell touch and returns the recorded value. Assigns the next
+ /// `seq`, links `prevSeqAtCell`, and persists asynchronously.
+ @discardableResult
+ func record(
+ gameID: UUID,
+ position: GridPosition,
+ state: JournalCellState,
+ actingAuthorID: String?,
+ kind: JournalKind,
+ targetSeq: Int64?,
+ batchID: UUID?
+ ) -> JournalValue {
+ ensureLoaded(gameID)
+ let value = JournalValue(
+ seq: nextSeq,
+ timestamp: Date(),
+ position: position,
+ state: state,
+ actingAuthorID: actingAuthorID,
+ kind: kind,
+ targetSeq: targetSeq,
+ batchID: batchID,
+ prevSeqAtCell: lastSeqAtCell[position]
+ )
+ nextSeq += 1
+ entries.append(value)
+ bySeq[value.seq] = value
+ lastSeqAtCell[position] = value.seq
+ persist(value, gameID: gameID)
+ return value
+ }
+
+ // MARK: - Replay
+
+ /// Read-only snapshot of the recorded log for a game, in seq order —
+ /// every move including checks and reveals. The basis for Phase 2 replay.
+ func recordedEntries(gameID: UUID) -> [JournalValue] {
+ ensureLoaded(gameID)
+ return entries
+ }
+
+ // MARK: - Undo / redo queries
+
+ func canUndo(gameID: UUID) -> Bool {
+ ensureLoaded(gameID)
+ return stacks().live.contains { !consumedUndoSteps.contains(stepID($0)) }
+ }
+
+ func canRedo(gameID: UUID) -> Bool {
+ ensureLoaded(gameID)
+ return stacks().redo.contains { !consumedRedoSteps.contains(stepID($0)) }
+ }
+
+ /// The cells to restore to undo the most recent still-undoable step, or
+ /// `nil` when there is nothing to undo. Does not mutate journal state — the
+ /// `undo` rows the caller records via `record` drive the stack forward.
+ func planUndo(gameID: UUID) -> JournalPlan? {
+ ensureLoaded(gameID)
+ guard let op = stacks().live.last(where: { !consumedUndoSteps.contains(stepID($0)) })
+ else { return nil }
+ let restores = op.entries.map { entry in
+ JournalRestore(
+ position: entry.position,
+ restoreTo: beforeState(of: entry),
+ expectedCurrent: entry.state,
+ targetSeq: entry.seq
+ )
+ }
+ return JournalPlan(restores: restores, stepID: stepID(op))
+ }
+
+ /// The cells to restore to redo the most recently undone step.
+ func planRedo(gameID: UUID) -> JournalPlan? {
+ ensureLoaded(gameID)
+ guard let op = stacks().redo.last(where: { !consumedRedoSteps.contains(stepID($0)) })
+ else { return nil }
+ let restores = op.entries.map { entry in
+ JournalRestore(
+ position: entry.position,
+ restoreTo: entry.state,
+ expectedCurrent: beforeState(of: entry),
+ targetSeq: entry.seq
+ )
+ }
+ return JournalPlan(restores: restores, stepID: stepID(op))
+ }
+
+ /// Records that a planned undo/redo step had no surviving cells (all
+ /// superseded), so the next plan skips past it instead of re-offering it.
+ func markUndoConsumed(stepID: Int64, gameID: UUID) {
+ ensureLoaded(gameID)
+ consumedUndoSteps.insert(stepID)
+ }
+
+ func markRedoConsumed(stepID: Int64, gameID: UUID) {
+ ensureLoaded(gameID)
+ consumedRedoSteps.insert(stepID)
+ }
+
+ // MARK: - Derivation
+
+ private struct Operation {
+ let kind: JournalKind
+ let entries: [JournalValue]
+ }
+
+ /// Stable identity for an operation — the seq of its first entry, which is
+ /// unique and immutable across re-derivations of the same log.
+ private func stepID(_ op: Operation) -> Int64 {
+ op.entries.first?.seq ?? -1
+ }
+
+ /// The before-state of an entry: the after-state of the previous touch at
+ /// the same cell, or empty if there was none.
+ private func beforeState(of entry: JournalValue) -> JournalCellState {
+ guard let prev = entry.prevSeqAtCell, let value = bySeq[prev] else {
+ return .empty
+ }
+ return value.state
+ }
+
+ /// 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`
+ /// holds undone ones available to re-apply. `check`/`reveal` ops are
+ /// recorded but inert here.
+ ///
+ /// `undo`/`redo` ops are matched to the undoable op they act on by
+ /// `targetSeq`, not by stack position: the supersession guard can skip a
+ /// superseded top step and undo an earlier one, so the op being reversed is
+ /// not necessarily on top.
+ private func stacks() -> (live: [Operation], redo: [Operation]) {
+ var operations: [Operation] = []
+ var i = 0
+ while i < entries.count {
+ let head = entries[i]
+ var run = [head]
+ var j = i + 1
+ if let batch = head.batchID {
+ while j < entries.count,
+ entries[j].batchID == batch,
+ entries[j].kind == head.kind {
+ run.append(entries[j])
+ j += 1
+ }
+ }
+ operations.append(Operation(kind: head.kind, entries: run))
+ i = j
+ }
+
+ // Each entry's seq maps to the op that owns it, so an undo/redo op can
+ // find the exact undoable op its `targetSeq` refers to.
+ var opIndexBySeq: [Int64: Int] = [:]
+ for (index, op) in operations.enumerated() {
+ for entry in op.entries { opIndexBySeq[entry.seq] = index }
+ }
+
+ var live: [Int] = []
+ var redo: [Int] = []
+ for (index, op) in operations.enumerated() {
+ switch op.kind {
+ case .undo:
+ guard let target = op.entries.first?.targetSeq,
+ let targetIndex = opIndexBySeq[target],
+ let pos = live.firstIndex(of: targetIndex) else { continue }
+ live.remove(at: pos)
+ redo.append(targetIndex)
+ case .redo:
+ guard let target = op.entries.first?.targetSeq,
+ let targetIndex = opIndexBySeq[target],
+ let pos = redo.firstIndex(of: targetIndex) else { continue }
+ redo.remove(at: pos)
+ live.append(targetIndex)
+ case .input, .clear:
+ redo.removeAll() // a new undoable edit cuts the redo branch
+ live.append(index)
+ case .check, .reveal:
+ break // recorded for replay, inert to undo/redo
+ }
+ }
+ return (live.map { operations[$0] }, redo.map { operations[$0] })
+ }
+
+ // MARK: - Loading & persistence
+
+ private func ensureLoaded(_ gameID: UUID) {
+ guard loadedGameID != gameID else { return }
+ load(gameID)
+ }
+
+ private func load(_ gameID: UUID) {
+ entries.removeAll()
+ bySeq.removeAll()
+ lastSeqAtCell.removeAll()
+ consumedUndoSteps.removeAll()
+ consumedRedoSteps.removeAll()
+ nextSeq = 0
+ loadedGameID = gameID
+
+ let ctx = backgroundContext
+ let loaded: [JournalValue] = ctx.performAndWait {
+ let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity")
+ req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
+ req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)]
+ let rows = (try? ctx.fetch(req)) ?? []
+ return rows.map(Self.value(from:))
+ }
+ for value in loaded {
+ entries.append(value)
+ bySeq[value.seq] = value
+ lastSeqAtCell[value.position] = value.seq
+ nextSeq = max(nextSeq, value.seq + 1)
+ }
+ }
+
+ private func persist(_ value: JournalValue, gameID: UUID) {
+ let ctx = backgroundContext
+ ctx.perform {
+ let entity = JournalEntity(context: ctx)
+ entity.gameID = gameID
+ entity.seq = value.seq
+ entity.timestamp = value.timestamp
+ entity.row = Int16(value.position.row)
+ entity.col = Int16(value.position.col)
+ entity.letter = value.state.letter
+ entity.markCode = CellMarkCodec.code(value.state.mark)
+ entity.cellAuthorID = value.state.cellAuthorID
+ entity.actingAuthorID = value.actingAuthorID
+ entity.kind = value.kind.rawValue
+ entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) }
+ entity.batchID = value.batchID
+ entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) }
+
+ let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ gameReq.fetchLimit = 1
+ entity.game = try? ctx.fetch(gameReq).first
+
+ do {
+ try ctx.save()
+ } catch {
+ print("MovesJournal: failed to persist entry: \(error)")
+ ctx.rollback()
+ }
+ }
+ }
+
+ private nonisolated static func value(from entity: JournalEntity) -> JournalValue {
+ JournalValue(
+ seq: entity.seq,
+ timestamp: entity.timestamp ?? .distantPast,
+ position: GridPosition(row: Int(entity.row), col: Int(entity.col)),
+ state: JournalCellState(
+ letter: entity.letter ?? "",
+ mark: CellMarkCodec.mark(code: entity.markCode),
+ cellAuthorID: entity.cellAuthorID
+ ),
+ actingAuthorID: entity.actingAuthorID,
+ kind: JournalKind(rawValue: entity.kind) ?? .input,
+ targetSeq: entity.targetSeq?.int64Value,
+ batchID: entity.batchID,
+ prevSeqAtCell: entity.prevSeqAtCell?.int64Value
+ )
+ }
+}
diff --git a/Tests/Support/TestHelpers.swift b/Tests/Support/TestHelpers.swift
@@ -132,7 +132,12 @@ func makeTestGame() throws -> (Game, GameMutator, GameEntity, PersistenceControl
try context.save()
- let mutator = GameMutator(game: game, gameID: gameID, movesUpdater: nil)
+ let mutator = GameMutator(
+ game: game,
+ gameID: gameID,
+ movesUpdater: nil,
+ movesJournal: MovesJournal(persistence: persistence)
+ )
return (game, mutator, entity, persistence)
}
diff --git a/Tests/Unit/MovesJournalTests.swift b/Tests/Unit/MovesJournalTests.swift
@@ -0,0 +1,227 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Undo/redo behaviour driven through `GameMutator`, which records every local
+/// move into the `MovesJournal` that `makeTestGame` wires up. Assertions read
+/// the in-memory `Game` — the journal's in-memory log is authoritative for the
+/// session, so no waiting on background persistence is needed.
+@Suite("MovesJournal undo/redo", .serialized)
+@MainActor
+struct MovesJournalTests {
+
+ // MARK: - Single-cell undo / redo
+
+ @Test("undo reverts a typed letter")
+ func undoRevertsTypedLetter() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ #expect(mutator.canUndo)
+
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "")
+ #expect(!mutator.canUndo)
+ #expect(mutator.canRedo)
+ }
+
+ @Test("redo re-applies an undone letter")
+ func redoReappliesLetter() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.undo()
+ mutator.redo()
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(mutator.canUndo)
+ #expect(!mutator.canRedo)
+ }
+
+ @Test("undo restores the previous letter, not just empty")
+ func undoRestoresPreviousLetter() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.setLetter("B", atRow: 0, atCol: 0, pencil: false) // overwrite same cell
+
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "A")
+
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "")
+ }
+
+ @Test("undo walks back across multiple cells in order")
+ func undoWalksBackMultipleCells() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+
+ mutator.undo()
+ #expect(game.squares[0][1].entry == "")
+ #expect(game.squares[0][0].entry == "A")
+
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "")
+ #expect(!mutator.canUndo)
+ }
+
+ @Test("a new edit after undo clears the redo branch")
+ func newEditClearsRedo() throws {
+ let (_, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.undo()
+ #expect(mutator.canRedo)
+
+ mutator.setLetter("C", atRow: 0, atCol: 1, pencil: false)
+ #expect(!mutator.canRedo)
+ }
+
+ // MARK: - Checks and reveals are not undoable
+
+ @Test("checking a cell creates no undo step")
+ func checkIsNotUndoable() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) // (0,0) solution is A
+ mutator.checkCells([game.puzzle.cells[0][0]])
+ #expect(game.squares[0][0].mark == .pen(checked: .right))
+
+ // The only undoable thing is the letter; one undo removes it (and the
+ // check with it), and there is nothing left to undo.
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "")
+ #expect(!mutator.canUndo)
+ }
+
+ @Test("typing then checking still lets the letter be undone")
+ func undoAfterCheckRemovesLetter() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("Z", atRow: 0, atCol: 0, pencil: false) // wrong, so it checks .wrong
+ mutator.checkCells([game.puzzle.cells[0][0]])
+ #expect(game.squares[0][0].mark == .pen(checked: .wrong))
+
+ // The check changed the mark but not the letter, so the letter-only
+ // guard still lets the typed letter be undone.
+ mutator.undo()
+ #expect(game.squares[0][0].entry == "")
+ }
+
+ @Test("revealing creates no undo step and undo leaves it alone")
+ func revealIsNotUndoable() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ let cells = [game.puzzle.cells[0][0], game.puzzle.cells[0][1]]
+ mutator.revealCells(cells)
+ #expect(game.squares[0][0].mark == .revealed)
+ #expect(!mutator.canUndo)
+
+ mutator.undo() // no-op
+ #expect(game.squares[0][0].mark == .revealed)
+ #expect(game.squares[0][0].entry == "A")
+ }
+
+ // MARK: - Bulk steps
+
+ @Test("undo reverses a clear-cells gesture as a single step")
+ func undoBulkClearIsOneStep() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+ let cells = [game.puzzle.cells[0][0], game.puzzle.cells[0][1]]
+ mutator.clearCells(cells)
+ #expect(game.squares[0][0].entry == "")
+ #expect(game.squares[0][1].entry == "")
+
+ mutator.undo() // one undo restores the whole cleared gesture
+
+ #expect(game.squares[0][0].entry == "A")
+ #expect(game.squares[0][1].entry == "B")
+ }
+
+ // MARK: - Recording (replay) vs undoability, at the journal level
+
+ @Test("checks and reveals are recorded for replay but are not undoable")
+ func checksAndRevealsRecordedNotUndoable() {
+ let journal = MovesJournal(persistence: makeTestPersistence())
+ let gameID = UUID()
+ let pos = GridPosition(row: 0, col: 0)
+
+ journal.record(
+ gameID: gameID, position: pos,
+ state: JournalCellState(letter: "A", mark: .pen(checked: .right), cellAuthorID: nil),
+ actingAuthorID: nil, kind: .check, targetSeq: nil, batchID: nil
+ )
+ journal.record(
+ gameID: gameID, position: GridPosition(row: 0, col: 1),
+ state: JournalCellState(letter: "B", mark: .revealed, cellAuthorID: nil),
+ actingAuthorID: nil, kind: .reveal, targetSeq: nil, batchID: nil
+ )
+
+ #expect(journal.recordedEntries(gameID: gameID).count == 2) // both logged
+ #expect(!journal.canUndo(gameID: gameID)) // neither undoable
+ }
+
+ @Test("input and clear are both recorded and undoable")
+ func inputAndClearAreUndoable() {
+ let journal = MovesJournal(persistence: makeTestPersistence())
+ let gameID = UUID()
+ let pos = GridPosition(row: 0, col: 0)
+
+ journal.record(
+ gameID: gameID, position: pos,
+ state: JournalCellState(letter: "A", mark: .none, cellAuthorID: nil),
+ actingAuthorID: nil, kind: .input, targetSeq: nil, batchID: nil
+ )
+ journal.record(
+ gameID: gameID, position: pos,
+ state: JournalCellState(letter: "", mark: .none, cellAuthorID: nil),
+ actingAuthorID: nil, kind: .clear, targetSeq: nil, batchID: nil
+ )
+
+ #expect(journal.recordedEntries(gameID: gameID).count == 2)
+ #expect(journal.canUndo(gameID: gameID)) // clear (top) is undoable
+ }
+
+ // MARK: - Supersession guard
+
+ @Test("undo skips a cell a collaborator changed since")
+ func undoSkipsSupersededCell() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ // Simulate a remote edit landing on the same cell: it writes straight
+ // into `Game` and is not journaled (remote changes bypass GameMutator).
+ game.setLetter("Z", atRow: 0, atCol: 0, pencil: false)
+
+ mutator.undo()
+
+ // The undo must not clobber the collaborator's current letter.
+ #expect(game.squares[0][0].entry == "Z")
+ // The superseded step is consumed, so there's nothing left to undo.
+ #expect(!mutator.canUndo)
+ }
+
+ @Test("undo passes over a fully superseded step to an earlier one")
+ func undoSkipsToEarlierStep() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false)
+ mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+ // Collaborator overwrites the most recent edit's cell.
+ game.setLetter("Z", atRow: 0, atCol: 1, pencil: false)
+
+ mutator.undo() // (0,1) is superseded → skip it and undo (0,0)
+
+ #expect(game.squares[0][1].entry == "Z") // untouched
+ #expect(game.squares[0][0].entry == "") // earlier edit reverted
+ #expect(!mutator.canUndo)
+ }
+}