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