diff --git a/internal/client/screens/puzzle.go b/internal/client/screens/puzzle.go index a8445b6..1601fc1 100644 --- a/internal/client/screens/puzzle.go +++ b/internal/client/screens/puzzle.go @@ -108,7 +108,6 @@ func (m *PuzzleModel) initGame() { m.solutionIdx = 0 m.enginePending = false m.input = NewLocalMoveInput(flipped) - m.submitted = false m.state = puzzleStatePlaying m.buildContextHistory() m.viewIdx = m.totalViewPositions() @@ -120,6 +119,15 @@ func (m *PuzzleModel) retry() { m.initGame() } +// resumeAfterFailure returns to the playing state at the current position without +// resetting the puzzle. The wrong move was never applied, so game/solutionIdx are +// already where the player needs to continue. ELO is unaffected (already recorded). +func (m *PuzzleModel) resumeAfterFailure() { + m.state = puzzleStatePlaying + m.viewIdx = m.totalViewPositions() + m.syncBoardToView() +} + // showSolution resets the game to the puzzle FEN, applies all solution moves, and sets // viewIdx to the puzzle start so the user can step through with arrow keys. func (m *PuzzleModel) showSolution() { @@ -393,8 +401,12 @@ func (m *PuzzleModel) navigateForward() { } func (m *PuzzleModel) handleRetryKey() tea.Cmd { - if m.state == puzzleStateFailure || m.state == puzzleStateSuccess || m.state == puzzleStateSolution { + switch m.state { + case puzzleStateFailure: + m.resumeAfterFailure() + case puzzleStateSuccess, puzzleStateSolution: m.retry() + case puzzleStateLoading, puzzleStatePlaying: } return m.initCmd() } diff --git a/internal/client/screens/puzzle_test.go b/internal/client/screens/puzzle_test.go index 406c77b..80fba65 100644 --- a/internal/client/screens/puzzle_test.go +++ b/internal/client/screens/puzzle_test.go @@ -84,17 +84,122 @@ func TestPuzzleValidateInvalidSAN(t *testing.T) { } } -func TestPuzzleRetryResetsToPlaying(t *testing.T) { +func TestRetryAfterFailureResumesPlaying(t *testing.T) { m := setupPuzzleModel(t) m.validateAndApply("e5") // wrong move → failure - m.retry() + m.handleRetryKey() if m.state != puzzleStatePlaying { t.Errorf("after retry state = %v, want puzzleStatePlaying", m.state) } - // FEN position should be restored — e4 should still have a pawn + // Wrong move was never applied — e4 should still have a pawn. pos := m.game.Position() if pos.Board().Piece(chess.E4) == chess.NoPiece { - t.Error("after retry, e4 should still have a pawn (restored from FEN)") + t.Error("after retry, e4 should still have a pawn") + } +} + +func TestRetryAfterFailureKeepsProgress(t *testing.T) { + m := setupMultiMovePuzzle(t) + ok, engineUCI := m.validateAndApply("d5") // correct first move + if !ok { + t.Fatal("correct first move d5 was rejected") + } + m.applyEngineResponse(engineUCI) // engine plays exd5 → solutionIdx 2, playing + if m.solutionIdx != 2 { + t.Fatalf("solutionIdx = %d before failure, want 2", m.solutionIdx) + } + ok, _ = m.validateAndApply("Nf6") // legal but wrong (solution is Qxd5) + if ok { + t.Fatal("wrong move Nf6 was accepted") + } + if m.state != puzzleStateFailure { + t.Fatalf("state = %v after wrong move, want puzzleStateFailure", m.state) + } + m.handleRetryKey() + if m.state != puzzleStatePlaying { + t.Errorf("state = %v after retry, want puzzleStatePlaying", m.state) + } + if m.solutionIdx != 2 { + t.Errorf("solutionIdx = %d after retry, want 2 (progress preserved)", m.solutionIdx) + } + if len(m.game.Moves()) != 2 { + t.Errorf("game has %d moves after retry, want 2 (not reset)", len(m.game.Moves())) + } +} + +func TestRetryAfterSuccessResetsToStart(t *testing.T) { + m := setupPuzzleModel(t) + m.validateAndApply("d5") // correct single-move solution → success + if m.state != puzzleStateSuccess { + t.Fatalf("state = %v, want puzzleStateSuccess", m.state) + } + m.handleRetryKey() + if m.state != puzzleStatePlaying { + t.Errorf("state = %v after retry, want puzzleStatePlaying", m.state) + } + if m.solutionIdx != 0 { + t.Errorf("solutionIdx = %d after retry, want 0 (reset to start)", m.solutionIdx) + } + if len(m.game.Moves()) != 0 { + t.Errorf("game has %d moves after retry, want 0 (reset to start)", len(m.game.Moves())) + } +} + +func TestSubmittedPersistsAcrossRetry(t *testing.T) { + // Failure path: submit fires once, guard survives the resume. + m := setupPuzzleModel(t) + m.validateAndApply("e5") // wrong → failure + if cmd := m.submitAttempt(false); cmd == nil { + t.Fatal("first submitAttempt returned nil, expected a command") + } + if !m.submitted { + t.Fatal("expected submitted=true after first submitAttempt") + } + m.handleRetryKey() // resume from failure + if !m.submitted { + t.Error("submitted should remain true after retry-from-failure") + } + if cmd := m.submitAttempt(true); cmd != nil { + t.Error("submitAttempt after retry should be a no-op (nil), guard not held") + } + // Success path: guard survives the reset-to-start replay. + m2 := setupPuzzleModel(t) + m2.validateAndApply("d5") // → success + m2.submitAttempt(true) + m2.handleRetryKey() // reset to start + if !m2.submitted { + t.Error("submitted should remain true after retry-from-success") + } + if cmd := m2.submitAttempt(true); cmd != nil { + t.Error("submitAttempt after success replay should be a no-op (nil)") + } +} + +func TestNewPuzzleResetsSubmitted(t *testing.T) { + m := setupPuzzleModel(t) + m.submitAttempt(false) + if !m.submitted { + t.Fatal("expected submitted=true after submitAttempt") + } + m.SetPuzzle(shared.PuzzleRecord{ + ID: "next1", FEN: openingFEN, Moves: "d7d5", Rating: 1500, UserPuzzleRating: 1500, + }) + if m.submitted { + t.Error("submitted should reset to false for a new puzzle") + } +} + +func TestDeltaPersistsAcrossRetry(t *testing.T) { + m := setupPuzzleModel(t) + m.validateAndApply("e5") // wrong → failure + m.submitAttempt(false) + m.Update(PuzzleAttemptMsg{NewRating: 1484}) // initial rating 1500 → delta -16 + if !m.hasDelta || m.lastDelta != -16 { + t.Fatalf("hasDelta=%v lastDelta=%d, want true/-16", m.hasDelta, m.lastDelta) + } + m.handleRetryKey() // resume from failure + if !m.hasDelta || m.lastDelta != -16 { + t.Errorf("after retry hasDelta=%v lastDelta=%d, want true/-16 (delta persists)", m.hasDelta, m.lastDelta) } } @@ -221,10 +326,13 @@ func TestRetryFromSolutionState(t *testing.T) { m := setupPuzzleModel(t) m.validateAndApply("e5") m.showSolution() - m.retry() + m.handleRetryKey() if m.state != puzzleStatePlaying { t.Errorf("state = %v after retry from solution, want puzzleStatePlaying", m.state) } + if m.solutionIdx != 0 { + t.Errorf("solutionIdx = %d after retry from solution, want 0 (reset to start)", m.solutionIdx) + } } func TestSKeyTriggersShowSolution(t *testing.T) { diff --git a/internal/server/db.go b/internal/server/db.go index 9c6118f..ea624e4 100644 --- a/internal/server/db.go +++ b/internal/server/db.go @@ -407,6 +407,17 @@ func (d *DB) RecordAttemptAndUpdateRating(ctx context.Context, username, puzzleI return tx.Commit(ctx) } +// HasAttempted reports whether the user already has any recorded attempt for the puzzle. +func (d *DB) HasAttempted(ctx context.Context, username, puzzleID string) (bool, error) { + var exists bool + err := d.pool.QueryRow( + ctx, + `SELECT EXISTS(SELECT 1 FROM user_puzzle_attempts WHERE username = $1 AND puzzle_id = $2)`, + username, puzzleID, + ).Scan(&exists) + return exists, err +} + // GetPuzzleRating returns the user's current puzzle rating. Returns 1500 if user not found. func (d *DB) GetPuzzleRating(ctx context.Context, username string) (int, error) { var rating int diff --git a/internal/server/db_test.go b/internal/server/db_test.go index e88801d..a3faa1a 100644 --- a/internal/server/db_test.go +++ b/internal/server/db_test.go @@ -406,6 +406,91 @@ func TestDB_RecordAttemptAndUpdateRating(t *testing.T) { } } +func TestDB_HasAttempted(t *testing.T) { + db := testDB(t) + truncateAll(t, db) + ctx := context.Background() + if err := db.CreateUser(ctx, "ha"); err != nil { + t.Fatal(err) + } + base := PuzzleRow{ + ID: "ha1", Rating: 1500, + FEN: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + Moves: "d7d5", PuzzleDate: time.Now().UTC().Truncate(24 * time.Hour), + Themes: []string{}, OpeningTags: []string{}, + } + if err := db.SavePuzzle(ctx, base); err != nil { + t.Fatal(err) + } + attempted, err := db.HasAttempted(ctx, "ha", "ha1") + if err != nil { + t.Fatalf("HasAttempted: %v", err) + } + if attempted { + t.Fatal("expected HasAttempted=false before any attempt") + } + if err := db.RecordAttemptAndUpdateRating(ctx, "ha", "ha1", true, false, 1516); err != nil { + t.Fatalf("RecordAttemptAndUpdateRating: %v", err) + } + attempted, err = db.HasAttempted(ctx, "ha", "ha1") + if err != nil { + t.Fatalf("HasAttempted: %v", err) + } + if !attempted { + t.Fatal("expected HasAttempted=true after recording an attempt") + } +} + +func TestRecordPuzzleAttempt_Idempotent(t *testing.T) { + db := testDB(t) + truncateAll(t, db) + ctx := context.Background() + if err := db.CreateUser(ctx, "idem"); err != nil { + t.Fatal(err) + } + base := PuzzleRow{ + ID: "idem1", Rating: 1500, + FEN: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + Moves: "d7d5", PuzzleDate: time.Now().UTC().Truncate(24 * time.Hour), + Themes: []string{}, OpeningTags: []string{}, + } + if err := db.SavePuzzle(ctx, base); err != nil { + t.Fatal(err) + } + h := &Hub{db: db, clients: make(map[string]*Client), games: make(map[string]*Game)} + r1, err := h.RecordPuzzleAttempt(ctx, "idem", "idem1", false, false) // first try: failed + if err != nil { + t.Fatalf("first RecordPuzzleAttempt: %v", err) + } + if r1 >= 1500 { + t.Fatalf("expected rating to drop after a failed attempt, got %d", r1) + } + r2, err := h.RecordPuzzleAttempt(ctx, "idem", "idem1", true, false) // retry: must be a no-op + if err != nil { + t.Fatalf("second RecordPuzzleAttempt: %v", err) + } + if r2 != r1 { + t.Errorf("second attempt changed rating: r1=%d r2=%d, want unchanged", r1, r2) + } + stored, err := db.GetPuzzleRating(ctx, "idem") + if err != nil { + t.Fatalf("GetPuzzleRating: %v", err) + } + if stored != r1 { + t.Errorf("stored rating = %d, want %d (no second mutation)", stored, r1) + } + var count int + row := db.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM user_puzzle_attempts WHERE username = $1 AND puzzle_id = $2`, + "idem", "idem1") + if err := row.Scan(&count); err != nil { + t.Fatalf("count attempts: %v", err) + } + if count != 1 { + t.Errorf("attempt rows = %d, want 1 (no duplicate)", count) + } +} + func TestDB_GetPuzzleRating_DefaultWhenNotFound(t *testing.T) { db := testDB(t) truncateAll(t, db) diff --git a/internal/server/hub.go b/internal/server/hub.go index a246698..8db5773 100644 --- a/internal/server/hub.go +++ b/internal/server/hub.go @@ -218,6 +218,14 @@ func (h *Hub) RecordPuzzleAttempt(ctx context.Context, username, puzzleID string if err != nil { return 0, err } + attempted, err := h.db.HasAttempted(ctx, username, puzzleID) + if err != nil { + return 0, err + } + if attempted { + slog.Info("Puzzle already attempted; rating unchanged", "username", username, "puzzle_id", puzzleID) + return currentRating, nil + } newRating := currentRating if !skipped { newRating = PuzzleEloOutcome(currentRating, puzzle.Rating, solved)