package storage import ( "context" "errors" "fmt" "math/rand" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) // QuestStore handles quest system CRUD operations against PostgreSQL. type QuestStore struct { pool *pgxpool.Pool } var ErrTownLayoutMissing = errors.New("town layout: missing npc or building") type TownLayoutNPCUpdate struct { ID *int64 Name string NameKey string Type string OffsetX float64 OffsetY float64 BuildingID *int64 } type TownLayoutBuildingUpsert struct { ID *int64 BuildingType string OffsetX float64 OffsetY float64 Facing string FootprintW float64 FootprintH float64 } type TownLayoutObjectUpsert struct { ID *int64 ObjectType string Variant int OffsetX float64 OffsetY float64 } // NewQuestStore creates a new QuestStore backed by the given connection pool. func NewQuestStore(pool *pgxpool.Pool) *QuestStore { return &QuestStore{pool: pool} } // ListTowns returns all towns ordered by level_min ascending. func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC `) if err != nil { return nil, fmt.Errorf("list towns: %w", err) } defer rows.Close() var towns []model.Town for rows.Next() { var t model.Town if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { return nil, fmt.Errorf("scan town: %w", err) } towns = append(towns, t) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list towns rows: %w", err) } if towns == nil { towns = []model.Town{} } return towns, nil } // GetTown loads a single town by ID. Returns (nil, nil) if not found. func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, error) { var t model.Town err := s.pool.QueryRow(ctx, ` SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max FROM towns WHERE id = $1 `, townID).Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("get town: %w", err) } return &t, nil } // ListNPCsByTown returns all NPCs in the given town. func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) { rows, err := s.pool.Query(ctx, ` SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id FROM npcs WHERE town_id = $1 ORDER BY id ASC `, townID) if err != nil { return nil, fmt.Errorf("list npcs by town: %w", err) } defer rows.Close() var npcs []model.NPC for rows.Next() { var n model.NPC if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } npcs = append(npcs, n) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list npcs rows: %w", err) } if npcs == nil { npcs = []model.NPC{} } return npcs, nil } // GetNPCByID loads a single NPC by primary key. Returns (nil, nil) if not found. func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) { var n model.NPC err := s.pool.QueryRow(ctx, ` SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id FROM npcs WHERE id = $1 `, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("get npc by id: %w", err) } return &n, nil } // ListAllNPCs returns every NPC across all towns. func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { rows, err := s.pool.Query(ctx, ` SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id FROM npcs ORDER BY town_id ASC, id ASC `) if err != nil { return nil, fmt.Errorf("list all npcs: %w", err) } defer rows.Close() var npcs []model.NPC for rows.Next() { var n model.NPC if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } npcs = append(npcs, n) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list all npcs rows: %w", err) } if npcs == nil { npcs = []model.NPC{} } return npcs, nil } // ListBuildingsByTown returns all buildings in the given town. func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]model.TownBuilding, error) { rows, err := s.pool.Query(ctx, ` SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h FROM town_buildings WHERE town_id = $1 ORDER BY id ASC `, townID) if err != nil { return nil, fmt.Errorf("list buildings by town: %w", err) } defer rows.Close() var buildings []model.TownBuilding for rows.Next() { var b model.TownBuilding if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil { return nil, fmt.Errorf("scan building: %w", err) } buildings = append(buildings, b) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list buildings rows: %w", err) } if buildings == nil { buildings = []model.TownBuilding{} } return buildings, nil } // UpdateTownLayout updates NPC offsets and upserts town buildings in a single transaction. func (s *QuestStore) UpdateTownLayout( ctx context.Context, townID int64, npcs []TownLayoutNPCUpdate, buildings []TownLayoutBuildingUpsert, objects []TownLayoutObjectUpsert, deleteNPCIDs []int64, deleteBuildingIDs []int64, deleteObjectIDs []int64, ) error { tx, err := s.pool.Begin(ctx) if err != nil { return fmt.Errorf("update town layout begin: %w", err) } defer tx.Rollback(ctx) if len(deleteNPCIDs) > 0 { if _, err := tx.Exec(ctx, ` DELETE FROM npcs WHERE town_id = $1 AND id = ANY($2) `, townID, deleteNPCIDs); err != nil { return fmt.Errorf("delete npcs: %w", err) } } if len(deleteBuildingIDs) > 0 { if _, err := tx.Exec(ctx, ` DELETE FROM town_buildings WHERE town_id = $1 AND id = ANY($2) `, townID, deleteBuildingIDs); err != nil { return fmt.Errorf("delete town buildings: %w", err) } } if len(deleteObjectIDs) > 0 { if _, err := tx.Exec(ctx, ` DELETE FROM town_objects WHERE town_id = $1 AND id = ANY($2) `, townID, deleteObjectIDs); err != nil { return fmt.Errorf("delete town objects: %w", err) } } for _, n := range npcs { if n.ID != nil { tag, err := tx.Exec(ctx, ` UPDATE npcs SET name = $1, name_key = $2, type = $3, offset_x = $4, offset_y = $5, building_id = $6 WHERE id = $7 AND town_id = $8 `, n.Name, n.NameKey, n.Type, n.OffsetX, n.OffsetY, n.BuildingID, *n.ID, townID) if err != nil { return fmt.Errorf("update npc: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("update npc: %w", ErrTownLayoutMissing) } continue } if _, err := tx.Exec(ctx, ` INSERT INTO npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, townID, n.Name, n.NameKey, n.Type, n.OffsetX, n.OffsetY, time.Now().UTC(), n.BuildingID); err != nil { return fmt.Errorf("insert npc: %w", err) } } now := time.Now().UTC() for _, b := range buildings { if b.ID != nil { tag, err := tx.Exec(ctx, ` UPDATE town_buildings SET building_type = $1, offset_x = $2, offset_y = $3, facing = $4, footprint_w = $5, footprint_h = $6 WHERE id = $7 AND town_id = $8 `, b.BuildingType, b.OffsetX, b.OffsetY, b.Facing, b.FootprintW, b.FootprintH, *b.ID, townID) if err != nil { return fmt.Errorf("update town building: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("update town building: %w", ErrTownLayoutMissing) } continue } if err := tx.QueryRow(ctx, ` INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, townID, b.BuildingType, b.OffsetX, b.OffsetY, b.Facing, b.FootprintW, b.FootprintH, now).Scan(new(int64)); err != nil { return fmt.Errorf("insert town building: %w", err) } } for _, o := range objects { if o.ID != nil { tag, err := tx.Exec(ctx, ` UPDATE town_objects SET object_type = $1, variant = $2, offset_x = $3, offset_y = $4 WHERE id = $5 AND town_id = $6 `, o.ObjectType, o.Variant, o.OffsetX, o.OffsetY, *o.ID, townID) if err != nil { return fmt.Errorf("update town object: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("update town object: %w", ErrTownLayoutMissing) } continue } if _, err := tx.Exec(ctx, ` INSERT INTO town_objects (town_id, object_type, variant, offset_x, offset_y, created_at) VALUES ($1, $2, $3, $4, $5, $6) `, townID, o.ObjectType, o.Variant, o.OffsetX, o.OffsetY, now); err != nil { return fmt.Errorf("insert town object: %w", err) } } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("update town layout commit: %w", err) } return nil } // ListTownObjectsByTown returns all props in the given town. func (s *QuestStore) ListTownObjectsByTown(ctx context.Context, townID int64) ([]model.TownObject, error) { rows, err := s.pool.Query(ctx, ` SELECT id, town_id, object_type, variant, offset_x, offset_y FROM town_objects WHERE town_id = $1 ORDER BY id ASC `, townID) if err != nil { return nil, fmt.Errorf("list town objects: %w", err) } defer rows.Close() var objects []model.TownObject for rows.Next() { var o model.TownObject if err := rows.Scan(&o.ID, &o.TownID, &o.ObjectType, &o.Variant, &o.OffsetX, &o.OffsetY); err != nil { return nil, fmt.Errorf("scan town object: %w", err) } objects = append(objects, o) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list town objects rows: %w", err) } if objects == nil { objects = []model.TownObject{} } return objects, nil } // ListAllBuildings returns every building across all towns (for road_graph preload). func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) { rows, err := s.pool.Query(ctx, ` SELECT id, town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h FROM town_buildings ORDER BY town_id ASC, id ASC `) if err != nil { return nil, fmt.Errorf("list all buildings: %w", err) } defer rows.Close() var buildings []model.TownBuilding for rows.Next() { var b model.TownBuilding if err := rows.Scan(&b.ID, &b.TownID, &b.BuildingType, &b.OffsetX, &b.OffsetY, &b.Facing, &b.FootprintW, &b.FootprintH); err != nil { return nil, fmt.Errorf("scan building: %w", err) } buildings = append(buildings, b) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list all buildings rows: %w", err) } if buildings == nil { buildings = []model.TownBuilding{} } return buildings, nil } // ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range. func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests WHERE npc_id = $1 AND $2 BETWEEN min_level AND max_level ORDER BY min_level ASC, id ASC `, npcID, heroLevel) if err != nil { return nil, fmt.Errorf("list quests by npc for level: %w", err) } defer rows.Close() var quests []model.Quest for rows.Next() { var q model.Quest if err := rows.Scan( &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan quest: %w", err) } quests = append(quests, q) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list quests by npc for level rows: %w", err) } if quests == nil { quests = []model.Quest{} } return quests, nil } // HeroTakenQuestTemplateIDs returns quest template ids already present in the hero's log (any status). func (s *QuestStore) HeroTakenQuestTemplateIDs(ctx context.Context, heroID int64) ([]int64, error) { rows, err := s.pool.Query(ctx, `SELECT quest_id FROM hero_quests WHERE hero_id = $1`, heroID) if err != nil { return nil, fmt.Errorf("hero taken quest ids: %w", err) } defer rows.Close() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan quest_id: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("hero taken quest ids rows: %w", err) } return ids, nil } // ListOfferableQuestsForNPC returns level-matching NPC quests excluding templates already on the hero's log. // limit comes from tuning (e.g. questOffersPerNPC). timeBucket is a stable bucket (e.g. unixSeconds/7200) for rotations. func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcID int64, heroLevel int, limit int, timeBucket int64) ([]model.Quest, error) { npcRow, err := s.GetNPCByID(ctx, npcID) if err != nil { return nil, fmt.Errorf("load npc for quest offers: %w", err) } if npcRow == nil { return nil, fmt.Errorf("npc not found") } all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel) if err != nil { return nil, err } all = model.FilterQuestTemplatesByNPCType(all, npcRow.Type) takenIDs, err := s.HeroTakenQuestTemplateIDs(ctx, heroID) if err != nil { return nil, err } taken := make(map[int64]struct{}, len(takenIDs)) for _, id := range takenIDs { taken[id] = struct{}{} } seed := npcID ^ timeBucket filtered := FilterCapOfferableQuests(all, taken, limit, seed) if len(filtered) == 0 { return filtered, nil } if QuestOfferDrySpellThisPeriod(npcID, heroID, timeBucket, tuning.EffectiveQuestOfferDrySpellChance()) { return []model.Quest{}, nil } return filtered, nil } // ListQuestsByNPC returns all quest templates offered by the given NPC. func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests WHERE npc_id = $1 ORDER BY min_level ASC, id ASC `, npcID) if err != nil { return nil, fmt.Errorf("list quests by npc: %w", err) } defer rows.Close() var quests []model.Quest for rows.Next() { var q model.Quest if err := rows.Scan( &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan quest: %w", err) } quests = append(quests, q) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list quests rows: %w", err) } if quests == nil { quests = []model.Quest{} } return quests, nil } // ListAllQuestTemplates returns every quest template row (content catalog). func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests ORDER BY id ASC `) if err != nil { return nil, fmt.Errorf("list all quest templates: %w", err) } defer rows.Close() var quests []model.Quest for rows.Next() { var q model.Quest if err := rows.Scan( &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan quest: %w", err) } quests = append(quests, q) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list all quest templates rows: %w", err) } if quests == nil { quests = []model.Quest{} } return quests, nil } // UpdateQuestTemplate updates a quest definition row by id. func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) error { if q == nil || q.ID <= 0 { return fmt.Errorf("invalid quest id") } cmd, err := s.pool.Exec(ctx, ` UPDATE quests SET npc_id = $2, quest_key = NULLIF($3, ''), title = $4, description = $5, type = $6, target_count = $7, target_enemy_type = $8, target_enemy_archetype = $9, target_town_id = $10, drop_chance = $11, min_level = $12, max_level = $13, reward_xp = $14, reward_gold = $15, reward_potions = $16 WHERE id = $1 `, q.ID, q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount, q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, ) if err != nil { return fmt.Errorf("update quest: %w", err) } if cmd.RowsAffected() == 0 { return fmt.Errorf("quest not found: %d", q.ID) } return nil } // CreateQuestTemplate inserts a new quest row and sets q.ID. func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) error { if q == nil { return fmt.Errorf("nil quest") } if q.NPCID <= 0 || strings.TrimSpace(q.Title) == "" || q.Type == "" { return fmt.Errorf("npcId, title and type are required") } err := s.pool.QueryRow(ctx, ` INSERT INTO quests (npc_id, quest_key, title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) VALUES ($1, NULLIF($2, ''), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id `, q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount, q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, ).Scan(&q.ID) if err != nil { return fmt.Errorf("create quest: %w", err) } if q.QuestKey == "" { q.QuestKey = fmt.Sprintf("quest.%d", q.ID) _, _ = s.pool.Exec(ctx, `UPDATE quests SET quest_key = $2 WHERE id = $1`, q.ID, q.QuestKey) } return nil } // AcceptQuest creates a hero_quests row for the given hero and quest. // Returns an error if the quest is already present in the log (any status). func (s *QuestStore) AcceptQuest(ctx context.Context, heroID int64, questID int64) error { inserted, err := s.TryAcceptQuest(ctx, heroID, questID) if err != nil { return err } if !inserted { return fmt.Errorf("quest already in log") } return nil } // TryAcceptQuest inserts an accepted hero_quest row when none exists yet. Returns whether a row was inserted. func (s *QuestStore) TryAcceptQuest(ctx context.Context, heroID int64, questID int64) (bool, error) { tag, err := s.pool.Exec(ctx, ` INSERT INTO hero_quests (hero_id, quest_id, status, progress, accepted_at) VALUES ($1, $2, 'accepted', 0, now()) ON CONFLICT (hero_id, quest_id) DO NOTHING `, heroID, questID) if err != nil { return false, fmt.Errorf("try accept quest: %w", err) } return tag.RowsAffected() > 0, nil } // ListHeroQuests returns all quests for the hero with their quest template joined. func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.HeroQuest, error) { rows, err := s.pool.Query(ctx, ` SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress, hq.accepted_at, hq.completed_at, hq.claimed_at, q.id, q.npc_id, COALESCE(q.quest_key, ''), q.title, q.description, q.type, q.target_count, q.target_enemy_type, q.target_enemy_archetype, q.target_town_id, COALESCE(tt.name, '') AS target_town_name, q.drop_chance, q.min_level, q.max_level, q.reward_xp, q.reward_gold, q.reward_potions FROM hero_quests hq JOIN quests q ON hq.quest_id = q.id LEFT JOIN towns tt ON tt.id = q.target_town_id WHERE hq.hero_id = $1 ORDER BY hq.accepted_at DESC `, heroID) if err != nil { return nil, fmt.Errorf("list hero quests: %w", err) } defer rows.Close() var hqs []model.HeroQuest for rows.Next() { var hq model.HeroQuest var q model.Quest if err := rows.Scan( &hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress, &hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt, &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.TargetTownName, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { return nil, fmt.Errorf("scan hero quest: %w", err) } hq.Quest = &q hqs = append(hqs, hq) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list hero quests rows: %w", err) } if hqs == nil { hqs = []model.HeroQuest{} } return hqs, nil } // IncrementQuestProgress increments progress for all matching accepted quests. // For kill_count: objectiveType="kill_count"; a quest matches when both non-null filters hold: // (target_enemy_type IS NULL OR = enemySlug) AND (target_enemy_archetype IS NULL OR = enemyArchetype). func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, objectiveType string, enemySlug, enemyArchetype string, delta int) error { if delta <= 0 { return nil } query := ` UPDATE hero_quests hq SET progress = LEAST(progress + $3, q.target_count), status = CASE WHEN progress + $3 >= q.target_count THEN 'completed' ELSE status END, completed_at = CASE WHEN progress + $3 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END FROM quests q WHERE hq.quest_id = q.id AND hq.hero_id = $1 AND hq.status = 'accepted' AND q.type = $2 AND (q.target_enemy_type IS NULL OR q.target_enemy_type = $4) AND (q.target_enemy_archetype IS NULL OR q.target_enemy_archetype = $5) ` _, err := s.pool.Exec(ctx, query, heroID, objectiveType, delta, enemySlug, enemyArchetype) if err != nil { return fmt.Errorf("increment quest progress: %w", err) } return nil } // ClaimQuestReward marks a completed quest as claimed and returns the rewards. // heroQuestID is hero_quests.id (quest log row), not the quests template id. // Returns an error if the quest is not in 'completed' status. func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, heroQuestID int64) (*model.QuestReward, error) { var reward model.QuestReward err := s.pool.QueryRow(ctx, ` UPDATE hero_quests hq SET status = 'claimed', claimed_at = now() FROM quests q WHERE hq.quest_id = q.id AND hq.hero_id = $1 AND hq.id = $2 AND hq.status = 'completed' RETURNING q.reward_xp, q.reward_gold, q.reward_potions `, heroID, heroQuestID).Scan(&reward.XP, &reward.Gold, &reward.Potions) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, fmt.Errorf("quest not found or not in completed status") } return nil, fmt.Errorf("claim quest reward: %w", err) } return &reward, nil } // DeleteAllHeroQuests removes every quest log row for the hero (accepted/completed/claimed). func (s *QuestStore) DeleteAllHeroQuests(ctx context.Context, heroID int64) error { _, err := s.pool.Exec(ctx, `DELETE FROM hero_quests WHERE hero_id = $1`, heroID) if err != nil { return fmt.Errorf("delete all hero quests: %w", err) } return nil } // AbandonQuest removes a hero's quest log row. heroQuestID is hero_quests.id (same id the client uses for claim). // Only accepted/completed quests can be abandoned (not already claimed). func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, heroQuestID int64) error { tag, err := s.pool.Exec(ctx, ` DELETE FROM hero_quests WHERE hero_id = $1 AND id = $2 AND status != 'claimed' `, heroID, heroQuestID) if err != nil { return fmt.Errorf("abandon quest: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("quest not found or already claimed") } return nil } // IncrementVisitTownProgress updates visit_town quests when a hero enters a town. func (s *QuestStore) IncrementVisitTownProgress(ctx context.Context, heroID int64, townID int64) error { _, err := s.pool.Exec(ctx, ` UPDATE hero_quests hq SET progress = LEAST(progress + 1, q.target_count), status = CASE WHEN progress + 1 >= q.target_count THEN 'completed' ELSE status END, completed_at = CASE WHEN progress + 1 >= q.target_count AND completed_at IS NULL THEN now() ELSE completed_at END FROM quests q WHERE hq.quest_id = q.id AND hq.hero_id = $1 AND hq.status = 'accepted' AND q.type = 'visit_town' AND q.target_town_id = $2 `, heroID, townID) if err != nil { return fmt.Errorf("increment visit town progress: %w", err) } return nil } // IncrementCollectItemProgress increments collect_item quests by rolling the drop_chance. // Called after a kill; each matching quest gets a roll for each delta kill. func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID int64, enemySlug, enemyArchetype string) error { // Fetch active collect_item quests for this hero rows, err := s.pool.Query(ctx, ` SELECT hq.id, q.target_count, hq.progress, q.drop_chance, q.target_enemy_type, q.target_enemy_archetype FROM hero_quests hq JOIN quests q ON hq.quest_id = q.id WHERE hq.hero_id = $1 AND hq.status = 'accepted' AND q.type = 'collect_item' `, heroID) if err != nil { return fmt.Errorf("list collect item quests: %w", err) } defer rows.Close() type collectQuest struct { hqID int64 targetCount int progress int dropChance float64 targetEnemyType *string targetEnemyArchetype *string } var cqs []collectQuest for rows.Next() { var cq collectQuest if err := rows.Scan(&cq.hqID, &cq.targetCount, &cq.progress, &cq.dropChance, &cq.targetEnemyType, &cq.targetEnemyArchetype); err != nil { return fmt.Errorf("scan collect quest: %w", err) } cqs = append(cqs, cq) } if err := rows.Err(); err != nil { return fmt.Errorf("collect item quests rows: %w", err) } for _, cq := range cqs { if cq.targetEnemyType != nil && *cq.targetEnemyType != enemySlug { continue } if cq.targetEnemyArchetype != nil && *cq.targetEnemyArchetype != enemyArchetype { continue } if cq.progress >= cq.targetCount { continue } // Roll the drop chance roll := randFloat64() if roll >= cq.dropChance { continue } // Increment progress by 1 _, err := s.pool.Exec(ctx, ` UPDATE hero_quests SET progress = LEAST(progress + 1, $2), status = CASE WHEN progress + 1 >= $2 THEN 'completed' ELSE status END, completed_at = CASE WHEN progress + 1 >= $2 AND completed_at IS NULL THEN now() ELSE completed_at END WHERE id = $1 `, cq.hqID, cq.targetCount) if err != nil { return fmt.Errorf("update collect item progress: %w", err) } } return nil } // randFloat64 wraps rand.Float64 for testability. var randFloat64 = rand.Float64