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). - 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

@ -1413,6 +1413,7 @@
<div class="kv"><kbd>Level</kbd><div>${e(h.level)}</div></div> <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>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>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 class="row">
<div><input id="hero-hp" type="number" placeholder="New HP" /></div> <div><input id="hero-hp" type="number" placeholder="New HP" /></div>
<div><input id="hero-gold" type="number" placeholder="New Gold" /></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-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('set-level',{level:Number(document.getElementById('hero-level').value)}))">Set Level</button>
<button class="btn" onclick="withAction(() => heroAction('revive',{}))">Revive</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 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.

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

@ -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" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"runtime" "runtime"
@ -20,6 +21,7 @@ import (
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
) )
var serverStartedAt = time.Now() var serverStartedAt = time.Now()
@ -1196,6 +1198,155 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
writeHeroJSON(w, http.StatusOK, hero) 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, // 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
@ -2169,7 +2320,7 @@ func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Reques
func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) {
poolStat := h.pool.Stat() poolStat := h.pool.Stat()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"version": "0.1.0-dev", "version": version.Version,
"goVersion": runtime.Version(), "goVersion": runtime.Version(),
"uptimeMs": time.Since(serverStartedAt).Milliseconds(), "uptimeMs": time.Since(serverStartedAt).Milliseconds(),
"dbPool": map[string]any{ "dbPool": map[string]any{

@ -2,6 +2,7 @@ package handler
import ( import (
"net/http" "net/http"
"strings"
"github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/game"
) )
@ -16,6 +17,11 @@ func APITimePausedMiddleware(engine *game.Engine) func(http.Handler) http.Handle
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return 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() { if engine != nil && engine.IsTimePaused() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{ writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "server time is paused", "error": "server time is paused",

@ -15,10 +15,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/denisovdennis/autohero/internal/changelog"
"github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning" "github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/version"
"github.com/denisovdennis/autohero/internal/world" "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. // 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.
@ -853,13 +855,16 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
townsWithNPCs := h.buildTownsWithNPCs(r.Context()) townsWithNPCs := h.buildTownsWithNPCs(r.Context())
pCost, hCost := tuning.EffectiveNPCShopCosts() pCost, hCost := tuning.EffectiveNPCShopCosts()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": nil, "hero": nil,
"needsName": true, "needsName": true,
"offlineReport": nil, "offlineReport": nil,
"mapRef": h.world.RefForLevel(1), "mapRef": h.world.RefForLevel(1),
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "npcCostHeal": hCost,
"serverVersion": version.Version,
"showChangelog": false,
"changelog": nil,
}) })
return return
} }
@ -919,17 +924,59 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
pCost, hCost := tuning.EffectiveNPCShopCosts() pCost, hCost := tuning.EffectiveNPCShopCosts()
model.AttachDebuffCatalogForClient(hero) 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{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"needsName": needsName, "needsName": needsName,
"offlineReport": report, "offlineReport": report,
"mapRef": h.world.RefForLevel(hero.Level), "mapRef": h.world.RefForLevel(hero.Level),
"towns": townsWithNPCs, "towns": townsWithNPCs,
"npcCostPotion": pCost, "npcCostPotion": pCost,
"npcCostHeal": hCost, "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 // buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
// TownWithNPCs suitable for the frontend map render. // TownWithNPCs suitable for the frontend map render.

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

@ -73,8 +73,10 @@ type Hero struct {
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
UpdatedAt time.Time `json:"updatedAt"` ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
// BuffChargeState tracks the remaining free charges and period window for a single buff type. // BuffChargeState tracks the remaining free charges and period window for a single buff type.

@ -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()
goldRarity := RarityFromRoll(float01()) goldDropChance := cfg.GoldDropChance * luckMultiplier
goldAmount := RollGoldWithRNG(goldRarity, rng) if goldDropChance > 1 {
if luckMultiplier > 1 { goldDropChance = 1
goldAmount = int64(float64(goldAmount) * luckMultiplier) }
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. // 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
}

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

@ -31,7 +31,7 @@ const heroSelectQuery = `
h.position_x, h.position_y, h.potions, 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.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.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 h.created_at, h.updated_at
FROM heroes h FROM heroes h
` `
@ -606,6 +606,17 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
return nil 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. // SavePosition is a lightweight UPDATE that persists only the hero's world position.
// Called frequently as the hero moves around the map. // Called frequently as the hero moves around the map.
func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64) error { 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.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {
@ -721,7 +732,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.PositionX, &h.PositionY, &h.Potions, &h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { 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"` 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"`
@ -251,14 +253,15 @@ func DefaultValues() Values {
MerchantTownAutoSellShare: 0.30, MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.62, MonsterEncounterWeightBase: 0.62,
MonsterEncounterWeightWildBonus: 0.18, MonsterEncounterWeightWildBonus: 0.18,
MerchantEncounterWeightBase: 0.04, MerchantEncounterWeightBase: 0.02,
MerchantEncounterWeightRoadBonus: 0.10, MerchantEncounterWeightRoadBonus: 0.05,
LootChanceCommon: 0.40, LootChanceCommon: 0.40,
LootChanceUncommon: 0.10, LootChanceUncommon: 0.10,
LootChanceRare: 0.02, LootChanceRare: 0.02,
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,

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

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

@ -92,6 +92,21 @@ export enum ArmorType {
// ---- Loot ---- // ---- 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 { export interface LootDrop {
itemType: 'weapon' | 'armor' | 'gold'; itemType: 'weapon' | 'armor' | 'gold';
itemName?: string; itemName?: string;
@ -99,7 +114,7 @@ export interface LootDrop {
goldAmount: number; goldAmount: number;
/** Optional equipment drop shown together with gold (backend may return gold + item). */ /** Optional equipment drop shown together with gold (backend may return gold + item). */
bonusItem?: { bonusItem?: {
itemType: 'weapon' | 'armor'; itemType: LootBonusItemSlot;
rarity: Rarity; rarity: Rarity;
itemName?: string; itemName?: string;
}; };
@ -497,7 +512,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;
} }

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

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

@ -189,6 +189,11 @@ export const ru: Translations = {
quests: '\u041a\u0432\u0435\u0441\u0442\u044b', quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
hero: '\u0413\u0435\u0440\u043e\u0439', 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
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438', settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
language: '\u042f\u0437\u044b\u043a', language: '\u042f\u0437\u044b\u043a',

@ -182,6 +182,12 @@ export interface OfflineReport {
message: string; message: string;
} }
/** Curated release notes for the current server version (see backend changelog.json). */
export interface ChangelogPayload {
title: string;
items: string[];
}
export interface InitHeroResponse { export interface InitHeroResponse {
/** Null until the player submits a valid name (no DB row until then). */ /** Null until the player submits a valid name (no DB row until then). */
hero: HeroResponse | null; hero: HeroResponse | null;
@ -192,6 +198,11 @@ export interface InitHeroResponse {
npcCostPotion?: number; npcCostPotion?: number;
/** Runtime tuning: healer full heal price (from DB / runtime_config). */ /** Runtime tuning: healer full heal price (from DB / runtime_config). */
npcCostHeal?: number; 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). */ /** 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}`); 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. */ /** Set the hero's display name (first time only). Returns updated hero on success. */
export async function setHeroName(name: string, telegramId?: number): Promise<HeroResponse> { export async function setHeroName(name: string, telegramId?: number): Promise<HeroResponse> {
const query = telegramId != null ? `?telegramId=${telegramId}` : ''; const query = telegramId != null ? `?telegramId=${telegramId}` : '';

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

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