@ -30,6 +30,12 @@ type NPCHandler struct {
hub * Hub
}
// merchantStockRow is one town merchant shelf row (stats + per-item gold fixed at open).
type merchantStockRow struct {
model . GearItem
Cost int64 ` json:"cost" `
}
// NewNPCHandler creates a new NPCHandler.
func NewNPCHandler ( questStore * storage . QuestStore , heroStore * storage . HeroStore , gearStore * storage . GearStore , logStore * storage . LogStore , logger * slog . Logger , eng * game . Engine , hub * Hub ) * NPCHandler {
return & NPCHandler {
@ -79,6 +85,41 @@ func dist2D(x1, y1, x2, y2 float64) float64 {
return math . Sqrt ( dx * dx + dy * dy )
}
// loadHeroNPCInTown loads the hero, NPC row, town, and checks hero stand position is inside the town radius.
func ( h * NPCHandler ) loadHeroNPCInTown ( ctx context . Context , telegramID , npcID int64 , posX , posY float64 , wantNPCType string ) ( * model . Hero , * model . NPC , * model . Town , error ) {
if npcID == 0 {
return nil , nil , nil , fmt . Errorf ( "npcId is required" )
}
hero , err := h . heroStore . GetByTelegramID ( ctx , telegramID )
if err != nil {
return nil , nil , nil , fmt . Errorf ( "failed to load hero" )
}
if hero == nil {
return nil , nil , nil , fmt . Errorf ( "hero not found" )
}
npc , err := h . questStore . GetNPCByID ( ctx , npcID )
if err != nil {
return nil , nil , nil , fmt . Errorf ( "failed to load npc" )
}
if npc == nil {
return nil , nil , nil , fmt . Errorf ( "npc not found" )
}
if wantNPCType != "" && npc . Type != wantNPCType {
return nil , nil , nil , fmt . Errorf ( "npc type mismatch" )
}
town , err := h . questStore . GetTown ( ctx , npc . TownID )
if err != nil {
return nil , nil , nil , fmt . Errorf ( "failed to load town" )
}
if town == nil {
return nil , nil , nil , fmt . Errorf ( "town not found" )
}
if dist2D ( posX , posY , town . WorldX , town . WorldY ) > town . Radius {
return nil , nil , nil , fmt . Errorf ( "hero is too far from the town" )
}
return hero , npc , town , nil
}
// InteractNPC handles POST /api/v1/hero/npc-interact.
// The hero interacts with a specific NPC; checks proximity to the NPC's town.
func ( h * NPCHandler ) InteractNPC ( w http . ResponseWriter , r * http . Request ) {
@ -176,7 +217,8 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
refreshSeconds := int64 ( time . Duration ( refreshHours ) * time . Hour / time . Second )
timeBucket := time . Now ( ) . UTC ( ) . Unix ( ) / refreshSeconds
limit := tuning . EffectiveQuestOffersPerNPC ( )
quests , err := h . questStore . ListOfferableQuestsForNPC ( r . Context ( ) , hero . ID , npc . ID , hero . Level , limit , timeBucket )
townOfferLevel := game . TownEffectiveLevel ( town )
quests , err := h . questStore . ListOfferableQuestsForNPC ( r . Context ( ) , hero . ID , npc . ID , townOfferLevel , limit , timeBucket )
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 {
@ -199,22 +241,29 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
}
case "merchant" :
potionCost , _ := tuning . EffectiveNPCShopCosts ( )
gearCost := tuning . EffectiveTownMerchantGearCost ( game . TownEffectiveLevel ( town ) )
actions = append ( actions , model . NPCInteractAction {
ActionType : "shop_item" ,
ItemKey : "shop.merchant_gear_rows" ,
ItemName : "Town gear" ,
ItemCost : gearCost ,
Description : "Stock is rolled when you open the shop (town-tier stats shown before purchase)." ,
} )
case "healer" :
potionCost , healCost := tuning . EffectiveNPCShopCosts ( )
actions = append ( actions , model . NPCInteractAction {
ActionType : "shop_item" ,
ItemKey : "shop.healing_potion" ,
ItemName : "Healing Potion" ,
ItemCost : potionCost ,
Description : "Restores health. Always handy in a pinch." ,
Description : "Restores health in combat .",
} )
case "healer" :
_ , healCost := tuning . EffectiveNPCShopCosts ( )
actions = append ( actions , model . NPCInteractAction {
ActionType : "heal" ,
ItemKey : "shop.full_heal" ,
ItemName : "Full Heal" ,
ItemCost : healCost ,
ItemKey : "shop.full_heal" ,
ItemName : "Full Heal" ,
ItemCost : healCost ,
Description : "Restore hero to full HP." ,
} )
}
@ -334,9 +383,9 @@ func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) err
}
// grantMerchantLoot rolls one random gear piece; auto-equips if better.
// Outside town, unwanted pieces are discarded (gold for sells only in town ).
// refLevel drives ilvl (hero level for wandering merchant, town tier for static shops ).
// Cost must already be deducted from hero.Gold.
func ( h * NPCHandler ) grantMerchantLoot ( ctx context . Context , hero * model . Hero , now time . Time ) ( * model . LootDrop , error ) {
func ( h * NPCHandler ) grantMerchantLoot ( ctx context . Context , hero * model . Hero , now time . Time , refLevel int ) ( * model . LootDrop , error ) {
slots := model . AllEquipmentSlots
if h . gearStore == nil {
return nil , errors . New ( "failed to roll gear" )
@ -354,7 +403,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
}
rarity := model . RollRarity ( )
ilvl := model . RollIlvl ( hero. Level, false )
ilvl := model . RollIlvl ( ref Level, false )
item := model . NewGearItem ( family , ilvl , rarity )
ctxCreate , cancel := context . WithTimeout ( ctx , 2 * time . Second )
@ -466,7 +515,7 @@ func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) erro
hero . Gold -= cost
now := time . Now ( )
drop , err := h . grantMerchantLoot ( ctx , hero , now )
drop , err := h . grantMerchantLoot ( ctx , hero , now , hero . Level )
if err != nil {
hero . Gold += cost
return err
@ -544,7 +593,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
hero . Gold -= cost
now := time . Now ( )
drop , err := h . grantMerchantLoot ( r . Context ( ) , hero , now )
drop , err := h . grantMerchantLoot ( r . Context ( ) , hero , now , hero . Level )
if err != nil {
hero . Gold += cost
writeJSON ( w , http . StatusInternalServerError , map [ string ] string {
@ -590,7 +639,9 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
}
var req struct {
NPCID int64 ` json:"npcId" `
NPCID int64 ` json:"npcId" `
PositionX float64 ` json:"positionX" `
PositionY float64 ` json:"positionY" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
@ -599,35 +650,35 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return
}
hero , err := h . heroStore . GetByTelegramID ( r . Context ( ) , telegramID )
if err != nil {
h . logger . Error ( "failed to get hero for heal" , "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
}
// Verify NPC is a healer.
var hero * model . Hero
if req . NPCID != 0 {
npc , err := h . questStore . GetNPCByID ( r . Context ( ) , req . NPCID )
var err error
hero , _ , _ , err = h . loadHeroNPCInTown ( r . Context ( ) , telegramID , req . NPCID , req . PositionX , req . PositionY , "healer" )
if err != nil {
h . logger . Error ( "failed to get npc for heal" , "npc_id" , req . NPCID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string {
"error" : "failed to load npc" ,
} )
msg := err . Error ( )
switch msg {
case "hero not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "npc not found" , "town not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "failed to load hero" , "failed to load npc" , "failed to load town" :
h . logger . Error ( "npc heal lookup" , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to load data" } )
default :
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : msg } )
}
return
}
if npc == nil || npc . Type != "healer" {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "npc is not a healer" ,
} )
} else {
var err error
hero , err = h . heroStore . GetByTelegramID ( r . Context ( ) , telegramID )
if err != nil {
h . logger . Error ( "failed to get hero for heal" , "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
}
}
@ -652,12 +703,14 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
}
h . addLogLine ( hero . ID , model . AdventureLogLine { Event : & model . AdventureLogEvent { Code : model . LogPhraseHealedFullTown } } )
// Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client.
if h . engine != nil {
h . engine . ApplyPersistedHeroSnapshot ( hero )
}
writeHeroJSON ( w , http . StatusOK , hero )
}
// BuyPotion handles POST /api/v1/hero/npc-buy-potion.
// A merchant NPC sells a healing potion for the runtime-configured gold cost .
// A healer NPC sells a healing potion (hero must stand in town near the NPC's town) .
func ( h * NPCHandler ) BuyPotion ( w http . ResponseWriter , r * http . Request ) {
telegramID , ok := resolveTelegramID ( r )
if ! ok {
@ -667,18 +720,32 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return
}
hero , err := h . heroStore . GetByTelegramID ( r . Context ( ) , telegramID )
if err != nil {
h . logger . Error ( "failed to get hero for buy potion" , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string {
"error" : "failed to load hero" ,
var req struct {
NPCID int64 ` json:"npcId" `
PositionX float64 ` json:"positionX" `
PositionY float64 ` json:"positionY" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "invalid request body" ,
} )
return
}
if hero == nil {
writeJSON ( w , http . StatusNotFound , map [ string ] string {
"error" : "hero not found" ,
} )
hero , _ , _ , err := h . loadHeroNPCInTown ( r . Context ( ) , telegramID , req . NPCID , req . PositionX , req . PositionY , "healer" )
if err != nil {
msg := err . Error ( )
switch msg {
case "hero not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "npc not found" , "town not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "failed to load hero" , "failed to load npc" , "failed to load town" :
h . logger . Error ( "buy potion lookup" , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to load data" } )
default :
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : msg } )
}
return
}
@ -702,5 +769,210 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
}
h . addLogLine ( hero . ID , model . AdventureLogLine { Event : & model . AdventureLogEvent { Code : model . LogPhraseBoughtPotionTown } } )
if h . engine != nil {
h . engine . ApplyPersistedHeroSnapshot ( hero )
}
writeHeroJSON ( w , http . StatusOK , hero )
}
// BuyTownMerchantGear handles POST /api/v1/hero/npc-buy-town-gear.
// Purchases one row from the current merchant stock (see POST .../npc-merchant-stock); equips immediately.
func ( h * NPCHandler ) BuyTownMerchantGear ( w http . ResponseWriter , r * http . Request ) {
telegramID , ok := resolveTelegramID ( r )
if ! ok {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "missing telegramId" ,
} )
return
}
var req struct {
NPCID int64 ` json:"npcId" `
PositionX float64 ` json:"positionX" `
PositionY float64 ` json:"positionY" `
OfferIndex int ` json:"offerIndex" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "invalid request body" ,
} )
return
}
hero , npc , town , err := h . loadHeroNPCInTown ( r . Context ( ) , telegramID , req . NPCID , req . PositionX , req . PositionY , "merchant" )
if err != nil {
msg := err . Error ( )
switch msg {
case "hero not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "npc not found" , "town not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "failed to load hero" , "failed to load npc" , "failed to load town" :
h . logger . Error ( "buy town gear lookup" , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to load data" } )
default :
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : msg } )
}
return
}
if h . gearStore == nil {
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "gear store unavailable" } )
return
}
if h . engine == nil {
writeJSON ( w , http . StatusServiceUnavailable , map [ string ] string { "error" : "world engine unavailable" } )
return
}
item , price , ok := h . engine . TakeMerchantOffer ( hero . ID , req . NPCID , req . OfferIndex )
if ! ok || item == nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "invalid or expired shop offer — reopen the merchant" ,
} )
return
}
if hero . Gold < price {
h . engine . UnshiftMerchantOffer ( hero . ID , npc . ID , town . ID , item , price )
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : fmt . Sprintf ( "not enough gold (need %d, have %d)" , price , hero . Gold ) ,
} )
return
}
hero . Gold -= price
now := time . Now ( )
drop , err := game . ApplyPreparedTownMerchantPurchase ( r . Context ( ) , h . gearStore , hero , item , now )
if err != nil {
hero . Gold += price
h . engine . UnshiftMerchantOffer ( hero . ID , npc . ID , town . ID , item , price )
if errors . Is ( err , storage . ErrInventoryFull ) {
writeJSON ( w , http . StatusBadRequest , map [ string ] string {
"error" : "inventory full — free a backpack slot to swap gear" ,
} )
return
}
h . logger . Warn ( "town merchant gear failed" , "hero_id" , hero . ID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to grant gear" } )
return
}
if err := h . heroStore . Save ( r . Context ( ) , hero ) ; err != nil {
h . logger . Error ( "failed to save hero after town gear" , "hero_id" , hero . ID , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to save hero" } )
return
}
h . addLogLine ( hero . ID , model . AdventureLogLine {
Event : & model . AdventureLogEvent {
Code : model . LogPhraseBoughtGearTownMerchant ,
Args : map [ string ] any {
"npcKey" : npc . NameKey , "townKey" : town . NameKey , "slot" : drop . ItemType , "rarity" : string ( drop . Rarity ) , "itemId" : drop . ItemID ,
} ,
} ,
} )
h . engine . ApplyPersistedHeroSnapshot ( hero )
writeHeroJSON ( w , http . StatusOK , hero )
}
// NPCDialogPause handles POST /api/v1/hero/npc-dialog-pause.
// While open, the engine freezes town NPC visit narration timers (shop / quest UI).
func ( h * NPCHandler ) NPCDialogPause ( w http . ResponseWriter , r * http . Request ) {
telegramID , ok := resolveTelegramID ( r )
if ! ok {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "missing telegramId" } )
return
}
var req struct {
Open bool ` json:"open" `
AdvanceTownVisit bool ` json:"advanceTownVisit" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid request body" } )
return
}
hero , err := h . heroStore . GetByTelegramID ( r . Context ( ) , telegramID )
if err != nil {
h . logger . Error ( "npc dialog pause: load hero" , "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
}
if h . engine != nil {
if ! req . Open && req . AdvanceTownVisit {
h . engine . SkipTownNPCNarrationAfterDialog ( hero . ID )
h . engine . ClearMerchantStock ( hero . ID )
} else {
h . engine . SetTownNPCUILock ( hero . ID , req . Open )
if ! req . Open {
h . engine . ClearMerchantStock ( hero . ID )
}
}
}
writeJSON ( w , http . StatusOK , map [ string ] bool { "ok" : true } )
}
// MerchantStock handles POST /api/v1/hero/npc-merchant-stock.
// Rolls town-tier gear rows (not persisted until purchase) and caches them on the engine.
func ( h * NPCHandler ) MerchantStock ( w http . ResponseWriter , r * http . Request ) {
telegramID , ok := resolveTelegramID ( r )
if ! ok {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "missing telegramId" } )
return
}
var req struct {
NPCID int64 ` json:"npcId" `
PositionX float64 ` json:"positionX" `
PositionY float64 ` json:"positionY" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid request body" } )
return
}
if h . engine == nil {
writeJSON ( w , http . StatusServiceUnavailable , map [ string ] string { "error" : "world engine unavailable" } )
return
}
hero , npc , town , err := h . loadHeroNPCInTown ( r . Context ( ) , telegramID , req . NPCID , req . PositionX , req . PositionY , "merchant" )
if err != nil {
msg := err . Error ( )
switch msg {
case "hero not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "npc not found" , "town not found" :
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : msg } )
case "failed to load hero" , "failed to load npc" , "failed to load town" :
h . logger . Error ( "merchant stock lookup" , "error" , err )
writeJSON ( w , http . StatusInternalServerError , map [ string ] string { "error" : "failed to load data" } )
default :
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : msg } )
}
return
}
townLv := game . TownEffectiveLevel ( town )
n := tuning . EffectiveMerchantTownStockCount ( )
items := game . RollTownMerchantStockItems ( townLv , n )
costs := make ( [ ] int64 , len ( items ) )
for i , it := range items {
if it == nil {
continue
}
costs [ i ] = game . RollTownMerchantOfferGold ( it . Ilvl , it . Rarity , townLv )
}
h . engine . SetTownNPCUILock ( hero . ID , true )
h . engine . SetMerchantStock ( hero . ID , npc . ID , town . ID , items , costs )
rows := make ( [ ] merchantStockRow , len ( items ) )
for i , it := range items {
if it == nil {
continue
}
rows [ i ] . GearItem = * it
rows [ i ] . Cost = costs [ i ]
}
writeJSON ( w , http . StatusOK , map [ string ] any {
"items" : rows ,
} )
}