package gamesession import ( "fmt" "time" "knowfoolery/backend/shared/types" "knowfoolery/backend/shared/errors" "knowfoolery/backend/shared/valueobjects" sessionValueObjects "knowfoolery/backend/services/game-session-service/internal/domain/valueobjects" ) // GameSession represents a quiz game session for a player type GameSession struct { id types.GameSessionID userID types.UserID playerName *valueobjects.PlayerName status types.SessionStatus score *sessionValueObjects.Score timer *sessionValueObjects.SessionTimer currentQuestionID *types.QuestionID questionsAsked []types.QuestionID // Track all questions asked attempts map[types.QuestionID][]*sessionValueObjects.Attempt // Attempts per question questionsCorrect int hintsUsed int hintUsedForCurrentQ bool // Track if hint was used for current question lastActivityAt types.Timestamp createdAt types.Timestamp updatedAt types.Timestamp } // NewGameSession creates a new game session func NewGameSession( userID types.UserID, playerName *valueobjects.PlayerName, ) (*GameSession, error) { if userID.IsEmpty() { return nil, errors.ErrValidationFailed("userID", "user ID cannot be empty") } if playerName == nil || playerName.IsEmpty() { return nil, errors.ErrInvalidPlayerName(playerName.String(), "player name cannot be empty") } now := types.NewTimestamp() gameSession := &GameSession{ id: types.NewGameSessionID(), userID: userID, playerName: playerName, status: types.SessionStatusCreated, score: sessionValueObjects.NewZeroScore(), timer: nil, // Will be set when session starts currentQuestionID: nil, questionsAsked: make([]types.QuestionID, 0), attempts: make(map[types.QuestionID][]*sessionValueObjects.Attempt), questionsCorrect: 0, hintsUsed: 0, hintUsedForCurrentQ: false, lastActivityAt: now, createdAt: now, updatedAt: now, } return gameSession, nil } // NewGameSessionWithID creates a GameSession with specific ID (for loading from persistence) func NewGameSessionWithID( id types.GameSessionID, userID types.UserID, playerName *valueobjects.PlayerName, status types.SessionStatus, score *sessionValueObjects.Score, timer *sessionValueObjects.SessionTimer, currentQuestionID *types.QuestionID, questionsAsked []types.QuestionID, attempts map[types.QuestionID][]*sessionValueObjects.Attempt, questionsCorrect int, hintsUsed int, lastActivityAt types.Timestamp, createdAt types.Timestamp, updatedAt types.Timestamp, ) (*GameSession, error) { if id.IsEmpty() { return nil, errors.ErrValidationFailed("id", "session ID cannot be empty") } if userID.IsEmpty() { return nil, errors.ErrValidationFailed("userID", "user ID cannot be empty") } if playerName == nil { return nil, errors.ErrInvalidPlayerName("", "player name cannot be nil") } if score == nil { return nil, errors.ErrValidationFailed("score", "score cannot be nil") } if questionsAsked == nil { questionsAsked = make([]types.QuestionID, 0) } if attempts == nil { attempts = make(map[types.QuestionID][]*sessionValueObjects.Attempt) } return &GameSession{ id: id, userID: userID, playerName: playerName, status: status, score: score, timer: timer, currentQuestionID: currentQuestionID, questionsAsked: questionsAsked, attempts: attempts, questionsCorrect: questionsCorrect, hintsUsed: hintsUsed, lastActivityAt: lastActivityAt, createdAt: createdAt, updatedAt: updatedAt, }, nil } // ID returns the session ID func (gs *GameSession) ID() types.GameSessionID { return gs.id } // UserID returns the user ID func (gs *GameSession) UserID() types.UserID { return gs.userID } // PlayerName returns the player name func (gs *GameSession) PlayerName() *valueobjects.PlayerName { return gs.playerName } // Status returns the session status func (gs *GameSession) Status() types.SessionStatus { return gs.status } // Score returns the current score func (gs *GameSession) Score() *sessionValueObjects.Score { return gs.score } // Timer returns the session timer func (gs *GameSession) Timer() *sessionValueObjects.SessionTimer { return gs.timer } // CurrentQuestionID returns the current question ID func (gs *GameSession) CurrentQuestionID() *types.QuestionID { return gs.currentQuestionID } // QuestionsAsked returns all questions asked in this session func (gs *GameSession) QuestionsAsked() []types.QuestionID { return gs.questionsAsked } // QuestionsCorrect returns the number of questions answered correctly func (gs *GameSession) QuestionsCorrect() int { return gs.questionsCorrect } // HintsUsed returns the total number of hints used func (gs *GameSession) HintsUsed() int { return gs.hintsUsed } // CreatedAt returns when the session was created func (gs *GameSession) CreatedAt() types.Timestamp { return gs.createdAt } // UpdatedAt returns when the session was last updated func (gs *GameSession) UpdatedAt() types.Timestamp { return gs.updatedAt } // LastActivityAt returns the last activity timestamp func (gs *GameSession) LastActivityAt() types.Timestamp { return gs.lastActivityAt } // Start starts the game session func (gs *GameSession) Start(firstQuestionID types.QuestionID) error { // Validate state transition if !gs.status.CanTransitionTo(types.SessionStatusActive) { return errors.ErrInvalidSessionTransition(gs.status, types.SessionStatusActive) } if firstQuestionID.IsEmpty() { return errors.ErrValidationFailed("firstQuestionID", "first question ID cannot be empty") } // Start the timer gs.timer = sessionValueObjects.NewSessionTimerNow() // Set first question gs.currentQuestionID = &firstQuestionID gs.questionsAsked = append(gs.questionsAsked, firstQuestionID) // Update status and timestamps gs.status = types.SessionStatusActive now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return nil } // PresentQuestion presents a new question to the player func (gs *GameSession) PresentQuestion(questionID types.QuestionID) error { if !gs.IsActive() { return errors.ErrSessionNotActive(gs.id, gs.status) } if questionID.IsEmpty() { return errors.ErrValidationFailed("questionID", "question ID cannot be empty") } // Check if question was already asked if gs.wasQuestionAsked(questionID) { return errors.ErrQuestionAlreadyAnswered(questionID, gs.id) } // Set new current question gs.currentQuestionID = &questionID gs.questionsAsked = append(gs.questionsAsked, questionID) gs.hintUsedForCurrentQ = false // Reset hint usage for new question // Update activity now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return nil } // RequestHint requests a hint for the current question func (gs *GameSession) RequestHint() error { if !gs.IsActive() { return errors.ErrSessionNotActive(gs.id, gs.status) } if gs.currentQuestionID == nil { return errors.ErrOperationNotAllowed("request hint", "no current question") } if gs.hintUsedForCurrentQ { return errors.ErrHintAlreadyUsed(*gs.currentQuestionID) } // Mark hint as used gs.hintUsedForCurrentQ = true gs.hintsUsed++ // Update activity now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return nil } // SubmitAnswer submits an answer for the current question func (gs *GameSession) SubmitAnswer( playerAnswer string, correctAnswer string, similarity float64, isFuzzyMatch bool, timeSinceQuestionPresented types.Duration, ) (*sessionValueObjects.Attempt, error) { // Validate session state if !gs.IsActive() { return nil, errors.ErrSessionNotActive(gs.id, gs.status) } if gs.currentQuestionID == nil { return nil, errors.ErrOperationNotAllowed("submit answer", "no current question") } // Check if we've exceeded max attempts for this question currentAttempts := gs.getAttemptsForQuestion(*gs.currentQuestionID) if len(currentAttempts) >= types.MaxAttemptsPerQuestion { return nil, errors.ErrMaxAttemptsExceeded(*gs.currentQuestionID, types.MaxAttemptsPerQuestion) } // Anti-cheat: Check minimum time between question presentation and answer if timeSinceQuestionPresented.Duration < types.MinTimeBeforeAnswer { return nil, errors.ErrAttemptTooFast(types.MinTimeBeforeAnswer.String()) } // Determine if answer is correct isCorrect := similarity >= types.FuzzyMatchThreshold // Calculate points pointsAwarded := sessionValueObjects.CalculatePointsForAttempt(isCorrect, gs.hintUsedForCurrentQ) // Create attempt result var attemptResult *sessionValueObjects.AttemptResult if isCorrect { attemptResult = sessionValueObjects.NewCorrectAttemptResult( pointsAwarded, gs.hintUsedForCurrentQ, timeSinceQuestionPresented, playerAnswer, correctAnswer, similarity, isFuzzyMatch, ) } else { attemptResult = sessionValueObjects.NewIncorrectAttemptResult( gs.hintUsedForCurrentQ, timeSinceQuestionPresented, playerAnswer, correctAnswer, similarity, ) } // Create attempt attemptNumber := len(currentAttempts) + 1 attempt, err := sessionValueObjects.NewAttempt( *gs.currentQuestionID, attemptNumber, playerAnswer, attemptResult, gs.hintUsedForCurrentQ, ) if err != nil { return nil, err } // Store the attempt gs.attempts[*gs.currentQuestionID] = append(currentAttempts, attempt) // Update score if correct if isCorrect { newScore, err := gs.score.Add(pointsAwarded) if err != nil { return nil, errors.ErrScoringFailed(fmt.Sprintf("failed to update score: %v", err)) } gs.score = newScore gs.questionsCorrect++ } // Update activity now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return attempt, nil } // HandleTimeout handles a timeout for the current question func (gs *GameSession) HandleTimeout() (*sessionValueObjects.Attempt, error) { if !gs.IsActive() { return nil, errors.ErrSessionNotActive(gs.id, gs.status) } if gs.currentQuestionID == nil { return nil, errors.ErrOperationNotAllowed("handle timeout", "no current question") } // Create timeout attempt result attemptResult := sessionValueObjects.NewTimeoutAttemptResult("") // Don't reveal correct answer on timeout // Create timeout attempt currentAttempts := gs.getAttemptsForQuestion(*gs.currentQuestionID) attemptNumber := len(currentAttempts) + 1 attempt, err := sessionValueObjects.NewAttempt( *gs.currentQuestionID, attemptNumber, "", attemptResult, gs.hintUsedForCurrentQ, ) if err != nil { return nil, err } // Store the attempt gs.attempts[*gs.currentQuestionID] = append(currentAttempts, attempt) // Update activity now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return attempt, nil } // End ends the game session func (gs *GameSession) End() error { if !gs.status.CanTransitionTo(types.SessionStatusCompleted) { return errors.ErrInvalidSessionTransition(gs.status, types.SessionStatusCompleted) } // End the timer if gs.timer != nil { if err := gs.timer.End(); err != nil { return err } } // Update status and timestamps gs.status = types.SessionStatusCompleted now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return nil } // Timeout marks the session as timed out func (gs *GameSession) Timeout() error { if !gs.status.CanTransitionTo(types.SessionStatusTimedOut) { return errors.ErrInvalidSessionTransition(gs.status, types.SessionStatusTimedOut) } // End the timer if gs.timer != nil { if err := gs.timer.End(); err != nil { return err } } // Update status and timestamps gs.status = types.SessionStatusTimedOut now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return nil } // Abandon marks the session as abandoned func (gs *GameSession) Abandon() error { if !gs.status.CanTransitionTo(types.SessionStatusAbandoned) { return errors.ErrInvalidSessionTransition(gs.status, types.SessionStatusAbandoned) } // End the timer if gs.timer != nil { if err := gs.timer.End(); err != nil { return err } } // Update status and timestamps gs.status = types.SessionStatusAbandoned now := types.NewTimestamp() gs.lastActivityAt = now gs.updatedAt = now return nil } // IsActive returns true if the session is currently active func (gs *GameSession) IsActive() bool { return gs.status == types.SessionStatusActive } // IsCompleted returns true if the session is completed func (gs *GameSession) IsCompleted() bool { return gs.status == types.SessionStatusCompleted } // IsTimedOut returns true if the session timed out func (gs *GameSession) IsTimedOut() bool { return gs.status == types.SessionStatusTimedOut } // IsAbandoned returns true if the session was abandoned func (gs *GameSession) IsAbandoned() bool { return gs.status == types.SessionStatusAbandoned } // IsFinished returns true if the session is in a terminal state func (gs *GameSession) IsFinished() bool { return gs.IsCompleted() || gs.IsTimedOut() || gs.IsAbandoned() } // GetRemainingAttemptsForCurrentQuestion returns remaining attempts for current question func (gs *GameSession) GetRemainingAttemptsForCurrentQuestion() int { if gs.currentQuestionID == nil { return 0 } currentAttempts := gs.getAttemptsForQuestion(*gs.currentQuestionID) return types.MaxAttemptsPerQuestion - len(currentAttempts) } // GetAttemptsForQuestion returns all attempts for a specific question func (gs *GameSession) GetAttemptsForQuestion(questionID types.QuestionID) []*sessionValueObjects.Attempt { return gs.getAttemptsForQuestion(questionID) } // GetAllAttempts returns all attempts made in this session func (gs *GameSession) GetAllAttempts() map[types.QuestionID][]*sessionValueObjects.Attempt { return gs.attempts } // GetTotalQuestionsAsked returns the total number of questions asked func (gs *GameSession) GetTotalQuestionsAsked() int { return len(gs.questionsAsked) } // GetSuccessRate returns the success rate as a percentage func (gs *GameSession) GetSuccessRate() float64 { totalQuestions := gs.GetTotalQuestionsAsked() if totalQuestions == 0 { return 0.0 } return (float64(gs.questionsCorrect) / float64(totalQuestions)) * 100.0 } // GetSessionDuration returns the total session duration func (gs *GameSession) GetSessionDuration() types.Duration { if gs.timer == nil { return types.NewDuration(0) } return gs.timer.GetSessionDuration() } // CanRequestMoreQuestions returns true if the session can handle more questions func (gs *GameSession) CanRequestMoreQuestions() bool { return gs.IsActive() && !gs.IsExpired() } // IsExpired returns true if the session has expired due to time limit func (gs *GameSession) IsExpired() bool { if gs.timer == nil { return false } return gs.timer.IsExpired() } // CheckForTimeout checks if session should be timed out and handles it func (gs *GameSession) CheckForTimeout() error { if gs.IsFinished() { return nil // Already finished } if gs.IsExpired() { return gs.Timeout() } return nil } // wasQuestionAsked checks if a question was already asked in this session func (gs *GameSession) wasQuestionAsked(questionID types.QuestionID) bool { for _, askedID := range gs.questionsAsked { if askedID == questionID { return true } } return false } // getAttemptsForQuestion returns attempts for a specific question func (gs *GameSession) getAttemptsForQuestion(questionID types.QuestionID) []*sessionValueObjects.Attempt { if attempts, exists := gs.attempts[questionID]; exists { return attempts } return make([]*sessionValueObjects.Attempt, 0) } // Validate performs comprehensive validation on the game session func (gs *GameSession) Validate() *types.ValidationResult { result := types.NewValidationResult() // Validate IDs if gs.id.IsEmpty() { result.AddError("session ID cannot be empty") } if gs.userID.IsEmpty() { result.AddError("user ID cannot be empty") } // Validate player name if gs.playerName == nil { result.AddError("player name cannot be nil") } else if gs.playerName.IsEmpty() { result.AddError("player name cannot be empty") } // Validate score if gs.score == nil { result.AddError("score cannot be nil") } // Validate status if !types.IsValidSessionStatus(gs.status) { result.AddError("invalid session status") } // Validate counts if gs.questionsCorrect < 0 { result.AddError("questions correct cannot be negative") } if gs.hintsUsed < 0 { result.AddError("hints used cannot be negative") } if gs.questionsCorrect > len(gs.questionsAsked) { result.AddError("questions correct cannot exceed questions asked") } // Validate timestamps if gs.createdAt.IsZero() { result.AddError("created timestamp cannot be zero") } if gs.updatedAt.IsZero() { result.AddError("updated timestamp cannot be zero") } if gs.createdAt.After(gs.updatedAt) { result.AddError("created timestamp cannot be after updated timestamp") } return result } // GameSessionSnapshot represents a read-only view of the game session type GameSessionSnapshot struct { ID types.GameSessionID `json:"id"` UserID types.UserID `json:"user_id"` PlayerName string `json:"player_name"` Status types.SessionStatus `json:"status"` Score int `json:"score"` CurrentQuestionID *types.QuestionID `json:"current_question_id,omitempty"` QuestionsAsked []types.QuestionID `json:"questions_asked"` QuestionsCorrect int `json:"questions_correct"` HintsUsed int `json:"hints_used"` SuccessRate float64 `json:"success_rate"` SessionDuration string `json:"session_duration"` TimerStatus *sessionValueObjects.TimerStatus `json:"timer_status,omitempty"` CreatedAt types.Timestamp `json:"created_at"` UpdatedAt types.Timestamp `json:"updated_at"` LastActivityAt types.Timestamp `json:"last_activity_at"` } // ToSnapshot creates a snapshot of the current session state func (gs *GameSession) ToSnapshot() *GameSessionSnapshot { var timerStatus *sessionValueObjects.TimerStatus if gs.timer != nil { timerStatus = gs.timer.GetStatus() } return &GameSessionSnapshot{ ID: gs.id, UserID: gs.userID, PlayerName: gs.playerName.Value(), Status: gs.status, Score: gs.score.Value(), CurrentQuestionID: gs.currentQuestionID, QuestionsAsked: gs.questionsAsked, QuestionsCorrect: gs.questionsCorrect, HintsUsed: gs.hintsUsed, SuccessRate: gs.GetSuccessRate(), SessionDuration: gs.GetSessionDuration().String(), TimerStatus: timerStatus, CreatedAt: gs.createdAt, UpdatedAt: gs.updatedAt, LastActivityAt: gs.lastActivityAt, } } // Clone creates a deep copy of the game session (for testing or other purposes) func (gs *GameSession) Clone() *GameSession { // Deep copy attempts map attemptsCopy := make(map[types.QuestionID][]*sessionValueObjects.Attempt) for questionID, attempts := range gs.attempts { attemptsCopy[questionID] = make([]*sessionValueObjects.Attempt, len(attempts)) for i, attempt := range attempts { attemptsCopy[questionID][i] = attempt.Clone() } } // Deep copy questions asked slice questionsAskedCopy := make([]types.QuestionID, len(gs.questionsAsked)) copy(questionsAskedCopy, gs.questionsAsked) return &GameSession{ id: gs.id, userID: gs.userID, playerName: gs.playerName, // PlayerName is immutable status: gs.status, score: gs.score, // Score is immutable (value object) timer: gs.timer, // SessionTimer manages its own state currentQuestionID: gs.currentQuestionID, questionsAsked: questionsAskedCopy, attempts: attemptsCopy, questionsCorrect: gs.questionsCorrect, hintsUsed: gs.hintsUsed, hintUsedForCurrentQ: gs.hintUsedForCurrentQ, lastActivityAt: gs.lastActivityAt, createdAt: gs.createdAt, updatedAt: gs.updatedAt, } }