package valueobjects import ( "fmt" "knowfoolery/backend/shared/types" "knowfoolery/backend/shared/errors" ) // Attempt represents a single answer attempt within a game session type Attempt struct { id types.AttemptID questionID types.QuestionID attemptNumber int // 1, 2, or 3 playerAnswer string result *AttemptResult hintUsed bool attemptedAt types.Timestamp } // NewAttempt creates a new attempt func NewAttempt( questionID types.QuestionID, attemptNumber int, playerAnswer string, result *AttemptResult, hintUsed bool, ) (*Attempt, error) { // Validate inputs if questionID.IsEmpty() { return nil, errors.ErrValidationFailed("questionID", "question ID cannot be empty") } if attemptNumber < 1 || attemptNumber > types.MaxAttemptsPerQuestion { return nil, errors.ErrValidationFailed("attemptNumber", "attempt number must be between 1 and 3") } if result == nil { return nil, errors.ErrValidationFailed("result", "attempt result cannot be nil") } // Player answer can be empty for timeout attempts attempt := &Attempt{ id: types.NewAttemptID(), questionID: questionID, attemptNumber: attemptNumber, playerAnswer: playerAnswer, result: result, hintUsed: hintUsed, attemptedAt: types.NewTimestamp(), } return attempt, nil } // NewAttemptWithID creates an attempt with a specific ID (for loading from persistence) func NewAttemptWithID( id types.AttemptID, questionID types.QuestionID, attemptNumber int, playerAnswer string, result *AttemptResult, hintUsed bool, attemptedAt types.Timestamp, ) (*Attempt, error) { if id.IsEmpty() { return nil, errors.ErrValidationFailed("id", "attempt ID cannot be empty") } if questionID.IsEmpty() { return nil, errors.ErrValidationFailed("questionID", "question ID cannot be empty") } if attemptNumber < 1 || attemptNumber > types.MaxAttemptsPerQuestion { return nil, errors.ErrValidationFailed("attemptNumber", "attempt number must be between 1 and 3") } if result == nil { return nil, errors.ErrValidationFailed("result", "attempt result cannot be nil") } return &Attempt{ id: id, questionID: questionID, attemptNumber: attemptNumber, playerAnswer: playerAnswer, result: result, hintUsed: hintUsed, attemptedAt: attemptedAt, }, nil } // ID returns the attempt's unique identifier func (a *Attempt) ID() types.AttemptID { return a.id } // QuestionID returns the ID of the question this attempt is for func (a *Attempt) QuestionID() types.QuestionID { return a.questionID } // AttemptNumber returns the attempt number (1, 2, or 3) func (a *Attempt) AttemptNumber() int { return a.attemptNumber } // PlayerAnswer returns the answer provided by the player func (a *Attempt) PlayerAnswer() string { return a.playerAnswer } // Result returns the result of this attempt func (a *Attempt) Result() *AttemptResult { return a.result } // HintUsed returns true if a hint was used for this attempt func (a *Attempt) HintUsed() bool { return a.hintUsed } // AttemptedAt returns when the attempt was made func (a *Attempt) AttemptedAt() types.Timestamp { return a.attemptedAt } // IsCorrect returns true if the attempt was correct func (a *Attempt) IsCorrect() bool { return a.result.IsCorrect() } // IsIncorrect returns true if the attempt was incorrect func (a *Attempt) IsIncorrect() bool { return a.result.IsIncorrect() } // IsTimeout returns true if the attempt timed out func (a *Attempt) IsTimeout() bool { return a.result.IsTimeout() } // PointsAwarded returns the points awarded for this attempt func (a *Attempt) PointsAwarded() int { return a.result.PointsAwarded() } // IsFirstAttempt returns true if this is the first attempt for the question func (a *Attempt) IsFirstAttempt() bool { return a.attemptNumber == 1 } // IsLastAttempt returns true if this is the final attempt for the question func (a *Attempt) IsLastAttempt() bool { return a.attemptNumber == types.MaxAttemptsPerQuestion } // GetMatchType returns the type of match (exact, fuzzy, incorrect, timeout) func (a *Attempt) GetMatchType() string { return a.result.GetMatchType() } // GetSimilarity returns the similarity score between player and correct answer func (a *Attempt) GetSimilarity() float64 { return a.result.Similarity() } // Equals checks if two attempts are the same (based on ID) func (a *Attempt) Equals(other *Attempt) bool { if other == nil { return false } return a.id == other.id } // String returns a string representation of the attempt func (a *Attempt) String() string { return fmt.Sprintf("Attempt %d: %s", a.attemptNumber, a.result.GetResultDescription()) } // AttemptSnapshot represents a read-only view of attempt data type AttemptSnapshot struct { ID types.AttemptID `json:"id"` QuestionID types.QuestionID `json:"question_id"` AttemptNumber int `json:"attempt_number"` PlayerAnswer string `json:"player_answer"` IsCorrect bool `json:"is_correct"` IsTimeout bool `json:"is_timeout"` PointsAwarded int `json:"points_awarded"` HintUsed bool `json:"hint_used"` MatchType string `json:"match_type"` Similarity float64 `json:"similarity"` TimeTaken string `json:"time_taken"` AttemptedAt types.Timestamp `json:"attempted_at"` Result *AttemptResultSummary `json:"result"` } // ToSnapshot creates a snapshot of the current attempt state func (a *Attempt) ToSnapshot() *AttemptSnapshot { return &AttemptSnapshot{ ID: a.id, QuestionID: a.questionID, AttemptNumber: a.attemptNumber, PlayerAnswer: a.playerAnswer, IsCorrect: a.IsCorrect(), IsTimeout: a.IsTimeout(), PointsAwarded: a.PointsAwarded(), HintUsed: a.hintUsed, MatchType: a.GetMatchType(), Similarity: a.GetSimilarity(), TimeTaken: a.result.GetTimeTakenFormatted(), AttemptedAt: a.attemptedAt, Result: a.result.ToSummary(), } } // Validate performs comprehensive validation on the attempt func (a *Attempt) Validate() *types.ValidationResult { result := types.NewValidationResult() // Validate ID if a.id.IsEmpty() { result.AddError("attempt ID cannot be empty") } // Validate question ID if a.questionID.IsEmpty() { result.AddError("question ID cannot be empty") } // Validate attempt number if a.attemptNumber < 1 || a.attemptNumber > types.MaxAttemptsPerQuestion { result.AddErrorf("attempt number must be between 1 and %d", types.MaxAttemptsPerQuestion) } // Validate result if a.result == nil { result.AddError("attempt result cannot be nil") } // Validate timestamp if a.attemptedAt.IsZero() { result.AddError("attempted timestamp cannot be zero") } // Business rule validations if a.IsTimeout() && a.playerAnswer != "" { result.AddError("timeout attempts should have empty player answer") } if !a.IsTimeout() && a.playerAnswer == "" { result.AddError("non-timeout attempts must have player answer") } return result } // GetAge returns how long ago this attempt was made func (a *Attempt) GetAge() types.Duration { now := types.NewTimestamp() age := now.Sub(a.attemptedAt) return types.NewDuration(age) } // Clone creates a deep copy of the attempt (for testing or other purposes) func (a *Attempt) Clone() *Attempt { return &Attempt{ id: a.id, questionID: a.questionID, attemptNumber: a.attemptNumber, playerAnswer: a.playerAnswer, result: a.result, // AttemptResult is immutable hintUsed: a.hintUsed, attemptedAt: a.attemptedAt, } }