new quests added

master
Denis Ranneft 1 month ago
parent 03208b17ba
commit a415951876

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

@ -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")

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

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

@ -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.

@ -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 (01) 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 (01). 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

@ -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 111 (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 58
(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 1316
(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 2124
(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 2830
(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 14
(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 912
(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 1720
(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 2527
(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 3133
(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 3437
(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 3840
(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);
Loading…
Cancel
Save