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
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
|
|
}
|