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')} )}