drop adjustments

master
Denis Ranneft 1 month ago
parent a93e9a2520
commit a5b5e8ba71

@ -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). - 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 **515** … Legendary **10005000** (spec §8.2 table). - Gold per tier: Common **515** … Legendary **10005000** (spec §8.2 table).
- **Gold is always guaranteed** per kill — every victory must award gold. - **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 items are **optional extra drops**, not a replacement for gold. - Equipment drop chance (`equipmentDropBase` × luck); items are **optional** extra drops.
- Luck buff boosts loot but does not override guaranteed gold. - Luck buff multiplies **both** gold and equipment drop chances (capped at 100%).
- Reward model: `gold always, item sometimes`. - Reward model: **`gold sometimes, item sometimes`** (exact values in runtime_config).
## Progression ## 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). - 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 **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 ## Balance philosophy

@ -1426,6 +1426,7 @@
<span class="muted" style="margin-left:4px">Подписка:</span> <span class="muted" style="margin-left:4px">Подписка:</span>
<input id="hero-sub-periods" type="number" min="1" max="52" value="1" style="width:56px" title="Число периодов (как при покупке подписки)" /> <input id="hero-sub-periods" type="number" min="1" max="52" value="1" style="width:56px" title="Число периодов (как при покупке подписки)" />
<button type="button" class="btn" onclick="withAction(() => heroAction('grant-subscription',{periods:Math.min(52,Math.max(1,parseInt(document.getElementById('hero-sub-periods').value,10)||1))}))" title="Выдать подписку на N периодов (длительность из runtime), без списания RUB">Выдать подписку</button> <button type="button" class="btn" onclick="withAction(() => heroAction('grant-subscription',{periods:Math.min(52,Math.max(1,parseInt(document.getElementById('hero-sub-periods').value,10)||1))}))" title="Выдать подписку на N периодов (длительность из runtime), без списания RUB">Выдать подписку</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('revoke-subscription',{}))" title="Снять подписку сейчас; заряды баффов и ревайвы ужимаются до бесплатных лимитов">Снять подписку</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('force-death',{}))" title="HP 0, state dead, ends combat; counts as a death if the hero was alive">Режим смерти</button> <button type="button" class="btn warn" onclick="withAction(() => heroAction('force-death',{}))" title="HP 0, state dead, ends combat; counts as a death if the hero was alive">Режим смерти</button>
<button class="btn" onclick="withAction(() => heroAction('start-rest',{}, true))" title="Town rest (same duration as normal town rest)">Start rest (town)</button> <button class="btn" onclick="withAction(() => heroAction('start-rest',{}, true))" title="Town rest (same duration as normal town rest)">Start rest (town)</button>
<button class="btn" onclick="withAction(() => heroAction('start-roadside-rest',{}, true))" title="Roadside rest at current road position (not in excursion)">Start rest (roadside)</button> <button class="btn" onclick="withAction(() => heroAction('start-roadside-rest',{}, true))" title="Roadside rest at current road position (not in excursion)">Start rest (roadside)</button>

Binary file not shown.

@ -130,6 +130,11 @@ func TestSkeletonKingSummonDamage(t *testing.T) {
} }
func TestLootGenerationOnEnemyDeath(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) drops := model.GenerateLoot(model.EnemyWolf, 1.0)
if len(drops) == 0 { if len(drops) == 0 {
t.Fatal("expected at least one loot drop (gold)") t.Fatal("expected at least one loot drop (gold)")
@ -145,7 +150,7 @@ func TestLootGenerationOnEnemyDeath(t *testing.T) {
} }
} }
if !hasGold { if !hasGold {
t.Fatal("expected gold drop from GenerateLoot") t.Fatal("expected gold drop from GenerateLoot when GoldDropChance is 1")
} }
} }

@ -21,9 +21,8 @@ type MessageSender interface {
BroadcastEvent(event model.CombatEvent) BroadcastEvent(event model.CombatEvent)
} }
// EnemyDeathCallback is invoked when an enemy dies, passing the hero and enemy type. // EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS.
// 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) []model.LootDrop
type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time)
// EngineStatus contains a snapshot of the engine's operational state. // EngineStatus contains a snapshot of the engine's operational state.
type EngineStatus struct { type EngineStatus struct {
@ -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 // Rewards (XP, gold, loot, level-ups) are handled by the onEnemyDeath callback
// via processVictoryRewards -- the single source of truth. // via processVictoryRewards -- the single source of truth.
var victoryDrops []model.LootDrop
if e.onEnemyDeath != nil && hero != nil { if e.onEnemyDeath != nil && hero != nil {
e.onEnemyDeath(hero, enemy, now) victoryDrops = e.onEnemyDeath(hero, enemy, now)
} }
e.emitEvent(model.CombatEvent{ 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 { if e.sender != nil {
goldFromLoot := model.SumGoldFromLootDrops(victoryDrops)
e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{ e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{
XPGained: enemy.XPReward, XPGained: enemy.XPReward,
GoldGained: enemy.GoldReward, GoldGained: goldFromLoot,
Loot: model.LootDropsToLootItems(victoryDrops),
LeveledUp: leveledUp, LeveledUp: leveledUp,
NewLevel: hero.Level, NewLevel: hero.Level,
}) })

@ -45,7 +45,7 @@ type VictoryRewardDeps struct {
} }
// ApplyVictoryRewards is the single source of truth for post-kill rewards. // 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. // 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 { func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, deps VictoryRewardDeps) []model.LootDrop {
if hero == nil || enemy == nil { if hero == nil || enemy == nil {

@ -1300,6 +1300,53 @@ func (h *AdminHandler) GrantHeroSubscription(w http.ResponseWriter, r *http.Requ
writeHeroJSON(w, http.StatusOK, hero) 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, // ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs,
// and increments death stats when transitioning from alive. // and increments death stats when transitioning from alive.
// POST /admin/heroes/{heroId}/force-death // POST /admin/heroes/{heroId}/force-death

@ -92,12 +92,12 @@ func (h *GameHandler) addLog(heroID int64, message string) {
// onEnemyDeath is called by the engine when an enemy is defeated. // onEnemyDeath is called by the engine when an enemy is defeated.
// Delegates to processVictoryRewards for canonical reward logic. // Delegates to processVictoryRewards for canonical reward logic.
func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now time.Time) { func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop {
h.processVictoryRewards(hero, enemy, now) return h.processVictoryRewards(hero, enemy, now)
} }
// processVictoryRewards is the single source of truth for post-kill rewards. // 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 // enemy.GoldReward add), processes equipment drops (auto-equip, else stash up to
// MaxInventorySlots, else discard + adventure log), runs the level-up loop, // MaxInventorySlots, else discard + adventure log), runs the level-up loop,
// sets hero state to walking, and records loot history. // sets hero state to walking, and records loot history.

@ -94,6 +94,31 @@ func (h *Hero) ActivateSubscription(now time.Time) {
h.SubscriptionActive = true 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. // MaxBuffCharges returns the max charges for a buff type, considering subscription status.
func (h *Hero) MaxBuffCharges(bt BuffType) int { func (h *Hero) MaxBuffCharges(bt BuffType) int {
if h.SubscriptionActive { if h.SubscriptionActive {

@ -121,6 +121,21 @@ func SetGearCatalog(families []GearFamily) {
merged = append(merged, fallback) 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 GearCatalog = merged
gearBySlot = make(map[EquipmentSlot][]GearFamily) gearBySlot = make(map[EquipmentSlot][]GearFamily)
for _, gf := range GearCatalog { for _, gf := range GearCatalog {

@ -120,16 +120,17 @@ var equipmentLootSlots = []struct {
itemType string itemType string
weight float64 weight float64
}{ }{
{string(SlotMainHand), 0.05}, // Weights must sum to 1.0 so rollEquipmentLootItemType does not bias the last slot.
{string(SlotChest), 0.05}, {string(SlotMainHand), 0.1},
{string(SlotHead), 0.05}, {string(SlotChest), 0.1},
{string(SlotFeet), 0.05}, {string(SlotHead), 0.1},
{string(SlotNeck), 0.05}, {string(SlotFeet), 0.1},
{string(SlotHands), 0.05}, {string(SlotNeck), 0.1},
{string(SlotLegs), 0.05}, {string(SlotHands), 0.1},
{string(SlotCloak), 0.05}, {string(SlotLegs), 0.1},
{string(SlotFinger), 0.05}, {string(SlotCloak), 0.1},
{string(SlotWrist), 0.05}, {string(SlotFinger), 0.1},
{string(SlotWrist), 0.1},
} }
func rollEquipmentLootItemType(float01 func() float64) string { func rollEquipmentLootItemType(float01 func() float64) string {
@ -144,8 +145,9 @@ func rollEquipmentLootItemType(float01 func() float64) string {
return equipmentLootSlots[len(equipmentLootSlots)-1].itemType return equipmentLootSlots[len(equipmentLootSlots)-1].itemType
} }
// GenerateLoot generates loot drops from defeating an enemy (preview / tests). // GenerateLoot builds a loot roll for an enemy (preview / tests).
// Guaranteed gold uses a spec rarity band; optional equipment is independent and does not replace gold. // Gold: rolled with GoldDropChance×luck (capped at 1); if it succeeds, rarity/amount use spec §8.18.2.
// Equipment: one extra roll uses EquipmentDropBase×luck; slot uses equipmentLootSlots weights.
func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop { func GenerateLoot(enemyType EnemyType, luckMultiplier float64) []LootDrop {
return GenerateLootWithRNG(enemyType, luckMultiplier, nil) return GenerateLootWithRNG(enemyType, luckMultiplier, nil)
} }
@ -161,20 +163,27 @@ func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.
return rng.Float64() return rng.Float64()
} }
// Gold tier roll (spec §8.18.2); independent of whether an item drops later. cfg := tuning.Get()
goldDropChance := cfg.GoldDropChance * luckMultiplier
if goldDropChance > 1 {
goldDropChance = 1
}
if goldDropChance < 0 {
goldDropChance = 0
}
if float01() < goldDropChance {
goldRarity := RarityFromRoll(float01()) goldRarity := RarityFromRoll(float01())
goldAmount := RollGoldWithRNG(goldRarity, rng) goldAmount := RollGoldWithRNG(goldRarity, rng)
if luckMultiplier > 1 { if luckMultiplier > 1 {
goldAmount = int64(float64(goldAmount) * luckMultiplier) goldAmount = int64(float64(goldAmount) * luckMultiplier)
} }
drops = append(drops, LootDrop{ drops = append(drops, LootDrop{
ItemType: "gold", ItemType: "gold",
Rarity: goldRarity, Rarity: goldRarity,
GoldAmount: goldAmount, GoldAmount: goldAmount,
}) })
}
cfg := tuning.Get()
// Configurable chance to drop a healing potion. // Configurable chance to drop a healing potion.
potionRoll := float01() potionRoll := float01()
if potionRoll < cfg.PotionDropChance { if potionRoll < cfg.PotionDropChance {
@ -219,3 +228,47 @@ func AutoSellPrice(rarity Rarity) int64 {
return 0 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
}

@ -83,6 +83,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/add-potions", adminH.AddPotions) r.Post("/heroes/{heroId}/add-potions", adminH.AddPotions)
r.Post("/heroes/{heroId}/revive", adminH.ReviveHero) r.Post("/heroes/{heroId}/revive", adminH.ReviveHero)
r.Post("/heroes/{heroId}/grant-subscription", adminH.GrantHeroSubscription) 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}/force-death", adminH.ForceHeroDeath)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)

@ -56,6 +56,8 @@ type Values struct {
LootChanceEpic float64 `json:"lootChanceEpic"` LootChanceEpic float64 `json:"lootChanceEpic"`
LootChanceLegendary float64 `json:"lootChanceLegendary"` LootChanceLegendary float64 `json:"lootChanceLegendary"`
GoldLootScale float64 `json:"goldLootScale"` 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"` PotionDropChance float64 `json:"potionDropChance"`
EquipmentDropBase float64 `json:"equipmentDropBase"` EquipmentDropBase float64 `json:"equipmentDropBase"`
@ -259,6 +261,7 @@ func DefaultValues() Values {
LootChanceEpic: 0.003, LootChanceEpic: 0.003,
LootChanceLegendary: 0.0005, LootChanceLegendary: 0.0005,
GoldLootScale: 0.5, GoldLootScale: 0.5,
GoldDropChance: 0.90,
PotionDropChance: 0.05, PotionDropChance: 0.05,
EquipmentDropBase: 0.15, EquipmentDropBase: 0.15,
GoldCommonMin: 0, GoldCommonMin: 0,

@ -151,7 +151,7 @@ AutoHero — это idle/incremental RPG с изометрическим вид
**Правила наград:** **Правила наград:**
- Квест не должен нарушать глобальное правило: **золото с убийств врагов** остаётся по §8; награда квеста — **дополнительный** слой (XP, предмет, косметика). - Квест не должен нарушать глобальную систему наград за убийства (**§8**); награда квеста — **дополнительный** слой (XP, предмет, косметика).
- Повторяемые квесты (ежедневные) имеют **сброс** и **лимит** попыток в день. - Повторяемые квесты (ежедневные) имеют **сброс** и **лимит** попыток в день.
--- ---
@ -557,17 +557,16 @@ secondaryOut = round( baseSecondary × M(rarity) )
### 8.1 Дроп система ### 8.1 Дроп система
- Шанс, что враг вообще дропнет предмет экипировки: `22%` - Шанс, что после победы выпадет **предмет экипировки**: настраивается в runtime (`equipmentDropBase` в JSON runtime_config), ориентир **~15%** при базовой удаче; бафф **Удача** умножает этот шанс (с потолком 100%).
- Если предмет выпал, его редкость распределяется так: - Если предмет выпал, его редкость распределяется так:
- Common: `75%` - Common: `75%`
- Uncommon: `20%` - Uncommon: `20%`
- Rare: `4%` - Rare: `4%`
- Epic: `0.9%` - Epic: `0.9%`
- Legendary: `0.1%` - Legendary: `0.1%`
- Каждый побеждённый враг **всегда** даёт золото. - Шанс, что после победы выпадет **золото** как отдельная награда: настраивается в runtime (`goldDropChance`), по умолчанию **высокий, но не 100%** (часть побед может не дать монет с трупа); бафф **Удача** умножает и этот шанс (с потолком 100%).
- Золото — это **гарантированная базовая награда** за убийство, чтобы каждая победа ощущалась как прогресс. - Если золото **выпало**, его **количество** и «тираж редкости» золотой пачки задаются таблицей **§8.2** (через бросок редкости лута и диапазоны по тиру).
- Предметы экипировки — это **дополнительный необязательный дроп**, а не замена золоту. - Предметы экипировки — **дополнительный** слой поверх (возможного) золота; отсутствие предмета после убийства — норма; отсутствие золота — допустимо при низком исходе броска.
- Отсутствие предмета после убийства — это нормальное поведение; отсутствие золота — нет.
### 8.2 Таблица редкости предмета ### 8.2 Таблица редкости предмета
@ -581,12 +580,12 @@ secondaryOut = round( baseSecondary × M(rarity) )
### 8.3 Правила наград ### 8.3 Правила наград
- Награда за бой состоит из: - Награда за бой состоит из (независимые броски):
- гарантированного золота - **возможного золота** (шанс `goldDropChance` × удача, см. §8.1)
- возможного предмета (оружие, броня нагрудника или — после внедрения §6.3 — предмет в любом доступном слоте) - **возможного предмета** (оружие, броня нагрудника или — после внедрения §6.3 — предмет в любом доступном слоте; шанс `equipmentDropBase` × удача)
- Если предмет выпал, он должен использовать ту же систему редкости (`Common` ... `Legendary`). - Если предмет выпал, он должен использовать ту же систему редкости (`Common` ... `Legendary`).
- Бафф **Удача** усиливает лут, но не отменяет правило гарантированного золота. - Бафф **Удача** усиливает шансы и на золото, и на предмет (не отменяет отдельные броски).
- В MVP допустим простой формат награды, но логика должна быть прозрачной: `gold always, item sometimes`. - В MVP допустим простой формат награды, но логика должна быть прозрачной: **`gold sometimes, item sometimes`** (конкретные вероятности — в runtime_config).
- Уровень выпавшего предмета (`ilvl`) и масштабирование статов — **§6.4**; связь `ilvl` с уровнем убитого врага — **§6.4.5**. - Уровень выпавшего предмета (`ilvl`) и масштабирование статов — **§6.4**; связь `ilvl` с уровнем убитого врага — **§6.4.5**.
### 8.4 MVP Inventory / Equipment HUD ### 8.4 MVP Inventory / Equipment HUD
@ -632,7 +631,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
| Epic | `60` | | Epic | `60` |
| Legendary | `180` | | Legendary | `180` |
Эта схема даёт игроку прозрачный MVP loop: золото приходит всегда, хороший предмет сразу усиливает героя, плохой предмет всё равно конвертируется в ощутимую награду. Эта схема даёт игроку прозрачный MVP loop: при выпадении золота и предметов хороший дроп сразу усиливает героя, слабый предмет при автопродаже конвертируется в ощутимую награду; отдельные победы без монет с трупа возможны при низком шансе золота.
### 8.6 Кратко: уровень предмета при дропе ### 8.6 Кратко: уровень предмета при дропе
@ -746,7 +745,7 @@ secondaryOut = round( baseSecondary × M(rarity) )
6. Мобильная оптимизация 6. Мобильная оптимизация
7. Кнопка паузы/play 7. Кнопка паузы/play
8. UI должен честно показывать состояние героя: нельзя визуально скрывать потерю HP автоматическим лечением, если сервер/механика этого не дали 8. UI должен честно показывать состояние героя: нельзя визуально скрывать потерю HP автоматическим лечением, если сервер/механика этого не дали
9. UI должен ясно показывать модель наград: золото за каждую победу, предметы только при фактическом дропе 9. UI должен ясно показывать модель наград: золото и предметы — только при фактическом дропе (в т.ч. `+N gold` в попапе/логе только если монеты с трупа выпали)
10. **Имя героя** (§1.2) всегда видно над моделью в мире; в социальных контекстах — то же имя, без расхождения с сервером 10. **Имя героя** (§1.2) всегда видно над моделью в мире; в социальных контекстах — то же имя, без расхождения с сервером
--- ---

@ -641,7 +641,9 @@ export function App() {
const parts: string[] = []; const parts: string[] = [];
if (p.xpGained > 0) parts.push(`+${p.xpGained} XP`); if (p.xpGained > 0) parts.push(`+${p.xpGained} XP`);
if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`); 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}`); if (equipDrop?.name) parts.push(`found ${equipDrop.name}`);
// Victory line comes from server adventure log (Defeated …) + WS adventure_log_line // Victory line comes from server adventure log (Defeated …) + WS adventure_log_line

@ -497,7 +497,7 @@ export interface EnemyRegenPayload {
export interface CombatEndPayload { export interface CombatEndPayload {
xpGained: number; xpGained: number;
goldGained: number; goldGained: number;
loot: Array<{ itemType: string; name: string; rarity: string }>; loot?: Array<{ itemType: string; name: string; rarity: string }>;
leveledUp: boolean; leveledUp: boolean;
newLevel?: number; newLevel?: number;
} }

@ -286,12 +286,17 @@ export function sendNPCAlmsDecline(ws: GameWebSocket): void {
/** /**
* Build a LootDrop from combat_end payload for the loot popup UI. * 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 { 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( const equip = loot.find((l) => isEquipmentLootItemType(l.itemType));
(l) => l.itemType === 'weapon' || l.itemType === 'armor',
);
return { return {
itemType: 'gold', itemType: 'gold',

@ -144,7 +144,11 @@ export function LootPopup({ loot }: LootPopupProps) {
}} }}
> >
{currentLoot.bonusItem.itemName ?? {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')}
</span> </span>
)} )}
</div> </div>

Loading…
Cancel
Save