diff --git a/.cursor/rules/autohero-specification.mdc b/.cursor/rules/autohero-specification.mdc index b4a85a8..3638921 100644 --- a/.cursor/rules/autohero-specification.mdc +++ b/.cursor/rules/autohero-specification.mdc @@ -47,7 +47,7 @@ alwaysApply: true ## Buffs / debuffs -- **8** buffs and **6** debuffs; effects and magnitudes (e.g. Rage +100% damage, Shield −50% incoming, Stun blocks attacks 2s) per spec **§7**. +- **8** buffs and **6** debuffs; effects and magnitudes per spec **§7** (e.g. Rage ~+67% damage, Shield ~−33% incoming, Stun blocks attacks — duration in debuff catalog). ## Loot and gold (§8) diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index 489f807..b752cfc 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -70,7 +70,7 @@ func damageRollMultiplier(minRoll, maxRoll float64) float64 { return minRoll + rand.Float64()*(maxRoll-minRoll) } -// CalculateIncomingDamage applies shield buff and weaken debuff reduction to incoming damage. +// CalculateIncomingDamage applies Shield (−magnitude fraction) and Weaken (+magnitude incoming) per spec §7. func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs []model.ActiveDebuff, now time.Time) int { dmg := float64(rawDamage) @@ -87,7 +87,7 @@ func CalculateIncomingDamage(rawDamage int, buffs []model.ActiveBuff, debuffs [] continue } if ad.Debuff.Type == model.DebuffWeaken { - dmg *= (1 - ad.Debuff.Magnitude) + dmg *= (1 + ad.Debuff.Magnitude) } } diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go index 20ebf45..a0da667 100644 --- a/backend/internal/game/combat_test.go +++ b/backend/internal/game/combat_test.go @@ -177,9 +177,10 @@ func TestLuckMultiplierWithBuff(t *testing.T) { }}, } + want := tuning.Get().LuckBuffMultiplier mult := LuckMultiplier(hero, now) - if mult != 1.75 { - t.Fatalf("expected luck multiplier 1.75, got %.2f", mult) + if mult != want { + t.Fatalf("expected luck multiplier %.4f, got %.4f", want, mult) } } @@ -303,6 +304,38 @@ func TestDamageRollAppliesRange(t *testing.T) { } } +func TestCalculateIncomingDamage_ShieldAndWeaken(t *testing.T) { + now := time.Now() + shield := model.ActiveBuff{ + Buff: mustBuffDef(model.BuffShield), + AppliedAt: now, + ExpiresAt: now.Add(time.Minute), + } + weaken := model.ActiveDebuff{ + Debuff: mustDebuffDef(model.DebuffWeaken), + AppliedAt: now, + ExpiresAt: now.Add(time.Minute), + } + + raw := 90 + shMag := mustBuffDef(model.BuffShield).Magnitude + wantShield := int(float64(raw) * (1 - shMag)) + if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, nil, now); got != wantShield { + t.Fatalf("shield: want %d got %d", wantShield, got) + } + + wkMag := mustDebuffDef(model.DebuffWeaken).Magnitude + wantWeaken := int(float64(raw) * (1 + wkMag)) + if got := CalculateIncomingDamage(raw, nil, []model.ActiveDebuff{weaken}, now); got != wantWeaken { + t.Fatalf("weaken: want %d got %d", wantWeaken, got) + } + + wantBoth := int(float64(raw) * (1 - shMag) * (1 + wkMag)) + if got := CalculateIncomingDamage(raw, []model.ActiveBuff{shield}, []model.ActiveDebuff{weaken}, now); got != wantBoth { + t.Fatalf("shield+weaken: want %d got %d", wantBoth, got) + } +} + func mustBuffDef(bt model.BuffType) model.Buff { b, ok := model.BuffDefinition(bt) if !ok { diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index ca342a1..33099d1 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -24,6 +24,14 @@ type MessageSender interface { // EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS. type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop +type merchantOfferSession struct { + NPCID int64 + TownID int64 + Items []*model.GearItem + Costs []int64 // parallel to Items — rolled when stock opens + Created time.Time +} + // EngineStatus contains a snapshot of the engine's operational state. type EngineStatus struct { Running bool `json:"running"` @@ -83,6 +91,9 @@ type Engine struct { heroSubscriber func(heroID int64) bool // lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber. lastDisconnectedFullSave map[int64]time.Time + + // merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close. + merchantStock map[int64]*merchantOfferSession } // offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected. @@ -99,6 +110,7 @@ func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *s eventCh: eventCh, logger: logger, lastDisconnectedFullSave: make(map[int64]time.Time), + merchantStock: make(map[int64]*merchantOfferSession), } heap.Init(&e.queue) return e @@ -1155,9 +1167,8 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) { } } -// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into -// the live movement session and pushes hero_state when a sender is configured. -func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) { +// ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state. +func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) { if hero == nil { return } @@ -1167,7 +1178,6 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) { hm, ok := e.movements[hero.ID] if ok { now := time.Now() - hm.WanderingMerchantDeadline = time.Time{} *hm.Hero = *hero hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) @@ -1183,6 +1193,21 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) { } } +// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into +// the live movement session and pushes hero_state when a sender is configured. +func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) { + if hero == nil { + return + } + e.mu.Lock() + if hm, ok := e.movements[hero.ID]; ok { + hm.WanderingMerchantDeadline = time.Time{} + } + e.mu.Unlock() + + e.ApplyPersistedHeroSnapshot(hero) +} + // ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted // the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online), // restores movement/route when needed, and pushes WS events so the client matches the DB. @@ -1207,6 +1232,7 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) { hm.State = hero.State hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} + hm.TownLastNPCLingerUntil = time.Time{} hm.LastMoveTick = now hm.refreshSpeed(now) @@ -1262,6 +1288,7 @@ func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) { now := time.Now() hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} + hm.TownLastNPCLingerUntil = time.Time{} *hm.Hero = *hero hm.State = model.StateDead hm.Hero.State = model.StateDead @@ -1959,3 +1986,113 @@ func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo { IsElite: e.IsElite, } } + +// SetTownNPCUILock freezes town NPC visit narration while the client shows shop or quest UI. +func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) { + if e == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + hm := e.movements[heroID] + if hm == nil { + return + } + hm.TownNPCUILock = locked +} + +// SkipTownNPCNarrationAfterDialog ends the current town NPC visit narration immediately when +// the client closes shop / healer / quest UI (next tick proceeds to the next NPC or plaza). +func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) { + if e == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + hm := e.movements[heroID] + if hm == nil { + return + } + hm.skipTownNPCNarrationForDialogClose(time.Now()) +} + +// SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared). +// costs must have the same length as items (gold price locked at roll time). +func (e *Engine) SetMerchantStock(heroID, npcID, townID int64, items []*model.GearItem, costs []int64) { + if e == nil { + return + } + if len(costs) != len(items) { + return + } + e.mu.Lock() + defer e.mu.Unlock() + if e.merchantStock == nil { + e.merchantStock = make(map[int64]*merchantOfferSession) + } + copies := make([]*model.GearItem, len(items)) + prices := make([]int64, len(costs)) + for i, it := range items { + copies[i] = model.CloneGearItem(it) + prices[i] = costs[i] + } + e.merchantStock[heroID] = &merchantOfferSession{ + NPCID: npcID, TownID: townID, Items: copies, Costs: prices, Created: time.Now(), + } +} + +// ClearMerchantStock drops cached merchant rows (e.g. dialog closed). +func (e *Engine) ClearMerchantStock(heroID int64) { + if e == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + delete(e.merchantStock, heroID) +} + +// TakeMerchantOffer validates npc and index, removes the row, returns a template for DB insert (id 0) and locked price. +func (e *Engine) TakeMerchantOffer(heroID, npcID int64, index int) (*model.GearItem, int64, bool) { + if e == nil { + return nil, 0, false + } + e.mu.Lock() + defer e.mu.Unlock() + s, ok := e.merchantStock[heroID] + if !ok || s == nil || s.NPCID != npcID || index < 0 || index >= len(s.Items) { + return nil, 0, false + } + if len(s.Costs) != len(s.Items) || index >= len(s.Costs) { + return nil, 0, false + } + item := model.CloneGearItem(s.Items[index]) + price := s.Costs[index] + s.Items = append(s.Items[:index], s.Items[index+1:]...) + s.Costs = append(s.Costs[:index], s.Costs[index+1:]...) + if len(s.Items) == 0 { + delete(e.merchantStock, heroID) + } + return item, price, true +} + +// UnshiftMerchantOffer puts an offer row back (e.g. failed persist after TakeMerchantOffer). +func (e *Engine) UnshiftMerchantOffer(heroID, npcID, townID int64, item *model.GearItem, cost int64) { + if e == nil || item == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + if e.merchantStock == nil { + e.merchantStock = make(map[int64]*merchantOfferSession) + } + s := e.merchantStock[heroID] + clone := model.CloneGearItem(item) + if s == nil || s.NPCID != npcID { + e.merchantStock[heroID] = &merchantOfferSession{ + NPCID: npcID, TownID: townID, Items: []*model.GearItem{clone}, Costs: []int64{cost}, Created: time.Now(), + } + return + } + s.Items = append([]*model.GearItem{clone}, s.Items...) + s.Costs = append([]int64{cost}, s.Costs...) +} diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 66e091c..b98cbb7 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -71,6 +71,9 @@ type HeroMovement struct { TownVisitStartedAt time.Time TownVisitLogsEmitted int + // TownNPCUILock: while true, town NPC visit narration timers do not advance (hero opened shop/quest UI). + TownNPCUILock bool + // RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild). RoadsideThoughtNextAt time.Time @@ -81,6 +84,8 @@ type HeroMovement struct { // TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end). TownLeaveAt time.Time + // TownLastNPCLingerUntil: after the final queued NPC visit ends, wait near them until this time before walking to plaza (shifted while TownNPCUILock). + TownLastNPCLingerUntil time.Time // TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded). TownPlazaHealActive bool @@ -158,6 +163,7 @@ type townPausePersistSignature struct { InTownNPCQueueFP uint64 InTownVisitName string InTownVisitType string + InTownLastNPCLinger time.Time } func npcQueueFingerprint(q []int64) uint64 { @@ -531,6 +537,7 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) { hm.RestUntil = shift(hm.RestUntil) hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) + hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil) hm.TownLeaveAt = shift(hm.TownLeaveAt) hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) @@ -806,6 +813,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { hm.clearTownCenterWalk() hm.TownPlazaHealActive = false hm.TownLeaveAt = time.Time{} + hm.TownLastNPCLingerUntil = time.Time{} if graph != nil && hm.CurrentTownID == 0 { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } @@ -1129,6 +1137,7 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} + hm.TownLastNPCLingerUntil = time.Time{} hm.TownRestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.ActiveRestKind = model.RestKindNone @@ -1164,6 +1173,7 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} + hm.TownLastNPCLingerUntil = time.Time{} hm.TownRestHealRemainder = 0 hm.RestUntil = time.Time{} hm.ActiveRestKind = model.RestKindNone @@ -1390,6 +1400,7 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature { sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue) sig.InTownVisitName = hm.TownVisitNPCName sig.InTownVisitType = hm.TownVisitNPCType + sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil } return sig } @@ -1439,6 +1450,10 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { t := hm.TownVisitStartedAt p.TownVisitStartedAt = &t } + if !hm.TownLastNPCLingerUntil.IsZero() { + t := hm.TownLastNPCLingerUntil + p.TownLastNPCLingerUntil = &t + } if hm.TownPlazaHealActive { p.TownPlazaHealActive = true } @@ -1537,6 +1552,9 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) if blob.TownVisitStartedAt != nil { hm.TownVisitStartedAt = *blob.TownVisitStartedAt } + if blob.TownLastNPCLingerUntil != nil { + hm.TownLastNPCLingerUntil = *blob.TownLastNPCLingerUntil + } hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted hm.TownNPCWalkTargetID = blob.NPCWalkTargetID hm.TownNPCWalkToX = blob.NPCWalkToX @@ -1662,8 +1680,24 @@ type AfterTownEnterPersist func(hero *model.Hero) // Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping. type TownNPCOfflineInteractHook func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) bool +func townLastNpcLingerDuration() time.Duration { + ms := tuning.Get().TownLastNpcLingerMs + if ms <= 0 { + ms = tuning.DefaultValues().TownLastNpcLingerMs + } + return time.Duration(ms) * time.Millisecond +} + +// scheduleLastNPCLingerFrom starts the “stand near last NPC” window when the NPC tour queue is empty. +func (hm *HeroMovement) scheduleLastNPCLingerFrom(now time.Time) { + if hm == nil || hm.State != model.StateInTown || len(hm.TownNPCQueue) != 0 { + return + } + hm.TownLastNPCLingerUntil = now.Add(townLastNpcLingerDuration()) +} + func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { - if log == nil || hm.TownVisitStartedAt.IsZero() { + if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock { return } logInterval := townNPCLogInterval() @@ -1682,6 +1716,28 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv } } +// skipTownNPCNarrationForDialogClose clears the per-NPC narration window after the player +// closes shop / healer / quest UI so the next movement tick can roll the next queued NPC or plaza rest. +func (hm *HeroMovement) skipTownNPCNarrationForDialogClose(now time.Time) { + if hm == nil || hm.State != model.StateInTown { + return + } + if hm.TownNPCWalkTargetID != 0 { + return + } + wasInVisit := !hm.TownVisitStartedAt.IsZero() + hm.TownVisitNPCName = "" + hm.TownVisitNPCKey = "" + hm.TownVisitNPCType = "" + hm.TownVisitStartedAt = time.Time{} + hm.TownVisitLogsEmitted = 0 + hm.NextTownNPCRollAt = time.Time{} + hm.TownNPCUILock = false + if wasInVisit && len(hm.TownNPCQueue) == 0 { + hm.scheduleLastNPCLingerFrom(now) + } +} + // --- Excursion (mini-adventure) FSM helpers --- func smoothstep(t float64) float64 { @@ -2027,6 +2083,20 @@ func ProcessSingleHeroMovementTick( } hm.LastMoveTick = now + // While a town NPC dialog (shop / quests) is open, freeze narration deadlines by shifting anchors. + if hm.TownNPCUILock && dtTown > 0 { + shift := time.Duration(dtTown * float64(time.Second)) + if !hm.TownVisitStartedAt.IsZero() { + hm.TownVisitStartedAt = hm.TownVisitStartedAt.Add(shift) + } + if !hm.NextTownNPCRollAt.IsZero() { + hm.NextTownNPCRollAt = hm.NextTownNPCRollAt.Add(shift) + } + if !hm.TownLastNPCLingerUntil.IsZero() { + hm.TownLastNPCLingerUntil = hm.TownLastNPCLingerUntil.Add(shift) + } + } + // --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) --- if hm.TownCenterWalkActive { walkSpeed := cfg.TownNPCWalkSpeed @@ -2148,12 +2218,15 @@ func ProcessSingleHeroMovementTick( } // NPC visit pause ended: clear visit log state before the next roll. - if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { + if !hm.TownNPCUILock && !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { hm.TownVisitNPCName = "" hm.TownVisitNPCKey = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 + if len(hm.TownNPCQueue) == 0 { + hm.scheduleLastNPCLingerFrom(now) + } } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) @@ -2170,6 +2243,22 @@ func ProcessSingleHeroMovementTick( } return } + // After the last NPC: stay at the stand point until linger ends and dialog is not open. + if !hm.TownLastNPCLingerUntil.IsZero() { + if hm.TownNPCUILock || now.Before(hm.TownLastNPCLingerUntil) { + if sender != nil && hm.Hero != nil { + sender.SendToHero(heroID, "hero_state", hm.Hero) + sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ + X: hm.CurrentX, Y: hm.CurrentY, + TargetX: hm.CurrentX, TargetY: hm.CurrentY, + Speed: 0, Heading: 0, + }) + } + hm.SyncToHero() + return + } + hm.TownLastNPCLingerUntil = time.Time{} + } cx, cy := town.WorldX, town.WorldY const plazaEps = 0.55 dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy) diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 5314913..a5df784 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -282,8 +282,8 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps { } // applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI). +// With no live WebSocket, service use (gear, potion, heal, quest accept) each fires independently with probability 0.2 when affordable. func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { - _ = graph _ = now cfg := tuning.Get() inter := cfg.TownNPCInteractChance @@ -300,6 +300,13 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID if h == nil { return false } + var town *model.Town + if graph != nil { + town = graph.Towns[hm.CurrentTownID] + } + townLv := TownEffectiveLevel(town) + const offlineServiceChance = 0.2 + switch npc.Type { case "merchant": share := cfg.MerchantTownAutoSellShare @@ -315,22 +322,33 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID }, }) } - potionCost, _ := tuning.EffectiveNPCShopCosts() - if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 { - h.Gold -= potionCost - h.Potions++ - if al != nil { + gearCost := tuning.EffectiveTownMerchantGearCost(townLv) + if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost && rand.Float64() < offlineServiceChance { + h.Gold -= gearCost + drop, err := ApplyTownMerchantGearPurchase(ctx, s.gearStore, h, townLv, now) + if err != nil { + h.Gold += gearCost + s.logger.Warn("offline town merchant gear", "hero_id", heroID, "error", err) + } else if al != nil && drop != nil { + townKey := "" + if town != nil { + townKey = town.NameKey + } al(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ - Code: model.LogPhrasePurchasedPotionFromNPC, - Args: map[string]any{"npcKey": npc.NameKey}, + Code: model.LogPhraseBoughtGearTownMerchant, + Args: map[string]any{ + "npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType, + "rarity": string(drop.Rarity), "itemId": drop.ItemID, + }, }, }) } } case "healer": _, healCost := tuning.EffectiveNPCShopCosts() - if h.HP < h.MaxHP && healCost > 0 && h.Gold >= healCost { + potionCost, _ := tuning.EffectiveNPCShopCosts() + if healCost > 0 && h.HP < h.MaxHP && h.Gold >= healCost && rand.Float64() < offlineServiceChance { h.Gold -= healCost h.HP = h.MaxHP if al != nil { @@ -342,6 +360,18 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID }) } } + if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < offlineServiceChance { + h.Gold -= potionCost + h.Potions++ + if al != nil { + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPhrasePurchasedPotionFromNPC, + Args: map[string]any{"npcKey": npc.NameKey}, + }, + }) + } + } case "quest_giver": if s.questStore == nil { return true @@ -355,7 +385,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID for _, hq := range hqs { taken[hq.QuestID] = struct{}{} } - offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, h.Level) + offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv) if err != nil { s.logger.Warn("offline town npc: list quests by npc", "error", err) return true @@ -377,6 +407,17 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID } return true } + if rand.Float64() >= offlineServiceChance { + if al != nil { + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPhraseQuestGiverChecked, + Args: map[string]any{"npcKey": npc.NameKey}, + }, + }) + } + return true + } pick := candidates[rand.Intn(len(candidates))] ok, err := s.questStore.TryAcceptQuest(ctx, heroID, pick.ID) if err != nil { @@ -650,19 +691,9 @@ func HeroHasEquippedGear(h *model.Hero) bool { return false } -// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear. +// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.). func HeroHasEquippedGearForCombat(h *model.Hero) bool { - if h == nil { - return false - } - h.EnsureGearMap() - var c = 0 - for _, it := range h.Gear { - if it != nil { - c++ - } - } - return c > 4 + return HeroHasEquippedGear(h) } func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) { diff --git a/backend/internal/game/town_level.go b/backend/internal/game/town_level.go new file mode 100644 index 0000000..f83dafe --- /dev/null +++ b/backend/internal/game/town_level.go @@ -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 +} diff --git a/backend/internal/game/town_merchant_gear.go b/backend/internal/game/town_merchant_gear.go new file mode 100644 index 0000000..0fd4f9d --- /dev/null +++ b/backend/internal/game/town_merchant_gear.go @@ -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 [1−v, 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) +} diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 63a0d0b..7a172ee 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -818,17 +818,28 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { if hero == nil { townsWithNPCs := h.buildTownsWithNPCs(r.Context()) pCost, hCost := tuning.EffectiveNPCShopCosts() + cfg := tuning.Get() + gearBase := cfg.MerchantTownGearCostBase + if gearBase <= 0 { + gearBase = tuning.DefaultValues().MerchantTownGearCostBase + } + gearPer := cfg.MerchantTownGearCostPerTownLevel + if gearPer < 0 { + gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel + } writeJSON(w, http.StatusOK, map[string]any{ - "hero": nil, - "needsName": true, - "offlineReport": nil, - "mapRef": h.world.RefForLevel(1), - "towns": townsWithNPCs, - "npcCostPotion": pCost, - "npcCostHeal": hCost, - "serverVersion": version.Version, - "showChangelog": false, - "changelog": nil, + "hero": nil, + "needsName": true, + "offlineReport": nil, + "mapRef": h.world.RefForLevel(1), + "towns": townsWithNPCs, + "npcCostPotion": pCost, + "npcCostHeal": hCost, + "merchantTownGearCostBase": gearBase, + "merchantTownGearCostPerTownLevel": gearPer, + "serverVersion": version.Version, + "showChangelog": false, + "changelog": nil, }) return } @@ -904,6 +915,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { // Build towns with NPCs for the frontend map. townsWithNPCs := h.buildTownsWithNPCs(r.Context()) pCost, hCost := tuning.EffectiveNPCShopCosts() + cfgT := tuning.Get() + gearBase := cfgT.MerchantTownGearCostBase + if gearBase <= 0 { + gearBase = tuning.DefaultValues().MerchantTownGearCostBase + } + gearPer := cfgT.MerchantTownGearCostPerTownLevel + if gearPer < 0 { + gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel + } model.AttachDebuffCatalogForClient(hero) @@ -918,16 +938,18 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ - "hero": hero, - "needsName": needsName, - "offlineReport": report, - "mapRef": h.world.RefForLevel(hero.Level), - "towns": townsWithNPCs, - "npcCostPotion": pCost, - "npcCostHeal": hCost, - "serverVersion": version.Version, - "showChangelog": showChangelog, - "changelog": changelogPayload, + "hero": hero, + "needsName": needsName, + "offlineReport": report, + "mapRef": h.world.RefForLevel(hero.Level), + "towns": townsWithNPCs, + "npcCostPotion": pCost, + "npcCostHeal": hCost, + "merchantTownGearCostBase": gearBase, + "merchantTownGearCostPerTownLevel": gearPer, + "serverVersion": version.Version, + "showChangelog": showChangelog, + "changelog": changelogPayload, }) } diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index a2244ee..7cc6290 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -30,6 +30,12 @@ type NPCHandler struct { hub *Hub } +// merchantStockRow is one town merchant shelf row (stats + per-item gold fixed at open). +type merchantStockRow struct { + model.GearItem + Cost int64 `json:"cost"` +} + // NewNPCHandler creates a new NPCHandler. func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger, eng *game.Engine, hub *Hub) *NPCHandler { return &NPCHandler{ @@ -79,6 +85,41 @@ func dist2D(x1, y1, x2, y2 float64) float64 { return math.Sqrt(dx*dx + dy*dy) } +// loadHeroNPCInTown loads the hero, NPC row, town, and checks hero stand position is inside the town radius. +func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID int64, posX, posY float64, wantNPCType string) (*model.Hero, *model.NPC, *model.Town, error) { + if npcID == 0 { + return nil, nil, nil, fmt.Errorf("npcId is required") + } + hero, err := h.heroStore.GetByTelegramID(ctx, telegramID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load hero") + } + if hero == nil { + return nil, nil, nil, fmt.Errorf("hero not found") + } + npc, err := h.questStore.GetNPCByID(ctx, npcID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load npc") + } + if npc == nil { + return nil, nil, nil, fmt.Errorf("npc not found") + } + if wantNPCType != "" && npc.Type != wantNPCType { + return nil, nil, nil, fmt.Errorf("npc type mismatch") + } + town, err := h.questStore.GetTown(ctx, npc.TownID) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load town") + } + if town == nil { + return nil, nil, nil, fmt.Errorf("town not found") + } + if dist2D(posX, posY, town.WorldX, town.WorldY) > town.Radius { + return nil, nil, nil, fmt.Errorf("hero is too far from the town") + } + return hero, npc, town, nil +} + // InteractNPC handles POST /api/v1/hero/npc-interact. // The hero interacts with a specific NPC; checks proximity to the NPC's town. func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { @@ -176,7 +217,8 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) timeBucket := time.Now().UTC().Unix() / refreshSeconds limit := tuning.EffectiveQuestOffersPerNPC() - quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, hero.Level, limit, timeBucket) + townOfferLevel := game.TownEffectiveLevel(town) + quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, townOfferLevel, limit, timeBucket) if err != nil { h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ @@ -199,22 +241,29 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { } case "merchant": - potionCost, _ := tuning.EffectiveNPCShopCosts() + gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town)) + actions = append(actions, model.NPCInteractAction{ + ActionType: "shop_item", + ItemKey: "shop.merchant_gear_rows", + ItemName: "Town gear", + ItemCost: gearCost, + Description: "Stock is rolled when you open the shop (town-tier stats shown before purchase).", + }) + + case "healer": + potionCost, healCost := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", ItemKey: "shop.healing_potion", ItemName: "Healing Potion", ItemCost: potionCost, - Description: "Restores health. Always handy in a pinch.", + Description: "Restores health in combat.", }) - - case "healer": - _, healCost := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "heal", - ItemKey: "shop.full_heal", - ItemName: "Full Heal", - ItemCost: healCost, + ItemKey: "shop.full_heal", + ItemName: "Full Heal", + ItemCost: healCost, Description: "Restore hero to full HP.", }) } @@ -334,9 +383,9 @@ func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) err } // grantMerchantLoot rolls one random gear piece; auto-equips if better. -// Outside town, unwanted pieces are discarded (gold for sells only in town). +// refLevel drives ilvl (hero level for wandering merchant, town tier for static shops). // Cost must already be deducted from hero.Gold. -func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time) (*model.LootDrop, error) { +func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time, refLevel int) (*model.LootDrop, error) { slots := model.AllEquipmentSlots if h.gearStore == nil { return nil, errors.New("failed to roll gear") @@ -354,7 +403,7 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no } rarity := model.RollRarity() - ilvl := model.RollIlvl(hero.Level, false) + ilvl := model.RollIlvl(refLevel, false) item := model.NewGearItem(family, ilvl, rarity) ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second) @@ -466,7 +515,7 @@ func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) erro hero.Gold -= cost now := time.Now() - drop, err := h.grantMerchantLoot(ctx, hero, now) + drop, err := h.grantMerchantLoot(ctx, hero, now, hero.Level) if err != nil { hero.Gold += cost return err @@ -544,7 +593,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { hero.Gold -= cost now := time.Now() - drop, err := h.grantMerchantLoot(r.Context(), hero, now) + drop, err := h.grantMerchantLoot(r.Context(), hero, now, hero.Level) if err != nil { hero.Gold += cost writeJSON(w, http.StatusInternalServerError, map[string]string{ @@ -590,7 +639,9 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { } var req struct { - NPCID int64 `json:"npcId"` + NPCID int64 `json:"npcId"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ @@ -599,35 +650,35 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { return } - hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) - if err != nil { - h.logger.Error("failed to get hero for heal", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to load hero", - }) - return - } - if hero == nil { - writeJSON(w, http.StatusNotFound, map[string]string{ - "error": "hero not found", - }) - return - } - - // Verify NPC is a healer. + var hero *model.Hero if req.NPCID != 0 { - npc, err := h.questStore.GetNPCByID(r.Context(), req.NPCID) + var err error + hero, _, _, err = h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer") if err != nil { - h.logger.Error("failed to get npc for heal", "npc_id", req.NPCID, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to load npc", - }) + msg := err.Error() + switch msg { + case "hero not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "npc not found", "town not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "failed to load hero", "failed to load npc", "failed to load town": + h.logger.Error("npc heal lookup", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + } return } - if npc == nil || npc.Type != "healer" { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "npc is not a healer", - }) + } else { + var err error + hero, err = h.heroStore.GetByTelegramID(r.Context(), telegramID) + if err != nil { + h.logger.Error("failed to get hero for heal", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) return } } @@ -652,12 +703,14 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { } h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHealedFullTown}}) - // Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client. + if h.engine != nil { + h.engine.ApplyPersistedHeroSnapshot(hero) + } writeHeroJSON(w, http.StatusOK, hero) } // BuyPotion handles POST /api/v1/hero/npc-buy-potion. -// A merchant NPC sells a healing potion for the runtime-configured gold cost. +// A healer NPC sells a healing potion (hero must stand in town near the NPC's town). func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { @@ -667,18 +720,32 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { return } - hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) - if err != nil { - h.logger.Error("failed to get hero for buy potion", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to load hero", + var req struct { + NPCID int64 `json:"npcId"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid request body", }) return } - if hero == nil { - writeJSON(w, http.StatusNotFound, map[string]string{ - "error": "hero not found", - }) + + hero, _, _, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer") + if err != nil { + msg := err.Error() + switch msg { + case "hero not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "npc not found", "town not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "failed to load hero", "failed to load npc", "failed to load town": + h.logger.Error("buy potion lookup", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + } return } @@ -702,5 +769,210 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { } h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseBoughtPotionTown}}) + if h.engine != nil { + h.engine.ApplyPersistedHeroSnapshot(hero) + } writeHeroJSON(w, http.StatusOK, hero) } + +// BuyTownMerchantGear handles POST /api/v1/hero/npc-buy-town-gear. +// Purchases one row from the current merchant stock (see POST .../npc-merchant-stock); equips immediately. +func (h *NPCHandler) BuyTownMerchantGear(w http.ResponseWriter, r *http.Request) { + telegramID, ok := resolveTelegramID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "missing telegramId", + }) + return + } + + var req struct { + NPCID int64 `json:"npcId"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` + OfferIndex int `json:"offerIndex"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid request body", + }) + return + } + + hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "merchant") + if err != nil { + msg := err.Error() + switch msg { + case "hero not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "npc not found", "town not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "failed to load hero", "failed to load npc", "failed to load town": + h.logger.Error("buy town gear lookup", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + } + return + } + + if h.gearStore == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear store unavailable"}) + return + } + if h.engine == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"}) + return + } + + item, price, ok := h.engine.TakeMerchantOffer(hero.ID, req.NPCID, req.OfferIndex) + if !ok || item == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid or expired shop offer — reopen the merchant", + }) + return + } + if hero.Gold < price { + h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price) + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": fmt.Sprintf("not enough gold (need %d, have %d)", price, hero.Gold), + }) + return + } + + hero.Gold -= price + now := time.Now() + drop, err := game.ApplyPreparedTownMerchantPurchase(r.Context(), h.gearStore, hero, item, now) + if err != nil { + hero.Gold += price + h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price) + if errors.Is(err, storage.ErrInventoryFull) { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "inventory full — free a backpack slot to swap gear", + }) + return + } + h.logger.Warn("town merchant gear failed", "hero_id", hero.ID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to grant gear"}) + return + } + + if err := h.heroStore.Save(r.Context(), hero); err != nil { + h.logger.Error("failed to save hero after town gear", "hero_id", hero.ID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"}) + return + } + + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPhraseBoughtGearTownMerchant, + Args: map[string]any{ + "npcKey": npc.NameKey, "townKey": town.NameKey, "slot": drop.ItemType, "rarity": string(drop.Rarity), "itemId": drop.ItemID, + }, + }, + }) + h.engine.ApplyPersistedHeroSnapshot(hero) + writeHeroJSON(w, http.StatusOK, hero) +} + +// NPCDialogPause handles POST /api/v1/hero/npc-dialog-pause. +// While open, the engine freezes town NPC visit narration timers (shop / quest UI). +func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) { + telegramID, ok := resolveTelegramID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"}) + return + } + var req struct { + Open bool `json:"open"` + AdvanceTownVisit bool `json:"advanceTownVisit"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID) + if err != nil { + h.logger.Error("npc dialog pause: load hero", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) + return + } + if h.engine != nil { + if !req.Open && req.AdvanceTownVisit { + h.engine.SkipTownNPCNarrationAfterDialog(hero.ID) + h.engine.ClearMerchantStock(hero.ID) + } else { + h.engine.SetTownNPCUILock(hero.ID, req.Open) + if !req.Open { + h.engine.ClearMerchantStock(hero.ID) + } + } + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// MerchantStock handles POST /api/v1/hero/npc-merchant-stock. +// Rolls town-tier gear rows (not persisted until purchase) and caches them on the engine. +func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) { + telegramID, ok := resolveTelegramID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"}) + return + } + var req struct { + NPCID int64 `json:"npcId"` + PositionX float64 `json:"positionX"` + PositionY float64 `json:"positionY"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if h.engine == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"}) + return + } + hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "merchant") + if err != nil { + msg := err.Error() + switch msg { + case "hero not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "npc not found", "town not found": + writeJSON(w, http.StatusNotFound, map[string]string{"error": msg}) + case "failed to load hero", "failed to load npc", "failed to load town": + h.logger.Error("merchant stock lookup", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"}) + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + } + return + } + townLv := game.TownEffectiveLevel(town) + n := tuning.EffectiveMerchantTownStockCount() + items := game.RollTownMerchantStockItems(townLv, n) + costs := make([]int64, len(items)) + for i, it := range items { + if it == nil { + continue + } + costs[i] = game.RollTownMerchantOfferGold(it.Ilvl, it.Rarity, townLv) + } + h.engine.SetTownNPCUILock(hero.ID, true) + h.engine.SetMerchantStock(hero.ID, npc.ID, town.ID, items, costs) + rows := make([]merchantStockRow, len(items)) + for i, it := range items { + if it == nil { + continue + } + rows[i].GearItem = *it + rows[i].Cost = costs[i] + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": rows, + }) +} diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index 3c03275..6914da3 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/tuning" @@ -162,7 +163,30 @@ func (h *QuestHandler) ListQuestsByNPC(w http.ResponseWriter, r *http.Request) { refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second) timeBucket := time.Now().UTC().Unix() / refreshSeconds limit := tuning.EffectiveQuestOffersPerNPC() - quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, hero.Level, limit, timeBucket) + npcRow, err := h.questStore.GetNPCByID(r.Context(), npcID) + if err != nil { + h.logger.Error("failed to get npc for npc quests", "npc_id", npcID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load npc", + }) + return + } + if npcRow == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "npc not found"}) + return + } + town, err := h.questStore.GetTown(r.Context(), npcRow.TownID) + if err != nil { + h.logger.Error("failed to get town for npc quests", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"}) + return + } + if town == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"}) + return + } + offerLevel := game.TownEffectiveLevel(town) + quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npcID, offerLevel, limit, timeBucket) if err != nil { h.logger.Error("failed to list offerable quests", "npc_id", npcID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ diff --git a/backend/internal/model/adventure_log_phrase_keys.go b/backend/internal/model/adventure_log_phrase_keys.go index aa87dcf..fdefa1b 100644 --- a/backend/internal/model/adventure_log_phrase_keys.go +++ b/backend/internal/model/adventure_log_phrase_keys.go @@ -26,6 +26,7 @@ const ( LogPhraseWanderingAlmsStashed = "log.wandering_alms_stashed" LogPhraseHealedFullTown = "log.healed_full_town" LogPhraseBoughtPotionTown = "log.bought_potion_town" + LogPhraseBoughtGearTownMerchant = "log.bought_gear_town_merchant" LogPhraseSoldItemsMerchant = "log.sold_items_merchant" LogPhraseNPCSkippedVisit = "log.npc_skipped_visit" LogPhrasePurchasedPotionFromNPC = "log.purchased_potion_from_npc" diff --git a/backend/internal/model/buff_catalog.go b/backend/internal/model/buff_catalog.go index ffa518d..b4bcd41 100644 --- a/backend/internal/model/buff_catalog.go +++ b/backend/internal/model/buff_catalog.go @@ -43,20 +43,22 @@ func init() { } func seedBuffMap() map[BuffType]Buff { + // Magnitudes follow docs/specification.md §7.1, then weakened by ⅓ (×2/3) vs the prior canon. + // Shield applies only in combat.CalculateIncomingDamage (not defense stats). return map[BuffType]Buff{ BuffRush: { Type: BuffRush, Name: "Rush", - Duration: 5 * time.Minute, Magnitude: 0.50, + Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // was +50% move → ~+33% CooldownDuration: 15 * time.Minute, }, BuffRage: { Type: BuffRage, Name: "Rage", - Duration: 3 * time.Minute, Magnitude: 1.00, + Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% damage CooldownDuration: 10 * time.Minute, }, BuffShield: { Type: BuffShield, Name: "Shield", - Duration: 5 * time.Minute, Magnitude: 0.50, + Duration: 5 * time.Minute, Magnitude: 1.0 / 3.0, // ~−33% incoming CooldownDuration: 12 * time.Minute, }, BuffLuck: { @@ -66,22 +68,22 @@ func seedBuffMap() map[BuffType]Buff { }, BuffResurrection: { Type: BuffResurrection, Name: "Resurrection", - Duration: 10 * time.Minute, Magnitude: 0.50, + Duration: 10 * time.Minute, Magnitude: 1.0 / 3.0, // ~33% max HP CooldownDuration: 30 * time.Minute, }, BuffHeal: { Type: BuffHeal, Name: "Heal", - Duration: 1 * time.Second, Magnitude: 0.50, + Duration: 1 * time.Second, Magnitude: 1.0 / 3.0, // ~+33% max HP CooldownDuration: 5 * time.Minute, }, BuffPowerPotion: { Type: BuffPowerPotion, Name: "Power Potion", - Duration: 5 * time.Minute, Magnitude: 1.50, + Duration: 5 * time.Minute, Magnitude: 1.0, // was +150% → +100% after ⅔ scaling CooldownDuration: 20 * time.Minute, }, BuffWarCry: { Type: BuffWarCry, Name: "War Cry", - Duration: 3 * time.Minute, Magnitude: 1.00, + Duration: 3 * time.Minute, Magnitude: 2.0 / 3.0, // ~+67% attack speed CooldownDuration: 10 * time.Minute, }, } diff --git a/backend/internal/model/gear.go b/backend/internal/model/gear.go index b9176c8..03fdec4 100644 --- a/backend/internal/model/gear.go +++ b/backend/internal/model/gear.go @@ -21,6 +21,16 @@ type GearItem struct { SpecialEffect string `json:"specialEffect,omitempty"` } +// CloneGearItem returns a deep copy with ID cleared (for ephemeral offers / re-persist). +func CloneGearItem(src *GearItem) *GearItem { + if src == nil { + return nil + } + c := *src + c.ID = 0 + return &c +} + // GearFamily is a template for generating gear drops from the unified catalog. type GearFamily struct { Slot EquipmentSlot `json:"slot"` diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index d7bf4ff..812f178 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -192,17 +192,10 @@ func (h *Hero) activeStatBonuses(now time.Time) statBonuses { out.movementMultiplier *= (1 + ab.Buff.Magnitude) case BuffRage: out.attackMultiplier *= (1 + ab.Buff.Magnitude) - out.strengthBonus += 10 case BuffPowerPotion: out.attackMultiplier *= (1 + ab.Buff.Magnitude) - out.strengthBonus += 12 case BuffWarCry: out.speedMultiplier *= (1 + ab.Buff.Magnitude) - out.strengthBonus += 6 - out.agilityBonus += 6 - case BuffShield: - out.constitutionBonus += 10 - out.defenseMultiplier *= (1 + ab.Buff.Magnitude) } } return out diff --git a/backend/internal/model/hero_test.go b/backend/internal/model/hero_test.go index c1dad02..61b86e0 100644 --- a/backend/internal/model/hero_test.go +++ b/backend/internal/model/hero_test.go @@ -85,33 +85,42 @@ func TestBuffsProvideTemporaryStatEffects(t *testing.T) { Strength: 10, Constitution: 8, Agility: 6, - Buffs: []ActiveBuff{ - { - Buff: mustBuffDef(BuffRage), - AppliedAt: now.Add(-time.Second), - ExpiresAt: now.Add(5 * time.Second), - }, - { - Buff: mustBuffDef(BuffWarCry), - AppliedAt: now.Add(-time.Second), - ExpiresAt: now.Add(5 * time.Second), - }, - { - Buff: mustBuffDef(BuffShield), - AppliedAt: now.Add(-time.Second), - ExpiresAt: now.Add(5 * time.Second), - }, + } + baseAtk := hero.EffectiveAttackAt(now) + baseDef := hero.EffectiveDefenseAt(now) + baseSpd := hero.EffectiveSpeedAt(now) + + rageMag := mustBuffDef(BuffRage).Magnitude + warMag := mustBuffDef(BuffWarCry).Magnitude + + hero.Buffs = []ActiveBuff{ + { + Buff: mustBuffDef(BuffRage), + AppliedAt: now.Add(-time.Second), + ExpiresAt: now.Add(5 * time.Second), + }, + { + Buff: mustBuffDef(BuffWarCry), + AppliedAt: now.Add(-time.Second), + ExpiresAt: now.Add(5 * time.Second), + }, + { + Buff: mustBuffDef(BuffShield), + AppliedAt: now.Add(-time.Second), + ExpiresAt: now.Add(5 * time.Second), }, } - if hero.EffectiveAttackAt(now) <= 30 { - t.Fatalf("expected buffed attack to increase above baseline") + wantAtk := int(float64(baseAtk) * (1 + rageMag)) + if got := hero.EffectiveAttackAt(now); got != wantAtk { + t.Fatalf("expected attack %d (rage mult only), got %d", wantAtk, got) } - if hero.EffectiveDefenseAt(now) <= 5 { - t.Fatalf("expected shield constitution bonus to increase defense") + if got := hero.EffectiveDefenseAt(now); got != baseDef { + t.Fatalf("shield must not change effective defense: base=%d got=%d", baseDef, got) } - if hero.EffectiveSpeedAt(now) <= 1.0 { - t.Fatalf("expected war cry to increase attack speed") + wantSpd := baseSpd * (1 + warMag) + if got := hero.EffectiveSpeedAt(now); math.Abs(got-wantSpd) > 0.001 { + t.Fatalf("expected speed %.4f, got %.4f", wantSpd, got) } } diff --git a/backend/internal/model/town_pause.go b/backend/internal/model/town_pause.go index bd793b9..ec1c4a3 100644 --- a/backend/internal/model/town_pause.go +++ b/backend/internal/model/town_pause.go @@ -24,6 +24,9 @@ type TownPausePersisted struct { NPCWalkToX float64 `json:"npcWalkToX,omitempty"` NPCWalkToY float64 `json:"npcWalkToY,omitempty"` + // After the last NPC visit: stand near them until this time (paused while dialog UI lock is on). + TownLastNPCLingerUntil *time.Time `json:"townLastNpcLingerUntil,omitempty"` + // Plaza: walk to town center after NPC tour, then wait/rest before leaving. TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"` CenterWalkActive bool `json:"centerWalkActive,omitempty"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c7619e9..6dc4b04 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -193,6 +193,9 @@ func New(deps Deps) *chi.Mux { r.Post("/hero/npc-alms", npcH.NPCAlms) r.Post("/hero/npc-heal", npcH.HealHero) r.Post("/hero/npc-buy-potion", npcH.BuyPotion) + r.Post("/hero/npc-buy-town-gear", npcH.BuyTownMerchantGear) + r.Post("/hero/npc-dialog-pause", npcH.NPCDialogPause) + r.Post("/hero/npc-merchant-stock", npcH.MerchantStock) // Gear routes. r.Get("/hero/gear", gameH.GetHeroGear) diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 98a4d2c..65d7e2f 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -33,7 +33,9 @@ type Values struct { TownNPCRollMaxMs int64 `json:"townNpcRollMaxMs"` TownNPCRetryMs int64 `json:"townNpcRetryMs"` TownNPCPauseMs int64 `json:"townNpcPauseMs"` - TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` + // TownLastNpcLingerMs: after the final NPC in the tour, stand near them this long before walking to the plaza (shifted while shop/quest UI is open). + TownLastNpcLingerMs int64 `json:"townLastNpcLingerMs"` + TownNPCLogIntervalMs int64 `json:"townNpcLogIntervalMs"` TownNPCWalkSpeed float64 `json:"townNpcWalkSpeed"` // TownNPCStandoffWorld: hero stops this many world units short of the NPC tile (along approach). TownNPCStandoffWorld float64 `json:"townNpcStandoffWorld"` @@ -83,6 +85,15 @@ type Values struct { NPCCostHeal int64 `json:"npcCostHeal"` NPCCostPotion int64 `json:"npcCostPotion"` NPCCostNearbyRadius float64 `json:"npcCostNearbyRadius"` + // MerchantTownGearCostBase / PerTownLevel: in-town merchant random gear (ilvl/rarity scale with town tier). + MerchantTownGearCostBase int64 `json:"merchantTownGearCostBase"` + MerchantTownGearCostPerTownLevel int64 `json:"merchantTownGearCostPerTownLevel"` + // MerchantTownStockCount: gear rows shown at in-town merchant (hard-capped small). + MerchantTownStockCount int `json:"merchantTownStockCount"` + // MerchantTownGearPricePerIlvl: gold multiplier for item level in town merchant pricing (before rarity and variance). + MerchantTownGearPricePerIlvl int64 `json:"merchantTownGearPricePerIlvl"` + // MerchantTownGearPriceVariancePct: uniform random ±% on the listed buy price (e.g. 15 → 85%–115%). + MerchantTownGearPriceVariancePct int `json:"merchantTownGearPriceVariancePct"` // QuestOffersPerNPC caps how many quest templates a quest_giver offers per interaction (after filtering taken quests). QuestOffersPerNPC int `json:"questOffersPerNPC"` // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). @@ -266,6 +277,7 @@ func DefaultValues() Values { TownNPCRollMaxMs: 2600, TownNPCRetryMs: 450, TownNPCPauseMs: 30_000, + TownLastNpcLingerMs: 10_000, TownNPCLogIntervalMs: 5_000, TownNPCWalkSpeed: 3.0, TownNPCStandoffWorld: 0.65, @@ -307,6 +319,11 @@ func DefaultValues() Values { NPCCostHeal: 100, NPCCostPotion: 50, NPCCostNearbyRadius: 3.0, + MerchantTownGearCostBase: 180, + MerchantTownGearCostPerTownLevel: 40, + MerchantTownStockCount: 3, + MerchantTownGearPricePerIlvl: 115, + MerchantTownGearPriceVariancePct: 15, QuestOffersPerNPC: 2, QuestOfferRefreshHours: 2, QuestOfferDrySpellChance: 0.20, @@ -329,7 +346,7 @@ func DefaultValues() Values { EnemyChainEveryN: 6, EnemyChainMultiplier: 3.0, EnemyEncounterStatMultiplier: 1.2, - EnemyStatMultiplierVsUnequippedHero: 0.75, + EnemyStatMultiplierVsUnequippedHero: 0.85, DebuffProcBurn: 0.18, DebuffProcPoison: 0.10, DebuffProcSlow: 0.25, @@ -342,7 +359,8 @@ func DefaultValues() Values { EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard, SummonCycleSeconds: 18, SummonDamageDivisor: 10, - LuckBuffMultiplier: 1.75, + // Spec §7.1 luck ×2.5, weakened by ⅓ → ×(5/3) on drop chances and gold amount when gold drops. + LuckBuffMultiplier: 5.0 / 3.0, MinAttackIntervalMs: 250, CombatPaceMultiplier: 14, PotionHealPercent: 0.30, @@ -452,6 +470,61 @@ func EffectiveNPCShopCosts() (potionCost, healCost int64) { return potionCost, healCost } +// EffectiveTownMerchantGearCost returns a town-tier gold anchor (used in merchant pricing and legacy paths). +const merchantTownStockHardMax = 3 + +// EffectiveMerchantTownStockCount returns how many gear offers to roll at the town merchant (max 3). +func EffectiveMerchantTownStockCount() int { + n := Get().MerchantTownStockCount + if n <= 0 { + n = DefaultValues().MerchantTownStockCount + } + if n > merchantTownStockHardMax { + n = merchantTownStockHardMax + } + return n +} + +func EffectiveTownMerchantGearCost(townLevel int) int64 { + cfg := Get() + base := cfg.MerchantTownGearCostBase + if base <= 0 { + base = DefaultValues().MerchantTownGearCostBase + } + per := cfg.MerchantTownGearCostPerTownLevel + if per < 0 { + per = DefaultValues().MerchantTownGearCostPerTownLevel + } + if townLevel < 1 { + townLevel = 1 + } + return base + int64(townLevel)*per +} + +// EffectiveMerchantTownGearPricePerIlvl returns the per–item-level gold factor for town merchant offers. +func EffectiveMerchantTownGearPricePerIlvl() int64 { + cfg := Get() + v := cfg.MerchantTownGearPricePerIlvl + if v <= 0 { + v = DefaultValues().MerchantTownGearPricePerIlvl + } + return v +} + +// EffectiveMerchantTownGearPriceVariancePct returns ±% jitter (clamped) for town merchant prices. +func EffectiveMerchantTownGearPriceVariancePct() int { + cfg := Get() + v := cfg.MerchantTownGearPriceVariancePct + d := DefaultValues().MerchantTownGearPriceVariancePct + if v < 0 || v > 45 { + if d < 0 { + d = 15 + } + return d + } + return v +} + // EffectiveQuestOffersPerNPC returns the max quest offers per quest_giver interaction from runtime tuning. func EffectiveQuestOffersPerNPC() int { n := Get().QuestOffersPerNPC diff --git a/docs/specification.md b/docs/specification.md index d616b61..12d719a 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -535,14 +535,14 @@ secondaryOut = round( baseSecondary × M(rarity) ) | Бафф | Эффект | |------|--------| -| Рывок | +50% движение | -| Ярость | +100% урон | -| Щит | -50% входящий урон | -| Удача | x2.5 лут | -| Воскрешение | Воскрес 50% HP | -| Исцеление | +50% HP | -| Зелье силы | +150% урон | -| Боевой клич | +100% Attack speed | +| Рывок | ~+33% движение (канон: `magnitude` в каталоге баффов) | +| Ярость | ~+67% урон | +| Щит | ~−33% входящий урон (только множитель к входящему; без бонуса к защите) | +| Удача | ×(5/3) (~×1,67) к шансам золота/предмета и к сумме золота при выпадении (см. §8.1) | +| Воскрешение | Воскрес ~33% max HP | +| Исцеление | ~+33% max HP мгновенно | +| Зелье силы | +100% урон | +| Боевой клич | ~+67% скорость атаки | ### 7.2 Дебаффы (6 штук) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbff099..14e02c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,6 @@ import { buildMerchantLootDrop, } from './game/ws-handler'; import { - ApiError, initHero, ackChangelog, getAdventureLog, @@ -27,10 +26,8 @@ import { abandonQuest, getAchievements, getNearbyHeroes, - buyPotion, - healAtNPC, requestRevive, - defaultNpcShopCosts, + defaultNpcShopBundle, npcShopCostsFromInit, offlineReportHasActivity, } from './network/api'; @@ -241,6 +238,9 @@ function townToTownData( worldX: town.worldX + n.offsetX, worldY: town.worldY + n.offsetY, buildingId: n.buildingId, + townId: town.id, + townLevelMin: town.levelMin, + townLevelMax: town.levelMax, })); return { id: town.id, @@ -387,7 +387,7 @@ export function App() { // Wandering NPC encounter state const [wanderingNPC, setWanderingNPC] = useState(null); - const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopCosts); + const [npcShopCosts, setNpcShopCosts] = useState(defaultNpcShopBundle); // Achievements const [achievements, setAchievements] = useState([]); const prevAchievementsRef = useRef([]); @@ -908,6 +908,7 @@ export function App() { setNearestNPC(null); setNpcInteractionDismissed(null); const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name; + const tw = townsRef.current.find((t) => t.id === p.townId); setNpcVisitAwaitingProximity({ id: p.npcId, name: displayName, @@ -915,6 +916,9 @@ export function App() { type: p.type as NPCData['type'], worldX: p.worldX ?? 0, worldY: p.worldY ?? 0, + townId: p.townId, + townLevelMin: tw?.levelMin ?? 1, + townLevelMax: tw?.levelMax ?? 1, }); }, @@ -1120,16 +1124,16 @@ export function App() { if (!alreadyActive) { switch (type) { case BuffType.Rage: - damage = Math.round(damage * 2); + damage = Math.round((damage * 5) / 3); break; case BuffType.PowerPotion: - damage = Math.round(damage * 2.5); + damage = Math.round(damage * 2); break; case BuffType.WarCry: - attackSpeed = Math.round(attackSpeed * 2 * 100) / 100; + attackSpeed = Math.round(((attackSpeed * 5) / 3) * 100) / 100; break; case BuffType.Heal: - hp = Math.min(maxHp, hp + Math.round(maxHp * 0.5)); + hp = Math.min(maxHp, hp + Math.round(maxHp / 3)); break; } } @@ -1336,61 +1340,19 @@ export function App() { const handleNPCViewQuests = useCallback((npc: NPCData) => { const matchedNPC: NPC = { id: npc.id, - townId: 0, + townId: npc.townId, name: npc.name, nameKey: npc.nameKey, type: npc.type, offsetX: 0, offsetY: 0, + townLevelMin: npc.townLevelMin, + townLevelMax: npc.townLevelMax, }; setSelectedNPC(matchedNPC); setNpcInteractionDismissed(npc.id); }, []); - const handleNPCBuyPotion = useCallback((_npc: NPCData) => { - const telegramId = getTelegramUserId() ?? 1; - buyPotion(telegramId) - .then((hero) => { - hapticImpact('medium'); - setToast({ message: t(tr.boughtPotion, { cost: npcShopCosts.potionCost }), color: '#88dd88' }); - handleNPCHeroUpdated(hero); - // Server logs purchase + WS - }) - .catch((err) => { - console.warn('[App] Failed to buy potion:', err); - if (err instanceof ApiError) { - try { - const j = JSON.parse(err.body) as { error?: string }; - setToast({ message: j.error ?? tr.failedToBuyPotion, color: '#ff4444' }); - } catch { - setToast({ message: tr.failedToBuyPotion, color: '#ff4444' }); - } - } - }); - }, [handleNPCHeroUpdated, npcShopCosts.potionCost, tr]); - - const handleNPCHeal = useCallback((npc: NPCData) => { - const telegramId = getTelegramUserId() ?? 1; - healAtNPC(telegramId, npc.id) - .then((hero) => { - hapticImpact('medium'); - setToast({ message: tr.healedToFull, color: '#44cc44' }); - handleNPCHeroUpdated(hero); - // Server logs heal + WS - }) - .catch((err) => { - console.warn('[App] Failed to heal:', err); - if (err instanceof ApiError) { - try { - const j = JSON.parse(err.body) as { error?: string }; - setToast({ message: j.error ?? tr.failedToHeal, color: '#ff4444' }); - } catch { - setToast({ message: tr.failedToHeal, color: '#ff4444' }); - } - } - }); - }, [handleNPCHeroUpdated]); - const handleNPCInteractionDismiss = useCallback(() => { if (nearestNPC) { setNpcInteractionDismissed(nearestNPC.id); @@ -1539,12 +1501,8 @@ export function App() { {showNPCInteraction && nearestNPC && ( )} @@ -1557,6 +1515,7 @@ export function App() { heroGold={gameState.hero?.gold ?? 0} potionCost={npcShopCosts.potionCost} healCost={npcShopCosts.healCost} + getHeroWorldPosition={() => engineRef.current?.getHeroDisplayWorldPosition() ?? { x: 0, y: 0 }} onClose={() => setSelectedNPC(null)} onQuestsChanged={refreshHeroQuests} onHeroUpdated={handleNPCHeroUpdated} diff --git a/frontend/src/game/adventureLogFormat.ts b/frontend/src/game/adventureLogFormat.ts index 4ea3770..2458a7d 100644 --- a/frontend/src/game/adventureLogFormat.ts +++ b/frontend/src/game/adventureLogFormat.ts @@ -247,7 +247,8 @@ function resolveAdventureLogVars( case 'log.leveled_up': return { level: intArg(a, 'level') }; case 'log.equipped_new': - case 'log.wandering_alms_equipped': { + case 'log.wandering_alms_equipped': + case 'log.bought_gear_town_merchant': { const slot = slotName(tr, strArg(a, 'slot')); const rarity = rarityName(tr, strArg(a, 'rarity')); const legacyName = strArg(a, 'itemName'); diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index bcf4355..fb4c9dc 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -297,6 +297,9 @@ export interface NPC { offsetX: number; offsetY: number; buildingId?: number; + /** Present when opened from map/town visit (shop tier). */ + townLevelMin?: number; + townLevelMax?: number; } export interface Quest { @@ -337,6 +340,8 @@ export interface HeroQuest { townName: string; /** Resolved name for visit_town delivery target */ targetTownName?: string; + /** Same as quests.target_town_id — for localized destination label. */ + targetTownId?: number; } // ---- Equipment Item (extended slots per §6.3) ---- @@ -377,6 +382,10 @@ export interface NPCData { worldX: number; worldY: number; buildingId?: number; + /** Town that owns this NPC (for shop tier / costs). */ + townId: number; + townLevelMin: number; + townLevelMax: number; } /** Server-driven building placed in a town */ diff --git a/frontend/src/i18n/contentLabels.ts b/frontend/src/i18n/contentLabels.ts index 0e3416c..8c82aad 100644 --- a/frontend/src/i18n/contentLabels.ts +++ b/frontend/src/i18n/contentLabels.ts @@ -7,18 +7,45 @@ export const WANDERING_MERCHANT_DIALOGUE_KEY = 'npc.wandering_merchant.dialogue. type Bilingual = { en: string; ru: string }; +/** Matches `towns.id` in DB / migrations (000026 name_key order). */ +export const TOWN_ID_TO_NAME_KEY: Record = { + 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 = { - 'town.willowdale.v1': { en: 'Willowdale', ru: 'Ивадол' }, - 'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Тернозорь' }, - 'town.ashengard.v1': { en: 'Ashengard', ru: 'Пепельный гард' }, - 'town.redcliff.v1': { en: 'Redcliff', ru: 'Красная скала' }, - 'town.boghollow.v1': { en: 'Boghollow', ru: 'Торфяная низина' }, - 'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Зола-крепость' }, - 'town.starfall.v1': { en: 'Starfall', ru: 'Звездопад' }, - 'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Мшистая гавань' }, - 'town.emberwell.v1': { en: 'Emberwell', ru: 'Угольный колодец' }, - 'town.frostmark.v1': { en: 'Frostmark', ru: 'Морозная метка' }, - 'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Сумеречный дозор' }, + 'town.willowdale.v1': { en: 'Willowdale', ru: 'Виллоудейл' }, + 'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Торнвотч' }, + 'town.ashengard.v1': { en: 'Ashengard', ru: 'Ашенгард' }, + 'town.redcliff.v1': { en: 'Redcliff', ru: 'Редклифф' }, + 'town.boghollow.v1': { en: 'Boghollow', ru: 'Богхоллоу' }, + 'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Синдеркип' }, + 'town.starfall.v1': { en: 'Starfall', ru: 'Старфолл' }, + 'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Моссхарбор' }, + 'town.emberwell.v1': { en: 'Emberwell', ru: 'Эмбервелл' }, + 'town.frostmark.v1': { en: 'Frostmark', ru: 'Фростмарк' }, + 'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Дасквотч' }, }; const NPCS: Record = { diff --git a/frontend/src/i18n/en.yml b/frontend/src/i18n/en.yml index b363fd9..9659b67 100644 --- a/frontend/src/i18n/en.yml +++ b/frontend/src/i18n/en.yml @@ -52,14 +52,14 @@ ui: buffHeal: Heal buffPowerPotion: Power buffWarCry: War Cry - buffRushDesc: "+50% movement speed" - buffRageDesc: "+100% damage" - buffShieldDesc: "-50% incoming damage" - buffLuckDesc: x2.5 loot drops - buffResurrectionDesc: Revive at 50% HP - buffHealDesc: "+50% HP instant" - buffPowerPotionDesc: "+150% damage" - buffWarCryDesc: "+100% attack speed" + buffRushDesc: "~+33% movement speed" + buffRageDesc: "~+67% damage" + buffShieldDesc: "~−33% incoming damage" + buffLuckDesc: ~×1.67 gold/item chances and gold amount when gold drops + buffResurrectionDesc: Revive at ~33% max HP + buffHealDesc: "~+33% max HP instant" + buffPowerPotionDesc: "+100% damage" + buffWarCryDesc: "~+67% attack speed" charges: Charges refillsAt: Refills at refill: Refill @@ -98,6 +98,15 @@ ui: npc: NPC buyPotion: Buy Potion buyPotionForGold: Buy Potion ({cost}g) + openMerchantShop: Open Shop + openHealerServices: Healer + buyRandomGear: Buy Random Gear + merchantStockLoading: Loading shop… + merchantStockFailed: Could not open the shop. Try again. + buyGearForGold: Buy — {cost} gold + merchantEmptyStock: No offers left. Close and speak to the merchant again. + boughtGearFromMerchant: Bought and equipped gear for {cost} gold + failedToBuyGear: Failed to buy gear healToFull: Heal to Full healToFullForGold: Heal to Full ({cost}g) viewQuests: View Quests @@ -200,6 +209,7 @@ adventure_log: log.wandering_alms_stashed: Stashed {item} in your inventory. log.healed_full_town: Paid for a full heal. log.bought_potion_town: Bought a potion in town. + log.bought_gear_town_merchant: 'Bought from the trader and equipped: {item}.' log.sold_items_merchant: Sold {count} items to {npc} (+{gold} gold). log.npc_skipped_visit: Skipped visiting {npc}. log.purchased_potion_from_npc: Bought a potion from {npc}. diff --git a/frontend/src/i18n/loadLocales.ts b/frontend/src/i18n/loadLocales.ts index 34b7df8..da28304 100644 --- a/frontend/src/i18n/loadLocales.ts +++ b/frontend/src/i18n/loadLocales.ts @@ -1,9 +1,14 @@ import { parse } from 'yaml'; import type { Locale } from './localeCodes'; -import type { LocaleYamlDoc, Translations } from './types'; +import type { LocaleYamlDoc, QuestLocaleBundle, Translations } from './types'; + +/** Parsed `en.yml` / `ru.yml` without bundled `quests.*.yml`. */ +type MainLocaleYamlDoc = Omit; import enRaw from './en.yml?raw'; import ruRaw from './ru.yml?raw'; +import questsEnRaw from './quests.en.yml?raw'; +import questsRuRaw from './quests.ru.yml?raw'; /** Matches backend townVisitLineSlugs (legacy town_visit.type.N → Nth slug). */ export const TOWN_VISIT_SLUG_ORDER: Record = { @@ -41,6 +46,40 @@ export const TOWN_VISIT_SLUG_ORDER: Record = { ], }; +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)) { + if (!v || typeof v !== 'object') { + throw new Error(`${label} quests.${k}: expected title/description map`); + } + const o = v as Record; + 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, b: Record): void { const ak = Object.keys(a).sort(); const bk = Object.keys(b).sort(); @@ -54,7 +93,7 @@ function assertRecordKeysMatch(name: string, a: Record, b: Recor } } -function loadDoc(raw: string): LocaleYamlDoc { +function loadDoc(raw: string): MainLocaleYamlDoc { const doc = parse(raw) as unknown; if (!doc || typeof doc !== 'object') { throw new Error('Invalid locale YAML root'); @@ -92,11 +131,17 @@ function loadDoc(raw: string): LocaleYamlDoc { } } } - return { ui, adventure_log, achievements, roadside, town_npc_visit, enemy_types }; + return { ui, adventure_log, achievements, roadside, town_npc_visit, enemy_types } satisfies MainLocaleYamlDoc; } -export const enDoc = loadDoc(enRaw); -export const ruDoc = loadDoc(ruRaw); +const questsEn = parseQuestBundle(questsEnRaw, 'en'); +const questsRu = parseQuestBundle(questsRuRaw, 'ru'); +assertQuestBundlesMatch(questsEn, questsRu); + +const mainEn = loadDoc(enRaw); +const mainRu = loadDoc(ruRaw); +export const enDoc: LocaleYamlDoc = { ...mainEn, quests: questsEn }; +export const ruDoc: LocaleYamlDoc = { ...mainRu, quests: questsRu }; assertRecordKeysMatch('enemy_types', enDoc.enemy_types, ruDoc.enemy_types); assertRecordKeysMatch('roadside', enDoc.roadside, ruDoc.roadside); @@ -114,6 +159,21 @@ export function achievementLogTitle(locale: Locale, achievementId: string): stri return m[achievementId] ?? ''; } +/** quest_key from DB → title or description; falls back to server string. */ +export function localizedQuestText( + locale: Locale, + questKey: string | undefined, + part: 'title' | 'description', + fallback: string, +): string { + if (!questKey) return fallback; + const bundle = locale === 'ru' ? ruDoc.quests : enDoc.quests; + const row = bundle[questKey]; + if (!row) return fallback; + const s = part === 'title' ? row.title : row.description; + return s?.trim() ? s : fallback; +} + /** Localized display name for DB `enemies.type` slug; empty if unknown. */ export function enemyTypeLabel(locale: Locale, enemyTypeSlug: string): string { const slug = enemyTypeSlug?.trim() ?? ''; diff --git a/frontend/src/i18n/questCopy.ts b/frontend/src/i18n/questCopy.ts index 6704b3f..29035c2 100644 --- a/frontend/src/i18n/questCopy.ts +++ b/frontend/src/i18n/questCopy.ts @@ -1,20 +1 @@ -import type { Locale } from './localeCodes'; - -/** Optional per-quest overrides; keys match `quest_key` from DB (e.g. quest.12). */ -const BUNDLES: Record< - string, - { title: { en: string; ru: string }; description: { en: string; ru: string } } -> = {}; - -export function localizedQuestText( - locale: Locale, - questKey: string | undefined, - part: 'title' | 'description', - fallback: string, -): string { - if (!questKey) return fallback; - const b = BUNDLES[questKey]; - if (!b) return fallback; - const piece = b[part]; - return locale === 'ru' ? (piece.ru || fallback) : (piece.en || fallback); -} +export { localizedQuestText } from './loadLocales'; diff --git a/frontend/src/i18n/ru.yml b/frontend/src/i18n/ru.yml index d70db23..53243d3 100644 --- a/frontend/src/i18n/ru.yml +++ b/frontend/src/i18n/ru.yml @@ -52,14 +52,14 @@ ui: buffHeal: 'Исцеление' buffPowerPotion: 'Сила' buffWarCry: 'Клич' - buffRushDesc: '+50% к скорости движения' - buffRageDesc: '+100% к урону' - buffShieldDesc: '-50% входящего урона' - buffLuckDesc: 'x2.5 дроп предметов' - buffResurrectionDesc: 'Воскрешение с 50% HP' - buffHealDesc: '+50% HP мгновенно' - buffPowerPotionDesc: '+150% к урону' - buffWarCryDesc: '+100% к скорости атаки' + buffRushDesc: '~+33% к скорости движения' + buffRageDesc: '~+67% к урону' + buffShieldDesc: '~−33% входящего урона' + buffLuckDesc: '~×1,67 к шансам золота/предмета и к сумме золота при выпадении' + buffResurrectionDesc: 'Воскрешение с ~33% max HP' + buffHealDesc: '~+33% max HP мгновенно' + buffPowerPotionDesc: '+100% к урону' + buffWarCryDesc: '~+67% к скорости атаки' charges: 'Заряды' refillsAt: 'Обновление в' refill: 'Пополнить' @@ -98,6 +98,15 @@ ui: npc: 'NPC' buyPotion: 'Купить зелье' buyPotionForGold: 'Купить зелье ({cost}з)' + openMerchantShop: 'Лавка' + openHealerServices: 'Целитель' + buyRandomGear: 'Случайная экипировка' + merchantStockLoading: 'Загрузка лавки…' + merchantStockFailed: 'Не удалось открыть лавку. Попробуйте снова.' + buyGearForGold: 'Купить — {cost} золота' + merchantEmptyStock: 'Предложений нет. Закройте окно и снова поговорите с торговцем.' + boughtGearFromMerchant: 'Куплено и надето снаряжение за {cost} золота' + failedToBuyGear: 'Не удалось купить снаряжение' healToFull: 'Исцелить полностью' healToFullForGold: 'Полное лечение ({cost}з)' viewQuests: 'Квесты' @@ -200,6 +209,7 @@ adventure_log: log.wandering_alms_stashed: '{item} убрано в инвентарь.' log.healed_full_town: 'Оплачено полное лечение.' log.bought_potion_town: 'Куплено зелье в городе.' + log.bought_gear_town_merchant: 'Куплено у торговца и надето: {item}.' log.sold_items_merchant: 'Продано предметов: {count} торговцу {npc} (+{gold} золота).' log.npc_skipped_visit: 'Пропущена встреча с {npc}.' log.purchased_potion_from_npc: 'Куплено зелье у {npc}.' diff --git a/frontend/src/i18n/types.ts b/frontend/src/i18n/types.ts index 4ae6a11..fb8fc34 100644 --- a/frontend/src/i18n/types.ts +++ b/frontend/src/i18n/types.ts @@ -98,6 +98,15 @@ export interface Translations { npc: string; buyPotion: string; buyPotionForGold: string; + openMerchantShop: string; + openHealerServices: string; + buyRandomGear: string; + merchantStockLoading: string; + merchantStockFailed: string; + buyGearForGold: string; + merchantEmptyStock: string; + boughtGearFromMerchant: string; + failedToBuyGear: string; healToFull: string; healToFullForGold: string; viewQuests: string; @@ -181,6 +190,9 @@ export interface Translations { export type TranslationKey = keyof Translations; +/** quest_key from DB → localized title/description (see quests.*.yml). */ +export type QuestLocaleBundle = Record; + export interface LocaleYamlDoc { ui: Translations; adventure_log: Record; @@ -191,4 +203,5 @@ export interface LocaleYamlDoc { town_npc_visit: Record>; /** DB `enemies.type` slug → display name (en / ru). */ enemy_types: Record; + quests: QuestLocaleBundle; } diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 2b91486..0a09dae 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -235,10 +235,12 @@ export interface InitHeroResponse { offlineReport: OfflineReport | null; mapRef: MapRefResponse; needsName?: boolean; - /** Runtime tuning: merchant potion price (from DB / runtime_config). */ + /** Runtime tuning: healer potion price (from DB / runtime_config). */ npcCostPotion?: number; /** Runtime tuning: healer full heal price (from DB / runtime_config). */ npcCostHeal?: number; + merchantTownGearCostBase?: number; + merchantTownGearCostPerTownLevel?: number; /** Server build id; bump on backend with changelog entry to show the modal. */ serverVersion?: string; /** True when there is a changelog entry for serverVersion and the player has not ack'd yet. */ @@ -251,13 +253,31 @@ export function defaultNpcShopCosts(): { potionCost: number; healCost: number } return { potionCost: 50, healCost: 100 }; } -export function npcShopCostsFromInit(res: InitHeroResponse): { potionCost: number; healCost: number } { - const d = defaultNpcShopCosts(); +export function defaultMerchantGearCosts(): { + merchantGearCostBase: number; + merchantGearCostPerTownLevel: number; +} { + return { merchantGearCostBase: 35, merchantGearCostPerTownLevel: 6 }; +} + +export type NpcShopBundle = ReturnType; + +export function defaultNpcShopBundle() { + return { ...defaultNpcShopCosts(), ...defaultMerchantGearCosts() }; +} + +export function npcShopCostsFromInit(res: InitHeroResponse): NpcShopBundle { + const d = defaultNpcShopBundle(); const p = res.npcCostPotion; const h = res.npcCostHeal; + const gb = res.merchantTownGearCostBase; + const gp = res.merchantTownGearCostPerTownLevel; return { potionCost: typeof p === 'number' && p > 0 ? p : d.potionCost, healCost: typeof h === 'number' && h > 0 ? h : d.healCost, + merchantGearCostBase: typeof gb === 'number' && gb > 0 ? gb : d.merchantGearCostBase, + merchantGearCostPerTownLevel: + typeof gp === 'number' && gp >= 0 ? gp : d.merchantGearCostPerTownLevel, }; } @@ -592,6 +612,7 @@ function flattenHeroQuest(raw: HeroQuestRaw): HeroQuest { type: raw.type ?? q?.type ?? '', targetCount: raw.targetCount ?? q?.targetCount ?? 0, targetTownName: raw.quest?.targetTownName ?? q?.targetTownName, + targetTownId: q?.targetTownId != null ? Number(q.targetTownId) : undefined, progress: raw.progress, status: (raw.status as HeroQuest['status']) ?? 'accepted', rewardXp: raw.rewardXp ?? q?.rewardXp ?? 0, @@ -629,16 +650,79 @@ export async function abandonQuest(heroQuestId: number, telegramId?: number): Pr // ---- NPC Services ---- -/** Buy a potion from a merchant NPC (matches backend POST /api/v1/hero/npc-buy-potion) */ -export async function buyPotion(telegramId?: number): Promise { +/** Buy a potion from a healer NPC (POST /hero/npc-buy-potion; hero position must be inside the town). */ +export async function buyPotion( + body: { npcId: number; positionX: number; positionY: number }, + telegramId?: number, +): Promise { + const query = telegramId != null ? `?telegramId=${telegramId}` : ''; + return apiPost(`/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 { + 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 { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; - return apiPost(`/hero/npc-buy-potion${query}`); + return apiPost(`/hero/npc-merchant-stock${query}`, body); } -/** Heal to full at a healer NPC (matches backend POST /api/v1/hero/npc-heal; body must be JSON with npcId) */ -export async function healAtNPC(telegramId?: number, npcId?: number): Promise { +/** Buy one row from current merchant stock; equips immediately (POST /hero/npc-buy-town-gear). */ +export async function buyTownMerchantGear( + body: { npcId: number; positionX: number; positionY: number; offerIndex: number }, + telegramId?: number, +): Promise { + const query = telegramId != null ? `?telegramId=${telegramId}` : ''; + return apiPost(`/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 { const query = telegramId != null ? `?telegramId=${telegramId}` : ''; - return apiPost(`/hero/npc-heal${query}`, { npcId: npcId ?? 0 }); + return apiPost(`/hero/npc-heal${query}`, body); } // ---- NPC Proximity & Interaction ---- diff --git a/frontend/src/ui/NPCDialog.tsx b/frontend/src/ui/NPCDialog.tsx index 7be941a..d6c2c03 100644 --- a/frontend/src/ui/NPCDialog.tsx +++ b/frontend/src/ui/NPCDialog.tsx @@ -1,7 +1,18 @@ import { useState, useEffect, useCallback, type CSSProperties } from 'react'; import type { NPC, Quest, HeroQuest } from '../game/types'; -import { getNPCQuests, acceptQuest, claimQuest, buyPotion, healAtNPC } from '../network/api'; -import type { HeroResponse } from '../network/api'; +import { + ApiError, + getNPCQuests, + acceptQuest, + claimQuest, + buyPotion, + buyTownMerchantGear, + healAtNPC, + setNPCDialogPause, + fetchMerchantStock, +} from '../network/api'; +import type { HeroResponse, MerchantGearOfferItem } from '../network/api'; +import { RARITY_COLORS, RARITY_GLOW } from '../shared/constants'; import { getTelegramUserId } from '../shared/telegram'; import { hapticImpact } from '../shared/telegram'; import { useT, t, useLocale } from '../i18n'; @@ -16,6 +27,7 @@ interface NPCDialogProps { heroGold: number; potionCost: number; healCost: number; + getHeroWorldPosition: () => { x: number; y: number }; onClose: () => void; onQuestsChanged: () => void; onHeroUpdated: (hero: HeroResponse) => void; @@ -234,6 +246,36 @@ function questTypeIcon(type: string): string { } } +function prettyEquipmentSlot(slot: string): string { + return slot + .split('_') + .map((p) => (p.length ? p.charAt(0).toUpperCase() + p.slice(1) : p)) + .join(' '); +} + +function gearStatLabel(tr: ReturnType, statType: string): string { + switch (statType) { + case 'attack': + return tr.atk; + case 'defense': + return tr.def; + case 'speed': + return tr.spd; + default: + return tr.stat; + } +} + +function offerRarityStyle(rarity: string): { color: string; textShadow?: string } { + const r = rarity.toLowerCase(); + const color = RARITY_COLORS[r] ?? '#9d9d9d'; + const glow = RARITY_GLOW[r]; + if (glow && glow !== 'none') { + return { color, textShadow: glow }; + } + return { color }; +} + // ---- Component ---- export function NPCDialog({ @@ -242,6 +284,7 @@ export function NPCDialog({ heroGold, potionCost, healCost, + getHeroWorldPosition, onClose, onQuestsChanged, onHeroUpdated, @@ -253,9 +296,54 @@ export function NPCDialog({ const npcDisplayName = npcLabel(locale, npc.nameKey, npc.name); const [availableQuests, setAvailableQuests] = useState([]); const [loading, setLoading] = useState(false); + const [merchantOffers, setMerchantOffers] = useState([]); + const [merchantStockLoading, setMerchantStockLoading] = useState(false); + const [merchantStockError, setMerchantStockError] = useState(false); const telegramId = getTelegramUserId() ?? 1; + // Pause town NPC visit timers while any NPC dialog (shop / quests / healer) is open. + useEffect(() => { + let cancelled = false; + setNPCDialogPause(true, telegramId).catch((err) => { + if (!cancelled) console.warn('[NPCDialog] npc-dialog-pause (open) failed:', err); + }); + return () => { + cancelled = true; + setNPCDialogPause(false, telegramId, { advanceTownVisit: true }).catch((err) => { + console.warn('[NPCDialog] npc-dialog-pause (close) failed:', err); + }); + }; + }, [telegramId]); + + // Merchant: roll stock when the shop opens (server-tier gear for this town). + useEffect(() => { + if (npc.type !== 'merchant') return; + let cancelled = false; + setMerchantStockLoading(true); + setMerchantStockError(false); + const pos = getHeroWorldPosition(); + fetchMerchantStock({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId) + .then((res) => { + if (cancelled) return; + setMerchantOffers(Array.isArray(res.items) ? res.items : []); + }) + .catch((err) => { + if (cancelled) return; + console.warn('[NPCDialog] merchant stock failed:', err); + setMerchantStockError(true); + setMerchantOffers([]); + }) + .finally(() => { + if (!cancelled) setMerchantStockLoading(false); + }); + return () => { + cancelled = true; + }; + // Intentionally depend on npc identity only — position is read once when the dialog opens. + // eslint-disable-next-line react-hooks/exhaustive-deps -- getHeroWorldPosition may be unstable from parent + }, [npc.id, npc.type, telegramId]); + // Fetch available quests for quest giver NPCs useEffect(() => { if (npc.type !== 'quest_giver') return; @@ -318,7 +406,8 @@ export function NPCDialog({ onToast(tr.notEnoughGold, '#ff4444'); return; } - buyPotion(telegramId) + const pos = getHeroWorldPosition(); + buyPotion({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId) .then((hero) => { hapticImpact('medium'); onToast(t(tr.boughtPotion, { cost: potionCost }), '#88dd88'); @@ -326,16 +415,66 @@ export function NPCDialog({ }) .catch((err) => { console.warn('[NPCDialog] Failed to buy potion:', err); - onToast(tr.failedToBuyPotion, '#ff4444'); + if (err instanceof ApiError) { + try { + const j = JSON.parse(err.body) as { error?: string }; + onToast(j.error ?? tr.failedToBuyPotion, '#ff4444'); + } catch { + onToast(tr.failedToBuyPotion, '#ff4444'); + } + } else { + onToast(tr.failedToBuyPotion, '#ff4444'); + } }); - }, [telegramId, heroGold, potionCost, onHeroUpdated, onToast, tr]); + }, [telegramId, heroGold, potionCost, npc.id, getHeroWorldPosition, onHeroUpdated, onToast, tr]); + + const handleBuyTownGear = useCallback( + (offerIndex: number) => { + const row = merchantOffers[offerIndex]; + const rowCost = typeof row?.cost === 'number' ? row.cost : 0; + if (rowCost <= 0) { + onToast(tr.failedToBuyGear, '#ff4444'); + return; + } + if (heroGold < rowCost) { + onToast(tr.notEnoughGold, '#ff4444'); + return; + } + const pos = getHeroWorldPosition(); + buyTownMerchantGear( + { npcId: npc.id, positionX: pos.x, positionY: pos.y, offerIndex }, + telegramId, + ) + .then((hero) => { + hapticImpact('medium'); + onToast(t(tr.boughtGearFromMerchant, { cost: rowCost }), '#c9a227'); + onHeroUpdated(hero); + setMerchantOffers((prev) => prev.filter((_, i) => i !== offerIndex)); + }) + .catch((err) => { + console.warn('[NPCDialog] Failed to buy gear:', err); + if (err instanceof ApiError) { + try { + const j = JSON.parse(err.body) as { error?: string }; + onToast(j.error ?? tr.failedToBuyGear, '#ff4444'); + } catch { + onToast(tr.failedToBuyGear, '#ff4444'); + } + } else { + onToast(tr.failedToBuyGear, '#ff4444'); + } + }); + }, + [telegramId, heroGold, merchantOffers, npc.id, getHeroWorldPosition, onHeroUpdated, onToast, tr], + ); const handleHeal = useCallback(() => { if (heroGold < healCost) { onToast(tr.notEnoughGold, '#ff4444'); return; } - healAtNPC(telegramId, npc.id) + const pos = getHeroWorldPosition(); + healAtNPC({ npcId: npc.id, positionX: pos.x, positionY: pos.y }, telegramId) .then((hero) => { hapticImpact('medium'); onToast(tr.healedToFull, '#44cc44'); @@ -343,9 +482,18 @@ export function NPCDialog({ }) .catch((err) => { console.warn('[NPCDialog] Failed to heal:', err); - onToast(tr.failedToHeal, '#ff4444'); + if (err instanceof ApiError) { + try { + const j = JSON.parse(err.body) as { error?: string }; + onToast(j.error ?? tr.failedToHeal, '#ff4444'); + } catch { + onToast(tr.failedToHeal, '#ff4444'); + } + } else { + onToast(tr.failedToHeal, '#ff4444'); + } }); - }, [telegramId, heroGold, healCost, onHeroUpdated, onToast, npc.id, tr]); + }, [telegramId, heroGold, healCost, npc.id, getHeroWorldPosition, onHeroUpdated, onToast, tr]); // Quests relevant to this NPC const npcHeroQuests = heroQuests.filter( @@ -540,6 +688,76 @@ export function NPCDialog({ {npc.type === 'merchant' && ( <>
{tr.shopLabel}
+ {merchantStockLoading ? ( +
+ {tr.merchantStockLoading} +
+ ) : merchantStockError ? ( +
+ {tr.merchantStockFailed} +
+ ) : merchantOffers.length === 0 ? ( +
+ {tr.merchantEmptyStock} +
+ ) : ( + 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 ( +
+
+ {'\u2694\uFE0F'} + {item.name} +
+
+ {item.rarity.charAt(0).toUpperCase() + item.rarity.slice(1)} · ilvl {item.ilvl} ·{' '} + {prettyEquipmentSlot(item.slot)} + {item.subtype ? ` · ${item.subtype}` : ''} +
+
+ {gearStatLabel(tr, item.statType)} {item.primaryStat} + {' · '} + crit {critPct}% + {' · '} + {tr.spd} ×{spd} + {(item.agilityBonus ?? 0) > 0 ? ` · +${item.agilityBonus} AGI` : ''} +
+ +
+ ); + }) + )} +
+ {t(tr.yourGoldLabel, { amount: heroGold })} +
+ + )} + + {/* ---- Healer ---- */} + {npc.type === 'healer' && ( + <> +
{tr.servicesSection}
-
- {t(tr.yourGoldLabel, { amount: heroGold })} -
- - )} - - {/* ---- Healer ---- */} - {npc.type === 'healer' && ( - <> -
{tr.servicesSection}