commit 27e90b5a958d93a4e1cc053efc648332183c81a3
parent 5a4d06bfa7b576d0f4e5185ad1698427ae785bdf
Author: Michael Camilleri <[email protected]>
Date: Sat, 6 Jun 2026 13:24:02 +0900
Show replay progress in Success Panel
This commit updates the Success Panel scoreboard from the active replay
frame while the scrubber is away from the end, so player counts and
revealed-square text tick with autoplay and manual scrubbing. At the
final scrubber position it falls back to the solved live grid.
To do this requires the recorded cursor direction to be carried through
replay frames and newly uploaded journal assets so that it can be used
to show the clue for the replay playhead in the Clue Bar without
mutating the live session cursor. Older journals without direction
decode as before and simply do not override the Clue Bar.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
8 files changed, 157 insertions(+), 35 deletions(-)
diff --git a/Crossmate/Models/ReplayControls.swift b/Crossmate/Models/ReplayControls.swift
@@ -35,6 +35,10 @@ struct ReplayFrame: Equatable {
let cursor: GridPosition?
/// Who made that move, so the playhead can take their colour.
let cursorAuthorID: String?
+ /// The direction the acting player had when the step was recorded. Older
+ /// decoded replay rows may not carry this, so clue display falls back to
+ /// puzzle geometry only when the cell is unambiguous.
+ let cursorDirection: Puzzle.Direction?
}
/// View-model for the finish-banner replay scrubber. Loads a finished game's
@@ -145,12 +149,25 @@ final class ReplayControls {
return timeline.actingAuthor(ofStep: position - 1)
}
+ /// The direction of the most recently applied single-cell step, used by
+ /// the clue bar during replay. `nil` for batched gestures, older decoded
+ /// rows, or entries that did not record a cursor direction.
+ var cursorDirection: Puzzle.Direction? {
+ guard let timeline, position > 0, position < timeline.count else { return nil }
+ return timeline.direction(ofStep: position - 1)
+ }
+
/// The frame `GridView` should render, bundling the grid override with the
/// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at
/// rest (head hard-right), where the live finished grid shows instead.
var frame: ReplayFrame? {
guard let cells = gridOverride else { return nil }
- return ReplayFrame(cells: cells, cursor: cursor, cursorAuthorID: cursorAuthorID)
+ return ReplayFrame(
+ cells: cells,
+ cursor: cursor,
+ cursorAuthorID: cursorAuthorID,
+ cursorDirection: cursorDirection
+ )
}
/// Loads the replay via the caller's loader. Idempotent for an in-flight or
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -497,6 +497,7 @@ enum JournalCodec {
let targetSeq: Int64?
let batchID: UUID?
let prevSeqAtCell: Int64?
+ let dir: Int?
init(
seq: Int64,
@@ -510,7 +511,8 @@ enum JournalCodec {
kind: Int16,
targetSeq: Int64?,
batchID: UUID?,
- prevSeqAtCell: Int64?
+ prevSeqAtCell: Int64?,
+ dir: Int?
) {
self.seq = seq
self.timestamp = timestamp
@@ -524,6 +526,7 @@ enum JournalCodec {
self.targetSeq = targetSeq
self.batchID = batchID
self.prevSeqAtCell = prevSeqAtCell
+ self.dir = dir
}
// Optionals are decoded leniently so a record written by a newer
@@ -543,6 +546,7 @@ enum JournalCodec {
targetSeq = try? c.decode(Int64.self, forKey: .targetSeq)
batchID = try? c.decode(UUID.self, forKey: .batchID)
prevSeqAtCell = try? c.decode(Int64.self, forKey: .prevSeqAtCell)
+ dir = try? c.decode(Int.self, forKey: .dir)
}
}
let entries: [Entry]
@@ -564,7 +568,8 @@ enum JournalCodec {
kind: value.kind.rawValue,
targetSeq: value.targetSeq,
batchID: value.batchID,
- prevSeqAtCell: value.prevSeqAtCell
+ prevSeqAtCell: value.prevSeqAtCell,
+ dir: value.direction?.rawValue
)
}
return try JSONEncoder().encode(Payload(entries: entries))
@@ -588,9 +593,7 @@ enum JournalCodec {
targetSeq: entry.targetSeq,
batchID: entry.batchID,
prevSeqAtCell: entry.prevSeqAtCell,
- // Cursor direction is device-local UX, not part of the replay
- // wire format — it is never encoded, so decoded entries carry none.
- direction: nil
+ direction: entry.dir.flatMap(Puzzle.Direction.init(rawValue:))
)
}
}
diff --git a/Crossmate/Persistence/JournalReplay.swift b/Crossmate/Persistence/JournalReplay.swift
@@ -110,6 +110,14 @@ struct ReplayTimeline: Sendable, Equatable {
let step = steps[index]
return step.count == 1 ? step.first?.actingAuthorID : nil
}
+
+ /// The cursor direction recorded with a single-cell step. Older decoded
+ /// replay rows and non-input rows may not carry one.
+ func direction(ofStep index: Int) -> Puzzle.Direction? {
+ guard steps.indices.contains(index) else { return nil }
+ let step = steps[index]
+ return step.count == 1 ? step.first?.direction : nil
+ }
}
/// Composes a `JournalReplayFetch` with this device's *live* journal into a
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -317,7 +317,7 @@ struct PuzzleView: View {
private func controlsArea(showClueBar: Bool) -> some View {
VStack(spacing: 0) {
if showClueBar {
- ClueBarSlot(session: session)
+ ClueBarSlot(session: session, replayFrame: replay.frame)
}
controlsPanel
.frame(height: controlsPanelHeight)
@@ -1360,14 +1360,25 @@ private struct ClueKey: Hashable {
let number: Int
}
+private struct ReplayClueTarget {
+ let position: GridPosition
+ let direction: Puzzle.Direction?
+}
+
private struct ClueBarSlot: View {
@Bindable var session: PlayerSession
+ let replayFrame: ReplayFrame?
+
+ private var replayClueTarget: ReplayClueTarget? {
+ guard let cursor = replayFrame?.cursor else { return nil }
+ return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection)
+ }
var body: some View {
ZStack(alignment: .bottom) {
ClueBarReservation()
- ClueBar(session: session)
+ ClueBar(session: session, replayClueTarget: replayClueTarget)
}
}
}
@@ -1475,6 +1486,7 @@ private struct ClueBarIcon: View {
private struct ClueBar: View {
@Bindable var session: PlayerSession
+ let replayClueTarget: ReplayClueTarget?
@Environment(PlayerPreferences.self) private var preferences
@State private var slideEdge: Edge = .trailing
@State private var isShowingClueList = false
@@ -1482,31 +1494,34 @@ private struct ClueBar: View {
private var backgroundColor: Color { preferences.color.authorTintFill }
var body: some View {
- let clue = session.currentClue()
- let currentKey = clue.map { ClueKey(direction: session.direction, number: $0.number) }
+ let display = replayClueDisplay ?? liveClueDisplay
+ let isShowingReplayClue = replayClueDisplay != nil
ClueBarContent(
- label: label(for: clue),
- clueText: clue?.text ?? "—",
- currentKey: currentKey,
+ label: label(for: display.clue, direction: display.direction),
+ clueText: display.clue?.text ?? "—",
+ currentKey: display.currentKey,
slideEdge: slideEdge,
- onPrevious: {
+ onPrevious: isShowingReplayClue ? nil : {
slideEdge = .leading
session.goToPreviousClue()
},
- onNext: {
+ onNext: isShowingReplayClue ? nil : {
slideEdge = .trailing
session.goToNextClue()
},
- onClueTap: {
+ onClueTap: isShowingReplayClue ? nil : {
isShowingClueList = true
},
- onLabelTap: {
+ onLabelTap: isShowingReplayClue ? nil : {
session.toggleDirection()
}
)
.background(backgroundColor)
- .animation(.smooth(duration: 0.22), value: currentKey)
+ .animation(
+ isShowingReplayClue ? nil : .smooth(duration: 0.22),
+ value: display.currentKey
+ )
.sheet(isPresented: $isShowingClueList) {
ClueList(session: session)
.presentationDetents([.medium, .large])
@@ -1514,8 +1529,40 @@ private struct ClueBar: View {
}
}
- private func label(for clue: Puzzle.Clue?) -> String {
- let direction = session.direction == .across ? "Across" : "Down"
+ private var liveClueDisplay: ClueDisplay {
+ let clue = session.currentClue()
+ return ClueDisplay(clue: clue, direction: session.direction)
+ }
+
+ private var replayClueDisplay: ClueDisplay? {
+ guard let replayClueTarget else { return nil }
+ let position = replayClueTarget.position
+ guard let direction = replayClueTarget.direction else { return nil }
+ return ClueDisplay(
+ clue: clue(atRow: position.row, col: position.col, direction: direction),
+ direction: direction
+ )
+ }
+
+ private struct ClueDisplay {
+ let clue: Puzzle.Clue?
+ let direction: Puzzle.Direction
+
+ var currentKey: ClueKey? {
+ clue.map { ClueKey(direction: direction, number: $0.number) }
+ }
+ }
+
+ private func clue(atRow row: Int, col: Int, direction: Puzzle.Direction) -> Puzzle.Clue? {
+ guard let number = session.puzzle.wordCells(atRow: row, col: col, direction: direction).first?.number else {
+ return nil
+ }
+ let clues = direction == .across ? session.puzzle.acrossClues : session.puzzle.downClues
+ return clues.first { $0.number == number }
+ }
+
+ private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String {
+ let direction = direction == .across ? "Across" : "Down"
if let clue {
return "\(clue.number) \(direction)"
}
diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift
@@ -20,12 +20,13 @@ struct SuccessPanel: View {
}
private var revealedSquareCount: Int {
+ let replayCells = replay?.frame?.cells
var count = 0
for r in 0..<session.puzzle.height {
for c in 0..<session.puzzle.width {
let cell = session.puzzle.cells[r][c]
guard !cell.isBlock else { continue }
- if session.game.squares[r][c].mark.isRevealed {
+ if displayedCellState(row: r, col: c, replayCells: replayCells).mark.isRevealed {
count += 1
}
}
@@ -45,17 +46,18 @@ struct SuccessPanel: View {
}
private var contributions: [Contribution] {
+ let replayCells = replay?.frame?.cells
var counts: [String?: Int] = [:]
for r in 0..<session.puzzle.height {
for c in 0..<session.puzzle.width {
let cell = session.puzzle.cells[r][c]
guard !cell.isBlock else { continue }
- let square = session.game.squares[r][c]
- guard !square.mark.isRevealed else { continue }
- let entry = square.entry
+ let state = displayedCellState(row: r, col: c, replayCells: replayCells)
+ guard !state.mark.isRevealed else { continue }
+ let entry = state.letter
guard !entry.isEmpty else { continue }
if cell.solution != nil, !cell.accepts(entry) { continue }
- counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1
+ counts[normalizedAuthorID(state.cellAuthorID), default: 0] += 1
}
}
@@ -116,6 +118,24 @@ struct SuccessPanel: View {
}
}
+ private func displayedCellState(
+ row: Int,
+ col: Int,
+ replayCells: [GridPosition: JournalCellState]?
+ ) -> JournalCellState {
+ let position = GridPosition(row: row, col: col)
+ if let replayCells {
+ return replayCells[position] ?? .empty
+ }
+
+ let square = session.game.squares[row][col]
+ return JournalCellState(
+ letter: square.entry,
+ mark: square.mark,
+ cellAuthorID: square.letterAuthorID
+ )
+ }
+
private func normalizedAuthorID(_ authorID: String?) -> String? {
guard let authorID else {
return roster.entries.contains(where: { !$0.isLocal }) ? nil : roster.localAuthorID
diff --git a/Tests/Unit/JournalReplayTests.swift b/Tests/Unit/JournalReplayTests.swift
@@ -18,7 +18,8 @@ struct JournalReplayTests {
mark: CellMark = .none,
author: String = "author",
kind: JournalKind = .input,
- batchID: UUID? = nil
+ batchID: UUID? = nil,
+ direction: Puzzle.Direction? = nil
) -> JournalValue {
JournalValue(
seq: seq,
@@ -30,7 +31,7 @@ struct JournalReplayTests {
targetSeq: nil,
batchID: batchID,
prevSeqAtCell: nil,
- direction: nil
+ direction: direction
)
}
@@ -139,6 +140,21 @@ struct JournalReplayTests {
#expect(timeline.focus(ofStep: 1) == nil) // batch has none
}
+ @Test("single-cell replay steps expose their recorded cursor direction")
+ func singleCellStepExposesDirection() {
+ let typed = entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", direction: .down)
+ let batch = UUID()
+ let clear = [
+ entry(seq: 1, at: 20, row: 0, col: 0, letter: "", kind: .clear, batchID: batch, direction: .down),
+ entry(seq: 2, at: 20, row: 0, col: 1, letter: "", kind: .clear, batchID: batch, direction: .down),
+ ]
+
+ let timeline = ReplayTimeline(merging: [[typed] + clear])
+
+ #expect(timeline.direction(ofStep: 0) == .down)
+ #expect(timeline.direction(ofStep: 1) == nil)
+ }
+
// MARK: - Completeness gate
private let d1 = JournalDeviceKey(authorID: "a1", deviceID: "dev1")
diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift
@@ -26,7 +26,8 @@ struct JournalCodecTests {
cellAuthorID: String? = nil,
targetSeq: Int64? = nil,
batchID: UUID? = nil,
- prevSeqAtCell: Int64? = nil
+ prevSeqAtCell: Int64? = nil,
+ direction: Puzzle.Direction? = nil
) -> JournalValue {
JournalValue(
seq: seq,
@@ -38,7 +39,7 @@ struct JournalCodecTests {
targetSeq: targetSeq,
batchID: batchID,
prevSeqAtCell: prevSeqAtCell,
- direction: nil
+ direction: direction
)
}
@@ -47,9 +48,9 @@ struct JournalCodecTests {
let batch = UUID()
let values = [
value(seq: 0, row: 0, col: 0, letter: "A", mark: .pen(checked: nil), kind: .input,
- actingAuthorID: "alice", cellAuthorID: "alice"),
+ actingAuthorID: "alice", cellAuthorID: "alice", direction: .across),
value(seq: 1, row: 1, col: 2, letter: "B", mark: .pencil(checked: .wrong), kind: .clear,
- actingAuthorID: "alice", cellAuthorID: nil, batchID: batch, prevSeqAtCell: 0),
+ actingAuthorID: "alice", cellAuthorID: nil, batchID: batch, prevSeqAtCell: 0, direction: .down),
value(seq: 2, row: 2, col: 2, letter: "", mark: .none, kind: .undo,
targetSeq: 0, batchID: batch),
value(seq: 3, row: 0, col: 1, letter: "C", mark: .revealed, kind: .reveal),
@@ -95,6 +96,7 @@ struct JournalCodecTests {
#expect(entry.targetSeq == nil)
#expect(entry.batchID == nil)
#expect(entry.prevSeqAtCell == nil)
+ #expect(entry.direction == nil)
}
}
diff --git a/Tests/Unit/ReplayControlsTests.swift b/Tests/Unit/ReplayControlsTests.swift
@@ -9,7 +9,14 @@ import Testing
@MainActor
struct ReplayControlsTests {
- private func entry(seq: Int64, at seconds: TimeInterval, row: Int, col: Int, letter: String) -> JournalValue {
+ private func entry(
+ seq: Int64,
+ at seconds: TimeInterval,
+ row: Int,
+ col: Int,
+ letter: String,
+ direction: Puzzle.Direction? = nil
+ ) -> JournalValue {
JournalValue(
seq: seq,
timestamp: Date(timeIntervalSince1970: seconds),
@@ -20,14 +27,14 @@ struct ReplayControlsTests {
targetSeq: nil,
batchID: nil,
prevSeqAtCell: nil,
- direction: nil
+ direction: direction
)
}
private func timeline() -> ReplayTimeline {
ReplayTimeline(merging: [[
- entry(seq: 0, at: 10, row: 0, col: 0, letter: "A"),
- entry(seq: 1, at: 20, row: 0, col: 1, letter: "B"),
+ entry(seq: 0, at: 10, row: 0, col: 0, letter: "A", direction: .across),
+ entry(seq: 1, at: 20, row: 0, col: 1, letter: "B", direction: .down),
]])
}
@@ -53,10 +60,12 @@ struct ReplayControlsTests {
#expect(controls.gridOverride?[GridPosition(row: 0, col: 0)]?.letter == "A")
#expect(controls.gridOverride?[GridPosition(row: 0, col: 1)] == nil)
#expect(controls.cursor == GridPosition(row: 0, col: 0))
+ #expect(controls.cursorDirection == .across)
controls.position = 0
#expect(controls.gridOverride?.isEmpty == true)
#expect(controls.cursor == nil)
+ #expect(controls.cursorDirection == nil)
}
@Test("a waiting load is not scrubbable")