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.

279 lines
7.6 KiB
Go

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