diff --git a/backend/services/game-session-service/internal/application/session/service.go b/backend/services/game-session-service/internal/application/session/service.go index 2a23eb3..2efa0b1 100644 --- a/backend/services/game-session-service/internal/application/session/service.go +++ b/backend/services/game-session-service/internal/application/session/service.go @@ -145,13 +145,9 @@ func (s *Service) StartSession(ctx context.Context, in StartSessionInput) (*Star // SubmitAnswer validates answer and transitions question/session state. func (s *Service) SubmitAnswer(ctx context.Context, in SubmitAnswerInput) (*SubmitAnswerResult, error) { - sessionID := strings.TrimSpace(in.SessionID) - if sessionID == "" { - return nil, domain.ErrInvalidState - } - answer := sharedsecurity.SanitizeAnswer(in.Answer) - if answer == "" { - return nil, sharederrors.New(sharederrors.CodeInvalidAnswer, "answer is required") + sessionID, answer, err := s.normalizeSubmitAnswerInput(in) + if err != nil { + return nil, err } if !s.state.AcquireLock(ctx, sessionID, s.cfg.LockTTL) { @@ -159,6 +155,60 @@ func (s *Service) SubmitAnswer(ctx context.Context, in SubmitAnswerInput) (*Subm } defer s.state.ReleaseLock(ctx, sessionID) + session, err := s.loadAnswerableSession(ctx, sessionID) + if err != nil { + return nil, err + } + + latencyMs, err := s.enforceAnswerLatency(ctx, session) + if err != nil { + return nil, err + } + + validation, err := s.qb.ValidateAnswer(ctx, session.CurrentQuestionID, answer) + if err != nil { + return nil, err + } + + attemptNum, score, err := s.recordAttempt(ctx, session, answer, validation.Matched, latencyMs) + if err != nil { + return nil, err + } + + s.applyAttemptOutcome(session, attemptNum, score, validation.Matched) + + result := &SubmitAnswerResult{Correct: validation.Matched, AwardedScore: score} + if err := s.progressAfterAnswer(ctx, session, attemptNum, validation.Matched, result); err != nil { + return nil, err + } + + updated, err := s.repo.UpdateSession(ctx, session) + if err != nil { + return nil, err + } + result.AttemptsRemaining = updated.Remaining(s.cfg.MaxAttempts) + result.Session = s.toSummary(updated) + return result, nil +} + +func (s *Service) normalizeSubmitAnswerInput( + in SubmitAnswerInput, +) (sessionID string, answer string, err error) { + sessionID = strings.TrimSpace(in.SessionID) + if sessionID == "" { + return "", "", domain.ErrInvalidState + } + answer = sharedsecurity.SanitizeAnswer(in.Answer) + if answer == "" { + return "", "", sharederrors.New(sharederrors.CodeInvalidAnswer, "answer is required") + } + return sessionID, answer, nil +} + +func (s *Service) loadAnswerableSession( + ctx context.Context, + sessionID string, +) (*domain.GameSession, error) { session, err := s.repo.GetSessionByID(ctx, sessionID) if err != nil { return nil, err @@ -169,7 +219,13 @@ func (s *Service) SubmitAnswer(ctx context.Context, in SubmitAnswerInput) (*Subm if strings.TrimSpace(session.CurrentQuestionID) == "" || session.QuestionStartedAt == nil { return nil, domain.ErrInvalidState } + return session, nil +} +func (s *Service) enforceAnswerLatency( + ctx context.Context, + session *domain.GameSession, +) (int, error) { latencyMs := int(time.Since(*session.QuestionStartedAt).Milliseconds()) if latencyMs < s.cfg.MinAnswerLatencyMs { _ = s.repo.CreateEvent(ctx, &domain.SessionEvent{ @@ -177,66 +233,79 @@ func (s *Service) SubmitAnswer(ctx context.Context, in SubmitAnswerInput) (*Subm EventType: "rapid_answer", Metadata: fmt.Sprintf(`{"latency_ms":%d}`, latencyMs), }) - return nil, domain.ErrRapidAnswer - } - - validation, err := s.qb.ValidateAnswer(ctx, session.CurrentQuestionID, answer) - if err != nil { - return nil, err + return 0, domain.ErrRapidAnswer } + return latencyMs, nil +} - attemptNum := session.CurrentAttempts + 1 - score := valueobjects.CalculateQuestionScore(validation.Matched, session.CurrentHintUsed) - if err := s.repo.CreateAttempt(ctx, &domain.SessionAttempt{ +func (s *Service) recordAttempt( + ctx context.Context, + session *domain.GameSession, + answer string, + matched bool, + latencyMs int, +) (attemptNum int, score int, err error) { + attemptNum = session.CurrentAttempts + 1 + score = valueobjects.CalculateQuestionScore(matched, session.CurrentHintUsed) + err = s.repo.CreateAttempt(ctx, &domain.SessionAttempt{ SessionID: session.ID, QuestionID: session.CurrentQuestionID, AttemptNumber: attemptNum, ProvidedAnswer: answer, - IsCorrect: validation.Matched, + IsCorrect: matched, UsedHint: session.CurrentHintUsed, AwardedScore: score, LatencyMs: latencyMs, - }); err != nil { - return nil, err + }) + if err != nil { + return 0, 0, err } + return attemptNum, score, nil +} +func (s *Service) applyAttemptOutcome( + session *domain.GameSession, + attemptNum, score int, + matched bool, +) { session.CurrentAttempts = attemptNum session.TotalScore += score - if validation.Matched { + if matched { session.QuestionsCorrect++ } +} - result := &SubmitAnswerResult{Correct: validation.Matched, AwardedScore: score} - if validation.Matched || attemptNum >= s.cfg.MaxAttempts { - next, completed, err := s.advanceQuestion(ctx, session) - if err != nil { - return nil, err - } - if completed { - now := time.Now().UTC() - session.Status = domain.StatusCompleted - session.EndTime = &now - _ = s.state.ClearActiveSession(ctx, session.PlayerID) - _ = s.state.ClearTimer(ctx, session.ID) - result.SessionCompleted = true - } else { - now := time.Now().UTC() - session.CurrentQuestionID = next.ID - session.CurrentAttempts = 0 - session.CurrentHintUsed = false - session.QuestionStartedAt = &now - session.QuestionsAsked++ - result.NextQuestion = next - } - } - - updated, err := s.repo.UpdateSession(ctx, session) +func (s *Service) progressAfterAnswer( + ctx context.Context, + session *domain.GameSession, + attemptNum int, + matched bool, + result *SubmitAnswerResult, +) error { + if !matched && attemptNum < s.cfg.MaxAttempts { + return nil + } + next, completed, err := s.advanceQuestion(ctx, session) if err != nil { - return nil, err + return err } - result.AttemptsRemaining = updated.Remaining(s.cfg.MaxAttempts) - result.Session = s.toSummary(updated) - return result, nil + if completed { + now := time.Now().UTC() + session.Status = domain.StatusCompleted + session.EndTime = &now + _ = s.state.ClearActiveSession(ctx, session.PlayerID) + _ = s.state.ClearTimer(ctx, session.ID) + result.SessionCompleted = true + return nil + } + now := time.Now().UTC() + session.CurrentQuestionID = next.ID + session.CurrentAttempts = 0 + session.CurrentHintUsed = false + session.QuestionStartedAt = &now + session.QuestionsAsked++ + result.NextQuestion = next + return nil } // RequestHint marks hint usage and returns hint content.