|
|
|
|
@ -238,6 +238,47 @@ func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int6
|
|
|
|
|
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). daySeed should be UTC day bucket (e.g. unix/86400) for daily rotation.
|
|
|
|
|
func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcID int64, heroLevel int, limit int, daySeed int64) ([]model.Quest, error) {
|
|
|
|
|
all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
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 := heroID ^ npcID ^ daySeed
|
|
|
|
|
return FilterCapOfferableQuests(all, taken, limit, seed), 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, `
|
|
|
|
|
@ -360,15 +401,14 @@ func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) er
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AcceptQuest creates a hero_quests row for the given hero and quest.
|
|
|
|
|
// Returns an error if the quest is already accepted/active.
|
|
|
|
|
// 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 {
|
|
|
|
|
_, 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)
|
|
|
|
|
inserted, err := s.TryAcceptQuest(ctx, heroID, questID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("accept quest: %w", err)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !inserted {
|
|
|
|
|
return fmt.Errorf("quest already in log")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
@ -392,10 +432,13 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
|
|
|
|
|
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, q.title, q.description, q.type, q.target_count,
|
|
|
|
|
q.target_enemy_type, q.target_town_id, q.drop_chance,
|
|
|
|
|
q.target_enemy_type, 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)
|
|
|
|
|
@ -412,7 +455,7 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model.
|
|
|
|
|
&hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress,
|
|
|
|
|
&hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt,
|
|
|
|
|
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
|
|
|
|
|
&q.TargetEnemyType, &q.TargetTownID, &q.DropChance,
|
|
|
|
|
&q.TargetEnemyType, &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)
|
|
|
|
|
@ -482,8 +525,9 @@ func (s *QuestStore) IncrementQuestProgress(ctx context.Context, heroID int64, o
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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, questID int64) (*model.QuestReward, error) {
|
|
|
|
|
func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, heroQuestID int64) (*model.QuestReward, error) {
|
|
|
|
|
var reward model.QuestReward
|
|
|
|
|
|
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
|
|
|
@ -495,7 +539,7 @@ func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, questID
|
|
|
|
|
AND hq.id = $2
|
|
|
|
|
AND hq.status = 'completed'
|
|
|
|
|
RETURNING q.reward_xp, q.reward_gold, q.reward_potions
|
|
|
|
|
`, heroID, questID).Scan(&reward.XP, &reward.Gold, &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")
|
|
|
|
|
@ -506,13 +550,13 @@ func (s *QuestStore) ClaimQuestReward(ctx context.Context, heroID int64, questID
|
|
|
|
|
return &reward, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AbandonQuest removes a hero's quest entry. Only accepted/completed quests
|
|
|
|
|
// can be abandoned (not already claimed).
|
|
|
|
|
func (s *QuestStore) AbandonQuest(ctx context.Context, heroID int64, questID int64) error {
|
|
|
|
|
// 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 quest_id = $2 AND status != 'claimed'
|
|
|
|
|
`, heroID, questID)
|
|
|
|
|
WHERE hero_id = $1 AND id = $2 AND status != 'claimed'
|
|
|
|
|
`, heroID, heroQuestID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("abandon quest: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|