npc logic update

master
Denis Ranneft 1 month ago
parent 082f96f627
commit 5bf7f5b0bd

@ -47,7 +47,7 @@ alwaysApply: true
## Buffs / debuffs
- **8** buffs and **6** debuffs; effects and magnitudes (e.g. Rage +100% damage, Shield 50% incoming, Stun blocks attacks 2s) per spec **§7**.
- **8** buffs and **6** debuffs; effects and magnitudes per spec **§7** (e.g. Rage ~+67% damage, Shield ~33% incoming, Stun blocks attacks — duration in debuff catalog).
## Loot and gold (§8)

@ -70,7 +70,7 @@ func damageRollMultiplier(minRoll, maxRoll float64) float64 {
return minRoll + rand.Float64()*(maxRoll-minRoll)
}
// CalculateIncomingDamage applies shield buff and weaken debuff reduction to incoming damage.
// CalculateIncomingDamage applies Shield (magnitude fraction) and Weaken (+magnitude incoming) per spec §7.
func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int {
dmg := float64(rawDamage)
@ -87,7 +87,7 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []
continue
}
if ad.Debuff.Type == model.DebuffWeaken {
dmg *= (1 - ad.Debuff.Magnitude)
dmg *= (1 + ad.Debuff.Magnitude)
}
}

@ -177,9 +177,10 @@ func TestLuckMultiplierWithBuff(t *testing.T) {
}},
}
want := tuning.Get().LuckBuffMultiplier
mult := LuckMultiplier(hero, now)
if mult != 1.75 {
t.Fatalf("expected luck multiplier 1.75, got %.2f", mult)
if mult != want {
t.Fatalf("expected luck multiplier %.4f, got %.4f", want, mult)
}
}
@ -303,6 +304,38 @@ func TestDamageRollAppliesRange(t *testing.T) {
}
}
func TestCalculateIncomingDamage_ShieldAndWeaken(t *testing.T) {
now := time.Now()
shield := model.ActiveBuff{
Buff: mustBuffDef(model.BuffShield),
AppliedAt: now,
ExpiresAt: now.Add(time.Minute),
}
weaken := model.ActiveDebuff{
Debuff: mustDebuffDef(model.DebuffWeaken),
AppliedAt: now,
ExpiresAt: now.Add(time.Minute),
}
raw := 90
shMag := mustBuffDef(model.BuffShield).Magnitude
wantShield := int(float64(raw) * (1 - shMag))
if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, nil, now); got != wantShield {
t.Fatalf("shield: want %d got %d", wantShield, got)
}
wkMag := mustDebuffDef(model.DebuffWeaken).Magnitude
wantWeaken := int(float64(raw) * (1 + wkMag))
if got := CalculateIncomingDamage(raw, nil, []model.ActiveDebuff{weaken}, now); got != wantWeaken {
t.Fatalf("weaken: want %d got %d", wantWeaken, got)
}
wantBoth := int(float64(raw) * (1 - shMag) * (1 + wkMag))
if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, []model.ActiveDebuff{weaken}, now); got != wantBoth {
t.Fatalf("shield+weaken: want %d got %d", wantBoth, got)
}
}
func mustBuffDef(bt model.BuffType) model.Buff {
b, ok := model.BuffDefinition(bt)
if !ok {

@ -24,6 +24,14 @@ type MessageSender interface {
// EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS.
type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop
type merchantOfferSession struct {
NPCID int64
TownID int64
Items []*model.GearItem
Costs []int64 // parallel to Items — rolled when stock opens
Created time.Time
}
// EngineStatus contains a snapshot of the engine's operational state.
type EngineStatus struct {
Running bool `json:"running"`
@ -83,6 +91,9 @@ type Engine struct {
heroSubscriber func(heroID int64) bool
// lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber.
lastDisconnectedFullSave map[int64]time.Time
// merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close.
merchantStock map[int64]*merchantOfferSession
}
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
@ -99,6 +110,7 @@ func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *s
eventCh: eventCh,
logger: logger,
lastDisconnectedFullSave: make(map[int64]time.Time),
merchantStock: make(map[int64]*merchantOfferSession),
}
heap.Init(&e.queue)
return e
@ -1155,9 +1167,8 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
}
}
// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into
// the live movement session and pushes hero_state when a sender is configured.
func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
// ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state.
func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) {
if hero == nil {
return
}
@ -1167,7 +1178,6 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
hm, ok := e.movements[hero.ID]
if ok {
now := time.Now()
hm.WanderingMerchantDeadline = time.Time{}
*hm.Hero = *hero
hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now)
@ -1183,6 +1193,21 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
}
}
// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into
// the live movement session and pushes hero_state when a sender is configured.
func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
if hero == nil {
return
}
e.mu.Lock()
if hm, ok := e.movements[hero.ID]; ok {
hm.WanderingMerchantDeadline = time.Time{}
}
e.mu.Unlock()
e.ApplyPersistedHeroSnapshot(hero)
}
// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
// restores movement/route when needed, and pushes WS events so the client matches the DB.
@ -1207,6 +1232,7 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
hm.State = hero.State
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.LastMoveTick = now
hm.refreshSpeed(now)
@ -1262,6 +1288,7 @@ func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) {
now := time.Now()
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
*hm.Hero = *hero
hm.State = model.StateDead
hm.Hero.State = model.StateDead
@ -1959,3 +1986,113 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
IsElite: e.IsElite,
}
}
// SetTownNPCUILock freezes town NPC visit narration while the client shows shop or quest UI.
func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
hm := e.movements[heroID]
if hm == nil {
return
}
hm.TownNPCUILock = locked
}
// SkipTownNPCNarrationAfterDialog ends the current town NPC visit narration immediately when
// the client closes shop / healer / quest UI (next tick proceeds to the next NPC or plaza).
func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
hm := e.movements[heroID]
if hm == nil {
return
}
hm.skipTownNPCNarrationForDialogClose(time.Now())
}
// SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared).
// costs must have the same length as items (gold price locked at roll time).
func (e *Engine) SetMerchantStock(heroID, npcID, townID int64, items []*model.GearItem, costs []int64) {
if e == nil {
return
}
if len(costs) != len(items) {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.merchantStock == nil {
e.merchantStock = make(map[int64]*merchantOfferSession)
}
copies := make([]*model.GearItem, len(items))
prices := make([]int64, len(costs))
for i, it := range items {
copies[i] = model.CloneGearItem(it)
prices[i] = costs[i]
}
e.merchantStock[heroID] = &merchantOfferSession{
NPCID: npcID, TownID: townID, Items: copies, Costs: prices, Created: time.Now(),
}
}
// ClearMerchantStock drops cached merchant rows (e.g. dialog closed).
func (e *Engine) ClearMerchantStock(heroID int64) {
if e == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
delete(e.merchantStock, heroID)
}
// TakeMerchantOffer validates npc and index, removes the row, returns a template for DB insert (id 0) and locked price.
func (e *Engine) TakeMerchantOffer(heroID, npcID int64, index int) (*model.GearItem, int64, bool) {
if e == nil {
return nil, 0, false
}
e.mu.Lock()
defer e.mu.Unlock()
s, ok := e.merchantStock[heroID]
if !ok || s == nil || s.NPCID != npcID || index < 0 || index >= len(s.Items) {
return nil, 0, false
}
if len(s.Costs) != len(s.Items) || index >= len(s.Costs) {
return nil, 0, false
}
item := model.CloneGearItem(s.Items[index])
price := s.Costs[index]
s.Items = append(s.Items[:index], s.Items[index+1:]...)
s.Costs = append(s.Costs[:index], s.Costs[index+1:]...)
if len(s.Items) == 0 {
delete(e.merchantStock, heroID)
}
return item, price, true
}
// UnshiftMerchantOffer puts an offer row back (e.g. failed persist after TakeMerchantOffer).
func (e *Engine) UnshiftMerchantOffer(heroID, npcID, townID int64, item *model.GearItem, cost int64) {
if e == nil || item == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.merchantStock == nil {
e.merchantStock = make(map[int64]*merchantOfferSession)
}
s := e.merchantStock[heroID]
clone := model.CloneGearItem(item)
if s == nil || s.NPCID != npcID {
e.merchantStock[heroID] = &merchantOfferSession{
NPCID: npcID, TownID: townID, Items: []*model.GearItem{clone}, Costs: []int64{cost}, Created: time.Now(),
}
return
}
s.Items = append([]*model.GearItem{clone}, s.Items...)
s.Costs = append([]int64{cost}, s.Costs...)
}

@ -71,6 +71,9 @@ type HeroMovement struct {
TownVisitStartedAt time.Time
TownVisitLogsEmitted int
// TownNPCUILock: while true, town NPC visit narration timers do not advance (hero opened shop/quest UI).
TownNPCUILock bool
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time.Time
@ -81,6 +84,8 @@ type HeroMovement struct {
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
TownLeaveAt time.Time
// TownLastNPCLingerUntil: after the final queued NPC visit ends, wait near them until this time before walking to plaza (shifted while TownNPCUILock).
TownLastNPCLingerUntil time.Time
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool
@ -158,6 +163,7 @@ type townPausePersistSignature struct {
InTownNPCQueueFP uint64
InTownVisitName string
InTownVisitType string
InTownLastNPCLinger time.Time
}
func npcQueueFingerprint(q []int64) uint64 {
@ -531,6 +537,7 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.RestUntil = shift(hm.RestUntil)
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil)
hm.TownLeaveAt = shift(hm.TownLeaveAt)
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
@ -806,6 +813,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
if graph != nil && hm.CurrentTownID == 0 {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
}
@ -1129,6 +1137,7 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.ActiveRestKind = model.RestKindNone
@ -1164,6 +1173,7 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone
@ -1390,6 +1400,7 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
sig.InTownVisitName = hm.TownVisitNPCName
sig.InTownVisitType = hm.TownVisitNPCType
sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil
}
return sig
}
@ -1439,6 +1450,10 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
t := hm.TownVisitStartedAt
p.TownVisitStartedAt = &t
}
if !hm.TownLastNPCLingerUntil.IsZero() {
t := hm.TownLastNPCLingerUntil
p.TownLastNPCLingerUntil = &t
}
if hm.TownPlazaHealActive {
p.TownPlazaHealActive = true
}
@ -1537,6 +1552,9 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
if blob.TownVisitStartedAt != nil {
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
}
if blob.TownLastNPCLingerUntil != nil {
hm.TownLastNPCLingerUntil = *blob.TownLastNPCLingerUntil
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
hm.TownNPCWalkToX = blob.NPCWalkToX
@ -1662,8 +1680,24 @@ type AfterTownEnterPersist func(hero *model.Hero)
// Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping.
type TownNPCOfflineInteractHook func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) bool
func townLastNpcLingerDuration() time.Duration {
ms := tuning.Get().TownLastNpcLingerMs
if ms <= 0 {
ms = tuning.DefaultValues().TownLastNpcLingerMs
}
return time.Duration(ms) * time.Millisecond
}
// scheduleLastNPCLingerFrom starts the “stand near last NPC” window when the NPC tour queue is empty.
func (hm *HeroMovement) scheduleLastNPCLingerFrom(now time.Time) {
if hm == nil || hm.State != model.StateInTown || len(hm.TownNPCQueue) != 0 {
return
}
hm.TownLastNPCLingerUntil = now.Add(townLastNpcLingerDuration())
}
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
if log == nil || hm.TownVisitStartedAt.IsZero() {
if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock {
return
}
logInterval := townNPCLogInterval()
@ -1682,6 +1716,28 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
}
}
// skipTownNPCNarrationForDialogClose clears the per-NPC narration window after the player
// closes shop / healer / quest UI so the next movement tick can roll the next queued NPC or plaza rest.
func (hm *HeroMovement) skipTownNPCNarrationForDialogClose(now time.Time) {
if hm == nil || hm.State != model.StateInTown {
return
}
if hm.TownNPCWalkTargetID != 0 {
return
}
wasInVisit := !hm.TownVisitStartedAt.IsZero()
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.NextTownNPCRollAt = time.Time{}
hm.TownNPCUILock = false
if wasInVisit && len(hm.TownNPCQueue) == 0 {
hm.scheduleLastNPCLingerFrom(now)
}
}
// --- Excursion (mini-adventure) FSM helpers ---
func smoothstep(t float64) float64 {
@ -2027,6 +2083,20 @@ func ProcessSingleHeroMovementTick(
}
hm.LastMoveTick = now
// While a town NPC dialog (shop / quests) is open, freeze narration deadlines by shifting anchors.
if hm.TownNPCUILock && dtTown > 0 {
shift := time.Duration(dtTown * float64(time.Second))
if !hm.TownVisitStartedAt.IsZero() {
hm.TownVisitStartedAt = hm.TownVisitStartedAt.Add(shift)
}
if !hm.NextTownNPCRollAt.IsZero() {
hm.NextTownNPCRollAt = hm.NextTownNPCRollAt.Add(shift)
}
if !hm.TownLastNPCLingerUntil.IsZero() {
hm.TownLastNPCLingerUntil = hm.TownLastNPCLingerUntil.Add(shift)
}
}
// --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
if hm.TownCenterWalkActive {
walkSpeed := cfg.TownNPCWalkSpeed
@ -2148,12 +2218,15 @@ func ProcessSingleHeroMovementTick(
}
// NPC visit pause ended: clear visit log state before the next roll.
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
if !hm.TownNPCUILock && !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
if len(hm.TownNPCQueue) == 0 {
hm.scheduleLastNPCLingerFrom(now)
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
@ -2170,6 +2243,22 @@ func ProcessSingleHeroMovementTick(
}
return
}
// After the last NPC: stay at the stand point until linger ends and dialog is not open.
if !hm.TownLastNPCLingerUntil.IsZero() {
if hm.TownNPCUILock || now.Before(hm.TownLastNPCLingerUntil) {
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.CurrentX, TargetY: hm.CurrentY,
Speed: 0, Heading: 0,
})
}
hm.SyncToHero()
return
}
hm.TownLastNPCLingerUntil = time.Time{}
}
cx, cy := town.WorldX, town.WorldY
const plazaEps = 0.55
dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy)

@ -282,8 +282,8 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
}
// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI).
// With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable.
func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = graph
_ = now
cfg := tuning.Get()
inter := cfg.TownNPCInteractChance
@ -300,6 +300,13 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if h == nil {
return false
}
var town *model.Town
if graph != nil {
town = graph.Towns[hm.CurrentTownID]
}
townLv := TownEffectiveLevel(town)
const offlineServiceChance = 0.2
switch npc.Type {
case "merchant":
share := cfg.MerchantTownAutoSellShare
@ -315,22 +322,33 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
},
})
}
potionCost, _ := tuning.EffectiveNPCShopCosts()
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 {
h.Gold -= potionCost
h.Potions++
if al != nil {
gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance {
h.Gold -= gearCost
drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now)
if err != nil {
h.Gold += gearCost
s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err)
} else if al != nil && drop != nil {
townKey := ""
if town != nil {
townKey = town.NameKey
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedPotionFromNPC,
Args: map[string]any{"npcKey": npc.NameKey},
Code: model.LogPhraseBoughtGearTownMerchant,
Args: map[string]any{
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
},
})
}
}
case "healer":
_, healCost := tuning.EffectiveNPCShopCosts()
if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost {
potionCost, _ := tuning.EffectiveNPCShopCosts()
if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance {
h.Gold -= healCost
h.HP = h.MaxHP
if al != nil {
@ -342,6 +360,18 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
})
}
}
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < offlineServiceChance {
h.Gold -= potionCost
h.Potions++
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedPotionFromNPC,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
}
case "quest_giver":
if s.questStore == nil {
return true
@ -355,7 +385,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
for _, hq := range hqs {
taken[hq.QuestID] = struct{}{}
}
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level)
offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
if err != nil {
s.logger.Warn("offline town npc: list quests by npc", "error", err)
return true
@ -377,6 +407,17 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
}
return true
}
if rand.Float64() >= offlineServiceChance {
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
return true
}
pick := candidates[rand.Intn(len(candidates))]
ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID)
if err != nil {
@ -650,19 +691,9 @@ func HeroHasEquippedGear(h *model.Hero) bool {
return false
}
// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear.
// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.).
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
if h == nil {
return false
}
h.EnsureGearMap()
var c = 0
for _, it := range h.Gear {
if it != nil {
c++
}
}
return c > 4
return HeroHasEquippedGear(h)
}
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {

@ -0,0 +1,15 @@
package game
import "github.com/denisovdennis/autohero/internal/model"
// TownEffectiveLevel is the reference level for shop gear ilvl / quest bands (mid of town bracket).
func TownEffectiveLevel(t *model.Town) int {
if t == nil {
return 1
}
mid := (t.LevelMin + t.LevelMax) / 2
if mid < 1 {
return 1
}
return mid
}

@ -0,0 +1,154 @@
package game
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// RollTownMerchantStockItems generates `count` gear rows for town-tier stock (one roll per slot order, unique slots first).
func RollTownMerchantStockItems(refLevel int, count int) []*model.GearItem {
if count < 1 {
count = 1
}
slots := model.AllEquipmentSlots
if count > len(slots) {
count = len(slots)
}
perm := rand.Perm(len(slots))
out := make([]*model.GearItem, 0, count)
for i := 0; i < count; i++ {
slot := slots[perm[i]]
family := model.PickGearFamily(slot)
if family == nil {
continue
}
rarity := model.RollRarity()
ilvl := model.RollIlvl(refLevel, false)
out = append(out, model.NewGearItem(family, ilvl, rarity))
}
for len(out) < count {
slot := slots[rand.Intn(len(slots))]
family := model.PickGearFamily(slot)
if family == nil {
continue
}
rarity := model.RollRarity()
ilvl := model.RollIlvl(refLevel, false)
out = append(out, model.NewGearItem(family, ilvl, rarity))
}
return out
}
func townMerchantRarityPriceMul(r model.Rarity) float64 {
switch r {
case model.RarityLegendary:
return 3.4
case model.RarityEpic:
return 2.25
case model.RarityRare:
return 1.65
case model.RarityUncommon:
return 1.28
default:
return 1.0
}
}
// RollTownMerchantOfferGold returns a per-item list buy price: town anchor + ilvl (× rarity), then uniform ±variance%.
// Inventory sell prices stay on model.AutoSellPrice (runtime autoSell*); they are not derived from this value.
func RollTownMerchantOfferGold(ilvl int, rarity model.Rarity, townLevel int) int64 {
if ilvl < 1 {
ilvl = 1
}
anchor := tuning.EffectiveTownMerchantGearCost(townLevel)
perIlvl := tuning.EffectiveMerchantTownGearPricePerIlvl()
variance := tuning.EffectiveMerchantTownGearPriceVariancePct()
ilvlPart := float64(ilvl) * float64(perIlvl) * townMerchantRarityPriceMul(rarity)
curve := float64(ilvl*ilvl) / 6.0
mean := float64(anchor) + ilvlPart + curve
if mean < 1 {
mean = 1
}
v := float64(variance) / 100.0
if v < 0 {
v = 0
}
if v > 0.45 {
v = 0.45
}
// Uniform in [1v, 1+v] (e.g. v=0.15 → 85%..115% of mean).
factor := (1.0 - v) + rand.Float64()*(2*v)
cost := int64(mean*factor + 0.5)
if cost < 1 {
cost = 1
}
return cost
}
// ApplyPreparedTownMerchantPurchase persists a rolled item (id 0) and force-equips it.
func ApplyPreparedTownMerchantPurchase(ctx context.Context, gs *storage.GearStore, hero *model.Hero, item *model.GearItem, now time.Time) (*model.LootDrop, error) {
if gs == nil || hero == nil || item == nil {
return nil, errors.New("nil gear store, hero, or item")
}
toCreate := model.CloneGearItem(item)
if toCreate == nil {
return nil, errors.New("nil item clone")
}
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
err := gs.CreateItem(ctxCreate, toCreate)
cancel()
if err != nil {
return nil, fmt.Errorf("create gear: %w", err)
}
ctxEq, cancelEq := context.WithTimeout(ctx, 2*time.Second)
err = gs.EquipItem(ctxEq, hero.ID, toCreate.Slot, toCreate.ID)
cancelEq()
if err != nil {
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
_ = gs.DeleteGearItem(ctxDel, toCreate.ID)
cancelDel()
return nil, err
}
ctxLoad, cancelLoad := context.WithTimeout(ctx, 2*time.Second)
gear, err := gs.GetHeroGear(ctxLoad, hero.ID)
cancelLoad()
if err != nil {
return nil, fmt.Errorf("reload gear: %w", err)
}
ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second)
inv, err := gs.GetHeroInventory(ctxInv, hero.ID)
cancelInv()
if err != nil {
return nil, fmt.Errorf("reload inventory: %w", err)
}
hero.Gear = gear
hero.Inventory = inv
hero.RefreshDerivedCombatStats(now)
return &model.LootDrop{
ItemType: string(toCreate.Slot),
ItemID: toCreate.ID,
ItemName: toCreate.Name,
Rarity: toCreate.Rarity,
}, nil
}
// ApplyTownMerchantGearPurchase rolls one gear piece using refLevel for ilvl (town tier),
// persists it, and force-equips into the hero slot (previous piece moves to backpack — same as admin EquipItem).
func ApplyTownMerchantGearPurchase(ctx context.Context, gs *storage.GearStore, hero *model.Hero, refLevel int, now time.Time) (*model.LootDrop, error) {
items := RollTownMerchantStockItems(refLevel, 1)
if len(items) == 0 {
return nil, errors.New("failed to roll gear family")
}
return ApplyPreparedTownMerchantPurchase(ctx, gs, hero, items[0], now)
}

@ -818,17 +818,28 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
if hero == nil {
townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts()
cfg := tuning.Get()
gearBase := cfg.MerchantTownGearCostBase
if gearBase <= 0 {
gearBase = tuning.DefaultValues().MerchantTownGearCostBase
}
gearPer := cfg.MerchantTownGearCostPerTownLevel
if gearPer < 0 {
gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel
}
writeJSON(w, http.StatusOK, map[string]any{
"hero": nil,
"needsName": true,
"offlineReport": nil,
"mapRef": h.world.RefForLevel(1),
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
"hero": nil,
"needsName": true,
"offlineReport": nil,
"mapRef": h.world.RefForLevel(1),
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
})
return
}
@ -904,6 +915,15 @@ 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()
cfgT := tuning.Get()
gearBase := cfgT.MerchantTownGearCostBase
if gearBase <= 0 {
gearBase = tuning.DefaultValues().MerchantTownGearCostBase
}
gearPer := cfgT.MerchantTownGearCostPerTownLevel
if gearPer < 0 {
gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel
}
model.AttachDebuffCatalogForClient(hero)
@ -918,16 +938,18 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"hero": hero,
"needsName": needsName,
"offlineReport": report,
"mapRef": h.world.RefForLevel(hero.Level),
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"serverVersion": version.Version,
"showChangelog": showChangelog,
"changelog": changelogPayload,
"hero": hero,
"needsName": needsName,
"offlineReport": report,
"mapRef": h.world.RefForLevel(hero.Level),
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version,
"showChangelog": showChangelog,
"changelog": changelogPayload,
})
}

@ -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(refLevel, 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,
})
}

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
@ -162,7 +163,30 @@ func (h *QuestHandler) ListQuestsByNPC(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, npcID, hero.Level, limit, timeBucket)
npcRow, err := h.questStore.GetNPCByID(r.Context(), npcID)
if err != nil {
h.logger.Error("failed to get npc for npc quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to load npc",
})
return
}
if npcRow == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "npc not found"})
return
}
town, err := h.questStore.GetTown(r.Context(), npcRow.TownID)
if err != nil {
h.logger.Error("failed to get town for npc quests", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
return
}
if town == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
return
}
offerLevel := game.TownEffectiveLevel(town)
quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, offerLevel, limit, timeBucket)
if err != nil {
h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{

@ -26,6 +26,7 @@ const (
LogPhraseWanderingAlmsStashed = "log.wandering_alms_stashed"
LogPhraseHealedFullTown = "log.healed_full_town"
LogPhraseBoughtPotionTown = "log.bought_potion_town"
LogPhraseBoughtGearTownMerchant = "log.bought_gear_town_merchant"
LogPhraseSoldItemsMerchant = "log.sold_items_merchant"
LogPhraseNPCSkippedVisit = "log.npc_skipped_visit"
LogPhrasePurchasedPotionFromNPC = "log.purchased_potion_from_npc"

@ -43,20 +43,22 @@ func init() {
}
func seedBuffMap() map[BuffType]Buff {
// Magnitudes follow docs/specification.md §7.1, then weakened by ⅓ (×2/3) vs the prior canon.
// Shield applies only in combat.CalculateIncomingDamage (not defense stats).
return map[BuffType]Buff{
BuffRush: {
Type: BuffRush, Name: "Rush",
Duration: 5 * time.Minute, Magnitude: 0.50,
Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // was +50% move → ~+33%
CooldownDuration: 15 * time.Minute,
},
BuffRage: {
Type: BuffRage, Name: "Rage",
Duration: 3 * time.Minute, Magnitude: 1.00,
Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% damage
CooldownDuration: 10 * time.Minute,
},
BuffShield: {
Type: BuffShield, Name: "Shield",
Duration: 5 * time.Minute, Magnitude: 0.50,
Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% incoming
CooldownDuration: 12 * time.Minute,
},
BuffLuck: {
@ -66,22 +68,22 @@ func seedBuffMap() map[BuffType]Buff {
},
BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection",
Duration: 10 * time.Minute, Magnitude: 0.50,
Duration: 10 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% max HP
CooldownDuration: 30 * time.Minute,
},
BuffHeal: {
Type: BuffHeal, Name: "Heal",
Duration: 1 * time.Second, Magnitude: 0.50,
Duration: 1 * time.Second, Magnitude: 1.0 / 3.0, // ~+33% max HP
CooldownDuration: 5 * time.Minute,
},
BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion",
Duration: 5 * time.Minute, Magnitude: 1.50,
Duration: 5 * time.Minute, Magnitude: 1.0, // was +150% → +100% after ⅔ scaling
CooldownDuration: 20 * time.Minute,
},
BuffWarCry: {
Type: BuffWarCry, Name: "War Cry",
Duration: 3 * time.Minute, Magnitude: 1.00,
Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% attack speed
CooldownDuration: 10 * time.Minute,
},
}

@ -21,6 +21,16 @@ type GearItem struct {
SpecialEffect string `json:"specialEffect,omitempty"`
}
// CloneGearItem returns a deep copy with ID cleared (for ephemeral offers / re-persist).
func CloneGearItem(src *GearItem) *GearItem {
if src == nil {
return nil
}
c := *src
c.ID = 0
return &c
}
// GearFamily is a template for generating gear drops from the unified catalog.
type GearFamily struct {
Slot EquipmentSlot `json:"slot"`

@ -192,17 +192,10 @@ func (h *Hero) activeStatBonuses(now time.Time) statBonuses {
out.movementMultiplier *= (1 + ab.Buff.Magnitude)
case BuffRage:
out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 10
case BuffPowerPotion:
out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 12
case BuffWarCry:
out.speedMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 6
out.agilityBonus += 6
case BuffShield:
out.constitutionBonus += 10
out.defenseMultiplier *= (1 + ab.Buff.Magnitude)
}
}
return out

@ -85,33 +85,42 @@ func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
Strength: 10,
Constitution: 8,
Agility: 6,
Buffs: []ActiveBuff{
{
Buff: mustBuffDef(BuffRage),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
{
Buff: mustBuffDef(BuffWarCry),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
{
Buff: mustBuffDef(BuffShield),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
}
baseAtk := hero.EffectiveAttackAt(now)
baseDef := hero.EffectiveDefenseAt(now)
baseSpd := hero.EffectiveSpeedAt(now)
rageMag := mustBuffDef(BuffRage).Magnitude
warMag := mustBuffDef(BuffWarCry).Magnitude
hero.Buffs = []ActiveBuff{
{
Buff: mustBuffDef(BuffRage),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
{
Buff: mustBuffDef(BuffWarCry),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
{
Buff: mustBuffDef(BuffShield),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
}
if hero.EffectiveAttackAt(now) <= 30 {
t.Fatalf("expected buffed attack to increase above baseline")
wantAtk := int(float64(baseAtk) * (1 + rageMag))
if got := hero.EffectiveAttackAt(now); got != wantAtk {
t.Fatalf("expected attack %d (rage mult only), got %d", wantAtk, got)
}
if hero.EffectiveDefenseAt(now) <= 5 {
t.Fatalf("expected shield constitution bonus to increase defense")
if got := hero.EffectiveDefenseAt(now); got != baseDef {
t.Fatalf("shield must not change effective defense: base=%d got=%d", baseDef, got)
}
if hero.EffectiveSpeedAt(now) <= 1.0 {
t.Fatalf("expected war cry to increase attack speed")
wantSpd := baseSpd * (1 + warMag)
if got := hero.EffectiveSpeedAt(now); math.Abs(got-wantSpd) > 0.001 {
t.Fatalf("expected speed %.4f, got %.4f", wantSpd, got)
}
}

@ -24,6 +24,9 @@ type TownPausePersisted struct {
NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
// After the last NPC visit: stand near them until this time (paused while dialog UI lock is on).
TownLastNPCLingerUntil *time.Time `json:"townLastNpcLingerUntil,omitempty"`
// Plaza: walk to town center after NPC tour, then wait/rest before leaving.
TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
CenterWalkActive bool `json:"centerWalkActive,omitempty"`

@ -193,6 +193,9 @@ func New(deps Deps) *chi.Mux {
r.Post("/hero/npc-alms", npcH.NPCAlms)
r.Post("/hero/npc-heal", npcH.HealHero)
r.Post("/hero/npc-buy-potion", npcH.BuyPotion)
r.Post("/hero/npc-buy-town-gear", npcH.BuyTownMerchantGear)
r.Post("/hero/npc-dialog-pause", npcH.NPCDialogPause)
r.Post("/hero/npc-merchant-stock", npcH.MerchantStock)
// Gear routes.
r.Get("/hero/gear", gameH.GetHeroGear)

@ -33,7 +33,9 @@ type Values struct {
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
// TownLastNpcLingerMs: after the final NPC in the tour, stand near them this long before walking to the plaza (shifted while shop/quest UI is open).
TownLastNpcLingerMs int64 `json:"townLastNpcLingerMs"`
TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"`
TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
// TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach).
TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"`
@ -83,6 +85,15 @@ type Values struct {
NPCCostHeal int64 `json:"npcCostHeal"`
NPCCostPotion int64 `json:"npcCostPotion"`
NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"`
// MerchantTownGearCostBase / PerTownLevel: in-town merchant random gear (ilvl/rarity scale with town tier).
MerchantTownGearCostBase int64 `json:"merchantTownGearCostBase"`
MerchantTownGearCostPerTownLevel int64 `json:"merchantTownGearCostPerTownLevel"`
// MerchantTownStockCount: gear rows shown at in-town merchant (hard-capped small).
MerchantTownStockCount int `json:"merchantTownStockCount"`
// MerchantTownGearPricePerIlvl: gold multiplier for item level in town merchant pricing (before rarity and variance).
MerchantTownGearPricePerIlvl int64 `json:"merchantTownGearPricePerIlvl"`
// MerchantTownGearPriceVariancePct: uniform random ±% on the listed buy price (e.g. 15 → 85%115%).
MerchantTownGearPriceVariancePct int `json:"merchantTownGearPriceVariancePct"`
// QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests).
QuestOffersPerNPC int `json:"questOffersPerNPC"`
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
@ -266,6 +277,7 @@ func DefaultValues() Values {
TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000,
TownLastNpcLingerMs: 10_000,
TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65,
@ -307,6 +319,11 @@ func DefaultValues() Values {
NPCCostHeal: 100,
NPCCostPotion: 50,
NPCCostNearbyRadius: 3.0,
MerchantTownGearCostBase: 180,
MerchantTownGearCostPerTownLevel: 40,
MerchantTownStockCount: 3,
MerchantTownGearPricePerIlvl: 115,
MerchantTownGearPriceVariancePct: 15,
QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 2,
QuestOfferDrySpellChance: 0.20,
@ -329,7 +346,7 @@ func DefaultValues() Values {
EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0,
EnemyEncounterStatMultiplier: 1.2,
EnemyStatMultiplierVsUnequippedHero: 0.75,
EnemyStatMultiplierVsUnequippedHero: 0.85,
DebuffProcBurn: 0.18,
DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25,
@ -342,7 +359,8 @@ func DefaultValues() Values {
EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard,
SummonCycleSeconds: 18,
SummonDamageDivisor: 10,
LuckBuffMultiplier: 1.75,
// Spec §7.1 luck ×2.5, weakened by ⅓ → ×(5/3) on drop chances and gold amount when gold drops.
LuckBuffMultiplier: 5.0 / 3.0,
MinAttackIntervalMs: 250,
CombatPaceMultiplier: 14,
PotionHealPercent: 0.30,
@ -452,6 +470,61 @@ func EffectiveNPCShopCosts() (potionCost, healCost int64) {
return potionCost, healCost
}
// EffectiveTownMerchantGearCost returns a town-tier gold anchor (used in merchant pricing and legacy paths).
const merchantTownStockHardMax = 3
// EffectiveMerchantTownStockCount returns how many gear offers to roll at the town merchant (max 3).
func EffectiveMerchantTownStockCount() int {
n := Get().MerchantTownStockCount
if n <= 0 {
n = DefaultValues().MerchantTownStockCount
}
if n > merchantTownStockHardMax {
n = merchantTownStockHardMax
}
return n
}
func EffectiveTownMerchantGearCost(townLevel int) int64 {
cfg := Get()
base := cfg.MerchantTownGearCostBase
if base <= 0 {
base = DefaultValues().MerchantTownGearCostBase
}
per := cfg.MerchantTownGearCostPerTownLevel
if per < 0 {
per = DefaultValues().MerchantTownGearCostPerTownLevel
}
if townLevel < 1 {
townLevel = 1
}
return base + int64(townLevel)*per
}
// EffectiveMerchantTownGearPricePerIlvl returns the peritem-level gold factor for town merchant offers.
func EffectiveMerchantTownGearPricePerIlvl() int64 {
cfg := Get()
v := cfg.MerchantTownGearPricePerIlvl
if v <= 0 {
v = DefaultValues().MerchantTownGearPricePerIlvl
}
return v
}
// EffectiveMerchantTownGearPriceVariancePct returns ±% jitter (clamped) for town merchant prices.
func EffectiveMerchantTownGearPriceVariancePct() int {
cfg := Get()
v := cfg.MerchantTownGearPriceVariancePct
d := DefaultValues().MerchantTownGearPriceVariancePct
if v < 0 || v > 45 {
if d < 0 {
d = 15
}
return d
}
return v
}
// EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning.
func EffectiveQuestOffersPerNPC() int {
n := Get().QuestOffersPerNPC

@ -535,14 +535,14 @@ secondaryOut = round( baseSecondary × M(rarity) )
| Бафф | Эффект |
|------|--------|
| Рывок | +50% движение |
| Ярость | +100% урон |
| Щит | -50% входящий урон |
| Удача | x2.5 лут |
| Воскрешение | Воскрес 50% HP |
| Исцеление | +50% HP |
| Зелье силы | +150% урон |
| Боевой клич | +100% Attack speed |
| Рывок | ~+33% движение (канон: `magnitude` в каталоге баффов) |
| Ярость | ~+67% урон |
| Щит | ~33% входящий урон (только множитель к входящему; без бонуса к защите) |
| Удача | ×(5/3) (~×1,67) к шансам золота/предмета и к сумме золота при выпадении (см. §8.1) |
| Воскрешение | Воскрес ~33% max HP |
| Исцеление | ~+33% max HP мгновенно |
| Зелье силы | +100% урон |
| Боевой клич | ~+67% скорость атаки |
### 7.2 Дебаффы (6 штук)

@ -14,7 +14,6 @@ import {
buildMerchantLootDrop,
} from './game/ws-handler';
import {
ApiError,
initHero,
ackChangelog,
getAdventureLog,
@ -27,10 +26,8 @@ import {
abandonQuest,
getAchievements,
getNearbyHeroes,
buyPotion,
healAtNPC,
requestRevive,
defaultNpcShopCosts,
defaultNpcShopBundle,
npcShopCostsFromInit,
offlineReportHasActivity,
} from './network/api';
@ -241,6 +238,9 @@ function townToTownData(
worldX: town.worldX + n.offsetX,
worldY: town.worldY + n.offsetY,
buildingId: n.buildingId,
townId: town.id,
townLevelMin: town.levelMin,
townLevelMax: town.levelMax,
}));
return {
id: town.id,
@ -387,7 +387,7 @@ export function App() {
// Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
// Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]);
@ -908,6 +908,7 @@ export function App() {
setNearestNPC(null);
setNpcInteractionDismissed(null);
const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name;
const tw = townsRef.current.find((t) => t.id === p.townId);
setNpcVisitAwaitingProximity({
id: p.npcId,
name: displayName,
@ -915,6 +916,9 @@ export function App() {
type: p.type as NPCData['type'],
worldX: p.worldX ?? 0,
worldY: p.worldY ?? 0,
townId: p.townId,
townLevelMin: tw?.levelMin ?? 1,
townLevelMax: tw?.levelMax ?? 1,
});
},
@ -1120,16 +1124,16 @@ export function App() {
if (!alreadyActive) {
switch (type) {
case BuffType.Rage:
damage = Math.round(damage * 2);
damage = Math.round((damage * 5) / 3);
break;
case BuffType.PowerPotion:
damage = Math.round(damage * 2.5);
damage = Math.round(damage * 2);
break;
case BuffType.WarCry:
attackSpeed = Math.round(attackSpeed * 2 * 100) / 100;
attackSpeed = Math.round(((attackSpeed * 5) / 3) * 100) / 100;
break;
case BuffType.Heal:
hp = Math.min(maxHp, hp + Math.round(maxHp * 0.5));
hp = Math.min(maxHp, hp + Math.round(maxHp / 3));
break;
}
}
@ -1336,61 +1340,19 @@ export function App() {
const handleNPCViewQuests = useCallback((npc: NPCData) => {
const matchedNPC: NPC = {
id: npc.id,
townId: 0,
townId: npc.townId,
name: npc.name,
nameKey: npc.nameKey,
type: npc.type,
offsetX: 0,
offsetY: 0,
townLevelMin: npc.townLevelMin,
townLevelMax: npc.townLevelMax,
};
setSelectedNPC(matchedNPC);
setNpcInteractionDismissed(npc.id);
}, []);
const handleNPCBuyPotion = useCallback((_npc: NPCData) => {
const telegramId = getTelegramUserId() ?? 1;
buyPotion(telegramId)
.then((hero) => {
hapticImpact('medium');
setToast({ message: t(tr.boughtPotion, { cost: npcShopCosts.potionCost }), color: '#88dd88' });
handleNPCHeroUpdated(hero);
// Server logs purchase + WS
})
.catch((err) => {
console.warn('[App] Failed to buy potion:', err);
if (err instanceof ApiError) {
try {
const j = JSON.parse(err.body) as { error?: string };
setToast({ message: j.error ?? tr.failedToBuyPotion, color: '#ff4444' });
} catch {
setToast({ message: tr.failedToBuyPotion, color: '#ff4444' });
}
}
});
}, [handleNPCHeroUpdated, npcShopCosts.potionCost, tr]);
const handleNPCHeal = useCallback((npc: NPCData) => {
const telegramId = getTelegramUserId() ?? 1;
healAtNPC(telegramId, npc.id)
.then((hero) => {
hapticImpact('medium');
setToast({ message: tr.healedToFull, color: '#44cc44' });
handleNPCHeroUpdated(hero);
// Server logs heal + WS
})
.catch((err) => {
console.warn('[App] Failed to heal:', err);
if (err instanceof ApiError) {
try {
const j = JSON.parse(err.body) as { error?: string };
setToast({ message: j.error ?? tr.failedToHeal, color: '#ff4444' });
} catch {
setToast({ message: tr.failedToHeal, color: '#ff4444' });
}
}
});
}, [handleNPCHeroUpdated]);
const handleNPCInteractionDismiss = useCallback(() => {
if (nearestNPC) {
setNpcInteractionDismissed(nearestNPC.id);
@ -1539,12 +1501,8 @@ export function App() {
{showNPCInteraction && nearestNPC && (
<NPCInteraction
npc={nearestNPC}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
onViewQuests={handleNPCViewQuests}
onBuyPotion={handleNPCBuyPotion}
onHeal={handleNPCHeal}
onOpenServiceDialog={handleNPCViewQuests}
onDismiss={handleNPCInteractionDismiss}
/>
)}
@ -1557,6 +1515,7 @@ export function App() {
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }}
onClose={() => setSelectedNPC(null)}
onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated}

@ -247,7 +247,8 @@ function resolveAdventureLogVars(
case 'log.leveled_up':
return { level: intArg(a, 'level') };
case 'log.equipped_new':
case 'log.wandering_alms_equipped': {
case 'log.wandering_alms_equipped':
case 'log.bought_gear_town_merchant': {
const slot = slotName(tr, strArg(a, 'slot'));
const rarity = rarityName(tr, strArg(a, 'rarity'));
const legacyName = strArg(a, 'itemName');

@ -297,6 +297,9 @@ export interface NPC {
offsetX: number;
offsetY: number;
buildingId?: number;
/** Present when opened from map/town visit (shop tier). */
townLevelMin?: number;
townLevelMax?: number;
}
export interface Quest {
@ -337,6 +340,8 @@ export interface HeroQuest {
townName: string;
/** Resolved name for visit_town delivery target */
targetTownName?: string;
/** Same as quests.target_town_id — for localized destination label. */
targetTownId?: number;
}
// ---- Equipment Item (extended slots per §6.3) ----
@ -377,6 +382,10 @@ export interface NPCData {
worldX: number;
worldY: number;
buildingId?: number;
/** Town that owns this NPC (for shop tier / costs). */
townId: number;
townLevelMin: number;
townLevelMax: number;
}
/** Server-driven building placed in a town */

@ -7,18 +7,45 @@ export const WANDERING_MERCHANT_DIALOGUE_KEY = 'npc.wandering_merchant.dialogue.
type Bilingual = { en: string; ru: string };
/** Matches `towns.id` in DB / migrations (000026 name_key order). */
export const TOWN_ID_TO_NAME_KEY: Record<number, string> = {
1: 'town.willowdale.v1',
2: 'town.thornwatch.v1',
3: 'town.ashengard.v1',
4: 'town.redcliff.v1',
5: 'town.boghollow.v1',
6: 'town.cinderkeep.v1',
7: 'town.starfall.v1',
8: 'town.mossharbor.v1',
9: 'town.emberwell.v1',
10: 'town.frostmark.v1',
11: 'town.duskwatch.v1',
};
/** Localized town label from numeric `towns.id` (visit_town quest target, etc.). */
export function townDisplayById(
locale: Locale,
townId: number | undefined | null,
fallback: string,
): string {
if (townId == null || !Number.isFinite(townId)) return fallback;
const key = TOWN_ID_TO_NAME_KEY[Math.trunc(townId)];
return key ? townLabel(locale, key, fallback) : fallback;
}
/** Russian: pragmatic transliteration of English fantasy names (see docs/i18n-display-rules.md). */
const TOWNS: Record<string, Bilingual> = {
'town.willowdale.v1': { en: 'Willowdale', ru: 'Ивадол' },
'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Тернозорь' },
'town.ashengard.v1': { en: 'Ashengard', ru: 'Пепельный гард' },
'town.redcliff.v1': { en: 'Redcliff', ru: 'Красная скала' },
'town.boghollow.v1': { en: 'Boghollow', ru: 'Торфяная низина' },
'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Зола-крепость' },
'town.starfall.v1': { en: 'Starfall', ru: 'Звездопад' },
'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Мшистая гавань' },
'town.emberwell.v1': { en: 'Emberwell', ru: 'Угольный колодец' },
'town.frostmark.v1': { en: 'Frostmark', ru: 'Морозная метка' },
'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Сумеречный дозор' },
'town.willowdale.v1': { en: 'Willowdale', ru: 'Виллоудейл' },
'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Торнвотч' },
'town.ashengard.v1': { en: 'Ashengard', ru: 'Ашенгард' },
'town.redcliff.v1': { en: 'Redcliff', ru: 'Редклифф' },
'town.boghollow.v1': { en: 'Boghollow', ru: 'Богхоллоу' },
'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Синдеркип' },
'town.starfall.v1': { en: 'Starfall', ru: 'Старфолл' },
'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Моссхарбор' },
'town.emberwell.v1': { en: 'Emberwell', ru: 'Эмбервелл' },
'town.frostmark.v1': { en: 'Frostmark', ru: 'Фростмарк' },
'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Дасквотч' },
};
const NPCS: Record<string, Bilingual> = {

@ -52,14 +52,14 @@ ui:
buffHeal: Heal
buffPowerPotion: Power
buffWarCry: War Cry
buffRushDesc: "+50% movement speed"
buffRageDesc: "+100% damage"
buffShieldDesc: "-50% incoming damage"
buffLuckDesc: x2.5 loot drops
buffResurrectionDesc: Revive at 50% HP
buffHealDesc: "+50% HP instant"
buffPowerPotionDesc: "+150% damage"
buffWarCryDesc: "+100% attack speed"
buffRushDesc: "~+33% movement speed"
buffRageDesc: "~+67% damage"
buffShieldDesc: "~33% incoming damage"
buffLuckDesc: ~×1.67 gold/item chances and gold amount when gold drops
buffResurrectionDesc: Revive at ~33% max HP
buffHealDesc: "~+33% max HP instant"
buffPowerPotionDesc: "+100% damage"
buffWarCryDesc: "~+67% attack speed"
charges: Charges
refillsAt: Refills at
refill: Refill
@ -98,6 +98,15 @@ ui:
npc: NPC
buyPotion: Buy Potion
buyPotionForGold: Buy Potion ({cost}g)
openMerchantShop: Open Shop
openHealerServices: Healer
buyRandomGear: Buy Random Gear
merchantStockLoading: Loading shop…
merchantStockFailed: Could not open the shop. Try again.
buyGearForGold: Buy — {cost} gold
merchantEmptyStock: No offers left. Close and speak to the merchant again.
boughtGearFromMerchant: Bought and equipped gear for {cost} gold
failedToBuyGear: Failed to buy gear
healToFull: Heal to Full
healToFullForGold: Heal to Full ({cost}g)
viewQuests: View Quests
@ -200,6 +209,7 @@ adventure_log:
log.wandering_alms_stashed: Stashed {item} in your inventory.
log.healed_full_town: Paid for a full heal.
log.bought_potion_town: Bought a potion in town.
log.bought_gear_town_merchant: 'Bought from the trader and equipped: {item}.'
log.sold_items_merchant: Sold {count} items to {npc} (+{gold} gold).
log.npc_skipped_visit: Skipped visiting {npc}.
log.purchased_potion_from_npc: Bought a potion from {npc}.

@ -1,9 +1,14 @@
import { parse } from 'yaml';
import type { Locale } from './localeCodes';
import type { LocaleYamlDoc, Translations } from './types';
import type { LocaleYamlDoc, QuestLocaleBundle, Translations } from './types';
/** Parsed `en.yml` / `ru.yml` without bundled `quests.*.yml`. */
type MainLocaleYamlDoc = Omit<LocaleYamlDoc, 'quests'>;
import enRaw from './en.yml?raw';
import ruRaw from './ru.yml?raw';
import questsEnRaw from './quests.en.yml?raw';
import questsRuRaw from './quests.ru.yml?raw';
/** Matches backend townVisitLineSlugs (legacy town_visit.type.N → Nth slug). */
export const TOWN_VISIT_SLUG_ORDER: Record<string, readonly string[]> = {
@ -41,6 +46,40 @@ export const TOWN_VISIT_SLUG_ORDER: Record<string, readonly string[]> = {
],
};
function parseQuestBundle(raw: string, label: string): QuestLocaleBundle {
const doc = parse(raw) as unknown;
if (!doc || typeof doc !== 'object') {
throw new Error(`Invalid ${label} quest YAML root`);
}
const out: QuestLocaleBundle = {};
for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
if (!v || typeof v !== 'object') {
throw new Error(`${label} quests.${k}: expected title/description map`);
}
const o = v as Record<string, unknown>;
const title = typeof o.title === 'string' ? o.title.trim() : '';
const description = typeof o.description === 'string' ? o.description.trim() : '';
if (!title || !description) {
throw new Error(`${label} quests.${k}: missing title or description`);
}
out[k] = { title, description };
}
return out;
}
function assertQuestBundlesMatch(enQ: QuestLocaleBundle, ruQ: QuestLocaleBundle): void {
const ek = Object.keys(enQ).sort();
const rk = Object.keys(ruQ).sort();
if (ek.length !== rk.length) {
throw new Error(`quests: key count mismatch en=${ek.length} ru=${rk.length}`);
}
for (let i = 0; i < ek.length; i++) {
if (ek[i] !== rk[i]) {
throw new Error(`quests: sorted keys differ at ${i}: en=${ek[i]} ru=${rk[i]}`);
}
}
}
function assertRecordKeysMatch(name: string, a: Record<string, string>, b: Record<string, string>): void {
const ak = Object.keys(a).sort();
const bk = Object.keys(b).sort();
@ -54,7 +93,7 @@ function assertRecordKeysMatch(name: string, a: Record<string, string>, b: Recor
}
}
function loadDoc(raw: string): LocaleYamlDoc {
function loadDoc(raw: string): MainLocaleYamlDoc {
const doc = parse(raw) as unknown;
if (!doc || typeof doc !== 'object') {
throw new Error('Invalid locale YAML root');
@ -92,11 +131,17 @@ function loadDoc(raw: string): LocaleYamlDoc {
}
}
}
return { ui, adventure_log, achievements, roadside, town_npc_visit, enemy_types };
return { ui, adventure_log, achievements, roadside, town_npc_visit, enemy_types } satisfies MainLocaleYamlDoc;
}
export const enDoc = loadDoc(enRaw);
export const ruDoc = loadDoc(ruRaw);
const questsEn = parseQuestBundle(questsEnRaw, 'en');
const questsRu = parseQuestBundle(questsRuRaw, 'ru');
assertQuestBundlesMatch(questsEn, questsRu);
const mainEn = loadDoc(enRaw);
const mainRu = loadDoc(ruRaw);
export const enDoc: LocaleYamlDoc = { ...mainEn, quests: questsEn };
export const ruDoc: LocaleYamlDoc = { ...mainRu, quests: questsRu };
assertRecordKeysMatch('enemy_types', enDoc.enemy_types, ruDoc.enemy_types);
assertRecordKeysMatch('roadside', enDoc.roadside, ruDoc.roadside);
@ -114,6 +159,21 @@ export function achievementLogTitle(locale: Locale, achievementId: string): stri
return m[achievementId] ?? '';
}
/** quest_key from DB → title or description; falls back to server string. */
export function localizedQuestText(
locale: Locale,
questKey: string | undefined,
part: 'title' | 'description',
fallback: string,
): string {
if (!questKey) return fallback;
const bundle = locale === 'ru' ? ruDoc.quests : enDoc.quests;
const row = bundle[questKey];
if (!row) return fallback;
const s = part === 'title' ? row.title : row.description;
return s?.trim() ? s : fallback;
}
/** Localized display name for DB `enemies.type` slug; empty if unknown. */
export function enemyTypeLabel(locale: Locale, enemyTypeSlug: string): string {
const slug = enemyTypeSlug?.trim() ?? '';

@ -1,20 +1 @@
import type { Locale } from './localeCodes';
/** Optional per-quest overrides; keys match `quest_key` from DB (e.g. quest.12). */
const BUNDLES: Record<
string,
{ title: { en: string; ru: string }; description: { en: string; ru: string } }
> = {};
export function localizedQuestText(
locale: Locale,
questKey: string | undefined,
part: 'title' | 'description',
fallback: string,
): string {
if (!questKey) return fallback;
const b = BUNDLES[questKey];
if (!b) return fallback;
const piece = b[part];
return locale === 'ru' ? (piece.ru || fallback) : (piece.en || fallback);
}
export { localizedQuestText } from './loadLocales';

@ -52,14 +52,14 @@ ui:
buffHeal: 'Исцеление'
buffPowerPotion: 'Сила'
buffWarCry: 'Клич'
buffRushDesc: '+50% к скорости движения'
buffRageDesc: '+100% к урону'
buffShieldDesc: '-50% входящего урона'
buffLuckDesc: 'x2.5 дроп предметов'
buffResurrectionDesc: 'Воскрешение с 50% HP'
buffHealDesc: '+50% HP мгновенно'
buffPowerPotionDesc: '+150% к урону'
buffWarCryDesc: '+100% к скорости атаки'
buffRushDesc: '~+33% к скорости движения'
buffRageDesc: '~+67% к урону'
buffShieldDesc: '~33% входящего урона'
buffLuckDesc: '~×1,67 к шансам золота/предмета и к сумме золота при выпадении'
buffResurrectionDesc: 'Воскрешение с ~33% max HP'
buffHealDesc: '~+33% max HP мгновенно'
buffPowerPotionDesc: '+100% к урону'
buffWarCryDesc: '~+67% к скорости атаки'
charges: 'Заряды'
refillsAt: 'Обновление в'
refill: 'Пополнить'
@ -98,6 +98,15 @@ ui:
npc: 'NPC'
buyPotion: 'Купить зелье'
buyPotionForGold: 'Купить зелье ({cost}з)'
openMerchantShop: 'Лавка'
openHealerServices: 'Целитель'
buyRandomGear: 'Случайная экипировка'
merchantStockLoading: 'Загрузка лавки…'
merchantStockFailed: 'Не удалось открыть лавку. Попробуйте снова.'
buyGearForGold: 'Купить — {cost} золота'
merchantEmptyStock: 'Предложений нет. Закройте окно и снова поговорите с торговцем.'
boughtGearFromMerchant: 'Куплено и надето снаряжение за {cost} золота'
failedToBuyGear: 'Не удалось купить снаряжение'
healToFull: 'Исцелить полностью'
healToFullForGold: 'Полное лечение ({cost}з)'
viewQuests: 'Квесты'
@ -200,6 +209,7 @@ adventure_log:
log.wandering_alms_stashed: '{item} убрано в инвентарь.'
log.healed_full_town: 'Оплачено полное лечение.'
log.bought_potion_town: 'Куплено зелье в городе.'
log.bought_gear_town_merchant: 'Куплено у торговца и надето: {item}.'
log.sold_items_merchant: 'Продано предметов: {count} торговцу {npc} (+{gold} золота).'
log.npc_skipped_visit: 'Пропущена встреча с {npc}.'
log.purchased_potion_from_npc: 'Куплено зелье у {npc}.'

@ -98,6 +98,15 @@ export interface Translations {
npc: string;
buyPotion: string;
buyPotionForGold: string;
openMerchantShop: string;
openHealerServices: string;
buyRandomGear: string;
merchantStockLoading: string;
merchantStockFailed: string;
buyGearForGold: string;
merchantEmptyStock: string;
boughtGearFromMerchant: string;
failedToBuyGear: string;
healToFull: string;
healToFullForGold: string;
viewQuests: string;
@ -181,6 +190,9 @@ export interface Translations {
export type TranslationKey = keyof Translations;
/** quest_key from DB → localized title/description (see quests.*.yml). */
export type QuestLocaleBundle = Record<string, { title: string; description: string }>;
export interface LocaleYamlDoc {
ui: Translations;
adventure_log: Record<string, string>;
@ -191,4 +203,5 @@ export interface LocaleYamlDoc {
town_npc_visit: Record<string, Record<string, string>>;
/** DB `enemies.type` slug → display name (en / ru). */
enemy_types: Record<string, string>;
quests: QuestLocaleBundle;
}

@ -235,10 +235,12 @@ export interface InitHeroResponse {
offlineReport: OfflineReport | null;
mapRef: MapRefResponse;
needsName?: boolean;
/** Runtime tuning: merchant potion price (from DB / runtime_config). */
/** Runtime tuning: healer potion price (from DB / runtime_config). */
npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: number;
merchantTownGearCostBase?: number;
merchantTownGearCostPerTownLevel?: number;
/** Server build id; bump on backend with changelog entry to show the modal. */
serverVersion?: string;
/** True when there is a changelog entry for serverVersion and the player has not ack'd yet. */
@ -251,13 +253,31 @@ export function defaultNpcShopCosts(): { potionCost: number; healCost: number }
return { potionCost: 50, healCost: 100 };
}
export function npcShopCostsFromInit(res: InitHeroResponse): { potionCost: number; healCost: number } {
const d = defaultNpcShopCosts();
export function defaultMerchantGearCosts(): {
merchantGearCostBase: number;
merchantGearCostPerTownLevel: number;
} {
return { merchantGearCostBase: 35, merchantGearCostPerTownLevel: 6 };
}
export type NpcShopBundle = ReturnType<typeof defaultNpcShopBundle>;
export function defaultNpcShopBundle() {
return { ...defaultNpcShopCosts(), ...defaultMerchantGearCosts() };
}
export function npcShopCostsFromInit(res: InitHeroResponse): NpcShopBundle {
const d = defaultNpcShopBundle();
const p = res.npcCostPotion;
const h = res.npcCostHeal;
const gb = res.merchantTownGearCostBase;
const gp = res.merchantTownGearCostPerTownLevel;
return {
potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost,
healCost: typeof h === 'number' && h > 0 ? h : d.healCost,
merchantGearCostBase: typeof gb === 'number' && gb > 0 ? gb : d.merchantGearCostBase,
merchantGearCostPerTownLevel:
typeof gp === 'number' && gp >= 0 ? gp : d.merchantGearCostPerTownLevel,
};
}
@ -592,6 +612,7 @@ function flattenHeroQuest(raw: HeroQuestRaw): HeroQuest {
type: raw.type ?? q?.type ?? '',
targetCount: raw.targetCount ?? q?.targetCount ?? 0,
targetTownName: raw.quest?.targetTownName ?? q?.targetTownName,
targetTownId: q?.targetTownId != null ? Number(q.targetTownId) : undefined,
progress: raw.progress,
status: (raw.status as HeroQuest['status']) ?? 'accepted',
rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0,
@ -629,16 +650,79 @@ export async function abandonQuest(heroQuestId: number, telegramId?: number): Pr
// ---- NPC Services ----
/** Buy a potion from a merchant NPC (matches backend POST /api/v1/hero/npc-buy-potion) */
export async function buyPotion(telegramId?: number): Promise<HeroResponse> {
/** Buy a potion from a healer NPC (POST /hero/npc-buy-potion; hero position must be inside the town). */
export async function buyPotion(
body: { npcId: number; positionX: number; positionY: number },
telegramId?: number,
): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/npc-buy-potion${query}`, body);
}
/** Server merchant offer row (id 0 until purchased). */
export interface MerchantGearOfferItem {
id: number;
slot: string;
formId: string;
name: string;
subtype: string;
rarity: string;
ilvl: number;
basePrimary: number;
primaryStat: number;
statType: string;
speedModifier: number;
critChance: number;
agilityBonus: number;
setName?: string;
specialEffect?: string;
/** Gold for this row, fixed when the shop opened. */
cost: number;
}
export interface MerchantStockResponse {
items: MerchantGearOfferItem[];
}
/** Freeze town NPC visit timers while quest/shop UI is open (POST /hero/npc-dialog-pause). */
export async function setNPCDialogPause(
open: boolean,
telegramId?: number,
opts?: { advanceTownVisit?: boolean },
): Promise<void> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
const body: { open: boolean; advanceTownVisit?: boolean } = { open };
if (!open && opts?.advanceTownVisit) {
body.advanceTownVisit = true;
}
await apiPost<{ ok: boolean }>(`/hero/npc-dialog-pause${query}`, body);
}
/** Roll merchant stock for this town tier (POST /hero/npc-merchant-stock). */
export async function fetchMerchantStock(
body: { npcId: number; positionX: number; positionY: number },
telegramId?: number,
): Promise<MerchantStockResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/npc-buy-potion${query}`);
return apiPost<MerchantStockResponse>(`/hero/npc-merchant-stock${query}`, body);
}
/** Heal to full at a healer NPC (matches backend POST /api/v1/hero/npc-heal; body must be JSON with npcId) */
export async function healAtNPC(telegramId?: number, npcId?: number): Promise<HeroResponse> {
/** Buy one row from current merchant stock; equips immediately (POST /hero/npc-buy-town-gear). */
export async function buyTownMerchantGear(
body: { npcId: number; positionX: number; positionY: number; offerIndex: number },
telegramId?: number,
): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/npc-buy-town-gear${query}`, body);
}
/** Heal to full at a healer NPC (POST /hero/npc-heal; include position when npcId is set). */
export async function healAtNPC(
body: { npcId: number; positionX: number; positionY: number },
telegramId?: number,
): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiPost<HeroResponse>(`/hero/npc-heal${query}`, { npcId: npcId ?? 0 });
return apiPost<HeroResponse>(`/hero/npc-heal${query}`, body);
}
// ---- NPC Proximity & Interaction ----

@ -1,7 +1,18 @@
import { useState, useEffect, useCallback, type CSSProperties } from 'react';
import type { NPC, Quest, HeroQuest } from '../game/types';
import { getNPCQuests, acceptQuest, claimQuest, buyPotion, healAtNPC } from '../network/api';
import type { HeroResponse } from '../network/api';
import {
ApiError,
getNPCQuests,
acceptQuest,
claimQuest,
buyPotion,
buyTownMerchantGear,
healAtNPC,
setNPCDialogPause,
fetchMerchantStock,
} from '../network/api';
import type { HeroResponse, MerchantGearOfferItem } from '../network/api';
import { RARITY_COLORS, RARITY_GLOW } from '../shared/constants';
import { getTelegramUserId } from '../shared/telegram';
import { hapticImpact } from '../shared/telegram';
import { useT, t, useLocale } from '../i18n';
@ -16,6 +27,7 @@ interface NPCDialogProps {
heroGold: number;
potionCost: number;
healCost: number;
getHeroWorldPosition: () => { x: number; y: number };
onClose: () => void;
onQuestsChanged: () => void;
onHeroUpdated: (hero: HeroResponse) => void;
@ -234,6 +246,36 @@ function questTypeIcon(type: string): string {
}
}
function prettyEquipmentSlot(slot: string): string {
return slot
.split('_')
.map((p) => (p.length ? p.charAt(0).toUpperCase() + p.slice(1) : p))
.join(' ');
}
function gearStatLabel(tr: ReturnType<typeof useT>, statType: string): string {
switch (statType) {
case 'attack':
return tr.atk;
case 'defense':
return tr.def;
case 'speed':
return tr.spd;
default:
return tr.stat;
}
}
function offerRarityStyle(rarity: string): { color: string; textShadow?: string } {
const r = rarity.toLowerCase();
const color = RARITY_COLORS[r] ?? '#9d9d9d';
const glow = RARITY_GLOW[r];
if (glow && glow !== 'none') {
return { color, textShadow: glow };
}
return { color };
}
// ---- Component ----
export function NPCDialog({
@ -242,6 +284,7 @@ export function NPCDialog({
heroGold,
potionCost,
healCost,
getHeroWorldPosition,
onClose,
onQuestsChanged,
onHeroUpdated,
@ -253,9 +296,54 @@ export function NPCDialog({
const npcDisplayName = npcLabel(locale, npc.nameKey, npc.name);
const [availableQuests, setAvailableQuests] = useState<Quest[]>([]);
const [loading, setLoading] = useState(false);
const [merchantOffers, setMerchantOffers] = useState<MerchantGearOfferItem[]>([]);
const [merchantStockLoading, setMerchantStockLoading] = useState(false);
const [merchantStockError, setMerchantStockError] = useState(false);
const telegramId = getTelegramUserId() ?? 1;
// Pause town NPC visit timers while any NPC dialog (shop / quests / healer) is open.
useEffect(() => {
let cancelled = false;
setNPCDialogPause(true, telegramId).catch((err) => {
if (!cancelled) console.warn('[NPCDialog] npc-dialog-pause (open) failed:', err);
});
return () => {
cancelled = true;
setNPCDialogPause(false, telegramId, { advanceTownVisit: true }).catch((err) => {
console.warn('[NPCDialog] npc-dialog-pause (close) failed:', err);
});
};
}, [telegramId]);
// Merchant: roll stock when the shop opens (server-tier gear for this town).
useEffect(() => {
if (npc.type !== 'merchant') return;
let cancelled = false;
setMerchantStockLoading(true);
setMerchantStockError(false);
const pos = getHeroWorldPosition();
fetchMerchantStock({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId)
.then((res) => {
if (cancelled) return;
setMerchantOffers(Array.isArray(res.items) ? res.items : []);
})
.catch((err) => {
if (cancelled) return;
console.warn('[NPCDialog] merchant stock failed:', err);
setMerchantStockError(true);
setMerchantOffers([]);
})
.finally(() => {
if (!cancelled) setMerchantStockLoading(false);
});
return () => {
cancelled = true;
};
// Intentionally depend on npc identity only — position is read once when the dialog opens.
// eslint-disable-next-line react-hooks/exhaustive-deps -- getHeroWorldPosition may be unstable from parent
}, [npc.id, npc.type, telegramId]);
// Fetch available quests for quest giver NPCs
useEffect(() => {
if (npc.type !== 'quest_giver') return;
@ -318,7 +406,8 @@ export function NPCDialog({
onToast(tr.notEnoughGold, '#ff4444');
return;
}
buyPotion(telegramId)
const pos = getHeroWorldPosition();
buyPotion({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId)
.then((hero) => {
hapticImpact('medium');
onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88');
@ -326,16 +415,66 @@ export function NPCDialog({
})
.catch((err) => {
console.warn('[NPCDialog] Failed to buy potion:', err);
onToast(tr.failedToBuyPotion, '#ff4444');
if (err instanceof ApiError) {
try {
const j = JSON.parse(err.body) as { error?: string };
onToast(j.error ?? tr.failedToBuyPotion, '#ff4444');
} catch {
onToast(tr.failedToBuyPotion, '#ff4444');
}
} else {
onToast(tr.failedToBuyPotion, '#ff4444');
}
});
}, [telegramId, heroGold, potionCost, onHeroUpdated, onToast, tr]);
}, [telegramId, heroGold, potionCost, npc.id, getHeroWorldPosition, onHeroUpdated, onToast, tr]);
const handleBuyTownGear = useCallback(
(offerIndex: number) => {
const row = merchantOffers[offerIndex];
const rowCost = typeof row?.cost === 'number' ? row.cost : 0;
if (rowCost <= 0) {
onToast(tr.failedToBuyGear, '#ff4444');
return;
}
if (heroGold < rowCost) {
onToast(tr.notEnoughGold, '#ff4444');
return;
}
const pos = getHeroWorldPosition();
buyTownMerchantGear(
{ npcId: npc.id, positionX: pos.x, positionY: pos.y, offerIndex },
telegramId,
)
.then((hero) => {
hapticImpact('medium');
onToast(t(tr.boughtGearFromMerchant, { cost: rowCost }), '#c9a227');
onHeroUpdated(hero);
setMerchantOffers((prev) => prev.filter((_, i) => i !== offerIndex));
})
.catch((err) => {
console.warn('[NPCDialog] Failed to buy gear:', err);
if (err instanceof ApiError) {
try {
const j = JSON.parse(err.body) as { error?: string };
onToast(j.error ?? tr.failedToBuyGear, '#ff4444');
} catch {
onToast(tr.failedToBuyGear, '#ff4444');
}
} else {
onToast(tr.failedToBuyGear, '#ff4444');
}
});
},
[telegramId, heroGold, merchantOffers, npc.id, getHeroWorldPosition, onHeroUpdated, onToast, tr],
);
const handleHeal = useCallback(() => {
if (heroGold < healCost) {
onToast(tr.notEnoughGold, '#ff4444');
return;
}
healAtNPC(telegramId, npc.id)
const pos = getHeroWorldPosition();
healAtNPC({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId)
.then((hero) => {
hapticImpact('medium');
onToast(tr.healedToFull, '#44cc44');
@ -343,9 +482,18 @@ export function NPCDialog({
})
.catch((err) => {
console.warn('[NPCDialog] Failed to heal:', err);
onToast(tr.failedToHeal, '#ff4444');
if (err instanceof ApiError) {
try {
const j = JSON.parse(err.body) as { error?: string };
onToast(j.error ?? tr.failedToHeal, '#ff4444');
} catch {
onToast(tr.failedToHeal, '#ff4444');
}
} else {
onToast(tr.failedToHeal, '#ff4444');
}
});
}, [telegramId, heroGold, healCost, onHeroUpdated, onToast, npc.id, tr]);
}, [telegramId, heroGold, healCost, npc.id, getHeroWorldPosition, onHeroUpdated, onToast, tr]);
// Quests relevant to this NPC
const npcHeroQuests = heroQuests.filter(
@ -540,6 +688,76 @@ export function NPCDialog({
{npc.type === 'merchant' && (
<>
<div style={sectionTitleStyle}>{tr.shopLabel}</div>
{merchantStockLoading ? (
<div style={{ color: '#666', fontSize: 12, textAlign: 'center', padding: 16 }}>
{tr.merchantStockLoading}
</div>
) : merchantStockError ? (
<div style={{ color: '#c66', fontSize: 12, textAlign: 'center', padding: 16 }}>
{tr.merchantStockFailed}
</div>
) : merchantOffers.length === 0 ? (
<div style={{ color: '#666', fontSize: 12, textAlign: 'center', padding: 16 }}>
{tr.merchantEmptyStock}
</div>
) : (
merchantOffers.map((item, offerIndex) => {
const rc = offerRarityStyle(item.rarity);
const critPct = Math.round((item.critChance ?? 0) * 100);
const spd = typeof item.speedModifier === 'number' ? item.speedModifier.toFixed(2) : '?';
const rowCost = typeof item.cost === 'number' ? item.cost : 0;
const canBuy = rowCost > 0 && heroGold >= rowCost;
return (
<div
key={`${item.formId}-${offerIndex}-${item.name}`}
style={{
...questCardStyle,
borderColor: 'rgba(200, 180, 80, 0.2)',
}}
>
<div style={questTitleRow}>
<span style={{ fontSize: 14 }}>{'\u2694\uFE0F'}</span>
<span style={{ ...questTitleText, ...rc }}>{item.name}</span>
</div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{item.rarity.charAt(0).toUpperCase() + item.rarity.slice(1)} · ilvl {item.ilvl} ·{' '}
{prettyEquipmentSlot(item.slot)}
{item.subtype ? ` · ${item.subtype}` : ''}
</div>
<div style={{ ...questDescStyle, marginTop: 6 }}>
{gearStatLabel(tr, item.statType)} {item.primaryStat}
{' · '}
crit {critPct}%
{' · '}
{tr.spd} ×{spd}
{(item.agilityBonus ?? 0) > 0 ? ` · +${item.agilityBonus} AGI` : ''}
</div>
<button
type="button"
style={
canBuy
? { ...serviceBtnStyle, backgroundColor: 'rgba(200, 180, 80, 0.2)', color: '#e8c547' }
: { ...disabledBtnStyle, backgroundColor: 'rgba(200, 180, 80, 0.1)', color: '#e8c547' }
}
onClick={() => handleBuyTownGear(offerIndex)}
disabled={!canBuy}
>
{rowCost > 0 ? t(tr.buyGearForGold, { cost: rowCost }) : tr.failedToBuyGear}
</button>
</div>
);
})
)}
<div style={{ fontSize: 11, color: '#666', textAlign: 'center', marginTop: 6 }}>
{t(tr.yourGoldLabel, { amount: heroGold })}
</div>
</>
)}
{/* ---- Healer ---- */}
{npc.type === 'healer' && (
<>
<div style={sectionTitleStyle}>{tr.servicesSection}</div>
<button
style={
heroGold >= potionCost
@ -551,16 +769,6 @@ export function NPCDialog({
>
{'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {potionCost} {tr.gold}
</button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
{t(tr.yourGoldLabel, { amount: heroGold })}
</div>
</>
)}
{/* ---- Healer ---- */}
{npc.type === 'healer' && (
<>
<div style={sectionTitleStyle}>{tr.servicesSection}</div>
<button
style={
heroGold >= healCost

@ -1,17 +1,14 @@
import { useCallback, type CSSProperties } from 'react';
import type { NPCData } from '../game/types';
import { useT, t } from '../i18n';
import { useT } from '../i18n';
// ---- Types ----
interface NPCInteractionProps {
npc: NPCData;
heroGold: number;
potionCost: number;
healCost: number;
onViewQuests: (npc: NPCData) => void;
onBuyPotion: (npc: NPCData) => void;
onHeal: (npc: NPCData) => void;
/** Open shop (merchant) or services (healer) dialog. */
onOpenServiceDialog: (npc: NPCData) => void;
onDismiss: () => void;
}
@ -102,12 +99,8 @@ function npcColor(
export function NPCInteraction({
npc,
heroGold,
potionCost,
healCost,
onViewQuests,
onBuyPotion,
onHeal,
onOpenServiceDialog,
onDismiss,
}: NPCInteractionProps) {
const tr = useT();
@ -119,32 +112,25 @@ export function NPCInteraction({
onViewQuests(npc);
break;
case 'merchant':
onBuyPotion(npc);
break;
case 'healer':
onHeal(npc);
onOpenServiceDialog(npc);
break;
}
}, [npc, onViewQuests, onBuyPotion, onHeal]);
}, [npc, onViewQuests, onOpenServiceDialog]);
const actionLabel = (() => {
switch (npc.type) {
case 'quest_giver':
return tr.viewQuests;
case 'merchant':
return t(tr.buyPotionForGold, { cost: potionCost });
return tr.openMerchantShop;
case 'healer':
return t(tr.healToFullForGold, { cost: healCost });
return tr.openHealerServices;
default:
return tr.npcInteractTalk;
}
})();
const canAfford =
npc.type === 'quest_giver' ||
(npc.type === 'merchant' && heroGold >= potionCost) ||
(npc.type === 'healer' && heroGold >= healCost);
return (
<>
<style>{`
@ -189,21 +175,20 @@ export function NPCInteraction({
<button
style={{
...actionBtnStyle,
backgroundColor: canAfford
? (npc.type === 'quest_giver' ? 'rgba(68, 170, 255, 0.2)' :
npc.type === 'merchant' ? 'rgba(68, 200, 68, 0.2)' :
'rgba(200, 68, 68, 0.2)')
: 'rgba(100, 100, 100, 0.15)',
color: canAfford
? (npc.type === 'quest_giver' ? '#66bbff' :
npc.type === 'merchant' ? '#88dd88' :
'#ff8888')
: '#666',
opacity: canAfford ? 1 : 0.5,
cursor: canAfford ? 'pointer' : 'default',
backgroundColor:
npc.type === 'quest_giver' ? 'rgba(68, 170, 255, 0.2)' :
npc.type === 'merchant' ? 'rgba(68, 200, 68, 0.2)' :
npc.type === 'healer' ? 'rgba(200, 68, 68, 0.2)' :
'rgba(100, 100, 100, 0.15)',
color:
npc.type === 'quest_giver' ? '#66bbff' :
npc.type === 'merchant' ? '#88dd88' :
npc.type === 'healer' ? '#ff8888' :
'#666',
cursor: 'pointer',
}}
onClick={canAfford ? handleAction : undefined}
disabled={!canAfford}
onClick={handleAction}
type="button"
>
{actionLabel}
</button>

@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, type CSSProperties } from 'react';
import type { HeroQuest } from '../game/types';
import { useT, useLocale } from '../i18n';
import { localizedQuestText } from '../i18n/questCopy';
import { townDisplayById } from '../i18n/contentLabels';
// ---- Types ----
@ -284,9 +285,14 @@ export function QuestLogList({ quests, onClaim, onAbandon, claimDisabled = false
<div style={descriptionStyle}>
{localizedQuestText(locale, q.questKey, 'description', q.description)}
</div>
{q.type === 'visit_town' && q.targetTownName ? (
{q.type === 'visit_town' && (q.targetTownId != null || q.targetTownName) ? (
<div style={{ ...descriptionStyle, color: '#9bdcff', fontSize: 11 }}>
{tr.questDestination}: {q.targetTownName}
{tr.questDestination}:{' '}
{townDisplayById(
locale,
q.targetTownId,
q.targetTownName ?? '',
)}
</div>
) : null}
<div style={{ ...descriptionStyle, color: '#777', fontSize: 10 }}>

Loading…
Cancel
Save