You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

489 lines
14 KiB
Go

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
}