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.

158 lines
4.6 KiB
Go

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
}