diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index a70002e..2b04e67 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -1007,12 +1007,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { if hero == nil { townsWithNPCs := h.buildTownsWithNPCs(r.Context()) + pCost, hCost := tuning.EffectiveNPCShopCosts() writeJSON(w, http.StatusOK, map[string]any{ "hero": nil, "needsName": true, "offlineReport": nil, "mapRef": h.world.RefForLevel(1), "towns": townsWithNPCs, + "npcCostPotion": pCost, + "npcCostHeal": hCost, }) return } @@ -1069,6 +1072,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { // Build towns with NPCs for the frontend map. townsWithNPCs := h.buildTownsWithNPCs(r.Context()) + pCost, hCost := tuning.EffectiveNPCShopCosts() writeJSON(w, http.StatusOK, map[string]any{ "hero": hero, @@ -1076,6 +1080,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { "offlineReport": report, "mapRef": h.world.RefForLevel(hero.Level), "towns": townsWithNPCs, + "npcCostPotion": pCost, + "npcCostHeal": hCost, }) } diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index 38ff142..841adbb 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -187,20 +187,20 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { } case "merchant": - cfg := tuning.Get() + potionCost, _ := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", ItemName: "Healing Potion", - ItemCost: cfg.NPCCostPotion, + ItemCost: potionCost, Description: "Restores health. Always handy in a pinch.", }) case "healer": - cfg := tuning.Get() + _, healCost := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "heal", ItemName: "Full Heal", - ItemCost: cfg.NPCCostHeal, + ItemCost: healCost, Description: "Restore hero to full HP.", }) } @@ -593,7 +593,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { } } - healCost := tuning.Get().NPCCostHeal + _, healCost := tuning.EffectiveNPCShopCosts() if hero.Gold < healCost { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("not enough gold (need %d, have %d)", healCost, hero.Gold), @@ -643,7 +643,7 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { return } - potionCost := tuning.Get().NPCCostPotion + potionCost, _ := tuning.EffectiveNPCShopCosts() if hero.Gold < potionCost { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold), diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 079e94a..375be30 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -361,6 +361,21 @@ func Get() Values { return *p } +// EffectiveNPCShopCosts returns potion and full-heal prices from runtime tuning (DB-merged JSON), +// falling back to defaults when unset or non-positive. +func EffectiveNPCShopCosts() (potionCost, healCost int64) { + cfg := Get() + potionCost = cfg.NPCCostPotion + if potionCost <= 0 { + potionCost = DefaultValues().NPCCostPotion + } + healCost = cfg.NPCCostHeal + if healCost <= 0 { + healCost = DefaultValues().NPCCostHeal + } + return potionCost, healCost +} + func Set(v Values) { current.Store(&v) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8fabb13..5fe8957 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,8 @@ import { buyPotion, healAtNPC, requestRevive, + defaultNpcShopCosts, + npcShopCostsFromInit, } from './network/api'; import type { HeroResponse, Achievement } from './network/api'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types'; @@ -340,6 +342,7 @@ export function App() { // Wandering NPC encounter state const [wanderingNPC, setWanderingNPC] = useState(null); + const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts); // Achievements const [achievements, setAchievements] = useState([]); const prevAchievementsRef = useRef([]); @@ -424,6 +427,7 @@ export function App() { try { const telegramId = getTelegramUserId() ?? 1; const initRes = await initHero(telegramId); + setNpcShopCosts(npcShopCostsFromInit(initRes)); // Gate game start behind name entry — no hero row until POST /hero/name if (initRes.needsName) { @@ -1067,7 +1071,7 @@ export function App() { buyPotion(telegramId) .then((hero) => { hapticImpact('medium'); - setToast({ message: t(tr.boughtPotion, { cost: 50 }), color: '#88dd88' }); + setToast({ message: t(tr.boughtPotion, { cost: npcShopCosts.potionCost }), color: '#88dd88' }); handleNPCHeroUpdated(hero); // Server logs purchase + WS }) @@ -1082,7 +1086,7 @@ export function App() { } } }); - }, [handleNPCHeroUpdated]); + }, [handleNPCHeroUpdated, npcShopCosts.potionCost, tr]); const handleNPCHeal = useCallback((npc: NPCData) => { const telegramId = getTelegramUserId() ?? 1; @@ -1237,6 +1241,8 @@ export function App() { setSelectedNPC(null)} onQuestsChanged={refreshHeroQuests} onHeroUpdated={handleNPCHeroUpdated} diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 7d1a520..d500295 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -186,6 +186,25 @@ export interface InitHeroResponse { offlineReport: OfflineReport | null; mapRef: MapRefResponse; needsName?: boolean; + /** Runtime tuning: merchant potion price (from DB / runtime_config). */ + npcCostPotion?: number; + /** Runtime tuning: healer full heal price (from DB / runtime_config). */ + npcCostHeal?: number; +} + +/** Matches server defaults when init omits costs (must stay in sync with tuning.DefaultValues). */ +export function defaultNpcShopCosts(): { potionCost: number; healCost: number } { + return { potionCost: 50, healCost: 100 }; +} + +export function npcShopCostsFromInit(res: InitHeroResponse): { potionCost: number; healCost: number } { + const d = defaultNpcShopCosts(); + const p = res.npcCostPotion; + const h = res.npcCostHeal; + return { + potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost, + healCost: typeof h === 'number' && h > 0 ? h : d.healCost, + }; } /** Initialize or retrieve a hero from the backend (creates on first call) */ diff --git a/frontend/src/ui/NPCDialog.tsx b/frontend/src/ui/NPCDialog.tsx index da477cc..aedc523 100644 --- a/frontend/src/ui/NPCDialog.tsx +++ b/frontend/src/ui/NPCDialog.tsx @@ -12,6 +12,8 @@ interface NPCDialogProps { npc: NPC; heroQuests: HeroQuest[]; heroGold: number; + potionCost: number; + healCost: number; onClose: () => void; onQuestsChanged: () => void; onHeroUpdated: (hero: HeroResponse) => void; @@ -228,17 +230,14 @@ function questTypeIcon(type: string): string { } } -// ---- Constants ---- - -const POTION_COST = 50; -const HEAL_COST = 30; - // ---- Component ---- export function NPCDialog({ npc, heroQuests, heroGold, + potionCost, + healCost, onClose, onQuestsChanged, onHeroUpdated, @@ -308,24 +307,24 @@ export function NPCDialog({ ); const handleBuyPotion = useCallback(() => { - if (heroGold < POTION_COST) { + if (heroGold < potionCost) { onToast(tr.notEnoughGold, '#ff4444'); return; } buyPotion(telegramId) .then((hero) => { hapticImpact('medium'); - onToast(t(tr.boughtPotion, { cost: POTION_COST }), '#88dd88'); + onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88'); onHeroUpdated(hero); }) .catch((err) => { console.warn('[NPCDialog] Failed to buy potion:', err); onToast(tr.failedToBuyPotion, '#ff4444'); }); - }, [telegramId, heroGold, onHeroUpdated, onToast]); + }, [telegramId, heroGold, potionCost, onHeroUpdated, onToast, tr]); const handleHeal = useCallback(() => { - if (heroGold < HEAL_COST) { + if (heroGold < healCost) { onToast(tr.notEnoughGold, '#ff4444'); return; } @@ -339,7 +338,7 @@ export function NPCDialog({ console.warn('[NPCDialog] Failed to heal:', err); onToast(tr.failedToHeal, '#ff4444'); }); - }, [telegramId, heroGold, onHeroUpdated, onToast, npc.id]); + }, [telegramId, heroGold, healCost, onHeroUpdated, onToast, npc.id, tr]); // Quests relevant to this NPC const npcHeroQuests = heroQuests.filter( @@ -522,14 +521,14 @@ export function NPCDialog({
Shop
Your gold: {heroGold} @@ -543,14 +542,14 @@ export function NPCDialog({
Services
Your gold: {heroGold} diff --git a/frontend/src/ui/NPCInteraction.tsx b/frontend/src/ui/NPCInteraction.tsx index fb46033..4fe10f9 100644 --- a/frontend/src/ui/NPCInteraction.tsx +++ b/frontend/src/ui/NPCInteraction.tsx @@ -6,6 +6,8 @@ import type { NPCData } from '../game/types'; interface NPCInteractionProps { npc: NPCData; heroGold: number; + potionCost: number; + healCost: number; onViewQuests: (npc: NPCData) => void; onBuyPotion: (npc: NPCData) => void; onHeal: (npc: NPCData) => void; @@ -77,9 +79,6 @@ const actionBtnStyle: CSSProperties = { textAlign: 'center', }; -const POTION_COST = 50; -const HEAL_COST = 30; - // ---- NPC appearance ---- function npcColor(type: string): { bg: string; icon: string; text: string } { @@ -100,6 +99,8 @@ function npcColor(type: string): { bg: string; icon: string; text: string } { export function NPCInteraction({ npc, heroGold, + potionCost, + healCost, onViewQuests, onBuyPotion, onHeal, @@ -126,9 +127,9 @@ export function NPCInteraction({ case 'quest_giver': return 'View Quests'; case 'merchant': - return `Buy Potion (${POTION_COST}g)`; + return `Buy Potion (${potionCost}g)`; case 'healer': - return `Heal to Full (${HEAL_COST}g)`; + return `Heal to Full (${healCost}g)`; default: return 'Talk'; } @@ -136,8 +137,8 @@ export function NPCInteraction({ const canAfford = npc.type === 'quest_giver' || - (npc.type === 'merchant' && heroGold >= POTION_COST) || - (npc.type === 'healer' && heroGold >= HEAL_COST); + (npc.type === 'merchant' && heroGold >= potionCost) || + (npc.type === 'healer' && heroGold >= healCost); return ( <>