From a4159518765b7bfa2c20ca2b90a88a8a28f54a2f Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 15:04:26 +0300 Subject: [PATCH] new quests added --- backend/docs/quest-system-design.md | 37 +++++++++- backend/internal/handler/quest.go | 2 +- backend/internal/storage/quest_offers.go | 17 +++++ backend/internal/storage/quest_offers_test.go | 28 +++++++ backend/internal/storage/quest_store.go | 10 ++- backend/internal/tuning/runtime.go | 13 ++++ backend/migrations/000027_more_quests.sql | 73 +++++++++++++++++++ 7 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/000027_more_quests.sql diff --git a/backend/docs/quest-system-design.md b/backend/docs/quest-system-design.md index 6c41d8e..34e28c5 100644 --- a/backend/docs/quest-system-design.md +++ b/backend/docs/quest-system-design.md @@ -91,6 +91,41 @@ A hero can have at most **3 active (accepted) quests** at a time. This keeps the Each quest has `min_level` / `max_level`. NPCs only show quests appropriate for the hero's current level. +### Quest offer pool (content invariant) + +For every NPC with `type = 'quest_giver'`, seed data must include **at least one** quest template (`quests.npc_id`) whose level band **overlaps** the home town band: + +`quest.max_level >= town.level_min AND quest.min_level <= town.level_max` + +That guarantees a hero whose level lies in the town range *could* see an offer (unless every overlapping template is already on the hero log). + +**Verification (run on PostgreSQL):** + +```sql +-- Expect zero rows. Lists quest_givers with no template overlapping their town level band. +SELECT n.id AS npc_id, n.name, t.name AS town_name, t.level_min, t.level_max +FROM npcs n +JOIN towns t ON t.id = n.town_id +WHERE n.type = 'quest_giver' + AND NOT EXISTS ( + SELECT 1 FROM quests q + WHERE q.npc_id = n.id + AND q.max_level >= t.level_min + AND q.min_level <= t.level_max + ) +ORDER BY n.id; +``` + +### Quest offers at runtime (`npc-interact`, `GET /npcs/:id/quests?telegramId=`) + +1. Load templates for the NPC where `hero.level` is between `quest.min_level` and `quest.max_level`. +2. Remove templates already present in `hero_quests` (any status). +3. Shuffle deterministically from seed `npc_id ^ time_bucket` (`time_bucket = floor(utc_now / questOfferRefreshHours)`). +4. Return at most `questOffersPerNPC` templates (runtime tuning; default 2). If fewer templates remain, return all of them — **never cap to zero** when the filtered list is non-empty. +5. **Dry spell:** With probability `questOfferDrySpellChance` (runtime tuning, default **0.20**), return an **empty** list even when step 4 would have returned one or more quests. The roll is **deterministic** per `(npc_id, hero_id, time_bucket)` so offers do not flicker between requests within the same refresh window. + +Set `questOfferDrySpellChance` to `0` in runtime config to disable dry spells. + --- ## 4. Reward Structure @@ -223,7 +258,7 @@ All under `/api/v1/`. Auth via `X-Telegram-Init-Data` header (existing pattern). | Method | Path | Description | |--------|------|-------------| -| `GET` | `/npcs/:id/quests` | List available quests from an NPC (filtered by hero level, excluding already accepted/claimed) | +| `GET` | `/npcs/:id/quests` | List available quests from an NPC (with `?telegramId=` — same filters as `npc-interact`: level, not in log, rotation cap, optional dry spell) | | `POST` | `/quests/:id/accept` | Accept a quest (hero must be in the NPC's town, max 3 active) | | `POST` | `/quests/:id/claim` | Claim rewards for a completed quest | | `GET` | `/hero/quests` | List hero's active/completed quests with progress | diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index deeb4a8..3c03275 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 on a configured cadence — same rules as npc-interact. +// With ?telegramId= the list is filtered (no already-logged templates), level-scoped, capped, rotated on a configured cadence, and may be empty during a deterministic “dry spell” (see quest-system-design.md) — 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") diff --git a/backend/internal/storage/quest_offers.go b/backend/internal/storage/quest_offers.go index 667c92c..793857d 100644 --- a/backend/internal/storage/quest_offers.go +++ b/backend/internal/storage/quest_offers.go @@ -31,3 +31,20 @@ func FilterCapOfferableQuests(all []model.Quest, taken map[int64]struct{}, limit } return shuffled[:limit] } + +// questOfferDrySpellSalt mixes into the RNG seed so dry-spell draws are independent of quest shuffle draws. +const questOfferDrySpellSalt int64 = 0x8BADF00D + +// QuestOfferDrySpellThisPeriod reports whether this hero/NPC/time bucket is a "dry spell" (no offers shown). +// Deterministic: same inputs always yield the same result. dryChance in [0,1]; 0 disables, 1 always dry. +func QuestOfferDrySpellThisPeriod(npcID, heroID, timeBucket int64, dryChance float64) bool { + if dryChance <= 0 { + return false + } + if dryChance >= 1 { + return true + } + seed := npcID ^ heroID ^ timeBucket ^ questOfferDrySpellSalt + rng := rand.New(rand.NewPCG(uint64(seed), uint64(seed>>32)^0x9e3779b97f4a7c15)) + return rng.Float64() < dryChance +} diff --git a/backend/internal/storage/quest_offers_test.go b/backend/internal/storage/quest_offers_test.go index b9eefdb..f61154c 100644 --- a/backend/internal/storage/quest_offers_test.go +++ b/backend/internal/storage/quest_offers_test.go @@ -48,3 +48,31 @@ func TestFilterCapOfferableQuests_limitZeroReturnsAll(t *testing.T) { t.Fatalf("len=%d want 2", len(out)) } } + +func TestQuestOfferDrySpellThisPeriod_edges(t *testing.T) { + if QuestOfferDrySpellThisPeriod(1, 2, 3, 0) { + t.Fatal("dryChance 0 should never dry") + } + if !QuestOfferDrySpellThisPeriod(9, 9, 9, 1) { + t.Fatal("dryChance 1 should always dry") + } + a := QuestOfferDrySpellThisPeriod(5, 7, 11, 0.5) + b := QuestOfferDrySpellThisPeriod(5, 7, 11, 0.5) + if a != b { + t.Fatalf("same inputs must match: %v vs %v", a, b) + } +} + +func TestQuestOfferDrySpellThisPeriod_distribution(t *testing.T) { + var dry int + const n = 8000 + for b := int64(0); b < n; b++ { + if QuestOfferDrySpellThisPeriod(101, 202, b, 0.2) { + dry++ + } + } + // Binomial(n=8000,p=0.2): ~99.9% within [0.17,0.23] + if dry < int(0.16*float64(n)) || dry > int(0.24*float64(n)) { + t.Fatalf("dry count %d out of %d, expected ~20%%", dry, n) + } +} diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 21eb73c..c55cde3 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -11,6 +11,7 @@ import ( "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. @@ -276,7 +277,14 @@ func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcI taken[id] = struct{}{} } seed := npcID ^ timeBucket - return FilterCapOfferableQuests(all, taken, limit, seed), nil + 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. diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 3b94cdb..8c1bdc9 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -87,6 +87,9 @@ type Values struct { QuestOffersPerNPC int `json:"questOffersPerNPC"` // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). QuestOfferRefreshHours int `json:"questOfferRefreshHours"` + // QuestOfferDrySpellChance is the probability (0–1) that a quest_giver returns no offers + // for a given hero/NPC/time bucket even when offerable templates exist. Deterministic per bucket. + QuestOfferDrySpellChance float64 `json:"questOfferDrySpellChance"` CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageRollMin float64 `json:"combatDamageRollMin"` @@ -302,6 +305,7 @@ func DefaultValues() Values { NPCCostNearbyRadius: 3.0, QuestOffersPerNPC: 2, QuestOfferRefreshHours: 2, + QuestOfferDrySpellChance: 0.20, // combatDamageScale tracks combatPaceMultiplier: DPS ~ scale/pace, so halving pace halves scale to keep fight length. CombatDamageScale: 0.216, CombatDamageRollMin: 0.60, @@ -460,6 +464,15 @@ func EffectiveQuestOfferRefreshHours() int { return n } +// EffectiveQuestOfferDrySpellChance returns P(no offers) when templates exist (0–1). Invalid values fall back to default. +func EffectiveQuestOfferDrySpellChance() float64 { + c := Get().QuestOfferDrySpellChance + if c < 0 || c > 1 { + return DefaultValues().QuestOfferDrySpellChance + } + return c +} + func effectiveRegenPerSecond(cfg float64, fallback float64) float64 { if cfg <= 0 { return fallback diff --git a/backend/migrations/000027_more_quests.sql b/backend/migrations/000027_more_quests.sql new file mode 100644 index 0000000..31da5f6 --- /dev/null +++ b/backend/migrations/000027_more_quests.sql @@ -0,0 +1,73 @@ +-- Align legacy quest filters with enemies.archetype (was wrongly stored as display names). +UPDATE public.quests +SET target_enemy_archetype = 'demon' +WHERE id IN (15, 16) AND target_enemy_archetype = 'fire_demon'; + +UPDATE public.quests +SET target_enemy_archetype = 'titan' +WHERE id = 17 AND target_enemy_archetype = 'lightning_titan'; + +-- Additional quests: quest_giver npc_id matches towns 1–11 (see npcs seed). +INSERT INTO public.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 +-- Mossharbor (npc 20), levels 5–8 +(20, 'quest.mossharbor_skeleton_cull.v1', 'Harborbone Sweep', 'Clear risen bones stalking the docks and tide paths.', 'kill_count', 7, NULL, 'skeleton', NULL, 0, 5, 8, 55, 32, 0), +(20, 'quest.mossharbor_letter_willowdale.v1', 'Letter for Willowdale', 'Carry this sealed letter to Elder Maren in Willowdale.', 'visit_town', 1, NULL, NULL, 1, 0, 5, 8, 38, 20, 0), +(20, 'quest.mossharbor_road_tolls.v1', 'Road Tolls', 'Bandits are shaking down travelers on the meadow road. Stop them.', 'kill_count', 6, NULL, 'bandit', NULL, 0, 5, 8, 62, 38, 1), +(20, 'quest.mossharbor_contraband.v1', 'Contraband Pouches', 'Recover marked pouches from fallen bandits for the harbor tally.', 'collect_item', 4, NULL, 'bandit', NULL, 0.28, 5, 8, 72, 42, 1), + +-- Emberwell (npc 22), levels 13–16 +(22, 'quest.emberwell_cultist_dispersal.v1', 'Cultist Dispersal', 'A splinter sect is burning waymarkers. Cut their numbers.', 'kill_count', 10, NULL, 'cultist', NULL, 0, 13, 16, 180, 95, 1), +(22, 'quest.emberwell_elemental_disturbance.v1', 'Elemental Disturbance', 'Rogue elementals are destabilizing the treeline. Banish them.', 'kill_count', 4, NULL, 'element', NULL, 0, 13, 16, 220, 115, 2), +(22, 'quest.emberwell_scout_ashengard.v1', 'Scout Ashengard', 'Deliver Ranger Kess''s field notes to Scholar Orin in Ashengard.', 'visit_town', 1, NULL, NULL, 3, 0, 13, 16, 110, 55, 0), +(22, 'quest.emberwell_spell_ash.v1', 'Spell Ash Samples', 'Collect ash-touched reagents from defeated cultists.', 'collect_item', 5, NULL, 'cultist', NULL, 0.27, 13, 16, 200, 105, 1), +(22, 'quest.emberwell_perimeter.v1', 'Ember Perimeter', 'Thin any hostile creatures pressing the Emberwell outskirts.', 'kill_count', 14, NULL, NULL, NULL, 0, 13, 16, 210, 100, 1), + +-- Frostmark (npc 24), levels 21–24 +(24, 'quest.frostmark_golem_breakers.v1', 'Golem Breakers', 'Animated rubble blocks old trade cuts. Smash the golems.', 'kill_count', 6, NULL, 'golem', NULL, 0, 21, 24, 270, 155, 2), +(24, 'quest.frostmark_wraith_tide.v1', 'Wraith Tide', 'Cold wraiths cling to the ruins road. Send them on.', 'kill_count', 10, NULL, 'wraith', NULL, 0, 21, 24, 290, 165, 1), +(24, 'quest.frostmark_shade_reagents.v1', 'Shade Reagents', 'Alchemists need slow-essence cores from shades.', 'collect_item', 5, NULL, 'shade', NULL, 0.22, 21, 24, 310, 175, 2), +(24, 'quest.frostmark_word_boghollow.v1', 'Word to Boghollow', 'Carry Torvik''s warning about the mist to Witch Nessa.', 'visit_town', 1, NULL, NULL, 5, 0, 21, 24, 130, 70, 0), +(24, 'quest.frostmark_kingless_dead.v1', 'Kingless Dead', 'Bone sovereigns rally the lesser dead. Strike the heads first.', 'kill_count', 3, NULL, 'skeleton_king', NULL, 0, 21, 24, 320, 190, 2), + +-- Duskwatch (npc 26), levels 28–30 +(26, 'quest.duskwatch_fen_titans.v1', 'Titans in the Fen', 'Titans wade the deep bog where lesser things fear to go.', 'kill_count', 4, NULL, 'titan', NULL, 0, 28, 30, 380, 220, 2), +(26, 'quest.duskwatch_heartwood.v1', 'Heartwood Banishing', 'Treant roots poison the stilts. Cut them back.', 'kill_count', 3, NULL, 'treant', NULL, 0, 28, 30, 400, 235, 2), +(26, 'quest.duskwatch_warden_due.v1', 'Warden''s Due', 'Forest wardens claim the marsh as their grove. Prove otherwise.', 'kill_count', 3, NULL, 'forest_warden', NULL, 0, 28, 30, 420, 245, 2), +(26, 'quest.duskwatch_starfall_ride.v1', 'Ride to Starfall', 'Sister Morah needs omen-salts from Seer Aelith at Starfall.', 'visit_town', 1, NULL, NULL, 7, 0, 28, 30, 200, 105, 1), + +-- Willowdale (npc 1), extras levels 1–4 +(1, 'quest.willowdale_mossharbor_run.v1', 'Mossharbor Run', 'Finn wants you to confirm the harbor is still trading.', 'visit_town', 1, NULL, NULL, 8, 0, 2, 4, 28, 14, 0), +(1, 'quest.willowdale_wolf_pelts.v1', 'Wolf Pelts for Market', 'Bring back presentable pelts from wolves along the road.', 'collect_item', 3, NULL, 'wolf', NULL, 0.35, 1, 4, 35, 18, 0), + +-- Thornwatch (npc 4), levels 9–12 +(4, 'quest.thornwatch_scaleback_cull.v1', 'Scaleback Cull', 'Battle lizards sun themselves on the bluffs. Reduce their packs.', 'kill_count', 8, NULL, 'battle_lizard', NULL, 0, 9, 12, 95, 48, 1), +(4, 'quest.thornwatch_spider_glands.v1', 'Spider Glands', 'The infirmary needs fresh glands for trail antidotes.', 'collect_item', 4, NULL, 'spider', NULL, 0.3, 9, 12, 105, 55, 1), +(4, 'quest.thornwatch_emberwell_resupply.v1', 'Emberwell Resupply', 'Bring this crate of resin vials to Ranger Kess in Emberwell.', 'visit_town', 1, NULL, NULL, 9, 0, 9, 12, 85, 42, 0), + +-- Ashengard (npc 6), levels 17–20 +(6, 'quest.ashengard_rattling_ranks.v1', 'Rattling Ranks', 'Skeleton patrols pace the breach. Break their line.', 'kill_count', 14, NULL, 'skeleton', NULL, 0, 17, 20, 210, 115, 1), +(6, 'quest.ashengard_royal_marrow.v1', 'Royal Marrow', 'Fetch marrow shards from bone sovereigns for Orin''s rite.', 'collect_item', 2, NULL, 'skeleton_king', NULL, 0.2, 17, 20, 260, 150, 2), +(6, 'quest.ashengard_redcliff_errand.v1', 'Redcliff Errand', 'Carry the sealed rubbings to Foreman Brak in Redcliff.', 'visit_town', 1, NULL, NULL, 4, 0, 17, 20, 140, 72, 0), + +-- Redcliff (npc 9), levels 25–27 +(9, 'quest.redcliff_wyvern_coast.v1', 'Wyvern on the Coast', 'Wyverns circle the lift crags. Drive them off.', 'kill_count', 7, NULL, 'wyvern', NULL, 0, 25, 27, 300, 175, 2), +(9, 'quest.redcliff_orc_caches.v1', 'Orc Ritual Caches', 'Search slain orc raiders for carved tokens the miners saw.', 'collect_item', 6, NULL, 'orc', NULL, 0.28, 25, 27, 280, 160, 1), +(9, 'quest.redcliff_summit_courier.v1', 'Summit Courier', 'Brak needs these manifests delivered to Warden Torvik in Frostmark.', 'visit_town', 1, NULL, NULL, 10, 0, 25, 27, 160, 85, 1), + +-- Boghollow (npc 11), levels 31–33 +(11, 'quest.boghollow_marsh_harpies.v1', 'Marsh Harpies', 'Harpies pick off ferry ropes. Ground them.', 'kill_count', 8, NULL, 'harpy', NULL, 0, 31, 33, 360, 205, 2), +(11, 'quest.boghollow_manticore_quota.v1', 'Manticore Quota', 'The village needs proof you can handle the deep marsh alphas.', 'kill_count', 5, NULL, 'manticore', NULL, 0, 31, 33, 390, 225, 2), +(11, 'quest.boghollow_duskwatch_warning.v1', 'Duskwatch Warning', 'Tell Sister Morah the eastern pools are boiling.', 'visit_town', 1, NULL, NULL, 11, 0, 31, 33, 180, 95, 1), + +-- Cinderkeep (npc 14), levels 34–37 +(14, 'quest.cinderkeep_sovereign_ash.v1', 'Sovereign Ash', 'Elite demons leave ash that still whispers. Collect it.', 'collect_item', 4, NULL, 'demon', NULL, 0.28, 34, 37, 520, 310, 3), +(14, 'quest.cinderkeep_infernal_line.v1', 'Infernal Line', 'Hold the lava veins: cull demons before they crest the berm.', 'kill_count', 6, NULL, 'demon', NULL, 0, 34, 37, 480, 285, 2), +(14, 'quest.cinderkeep_starfall_prelude.v1', 'Starfall Prelude', 'Kael wants a reading of the astral veil from Seer Aelith.', 'visit_town', 1, NULL, NULL, 7, 0, 34, 37, 220, 115, 1), + +-- Starfall (npc 16), levels 38–40 +(16, 'quest.starfall_shade_erasure.v1', 'Shade Erasure', 'Shades pool where the road thins into void. Erase them.', 'kill_count', 8, NULL, 'shade', NULL, 0, 38, 40, 620, 360, 3), +(16, 'quest.starfall_manticore_spines.v1', 'Manticore Crown Spines', 'Gather intact crown spines for Aelith''s focus circles.', 'collect_item', 4, NULL, 'manticore', NULL, 0.22, 38, 40, 680, 400, 3), +(16, 'quest.starfall_cinder_echo.v1', 'Cinder Echo', 'Return a stabilized echo-crystal to Forge-master Kael.', 'visit_town', 1, NULL, NULL, 6, 0, 38, 40, 240, 130, 2); + +SELECT pg_catalog.setval('public.quests_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.quests), true);