package storage import ( "context" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" ) // AchievementStore handles achievement CRUD operations against PostgreSQL. type AchievementStore struct { pool *pgxpool.Pool } // NewAchievementStore creates a new AchievementStore backed by the given connection pool. func NewAchievementStore(pool *pgxpool.Pool) *AchievementStore { return &AchievementStore{pool: pool} } // ListAchievements returns all achievement definitions. func (s *AchievementStore) ListAchievements(ctx context.Context) ([]model.Achievement, error) { rows, err := s.pool.Query(ctx, ` SELECT id, title, description, condition_type, condition_value, reward_type, reward_amount FROM achievements ORDER BY condition_value ASC `) if err != nil { return nil, fmt.Errorf("list achievements: %w", err) } defer rows.Close() var achievements []model.Achievement for rows.Next() { var a model.Achievement if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.ConditionType, &a.ConditionValue, &a.RewardType, &a.RewardAmount); err != nil { return nil, fmt.Errorf("scan achievement: %w", err) } achievements = append(achievements, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list achievements rows: %w", err) } if achievements == nil { achievements = []model.Achievement{} } return achievements, nil } // GetHeroAchievements returns all unlocked achievements for a hero. func (s *AchievementStore) GetHeroAchievements(ctx context.Context, heroID int64) ([]model.HeroAchievement, error) { rows, err := s.pool.Query(ctx, ` SELECT ha.hero_id, ha.achievement_id, ha.unlocked_at, a.id, a.title, a.description, a.condition_type, a.condition_value, a.reward_type, a.reward_amount FROM hero_achievements ha JOIN achievements a ON ha.achievement_id = a.id WHERE ha.hero_id = $1 ORDER BY ha.unlocked_at ASC `, heroID) if err != nil { return nil, fmt.Errorf("get hero achievements: %w", err) } defer rows.Close() var has []model.HeroAchievement for rows.Next() { var ha model.HeroAchievement var a model.Achievement if err := rows.Scan( &ha.HeroID, &ha.AchievementID, &ha.UnlockedAt, &a.ID, &a.Title, &a.Description, &a.ConditionType, &a.ConditionValue, &a.RewardType, &a.RewardAmount, ); err != nil { return nil, fmt.Errorf("scan hero achievement: %w", err) } ha.Achievement = &a has = append(has, ha) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("hero achievements rows: %w", err) } if has == nil { has = []model.HeroAchievement{} } return has, nil } // UnlockAchievement records that a hero has unlocked an achievement. // Uses ON CONFLICT DO NOTHING to be idempotent. func (s *AchievementStore) UnlockAchievement(ctx context.Context, heroID int64, achievementID string) error { _, err := s.pool.Exec(ctx, ` INSERT INTO hero_achievements (hero_id, achievement_id, unlocked_at) VALUES ($1, $2, now()) ON CONFLICT DO NOTHING `, heroID, achievementID) if err != nil { return fmt.Errorf("unlock achievement: %w", err) } return nil } // CheckAndUnlock checks all achievement conditions against the hero's current stats, // unlocks any newly met achievements, and returns the list of newly unlocked ones. func (s *AchievementStore) CheckAndUnlock(ctx context.Context, hero *model.Hero) ([]model.Achievement, error) { allAchievements, err := s.ListAchievements(ctx) if err != nil { return nil, fmt.Errorf("check and unlock: %w", err) } // Load already-unlocked IDs for fast lookup. unlockedRows, err := s.pool.Query(ctx, ` SELECT achievement_id FROM hero_achievements WHERE hero_id = $1 `, hero.ID) if err != nil { return nil, fmt.Errorf("check unlocked: %w", err) } defer unlockedRows.Close() unlocked := make(map[string]bool) for unlockedRows.Next() { var id string if err := unlockedRows.Scan(&id); err != nil { return nil, fmt.Errorf("scan unlocked id: %w", err) } unlocked[id] = true } if err := unlockedRows.Err(); err != nil { return nil, fmt.Errorf("unlocked rows: %w", err) } var newlyUnlocked []model.Achievement now := time.Now() for i := range allAchievements { a := &allAchievements[i] if unlocked[a.ID] { continue } if !model.CheckAchievementCondition(a, hero) { continue } // Unlock it. _, err := s.pool.Exec(ctx, ` INSERT INTO hero_achievements (hero_id, achievement_id, unlocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING `, hero.ID, a.ID, now) if err != nil { return nil, fmt.Errorf("unlock achievement %s: %w", a.ID, err) } newlyUnlocked = append(newlyUnlocked, *a) } return newlyUnlocked, nil }