update shop costs

master
Denis Ranneft 1 month ago
parent b11b9bc437
commit bd1a636086

@ -1007,12 +1007,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
if hero == nil { if hero == nil {
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": nil, "hero": nil,
"needsName": true, "needsName": true,
"offlineReport": nil, "offlineReport": nil,
"mapRef": h.world.RefForLevel(1), "mapRef": h.world.RefForLevel(1),
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
}) })
return return
} }
@ -1069,6 +1072,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
// Build towns with NPCs for the frontend map. // Build towns with NPCs for the frontend map.
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
@ -1076,6 +1080,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"offlineReport": report, "offlineReport": report,
"mapRef": h.world.RefForLevel(hero.Level), "mapRef": h.world.RefForLevel(hero.Level),
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
}) })
} }

@ -187,20 +187,20 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
} }
case "merchant": case "merchant":
cfg := tuning.Get() potionCost, _ := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{ actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item", ActionType: "shop_item",
ItemName: "Healing Potion", ItemName: "Healing Potion",
ItemCost: cfg.NPCCostPotion, ItemCost: potionCost,
Description: "Restores health. Always handy in a pinch.", Description: "Restores health. Always handy in a pinch.",
}) })
case "healer": case "healer":
cfg := tuning.Get() _, healCost := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{ actions = append(actions, model.NPCInteractAction{
ActionType: "heal", ActionType: "heal",
ItemName: "Full Heal", ItemName: "Full Heal",
ItemCost: cfg.NPCCostHeal, ItemCost: healCost,
Description: "Restore hero to full HP.", 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 { if hero.Gold < healCost {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", healCost, hero.Gold), "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 return
} }
potionCost := tuning.Get().NPCCostPotion potionCost, _ := tuning.EffectiveNPCShopCosts()
if hero.Gold < potionCost { if hero.Gold < potionCost {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold), "error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold),

@ -361,6 +361,21 @@ func Get() Values {
return *p 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) { func Set(v Values) {
current.Store(&v) current.Store(&v)
} }

@ -29,6 +29,8 @@ import {
buyPotion, buyPotion,
healAtNPC, healAtNPC,
requestRevive, requestRevive,
defaultNpcShopCosts,
npcShopCostsFromInit,
} from './network/api'; } from './network/api';
import type { HeroResponse, Achievement } from './network/api'; import type { HeroResponse, Achievement } from './network/api';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
@ -340,6 +342,7 @@ export function App() {
// Wandering NPC encounter state // Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null); const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts);
// Achievements // Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]); const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]); const prevAchievementsRef = useRef<Achievement[]>([]);
@ -424,6 +427,7 @@ export function App() {
try { try {
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
const initRes = await initHero(telegramId); const initRes = await initHero(telegramId);
setNpcShopCosts(npcShopCostsFromInit(initRes));
// Gate game start behind name entry — no hero row until POST /hero/name // Gate game start behind name entry — no hero row until POST /hero/name
if (initRes.needsName) { if (initRes.needsName) {
@ -1067,7 +1071,7 @@ export function App() {
buyPotion(telegramId) buyPotion(telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
setToast({ message: t(tr.boughtPotion, { cost: 50 }), color: '#88dd88' }); setToast({ message: t(tr.boughtPotion, { cost: npcShopCosts.potionCost }), color: '#88dd88' });
handleNPCHeroUpdated(hero); handleNPCHeroUpdated(hero);
// Server logs purchase + WS // Server logs purchase + WS
}) })
@ -1082,7 +1086,7 @@ export function App() {
} }
} }
}); });
}, [handleNPCHeroUpdated]); }, [handleNPCHeroUpdated, npcShopCosts.potionCost, tr]);
const handleNPCHeal = useCallback((npc: NPCData) => { const handleNPCHeal = useCallback((npc: NPCData) => {
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
@ -1237,6 +1241,8 @@ export function App() {
<NPCInteraction <NPCInteraction
npc={nearestNPC} npc={nearestNPC}
heroGold={gameState.hero?.gold ?? 0} heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
onViewQuests={handleNPCViewQuests} onViewQuests={handleNPCViewQuests}
onBuyPotion={handleNPCBuyPotion} onBuyPotion={handleNPCBuyPotion}
onHeal={handleNPCHeal} onHeal={handleNPCHeal}
@ -1250,6 +1256,8 @@ export function App() {
npc={selectedNPC} npc={selectedNPC}
heroQuests={heroQuests} heroQuests={heroQuests}
heroGold={gameState.hero?.gold ?? 0} heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
onClose={() => setSelectedNPC(null)} onClose={() => setSelectedNPC(null)}
onQuestsChanged={refreshHeroQuests} onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated} onHeroUpdated={handleNPCHeroUpdated}

@ -186,6 +186,25 @@ export interface InitHeroResponse {
offlineReport: OfflineReport | null; offlineReport: OfflineReport | null;
mapRef: MapRefResponse; mapRef: MapRefResponse;
needsName?: boolean; 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) */ /** Initialize or retrieve a hero from the backend (creates on first call) */

@ -12,6 +12,8 @@ interface NPCDialogProps {
npc: NPC; npc: NPC;
heroQuests: HeroQuest[]; heroQuests: HeroQuest[];
heroGold: number; heroGold: number;
potionCost: number;
healCost: number;
onClose: () => void; onClose: () => void;
onQuestsChanged: () => void; onQuestsChanged: () => void;
onHeroUpdated: (hero: HeroResponse) => void; onHeroUpdated: (hero: HeroResponse) => void;
@ -228,17 +230,14 @@ function questTypeIcon(type: string): string {
} }
} }
// ---- Constants ----
const POTION_COST = 50;
const HEAL_COST = 30;
// ---- Component ---- // ---- Component ----
export function NPCDialog({ export function NPCDialog({
npc, npc,
heroQuests, heroQuests,
heroGold, heroGold,
potionCost,
healCost,
onClose, onClose,
onQuestsChanged, onQuestsChanged,
onHeroUpdated, onHeroUpdated,
@ -308,24 +307,24 @@ export function NPCDialog({
); );
const handleBuyPotion = useCallback(() => { const handleBuyPotion = useCallback(() => {
if (heroGold < POTION_COST) { if (heroGold < potionCost) {
onToast(tr.notEnoughGold, '#ff4444'); onToast(tr.notEnoughGold, '#ff4444');
return; return;
} }
buyPotion(telegramId) buyPotion(telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
onToast(t(tr.boughtPotion, { cost: POTION_COST }), '#88dd88'); onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88');
onHeroUpdated(hero); onHeroUpdated(hero);
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to buy potion:', err); console.warn('[NPCDialog] Failed to buy potion:', err);
onToast(tr.failedToBuyPotion, '#ff4444'); onToast(tr.failedToBuyPotion, '#ff4444');
}); });
}, [telegramId, heroGold, onHeroUpdated, onToast]); }, [telegramId, heroGold, potionCost, onHeroUpdated, onToast, tr]);
const handleHeal = useCallback(() => { const handleHeal = useCallback(() => {
if (heroGold < HEAL_COST) { if (heroGold < healCost) {
onToast(tr.notEnoughGold, '#ff4444'); onToast(tr.notEnoughGold, '#ff4444');
return; return;
} }
@ -339,7 +338,7 @@ export function NPCDialog({
console.warn('[NPCDialog] Failed to heal:', err); console.warn('[NPCDialog] Failed to heal:', err);
onToast(tr.failedToHeal, '#ff4444'); onToast(tr.failedToHeal, '#ff4444');
}); });
}, [telegramId, heroGold, onHeroUpdated, onToast, npc.id]); }, [telegramId, heroGold, healCost, onHeroUpdated, onToast, npc.id, tr]);
// Quests relevant to this NPC // Quests relevant to this NPC
const npcHeroQuests = heroQuests.filter( const npcHeroQuests = heroQuests.filter(
@ -522,14 +521,14 @@ export function NPCDialog({
<div style={sectionTitleStyle}>Shop</div> <div style={sectionTitleStyle}>Shop</div>
<button <button
style={ style={
heroGold >= POTION_COST heroGold >= potionCost
? { ...serviceBtnStyle, backgroundColor: 'rgba(68, 200, 68, 0.2)', color: '#88dd88' } ? { ...serviceBtnStyle, backgroundColor: 'rgba(68, 200, 68, 0.2)', color: '#88dd88' }
: { ...disabledBtnStyle, backgroundColor: 'rgba(68, 200, 68, 0.1)', color: '#88dd88' } : { ...disabledBtnStyle, backgroundColor: 'rgba(68, 200, 68, 0.1)', color: '#88dd88' }
} }
onClick={handleBuyPotion} onClick={handleBuyPotion}
disabled={heroGold < POTION_COST} disabled={heroGold < potionCost}
> >
{'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {POTION_COST} {tr.gold} {'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {potionCost} {tr.gold}
</button> </button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}> <div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold} Your gold: {heroGold}
@ -543,14 +542,14 @@ export function NPCDialog({
<div style={sectionTitleStyle}>Services</div> <div style={sectionTitleStyle}>Services</div>
<button <button
style={ style={
heroGold >= HEAL_COST heroGold >= healCost
? { ...serviceBtnStyle, backgroundColor: 'rgba(200, 68, 68, 0.2)', color: '#ff8888' } ? { ...serviceBtnStyle, backgroundColor: 'rgba(200, 68, 68, 0.2)', color: '#ff8888' }
: { ...disabledBtnStyle, backgroundColor: 'rgba(200, 68, 68, 0.1)', color: '#ff8888' } : { ...disabledBtnStyle, backgroundColor: 'rgba(200, 68, 68, 0.1)', color: '#ff8888' }
} }
onClick={handleHeal} onClick={handleHeal}
disabled={heroGold < HEAL_COST} disabled={heroGold < healCost}
> >
{'\u2764\uFE0F'} {tr.healToFull} &mdash; {HEAL_COST} {tr.gold} {'\u2764\uFE0F'} {tr.healToFull} &mdash; {healCost} {tr.gold}
</button> </button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}> <div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold} Your gold: {heroGold}

@ -6,6 +6,8 @@ import type { NPCData } from '../game/types';
interface NPCInteractionProps { interface NPCInteractionProps {
npc: NPCData; npc: NPCData;
heroGold: number; heroGold: number;
potionCost: number;
healCost: number;
onViewQuests: (npc: NPCData) => void; onViewQuests: (npc: NPCData) => void;
onBuyPotion: (npc: NPCData) => void; onBuyPotion: (npc: NPCData) => void;
onHeal: (npc: NPCData) => void; onHeal: (npc: NPCData) => void;
@ -77,9 +79,6 @@ const actionBtnStyle: CSSProperties = {
textAlign: 'center', textAlign: 'center',
}; };
const POTION_COST = 50;
const HEAL_COST = 30;
// ---- NPC appearance ---- // ---- NPC appearance ----
function npcColor(type: string): { bg: string; icon: string; text: string } { 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({ export function NPCInteraction({
npc, npc,
heroGold, heroGold,
potionCost,
healCost,
onViewQuests, onViewQuests,
onBuyPotion, onBuyPotion,
onHeal, onHeal,
@ -126,9 +127,9 @@ export function NPCInteraction({
case 'quest_giver': case 'quest_giver':
return 'View Quests'; return 'View Quests';
case 'merchant': case 'merchant':
return `Buy Potion (${POTION_COST}g)`; return `Buy Potion (${potionCost}g)`;
case 'healer': case 'healer':
return `Heal to Full (${HEAL_COST}g)`; return `Heal to Full (${healCost}g)`;
default: default:
return 'Talk'; return 'Talk';
} }
@ -136,8 +137,8 @@ export function NPCInteraction({
const canAfford = const canAfford =
npc.type === 'quest_giver' || npc.type === 'quest_giver' ||
(npc.type === 'merchant' && heroGold >= POTION_COST) || (npc.type === 'merchant' && heroGold >= potionCost) ||
(npc.type === 'healer' && heroGold >= HEAL_COST); (npc.type === 'healer' && heroGold >= healCost);
return ( return (
<> <>

Loading…
Cancel
Save