Compare commits

..

7 Commits

@ -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 **515** … Legendary **10005000** (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

@ -1413,6 +1413,7 @@
<div class="kv"><kbd>Level</kbd><div>${e(h.level)}</div></div>
<div class="kv"><kbd>HP</kbd><div>${e(h.hp)}/${e(h.maxHp)}</div></div>
<div class="kv"><kbd>Gold</kbd><div>${e(h.gold)}</div></div>
<div class="kv"><kbd>Subscription</kbd><div>${h.subscriptionActive ? "active" : "inactive"}${h.subscriptionExpiresAt ? " · until " + e(h.subscriptionExpiresAt) : ""}</div></div>
<div class="row">
<div><input id="hero-hp" type="number" placeholder="New HP" /></div>
<div><input id="hero-gold" type="number" placeholder="New Gold" /></div>
@ -1422,6 +1423,10 @@
<button class="btn" onclick="withAction(() => heroAction('set-gold',{gold:Number(document.getElementById('hero-gold').value)}))">Set Gold</button>
<button class="btn" onclick="withAction(() => heroAction('set-level',{level:Number(document.getElementById('hero-level').value)}))">Set Level</button>
<button class="btn" onclick="withAction(() => heroAction('revive',{}))">Revive</button>
<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="Число периодов (как при покупке подписки)" />
<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 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>

Binary file not shown.

@ -0,0 +1,48 @@
package changelog
import (
_ "embed"
"encoding/json"
"sync"
)
//go:embed data/changelog.json
var embedded []byte
// Release is one curated release note block keyed by Version (must match internal/version.Version when you want it shown).
type Release struct {
Version string `json:"version"`
Title string `json:"title"`
Items []string `json:"items"`
}
type fileShape struct {
Releases []Release `json:"releases"`
}
var (
loadOnce sync.Once
parsed fileShape
loadErr error
)
func load() {
loadOnce.Do(func() {
loadErr = json.Unmarshal(embedded, &parsed)
})
}
// ForVersion returns the release entry for the given server version, or nil if none (no modal).
func ForVersion(serverVersion string) *Release {
load()
if loadErr != nil || serverVersion == "" {
return nil
}
for i := range parsed.Releases {
if parsed.Releases[i].Version == serverVersion {
r := parsed.Releases[i]
return &r
}
}
return nil
}

@ -0,0 +1,24 @@
{
"releases": [
{
"version": "0.1.1-dev",
"title": "AutoHero — 0.1.1",
"items": [
"Changelog added",
"Combat UI updated",
"Dead screen no longer blocks the hero stats button",
"Fixed floating damage numbers and evade / blocked / crit indicators",
"Buff buttons: info is greyed out when the buff is not active",
"Some other minor UI improvements",
"Something else"
]
},
{
"version": "0.1.0-dev",
"title": "AutoHero",
"items": [
"Добавлен экран «Что нового» после обновления сервера (наполняется вручную в changelog.json)."
]
}
]
}

@ -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")
}
}

@ -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,
})

@ -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 {

@ -0,0 +1,48 @@
package game
import (
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// ApplyVictoryRewards + GenerateLoot: gold only if gold roll succeeds; force chance 1.0 so the test is deterministic.
func TestApplyVictoryRewards_awardsGoldFromLoot(t *testing.T) {
v := tuning.DefaultValues()
v.GoldDropChance = 1.0
tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
hero := &model.Hero{
ID: 1, Level: 1, Gold: 0, XP: 0,
State: model.StateFighting,
}
enemy := &model.Enemy{
Type: model.EnemyWolf, Name: "Wolf",
MinLevel: 1, MaxLevel: 5,
XPReward: 10,
}
beforeGold := hero.Gold
drops := ApplyVictoryRewards(hero, enemy, time.Now(), VictoryRewardDeps{})
if len(drops) < 1 {
t.Fatal("expected at least one loot drop")
}
var hasGold bool
for _, d := range drops {
if d.ItemType == "gold" && d.GoldAmount > 0 {
hasGold = true
break
}
}
if !hasGold {
t.Fatalf("expected a gold entry in drops: %#v", drops)
}
if hero.Gold <= beforeGold {
t.Fatalf("hero gold should increase (loot gold); before=%d after=%d", beforeGold, hero.Gold)
}
if model.SumGoldFromLootDrops(drops) <= 0 {
t.Fatal("SumGoldFromLootDrops should be positive for victory drops")
}
}

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"runtime"
@ -20,6 +21,7 @@ import (
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
)
var serverStartedAt = time.Now()
@ -1196,6 +1198,155 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
writeHeroJSON(w, http.StatusOK, hero)
}
type grantSubscriptionRequest struct {
// Periods is how many subscription durations to add (stacking extends from current expiry). Default 1.
Periods int `json:"periods"`
}
// GrantHeroSubscription activates or extends subscription like a purchase, without charging RUB (admin grant).
// POST /admin/heroes/{heroId}/grant-subscription
func (h *AdminHandler) GrantHeroSubscription(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
}
var req grantSubscriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body: " + err.Error(),
})
return
}
periods := req.Periods
if periods < 1 {
periods = 1
}
if periods > 52 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "periods must be between 1 and 52",
})
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for grant-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()
for i := 0; i < periods; i++ {
hero.ActivateSubscription(now)
}
hero.EnsureBuffChargesPopulated(now)
for bt := range model.BuffFreeChargesPerType {
state := hero.GetBuffCharges(bt, now)
subMax := hero.MaxBuffCharges(bt)
if state.Remaining < subMax {
state.Remaining = subMax
hero.BuffCharges[string(bt)] = state
}
}
payment := &model.Payment{
HeroID: hero.ID,
Type: model.PaymentType("subscription_admin"),
AmountRUB: 0,
Status: model.PaymentCompleted,
CreatedAt: now,
CompletedAt: &now,
}
if err := h.store.CreatePayment(r.Context(), payment); err != nil {
h.logger.Error("admin: grant-subscription payment row", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to record payment",
})
return
}
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after grant-subscription", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.logger.Info("admin: subscription granted",
"hero_id", heroID,
"periods", periods,
"expires_at", hero.SubscriptionExpiresAt,
)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(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,
// and increments death stats when transitioning from alive.
// POST /admin/heroes/{heroId}/force-death
@ -2169,7 +2320,7 @@ func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Reques
func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) {
poolStat := h.pool.Stat()
writeJSON(w, http.StatusOK, map[string]any{
"version": "0.1.0-dev",
"version": version.Version,
"goVersion": runtime.Version(),
"uptimeMs": time.Since(serverStartedAt).Milliseconds(),
"dbPool": map[string]any{

@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strings"
"github.com/denisovdennis/autohero/internal/game"
)
@ -16,6 +17,11 @@ func APITimePausedMiddleware(engine *game.Engine) func(http.Handler) http.Handle
next.ServeHTTP(w, r)
return
}
// Client preference only; not part of combat/world simulation.
if strings.HasSuffix(r.URL.Path, "/hero/changelog/ack") {
next.ServeHTTP(w, r)
return
}
if engine != nil && engine.IsTimePaused() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "server time is paused",

@ -15,10 +15,12 @@ import (
"github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/changelog"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
"github.com/denisovdennis/autohero/internal/world"
)
@ -90,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.
@ -860,6 +862,9 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
})
return
}
@ -919,6 +924,17 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
pCost, hCost := tuning.EffectiveNPCShopCosts()
model.AttachDebuffCatalogForClient(hero)
rel := changelog.ForVersion(version.Version)
showChangelog := rel != nil && hero.ChangelogAckVersion != version.Version
var changelogPayload any
if showChangelog && rel != nil {
changelogPayload = map[string]any{
"title": rel.Title,
"items": rel.Items,
}
}
writeJSON(w, http.StatusOK, map[string]any{
"hero": hero,
"needsName": needsName,
@ -927,9 +943,40 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"towns": townsWithNPCs,
"npcCostPotion": pCost,
"npcCostHeal": hCost,
"serverVersion": version.Version,
"showChangelog": showChangelog,
"changelog": changelogPayload,
})
}
// AckChangelog marks the current server changelog as seen for this hero.
// POST /api/v1/hero/changelog/ack
func (h *GameHandler) AckChangelog(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "missing telegramId",
})
return
}
hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
if err != nil {
h.logger.Error("changelog ack: get hero", "telegram_id", telegramID, "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 err := h.store.SetChangelogAckVersion(r.Context(), hero.ID, version.Version); err != nil {
h.logger.Error("changelog ack: save", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
// TownWithNPCs suitable for the frontend map render.

@ -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 {

@ -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 {

@ -73,6 +73,8 @@ type Hero struct {
TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

@ -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.18.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.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())
goldAmount := RollGoldWithRNG(goldRarity, rng)
if luckMultiplier > 1 {
goldAmount = int64(float64(goldAmount) * luckMultiplier)
}
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
}

@ -0,0 +1,108 @@
package model
import (
"math"
"math/rand"
"testing"
"github.com/denisovdennis/autohero/internal/tuning"
)
// equipmentLootSlots must give every slot a positive share so that, with EquipmentDropBase > 0,
// P(drop slot s | luck=1) = EquipmentDropBase * weight[s] > 0.
func TestEquipmentLootSlotWeights_positiveAndSumToOne(t *testing.T) {
var sum float64
for _, row := range equipmentLootSlots {
if row.weight <= 0 {
t.Fatalf("slot %q: weight must be > 0", row.itemType)
}
sum += row.weight
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("equipmentLootSlots weights sum to %g, want 1.0", sum)
}
}
// User-facing slots: weapon, armor, necklace, ring, boots, pants, bracers, gloves
// map to main_hand, chest, neck, finger, feet, legs, wrist, hands — all must appear with positive weight.
func TestEquipmentLootSlotWeights_coversCoreSlots(t *testing.T) {
want := []EquipmentSlot{
SlotMainHand, SlotChest, SlotNeck, SlotFinger, SlotFeet, SlotLegs, SlotWrist, SlotHands,
}
seen := make(map[string]bool, len(equipmentLootSlots))
for _, row := range equipmentLootSlots {
seen[row.itemType] = true
}
for _, s := range want {
if !seen[string(s)] {
t.Fatalf("missing slot %q in equipmentLootSlots", s)
}
}
}
// With default tuning and luck 1.0, marginal probability of rolling a specific equipment slot
// (equip roll succeeds, then slot roll) is EquipmentDropBase * weight > 0.
func TestMarginalEquipmentDropChancePerSlot_nonZeroWithDefaults(t *testing.T) {
cfg := tuning.DefaultValues()
if cfg.EquipmentDropBase <= 0 {
t.Fatal("default EquipmentDropBase must be > 0")
}
for _, row := range equipmentLootSlots {
marginal := cfg.EquipmentDropBase * row.weight
if marginal <= 0 {
t.Fatalf("marginal chance for %q is %g", row.itemType, marginal)
}
}
}
func TestGenerateLoot_goldLineWhenChanceSucceeds(t *testing.T) {
v := tuning.DefaultValues()
v.GoldDropChance = 1.0
tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil)
var gold *LootDrop
for i := range drops {
if drops[i].ItemType == "gold" {
gold = &drops[i]
break
}
}
if gold == nil {
t.Fatal("expected a gold LootDrop line when GoldDropChance is 1")
}
if gold.GoldAmount < 1 {
t.Fatalf("gold amount should be >= 1, got %d", gold.GoldAmount)
}
}
func TestGenerateLoot_noGoldWhenChanceZero(t *testing.T) {
v := tuning.DefaultValues()
v.GoldDropChance = 0
tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil)
for _, d := range drops {
if d.ItemType == "gold" {
t.Fatalf("unexpected gold line: %#v", drops)
}
}
}
func TestGenerateLoot_goldOmittedWhenFirstRollFails(t *testing.T) {
v := tuning.DefaultValues()
v.GoldDropChance = 0.5
tuning.Set(v)
t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) })
// rng returns 0.99, 0.1, ... — first roll fails gold (< 0.5), second is potion check, etc.
r := rand.New(rand.NewSource(1))
drops := GenerateLootWithRNG(EnemyWolf, 1.0, r)
for _, d := range drops {
if d.ItemType == "gold" {
t.Fatal("expected no gold when first float is high and chance is 0.5")
}
}
}

@ -0,0 +1,30 @@
package model
import "testing"
func TestSumGoldFromLootDrops(t *testing.T) {
drops := []LootDrop{
{ItemType: "gold", GoldAmount: 30, Rarity: RarityCommon},
{ItemType: "gold", GoldAmount: 12, Rarity: RarityCommon},
{ItemType: "main_hand", ItemName: "Blade", Rarity: RarityRare},
}
if g := SumGoldFromLootDrops(drops); g != 42 {
t.Fatalf("SumGoldFromLootDrops: want 42, got %d", g)
}
}
func TestLootDropsToLootItems_skipsGold_includesGearAndPotion(t *testing.T) {
drops := []LootDrop{
{ItemType: "gold", GoldAmount: 99, Rarity: RarityCommon},
{ItemType: "potion", Rarity: RarityCommon},
{ItemType: "chest", ItemName: "Chainmail", Rarity: RarityUncommon},
{ItemType: "head", ItemName: "", Rarity: RarityCommon}, // no name → omitted
}
items := LootDropsToLootItems(drops)
if len(items) != 2 {
t.Fatalf("want 2 loot lines (potion + chest), got %d: %+v", len(items), items)
}
if items[0].ItemType != "potion" || items[1].ItemType != "chest" || items[1].Name != "Chainmail" {
t.Fatalf("unexpected items: %+v", items)
}
}

@ -82,6 +82,8 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/set-hp", adminH.SetHeroHP)
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)
@ -153,6 +155,7 @@ func New(deps Deps) *chi.Mux {
r.Get("/hero", gameH.GetHero)
r.Get("/hero/init", gameH.InitHero)
r.Post("/hero/changelog/ack", gameH.AckChangelog)
r.Post("/hero/name", gameH.SetHeroName)
r.Post("/hero/buff/{buffType}", gameH.ActivateBuff)
r.Post("/hero/encounter", gameH.RequestEncounter)

@ -31,7 +31,7 @@ const heroSelectQuery = `
h.position_x, h.position_y, h.potions,
h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops,
h.current_town_id, h.destination_town_id, h.move_state, h.town_pause,
h.last_online_at,
h.last_online_at, h.changelog_ack_version,
h.created_at, h.updated_at
FROM heroes h
`
@ -606,6 +606,17 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
return nil
}
// SetChangelogAckVersion records that the player has seen the changelog for the given server version.
func (s *HeroStore) SetChangelogAckVersion(ctx context.Context, heroID int64, v string) error {
_, err := s.pool.Exec(ctx, `
UPDATE heroes SET changelog_ack_version = $1, updated_at = now() WHERE id = $2
`, v, heroID)
if err != nil {
return fmt.Errorf("set changelog ack: %w", err)
}
return nil
}
// SavePosition is a lightweight UPDATE that persists only the hero's world position.
// Called frequently as the hero moves around the map.
func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64) error {
@ -687,7 +698,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.CreatedAt, &h.UpdatedAt,
)
if err != nil {
@ -721,7 +732,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.CreatedAt, &h.UpdatedAt,
)
if err != nil {

@ -0,0 +1,25 @@
package tuning
import "testing"
// Defaults must keep equipment drop and gold scaling positive so marginal per-slot
// equipment chances (EquipmentDropBase × slot weight) stay non-zero.
func TestDefaultLootTuning_nonZeroEquipmentAndGoldScale(t *testing.T) {
d := DefaultValues()
if d.EquipmentDropBase <= 0 {
t.Fatal("default EquipmentDropBase must be > 0 so equipment can drop")
}
if d.EquipmentDropBase > 1 {
t.Fatal("default EquipmentDropBase should be a probability in (0,1]")
}
if d.GoldLootScale <= 0 {
t.Fatal("default GoldLootScale must be > 0")
}
if d.GoldDropChance <= 0 || d.GoldDropChance > 1 {
t.Fatalf("default GoldDropChance must be in (0,1], got %v", d.GoldDropChance)
}
// Potion is independent; allow 0 only if explicitly intended (currently 0.05).
if d.PotionDropChance < 0 || d.PotionDropChance > 1 {
t.Fatalf("PotionDropChance out of range: %v", d.PotionDropChance)
}
}

@ -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"`
@ -251,14 +253,15 @@ func DefaultValues() Values {
MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.62,
MonsterEncounterWeightWildBonus: 0.18,
MerchantEncounterWeightBase: 0.04,
MerchantEncounterWeightRoadBonus: 0.10,
MerchantEncounterWeightBase: 0.02,
MerchantEncounterWeightRoadBonus: 0.05,
LootChanceCommon: 0.40,
LootChanceUncommon: 0.10,
LootChanceRare: 0.02,
LootChanceEpic: 0.003,
LootChanceLegendary: 0.0005,
GoldLootScale: 0.5,
GoldDropChance: 0.90,
PotionDropChance: 0.05,
EquipmentDropBase: 0.15,
GoldCommonMin: 0,

@ -0,0 +1,6 @@
// Package version holds the server release string exposed to clients and admin.
// Bump this when you deploy a release that should drive the changelog gate.
package version
// Version is the active server build id (shown in /hero/init and admin /info).
const Version = "0.1.1-dev"

@ -0,0 +1,3 @@
-- Track which server version the player last acknowledged in the changelog UI.
ALTER TABLE heroes
ADD COLUMN IF NOT EXISTS changelog_ack_version TEXT NOT NULL DEFAULT '';

@ -0,0 +1,9 @@
-- Lower wandering merchant encounter weights (relative to monster weight).
UPDATE runtime_config
SET
payload = payload || '{
"merchantEncounterWeightBase": 0.02,
"merchantEncounterWeightRoadBonus": 0.05
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -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) всегда видно над моделью в мире; в социальных контекстах — то же имя, без расхождения с сервером
---

@ -16,6 +16,7 @@ import {
import {
ApiError,
initHero,
ackChangelog,
getAdventureLog,
getTowns,
getTownNPCs,
@ -32,7 +33,7 @@ import {
defaultNpcShopCosts,
npcShopCostsFromInit,
} from './network/api';
import type { HeroResponse, Achievement } from './network/api';
import type { HeroResponse, Achievement, ChangelogPayload } from './network/api';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api';
import {
@ -60,6 +61,7 @@ import { OfflineReport } from './ui/OfflineReport';
import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
import { NPCDialog } from './ui/NPCDialog';
import { NameEntryScreen } from './ui/NameEntryScreen';
import { ChangelogModal } from './ui/ChangelogModal';
import { AchievementsPanel } from './ui/AchievementsPanel';
import { Minimap } from './ui/Minimap';
import { NPCInteraction } from './ui/NPCInteraction';
@ -331,6 +333,9 @@ export function App() {
const [combatLogLines, setCombatLogLines] = useState<string[]>([]);
const [offlineReport, setOfflineReport] = useState<OfflineReportData | null>(null);
const [needsName, setNeedsName] = useState(false);
type ChangelogOpen = { payload: ChangelogPayload; serverVersion?: string };
const pendingChangelogRef = useRef<ChangelogOpen | null>(null);
const [changelogOpen, setChangelogOpen] = useState<ChangelogOpen | null>(null);
const logIdCounter = useRef(0);
const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -445,6 +450,18 @@ export function App() {
const initRes = await initHero(telegramId);
setNpcShopCosts(npcShopCostsFromInit(initRes));
if (initRes.showChangelog && initRes.changelog) {
const bundle: ChangelogOpen = {
payload: initRes.changelog,
serverVersion: initRes.serverVersion,
};
if (initRes.needsName) {
pendingChangelogRef.current = bundle;
} else {
setChangelogOpen(bundle);
}
}
// Gate game start behind name entry — no hero row until POST /hero/name
if (initRes.needsName) {
setNeedsName(true);
@ -624,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
@ -844,6 +863,10 @@ export function App() {
const hero = engine?.gameState.hero;
const now = Date.now();
if (engine?.gameState.phase === GamePhase.Dead) {
return;
}
// Check per-buff charge quota
if (hero) {
const charge = hero.buffCharges?.[type];
@ -1055,9 +1078,20 @@ export function App() {
getAchievements(telegramId)
.then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
.catch(() => {});
if (pendingChangelogRef.current) {
setChangelogOpen(pendingChangelogRef.current);
pendingChangelogRef.current = null;
}
}
}, []);
const handleDismissChangelog = useCallback(() => {
setChangelogOpen(null);
const telegramId = getTelegramUserId() ?? 1;
ackChangelog(telegramId).catch(() => console.warn('[App] changelog ack failed'));
}, []);
const handleUsePotion = useCallback(() => {
const ws = wsRef.current;
const hero = engineRef.current?.gameState.hero;
@ -1226,6 +1260,15 @@ export function App() {
{/* Name Entry Screen */}
{needsName && <NameEntryScreen onNameSet={handleNameSet} />}
{changelogOpen && (
<ChangelogModal
title={changelogOpen.payload.title}
items={changelogOpen.payload.items}
serverVersion={changelogOpen.serverVersion}
onDismiss={handleDismissChangelog}
/>
)}
{/* Death Screen */}
<DeathScreen
visible={gameState.phase === GamePhase.Dead}

@ -92,6 +92,21 @@ export enum ArmorType {
// ---- Loot ----
/** Server `EquipmentSlot` strings + legacy weapon/armor labels for popup copy. */
export type LootBonusItemSlot =
| 'main_hand'
| 'chest'
| 'head'
| 'feet'
| 'neck'
| 'hands'
| 'legs'
| 'cloak'
| 'finger'
| 'wrist'
| 'weapon'
| 'armor';
export interface LootDrop {
itemType: 'weapon' | 'armor' | 'gold';
itemName?: string;
@ -99,7 +114,7 @@ export interface LootDrop {
goldAmount: number;
/** Optional equipment drop shown together with gold (backend may return gold + item). */
bonusItem?: {
itemType: 'weapon' | 'armor';
itemType: LootBonusItemSlot;
rarity: Rarity;
itemName?: string;
};
@ -497,7 +512,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;
}

@ -25,6 +25,7 @@ import type {
ServerErrorPayload,
EnemyState,
LootDrop,
LootBonusItemSlot,
MerchantLootPayload,
DebuffAppliedPayload,
} from './types';
@ -286,12 +287,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',
@ -302,7 +308,7 @@ export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null {
itemName: equip?.name,
bonusItem: equip
? {
itemType: equip.itemType as 'weapon' | 'armor',
itemType: equip.itemType as LootBonusItemSlot,
rarity: (equip.rarity?.toLowerCase() ?? 'common') as Rarity,
itemName: equip.name,
}

@ -185,6 +185,11 @@ export const en = {
quests: 'Quests',
hero: 'Hero',
// Changelog (server release notes)
changelogTitle: "What's new",
changelogOk: 'Got it',
changelogVersion: 'Version {version}',
// Settings
settings: 'Settings',
language: 'Language',

@ -189,6 +189,11 @@ export const ru: Translations = {
quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
hero: '\u0413\u0435\u0440\u043e\u0439',
// Changelog
changelogTitle: '\u0427\u0442\u043e \u043d\u043e\u0432\u043e\u0433\u043e',
changelogOk: '\u041f\u043e\u043d\u044f\u0442\u043d\u043e',
changelogVersion: '\u0412\u0435\u0440\u0441\u0438\u044f {version}',
// Settings
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
language: '\u042f\u0437\u044b\u043a',

@ -182,6 +182,12 @@ export interface OfflineReport {
message: string;
}
/** Curated release notes for the current server version (see backend changelog.json). */
export interface ChangelogPayload {
title: string;
items: string[];
}
export interface InitHeroResponse {
/** Null until the player submits a valid name (no DB row until then). */
hero: HeroResponse | null;
@ -192,6 +198,11 @@ export interface InitHeroResponse {
npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: 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. */
showChangelog?: boolean;
changelog?: ChangelogPayload | null;
}
/** Matches server defaults when init omits costs (must stay in sync with tuning.DefaultValues). */
@ -215,6 +226,12 @@ export async function initHero(telegramId?: number): Promise<InitHeroResponse> {
return apiGet<InitHeroResponse>(`/hero/init${query}`);
}
/** Mark the current server changelog as read (call after the user dismisses the modal). */
export async function ackChangelog(telegramId?: number): Promise<void> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';
await apiPost<{ ok?: boolean }>(`/hero/changelog/ack${query}`);
}
/** Set the hero's display name (first time only). Returns updated hero on success. */
export async function setHeroName(name: string, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : '';

@ -17,6 +17,8 @@ interface BuffBarProps {
buffCharges: Partial<Record<BuffType, BuffChargeState>>;
/** When true, UI max charge labels use subscriber caps (×2). */
subscriptionActive?: boolean;
/** When true (e.g. hero dead), buff taps do nothing and refill flow is blocked. */
buffsLocked?: boolean;
nowMs: number;
onActivate: (type: BuffType) => void;
/** Called when a buff refill purchase returns an updated hero */
@ -31,6 +33,7 @@ interface BuffButtonProps {
onActivate: () => void;
onRefill?: (type: BuffType) => void;
nowMs: number;
buffsLocked?: boolean;
}
// ---- Tooltip ----
@ -166,7 +169,7 @@ const buttonBase: CSSProperties = {
WebkitTapHighlightColor: 'transparent',
};
function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowMs }: BuffButtonProps) {
function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowMs, buffsLocked }: BuffButtonProps) {
const tr = useT();
const [pressed, setPressed] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
@ -189,7 +192,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
const remaining = charge?.remaining;
const hasChargeData = remaining != null;
const isOutOfCharges = hasChargeData && remaining === 0;
const isDisabled = isOnCooldown || (isOutOfCharges && !isActive);
const isDisabled = isOnCooldown || (isOutOfCharges && !isActive) || !!buffsLocked;
useEffect(() => {
return () => {
@ -198,6 +201,13 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
};
}, []);
useEffect(() => {
if (buffsLocked) {
setShowTooltip(false);
setShowRefillConfirm(false);
}
}, [buffsLocked]);
const openTooltip = (): void => {
setShowTooltip(true);
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
@ -210,6 +220,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
};
const handleTouchStart = (): void => {
if (buffsLocked) return;
didLongPress.current = false;
longPressTimer.current = setTimeout(() => {
didLongPress.current = true;
@ -225,6 +236,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
};
const handleClick = (): void => {
if (buffsLocked) return;
if (didLongPress.current) {
didLongPress.current = false;
return;
@ -242,7 +254,11 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
onActivate();
};
const dimmedFaceOpacity = isDisabled ? (isOutOfCharges && !isOnCooldown ? 0.3 : 0.55) : 1;
const dimmedFaceOpacity = buffsLocked
? 0.4
: isDisabled
? (isOutOfCharges && !isOnCooldown ? 0.3 : 0.55)
: 1;
const hitStyle: CSSProperties = {
position: 'relative',
@ -251,7 +267,8 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
padding: 0,
border: 'none',
background: 'transparent',
cursor: isDisabled ? 'not-allowed' : 'pointer',
pointerEvents: buffsLocked ? 'none' : 'auto',
cursor: buffsLocked || isDisabled ? 'not-allowed' : 'pointer',
transform: pressed ? 'scale(0.94)' : 'scale(1)',
transition: 'transform 80ms ease',
};
@ -282,8 +299,8 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onMouseEnter={openTooltip}
onMouseLeave={closeTooltip}
onMouseEnter={buffsLocked ? undefined : openTooltip}
onMouseLeave={buffsLocked ? undefined : closeTooltip}
aria-disabled={isDisabled}
aria-label={`${meta.label}: ${meta.desc}`}
>
@ -515,7 +532,16 @@ function getBuffEntry(
};
}
export function BuffBar({ buffs, cooldownEndsAt, buffCharges, subscriptionActive, nowMs, onActivate, onHeroUpdated }: BuffBarProps) {
export function BuffBar({
buffs,
cooldownEndsAt,
buffCharges,
subscriptionActive,
buffsLocked,
nowMs,
onActivate,
onHeroUpdated,
}: BuffBarProps) {
const handleActivate = useCallback(
(type: BuffType) => () => onActivate(type),
[onActivate],
@ -552,6 +578,7 @@ export function BuffBar({ buffs, cooldownEndsAt, buffCharges, subscriptionActive
onActivate={handleActivate(type)}
onRefill={handleRefill}
nowMs={nowMs}
buffsLocked={buffsLocked}
/>
);
})}

@ -0,0 +1,103 @@
import type { CSSProperties } from 'react';
import { useT, t } from '../i18n';
export interface ChangelogModalProps {
title: string;
items: string[];
serverVersion?: string;
onDismiss: () => void;
}
const overlayStyle: CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.72)',
zIndex: 450,
cursor: 'pointer',
pointerEvents: 'auto',
};
const cardStyle: CSSProperties = {
backgroundColor: 'rgba(15, 15, 30, 0.97)',
border: '1px solid rgba(120, 200, 255, 0.35)',
borderRadius: 12,
padding: '20px 22px',
maxWidth: 340,
width: 'calc(100vw - 40px)',
maxHeight: 'min(70vh, 420px)',
overflowY: 'auto',
boxShadow: '0 0 32px rgba(60, 120, 220, 0.25)',
cursor: 'default',
};
const titleStyle: CSSProperties = {
fontSize: 17,
fontWeight: 700,
color: '#e8f0ff',
marginBottom: 6,
textAlign: 'center',
};
const versionStyle: CSSProperties = {
fontSize: 11,
color: 'rgba(180, 200, 230, 0.75)',
textAlign: 'center',
marginBottom: 14,
};
const listStyle: CSSProperties = {
margin: '0 0 16px 0',
paddingLeft: 18,
color: '#c8d8f0',
fontSize: 13,
lineHeight: 1.45,
};
const buttonStyle: CSSProperties = {
display: 'block',
width: '100%',
padding: '10px 16px',
borderRadius: 8,
border: 'none',
background: 'linear-gradient(180deg, #4a8cff 0%, #2d5eb8 100%)',
color: '#fff',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
};
export function ChangelogModal({ title, items, serverVersion, onDismiss }: ChangelogModalProps) {
const tr = useT();
return (
<div
style={overlayStyle}
role="dialog"
aria-modal
aria-labelledby="changelog-title"
onClick={onDismiss}
>
<div style={cardStyle} onClick={(e) => e.stopPropagation()}>
<div id="changelog-title" style={titleStyle}>
{title}
</div>
{serverVersion ? (
<div style={versionStyle}>{t(tr.changelogVersion, { version: serverVersion })}</div>
) : null}
<ul style={listStyle}>
{items.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
<button type="button" style={buttonStyle} onClick={onDismiss}>
{tr.changelogOk}
</button>
</div>
</div>
);
}

@ -332,6 +332,7 @@ export function HUD({
cooldownEndsAt={buffCooldownEndsAt}
buffCharges={hero.buffCharges}
subscriptionActive={hero.subscriptionActive}
buffsLocked={phase === GamePhase.Dead}
nowMs={nowMs}
onActivate={handleBuffActivate}
onHeroUpdated={onHeroUpdated}

@ -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')}
</span>
)}
</div>

Loading…
Cancel
Save