|
|
|
|
@ -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.
|
|
|
|
|
|