crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++++++
ACrossmate/Models/CellMarkCodec.swift | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 21+++++++++++++++++++++
MCrossmate/Models/Game.swift | 15+++++++++++++++
MCrossmate/Persistence/GameMutator.swift | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
MCrossmate/Persistence/GameStore.swift | 26++++++++------------------
ACrossmate/Persistence/Journal.swift | 375+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Support/TestHelpers.swift | 7++++++-
ATests/Unit/MovesJournalTests.swift | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}