package session import ( "context" "errors" "fmt" "strings" "time" domain "knowfoolery/backend/services/game-session-service/internal/domain/session" sharederrors "knowfoolery/backend/shared/domain/errors" "knowfoolery/backend/shared/domain/valueobjects" sharedsecurity "knowfoolery/backend/shared/infra/security" ) // QuestionBankClient defines question-bank adapter methods used by session service. type QuestionBankClient interface { GetRandomQuestion(ctx context.Context, exclusions []string, theme, difficulty string) (*SessionQuestion, error) GetQuestionByID(ctx context.Context, id string) (*SessionQuestion, error) ValidateAnswer(ctx context.Context, questionID, answer string) (*AnswerValidationResult, error) } // UserClient defines user-service adapter methods used by session service. type UserClient interface { GetUserProfile(ctx context.Context, userID, bearerToken string) (*UserProfile, error) } // StateStore defines ephemeral session state and locking behavior. type StateStore interface { GetActiveSession(ctx context.Context, playerID string) (string, bool) SetActiveSession(ctx context.Context, playerID, sessionID string, ttl time.Duration) error ClearActiveSession(ctx context.Context, playerID string) error SetTimer(ctx context.Context, sessionID string, expiresAt time.Time, ttl time.Duration) error GetTimer(ctx context.Context, sessionID string) (time.Time, bool) ClearTimer(ctx context.Context, sessionID string) error AcquireLock(ctx context.Context, sessionID string, ttl time.Duration) bool ReleaseLock(ctx context.Context, sessionID string) } // Config controls application-level game session behavior. type Config struct { SessionDuration time.Duration MaxAttempts int MinAnswerLatencyMs int LockTTL time.Duration ActiveSessionKeyTTL time.Duration EndReasonDefault string } // Service orchestrates game session use-cases. type Service struct { repo domain.Repository qb QuestionBankClient users UserClient state StateStore cfg Config } // NewService creates a new game session service. func NewService( repo domain.Repository, qb QuestionBankClient, users UserClient, state StateStore, cfg Config, ) *Service { if cfg.SessionDuration <= 0 { cfg.SessionDuration = 30 * time.Minute } if cfg.MaxAttempts <= 0 { cfg.MaxAttempts = valueobjects.MaxAttempts } if cfg.MinAnswerLatencyMs < 0 { cfg.MinAnswerLatencyMs = 300 } if cfg.LockTTL <= 0 { cfg.LockTTL = 3 * time.Second } if cfg.ActiveSessionKeyTTL <= 0 { cfg.ActiveSessionKeyTTL = 35 * time.Minute } if strings.TrimSpace(cfg.EndReasonDefault) == "" { cfg.EndReasonDefault = "abandoned" } return &Service{repo: repo, qb: qb, users: users, state: state, cfg: cfg} } // StartSession creates a new session and assigns the first question. func (s *Service) StartSession(ctx context.Context, in StartSessionInput) (*StartSessionResult, error) { playerID := strings.TrimSpace(in.PlayerID) if playerID == "" { return nil, sharederrors.New(sharederrors.CodeUnauthorized, "player identity is required") } if active, ok := s.state.GetActiveSession(ctx, playerID); ok && strings.TrimSpace(active) != "" { return nil, domain.ErrGameInProgress } if existing, err := s.repo.GetActiveSessionByPlayerID(ctx, playerID); err == nil && existing != nil { return nil, domain.ErrGameInProgress } else if err != nil && !isNotFound(err) { return nil, err } profile, err := s.users.GetUserProfile(ctx, playerID, in.BearerToken) if err != nil { return nil, err } if !profile.EmailVerified { return nil, sharederrors.New(sharederrors.CodeEmailNotVerified, "email must be verified to start a session") } now := time.Now().UTC() session := &domain.GameSession{ PlayerID: profile.ID, PlayerName: sharedsecurity.SanitizePlayerName(profile.DisplayName), Status: domain.StatusActive, StartTime: now, } if session.PlayerName == "" { session.PlayerName = "Player" } created, err := s.repo.CreateSession(ctx, session) if err != nil { return nil, err } question, err := s.qb.GetRandomQuestion(ctx, nil, in.PreferredTheme, in.Difficulty) if err != nil { return nil, err } created.CurrentQuestionID = question.ID created.QuestionStartedAt = ptrTime(now) created.QuestionsAsked = 1 updated, err := s.repo.UpdateSession(ctx, created) if err != nil { return nil, err } expires := now.Add(s.cfg.SessionDuration) _ = s.state.SetActiveSession(ctx, updated.PlayerID, updated.ID, s.cfg.ActiveSessionKeyTTL) _ = s.state.SetTimer(ctx, updated.ID, expires, s.cfg.ActiveSessionKeyTTL) return &StartSessionResult{Session: s.toSummary(updated), Question: *question}, nil } // SubmitAnswer validates answer and transitions question/session state. func (s *Service) SubmitAnswer(ctx context.Context, in SubmitAnswerInput) (*SubmitAnswerResult, error) { sessionID, answer, err := s.normalizeSubmitAnswerInput(in) if err != nil { return nil, err } if !s.state.AcquireLock(ctx, sessionID, s.cfg.LockTTL) { return nil, sharederrors.New(sharederrors.CodeConflict, "session is being updated") } 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 } if err := s.ensureSessionActive(ctx, session); err != nil { return nil, err } 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{ SessionID: session.ID, EventType: "rapid_answer", Metadata: fmt.Sprintf(`{"latency_ms":%d}`, latencyMs), }) return 0, domain.ErrRapidAnswer } return latencyMs, nil } 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: matched, UsedHint: session.CurrentHintUsed, AwardedScore: score, LatencyMs: latencyMs, }) 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 matched { session.QuestionsCorrect++ } } 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 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 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. func (s *Service) RequestHint(ctx context.Context, in RequestHintInput) (*HintResult, error) { sessionID := strings.TrimSpace(in.SessionID) if sessionID == "" { return nil, domain.ErrInvalidState } if !s.state.AcquireLock(ctx, sessionID, s.cfg.LockTTL) { return nil, sharederrors.New(sharederrors.CodeConflict, "session is being updated") } defer s.state.ReleaseLock(ctx, sessionID) session, err := s.repo.GetSessionByID(ctx, sessionID) if err != nil { return nil, err } if err := s.ensureSessionActive(ctx, session); err != nil { return nil, err } if session.CurrentQuestionID == "" { return nil, domain.ErrInvalidState } if session.CurrentHintUsed { return nil, domain.ErrHintAlreadyUsed } question, err := s.qb.GetQuestionByID(ctx, session.CurrentQuestionID) if err != nil { return nil, err } session.CurrentHintUsed = true session.HintsUsed++ updated, err := s.repo.UpdateSession(ctx, session) if err != nil { return nil, err } return &HintResult{Session: s.toSummary(updated), Hint: question.Hint}, nil } // GetSession returns canonical session status. func (s *Service) GetSession(ctx context.Context, sessionID string) (*SessionSummary, error) { session, err := s.repo.GetSessionByID(ctx, strings.TrimSpace(sessionID)) if err != nil { return nil, err } if session.Status == domain.StatusActive { _ = s.ensureSessionActive(ctx, session) session, err = s.repo.GetSessionByID(ctx, strings.TrimSpace(sessionID)) if err != nil { return nil, err } } summary := s.toSummary(session) return &summary, nil } // GetCurrentQuestion returns current question payload for a session. func (s *Service) GetCurrentQuestion(ctx context.Context, sessionID string) (*SessionQuestion, error) { session, err := s.repo.GetSessionByID(ctx, strings.TrimSpace(sessionID)) if err != nil { return nil, err } if session.CurrentQuestionID == "" { return nil, domain.ErrInvalidState } return s.qb.GetQuestionByID(ctx, session.CurrentQuestionID) } // EndSession ends an active session. func (s *Service) EndSession(ctx context.Context, in EndSessionInput) (*SessionSummary, error) { session, err := s.repo.GetSessionByID(ctx, strings.TrimSpace(in.SessionID)) if err != nil { return nil, err } if session.Status.IsTerminal() { summary := s.toSummary(session) return &summary, nil } now := time.Now().UTC() session.Status = domain.StatusAbandoned session.EndTime = &now updated, err := s.repo.UpdateSession(ctx, session) if err != nil { return nil, err } reason := strings.TrimSpace(in.Reason) if reason == "" { reason = s.cfg.EndReasonDefault } _ = s.repo.CreateEvent(ctx, &domain.SessionEvent{ SessionID: session.ID, EventType: "session_ended", Metadata: fmt.Sprintf(`{"reason":"%s"}`, reason), }) _ = s.state.ClearActiveSession(ctx, session.PlayerID) _ = s.state.ClearTimer(ctx, session.ID) summary := s.toSummary(updated) return &summary, nil } func (s *Service) advanceQuestion(ctx context.Context, session *domain.GameSession) (*SessionQuestion, bool, error) { exclusions, err := s.repo.ListQuestionIDsForSession(ctx, session.ID) if err != nil { return nil, false, err } next, err := s.qb.GetRandomQuestion(ctx, exclusions, "", "") if err != nil { var domainErr *sharederrors.DomainError if errors.As(err, &domainErr) && domainErr.Code == sharederrors.CodeNoQuestionsAvailable { return nil, true, nil } return nil, false, err } return next, false, nil } func (s *Service) ensureSessionActive(ctx context.Context, session *domain.GameSession) error { if session.Status != domain.StatusActive { if session.Status == domain.StatusTimedOut { return domain.ErrSessionExpired } return domain.ErrSessionNotActive } expiresAt := session.StartTime.Add(s.cfg.SessionDuration) if time.Now().UTC().After(expiresAt) { now := time.Now().UTC() session.Status = domain.StatusTimedOut session.EndTime = &now _, _ = s.repo.UpdateSession(ctx, session) _ = s.repo.CreateEvent(ctx, &domain.SessionEvent{SessionID: session.ID, EventType: "timeout", Metadata: `{}`}) _ = s.state.ClearActiveSession(ctx, session.PlayerID) _ = s.state.ClearTimer(ctx, session.ID) return domain.ErrSessionExpired } return nil } func (s *Service) toSummary(session *domain.GameSession) SessionSummary { remaining := int64(session.StartTime.Add(s.cfg.SessionDuration).Sub(time.Now().UTC()).Seconds()) if remaining < 0 { remaining = 0 } return SessionSummary{ ID: session.ID, PlayerID: session.PlayerID, PlayerName: session.PlayerName, Status: string(session.Status), TotalScore: session.TotalScore, QuestionsAsked: session.QuestionsAsked, QuestionsCorrect: session.QuestionsCorrect, HintsUsed: session.HintsUsed, CurrentQuestionID: session.CurrentQuestionID, CurrentAttempts: session.CurrentAttempts, CurrentHintUsed: session.CurrentHintUsed, StartTime: session.StartTime, EndTime: session.EndTime, RemainingSeconds: remaining, } } func ptrTime(t time.Time) *time.Time { return &t } func isNotFound(err error) bool { if err == nil { return false } if errors.Is(err, domain.ErrSessionNotFound) { return true } var d *sharederrors.DomainError if errors.As(err, &d) { return d.Code == sharederrors.CodeNotFound } return false }