Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions internal/client/screens/puzzle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() {
Expand Down Expand Up @@ -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()
}
Expand Down
118 changes: 113 additions & 5 deletions internal/client/screens/puzzle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions internal/server/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions internal/server/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/server/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading