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