diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 16e6bac..78d352a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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() diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 643476f..5271fd9 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -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) } } diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 3ec70a0..e07d7f8 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -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 { diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index 841adbb..b87b51b 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -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{ diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index f0598f5..d98841e 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -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 { diff --git a/backend/internal/model/quest.go b/backend/internal/model/quest.go index 0ed4d51..4cb93ef 100644 --- a/backend/internal/model/quest.go +++ b/backend/internal/model/quest.go @@ -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"` diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 88e0e5c..ba1de6c 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -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) } diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 7e32b01..1535425 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -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) } diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index d3ba895..64a7e88 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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); diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index f89ce33..c96de29 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -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) ---- diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index c640e8c..b8d2282 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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!', diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index 5e237e5..8a847d3 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -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!', diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index d500295..e4fc897 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -476,7 +476,8 @@ export async function getTownBuildings(townId: number): Promise /** Fetch available quests from an NPC */ export async function getNPCQuests(npcId: number, telegramId?: number): Promise { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; - return apiGet(`/npcs/${npcId}/quests${query}`); + const data = await apiGet(`/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, diff --git a/frontend/src/ui/NPCDialog.tsx b/frontend/src/ui/NPCDialog.tsx index aedc523..34309b9 100644 --- a/frontend/src/ui/NPCDialog.tsx +++ b/frontend/src/ui/NPCDialog.tsx @@ -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({
Loading quests...
- ) : availableQuests.length > 0 ? ( + ) : (availableQuests?.length ?? 0) > 0 ? ( <>
Available Quests
- {availableQuests.map((q) => ( + {(availableQuests ?? []).map((q) => (
{questTypeIcon(q.type)} diff --git a/frontend/src/ui/QuestLog.tsx b/frontend/src/ui/QuestLog.tsx index e03cf60..10dd712 100644 --- a/frontend/src/ui/QuestLog.tsx +++ b/frontend/src/ui/QuestLog.tsx @@ -266,6 +266,11 @@ export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps) {isExpanded && ( <>
{q.description}
+ {q.type === 'visit_town' && q.targetTownName ? ( +
+ {tr.questDestination}: {q.targetTownName} +
+ ) : null}
{q.npcName} · {q.townName}