quests reworked

master
Denis Ranneft 1 month ago
parent 41e246b2f1
commit 1487031748

@ -104,6 +104,7 @@ func main() {
engine.SetSender(hub) // Hub implements game.MessageSender
engine.SetRoadGraph(roadGraph)
engine.SetHeroStore(heroStore)
engine.SetQuestStore(questStore)
engine.SetAdventureLog(func(heroID int64, msg string) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

@ -61,6 +61,7 @@ type Engine struct {
roadGraph *RoadGraph
sender MessageSender
heroStore *storage.HeroStore
questStore *storage.QuestStore
incomingCh chan IncomingMessage // client commands
mu sync.RWMutex
eventCh chan model.CombatEvent
@ -223,6 +224,13 @@ func (e *Engine) SetHeroStore(hs *storage.HeroStore) {
e.heroStore = hs
}
// SetQuestStore sets the quest store used for visit_town progress on town arrival.
func (e *Engine) SetQuestStore(qs *storage.QuestStore) {
e.mu.Lock()
defer e.mu.Unlock()
e.questStore = qs
}
// SetOnEnemyDeath registers a callback for enemy death events (e.g. loot generation).
func (e *Engine) SetOnEnemyDeath(cb EnemyDeathCallback) {
e.mu.Lock()
@ -690,6 +698,7 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
})
}
}
e.applyVisitTownQuestProgress(h)
return h, true
}
@ -1378,6 +1387,20 @@ func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) {
defer cancel()
if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
e.logger.Error("persist hero after town enter", "hero_id", h.ID, "error", err)
return
}
e.applyVisitTownQuestProgress(h)
}
// applyVisitTownQuestProgress advances visit_town quests when the hero is in a town (matches quests.target_town_id).
func (e *Engine) applyVisitTownQuestProgress(h *model.Hero) {
if e.questStore == nil || h == nil || h.CurrentTownID == nil || *h.CurrentTownID <= 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := e.questStore.IncrementVisitTownProgress(ctx, h.ID, *h.CurrentTownID); err != nil && e.logger != nil {
e.logger.Warn("visit town quest progress failed", "hero_id", h.ID, "town_id", *h.CurrentTownID, "error", err)
}
}

@ -530,7 +530,7 @@ func (h *AdminHandler) ClaimHeroQuest(w http.ResponseWriter, r *http.Request) {
}
// AbandonHeroQuest removes quest from hero log.
// DELETE /admin/heroes/{heroId}/quests/{questId}
// DELETE /admin/heroes/{heroId}/quests/{questId} — questId is hero_quests.id (log row).
func (h *AdminHandler) AbandonHeroQuest(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
if err != nil {

@ -169,7 +169,9 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
switch npc.Type {
case "quest_giver":
quests, err := h.questStore.ListQuestsByNPCForHeroLevel(r.Context(), npc.ID, hero.Level)
daySeed := time.Now().UTC().Unix() / 86400
limit := tuning.EffectiveQuestOffersPerNPC()
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, daySeed)
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{

@ -4,11 +4,13 @@ import (
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// QuestHandler serves quest system API endpoints.
@ -117,8 +119,10 @@ func (h *QuestHandler) ListBuildingsByTown(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, views)
}
// ListQuestsByNPC returns all quests offered by an NPC.
// 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.
// 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")
npcID, err := strconv.ParseInt(npcIDStr, 10, 64)
@ -129,6 +133,42 @@ func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) {
return
}
if tgStr := r.URL.Query().Get("telegramId"); tgStr != "" {
tgID, err := strconv.ParseInt(tgStr, 10, 64)
if err != nil || tgID == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid telegramId",
})
return
}
hero, err := h.heroStore.GetByTelegramID(r.Context(), tgID)
if err != nil {
h.logger.Error("failed to get hero for npc quests", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load hero",
})
return
}
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "hero not found",
})
return
}
daySeed := time.Now().UTC().Unix() / 86400
limit := tuning.EffectiveQuestOffersPerNPC()
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, daySeed)
if err != nil {
h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to list quests",
})
return
}
writeJSON(w, http.StatusOK, quests)
return
}
quests, err := h.questStore.ListQuestsByNPC(r.Context(), npcID)
if err != nil {
h.logger.Error("failed to list quests", "npc_id", npcID, "error", err)
@ -176,13 +216,20 @@ func (h *QuestHandler) AcceptQuest(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.questStore.AcceptQuest(r.Context(), hero.ID, req.QuestID); err != nil {
inserted, err := h.questStore.TryAcceptQuest(r.Context(), hero.ID, req.QuestID)
if err != nil {
h.logger.Error("failed to accept quest", "hero_id", hero.ID, "quest_id", req.QuestID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to accept quest",
})
return
}
if !inserted {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "quest already in log",
})
return
}
h.logger.Info("quest accepted", "hero_id", hero.ID, "quest_id", req.QuestID)
writeJSON(w, http.StatusOK, map[string]string{
@ -228,7 +275,7 @@ func (h *QuestHandler) ListHeroQuests(w http.ResponseWriter, r *http.Request) {
}
// ClaimQuestReward claims a completed quest's reward.
// POST /api/v1/hero/quests/{questId}/claim
// POST /api/v1/hero/quests/{questId}/claim — questId is hero_quests.id (log row).
func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
@ -296,7 +343,7 @@ func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request)
}
// AbandonQuest removes a quest from the hero's quest log.
// DELETE /api/v1/hero/quests/{questId}
// DELETE /api/v1/hero/quests/{questId} — questId is hero_quests.id (log row), not the template id.
func (h *QuestHandler) AbandonQuest(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {

@ -35,6 +35,7 @@ type Quest struct {
TargetCount int `json:"targetCount"`
TargetEnemyType *string `json:"targetEnemyType"` // NULL = any enemy
TargetTownID *int64 `json:"targetTownId"` // for visit_town quests
TargetTownName string `json:"targetTownName,omitempty"` // set when joined from towns (e.g. hero quest list)
DropChance float64 `json:"dropChance"` // for collect_item
MinLevel int `json:"minLevel"`
MaxLevel int `json:"maxLevel"`

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

@ -81,6 +81,8 @@ type Values struct {
NPCCostHeal int64 `json:"npcCostHeal"`
NPCCostPotion int64 `json:"npcCostPotion"`
NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"`
// QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests).
QuestOffersPerNPC int `json:"questOffersPerNPC"`
CombatDamageScale float64 `json:"combatDamageScale"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"`
@ -267,6 +269,7 @@ func DefaultValues() Values {
NPCCostHeal: 100,
NPCCostPotion: 50,
NPCCostNearbyRadius: 3.0,
QuestOffersPerNPC: 2,
CombatDamageScale: 0.35,
EnemyDodgeChance: 0.20,
EnemyCriticalMinChance: 0.15,
@ -388,6 +391,15 @@ func EffectiveNPCShopCosts() (potionCost, healCost int64) {
return potionCost, healCost
}
// EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning.
func EffectiveQuestOffersPerNPC() int {
n := Get().QuestOffersPerNPC
if n <= 0 {
return DefaultValues().QuestOffersPerNPC
}
return n
}
func Set(v Values) {
current.Store(&v)
}

@ -784,10 +784,10 @@ export class GameEngine {
const rk = state.hero.restKind?.toLowerCase() ?? '';
const excPhase = state.hero.excursionPhase?.toLowerCase() ?? '';
const offRoad = excPhase === 'wild' || excPhase === 'return';
// Camp only during the stationary wild phase; hide as soon as rest ends and return leg starts.
const showRestCamp =
state.phase === GamePhase.Resting &&
offRoad &&
excPhase === 'wild' &&
(rk === 'roadside' || rk === 'adventure_inline');
if (showRestCamp) {
this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now);

@ -260,6 +260,7 @@ export interface Quest {
targetCount: number;
targetEnemyType?: string;
targetTownId?: number;
targetTownName?: string;
dropChance: number;
minLevel: number;
maxLevel: number;
@ -282,6 +283,8 @@ export interface HeroQuest {
rewardPotions: number;
npcName: string;
townName: string;
/** Resolved name for visit_town delivery target */
targetTownName?: string;
}
// ---- Equipment Item (extended slots per §6.3) ----

@ -92,6 +92,7 @@ export const en = {
questLog: 'Quest Log',
noActiveQuests: 'No active quests. Visit an NPC to accept quests!',
claimRewards: 'Claim Rewards',
questDestination: 'Destination',
abandon: 'Abandon',
acceptQuest: 'Accept',
questAccepted: 'Quest accepted!',

@ -94,6 +94,7 @@ export const ru: Translations = {
questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439',
noActiveQuests: '\u041d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u0434\u0430\u043d\u0438\u0439. \u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u0441 NPC!',
claimRewards: '\u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
questDestination: '\u041f\u0443\u043d\u043a\u0442 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f',
abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f',
acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
questAccepted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043d\u044f\u0442\u043e!',

@ -476,7 +476,8 @@ export async function getTownBuildings(townId: number): Promise<BuildingData[]>
/** Fetch available quests from an NPC */
export async function getNPCQuests(npcId: number, telegramId?: number): Promise<Quest[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<Quest[]>(`/npcs/${npcId}/quests${query}`);
const data = await apiGet<Quest[] | null>(`/npcs/${npcId}/quests${query}`);
return Array.isArray(data) ? data : [];
}
// ---- Hero Quests ----
@ -497,6 +498,7 @@ interface HeroQuestRaw {
targetCount: number;
targetEnemyType?: string;
targetTownId?: number;
targetTownName?: string;
dropChance: number;
minLevel: number;
maxLevel: number;
@ -525,6 +527,7 @@ function flattenHeroQuest(raw: HeroQuestRaw): HeroQuest {
description: raw.description ?? q?.description ?? '',
type: raw.type ?? q?.type ?? '',
targetCount: raw.targetCount ?? q?.targetCount ?? 0,
targetTownName: raw.quest?.targetTownName ?? q?.targetTownName,
progress: raw.progress,
status: (raw.status as HeroQuest['status']) ?? 'accepted',
rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0,

@ -254,7 +254,7 @@ export function NPCDialog({
if (npc.type !== 'quest_giver') return;
setLoading(true);
getNPCQuests(npc.id, telegramId)
.then((qs) => setAvailableQuests(qs))
.then((qs) => setAvailableQuests(Array.isArray(qs) ? qs : []))
.catch((err) => {
console.warn('[NPCDialog] Failed to fetch quests:', err);
setAvailableQuests([]);
@ -279,7 +279,7 @@ export function NPCDialog({
onToast(tr.questAccepted, '#44aaff');
onQuestsChanged();
// Remove from available list
setAvailableQuests((prev) => prev.filter((q) => q.id !== questId));
setAvailableQuests((prev) => (Array.isArray(prev) ? prev : []).filter((q) => q.id !== questId));
})
.catch((err) => {
console.warn('[NPCDialog] Failed to accept quest:', err);
@ -468,10 +468,10 @@ export function NPCDialog({
<div style={{ color: '#666', fontSize: 12, textAlign: 'center', padding: 16 }}>
Loading quests...
</div>
) : availableQuests.length > 0 ? (
) : (availableQuests?.length ?? 0) > 0 ? (
<>
<div style={sectionTitleStyle}>Available Quests</div>
{availableQuests.map((q) => (
{(availableQuests ?? []).map((q) => (
<div key={q.id} style={questCardStyle}>
<div style={questTitleRow}>
<span style={{ fontSize: 14 }}>{questTypeIcon(q.type)}</span>

@ -266,6 +266,11 @@ export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps)
{isExpanded && (
<>
<div style={descriptionStyle}>{q.description}</div>
{q.type === 'visit_town' && q.targetTownName ? (
<div style={{ ...descriptionStyle, color: '#9bdcff', fontSize: 11 }}>
{tr.questDestination}: {q.targetTownName}
</div>
) : null}
<div style={{ ...descriptionStyle, color: '#777', fontSize: 10 }}>
{q.npcName} &middot; {q.townName}
</div>

Loading…
Cancel
Save