diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index b87b51b..2de11b0 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -169,9 +169,14 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { switch npc.Type { case "quest_giver": - daySeed := time.Now().UTC().Unix() / 86400 + refreshHours := tuning.EffectiveQuestOfferRefreshHours() + if refreshHours <= 0 { + refreshHours = 2 + } + refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) + timeBucket := time.Now().UTC().Unix() / refreshSeconds limit := tuning.EffectiveQuestOffersPerNPC() - quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, daySeed) + quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, timeBucket) if err != nil { h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index d98841e..6d0ef8b 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -121,7 +121,7 @@ func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Reques // ListQuestsByNPC returns quests offered by an NPC. // GET /api/v1/npcs/{npcId}/quests -// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, and rotated daily — same rules as npc-interact. +// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, and rotated on a configured cadence — same rules as npc-interact. // Without telegramId, returns all templates for that NPC (catalog / tools). func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { npcIDStr := chi.URLParam(r, "npcId") @@ -155,9 +155,14 @@ func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { }) return } - daySeed := time.Now().UTC().Unix() / 86400 + refreshHours := tuning.EffectiveQuestOfferRefreshHours() + if refreshHours <= 0 { + refreshHours = 2 + } + refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) + timeBucket := time.Now().UTC().Unix() / refreshSeconds limit := tuning.EffectiveQuestOffersPerNPC() - quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, daySeed) + quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, timeBucket) if err != nil { h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index ba1de6c..7fc06cb 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -261,8 +261,8 @@ func (s *QuestStore) HeroTakenQuestTemplateIDs(ctx context.Context, heroID int64 } // 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) { +// 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) { all, err := s.ListQuestsByNPCForHeroLevel(ctx, npcID, heroLevel) if err != nil { return nil, err @@ -275,7 +275,7 @@ func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcI for _, id := range takenIDs { taken[id] = struct{}{} } - seed := heroID ^ npcID ^ daySeed + seed := npcID ^ timeBucket return FilterCapOfferableQuests(all, taken, limit, seed), nil } diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 2c5fb06..d37b61c 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -83,6 +83,8 @@ type Values struct { NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"` // QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests). QuestOffersPerNPC int `json:"questOffersPerNPC"` + // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). + QuestOfferRefreshHours int `json:"questOfferRefreshHours"` CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageRollMin float64 `json:"combatDamageRollMin"` @@ -276,6 +278,7 @@ func DefaultValues() Values { NPCCostPotion: 50, NPCCostNearbyRadius: 3.0, QuestOffersPerNPC: 2, + QuestOfferRefreshHours: 2, CombatDamageScale: 0.35, CombatDamageRollMin: 0.60, CombatDamageRollMax: 1.10, @@ -412,6 +415,15 @@ func EffectiveQuestOffersPerNPC() int { return n } +// EffectiveQuestOfferRefreshHours returns the rotation cadence (hours) for quest_giver offers. +func EffectiveQuestOfferRefreshHours() int { + n := Get().QuestOfferRefreshHours + if n <= 0 { + return DefaultValues().QuestOfferRefreshHours + } + return n +} + func Set(v Values) { current.Store(&v) }