From c05a1c9cdb6fc02c7fa956835d82f16cd7321aca Mon Sep 17 00:00:00 2001 From: Jeremy Smart Date: Tue, 30 Jun 2026 10:20:15 -0400 Subject: [PATCH] Accept alternative checkmating moves in puzzles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puzzle validation previously accepted only the single scripted UCI move from the Lichess solution line. When a player finds a different checkmate than the one scripted, the puzzle is still solved — a mate ends the game regardless of which mating move is played (matching Lichess's own rule). validateAndApply now accepts an off-solution move if it delivers mate, and marks the puzzle solved immediately. The relaxation is intentionally narrow: only mating alternatives are accepted, since the puzzle data carries no eval signal for non-mate alternatives and the scripted engine reply only makes sense in response to the scripted move. Co-Authored-By: Claude Opus 4.8 --- internal/client/screens/puzzle.go | 10 +++++- internal/client/screens/puzzle_test.go | 47 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/internal/client/screens/puzzle.go b/internal/client/screens/puzzle.go index c79ae62..fc53433 100644 --- a/internal/client/screens/puzzle.go +++ b/internal/client/screens/puzzle.go @@ -261,7 +261,9 @@ func (m *PuzzleModel) validateAndApply(userSAN string) (bool, string) { if algN.Encode(pos, mv) != userSAN { continue } - if uciN.Encode(pos, mv) != expectedUCI { + // Accept the scripted move, or any alternative that delivers checkmate: + // if the player finds a different mate, the puzzle is solved all the same. + if uciN.Encode(pos, mv) != expectedUCI && pos.Update(mv).Status() != chess.Checkmate { m.state = puzzleStateFailure return false, "" } @@ -280,6 +282,12 @@ func (m *PuzzleModel) validateAndApply(userSAN string) (bool, string) { m.solutionIdx++ m.viewIdx = m.totalViewPositions() m.updateMoveList() + // A checkmating move ends the game immediately — the puzzle is solved even if + // the scripted line had more moves (e.g. an alternative, shorter mate). + if m.game.Method() == chess.Checkmate { + m.state = puzzleStateSuccess + return true, "" + } if m.solutionIdx < len(m.solution) { return true, m.solution[m.solutionIdx] } diff --git a/internal/client/screens/puzzle_test.go b/internal/client/screens/puzzle_test.go index 80fba65..8a53f55 100644 --- a/internal/client/screens/puzzle_test.go +++ b/internal/client/screens/puzzle_test.go @@ -212,6 +212,53 @@ func TestPuzzleSkipMarksAsUnsolved(t *testing.T) { } } +// TestPuzzleAcceptsAlternativeMate verifies that a move differing from the scripted +// solution is accepted when it delivers checkmate. The position (white to move) has +// several queen mates on the h-file/g7; the puzzle scripts Qh1# ("g2h1") but Qh2# is +// an equally valid mate and must be accepted as solved. +func TestPuzzleAcceptsAlternativeMate(t *testing.T) { + record := shared.PuzzleRecord{ + ID: "altmate1", + FEN: "7k/5K2/8/8/8/8/6Q1/8 w - - 0 1", + Moves: "g2h1", // scripted: Qh1# + Rating: 1500, + UserPuzzleRating: 1500, + } + m := NewPuzzleModel("testuser") + m.SetPuzzle(record) + ok, engineUCI := m.validateAndApply("Qh2#") // different mate than scripted Qh1# + if !ok { + t.Fatal("alternative mating move Qh2# was rejected") + } + if engineUCI != "" { + t.Errorf("expected no engine response after mate, got %q", engineUCI) + } + if m.state != puzzleStateSuccess { + t.Errorf("state = %v after alternative mate, want puzzleStateSuccess", m.state) + } +} + +// TestPuzzleRejectsNonMateAlternative verifies the alternative acceptance is narrow: +// a legal-but-wrong move that does NOT deliver mate is still a failure. +func TestPuzzleRejectsNonMateAlternative(t *testing.T) { + record := shared.PuzzleRecord{ + ID: "altmate2", + FEN: "7k/5K2/8/8/8/8/6Q1/8 w - - 0 1", + Moves: "g2h1", // scripted: Qh1# + Rating: 1500, + UserPuzzleRating: 1500, + } + m := NewPuzzleModel("testuser") + m.SetPuzzle(record) + ok, _ := m.validateAndApply("Qa2") // legal, not the solution, not mate + if ok { + t.Error("non-mating off-solution move Qa2 was accepted") + } + if m.state != puzzleStateFailure { + t.Errorf("state = %v, want puzzleStateFailure", m.state) + } +} + func setupMultiMovePuzzle(t *testing.T) *PuzzleModel { t.Helper() record := shared.PuzzleRecord{