diff --git a/.cursor/rules/autohero-specification.mdc b/.cursor/rules/autohero-specification.mdc
index a49e933..b4a85a8 100644
--- a/.cursor/rules/autohero-specification.mdc
+++ b/.cursor/rules/autohero-specification.mdc
@@ -53,10 +53,10 @@ alwaysApply: true
- Rarity tiers and approximate drop frequency: Common **75%**, Uncommon **20%**, Rare **4%**, Epic **0.9%**, Legendary **0.1%** (spec §8.1).
- Gold per tier: Common **5–15** … Legendary **1000–5000** (spec §8.2 table).
-- **Gold is always guaranteed** per kill — every victory must award gold.
-- Equipment items are **optional extra drops**, not a replacement for gold.
-- Luck buff boosts loot but does not override guaranteed gold.
-- Reward model: `gold always, item sometimes`.
+- **Gold drop chance** (`goldDropChance` in runtime config) is high by default but **not 100%** — some kills may award no gold from the corpse roll.
+- Equipment drop chance (`equipmentDropBase` × luck); items are **optional** extra drops.
+- Luck buff multiplies **both** gold and equipment drop chances (capped at 100%).
+- Reward model: **`gold sometimes, item sometimes`** (exact values in runtime_config).
## Progression
@@ -84,7 +84,7 @@ alwaysApply: true
- One screen = full game; **icons and color over text**; player stays in flow; **no blocking loads**; **mobile-first**; clear **pause/play** (spec §12).
- UI must **honestly show hero state** — never visually hide HP loss with auto-healing the server/mechanic didn't grant.
-- UI must **clearly show reward model** — gold on every victory, items only on actual drops.
+- UI must **clearly show reward model** — gold and items only when they actually dropped (no fake +gold on screen).
## Balance philosophy
diff --git a/admin-web/index.html b/admin-web/index.html
index 20b7b52..9e6c8c7 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -1426,6 +1426,7 @@
Подписка:
+
diff --git a/backend/cmd/server/server.exe b/backend/cmd/server/server.exe
index ed92dde..12761d9 100644
Binary files a/backend/cmd/server/server.exe and b/backend/cmd/server/server.exe differ
diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go
index 69e6af4..7b7a507 100644
--- a/backend/internal/game/combat_test.go
+++ b/backend/internal/game/combat_test.go
@@ -130,6 +130,11 @@ func TestSkeletonKingSummonDamage(t *testing.T) {
}
func TestLootGenerationOnEnemyDeath(t *testing.T) {
+ v := tuning.DefaultValues()
+ v.GoldDropChance = 1.0
+ tuning.Set(v)
+ t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
+
drops := model.GenerateLoot(model.EnemyWolf, 1.0)
if len(drops) == 0 {
t.Fatal("expected at least one loot drop (gold)")
@@ -145,7 +150,7 @@ func TestLootGenerationOnEnemyDeath(t *testing.T) {
}
}
if !hasGold {
- t.Fatal("expected gold drop from GenerateLoot")
+ t.Fatal("expected gold drop from GenerateLoot when GoldDropChance is 1")
}
}
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index 9715f95..bc04c8f 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -21,9 +21,8 @@ type MessageSender interface {
BroadcastEvent(event model.CombatEvent)
}
-// EnemyDeathCallback is invoked when an enemy dies, passing the hero and enemy type.
-// Used to wire loot generation without coupling the engine to the handler layer.
-type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time)
+// 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
// EngineStatus contains a snapshot of the engine's operational state.
type EngineStatus struct {
@@ -1489,8 +1488,9 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
// Rewards (XP, gold, loot, level-ups) are handled by the onEnemyDeath callback
// via processVictoryRewards -- the single source of truth.
+ var victoryDrops []model.LootDrop
if e.onEnemyDeath != nil && hero != nil {
- e.onEnemyDeath(hero, enemy, now)
+ victoryDrops = e.onEnemyDeath(hero, enemy, now)
}
e.emitEvent(model.CombatEvent{
@@ -1522,11 +1522,13 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
}
}
- // Push typed combat_end envelope.
+ // Push typed combat_end envelope (gold from loot rolls, not enemy template column).
if e.sender != nil {
+ goldFromLoot := model.SumGoldFromLootDrops(victoryDrops)
e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{
XPGained: enemy.XPReward,
- GoldGained: enemy.GoldReward,
+ GoldGained: goldFromLoot,
+ Loot: model.LootDropsToLootItems(victoryDrops),
LeveledUp: leveledUp,
NewLevel: hero.Level,
})
diff --git a/backend/internal/game/rewards.go b/backend/internal/game/rewards.go
index a099e4e..a843be5 100644
--- a/backend/internal/game/rewards.go
+++ b/backend/internal/game/rewards.go
@@ -45,7 +45,7 @@ type VictoryRewardDeps struct {
}
// ApplyVictoryRewards is the single source of truth for post-kill rewards.
-// It awards XP, generates loot (gold guaranteed via GenerateLoot), processes equipment drops,
+// It awards XP, generates loot (gold/equipment per tuning + luck), processes equipment drops,
// runs the level-up loop, updates stats, and triggers optional meta-progress hooks.
func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, deps VictoryRewardDeps) []model.LootDrop {
if hero == nil || enemy == nil {
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 4fefc80..448f083 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -1300,6 +1300,53 @@ func (h *AdminHandler) GrantHeroSubscription(w http.ResponseWriter, r *http.Requ
writeHeroJSON(w, http.StatusOK, hero)
}
+// RevokeHeroSubscription removes subscription immediately (admin); clamps buff charges and revives to free tier.
+// POST /admin/heroes/{heroId}/revoke-subscription
+func (h *AdminHandler) RevokeHeroSubscription(w http.ResponseWriter, r *http.Request) {
+ heroID, err := parseHeroID(r)
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid heroId: " + err.Error(),
+ })
+ return
+ }
+ if h.isHeroInCombat(w, heroID) {
+ return
+ }
+
+ hero, err := h.store.GetByID(r.Context(), heroID)
+ if err != nil {
+ h.logger.Error("admin: get hero for revoke-subscription", "hero_id", heroID, "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
+ }
+
+ now := time.Now()
+ hero.RevokeSubscription(now)
+
+ if err := h.store.Save(r.Context(), hero); err != nil {
+ h.logger.Error("admin: save hero after revoke-subscription", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "failed to save hero",
+ })
+ return
+ }
+
+ h.logger.Info("admin: subscription revoked", "hero_id", heroID)
+ hero.EnsureGearMap()
+ hero.RefreshDerivedCombatStats(now)
+ h.engine.ApplyAdminHeroSnapshot(hero)
+ writeHeroJSON(w, http.StatusOK, hero)
+}
+
// ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs,
// and increments death stats when transitioning from alive.
// POST /admin/heroes/{heroId}/force-death
diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go
index 81acee4..c4c73cc 100644
--- a/backend/internal/handler/game.go
+++ b/backend/internal/handler/game.go
@@ -92,12 +92,12 @@ func (h *GameHandler) addLog(heroID int64, message string) {
// onEnemyDeath is called by the engine when an enemy is defeated.
// Delegates to processVictoryRewards for canonical reward logic.
-func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now time.Time) {
- h.processVictoryRewards(hero, enemy, now)
+func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop {
+ return h.processVictoryRewards(hero, enemy, now)
}
// processVictoryRewards is the single source of truth for post-kill rewards.
-// It awards XP, generates loot (gold is guaranteed via GenerateLoot — no separate
+// It awards XP, generates loot (gold/equipment via GenerateLoot + tuning; no separate
// enemy.GoldReward add), processes equipment drops (auto-equip, else stash up to
// MaxInventorySlots, else discard + adventure log), runs the level-up loop,
// sets hero state to walking, and records loot history.
diff --git a/backend/internal/model/buff_quota.go b/backend/internal/model/buff_quota.go
index 58b4f3a..1f389a9 100644
--- a/backend/internal/model/buff_quota.go
+++ b/backend/internal/model/buff_quota.go
@@ -94,6 +94,31 @@ func (h *Hero) ActivateSubscription(now time.Time) {
h.SubscriptionActive = true
}
+// RevokeSubscription clears subscription immediately and clamps buff charges / revive uses to free-tier limits.
+func (h *Hero) RevokeSubscription(now time.Time) {
+ h.SubscriptionActive = false
+ h.SubscriptionExpiresAt = nil
+ if h.BuffCharges != nil {
+ for bt := range BuffFreeChargesPerType {
+ key := string(bt)
+ state, ok := h.BuffCharges[key]
+ if !ok {
+ continue
+ }
+ freeMax := BuffFreeChargesPerType[bt]
+ if state.Remaining > freeMax {
+ state.Remaining = freeMax
+ h.BuffCharges[key] = state
+ }
+ }
+ }
+ maxR := h.MaxRevives()
+ if h.ReviveCount > maxR {
+ h.ReviveCount = maxR
+ }
+ h.EnsureBuffChargesPopulated(now)
+}
+
// MaxBuffCharges returns the max charges for a buff type, considering subscription status.
func (h *Hero) MaxBuffCharges(bt BuffType) int {
if h.SubscriptionActive {
diff --git a/backend/internal/model/gear.go b/backend/internal/model/gear.go
index 0c4e16a..9a2b0e4 100644
--- a/backend/internal/model/gear.go
+++ b/backend/internal/model/gear.go
@@ -121,6 +121,21 @@ func SetGearCatalog(families []GearFamily) {
merged = append(merged, fallback)
}
+ // If DB claimed a slot but supplied no rows (or bad keys), ensure defaults exist.
+ countBySlot := make(map[EquipmentSlot]int)
+ for _, gf := range merged {
+ countBySlot[gf.Slot]++
+ }
+ for _, slot := range AllEquipmentSlots {
+ if countBySlot[slot] == 0 {
+ for _, fallback := range defaultGearCatalog {
+ if fallback.Slot == slot {
+ merged = append(merged, fallback)
+ }
+ }
+ }
+ }
+
GearCatalog = merged
gearBySlot = make(map[EquipmentSlot][]GearFamily)
for _, gf := range GearCatalog {
diff --git a/backend/internal/model/loot.go b/backend/internal/model/loot.go
index cc479a0..0a18aa9 100644
--- a/backend/internal/model/loot.go
+++ b/backend/internal/model/loot.go
@@ -120,16 +120,17 @@ var equipmentLootSlots = []struct {
itemType string
weight float64
}{
- {string(SlotMainHand), 0.05},
- {string(SlotChest), 0.05},
- {string(SlotHead), 0.05},
- {string(SlotFeet), 0.05},
- {string(SlotNeck), 0.05},
- {string(SlotHands), 0.05},
- {string(SlotLegs), 0.05},
- {string(SlotCloak), 0.05},
- {string(SlotFinger), 0.05},
- {string(SlotWrist), 0.05},
+ // Weights must sum to 1.0 so rollEquipmentLootItemType does not bias the last slot.
+ {string(SlotMainHand), 0.1},
+ {string(SlotChest), 0.1},
+ {string(SlotHead), 0.1},
+ {string(SlotFeet), 0.1},
+ {string(SlotNeck), 0.1},
+ {string(SlotHands), 0.1},
+ {string(SlotLegs), 0.1},
+ {string(SlotCloak), 0.1},
+ {string(SlotFinger), 0.1},
+ {string(SlotWrist), 0.1},
}
func rollEquipmentLootItemType(float01 func() float64) string {
@@ -144,8 +145,9 @@ func rollEquipmentLootItemType(float01 func() float64) string {
return equipmentLootSlots[len(equipmentLootSlots)-1].itemType
}
-// GenerateLoot generates loot drops from defeating an enemy (preview / tests).
-// Guaranteed gold uses a spec rarity band; optional equipment is independent and does not replace gold.
+// GenerateLoot builds a loot roll for an enemy (preview / tests).
+// Gold: rolled with GoldDropChance×luck (capped at 1); if it succeeds, rarity/amount use spec §8.1–8.2.
+// Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights.
func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop {
return GenerateLootWithRNG(enemyType, luckMultiplier, nil)
}
@@ -161,20 +163,27 @@ func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.
return rng.Float64()
}
- // Gold tier roll (spec §8.1–8.2); independent of whether an item drops later.
- goldRarity := RarityFromRoll(float01())
- goldAmount := RollGoldWithRNG(goldRarity, rng)
- if luckMultiplier > 1 {
- goldAmount = int64(float64(goldAmount) * luckMultiplier)
+ cfg := tuning.Get()
+ goldDropChance := cfg.GoldDropChance * luckMultiplier
+ if goldDropChance > 1 {
+ goldDropChance = 1
+ }
+ if goldDropChance < 0 {
+ goldDropChance = 0
+ }
+ if float01() < goldDropChance {
+ goldRarity := RarityFromRoll(float01())
+ goldAmount := RollGoldWithRNG(goldRarity, rng)
+ if luckMultiplier > 1 {
+ goldAmount = int64(float64(goldAmount) * luckMultiplier)
+ }
+ drops = append(drops, LootDrop{
+ ItemType: "gold",
+ Rarity: goldRarity,
+ GoldAmount: goldAmount,
+ })
}
- drops = append(drops, LootDrop{
- ItemType: "gold",
- Rarity: goldRarity,
- GoldAmount: goldAmount,
- })
-
- cfg := tuning.Get()
// Configurable chance to drop a healing potion.
potionRoll := float01()
if potionRoll < cfg.PotionDropChance {
@@ -219,3 +228,47 @@ func AutoSellPrice(rarity Rarity) int64 {
return 0
}
}
+
+// SumGoldFromLootDrops sums gold entries from ApplyVictoryRewards output.
+func SumGoldFromLootDrops(drops []LootDrop) int64 {
+ var s int64
+ for _, d := range drops {
+ if d.ItemType == "gold" {
+ s += d.GoldAmount
+ }
+ }
+ return s
+}
+
+// LootDropsToLootItems builds combat_end loot lines (equipment/potion; gold is in GoldGained).
+func LootDropsToLootItems(drops []LootDrop) []LootItem {
+ if len(drops) == 0 {
+ return nil
+ }
+ out := make([]LootItem, 0, len(drops))
+ for _, d := range drops {
+ switch d.ItemType {
+ case "gold":
+ continue
+ case "potion":
+ out = append(out, LootItem{
+ ItemType: "potion",
+ Name: "Healing potion",
+ Rarity: string(d.Rarity),
+ })
+ default:
+ if d.ItemName == "" {
+ continue
+ }
+ out = append(out, LootItem{
+ ItemType: d.ItemType,
+ Name: d.ItemName,
+ Rarity: string(d.Rarity),
+ })
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index c7412fa..78958fe 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -83,6 +83,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/add-potions", adminH.AddPotions)
r.Post("/heroes/{heroId}/revive", adminH.ReviveHero)
r.Post("/heroes/{heroId}/grant-subscription", adminH.GrantHeroSubscription)
+ r.Post("/heroes/{heroId}/revoke-subscription", adminH.RevokeHeroSubscription)
r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index cc70160..47ab396 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -56,6 +56,8 @@ type Values struct {
LootChanceEpic float64 `json:"lootChanceEpic"`
LootChanceLegendary float64 `json:"lootChanceLegendary"`
GoldLootScale float64 `json:"goldLootScale"`
+ // GoldDropChance is P(at least one gold line) per kill before luck; rolled first, then rarity/amount.
+ GoldDropChance float64 `json:"goldDropChance"`
PotionDropChance float64 `json:"potionDropChance"`
EquipmentDropBase float64 `json:"equipmentDropBase"`
@@ -259,6 +261,7 @@ func DefaultValues() Values {
LootChanceEpic: 0.003,
LootChanceLegendary: 0.0005,
GoldLootScale: 0.5,
+ GoldDropChance: 0.90,
PotionDropChance: 0.05,
EquipmentDropBase: 0.15,
GoldCommonMin: 0,
diff --git a/docs/specification.md b/docs/specification.md
index 73b3525..16ffd38 100644
--- a/docs/specification.md
+++ b/docs/specification.md
@@ -151,7 +151,7 @@ AutoHero — это idle/incremental RPG с изометрическим вид
**Правила наград:**
-- Квест не должен нарушать глобальное правило: **золото с убийств врагов** остаётся по §8; награда квеста — **дополнительный** слой (XP, предмет, косметика).
+- Квест не должен нарушать глобальную систему наград за убийства (**§8**); награда квеста — **дополнительный** слой (XP, предмет, косметика).
- Повторяемые квесты (ежедневные) имеют **сброс** и **лимит** попыток в день.
---
@@ -557,17 +557,16 @@ secondaryOut = round( baseSecondary × M(rarity) )
### 8.1 Дроп система
-- Шанс, что враг вообще дропнет предмет экипировки: `22%`
+- Шанс, что после победы выпадет **предмет экипировки**: настраивается в runtime (`equipmentDropBase` в JSON runtime_config), ориентир **~15%** при базовой удаче; бафф **Удача** умножает этот шанс (с потолком 100%).
- Если предмет выпал, его редкость распределяется так:
- Common: `75%`
- Uncommon: `20%`
- Rare: `4%`
- Epic: `0.9%`
- Legendary: `0.1%`
-- Каждый побеждённый враг **всегда** даёт золото.
-- Золото — это **гарантированная базовая награда** за убийство, чтобы каждая победа ощущалась как прогресс.
-- Предметы экипировки — это **дополнительный необязательный дроп**, а не замена золоту.
-- Отсутствие предмета после убийства — это нормальное поведение; отсутствие золота — нет.
+- Шанс, что после победы выпадет **золото** как отдельная награда: настраивается в runtime (`goldDropChance`), по умолчанию **высокий, но не 100%** (часть побед может не дать монет с трупа); бафф **Удача** умножает и этот шанс (с потолком 100%).
+- Если золото **выпало**, его **количество** и «тираж редкости» золотой пачки задаются таблицей **§8.2** (через бросок редкости лута и диапазоны по тиру).
+- Предметы экипировки — **дополнительный** слой поверх (возможного) золота; отсутствие предмета после убийства — норма; отсутствие золота — допустимо при низком исходе броска.
### 8.2 Таблица редкости предмета
@@ -581,12 +580,12 @@ secondaryOut = round( baseSecondary × M(rarity) )
### 8.3 Правила наград
-- Награда за бой состоит из:
- - гарантированного золота
- - возможного предмета (оружие, броня нагрудника или — после внедрения §6.3 — предмет в любом доступном слоте)
+- Награда за бой состоит из (независимые броски):
+ - **возможного золота** (шанс `goldDropChance` × удача, см. §8.1)
+ - **возможного предмета** (оружие, броня нагрудника или — после внедрения §6.3 — предмет в любом доступном слоте; шанс `equipmentDropBase` × удача)
- Если предмет выпал, он должен использовать ту же систему редкости (`Common` ... `Legendary`).
-- Бафф **Удача** усиливает лут, но не отменяет правило гарантированного золота.
-- В MVP допустим простой формат награды, но логика должна быть прозрачной: `gold always, item sometimes`.
+- Бафф **Удача** усиливает шансы и на золото, и на предмет (не отменяет отдельные броски).
+- В MVP допустим простой формат награды, но логика должна быть прозрачной: **`gold sometimes, item sometimes`** (конкретные вероятности — в runtime_config).
- Уровень выпавшего предмета (`ilvl`) и масштабирование статов — **§6.4**; связь `ilvl` с уровнем убитого врага — **§6.4.5**.
### 8.4 MVP Inventory / Equipment HUD
@@ -632,7 +631,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
| Epic | `60` |
| Legendary | `180` |
-Эта схема даёт игроку прозрачный MVP loop: золото приходит всегда, хороший предмет сразу усиливает героя, плохой предмет всё равно конвертируется в ощутимую награду.
+Эта схема даёт игроку прозрачный MVP loop: при выпадении золота и предметов хороший дроп сразу усиливает героя, слабый предмет при автопродаже конвертируется в ощутимую награду; отдельные победы без монет с трупа возможны при низком шансе золота.
### 8.6 Кратко: уровень предмета при дропе
@@ -746,7 +745,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
6. Мобильная оптимизация
7. Кнопка паузы/play
8. UI должен честно показывать состояние героя: нельзя визуально скрывать потерю HP автоматическим лечением, если сервер/механика этого не дали
-9. UI должен ясно показывать модель наград: золото за каждую победу, предметы только при фактическом дропе
+9. UI должен ясно показывать модель наград: золото и предметы — только при фактическом дропе (в т.ч. `+N gold` в попапе/логе только если монеты с трупа выпали)
10. **Имя героя** (§1.2) всегда видно над моделью в мире; в социальных контекстах — то же имя, без расхождения с сервером
---
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 394b5ef..c256d98 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -641,7 +641,9 @@ export function App() {
const parts: string[] = [];
if (p.xpGained > 0) parts.push(`+${p.xpGained} XP`);
if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`);
- const equipDrop = p.loot.find((l) => l.itemType === 'weapon' || l.itemType === 'armor');
+ const equipDrop = (p.loot ?? []).find(
+ (l) => l.itemType !== 'gold' && l.itemType !== 'potion',
+ );
if (equipDrop?.name) parts.push(`found ${equipDrop.name}`);
// Victory line comes from server adventure log (Defeated …) + WS adventure_log_line
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index a799c00..7312ac6 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -497,7 +497,7 @@ export interface EnemyRegenPayload {
export interface CombatEndPayload {
xpGained: number;
goldGained: number;
- loot: Array<{ itemType: string; name: string; rarity: string }>;
+ loot?: Array<{ itemType: string; name: string; rarity: string }>;
leveledUp: boolean;
newLevel?: number;
}
diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts
index 1f9a1e5..34da80d 100644
--- a/frontend/src/game/ws-handler.ts
+++ b/frontend/src/game/ws-handler.ts
@@ -286,12 +286,17 @@ export function sendNPCAlmsDecline(ws: GameWebSocket): void {
/**
* Build a LootDrop from combat_end payload for the loot popup UI.
*/
+function isEquipmentLootItemType(t: string): boolean {
+ if (t === 'gold' || t === 'potion') return false;
+ // Server uses equipment slot ids (main_hand, chest, …), not legacy weapon/armor.
+ return true;
+}
+
export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null {
- if (p.goldGained <= 0 && p.loot.length === 0) return null;
+ const loot = p.loot ?? [];
+ if (p.goldGained <= 0 && loot.length === 0) return null;
- const equip = p.loot.find(
- (l) => l.itemType === 'weapon' || l.itemType === 'armor',
- );
+ const equip = loot.find((l) => isEquipmentLootItemType(l.itemType));
return {
itemType: 'gold',
diff --git a/frontend/src/ui/LootPopup.tsx b/frontend/src/ui/LootPopup.tsx
index dfc492f..0a2db36 100644
--- a/frontend/src/ui/LootPopup.tsx
+++ b/frontend/src/ui/LootPopup.tsx
@@ -144,7 +144,11 @@ export function LootPopup({ loot }: LootPopupProps) {
}}
>
{currentLoot.bonusItem.itemName ??
- (currentLoot.bonusItem.itemType === 'weapon' ? 'Weapon drop' : 'Armor drop')}
+ (currentLoot.bonusItem.itemType === 'main_hand' || currentLoot.bonusItem.itemType === 'weapon'
+ ? 'Weapon drop'
+ : currentLoot.bonusItem.itemType === 'chest' || currentLoot.bonusItem.itemType === 'armor'
+ ? 'Armor drop'
+ : 'Equipment drop')}
)}