crossmate

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

commit 33deff922246beaccbd490b5ad83496bf8f89156
parent 49a6a35033d19b357ec1865906eb36ddc3d789e3
Author: Michael Camilleri <[email protected]>
Date:   Sat, 25 Jul 2026 22:53:07 +0900

Stop the Success Panel replay looping once it reaches the end

The replay autoplay wrapped back to the first move whenever the head
reached the end of the timeline, so a finished game cycled indefinitely.
It looked good but read poorly: the grid kept clearing itself under the
user instead of settling on the puzzle they had just solved.

This commit ends a run at the end of the game. advancePlayback now
clamps the head to the last step and clears isPlaybackActive on arrival,
so playback runs through once and rests on the finished grid. A ready
timeline already parks the head at the end, though, so stopping there
alone would leave the playback control inert — the first tick of a new
run would halt it immediately. togglePlayback therefore rewinds to the
start when a run begins with the head at the end, which reads that
control as 'replay from the beginning' while pausing and resuming
mid-run still continues from where the head stopped.

Co-Authored-By: Claude Opus 5 <[email protected]>

Diffstat:
MCrossmate/Models/ReplayControls.swift | 18++++++++++++++----
MTests/Unit/ReplayControlsTests.swift | 43+++++++++++++++++++++++++++++++++++++++++++
2 files changed, 57 insertions(+), 4 deletions(-)

diff --git a/Crossmate/Models/ReplayControls.swift b/Crossmate/Models/ReplayControls.swift @@ -87,8 +87,14 @@ final class ReplayControls { } /// Toggles autoplay without losing the selected speed, so pause/resume - /// preserves the user's pace. + /// preserves the user's pace. Starting with the head already at the end + /// rewinds to the beginning first: playback runs through once and stops + /// there, so pressing play at rest replays the game instead of doing + /// nothing. func togglePlayback() { + if !isPlaybackActive, let timeline, position >= timeline.count { + position = 0 + } isPlaybackActive.toggle() } @@ -98,11 +104,15 @@ final class ReplayControls { isPlaybackActive = false } - /// Advances one autoplay step, looping back to the start once the head - /// reaches the end. A no-op when stopped or before a timeline loads. + /// Advances one autoplay step, stopping once the head reaches the end + /// rather than looping — the run ends on the finished grid and stays there. + /// A no-op when stopped or before a timeline loads. func advancePlayback() { guard let timeline, isPlaybackActive else { return } - position = position >= timeline.count ? 0 : position + 1 + position = min(position + 1, timeline.count) + if position >= timeline.count { + isPlaybackActive = false + } } var timeline: ReplayTimeline? { diff --git a/Tests/Unit/ReplayControlsTests.swift b/Tests/Unit/ReplayControlsTests.swift @@ -124,4 +124,47 @@ struct ReplayControlsTests { #expect(!controls.isPlaybackActive) #expect(controls.selectedPlaybackSpeed == 1) } + + @Test("playback stops at the end instead of looping") + func playbackStopsAtEnd() async { + let controls = ReplayControls() + await controls.load { .ready(timeline()) } + + controls.position = 0 + controls.togglePlayback() + + controls.advancePlayback() + #expect(controls.position == 1) + #expect(controls.isPlaybackActive) + + // Reaching the end ends the run: the head parks on the finished grid + // and autoplay clears itself rather than wrapping to the start. + controls.advancePlayback() + #expect(controls.position == 2) + #expect(!controls.isPlaybackActive) + + // A further tick can't sneak the head past the end or restart it. + controls.advancePlayback() + #expect(controls.position == 2) + #expect(!controls.isPlaybackActive) + } + + @Test("pressing play with the head at the end replays from the start") + func playFromEndRewinds() async { + let controls = ReplayControls() + await controls.load { .ready(timeline()) } + #expect(controls.position == 2) + + controls.togglePlayback() + #expect(controls.isPlaybackActive) + #expect(controls.position == 0) + + // Pausing mid-run and resuming keeps the position — only a run that + // starts from the end rewinds. + controls.advancePlayback() + controls.pausePlayback() + controls.togglePlayback() + #expect(controls.isPlaybackActive) + #expect(controls.position == 1) + } }