npc logic update

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

@ -47,7 +47,7 @@ alwaysApply: true
## Buffs / debuffs ## 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) ## Loot and gold (§8)

@ -70,7 +70,7 @@ func damageRollMultiplier(minRoll, maxRoll float64) float64 {
return minRoll + rand.Float64()*(maxRoll-minRoll) 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 { func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int {
dmg := float64(rawDamage) dmg := float64(rawDamage)
@ -87,7 +87,7 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []
continue continue
} }
if ad.Debuff.Type == model.DebuffWeaken { 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) mult := LuckMultiplier(hero, now)
if mult != 1.75 { if mult != want {
t.Fatalf("expected luck multiplier 1.75, got %.2f", mult) 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 { func mustBuffDef(bt model.BuffType) model.Buff {
b, ok := model.BuffDefinition(bt) b, ok := model.BuffDefinition(bt)
if !ok { 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. // 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 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. // EngineStatus contains a snapshot of the engine's operational state.
type EngineStatus struct { type EngineStatus struct {
Running bool `json:"running"` Running bool `json:"running"`
@ -83,6 +91,9 @@ type Engine struct {
heroSubscriber func(heroID int64) bool heroSubscriber func(heroID int64) bool
// lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber. // lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber.
lastDisconnectedFullSave map[int64]time.Time 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. // 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, eventCh: eventCh,
logger: logger, logger: logger,
lastDisconnectedFullSave: make(map[int64]time.Time), lastDisconnectedFullSave: make(map[int64]time.Time),
merchantStock: make(map[int64]*merchantOfferSession),
} }
heap.Init(&e.queue) heap.Init(&e.queue)
return e return e
@ -1155,9 +1167,8 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
} }
} }
// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into // ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state.
// the live movement session and pushes hero_state when a sender is configured. func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) {
func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
if hero == nil { if hero == nil {
return return
} }
@ -1167,7 +1178,6 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
hm, ok := e.movements[hero.ID] hm, ok := e.movements[hero.ID]
if ok { if ok {
now := time.Now() now := time.Now()
hm.WanderingMerchantDeadline = time.Time{}
*hm.Hero = *hero *hm.Hero = *hero
hm.Hero.EnsureGearMap() hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now) 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 // 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), // 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. // 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.State = hero.State
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.LastMoveTick = now hm.LastMoveTick = now
hm.refreshSpeed(now) hm.refreshSpeed(now)
@ -1262,6 +1288,7 @@ func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) {
now := time.Now() now := time.Now()
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
*hm.Hero = *hero *hm.Hero = *hero
hm.State = model.StateDead hm.State = model.StateDead
hm.Hero.State = model.StateDead hm.Hero.State = model.StateDead
@ -1959,3 +1986,113 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
IsElite: e.IsElite, 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 TownVisitStartedAt time.Time
TownVisitLogsEmitted int 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 schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time.Time 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: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
TownLeaveAt time.Time 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: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool TownPlazaHealActive bool
@ -158,6 +163,7 @@ type townPausePersistSignature struct {
InTownNPCQueueFP uint64 InTownNPCQueueFP uint64
InTownVisitName string InTownVisitName string
InTownVisitType string InTownVisitType string
InTownLastNPCLinger time.Time
} }
func npcQueueFingerprint(q []int64) uint64 { 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.RestUntil = shift(hm.RestUntil)
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil)
hm.TownLeaveAt = shift(hm.TownLeaveAt) hm.TownLeaveAt = shift(hm.TownLeaveAt)
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
@ -806,6 +813,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
hm.clearTownCenterWalk() hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false hm.TownPlazaHealActive = false
hm.TownLeaveAt = time.Time{} hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
if graph != nil && hm.CurrentTownID == 0 { if graph != nil && hm.CurrentTownID == 0 {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) 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.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0 hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{} hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownRestHealRemainder = 0 hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{} hm.Excursion = model.ExcursionSession{}
hm.ActiveRestKind = model.RestKindNone hm.ActiveRestKind = model.RestKindNone
@ -1164,6 +1173,7 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownVisitStartedAt = time.Time{} hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0 hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{} hm.TownLeaveAt = time.Time{}
hm.TownLastNPCLingerUntil = time.Time{}
hm.TownRestHealRemainder = 0 hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{} hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone hm.ActiveRestKind = model.RestKindNone
@ -1390,6 +1400,7 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue) sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
sig.InTownVisitName = hm.TownVisitNPCName sig.InTownVisitName = hm.TownVisitNPCName
sig.InTownVisitType = hm.TownVisitNPCType sig.InTownVisitType = hm.TownVisitNPCType
sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil
} }
return sig return sig
} }
@ -1439,6 +1450,10 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
t := hm.TownVisitStartedAt t := hm.TownVisitStartedAt
p.TownVisitStartedAt = &t p.TownVisitStartedAt = &t
} }
if !hm.TownLastNPCLingerUntil.IsZero() {
t := hm.TownLastNPCLingerUntil
p.TownLastNPCLingerUntil = &t
}
if hm.TownPlazaHealActive { if hm.TownPlazaHealActive {
p.TownPlazaHealActive = true p.TownPlazaHealActive = true
} }
@ -1537,6 +1552,9 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
if blob.TownVisitStartedAt != nil { if blob.TownVisitStartedAt != nil {
hm.TownVisitStartedAt = *blob.TownVisitStartedAt hm.TownVisitStartedAt = *blob.TownVisitStartedAt
} }
if blob.TownLastNPCLingerUntil != nil {
hm.TownLastNPCLingerUntil = *blob.TownLastNPCLingerUntil
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
hm.TownNPCWalkToX = blob.NPCWalkToX 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. // 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 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) { 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 return
} }
logInterval := townNPCLogInterval() 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 --- // --- Excursion (mini-adventure) FSM helpers ---
func smoothstep(t float64) float64 { func smoothstep(t float64) float64 {
@ -2027,6 +2083,20 @@ func ProcessSingleHeroMovementTick(
} }
hm.LastMoveTick = now 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) --- // --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
if hm.TownCenterWalkActive { if hm.TownCenterWalkActive {
walkSpeed := cfg.TownNPCWalkSpeed walkSpeed := cfg.TownNPCWalkSpeed
@ -2148,12 +2218,15 @@ func ProcessSingleHeroMovementTick(
} }
// NPC visit pause ended: clear visit log state before the next roll. // 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.TownVisitNPCName = ""
hm.TownVisitNPCKey = "" hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = "" hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{} hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0 hm.TownVisitLogsEmitted = 0
if len(hm.TownNPCQueue) == 0 {
hm.scheduleLastNPCLingerFrom(now)
}
} }
emitTownNPCVisitLogs(heroID, hm, now, adventureLog) emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
@ -2170,6 +2243,22 @@ func ProcessSingleHeroMovementTick(
} }
return 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 cx, cy := town.WorldX, town.WorldY
const plazaEps = 0.55 const plazaEps = 0.55
dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy) 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). // 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 { func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = graph
_ = now _ = now
cfg := tuning.Get() cfg := tuning.Get()
inter := cfg.TownNPCInteractChance inter := cfg.TownNPCInteractChance
@ -300,6 +300,13 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if h == nil { if h == nil {
return false return false
} }
var town *model.Town
if graph != nil {
town = graph.Towns[hm.CurrentTownID]
}
townLv := TownEffectiveLevel(town)
const offlineServiceChance = 0.2
switch npc.Type { switch npc.Type {
case "merchant": case "merchant":
share := cfg.MerchantTownAutoSellShare share := cfg.MerchantTownAutoSellShare
@ -315,22 +322,33 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
}, },
}) })
} }
potionCost, _ := tuning.EffectiveNPCShopCosts() gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 { if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance {
h.Gold -= potionCost h.Gold -= gearCost
h.Potions++ drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now)
if al != nil { 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{ al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{
Code: model.LogPhrasePurchasedPotionFromNPC, Code: model.LogPhraseBoughtGearTownMerchant,
Args: map[string]any{"npcKey": npc.NameKey}, Args: map[string]any{
"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
"rarity": string(drop.Rarity), "itemId": drop.ItemID,
},
}, },
}) })
} }
} }
case "healer": case "healer":
_, healCost := tuning.EffectiveNPCShopCosts() _, 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.Gold -= healCost
h.HP = h.MaxHP h.HP = h.MaxHP
if al != nil { 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": case "quest_giver":
if s.questStore == nil { if s.questStore == nil {
return true return true
@ -355,7 +385,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
for _, hq := range hqs { for _, hq := range hqs {
taken[hq.QuestID] = struct{}{} 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 { if err != nil {
s.logger.Warn("offline town npc: list quests by npc", "error", err) s.logger.Warn("offline town npc: list quests by npc", "error", err)
return true return true
@ -377,6 +407,17 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
} }
return true 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))] pick := candidates[rand.Intn(len(candidates))]
ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID) ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID)
if err != nil { if err != nil {
@ -650,19 +691,9 @@ func HeroHasEquippedGear(h *model.Hero) bool {
return false 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 { func HeroHasEquippedGearForCombat(h *model.Hero) bool {
if h == nil { return HeroHasEquippedGear(h)
return false
}
h.EnsureGearMap()
var c = 0
for _, it := range h.Gear {
if it != nil {
c++
}
}
return c > 4
} }
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) { 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 { if hero == nil {
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts() 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{ writeJSON(w, http.StatusOK, map[string]any{
"hero": nil, "hero": nil,
"needsName": true, "needsName": true,
"offlineReport": nil, "offlineReport": nil,
"mapRef": h.world.RefForLevel(1), "mapRef": h.world.RefForLevel(1),
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "npcCostHeal": hCost,
"serverVersion": version.Version, "merchantTownGearCostBase": gearBase,
"showChangelog": false, "merchantTownGearCostPerTownLevel": gearPer,
"changelog": nil, "serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
}) })
return return
} }
@ -904,6 +915,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
// Build towns with NPCs for the frontend map. // Build towns with NPCs for the frontend map.
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts() 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) model.AttachDebuffCatalogForClient(hero)
@ -918,16 +938,18 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"needsName": needsName, "needsName": needsName,
"offlineReport": report, "offlineReport": report,
"mapRef": h.world.RefForLevel(hero.Level), "mapRef": h.world.RefForLevel(hero.Level),
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "npcCostHeal": hCost,
"serverVersion": version.Version, "merchantTownGearCostBase": gearBase,
"showChangelog": showChangelog, "merchantTownGearCostPerTownLevel": gearPer,
"changelog": changelogPayload, "serverVersion": version.Version,
"showChangelog": showChangelog,
"changelog": changelogPayload,
}) })
} }

@ -30,6 +30,12 @@ type NPCHandler struct {
hub *Hub 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. // 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 { 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{ return &NPCHandler{
@ -79,6 +85,41 @@ func dist2D(x1, y1, x2, y2 float64) float64 {
return math.Sqrt(dx*dx + dy*dy) 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. // InteractNPC handles POST /api/v1/hero/npc-interact.
// The hero interacts with a specific NPC; checks proximity to the NPC's town. // The hero interacts with a specific NPC; checks proximity to the NPC's town.
func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { 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) refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC() 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 { if err != nil {
h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err) h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -199,22 +241,29 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
} }
case "merchant": 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{ actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item", ActionType: "shop_item",
ItemKey: "shop.healing_potion", ItemKey: "shop.healing_potion",
ItemName: "Healing Potion", ItemName: "Healing Potion",
ItemCost: potionCost, 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{ actions = append(actions, model.NPCInteractAction{
ActionType: "heal", ActionType: "heal",
ItemKey: "shop.full_heal", ItemKey: "shop.full_heal",
ItemName: "Full Heal", ItemName: "Full Heal",
ItemCost: healCost, ItemCost: healCost,
Description: "Restore hero to full HP.", 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. // 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. // 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 slots := model.AllEquipmentSlots
if h.gearStore == nil { if h.gearStore == nil {
return nil, errors.New("failed to roll gear") 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() rarity := model.RollRarity()
ilvl := model.RollIlvl(hero.Level, false) ilvl := model.RollIlvl(refLevel, false)
item := model.NewGearItem(family, ilvl, rarity) item := model.NewGearItem(family, ilvl, rarity)
ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second) 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 hero.Gold -= cost
now := time.Now() now := time.Now()
drop, err := h.grantMerchantLoot(ctx, hero, now) drop, err := h.grantMerchantLoot(ctx, hero, now, hero.Level)
if err != nil { if err != nil {
hero.Gold += cost hero.Gold += cost
return err return err
@ -544,7 +593,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
hero.Gold -= cost hero.Gold -= cost
now := time.Now() now := time.Now()
drop, err := h.grantMerchantLoot(r.Context(), hero, now) drop, err := h.grantMerchantLoot(r.Context(), hero, now, hero.Level)
if err != nil { if err != nil {
hero.Gold += cost hero.Gold += cost
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -590,7 +639,9 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
} }
var req struct { 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 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
@ -599,35 +650,35 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return return
} }
hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) var hero *model.Hero
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.
if req.NPCID != 0 { 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 { if err != nil {
h.logger.Error("failed to get npc for heal", "npc_id", req.NPCID, "error", err) msg := err.Error()
writeJSON(w, http.StatusInternalServerError, map[string]string{ switch msg {
"error": "failed to load npc", 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 return
} }
if npc == nil || npc.Type != "healer" { } else {
writeJSON(w, http.StatusBadRequest, map[string]string{ var err error
"error": "npc is not a healer", 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 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}}) 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) writeHeroJSON(w, http.StatusOK, hero)
} }
// BuyPotion handles POST /api/v1/hero/npc-buy-potion. // 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) { func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r) telegramID, ok := resolveTelegramID(r)
if !ok { if !ok {
@ -667,18 +720,32 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return return
} }
hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) var req struct {
if err != nil { NPCID int64 `json:"npcId"`
h.logger.Error("failed to get hero for buy potion", "error", err) PositionX float64 `json:"positionX"`
writeJSON(w, http.StatusInternalServerError, map[string]string{ PositionY float64 `json:"positionY"`
"error": "failed to load hero", }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body",
}) })
return return
} }
if hero == nil {
writeJSON(w, http.StatusNotFound, map[string]string{ hero, _, _, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer")
"error": "hero not found", 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 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}}) 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) 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/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning" "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) refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
timeBucket := time.Now().UTC().Unix() / refreshSeconds timeBucket := time.Now().UTC().Unix() / refreshSeconds
limit := tuning.EffectiveQuestOffersPerNPC() 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 { if err != nil {
h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err) h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{

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

@ -43,20 +43,22 @@ func init() {
} }
func seedBuffMap() map[BuffType]Buff { 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{ return map[BuffType]Buff{
BuffRush: { BuffRush: {
Type: BuffRush, Name: "Rush", 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, CooldownDuration: 15 * time.Minute,
}, },
BuffRage: { BuffRage: {
Type: BuffRage, Name: "Rage", 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, CooldownDuration: 10 * time.Minute,
}, },
BuffShield: { BuffShield: {
Type: BuffShield, Name: "Shield", 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, CooldownDuration: 12 * time.Minute,
}, },
BuffLuck: { BuffLuck: {
@ -66,22 +68,22 @@ func seedBuffMap() map[BuffType]Buff {
}, },
BuffResurrection: { BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection", 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, CooldownDuration: 30 * time.Minute,
}, },
BuffHeal: { BuffHeal: {
Type: BuffHeal, Name: "Heal", 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, CooldownDuration: 5 * time.Minute,
}, },
BuffPowerPotion: { BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion", 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, CooldownDuration: 20 * time.Minute,
}, },
BuffWarCry: { BuffWarCry: {
Type: BuffWarCry, Name: "War Cry", 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, CooldownDuration: 10 * time.Minute,
}, },
} }

@ -21,6 +21,16 @@ type GearItem struct {
SpecialEffect string `json:"specialEffect,omitempty"` 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. // GearFamily is a template for generating gear drops from the unified catalog.
type GearFamily struct { type GearFamily struct {
Slot EquipmentSlot `json:"slot"` Slot EquipmentSlot `json:"slot"`

@ -192,17 +192,10 @@ func (h *Hero) activeStatBonuses(now time.Time) statBonuses {
out.movementMultiplier *= (1 + ab.Buff.Magnitude) out.movementMultiplier *= (1 + ab.Buff.Magnitude)
case BuffRage: case BuffRage:
out.attackMultiplier *= (1 + ab.Buff.Magnitude) out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 10
case BuffPowerPotion: case BuffPowerPotion:
out.attackMultiplier *= (1 + ab.Buff.Magnitude) out.attackMultiplier *= (1 + ab.Buff.Magnitude)
out.strengthBonus += 12
case BuffWarCry: case BuffWarCry:
out.speedMultiplier *= (1 + ab.Buff.Magnitude) 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 return out

@ -85,33 +85,42 @@ func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
Strength: 10, Strength: 10,
Constitution: 8, Constitution: 8,
Agility: 6, Agility: 6,
Buffs: []ActiveBuff{ }
{ baseAtk := hero.EffectiveAttackAt(now)
Buff: mustBuffDef(BuffRage), baseDef := hero.EffectiveDefenseAt(now)
AppliedAt: now.Add(-time.Second), baseSpd := hero.EffectiveSpeedAt(now)
ExpiresAt: now.Add(5 * time.Second),
}, rageMag := mustBuffDef(BuffRage).Magnitude
{ warMag := mustBuffDef(BuffWarCry).Magnitude
Buff: mustBuffDef(BuffWarCry),
AppliedAt: now.Add(-time.Second), hero.Buffs = []ActiveBuff{
ExpiresAt: now.Add(5 * time.Second), {
}, Buff: mustBuffDef(BuffRage),
{ AppliedAt: now.Add(-time.Second),
Buff: mustBuffDef(BuffShield), ExpiresAt: now.Add(5 * time.Second),
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 { wantAtk := int(float64(baseAtk) * (1 + rageMag))
t.Fatalf("expected buffed attack to increase above baseline") if got := hero.EffectiveAttackAt(now); got != wantAtk {
t.Fatalf("expected attack %d (rage mult only), got %d", wantAtk, got)
} }
if hero.EffectiveDefenseAt(now) <= 5 { if got := hero.EffectiveDefenseAt(now); got != baseDef {
t.Fatalf("expected shield constitution bonus to increase defense") t.Fatalf("shield must not change effective defense: base=%d got=%d", baseDef, got)
} }
if hero.EffectiveSpeedAt(now) <= 1.0 { wantSpd := baseSpd * (1 + warMag)
t.Fatalf("expected war cry to increase attack speed") 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"` NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkToY float64 `json:"npcWalkToY,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. // Plaza: walk to town center after NPC tour, then wait/rest before leaving.
TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"` TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
CenterWalkActive bool `json:"centerWalkActive,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-alms", npcH.NPCAlms)
r.Post("/hero/npc-heal", npcH.HealHero) r.Post("/hero/npc-heal", npcH.HealHero)
r.Post("/hero/npc-buy-potion", npcH.BuyPotion) 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. // Gear routes.
r.Get("/hero/gear", gameH.GetHeroGear) r.Get("/hero/gear", gameH.GetHeroGear)

@ -33,7 +33,9 @@ type Values struct {
TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"`
TownNPCRetryMs int64 `json:"townNpcRetryMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"`
TownNPCPauseMs int64 `json:"townNpcPauseMs"` 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"` TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"`
// TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach).
TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"`
@ -83,6 +85,15 @@ type Values struct {
NPCCostHeal int64 `json:"npcCostHeal"` NPCCostHeal int64 `json:"npcCostHeal"`
NPCCostPotion int64 `json:"npcCostPotion"` NPCCostPotion int64 `json:"npcCostPotion"`
NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"` 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 caps how many quest templates a quest_giver offers per interaction (after filtering taken quests).
QuestOffersPerNPC int `json:"questOffersPerNPC"` QuestOffersPerNPC int `json:"questOffersPerNPC"`
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
@ -266,6 +277,7 @@ func DefaultValues() Values {
TownNPCRollMaxMs: 2600, TownNPCRollMaxMs: 2600,
TownNPCRetryMs: 450, TownNPCRetryMs: 450,
TownNPCPauseMs: 30_000, TownNPCPauseMs: 30_000,
TownLastNpcLingerMs: 10_000,
TownNPCLogIntervalMs: 5_000, TownNPCLogIntervalMs: 5_000,
TownNPCWalkSpeed: 3.0, TownNPCWalkSpeed: 3.0,
TownNPCStandoffWorld: 0.65, TownNPCStandoffWorld: 0.65,
@ -307,6 +319,11 @@ func DefaultValues() Values {
NPCCostHeal: 100, NPCCostHeal: 100,
NPCCostPotion: 50, NPCCostPotion: 50,
NPCCostNearbyRadius: 3.0, NPCCostNearbyRadius: 3.0,
MerchantTownGearCostBase: 180,
MerchantTownGearCostPerTownLevel: 40,
MerchantTownStockCount: 3,
MerchantTownGearPricePerIlvl: 115,
MerchantTownGearPriceVariancePct: 15,
QuestOffersPerNPC: 2, QuestOffersPerNPC: 2,
QuestOfferRefreshHours: 2, QuestOfferRefreshHours: 2,
QuestOfferDrySpellChance: 0.20, QuestOfferDrySpellChance: 0.20,
@ -329,7 +346,7 @@ func DefaultValues() Values {
EnemyChainEveryN: 6, EnemyChainEveryN: 6,
EnemyChainMultiplier: 3.0, EnemyChainMultiplier: 3.0,
EnemyEncounterStatMultiplier: 1.2, EnemyEncounterStatMultiplier: 1.2,
EnemyStatMultiplierVsUnequippedHero: 0.75, EnemyStatMultiplierVsUnequippedHero: 0.85,
DebuffProcBurn: 0.18, DebuffProcBurn: 0.18,
DebuffProcPoison: 0.10, DebuffProcPoison: 0.10,
DebuffProcSlow: 0.25, DebuffProcSlow: 0.25,
@ -342,7 +359,8 @@ func DefaultValues() Values {
EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard,
SummonCycleSeconds: 18, SummonCycleSeconds: 18,
SummonDamageDivisor: 10, 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, MinAttackIntervalMs: 250,
CombatPaceMultiplier: 14, CombatPaceMultiplier: 14,
PotionHealPercent: 0.30, PotionHealPercent: 0.30,
@ -452,6 +470,61 @@ func EffectiveNPCShopCosts() (potionCost, healCost int64) {
return potionCost, healCost 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. // EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning.
func EffectiveQuestOffersPerNPC() int { func EffectiveQuestOffersPerNPC() int {
n := Get().QuestOffersPerNPC n := Get().QuestOffersPerNPC

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

@ -14,7 +14,6 @@ import {
buildMerchantLootDrop, buildMerchantLootDrop,
} from './game/ws-handler'; } from './game/ws-handler';
import { import {
ApiError,
initHero, initHero,
ackChangelog, ackChangelog,
getAdventureLog, getAdventureLog,
@ -27,10 +26,8 @@ import {
abandonQuest, abandonQuest,
getAchievements, getAchievements,
getNearbyHeroes, getNearbyHeroes,
buyPotion,
healAtNPC,
requestRevive, requestRevive,
defaultNpcShopCosts, defaultNpcShopBundle,
npcShopCostsFromInit, npcShopCostsFromInit,
offlineReportHasActivity, offlineReportHasActivity,
} from './network/api'; } from './network/api';
@ -241,6 +238,9 @@ function townToTownData(
worldX: town.worldX + n.offsetX, worldX: town.worldX + n.offsetX,
worldY: town.worldY + n.offsetY, worldY: town.worldY + n.offsetY,
buildingId: n.buildingId, buildingId: n.buildingId,
townId: town.id,
townLevelMin: town.levelMin,
townLevelMax: town.levelMax,
})); }));
return { return {
id: town.id, id: town.id,
@ -387,7 +387,7 @@ export function App() {
// Wandering NPC encounter state // Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null); const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts); const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle);
// Achievements // Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]); const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]); const prevAchievementsRef = useRef<Achievement[]>([]);
@ -908,6 +908,7 @@ export function App() {
setNearestNPC(null); setNearestNPC(null);
setNpcInteractionDismissed(null); setNpcInteractionDismissed(null);
const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name; const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name;
const tw = townsRef.current.find((t) => t.id === p.townId);
setNpcVisitAwaitingProximity({ setNpcVisitAwaitingProximity({
id: p.npcId, id: p.npcId,
name: displayName, name: displayName,
@ -915,6 +916,9 @@ export function App() {
type: p.type as NPCData['type'], type: p.type as NPCData['type'],
worldX: p.worldX ?? 0, worldX: p.worldX ?? 0,
worldY: p.worldY ?? 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) { if (!alreadyActive) {
switch (type) { switch (type) {
case BuffType.Rage: case BuffType.Rage:
damage = Math.round(damage * 2); damage = Math.round((damage * 5) / 3);
break; break;
case BuffType.PowerPotion: case BuffType.PowerPotion:
damage = Math.round(damage * 2.5); damage = Math.round(damage * 2);
break; break;
case BuffType.WarCry: case BuffType.WarCry:
attackSpeed = Math.round(attackSpeed * 2 * 100) / 100; attackSpeed = Math.round(((attackSpeed * 5) / 3) * 100) / 100;
break; break;
case BuffType.Heal: case BuffType.Heal:
hp = Math.min(maxHp, hp + Math.round(maxHp * 0.5)); hp = Math.min(maxHp, hp + Math.round(maxHp / 3));
break; break;
} }
} }
@ -1336,61 +1340,19 @@ export function App() {
const handleNPCViewQuests = useCallback((npc: NPCData) => { const handleNPCViewQuests = useCallback((npc: NPCData) => {
const matchedNPC: NPC = { const matchedNPC: NPC = {
id: npc.id, id: npc.id,
townId: 0, townId: npc.townId,
name: npc.name, name: npc.name,
nameKey: npc.nameKey, nameKey: npc.nameKey,
type: npc.type, type: npc.type,
offsetX: 0, offsetX: 0,
offsetY: 0, offsetY: 0,
townLevelMin: npc.townLevelMin,
townLevelMax: npc.townLevelMax,
}; };
setSelectedNPC(matchedNPC); setSelectedNPC(matchedNPC);
setNpcInteractionDismissed(npc.id); 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(() => { const handleNPCInteractionDismiss = useCallback(() => {
if (nearestNPC) { if (nearestNPC) {
setNpcInteractionDismissed(nearestNPC.id); setNpcInteractionDismissed(nearestNPC.id);
@ -1539,12 +1501,8 @@ export function App() {
{showNPCInteraction && nearestNPC && ( {showNPCInteraction && nearestNPC && (
<NPCInteraction <NPCInteraction
npc={nearestNPC} npc={nearestNPC}
heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost}
onViewQuests={handleNPCViewQuests} onViewQuests={handleNPCViewQuests}
onBuyPotion={handleNPCBuyPotion} onOpenServiceDialog={handleNPCViewQuests}
onHeal={handleNPCHeal}
onDismiss={handleNPCInteractionDismiss} onDismiss={handleNPCInteractionDismiss}
/> />
)} )}
@ -1557,6 +1515,7 @@ export function App() {
heroGold={gameState.hero?.gold ?? 0} heroGold={gameState.hero?.gold ?? 0}
potionCost={npcShopCosts.potionCost} potionCost={npcShopCosts.potionCost}
healCost={npcShopCosts.healCost} healCost={npcShopCosts.healCost}
getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }}
onClose={() => setSelectedNPC(null)} onClose={() => setSelectedNPC(null)}
onQuestsChanged={refreshHeroQuests} onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated} onHeroUpdated={handleNPCHeroUpdated}

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

@ -297,6 +297,9 @@ export interface NPC {
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
buildingId?: number; buildingId?: number;
/** Present when opened from map/town visit (shop tier). */
townLevelMin?: number;
townLevelMax?: number;
} }
export interface Quest { export interface Quest {
@ -337,6 +340,8 @@ export interface HeroQuest {
townName: string; townName: string;
/** Resolved name for visit_town delivery target */ /** Resolved name for visit_town delivery target */
targetTownName?: string; targetTownName?: string;
/** Same as quests.target_town_id — for localized destination label. */
targetTownId?: number;
} }
// ---- Equipment Item (extended slots per §6.3) ---- // ---- Equipment Item (extended slots per §6.3) ----
@ -377,6 +382,10 @@ export interface NPCData {
worldX: number; worldX: number;
worldY: number; worldY: number;
buildingId?: 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 */ /** 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 }; 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> = { const TOWNS: Record<string, Bilingual> = {
'town.willowdale.v1': { en: 'Willowdale', ru: 'Ивадол' }, 'town.willowdale.v1': { en: 'Willowdale', ru: 'Виллоудейл' },
'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Тернозорь' }, 'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Торнвотч' },
'town.ashengard.v1': { en: 'Ashengard', ru: 'Пепельный гард' }, 'town.ashengard.v1': { en: 'Ashengard', ru: 'Ашенгард' },
'town.redcliff.v1': { en: 'Redcliff', ru: 'Красная скала' }, 'town.redcliff.v1': { en: 'Redcliff', ru: 'Редклифф' },
'town.boghollow.v1': { en: 'Boghollow', ru: 'Торфяная низина' }, 'town.boghollow.v1': { en: 'Boghollow', ru: 'Богхоллоу' },
'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Зола-крепость' }, 'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Синдеркип' },
'town.starfall.v1': { en: 'Starfall', ru: 'Звездопад' }, 'town.starfall.v1': { en: 'Starfall', ru: 'Старфолл' },
'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Мшистая гавань' }, 'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Моссхарбор' },
'town.emberwell.v1': { en: 'Emberwell', ru: 'Угольный колодец' }, 'town.emberwell.v1': { en: 'Emberwell', ru: 'Эмбервелл' },
'town.frostmark.v1': { en: 'Frostmark', ru: 'Морозная метка' }, 'town.frostmark.v1': { en: 'Frostmark', ru: 'Фростмарк' },
'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Сумеречный дозор' }, 'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Дасквотч' },
}; };
const NPCS: Record<string, Bilingual> = { const NPCS: Record<string, Bilingual> = {

@ -52,14 +52,14 @@ ui:
buffHeal: Heal buffHeal: Heal
buffPowerPotion: Power buffPowerPotion: Power
buffWarCry: War Cry buffWarCry: War Cry
buffRushDesc: "+50% movement speed" buffRushDesc: "~+33% movement speed"
buffRageDesc: "+100% damage" buffRageDesc: "~+67% damage"
buffShieldDesc: "-50% incoming damage" buffShieldDesc: "~33% incoming damage"
buffLuckDesc: x2.5 loot drops buffLuckDesc: ~×1.67 gold/item chances and gold amount when gold drops
buffResurrectionDesc: Revive at 50% HP buffResurrectionDesc: Revive at ~33% max HP
buffHealDesc: "+50% HP instant" buffHealDesc: "~+33% max HP instant"
buffPowerPotionDesc: "+150% damage" buffPowerPotionDesc: "+100% damage"
buffWarCryDesc: "+100% attack speed" buffWarCryDesc: "~+67% attack speed"
charges: Charges charges: Charges
refillsAt: Refills at refillsAt: Refills at
refill: Refill refill: Refill
@ -98,6 +98,15 @@ ui:
npc: NPC npc: NPC
buyPotion: Buy Potion buyPotion: Buy Potion
buyPotionForGold: Buy Potion ({cost}g) 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 healToFull: Heal to Full
healToFullForGold: Heal to Full ({cost}g) healToFullForGold: Heal to Full ({cost}g)
viewQuests: View Quests viewQuests: View Quests
@ -200,6 +209,7 @@ adventure_log:
log.wandering_alms_stashed: Stashed {item} in your inventory. log.wandering_alms_stashed: Stashed {item} in your inventory.
log.healed_full_town: Paid for a full heal. log.healed_full_town: Paid for a full heal.
log.bought_potion_town: Bought a potion in town. 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.sold_items_merchant: Sold {count} items to {npc} (+{gold} gold).
log.npc_skipped_visit: Skipped visiting {npc}. log.npc_skipped_visit: Skipped visiting {npc}.
log.purchased_potion_from_npc: Bought a potion from {npc}. log.purchased_potion_from_npc: Bought a potion from {npc}.

@ -1,9 +1,14 @@
import { parse } from 'yaml'; import { parse } from 'yaml';
import type { Locale } from './localeCodes'; 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 enRaw from './en.yml?raw';
import ruRaw from './ru.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). */ /** Matches backend townVisitLineSlugs (legacy town_visit.type.N → Nth slug). */
export const TOWN_VISIT_SLUG_ORDER: Record<string, readonly string[]> = { 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 { function assertRecordKeysMatch(name: string, a: Record<string, string>, b: Record<string, string>): void {
const ak = Object.keys(a).sort(); const ak = Object.keys(a).sort();
const bk = Object.keys(b).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; const doc = parse(raw) as unknown;
if (!doc || typeof doc !== 'object') { if (!doc || typeof doc !== 'object') {
throw new Error('Invalid locale YAML root'); 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); const questsEn = parseQuestBundle(questsEnRaw, 'en');
export const ruDoc = loadDoc(ruRaw); 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('enemy_types', enDoc.enemy_types, ruDoc.enemy_types);
assertRecordKeysMatch('roadside', enDoc.roadside, ruDoc.roadside); assertRecordKeysMatch('roadside', enDoc.roadside, ruDoc.roadside);
@ -114,6 +159,21 @@ export function achievementLogTitle(locale: Locale, achievementId: string): stri
return m[achievementId] ?? ''; 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. */ /** Localized display name for DB `enemies.type` slug; empty if unknown. */
export function enemyTypeLabel(locale: Locale, enemyTypeSlug: string): string { export function enemyTypeLabel(locale: Locale, enemyTypeSlug: string): string {
const slug = enemyTypeSlug?.trim() ?? ''; const slug = enemyTypeSlug?.trim() ?? '';

@ -1,20 +1 @@
import type { Locale } from './localeCodes'; export { localizedQuestText } from './loadLocales';
/** 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);
}

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

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

@ -235,10 +235,12 @@ export interface InitHeroResponse {
offlineReport: OfflineReport | null; offlineReport: OfflineReport | null;
mapRef: MapRefResponse; mapRef: MapRefResponse;
needsName?: boolean; needsName?: boolean;
/** Runtime tuning: merchant potion price (from DB / runtime_config). */ /** Runtime tuning: healer potion price (from DB / runtime_config). */
npcCostPotion?: number; npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */ /** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: number; npcCostHeal?: number;
merchantTownGearCostBase?: number;
merchantTownGearCostPerTownLevel?: number;
/** Server build id; bump on backend with changelog entry to show the modal. */ /** Server build id; bump on backend with changelog entry to show the modal. */
serverVersion?: string; serverVersion?: string;
/** True when there is a changelog entry for serverVersion and the player has not ack'd yet. */ /** 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 }; return { potionCost: 50, healCost: 100 };
} }
export function npcShopCostsFromInit(res: InitHeroResponse): { potionCost: number; healCost: number } { export function defaultMerchantGearCosts(): {
const d = defaultNpcShopCosts(); 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 p = res.npcCostPotion;
const h = res.npcCostHeal; const h = res.npcCostHeal;
const gb = res.merchantTownGearCostBase;
const gp = res.merchantTownGearCostPerTownLevel;
return { return {
potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost, potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost,
healCost: typeof h === 'number' && h > 0 ? h : d.healCost, 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 ?? '', type: raw.type ?? q?.type ?? '',
targetCount: raw.targetCount ?? q?.targetCount ?? 0, targetCount: raw.targetCount ?? q?.targetCount ?? 0,
targetTownName: raw.quest?.targetTownName ?? q?.targetTownName, targetTownName: raw.quest?.targetTownName ?? q?.targetTownName,
targetTownId: q?.targetTownId != null ? Number(q.targetTownId) : undefined,
progress: raw.progress, progress: raw.progress,
status: (raw.status as HeroQuest['status']) ?? 'accepted', status: (raw.status as HeroQuest['status']) ?? 'accepted',
rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0, rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0,
@ -629,16 +650,79 @@ export async function abandonQuest(heroQuestId: number, telegramId?: number): Pr
// ---- NPC Services ---- // ---- NPC Services ----
/** Buy a potion from a merchant NPC (matches backend POST /api/v1/hero/npc-buy-potion) */ /** Buy a potion from a healer NPC (POST /hero/npc-buy-potion; hero position must be inside the town). */
export async function buyPotion(telegramId?: number): Promise<HeroResponse> { 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}` : ''; 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) */ /** Buy one row from current merchant stock; equips immediately (POST /hero/npc-buy-town-gear). */
export async function healAtNPC(telegramId?: number, npcId?: number): Promise<HeroResponse> { 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}` : ''; 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 ---- // ---- NPC Proximity & Interaction ----

@ -1,7 +1,18 @@
import { useState, useEffect, useCallback, type CSSProperties } from 'react'; import { useState, useEffect, useCallback, type CSSProperties } from 'react';
import type { NPC, Quest, HeroQuest } from '../game/types'; import type { NPC, Quest, HeroQuest } from '../game/types';
import { getNPCQuests, acceptQuest, claimQuest, buyPotion, healAtNPC } from '../network/api'; import {
import type { HeroResponse } from '../network/api'; 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 { getTelegramUserId } from '../shared/telegram';
import { hapticImpact } from '../shared/telegram'; import { hapticImpact } from '../shared/telegram';
import { useT, t, useLocale } from '../i18n'; import { useT, t, useLocale } from '../i18n';
@ -16,6 +27,7 @@ interface NPCDialogProps {
heroGold: number; heroGold: number;
potionCost: number; potionCost: number;
healCost: number; healCost: number;
getHeroWorldPosition: () => { x: number; y: number };
onClose: () => void; onClose: () => void;
onQuestsChanged: () => void; onQuestsChanged: () => void;
onHeroUpdated: (hero: HeroResponse) => 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 ---- // ---- Component ----
export function NPCDialog({ export function NPCDialog({
@ -242,6 +284,7 @@ export function NPCDialog({
heroGold, heroGold,
potionCost, potionCost,
healCost, healCost,
getHeroWorldPosition,
onClose, onClose,
onQuestsChanged, onQuestsChanged,
onHeroUpdated, onHeroUpdated,
@ -253,9 +296,54 @@ export function NPCDialog({
const npcDisplayName = npcLabel(locale, npc.nameKey, npc.name); const npcDisplayName = npcLabel(locale, npc.nameKey, npc.name);
const [availableQuests, setAvailableQuests] = useState<Quest[]>([]); const [availableQuests, setAvailableQuests] = useState<Quest[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [merchantOffers, setMerchantOffers] = useState<MerchantGearOfferItem[]>([]);
const [merchantStockLoading, setMerchantStockLoading] = useState(false);
const [merchantStockError, setMerchantStockError] = useState(false);
const telegramId = getTelegramUserId() ?? 1; 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 // Fetch available quests for quest giver NPCs
useEffect(() => { useEffect(() => {
if (npc.type !== 'quest_giver') return; if (npc.type !== 'quest_giver') return;
@ -318,7 +406,8 @@ export function NPCDialog({
onToast(tr.notEnoughGold, '#ff4444'); onToast(tr.notEnoughGold, '#ff4444');
return; return;
} }
buyPotion(telegramId) const pos = getHeroWorldPosition();
buyPotion({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88'); onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88');
@ -326,16 +415,66 @@ export function NPCDialog({
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to buy potion:', 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(() => { const handleHeal = useCallback(() => {
if (heroGold < healCost) { if (heroGold < healCost) {
onToast(tr.notEnoughGold, '#ff4444'); onToast(tr.notEnoughGold, '#ff4444');
return; return;
} }
healAtNPC(telegramId, npc.id) const pos = getHeroWorldPosition();
healAtNPC({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
onToast(tr.healedToFull, '#44cc44'); onToast(tr.healedToFull, '#44cc44');
@ -343,9 +482,18 @@ export function NPCDialog({
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to heal:', 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 // Quests relevant to this NPC
const npcHeroQuests = heroQuests.filter( const npcHeroQuests = heroQuests.filter(
@ -540,6 +688,76 @@ export function NPCDialog({
{npc.type === 'merchant' && ( {npc.type === 'merchant' && (
<> <>
<div style={sectionTitleStyle}>{tr.shopLabel}</div> <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 <button
style={ style={
heroGold >= potionCost heroGold >= potionCost
@ -551,16 +769,6 @@ export function NPCDialog({
> >
{'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {potionCost} {tr.gold} {'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {potionCost} {tr.gold}
</button> </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 <button
style={ style={
heroGold >= healCost heroGold >= healCost

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

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

Loading…
Cancel
Save