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.

716 lines
20 KiB
Go

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