Compare commits

...

10 Commits

@ -112,6 +112,10 @@
}; };
state._confirmAction = null; state._confirmAction = null;
/** Matches model.AllBuffTypes / AllDebuffTypes (admin manual apply). */
const ADMIN_BUFF_TYPES = ["rush", "rage", "shield", "luck", "resurrection", "heal", "power_potion", "war_cry"];
const ADMIN_DEBUFF_TYPES = ["poison", "freeze", "burn", "stun", "slow", "weaken", "ice_slow"];
function e(v) { return String(v ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); } function e(v) { return String(v ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); }
function authHeader() { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; } function authHeader() { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; }
function setMessage(text) { state.message = text; render(); } function setMessage(text) { state.message = text; render(); }
@ -1185,6 +1189,25 @@
async function claimQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}/claim`, { method: "POST", body: "{}" }); await loadHero(state.selectedHeroId); } async function claimQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}/claim`, { method: "POST", body: "{}" }); await loadHero(state.selectedHeroId); }
async function abandonQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}`, { method: "DELETE" }); await loadHero(state.selectedHeroId); } async function abandonQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}`, { method: "DELETE" }); await loadHero(state.selectedHeroId); }
async function applyHeroBuffAdmin() {
if (!state.selectedHeroId) { setMessage("Сначала выберите героя"); return; }
const buffType = document.getElementById("hero-admin-buff-type")?.value;
if (!buffType) { setMessage("Выберите тип баффа"); return; }
await withRowAction("hero-admin-buff", async () => {
await api(`heroes/${state.selectedHeroId}/apply-buff`, { method: "POST", body: JSON.stringify({ buffType }) });
await loadHero(state.selectedHeroId);
}, "Бафф применён");
}
async function applyHeroDebuffAdmin() {
if (!state.selectedHeroId) { setMessage("Сначала выберите героя"); return; }
const debuffType = document.getElementById("hero-admin-debuff-type")?.value;
if (!debuffType) { setMessage("Выберите тип дебаффа"); return; }
await withRowAction("hero-admin-debuff", async () => {
await api(`heroes/${state.selectedHeroId}/apply-debuff`, { method: "POST", body: JSON.stringify({ debuffType }) });
await loadHero(state.selectedHeroId);
}, "Дебафф применён");
}
function login() { function login() {
state.auth.username = document.getElementById("login-user").value.trim(); state.auth.username = document.getElementById("login-user").value.trim();
state.auth.password = document.getElementById("login-pass").value.trim(); state.auth.password = document.getElementById("login-pass").value.trim();
@ -1399,6 +1422,7 @@
<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>
<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>
<button class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button> <button class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button>
@ -1412,6 +1436,33 @@
<div><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport to town</button></div> <div><button type="button" class="btn" onclick="withAction(teleportHeroToTown)">Teleport to town</button></div>
</div> </div>
<p class="muted" style="margin-top:6px">Towns come from the loaded road graph (<kbd>GET /admin/towns</kbd>). Hero must be alive and not in combat.</p> <p class="muted" style="margin-top:6px">Towns come from the loaded road graph (<kbd>GET /admin/towns</kbd>). Hero must be alive and not in combat.</p>
<div style="margin-top:14px;padding-top:12px;border-top:1px solid #2a3551">
<h4 style="margin:0 0 6px">Баффы / дебаффы (вручную)</h4>
<p class="muted" style="margin:0 0 8px">Эффект из серверного каталога, без списания бесплатных зарядов. Только вне боя (как и прочие правки героя).</p>
<div class="row" style="align-items:end">
<div>
<label class="muted">Бафф</label>
<select id="hero-admin-buff-type">
<option value=""></option>
${ADMIN_BUFF_TYPES.map(t => `<option value="${e(t)}">${e(t)}</option>`).join("")}
</select>
</div>
<div>
<label class="muted">Дебафф</label>
<select id="hero-admin-debuff-type">
<option value=""></option>
${ADMIN_DEBUFF_TYPES.map(t => `<option value="${e(t)}">${e(t)}</option>`).join("")}
</select>
</div>
<div></div>
</div>
<div style="margin-top:8px">
<button type="button" class="btn" onclick="withAction(applyHeroBuffAdmin)">Наложить бафф</button>
<span class="${state.rowStatus["hero-admin-buff"]?.ok ? "status-ok" : "status-err"}">${e(state.rowStatus["hero-admin-buff"]?.message || "")}</span>
<button type="button" class="btn" onclick="withAction(applyHeroDebuffAdmin)" style="margin-left:8px">Наложить дебафф</button>
<span class="${state.rowStatus["hero-admin-debuff"]?.ok ? "status-ok" : "status-err"}">${e(state.rowStatus["hero-admin-debuff"]?.message || "")}</span>
</div>
</div>
` : `<div class="muted">Select hero from list</div>`} ` : `<div class="muted">Select hero from list</div>`}
</div> </div>
</div> </div>

@ -65,6 +65,9 @@ func main() {
heroStore := storage.NewHeroStore(pgPool, logger) heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool) logStore := storage.NewLogStore(pgPool)
questStore := storage.NewQuestStore(pgPool) questStore := storage.NewQuestStore(pgPool)
gearStore := storage.NewGearStore(pgPool)
achievementStore := storage.NewAchievementStore(pgPool)
taskStore := storage.NewDailyTaskStore(pgPool)
runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool) runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool)
if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil { if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil {
logger.Error("failed to load runtime config", "error", err) logger.Error("failed to load runtime config", "error", err)
@ -177,7 +180,9 @@ func main() {
serverStartedAt := time.Now() serverStartedAt := time.Now()
offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool { offlineSim := game.NewOfflineSimulator(heroStore, logStore, questStore, roadGraph, logger, func() bool {
return engine.IsTimePaused() return engine.IsTimePaused()
}, engine.HeroHasActiveMovement) }, engine.HeroHasActiveMovement).
WithCombatTickRate(engine.TickRate()).
WithRewardStores(gearStore, achievementStore, taskStore)
go func() { go func() {
if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
logger.Error("offline simulator error", "error", err) logger.Error("offline simulator error", "error", err)

Binary file not shown.

@ -0,0 +1,20 @@
package game
import "fmt"
// Prefixes embed grouping hints for the client adventure log (no DB migration).
// Stripped for human-readable display outside structured UIs.
const (
AdventureLogEncounterPrefix = "__AH_ENC__"
AdventureLogBattlePrefix = "__AH_BAT__"
)
// FormatEncounterLogLine is logged once when combat starts (before battle detail lines).
func FormatEncounterLogLine(enemyName string) string {
return AdventureLogEncounterPrefix + fmt.Sprintf("You encounter %s.", enemyName)
}
// FormatBattleLogLine wraps a single combat narration line (hit, dodge, block, stun, debuff).
func FormatBattleLogLine(msg string) string {
return AdventureLogBattlePrefix + msg
}

@ -21,15 +21,15 @@ type DamageBreakdown struct {
IsCrit bool IsCrit bool
} }
// CalculateDamage computes the final damage dealt from attacker stats to a defender, // CalculateDamage computes hero→enemy damage (combatDamageScale + combatDamageRoll*).
// applying defense and critical hits.
func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) { func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) {
breakdown := calculateDamageBreakdown(baseAttack, defense, critChance) cfg := tuning.Get()
breakdown := calculateDamageBreakdown(baseAttack, defense, critChance, cfg.CombatDamageScale, cfg.CombatDamageRollMin, cfg.CombatDamageRollMax)
return breakdown.FinalDamage, breakdown.IsCrit return breakdown.FinalDamage, breakdown.IsCrit
} }
func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) DamageBreakdown { func calculateDamageBreakdown(baseAttack int, defense int, critChance float64, damageScale, rollMin, rollMax float64) DamageBreakdown {
atk := float64(baseAttack) * damageRollMultiplier() atk := float64(baseAttack) * damageRollMultiplier(rollMin, rollMax)
// Defense reduces damage (simple formula: damage = atk - def, min 1). // Defense reduces damage (simple formula: damage = atk - def, min 1).
dmg := atk - float64(defense) dmg := atk - float64(defense)
@ -45,7 +45,7 @@ func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) D
isCrit = true isCrit = true
} }
dmg *= tuning.Get().CombatDamageScale dmg *= damageScale
if dmg < 1 { if dmg < 1 {
dmg = 1 dmg = 1
} }
@ -57,10 +57,7 @@ func calculateDamageBreakdown(baseAttack int, defense int, critChance float64) D
} }
} }
func damageRollMultiplier() float64 { func damageRollMultiplier(minRoll, maxRoll float64) float64 {
cfg := tuning.Get()
minRoll := cfg.CombatDamageRollMin
maxRoll := cfg.CombatDamageRollMax
if minRoll <= 0 || maxRoll <= 0 { if minRoll <= 0 || maxRoll <= 0 {
return 1.0 return 1.0
} }
@ -183,7 +180,19 @@ func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) mod
} }
critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap) critChance = capChance(critChance, tuning.Get().EnemyCritChanceCap)
rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance) cfg := tuning.Get()
scale := cfg.EnemyCombatDamageScale
if scale <= 0 {
scale = tuning.DefaultEnemyCombatDamageScale
}
rollMin := cfg.EnemyCombatDamageRollMin
rollMax := cfg.EnemyCombatDamageRollMax
if rollMin <= 0 || rollMax <= 0 {
rollMin = tuning.DefaultEnemyCombatDamageRollMin
rollMax = tuning.DefaultEnemyCombatDamageRollMax
}
breakdown := calculateDamageBreakdown(enemy.Attack, hero.EffectiveDefenseAt(now), critChance, scale, rollMin, rollMax)
rawDmg, isCrit := breakdown.FinalDamage, breakdown.IsCrit
// Apply burst/chain ability multiplier. // Apply burst/chain ability multiplier.
burstMult := EnemyAttackDamageMultiplier(enemy) burstMult := EnemyAttackDamageMultiplier(enemy)
@ -272,15 +281,15 @@ func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string
continue continue
} }
applyDebuff(hero, rule.debuff, now) ApplyDebuff(hero, rule.debuff, now)
return string(rule.debuff) return string(rule.debuff)
} }
return "" return ""
} }
// applyDebuff adds a debuff to the hero. If the same debuff type is already active, it refreshes. // ApplyDebuff adds a debuff to the hero. If the same debuff type is already active, it refreshes.
func applyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) { func ApplyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
def, ok := model.DebuffDefinition(debuffType) def, ok := model.DebuffDefinition(debuffType)
if !ok { if !ok {
return return
@ -356,16 +365,17 @@ func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration, remainder
return 0 return 0
} }
// Regen rates vary by enemy type. // Regen rates: runtime_config JSON merged at startup; Effective* falls back to tuning.DefaultEnemyRegen*.
cfg := tuning.Get() var regenRate float64
regenRate := cfg.EnemyRegenDefault
switch enemy.Type { switch enemy.Type {
case model.EnemySkeletonKing: case model.EnemySkeletonKing:
regenRate = cfg.EnemyRegenSkeletonKing regenRate = tuning.EffectiveEnemyRegenSkeletonKing()
case model.EnemyForestWarden: case model.EnemyForestWarden:
regenRate = cfg.EnemyRegenForestWarden regenRate = tuning.EffectiveEnemyRegenForestWarden()
case model.EnemyBattleLizard: case model.EnemyBattleLizard:
regenRate = cfg.EnemyRegenBattleLizard regenRate = tuning.EffectiveEnemyRegenBattleLizard()
default:
regenRate = tuning.EffectiveEnemyRegenDefault()
} }
healFloat := float64(enemy.MaxHP) * regenRate * tickDuration.Seconds() healFloat := float64(enemy.MaxHP) * regenRate * tickDuration.Seconds()

@ -0,0 +1,61 @@
package game
import (
"io"
"log/slog"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
)
func TestResolveCombat_MatchesEngineOutcome(t *testing.T) {
baseHero := &model.Hero{
ID: 1,
Level: 5,
MaxHP: 320,
HP: 320,
Attack: 25,
Defense: 8,
Speed: 1.0,
Strength: 10,
Constitution: 12,
Agility: 8,
Luck: 5,
Potions: 0,
State: model.StateWalking,
}
tmpl := model.EnemyTemplates[model.EnemyWolf]
enemy := ScaleEnemyTemplate(tmpl, baseHero.Level)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
eventCh := make(chan model.CombatEvent, 8)
engine := NewEngine(100*time.Millisecond, eventCh, logger)
heroEngine := *baseHero
enemyEngine := enemy
engine.StartCombat(&heroEngine, &enemyEngine)
now := time.Now()
for i := 0; i < 20000; i++ {
now = now.Add(100 * time.Millisecond)
engine.processCombatTick(now)
if _, ok := engine.GetCombat(heroEngine.ID); !ok {
break
}
}
heroResolve := *baseHero
enemyResolve := enemy
survived := ResolveCombatToEnd(&heroResolve, &enemyResolve, time.Now(), CombatSimOptions{
TickRate: 100 * time.Millisecond,
})
engineSurvived := heroEngine.HP > 0
if survived != engineSurvived {
t.Fatalf("survival mismatch: resolve=%v engine=%v", survived, engineSurvived)
}
// Final HP can differ: engine applies debuff ticks for all combats before the attack queue each tick,
// while ResolveCombatToEnd interleaves tick vs attacks on one timeline. Survival is the parity signal.
}

@ -0,0 +1,121 @@
package game
import (
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
const (
offlineAutoPotionChance = 0.02
offlineAutoPotionHPThresh = 0.40
)
// CombatSimOptions configures the shared combat resolution loop.
type CombatSimOptions struct {
// TickRate matches the engine combat tick cadence (used for periodic effects).
TickRate time.Duration
// AutoUsePotion decides whether to consume a potion after damage ticks/attacks.
// It should return true when a potion was used.
AutoUsePotion func(hero *model.Hero, now time.Time) bool
}
// ResolveCombatToEnd runs a combat loop using the same mechanics as the online engine.
// It mutates hero and enemy until one side dies, returning whether the hero survived.
func ResolveCombatToEnd(hero *model.Hero, enemy *model.Enemy, start time.Time, opts CombatSimOptions) bool {
if hero == nil || enemy == nil {
return false
}
tickRate := opts.TickRate
if tickRate <= 0 {
tickRate = 100 * time.Millisecond
}
now := start
heroNext := now.Add(attackInterval(hero.EffectiveSpeed()))
enemyNext := now.Add(attackInterval(enemy.Speed))
nextTick := now.Add(tickRate)
lastTickAt := now
var regenRemainder float64
step := 0
const maxSteps = 200000
for step < maxSteps {
step++
next := heroNext
if enemyNext.Before(next) {
next = enemyNext
}
if nextTick.Before(next) {
next = nextTick
}
now = next
if now.Equal(nextTick) {
tickDur := now.Sub(lastTickAt)
if tickDur > 0 {
ProcessDebuffDamage(hero, tickDur, now)
ProcessEnemyRegen(enemy, tickDur, &regenRemainder)
ProcessSummonDamage(hero, enemy, start, lastTickAt, now)
lastTickAt = now
if CheckDeath(hero, now) {
hero.HP = 0
return false
}
}
nextTick = nextTick.Add(tickRate)
continue
}
if !heroNext.After(enemyNext) && now.Equal(heroNext) {
ProcessAttack(hero, enemy, now)
if !enemy.IsAlive() {
return true
}
heroNext = now.Add(attackInterval(hero.EffectiveSpeed()))
continue
}
if now.Equal(enemyNext) {
ProcessEnemyAttack(hero, enemy, now)
if CheckDeath(hero, now) {
hero.HP = 0
return false
}
if opts.AutoUsePotion != nil {
_ = opts.AutoUsePotion(hero, now)
}
enemyNext = now.Add(attackInterval(enemy.Speed))
}
}
return hero.HP > 0 && enemy.IsAlive() == false
}
// OfflineAutoPotionHook is a low-probability offline-only potion usage policy.
func OfflineAutoPotionHook(hero *model.Hero, now time.Time) bool {
if hero == nil || hero.Potions <= 0 || hero.HP <= 0 {
return false
}
hpThresh := int(float64(hero.MaxHP) * offlineAutoPotionHPThresh)
if hero.HP >= hpThresh {
return false
}
if rand.Float64() >= offlineAutoPotionChance {
return false
}
hero.Potions--
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
return true
}

@ -278,7 +278,7 @@ func TestDamageRollAppliesRange(t *testing.T) {
tuning.Set(cfg) tuning.Set(cfg)
rand.Seed(1) rand.Seed(1)
breakdown := calculateDamageBreakdown(10, 0, 0) breakdown := calculateDamageBreakdown(10, 0, 0, cfg.CombatDamageScale, cfg.CombatDamageRollMin, cfg.CombatDamageRollMax)
if breakdown.RawDamage != 5 || breakdown.FinalDamage != 5 { if breakdown.RawDamage != 5 || breakdown.FinalDamage != 5 {
t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage) t.Fatalf("expected roll to halve damage to 5, got raw=%d final=%d", breakdown.RawDamage, breakdown.FinalDamage)
} }

@ -680,6 +680,13 @@ func (e *Engine) Status() EngineStatus {
} }
} }
// TickRate returns the combat tick rate configured for the engine.
func (e *Engine) TickRate() time.Duration {
e.mu.RLock()
defer e.mu.RUnlock()
return e.tickRate
}
// ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival). // ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival).
func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()
@ -979,6 +986,10 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
}) })
} }
if e.adventureLog != nil {
e.adventureLog(hero.ID, FormatEncounterLogLine(enemy.Name))
}
e.logger.Info("combat started", e.logger.Info("combat started",
"hero_id", hero.ID, "hero_id", hero.ID,
"enemy", enemy.Name, "enemy", enemy.Name,
@ -1130,6 +1141,55 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
} }
} }
// ApplyAdminHeroDeath merges a persisted dead hero after POST /admin/.../force-death, clears combat,
// updates live movement (if any), and pushes hero_state; optionally hero_died for clients.
func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) {
if hero == nil {
return
}
e.mu.Lock()
defer e.mu.Unlock()
delete(e.combats, hero.ID)
hm, ok := e.movements[hero.ID]
if !ok {
if e.sender != nil {
now := time.Now()
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hero)
if sendDiedEvent {
e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{
KilledBy: "admin",
})
}
}
return
}
now := time.Now()
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
*hm.Hero = *hero
hm.State = model.StateDead
hm.Hero.State = model.StateDead
hm.Hero.HP = 0
hm.Die()
if e.sender == nil {
return
}
hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
if sendDiedEvent {
e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{
KilledBy: "admin",
})
}
}
// GetCombat returns the current combat state for a hero, if any. // GetCombat returns the current combat state for a hero, if any.
func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) { func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) {
e.mu.RLock() e.mu.RLock()
@ -1223,6 +1283,58 @@ func (e *Engine) processAttackEvent(evt *model.AttackEvent, cs *model.CombatStat
} }
} }
// sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty.
func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) {
if e.sender == nil || debuffTypeStr == "" {
return
}
dt, ok := model.ValidDebuffType(debuffTypeStr)
if !ok {
return
}
def, ok := model.DebuffDefinition(dt)
if !ok {
return
}
e.sender.SendToHero(heroID, "debuff_applied", model.DebuffAppliedPayload{
DebuffType: string(dt),
DurationMs: def.Duration.Milliseconds(),
Magnitude: def.Magnitude,
ExpiresAt: now.Add(def.Duration),
})
}
// rescheduleHeroAttackAfterSlowDebuff stretches the hero's pending swing when attack speed drops (freeze, ice_slow).
func (e *Engine) rescheduleHeroAttackAfterSlowDebuff(cs *model.CombatState, speedBefore float64, now time.Time) {
if cs.Hero == nil {
return
}
speedAfter := cs.Hero.EffectiveSpeedAt(now)
if speedAfter >= speedBefore || speedBefore <= 0 {
return
}
oldInt := attackInterval(speedBefore)
newInt := attackInterval(speedAfter)
if oldInt <= 0 || newInt <= 0 {
return
}
ratio := float64(newInt) / float64(oldInt)
if cs.HeroNextAttack.After(now) {
remaining := cs.HeroNextAttack.Sub(now)
scaled := time.Duration(float64(remaining) * ratio)
cs.HeroNextAttack = now.Add(scaled)
} else {
cs.HeroNextAttack = now.Add(newInt)
}
for i := range e.queue {
if e.queue[i].CombatID == cs.HeroID && e.queue[i].IsHero {
e.queue[i].NextAttackAt = cs.HeroNextAttack
heap.Fix(&e.queue, i)
return
}
}
}
func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) { func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
if cs.Hero == nil { if cs.Hero == nil {
e.logger.Error("processHeroAttack: nil hero reference", "hero_id", cs.HeroID) e.logger.Error("processHeroAttack: nil hero reference", "hero_id", cs.HeroID)
@ -1244,18 +1356,7 @@ func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
EnemyHP: combatEvt.EnemyHP, EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied, DebuffApplied: combatEvt.DebuffApplied,
}) })
if combatEvt.DebuffApplied != "" { e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now)
if dt, ok := model.ValidDebuffType(combatEvt.DebuffApplied); ok {
if def, ok := model.DebuffDefinition(dt); ok {
e.sender.SendToHero(cs.HeroID, "debuff_applied", model.DebuffAppliedPayload{
DebuffType: string(dt),
DurationMs: def.Duration.Milliseconds(),
Magnitude: def.Magnitude,
ExpiresAt: now.Add(def.Duration),
})
}
}
}
} }
if !cs.Enemy.IsAlive() { if !cs.Enemy.IsAlive() {
@ -1278,6 +1379,7 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
return return
} }
speedBefore := cs.Hero.EffectiveSpeedAt(now)
combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now) combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
e.emitEvent(combatEvt) e.emitEvent(combatEvt)
e.logCombatAttack(cs, combatEvt) e.logCombatAttack(cs, combatEvt)
@ -1293,7 +1395,9 @@ func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
EnemyHP: combatEvt.EnemyHP, EnemyHP: combatEvt.EnemyHP,
DebuffApplied: combatEvt.DebuffApplied, DebuffApplied: combatEvt.DebuffApplied,
}) })
e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now)
} }
e.rescheduleHeroAttackAfterSlowDebuff(cs, speedBefore, now)
// Check if the hero died from this attack. // Check if the hero died from this attack.
if CheckDeath(cs.Hero, now) { if CheckDeath(cs.Hero, now) {
@ -1363,7 +1467,7 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied." msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied."
} }
if msg != "" { if msg != "" {
e.adventureLog(cs.HeroID, msg) e.adventureLog(cs.HeroID, FormatBattleLogLine(msg))
} }
} }

@ -20,9 +20,13 @@ type OfflineSimulator struct {
store *storage.HeroStore store *storage.HeroStore
logStore *storage.LogStore logStore *storage.LogStore
questStore *storage.QuestStore questStore *storage.QuestStore
gearStore *storage.GearStore
taskStore *storage.DailyTaskStore
achStore *storage.AchievementStore
graph *RoadGraph graph *RoadGraph
interval time.Duration interval time.Duration
logger *slog.Logger logger *slog.Logger
combatTickRate time.Duration
// isPaused, when set, skips simulation ticks while global server time is frozen. // isPaused, when set, skips simulation ticks while global server time is frozen.
isPaused func() bool isPaused func() bool
// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session) // skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session)
@ -41,11 +45,28 @@ func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, q
graph: graph, graph: graph,
interval: 30 * time.Second, interval: 30 * time.Second,
logger: logger, logger: logger,
combatTickRate: 100 * time.Millisecond,
isPaused: isPaused, isPaused: isPaused,
skipIfLive: skipIfLive, skipIfLive: skipIfLive,
} }
} }
// WithCombatTickRate overrides the combat tick rate used in offline simulations.
func (s *OfflineSimulator) WithCombatTickRate(tick time.Duration) *OfflineSimulator {
if tick > 0 {
s.combatTickRate = tick
}
return s
}
// WithRewardStores wires optional stores for offline reward hooks.
func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievements *storage.AchievementStore, tasks *storage.DailyTaskStore) *OfflineSimulator {
s.gearStore = gear
s.achStore = achievements
s.taskStore = tasks
return s
}
// Run starts the offline simulation loop. It blocks until the context is cancelled. // Run starts the offline simulation loop. It blocks until the context is cancelled.
func (s *OfflineSimulator) Run(ctx context.Context) error { func (s *OfflineSimulator) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval) ticker := time.NewTicker(s.interval)
@ -85,7 +106,7 @@ func (s *OfflineSimulator) processTick(ctx context.Context) {
if s.skipIfLive != nil && s.skipIfLive(hero.ID) { if s.skipIfLive != nil && s.skipIfLive(hero.ID) {
continue continue
} }
if err := s.simulateHeroTick(ctx, hero); err != nil { if err := s.simulateHeroTick(ctx, hero, time.Now(), true); err != nil {
s.logger.Error("offline simulator: hero tick failed", s.logger.Error("offline simulator: hero tick failed",
"hero_id", hero.ID, "hero_id", hero.ID,
"error", err, "error", err,
@ -98,12 +119,11 @@ func (s *OfflineSimulator) processTick(ctx context.Context) {
// simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now, // simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now,
// then persists. Random encounters use the same rolls as online; combat is resolved // then persists. Random encounters use the same rolls as online; combat is resolved
// synchronously via SimulateOneFight (no WebSocket). // synchronously via SimulateOneFight (no WebSocket).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero) error { func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {
now := time.Now()
// Auto-revive after configured downtime (autoReviveAfterMs). // Auto-revive after configured downtime (autoReviveAfterMs).
gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > gap { if (hero.State == model.StateDead || hero.HP <= 0) && now.Sub(hero.UpdatedAt) > gap {
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent) hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 { if hero.HP < 1 {
hero.HP = 1 hero.HP = 1
@ -135,10 +155,9 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
} }
encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) { encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) s.addLog(ctx, hm.Hero.ID, FormatEncounterLogLine(enemy.Name))
survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, func(msg string) { rewardDeps := s.rewardDeps(tickNow)
s.addLog(ctx, hm.Hero.ID, msg) survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
})
if survived { if survived {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained)) s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
hm.ResumeWalking(tickNow) hm.ResumeWalking(tickNow)
@ -180,19 +199,49 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
hm.SyncToHero() hm.SyncToHero()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
if err := s.store.Save(ctx, hero); err != nil { if persist && s.store != nil {
return fmt.Errorf("save hero after offline tick: %w", err) if err := s.store.Save(ctx, hero); err != nil {
return fmt.Errorf("save hero after offline tick: %w", err)
}
} }
return nil return nil
} }
// SimulateHeroAt runs a single offline catch-up tick up to the given time.
func (s *OfflineSimulator) SimulateHeroAt(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {
return s.simulateHeroTick(ctx, hero, now, persist)
}
func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook { func (s *OfflineSimulator) offlineTownNPCInteractHook(ctx context.Context) TownNPCOfflineInteractHook {
return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al) return s.applyOfflineTownNPCVisit(ctx, heroID, hm, graph, npc, now, al)
} }
} }
func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
return VictoryRewardDeps{
GearStore: s.gearStore,
QuestProgressor: s.questStore,
AchievementCheck: s.achStore,
TaskProgressor: s.taskStore,
LogWriter: func(heroID int64, msg string) {
logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.logStore.Add(logCtx, heroID, msg); err != nil && s.logger != nil {
s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err)
}
},
InTown: func(ctx context.Context, posX, posY float64) bool {
if s.graph == nil {
return false
}
return s.graph.HeroInTownAt(posX, posY)
},
Logger: s.logger,
}
}
// applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI). // applyOfflineTownNPCVisit rolls TownNPCInteractChance; on success simulates merchant / healer / quest-giver actions (no UI).
func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool { func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) bool {
_ = graph _ = graph
@ -296,78 +345,26 @@ func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message str
} }
} }
// SimulateOneFight runs one combat encounter for an offline hero. // SimulateOneFight runs one combat encounter using the shared combat loop and reward logic.
// It mutates the hero (HP, XP, gold, potions, level, equipment, state).
// If encounterEnemy is non-nil, that enemy is used (same as movement encounter roll);
// otherwise a new enemy is picked for the hero's level.
// onInventoryDiscard is called when a gear drop cannot be equipped and the backpack is full (may be nil).
// Returns whether the hero survived, the enemy fought, XP gained, and gold gained. // Returns whether the hero survived, the enemy fought, XP gained, and gold gained.
func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, onInventoryDiscard func(string)) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) { func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) {
if encounterEnemy != nil { if encounterEnemy != nil {
enemy = *encounterEnemy enemy = *encounterEnemy
} else { } else {
enemy = PickEnemyForLevel(hero.Level) enemy = PickEnemyForLevel(hero.Level)
} }
if rewardDeps.InTown == nil && g != nil {
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY) rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool {
return g.HeroInTownAt(posX, posY)
combatStart := now
lastTick := now
var regenRemainder float64
heroNext := now.Add(attackInterval(hero.EffectiveSpeedAt(now)))
enemyNext := now.Add(attackInterval(enemy.Speed))
const maxCombatSteps = 100000
for step := 0; step < maxCombatSteps && hero.IsAlive() && enemy.IsAlive(); step++ {
nextTime := heroNext
isHero := true
if enemyNext.Before(heroNext) {
nextTime = enemyNext
isHero = false
}
if !nextTime.After(lastTick) {
nextTime = lastTick.Add(time.Millisecond)
}
tickDur := nextTime.Sub(lastTick)
if tickDur > 0 {
ProcessDebuffDamage(hero, tickDur, nextTime)
ProcessEnemyRegen(&enemy, tickDur, &regenRemainder)
ProcessSummonDamage(hero, &enemy, combatStart, lastTick, nextTime)
}
lastTick = nextTime
if CheckDeath(hero, nextTime) {
break
}
if isHero {
ProcessAttack(hero, &enemy, nextTime)
if !enemy.IsAlive() {
break
}
heroNext = nextTime.Add(attackInterval(hero.EffectiveSpeedAt(nextTime)))
} else {
ProcessEnemyAttack(hero, &enemy, nextTime)
if CheckDeath(hero, nextTime) {
break
}
enemyNext = nextTime.Add(attackInterval(enemy.Speed))
} }
} }
// Use potion if HP drops below 30% and hero has potions. survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{
if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 { TickRate: tickRate,
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent) AutoUsePotion: OfflineAutoPotionHook,
if healAmount < 1 { })
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
}
hero.Potions--
}
if hero.HP <= 0 { if !survived || hero.HP <= 0 {
hero.HP = 0 hero.HP = 0
hero.State = model.StateDead hero.State = model.StateDead
hero.TotalDeaths++ hero.TotalDeaths++
@ -375,56 +372,21 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
return false, enemy, 0, 0 return false, enemy, 0, 0
} }
// Hero survived — apply rewards and stat tracking.
hero.TotalKills++
hero.KillsSinceDeath++
if enemy.IsElite {
hero.EliteKills++
}
xpGained = enemy.XPReward xpGained = enemy.XPReward
hero.XP += xpGained drops := ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
goldGained = sumGoldFromDrops(drops)
hero.RefreshDerivedCombatStats(now)
return true, enemy, xpGained, goldGained
}
// Loot generation. func sumGoldFromDrops(drops []model.LootDrop) int64 {
luckMult := LuckMultiplier(hero, now) var total int64
drops := model.GenerateLoot(enemy.Type, luckMult)
for _, drop := range drops { for _, drop := range drops {
// Track legendary equipment drops for achievements. if drop.ItemType == "gold" || drop.GoldAmount > 0 {
if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" { total += drop.GoldAmount
hero.LegendaryDrops++
} }
switch drop.ItemType {
case "gold":
hero.Gold += drop.GoldAmount
goldGained += drop.GoldAmount
case "potion":
hero.Potions++
default:
// All equipment drops go through the unified gear system.
slot := model.EquipmentSlot(drop.ItemType)
family := model.PickGearFamily(slot)
if family != nil {
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
item := model.NewGearItem(family, ilvl, drop.Rarity)
TryEquipOrStashOffline(hero, item, now, onInventoryDiscard)
} else if allowSell {
price := model.AutoSellPrice(drop.Rarity)
hero.Gold += price
goldGained += price
}
}
}
// Also add the base gold reward from the enemy.
hero.Gold += enemy.GoldReward
goldGained += enemy.GoldReward
// Level-up loop.
for hero.LevelUp() {
} }
return total
hero.RefreshDerivedCombatStats(now)
return true, enemy, xpGained, goldGained
} }
// PickEnemyForLevel selects a random enemy appropriate for the hero's level // PickEnemyForLevel selects a random enemy appropriate for the hero's level

@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, nil) survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived { if !survived {
t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name)
@ -42,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, nil) survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if survived { if survived {
t.Fatal("1 HP hero should die to any enemy") t.Fatal("1 HP hero should die to any enemy")
@ -66,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, nil) survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived { if !survived {
t.Fatal("overpowered hero should survive") t.Fatal("overpowered hero should survive")
@ -79,30 +79,17 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
} }
} }
func TestSimulateOneFight_PotionUsage(t *testing.T) { func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) {
// Create a hero that will take significant damage but survive.
hero := &model.Hero{ hero := &model.Hero{
Level: 1, XP: 0, MaxHP: 100,
MaxHP: 100, HP: 100, HP: 100,
Attack: 50, Defense: 3, Speed: 1.0, Potions: 3,
Potions: 5,
State: model.StateWalking,
} }
if used := OfflineAutoPotionHook(hero, time.Now()); used {
now := time.Now() t.Fatal("expected no potion usage when hero is above threshold")
startPotions := hero.Potions
// Run multiple fights — at least one should use a potion.
for i := 0; i < 20; i++ {
if hero.HP <= 0 {
break
}
hero.HP = 25 // force low HP to trigger potion usage
SimulateOneFight(hero, now, nil, nil, nil)
} }
if hero.Potions != 3 {
if hero.Potions >= startPotions { t.Fatalf("expected potions unchanged, got %d", hero.Potions)
t.Log("no potions used after 20 fights with low HP — may be probabilistic, not a hard failure")
} }
} }

@ -0,0 +1,290 @@
package game
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
type GearStore interface {
CreateItem(ctx context.Context, item *model.GearItem) error
DeleteGearItem(ctx context.Context, itemID int64) error
AddToInventory(ctx context.Context, heroID int64, itemID int64) error
EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, itemID int64) error
}
type QuestProgressor interface {
IncrementQuestProgress(ctx context.Context, heroID int64, questType string, enemyType string, amount int) error
IncrementCollectItemProgress(ctx context.Context, heroID int64, enemyType string) error
}
type AchievementChecker interface {
CheckAndUnlock(ctx context.Context, hero *model.Hero) ([]model.Achievement, error)
}
type TaskProgressor interface {
EnsureHeroTasks(ctx context.Context, heroID int64, now time.Time) error
IncrementTaskProgress(ctx context.Context, heroID int64, taskType string, amount int) error
}
// VictoryRewardDeps provides optional services for applying rewards.
type VictoryRewardDeps struct {
GearStore GearStore
QuestProgressor QuestProgressor
AchievementCheck AchievementChecker
TaskProgressor TaskProgressor
LogWriter func(heroID int64, msg string)
LootRecorder func(entry model.LootHistory)
InTown func(ctx context.Context, posX, posY float64) bool
Logger *slog.Logger
}
// ApplyVictoryRewards is the single source of truth for post-kill rewards.
// It awards XP, generates loot (gold guaranteed via GenerateLoot), 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 {
return nil
}
oldLevel := hero.Level
hero.XP += enemy.XPReward
levelsGained := 0
for hero.LevelUp() {
levelsGained++
}
hero.State = model.StateWalking
luckMult := LuckMultiplier(hero, now)
drops := model.GenerateLoot(enemy.Type, luckMult)
inTown := false
if deps.InTown != nil {
ctxTown, cancel := context.WithTimeout(context.Background(), 2*time.Second)
inTown = deps.InTown(ctxTown, hero.PositionX, hero.PositionY)
cancel()
}
var goldGained int64
for i := range drops {
drop := &drops[i]
switch drop.ItemType {
case "gold":
hero.Gold += drop.GoldAmount
goldGained += drop.GoldAmount
case "potion":
hero.Potions++
default:
slot := model.EquipmentSlot(drop.ItemType)
family := model.PickGearFamily(slot)
if family != nil {
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
item := model.NewGearItem(family, ilvl, drop.Rarity)
if deps.GearStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := deps.GearStore.CreateItem(ctx, item); err != nil {
cancel()
if deps.Logger != nil {
deps.Logger.Warn("failed to create gear item", "slot", slot, "error", err)
}
if inTown {
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
goldGained += sellPrice
drop.GoldAmount = sellPrice
} else {
drop.GoldAmount = 0
}
goto recordLoot
}
cancel()
}
drop.ItemID = item.ID
drop.ItemName = item.Name
hero.EnsureGearMap()
prev := hero.Gear[item.Slot]
if TryAutoEquipInMemory(hero, item, now) {
if deps.GearStore != nil && item.ID != 0 {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := deps.GearStore.EquipItem(ctx, hero.ID, item.Slot, item.ID)
cancel()
if err != nil {
if prev == nil {
delete(hero.Gear, item.Slot)
} else {
hero.Gear[item.Slot] = prev
}
hero.RefreshDerivedCombatStats(now)
if deps.Logger != nil {
if errors.Is(err, storage.ErrInventoryFull) {
deps.Logger.Warn("persist gear equip skipped: inventory full (free a slot to swap)",
"hero_id", hero.ID, "slot", item.Slot)
} else {
deps.Logger.Warn("failed to persist gear equip",
"hero_id", hero.ID, "slot", item.Slot, "error", err)
}
}
goto recordLoot
}
}
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
}
if prev != nil && prev.ID != item.ID {
hero.EnsureInventorySlice()
hero.Inventory = append(hero.Inventory, prev)
}
goto recordLoot
}
hero.EnsureInventorySlice()
if len(hero.Inventory) >= model.MaxInventorySlots {
if deps.GearStore != nil && item.ID != 0 {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
if err := deps.GearStore.DeleteGearItem(ctxDel, item.ID); err != nil && deps.Logger != nil {
deps.Logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err)
}
cancelDel()
}
drop.ItemID = 0
drop.ItemName = ""
drop.GoldAmount = 0
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
}
} else {
if deps.GearStore != nil {
ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second)
err := deps.GearStore.AddToInventory(ctxInv, hero.ID, item.ID)
cancelInv()
if err != nil {
if deps.Logger != nil {
deps.Logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err)
}
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
if deps.GearStore != nil && item.ID != 0 {
_ = deps.GearStore.DeleteGearItem(ctxDel, item.ID)
}
cancelDel()
drop.ItemID = 0
drop.ItemName = ""
drop.GoldAmount = 0
} else {
hero.Inventory = append(hero.Inventory, item)
drop.GoldAmount = 0
}
} else {
hero.Inventory = append(hero.Inventory, item)
drop.GoldAmount = 0
}
}
} else if inTown {
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
goldGained += sellPrice
drop.GoldAmount = sellPrice
}
}
recordLoot:
if deps.LootRecorder != nil {
entry := model.LootHistory{
HeroID: hero.ID,
EnemyType: string(enemy.Type),
ItemType: drop.ItemType,
ItemID: drop.ItemID,
Rarity: drop.Rarity,
GoldAmount: drop.GoldAmount,
CreatedAt: now,
}
deps.LootRecorder(entry)
}
}
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, goldGained))
for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
deps.LogWriter(hero.ID, fmt.Sprintf("Leveled up to %d!", l))
}
}
hero.TotalKills++
hero.KillsSinceDeath++
if enemy.IsElite {
hero.EliteKills++
}
for _, drop := range drops {
if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" {
hero.LegendaryDrops++
}
}
if deps.QuestProgressor != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := deps.QuestProgressor.IncrementQuestProgress(ctx, hero.ID, "kill_count", string(enemy.Type), 1); err != nil && deps.Logger != nil {
deps.Logger.Warn("quest kill_count progress failed", "hero_id", hero.ID, "error", err)
}
if err := deps.QuestProgressor.IncrementCollectItemProgress(ctx, hero.ID, string(enemy.Type)); err != nil && deps.Logger != nil {
deps.Logger.Warn("quest collect_item progress failed", "hero_id", hero.ID, "error", err)
}
cancel()
}
if deps.AchievementCheck != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
newlyUnlocked, err := deps.AchievementCheck.CheckAndUnlock(ctx, hero)
cancel()
if err != nil {
if deps.Logger != nil {
deps.Logger.Warn("achievement check failed", "hero_id", hero.ID, "error", err)
}
} else if deps.LogWriter != nil {
for _, a := range newlyUnlocked {
switch a.RewardType {
case "gold":
hero.Gold += int64(a.RewardAmount)
case "potion":
hero.Potions += a.RewardAmount
}
deps.LogWriter(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
}
}
}
if deps.TaskProgressor != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := deps.TaskProgressor.EnsureHeroTasks(ctx, hero.ID, time.Now()); err != nil {
if deps.Logger != nil {
deps.Logger.Warn("task ensure failed", "hero_id", hero.ID, "error", err)
}
cancel()
return drops
}
if err := deps.TaskProgressor.IncrementTaskProgress(ctx, hero.ID, "kill_count", 1); err != nil && deps.Logger != nil {
deps.Logger.Warn("task kill_count progress failed", "hero_id", hero.ID, "error", err)
}
if enemy.IsElite {
if err := deps.TaskProgressor.IncrementTaskProgress(ctx, hero.ID, "elite_kill", 1); err != nil && deps.Logger != nil {
deps.Logger.Warn("task elite_kill progress failed", "hero_id", hero.ID, "error", err)
}
}
if goldGained > 0 {
if err := deps.TaskProgressor.IncrementTaskProgress(ctx, hero.ID, "collect_gold", int(goldGained)); err != nil && deps.Logger != nil {
deps.Logger.Warn("task collect_gold progress failed", "hero_id", hero.ID, "error", err)
}
}
cancel()
}
return drops
}

@ -570,7 +570,7 @@ func (h *AdminHandler) ClaimHeroQuest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"})
return return
} }
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// AbandonHeroQuest removes quest from hero log. // AbandonHeroQuest removes quest from hero log.
@ -957,7 +957,7 @@ func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type setGoldRequest struct { type setGoldRequest struct {
@ -1022,7 +1022,7 @@ func (h *AdminHandler) SetHeroGold(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type addPotionsRequest struct { type addPotionsRequest struct {
@ -1076,7 +1076,7 @@ func (h *AdminHandler) AddPotions(w http.ResponseWriter, r *http.Request) {
h.logger.Info("admin: hero added potions", "hero_id", heroID, "potions", hero.Potions) h.logger.Info("admin: hero added potions", "hero_id", heroID, "potions", hero.Potions)
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.SyncHeroState(hero) h.engine.SyncHeroState(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type setHPRequest struct { type setHPRequest struct {
@ -1146,7 +1146,7 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) {
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero) h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// ReviveHero force-revives a hero to full HP regardless of current state. // ReviveHero force-revives a hero to full HP regardless of current state.
@ -1193,7 +1193,62 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP) h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP)
hero.EnsureGearMap() hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
writeJSON(w, http.StatusOK, 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
func (h *AdminHandler) ForceHeroDeath(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
}
h.engine.StopCombat(heroID)
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for force-death", "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
}
wasAlive := hero.State != model.StateDead && hero.HP > 0
hero.HP = 0
hero.State = model.StateDead
hero.Buffs = nil
hero.Debuffs = nil
if wasAlive {
hero.TotalDeaths++
hero.KillsSinceDeath = 0
}
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after force-death", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.engine.ApplyAdminHeroDeath(hero, wasAlive)
h.logger.Info("admin: hero force-death", "hero_id", heroID, "was_alive", wasAlive)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now())
writeHeroJSON(w, http.StatusOK, hero)
} }
// ResetHero resets a hero to fresh level 1 defaults. // ResetHero resets a hero to fresh level 1 defaults.
@ -1238,7 +1293,7 @@ func (h *AdminHandler) ResetHero(w http.ResponseWriter, r *http.Request) {
h.logger.Info("admin: hero reset", "hero_id", heroID) h.logger.Info("admin: hero reset", "hero_id", heroID)
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
type resetBuffChargesRequest struct { type resetBuffChargesRequest struct {
@ -1309,7 +1364,150 @@ func (h *AdminHandler) ResetBuffCharges(w http.ResponseWriter, r *http.Request)
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
}
type applyBuffAdminRequest struct {
BuffType string `json:"buffType"`
}
// ApplyHeroBuff applies a buff from the catalog without consuming free-charge quota (admin/testing).
// POST /admin/heroes/{heroId}/apply-buff
func (h *AdminHandler) ApplyHeroBuff(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 applyBuffAdminRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body: " + err.Error(),
})
return
}
bt, ok := model.ValidBuffType(req.BuffType)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid buffType: " + req.BuffType,
})
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for apply-buff", "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()
if game.ApplyBuff(hero, bt, now) == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "buff could not be applied (unknown catalog entry)",
})
return
}
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after apply-buff", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.logger.Info("admin: buff applied", "hero_id", heroID, "buff_type", bt)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero)
writeHeroJSON(w, http.StatusOK, hero)
}
type applyDebuffAdminRequest struct {
DebuffType string `json:"debuffType"`
}
// ApplyHeroDebuff applies a debuff from the catalog (admin/testing).
// POST /admin/heroes/{heroId}/apply-debuff
func (h *AdminHandler) ApplyHeroDebuff(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 applyDebuffAdminRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid request body: " + err.Error(),
})
return
}
dt, ok := model.ValidDebuffType(req.DebuffType)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid debuffType: " + req.DebuffType,
})
return
}
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
h.logger.Error("admin: get hero for apply-debuff", "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()
if _, defOk := model.DebuffDefinition(dt); !defOk {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "debuff not in catalog: " + req.DebuffType,
})
return
}
game.ApplyDebuff(hero, dt, now)
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after apply-debuff", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to save hero",
})
return
}
h.logger.Info("admin: debuff applied", "hero_id", heroID, "debuff_type", dt)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
h.engine.ApplyAdminHeroSnapshot(hero)
writeHeroJSON(w, http.StatusOK, hero)
} }
// DeleteHero permanently removes a hero from the database. // DeleteHero permanently removes a hero from the database.

@ -101,161 +101,30 @@ func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now tim
// sets hero state to walking, and records loot history. // sets hero state to walking, and records loot history.
// Returns the drops for API response building. // Returns the drops for API response building.
func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop { func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop {
oldLevel := hero.Level
hero.XP += enemy.XPReward
levelsGained := 0
for hero.LevelUp() {
levelsGained++
}
hero.State = model.StateWalking
luckMult := game.LuckMultiplier(hero, now)
drops := model.GenerateLoot(enemy.Type, luckMult)
ctxTown, cancelTown := context.WithTimeout(context.Background(), 2*time.Second)
inTown := h.isHeroInTown(ctxTown, hero.PositionX, hero.PositionY)
cancelTown()
h.lootMu.Lock() h.lootMu.Lock()
defer h.lootMu.Unlock() defer h.lootMu.Unlock()
for i := range drops { return game.ApplyVictoryRewards(hero, enemy, now, game.VictoryRewardDeps{
drop := &drops[i] GearStore: h.gearStore,
QuestProgressor: h.questStore,
switch drop.ItemType { AchievementCheck: h.achievementStore,
case "gold": TaskProgressor: h.taskStore,
hero.Gold += drop.GoldAmount LogWriter: h.addLog,
InTown: func(ctx context.Context, posX, posY float64) bool {
case "potion": return h.isHeroInTown(ctx, posX, posY)
hero.Potions++ },
LootRecorder: func(entry model.LootHistory) {
default: h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry)
// All equipment drops go through the unified gear system. lootHistoryLimit := int(tuning.Get().LootHistoryLimit)
slot := model.EquipmentSlot(drop.ItemType) if lootHistoryLimit < 1 {
family := model.PickGearFamily(slot) lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit)
if family != nil {
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
item := model.NewGearItem(family, ilvl, drop.Rarity)
// Persist the gear item to DB.
if h.gearStore != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := h.gearStore.CreateItem(ctx, item); err != nil {
h.logger.Warn("failed to create gear item", "slot", slot, "error", err)
cancel()
if inTown {
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
drop.GoldAmount = sellPrice
} else {
drop.GoldAmount = 0
}
goto recordLoot
}
cancel()
}
drop.ItemID = item.ID
drop.ItemName = item.Name
equipped := h.tryAutoEquipGear(hero, item, now)
if equipped {
h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
} else {
hero.EnsureInventorySlice()
if len(hero.Inventory) >= model.MaxInventorySlots {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
if h.gearStore != nil && item.ID != 0 {
if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil {
h.logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err)
}
}
cancelDel()
drop.ItemID = 0
drop.ItemName = ""
drop.GoldAmount = 0
h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
} else {
ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second)
var err error
if h.gearStore != nil {
err = h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
}
cancelInv()
if err != nil {
h.logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err)
ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
if h.gearStore != nil && item.ID != 0 {
_ = h.gearStore.DeleteGearItem(ctxDel, item.ID)
}
cancelDel()
drop.ItemID = 0
drop.ItemName = ""
drop.GoldAmount = 0
} else {
hero.Inventory = append(hero.Inventory, item)
drop.GoldAmount = 0
}
}
}
} else if inTown {
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
drop.GoldAmount = sellPrice
} }
} if len(h.lootCache[hero.ID]) > lootHistoryLimit {
h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:]
recordLoot: }
entry := model.LootHistory{ },
HeroID: hero.ID, Logger: h.logger,
EnemyType: string(enemy.Type), })
ItemType: drop.ItemType,
ItemID: drop.ItemID,
Rarity: drop.Rarity,
GoldAmount: drop.GoldAmount,
CreatedAt: now,
}
h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry)
lootHistoryLimit := int(tuning.Get().LootHistoryLimit)
if lootHistoryLimit < 1 {
lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit)
}
if len(h.lootCache[hero.ID]) > lootHistoryLimit {
h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:]
}
}
// Log the victory.
h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, enemy.GoldReward))
// Log level-ups.
for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
h.addLog(hero.ID, fmt.Sprintf("Leveled up to %d!", l))
}
// Stat tracking for achievements.
hero.TotalKills++
hero.KillsSinceDeath++
if enemy.IsElite {
hero.EliteKills++
}
// Track legendary drops for achievement conditions.
for _, drop := range drops {
if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" {
hero.LegendaryDrops++
}
}
// Quest progress hooks (fire-and-forget, errors logged but not fatal).
h.progressQuestsAfterKill(hero.ID, enemy)
// Achievement check (fire-and-forget).
h.checkAchievementsAfterKill(hero)
// Daily/weekly task progress (fire-and-forget).
h.progressTasksAfterKill(hero.ID, enemy, drops)
return drops
} }
// resolveTelegramID extracts the Telegram user ID from auth context, // resolveTelegramID extracts the Telegram user ID from auth context,
@ -321,7 +190,7 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) {
} }
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// ActivateBuff activates a buff on the hero. // ActivateBuff activates a buff on the hero.
@ -408,6 +277,7 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"buff": ab, "buff": ab,
"heroBuffs": hero.Buffs, "heroBuffs": hero.Buffs,
@ -481,7 +351,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
h.addLog(hero.ID, "Hero revived") h.addLog(hero.ID, "Hero revived")
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// RequestEncounter picks a backend-generated enemy for the hero's current level. // RequestEncounter picks a backend-generated enemy for the hero's current level.
@ -788,6 +658,7 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, victoryResponse{ writeJSON(w, http.StatusOK, victoryResponse{
Hero: hero, Hero: hero,
Drops: outDrops, Drops: outDrops,
@ -894,48 +765,21 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
gapDuration = 8 * time.Hour gapDuration = 8 * time.Hour
} }
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3). var rg *game.RoadGraph
if (hero.State == model.StateDead || hero.HP <= 0) && gapDuration > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond { if h.engine != nil {
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent) rg = h.engine.RoadGraph()
if hero.HP < 1 {
hero.HP = 1
}
hero.State = model.StateWalking
hero.Debuffs = nil
h.addLog(hero.ID, "Auto-revived after 1 hour")
} }
sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil).
totalFights := int(gapDuration.Seconds()) / 10 WithRewardStores(h.gearStore, h.achievementStore, h.taskStore)
if totalFights <= 0 { if h.engine != nil {
return false sim.WithCombatTickRate(h.engine.TickRate())
} }
before := hero.UpdatedAt
now := time.Now() if err := sim.SimulateHeroAt(ctx, hero, h.serverStartedAt, false); err != nil {
performed := false h.logger.Error("catch-up sim failed", "hero_id", hero.ID, "error", err)
return false
for i := 0; i < totalFights; i++ {
if hero.HP <= 0 || hero.State == model.StateDead {
break
}
var rg *game.RoadGraph
if h.engine != nil {
rg = h.engine.RoadGraph()
}
survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil, rg, func(msg string) {
h.addLog(hero.ID, msg)
})
performed = true
h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
if survived {
h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, xpGained, goldGained))
} else {
h.addLog(hero.ID, fmt.Sprintf("Died fighting %s", enemy.Name))
}
} }
return hero.UpdatedAt.After(before)
return performed
} }
// parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern. // parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.
@ -1074,6 +918,7 @@ 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()
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"needsName": needsName, "needsName": needsName,
@ -1222,7 +1067,7 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
} }
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name) h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
return return
} }
@ -1255,7 +1100,7 @@ func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// containsUniqueViolation checks if an error message indicates a PostgreSQL unique violation. // containsUniqueViolation checks if an error message indicates a PostgreSQL unique violation.
@ -1365,7 +1210,7 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request)
h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt)) h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt))
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// PurchaseSubscription purchases the configured subscription duration (x2 buffs, x2 revives). // PurchaseSubscription purchases the configured subscription duration (x2 buffs, x2 revives).
@ -1427,6 +1272,7 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques
h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice())) h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice()))
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"hero": hero, "hero": hero,
"expiresAt": hero.SubscriptionExpiresAt, "expiresAt": hero.SubscriptionExpiresAt,
@ -1529,7 +1375,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// GetAdventureLog returns the hero's recent adventure log entries. // GetAdventureLog returns the hero's recent adventure log entries.
@ -1689,6 +1535,12 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)
} }
// writeHeroJSON encodes a hero with client-only fields (debuff catalog durations).
func writeHeroJSON(w http.ResponseWriter, status int, hero *model.Hero) {
model.AttachDebuffCatalogForClient(hero)
writeJSON(w, status, hero)
}
// checkAchievementsAfterKill runs achievement condition checks and applies rewards. // checkAchievementsAfterKill runs achievement condition checks and applies rewards.
func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) { func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
if h.achievementStore == nil { if h.achievementStore == nil {

@ -621,7 +621,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
h.addLog(hero.ID, "Healed to full HP by a town healer") h.addLog(hero.ID, "Healed to full HP by a town healer")
// Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client. // Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client.
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// BuyPotion handles POST /api/v1/hero/npc-buy-potion. // BuyPotion handles POST /api/v1/hero/npc-buy-potion.
@ -670,5 +670,5 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
} }
h.addLog(hero.ID, "Purchased a Healing Potion from a merchant") h.addLog(hero.ID, "Purchased a Healing Potion from a merchant")
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }

@ -344,7 +344,7 @@ func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request)
h.logger.Info("quest reward claimed", "hero_id", hero.ID, "quest_id", questID, h.logger.Info("quest reward claimed", "hero_id", hero.ID, "quest_id", questID,
"xp", reward.XP, "gold", reward.Gold, "potions", reward.Potions) "xp", reward.XP, "gold", reward.Gold, "potions", reward.Potions)
writeJSON(w, http.StatusOK, hero) writeHeroJSON(w, http.StatusOK, hero)
} }
// AbandonQuest removes a quest from the hero's quest log. // AbandonQuest removes a quest from the hero's quest log.

@ -135,6 +135,11 @@ func (h *Hub) BroadcastEvent(event model.CombatEvent) {
// SendToHero sends a typed message to all WebSocket connections for a specific hero. // SendToHero sends a typed message to all WebSocket connections for a specific hero.
func (h *Hub) SendToHero(heroID int64, msgType string, payload any) { func (h *Hub) SendToHero(heroID int64, msgType string, payload any) {
if msgType == "hero_state" {
if hero, ok := payload.(*model.Hero); ok {
model.AttachDebuffCatalogForClient(hero)
}
}
env := model.NewWSEnvelope(msgType, payload) env := model.NewWSEnvelope(msgType, payload)
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()

@ -91,31 +91,31 @@ func seedDebuffMap() map[DebuffType]Debuff {
return map[DebuffType]Debuff{ return map[DebuffType]Debuff{
DebuffPoison: { DebuffPoison: {
Type: DebuffPoison, Name: "Poison", Type: DebuffPoison, Name: "Poison",
Duration: 5 * time.Second, Magnitude: 0.02, Duration: 50 * time.Second, Magnitude: 0.02,
}, },
DebuffFreeze: { DebuffFreeze: {
Type: DebuffFreeze, Name: "Freeze", Type: DebuffFreeze, Name: "Freeze",
Duration: 3 * time.Second, Magnitude: 0.50, Duration: 30 * time.Second, Magnitude: 0.50,
}, },
DebuffBurn: { DebuffBurn: {
Type: DebuffBurn, Name: "Burn", Type: DebuffBurn, Name: "Burn",
Duration: 4 * time.Second, Magnitude: 0.03, Duration: 40 * time.Second, Magnitude: 0.03,
}, },
DebuffStun: { DebuffStun: {
Type: DebuffStun, Name: "Stun", Type: DebuffStun, Name: "Stun",
Duration: 2 * time.Second, Magnitude: 1.0, Duration: 5 * time.Second, Magnitude: 1.0,
}, },
DebuffSlow: { DebuffSlow: {
Type: DebuffSlow, Name: "Slow", Type: DebuffSlow, Name: "Slow",
Duration: 4 * time.Second, Magnitude: 0.40, Duration: 40 * time.Second, Magnitude: 0.40,
}, },
DebuffWeaken: { DebuffWeaken: {
Type: DebuffWeaken, Name: "Weaken", Type: DebuffWeaken, Name: "Weaken",
Duration: 5 * time.Second, Magnitude: 0.30, Duration: 50 * time.Second, Magnitude: 0.30,
}, },
DebuffIceSlow: { DebuffIceSlow: {
Type: DebuffIceSlow, Name: "Ice Slow", Type: DebuffIceSlow, Name: "Ice Slow",
Duration: 4 * time.Second, Magnitude: 0.20, Duration: 40 * time.Second, Magnitude: 0.20,
}, },
} }
} }
@ -225,6 +225,15 @@ func BuffCatalogSnapshot() map[BuffType]Buff {
return out return out
} }
// AttachDebuffCatalogForClient fills h.DebuffCatalog from the active catalog (for JSON responses only).
func AttachDebuffCatalogForClient(h *Hero) {
if h == nil {
return
}
_, deb := BuffCatalogEffectiveJSON()
h.DebuffCatalog = deb
}
// DebuffCatalogSnapshot returns copies for admin/API. // DebuffCatalogSnapshot returns copies for admin/API.
func DebuffCatalogSnapshot() map[DebuffType]Debuff { func DebuffCatalogSnapshot() map[DebuffType]Debuff {
src := catalogData().debuffs src := catalogData().debuffs

@ -36,8 +36,17 @@ type AttackEvent struct {
// AttackQueue implements container/heap.Interface for scheduling attacks. // AttackQueue implements container/heap.Interface for scheduling attacks.
type AttackQueue []*AttackEvent type AttackQueue []*AttackEvent
func (q AttackQueue) Len() int { return len(q) } func (q AttackQueue) Len() int { return len(q) }
func (q AttackQueue) Less(i, j int) bool { return q[i].NextAttackAt.Before(q[j].NextAttackAt) } func (q AttackQueue) Less(i, j int) bool {
a, b := q[i], q[j]
if a.NextAttackAt.Equal(b.NextAttackAt) {
if a.IsHero != b.IsHero {
return a.IsHero // hero before enemy when simultaneous
}
return a.CombatID < b.CombatID
}
return a.NextAttackAt.Before(b.NextAttackAt)
}
func (q AttackQueue) Swap(i, j int) { q[i], q[j] = q[j], q[i] } func (q AttackQueue) Swap(i, j int) { q[i], q[j] = q[j], q[i] }
func (q *AttackQueue) Push(x any) { func (q *AttackQueue) Push(x any) {

@ -33,6 +33,8 @@ type Hero struct {
Inventory []*GearItem `json:"inventory,omitempty"` Inventory []*GearItem `json:"inventory,omitempty"`
Buffs []ActiveBuff `json:"buffs,omitempty"` Buffs []ActiveBuff `json:"buffs,omitempty"`
Debuffs []ActiveDebuff `json:"debuffs,omitempty"` Debuffs []ActiveDebuff `json:"debuffs,omitempty"`
// DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted.
DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"`
Gold int64 `json:"gold"` Gold int64 `json:"gold"`
XP int64 `json:"xp"` XP int64 `json:"xp"`
Level int `json:"level"` Level int `json:"level"`

@ -82,8 +82,11 @@ 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}/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)
r.Post("/heroes/{heroId}/apply-buff", adminH.ApplyHeroBuff)
r.Post("/heroes/{heroId}/apply-debuff", adminH.ApplyHeroDebuff)
r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown) r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown)
r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest) r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest)
r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartHeroRoadsideRest) r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartHeroRoadsideRest)

@ -0,0 +1,17 @@
package tuning
// Defaults for enemy→hero damage (runtime_config JSON keys: enemyCombatDamageScale, enemyCombatDamageRollMin, enemyCombatDamageRollMax).
const (
DefaultEnemyCombatDamageScale = 1.0
DefaultEnemyCombatDamageRollMin = 0.8
DefaultEnemyCombatDamageRollMax = 1.0
)
// Enemy HP regen: fraction of MaxHP healed per second (runtime_config JSON keys below).
// Loaded from DB via tuning.ReloadNow; use EffectiveEnemyRegen* when a positive DB value is required.
const (
DefaultEnemyRegenDefault = 0.02 // enemyRegenDefault
DefaultEnemyRegenSkeletonKing = 0.04 // enemyRegenSkeletonKing
DefaultEnemyRegenForestWarden = 0.05 // enemyRegenForestWarden
DefaultEnemyRegenBattleLizard = 0.01 // enemyRegenBattleLizard
)

@ -86,9 +86,13 @@ type Values struct {
// QuestOfferRefreshHours controls how often quest_giver offers rotate (hours). // QuestOfferRefreshHours controls how often quest_giver offers rotate (hours).
QuestOfferRefreshHours int `json:"questOfferRefreshHours"` QuestOfferRefreshHours int `json:"questOfferRefreshHours"`
CombatDamageScale float64 `json:"combatDamageScale"` CombatDamageScale float64 `json:"combatDamageScale"`
CombatDamageRollMin float64 `json:"combatDamageRollMin"` CombatDamageRollMin float64 `json:"combatDamageRollMin"`
CombatDamageRollMax float64 `json:"combatDamageRollMax"` CombatDamageRollMax float64 `json:"combatDamageRollMax"`
// EnemyCombatDamageScale / Roll* apply only when an enemy hits the hero (not hero→enemy).
EnemyCombatDamageScale float64 `json:"enemyCombatDamageScale"`
EnemyCombatDamageRollMin float64 `json:"enemyCombatDamageRollMin"`
EnemyCombatDamageRollMax float64 `json:"enemyCombatDamageRollMax"`
EnemyDodgeChance float64 `json:"enemyDodgeChance"` EnemyDodgeChance float64 `json:"enemyDodgeChance"`
EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"` EnemyCriticalMinChance float64 `json:"enemyCriticalMinChance"`
EnemyCritChanceCap float64 `json:"enemyCritChanceCap"` EnemyCritChanceCap float64 `json:"enemyCritChanceCap"`
@ -282,6 +286,9 @@ func DefaultValues() Values {
CombatDamageScale: 0.35, CombatDamageScale: 0.35,
CombatDamageRollMin: 0.60, CombatDamageRollMin: 0.60,
CombatDamageRollMax: 1.10, CombatDamageRollMax: 1.10,
EnemyCombatDamageScale: DefaultEnemyCombatDamageScale,
EnemyCombatDamageRollMin: DefaultEnemyCombatDamageRollMin,
EnemyCombatDamageRollMax: DefaultEnemyCombatDamageRollMax,
EnemyDodgeChance: 0.20, EnemyDodgeChance: 0.20,
EnemyCriticalMinChance: 0.10, EnemyCriticalMinChance: 0.10,
EnemyCritChanceCap: 0.20, EnemyCritChanceCap: 0.20,
@ -298,10 +305,10 @@ func DefaultValues() Values {
DebuffProcStun: 0.25, DebuffProcStun: 0.25,
DebuffProcFreeze: 0.20, DebuffProcFreeze: 0.20,
DebuffProcIceSlow: 0.20, DebuffProcIceSlow: 0.20,
EnemyRegenDefault: 0.02, EnemyRegenDefault: DefaultEnemyRegenDefault,
EnemyRegenSkeletonKing: 0.10, EnemyRegenSkeletonKing: DefaultEnemyRegenSkeletonKing,
EnemyRegenForestWarden: 0.05, EnemyRegenForestWarden: DefaultEnemyRegenForestWarden,
EnemyRegenBattleLizard: 0.01, EnemyRegenBattleLizard: DefaultEnemyRegenBattleLizard,
SummonCycleSeconds: 15, SummonCycleSeconds: 15,
SummonDamageDivisor: 4, SummonDamageDivisor: 4,
LuckBuffMultiplier: 1.75, LuckBuffMultiplier: 1.75,
@ -424,6 +431,33 @@ func EffectiveQuestOfferRefreshHours() int {
return n return n
} }
func effectiveRegenPerSecond(cfg float64, fallback float64) float64 {
if cfg <= 0 {
return fallback
}
return cfg
}
// EffectiveEnemyRegenDefault returns enemy regen rate (MaxHP per second) from runtime config with code fallback.
func EffectiveEnemyRegenDefault() float64 {
return effectiveRegenPerSecond(Get().EnemyRegenDefault, DefaultEnemyRegenDefault)
}
// EffectiveEnemyRegenSkeletonKing returns Skeleton King regen rate (MaxHP per second) from runtime config with code fallback.
func EffectiveEnemyRegenSkeletonKing() float64 {
return effectiveRegenPerSecond(Get().EnemyRegenSkeletonKing, DefaultEnemyRegenSkeletonKing)
}
// EffectiveEnemyRegenForestWarden returns Forest Warden regen rate from runtime config with code fallback.
func EffectiveEnemyRegenForestWarden() float64 {
return effectiveRegenPerSecond(Get().EnemyRegenForestWarden, DefaultEnemyRegenForestWarden)
}
// EffectiveEnemyRegenBattleLizard returns Battle Lizard regen rate from runtime config with code fallback.
func EffectiveEnemyRegenBattleLizard() float64 {
return effectiveRegenPerSecond(Get().EnemyRegenBattleLizard, DefaultEnemyRegenBattleLizard)
}
func Set(v Values) { func Set(v Values) {
current.Store(&v) current.Store(&v)
} }

@ -0,0 +1,28 @@
-- Seed buff_debuff_config.payload from model seedBuffMap / seedDebuffMap (backend/internal/model/buff_catalog.go).
-- Durations are stored in milliseconds per BuffJSON / DebuffJSON.
UPDATE buff_debuff_config
SET
payload = '{
"buffs": {
"rush": {"name": "Rush", "durationMs": 300000, "magnitude": 0.5, "cooldownMs": 900000},
"rage": {"name": "Rage", "durationMs": 180000, "magnitude": 1.0, "cooldownMs": 600000},
"shield": {"name": "Shield", "durationMs": 300000, "magnitude": 0.5, "cooldownMs": 720000},
"luck": {"name": "Luck", "durationMs": 1800000, "magnitude": 1.0, "cooldownMs": 7200000},
"resurrection": {"name": "Resurrection", "durationMs": 600000, "magnitude": 0.5, "cooldownMs": 1800000},
"heal": {"name": "Heal", "durationMs": 1000, "magnitude": 0.5, "cooldownMs": 300000},
"power_potion": {"name": "Power Potion", "durationMs": 300000, "magnitude": 1.5, "cooldownMs": 1200000},
"war_cry": {"name": "War Cry", "durationMs": 180000, "magnitude": 1.0, "cooldownMs": 600000}
},
"debuffs": {
"poison": {"name": "Poison", "durationMs": 50000, "magnitude": 0.02},
"freeze": {"name": "Freeze", "durationMs": 30000, "magnitude": 0.5},
"burn": {"name": "Burn", "durationMs": 40000, "magnitude": 0.03},
"stun": {"name": "Stun", "durationMs": 5000, "magnitude": 1.0},
"slow": {"name": "Slow", "durationMs": 40000, "magnitude": 0.4},
"weaken": {"name": "Weaken", "durationMs": 50000, "magnitude": 0.3},
"ice_slow": {"name": "Ice Slow", "durationMs": 40000, "magnitude": 0.2}
}
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -0,0 +1,10 @@
-- Enemy→hero damage: full scale (not hero 0.35) and tighter roll band 0.81.0.
UPDATE runtime_config
SET
payload = payload || '{
"enemyCombatDamageScale": 1.0,
"enemyCombatDamageRollMin": 0.8,
"enemyCombatDamageRollMax": 1.0
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -0,0 +1,8 @@
-- Skeleton King regen: seed runtime_config so production DB overrides legacy 0.10; in-code default is tuning.DefaultEnemyRegenSkeletonKing.
UPDATE runtime_config
SET
payload = payload || '{
"enemyRegenSkeletonKing": 0.04
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -17,6 +17,31 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
touch-action: none; touch-action: none;
} }
.ah-adventure-details > summary {
list-style: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.ah-adventure-details > summary::-webkit-details-marker {
display: none;
}
.ah-adventure-summary-text {
flex: 1;
min-width: 0;
}
.ah-adventure-details > summary::after {
content: '▸';
flex-shrink: 0;
display: block;
opacity: 0.65;
transition: transform 0.12s ease;
line-height: 1.5;
}
.ah-adventure-details[open] > summary::after {
transform: rotate(90deg);
}
</style> </style>
</head> </head>
<body> <body>

@ -38,6 +38,7 @@ import type { OfflineReport as OfflineReportData } from './network/api';
import { import {
BUFF_COOLDOWN_MS, BUFF_COOLDOWN_MS,
BUFF_DURATION_MS, BUFF_DURATION_MS,
debuffDurationsFromCatalog,
mapHeroBuffsFromServer, mapHeroBuffsFromServer,
mapHeroDebuffsFromServer, mapHeroDebuffsFromServer,
} from './network/buffMap'; } from './network/buffMap';
@ -45,10 +46,15 @@ import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } f
import { Rarity } from './game/types'; import { Rarity } from './game/types';
import type { HeroState, BuffChargeState } from './game/types'; import type { HeroState, BuffChargeState } from './game/types';
import { useUiClock } from './hooks/useUiClock'; import { useUiClock } from './hooks/useUiClock';
import { adventureEntriesFromServerLog } from './game/adventureLogMap'; import {
adventureEntriesFromServerLog,
appendAdventureLogMessage,
} from './game/adventureLogMap';
import { parseAdventureLogLine } from './game/adventureLogMarkers';
import { HUD } from './ui/HUD'; import { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen'; import { DeathScreen } from './ui/DeathScreen';
import { FloatingDamage } from './ui/FloatingDamage'; import { FloatingDamage } from './ui/FloatingDamage';
import { CombatLogPanel } from './ui/CombatLogPanel';
import { GameToast } from './ui/GameToast'; import { GameToast } from './ui/GameToast';
import { OfflineReport } from './ui/OfflineReport'; import { OfflineReport } from './ui/OfflineReport';
import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal'; import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
@ -269,6 +275,7 @@ function heroResponseToState(res: HeroResponse): HeroState {
moveSpeed: res.moveSpeed, moveSpeed: res.moveSpeed,
equipment: mapEquipment(res.equipment, res), equipment: mapEquipment(res.equipment, res),
inventory: mapInventoryFromResponse(res.inventory), inventory: mapInventoryFromResponse(res.inventory),
debuffCatalogDurations: debuffDurationsFromCatalog(res.debuffCatalog),
}; };
} }
@ -320,6 +327,8 @@ export function App() {
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; color: string } | null>(null); const [toast, setToast] = useState<{ message: string; color: string } | null>(null);
const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]); const [logEntries, setLogEntries] = useState<AdventureLogEntry[]>([]);
/** Live combat narration (mirrors prefixed adventure log lines). */
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);
const logIdCounter = useRef(0); const logIdCounter = useRef(0);
@ -349,14 +358,19 @@ export function App() {
const sheetNowMs = useUiClock(100); const sheetNowMs = useUiClock(100);
const addLogEntry = useCallback((message: string) => { const appendLogLine = useCallback((rawMessage: string) => {
logIdCounter.current += 1; setLogEntries((prev) =>
const entry: AdventureLogEntry = { appendAdventureLogMessage(prev, rawMessage, () => {
id: logIdCounter.current, logIdCounter.current += 1;
message, return logIdCounter.current;
timestamp: Date.now(), }),
}; );
setLogEntries((prev) => [...prev, entry]); const parsed = parseAdventureLogLine(rawMessage);
if (parsed.type === 'encounter') {
setCombatLogLines([parsed.title]);
} else if (parsed.type === 'battle') {
setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
}
}, []); }, []);
const refreshEquipment = useCallback(() => { const refreshEquipment = useCallback(() => {
@ -590,6 +604,10 @@ export function App() {
// Wire WS handler -- routes server messages to engine + UI callbacks // Wire WS handler -- routes server messages to engine + UI callbacks
wireWSHandler(ws, engine, { wireWSHandler(ws, engine, {
onCombatStart: () => {
setCombatLogLines([]);
},
onHeroStateReceived: (payload) => { onHeroStateReceived: (payload) => {
// Convert raw payload to HeroResponse shape and apply // Convert raw payload to HeroResponse shape and apply
const res = payload as unknown as HeroResponse; const res = payload as unknown as HeroResponse;
@ -598,6 +616,7 @@ export function App() {
}, },
onCombatEnd: (p) => { onCombatEnd: (p) => {
setCombatLogLines([]);
const loot = buildLootFromCombatEnd(p); const loot = buildLootFromCombatEnd(p);
engine.applyLoot(loot); engine.applyLoot(loot);
hapticNotification('success'); hapticNotification('success');
@ -636,6 +655,7 @@ export function App() {
}, },
onHeroRevived: () => { onHeroRevived: () => {
setCombatLogLines([]);
setToast({ message: tr.heroRevived, color: '#44cc44' }); setToast({ message: tr.heroRevived, color: '#44cc44' });
// "Hero revived" comes from server log + WS // "Hero revived" comes from server log + WS
}, },
@ -648,7 +668,7 @@ export function App() {
const town = townsRef.current.find((t) => t.id === p.townId) ?? null; const town = townsRef.current.find((t) => t.id === p.townId) ?? null;
setCurrentTown(town); setCurrentTown(town);
setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
addLogEntry(`Entered ${p.townName}`); appendLogLine(`Entered ${p.townName}`);
setNearestNPC(null); setNearestNPC(null);
setNpcVisitAwaitingProximity(null); setNpcVisitAwaitingProximity(null);
setSelectedNPC(null); setSelectedNPC(null);
@ -656,7 +676,7 @@ export function App() {
}, },
onAdventureLogLine: (p) => { onAdventureLogLine: (p) => {
addLogEntry(p.message); appendLogLine(p.message);
}, },
onTownNPCVisit: (p) => { onTownNPCVisit: (p) => {
@ -689,7 +709,7 @@ export function App() {
onNPCEncounterEnd: (p) => { onNPCEncounterEnd: (p) => {
if (p.reason === 'timeout') { if (p.reason === 'timeout') {
addLogEntry('Wandering merchant moved on'); appendLogLine('Wandering merchant moved on');
} }
setWanderingNPC(null); setWanderingNPC(null);
}, },
@ -1135,8 +1155,8 @@ export function App() {
sendNPCAlmsDecline(ws); sendNPCAlmsDecline(ws);
} }
setWanderingNPC(null); setWanderingNPC(null);
addLogEntry('Declined wandering merchant'); appendLogLine('Declined wandering merchant');
}, [addLogEntry]); }, [appendLogLine]);
// Show NPC interaction when near an NPC and not dismissed // Show NPC interaction when near an NPC and not dismissed
const showNPCInteraction = const showNPCInteraction =
@ -1153,6 +1173,9 @@ export function App() {
const dismissToast = useCallback(() => setToast(null), []); const dismissToast = useCallback(() => setToast(null), []);
const questClaimDisabled =
gameState.phase === GamePhase.Dead || (gameState.hero?.hp ?? 0) <= 0;
return ( return (
<I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}> <I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
<div style={appStyle}> <div style={appStyle}>
@ -1185,12 +1208,21 @@ export function App() {
quests={heroQuests} quests={heroQuests}
onQuestClaim={handleQuestClaim} onQuestClaim={handleQuestClaim}
onQuestAbandon={handleQuestAbandon} onQuestAbandon={handleQuestAbandon}
questClaimDisabled={questClaimDisabled}
/> />
)} )}
{/* Floating Damage Numbers */} {/* Floating Damage Numbers */}
<FloatingDamage damages={damages} /> <FloatingDamage damages={damages} />
<CombatLogPanel
visible={
gameState.phase === GamePhase.Fighting || gameState.phase === GamePhase.Dead
}
lines={combatLogLines}
anchor={gameState.enemyOnScreenRight !== false ? 'left' : 'right'}
/>
{/* Name Entry Screen */} {/* Name Entry Screen */}
{needsName && <NameEntryScreen onNameSet={handleNameSet} />} {needsName && <NameEntryScreen onNameSet={handleNameSet} />}
@ -1198,11 +1230,8 @@ export function App() {
<DeathScreen <DeathScreen
visible={gameState.phase === GamePhase.Dead} visible={gameState.phase === GamePhase.Dead}
onRevive={handleRevive} onRevive={handleRevive}
revivesRemaining={ subscriptionUnlimited={!!gameState.hero?.subscriptionActive}
gameState.hero?.subscriptionActive revivesRemaining={Math.max(0, 2 - (gameState.hero?.reviveCount ?? 0))}
? undefined
: Math.max(0, 2 - (gameState.hero?.reviveCount ?? 0))
}
/> />
{/* Toast Notification */} {/* Toast Notification */}
@ -1264,6 +1293,7 @@ export function App() {
onQuestsChanged={refreshHeroQuests} onQuestsChanged={refreshHeroQuests}
onHeroUpdated={handleNPCHeroUpdated} onHeroUpdated={handleNPCHeroUpdated}
onToast={(message, color) => setToast({ message, color })} onToast={(message, color) => setToast({ message, color })}
questClaimDisabled={questClaimDisabled}
/> />
)} )}

@ -1,5 +1,97 @@
import type { AdventureLogEntry } from './types'; import type { AdventureLogBattleGroup, AdventureLogEntry, AdventureLogPlainEntry } from './types';
import type { LogEntry } from '../network/api'; import type { LogEntry } from '../network/api';
import { parseAdventureLogLine } from './adventureLogMarkers';
/** Group server log rows (oldest first) into plain lines + battle groups. */
export function groupAdventureLogFromServer(
sortedOldestFirst: Array<{ id: number; message: string; timestamp: number }>,
): AdventureLogEntry[] {
const out: AdventureLogEntry[] = [];
let i = 0;
while (i < sortedOldestFirst.length) {
const row = sortedOldestFirst[i]!;
const parsed = parseAdventureLogLine(row.message);
if (parsed.type === 'encounter') {
const lines: { id: number; message: string }[] = [];
i++;
while (i < sortedOldestFirst.length) {
const inner = sortedOldestFirst[i]!;
const innerParsed = parseAdventureLogLine(inner.message);
if (innerParsed.type === 'battle') {
lines.push({ id: inner.id, message: innerParsed.text });
i++;
} else {
break;
}
}
const group: AdventureLogBattleGroup = {
kind: 'battle_group',
id: row.id,
title: parsed.title,
timestamp: row.timestamp,
lines,
};
out.push(group);
} else {
const text =
parsed.type === 'plain' ? parsed.text : parsed.type === 'battle' ? parsed.text : row.message;
const plain: AdventureLogPlainEntry = {
kind: 'line',
id: row.id,
message: text,
timestamp: row.timestamp,
};
out.push(plain);
i++;
}
}
return out;
}
export function appendAdventureLogMessage(
prev: AdventureLogEntry[],
rawMessage: string,
nextId: () => number,
): AdventureLogEntry[] {
const parsed = parseAdventureLogLine(rawMessage);
if (parsed.type === 'encounter') {
const group: AdventureLogBattleGroup = {
kind: 'battle_group',
id: nextId(),
title: parsed.title,
timestamp: Date.now(),
lines: [],
};
return [...prev, group];
}
if (parsed.type === 'battle') {
const last = prev[prev.length - 1];
if (last?.kind === 'battle_group') {
const lineId = nextId();
return [
...prev.slice(0, -1),
{
...last,
lines: [...last.lines, { id: lineId, message: parsed.text }],
},
];
}
const line: AdventureLogPlainEntry = {
kind: 'line',
id: nextId(),
message: parsed.text,
timestamp: Date.now(),
};
return [...prev, line];
}
const line: AdventureLogPlainEntry = {
kind: 'line',
id: nextId(),
message: parsed.text,
timestamp: Date.now(),
};
return [...prev, line];
}
/** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */ /** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */
export function adventureEntriesFromServerLog(serverLog: LogEntry[]): { export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
@ -9,11 +101,12 @@ export function adventureEntriesFromServerLog(serverLog: LogEntry[]): {
const sorted = [...serverLog].sort( const sorted = [...serverLog].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
); );
const entries: AdventureLogEntry[] = sorted.map((entry) => ({ const flat = sorted.map((entry) => ({
id: Number(entry.id), id: Number(entry.id),
message: entry.message, message: entry.message,
timestamp: new Date(entry.createdAt).getTime(), timestamp: new Date(entry.createdAt).getTime(),
})); }));
const maxId = entries.reduce((m, e) => Math.max(m, e.id), 0); const entries = groupAdventureLogFromServer(flat);
const maxId = flat.reduce((m, e) => Math.max(m, e.id), 0);
return { entries, maxId }; return { entries, maxId };
} }

@ -0,0 +1,22 @@
/** Must match backend `internal/game/adventure_log_markers.go` */
export const AH_ENC_PREFIX = '__AH_ENC__';
export const AH_BAT_PREFIX = '__AH_BAT__';
export type ParsedAdventureLine =
| { type: 'encounter'; title: string }
| { type: 'battle'; text: string }
| { type: 'plain'; text: string };
export function parseAdventureLogLine(raw: string): ParsedAdventureLine {
if (raw.startsWith(AH_ENC_PREFIX)) {
return { type: 'encounter', title: raw.slice(AH_ENC_PREFIX.length) };
}
if (raw.startsWith(AH_BAT_PREFIX)) {
return { type: 'battle', text: raw.slice(AH_BAT_PREFIX.length) };
}
return { type: 'plain', text: raw };
}
export function shouldSuppressThoughtBubble(raw: string): boolean {
return raw.startsWith(AH_ENC_PREFIX) || raw.startsWith(AH_BAT_PREFIX);
}

@ -78,6 +78,7 @@ export class GameEngine {
tick: 0, tick: 0,
serverTimeMs: 0, serverTimeMs: 0,
routeWaypoints: null, routeWaypoints: null,
enemyOnScreenRight: undefined,
}; };
// ---- Server-driven position interpolation ---- // ---- Server-driven position interpolation ----
@ -109,6 +110,9 @@ export class GameEngine {
/** Nearby heroes from the shared world (polled periodically) */ /** Nearby heroes from the shared world (polled periodically) */
private _nearbyHeroes: NearbyHeroData[] = []; private _nearbyHeroes: NearbyHeroData[] = [];
/** Debuff full-duration ms from last hero snapshot (`debuffCatalog`); used when WS omits durationMs. */
private _debuffDurationMsByType: Partial<Record<DebuffType, number>> = {};
/** Callbacks */ /** Callbacks */
private _onStateChange: ((state: GameState) => void) | null = null; private _onStateChange: ((state: GameState) => void) | null = null;
private _onDamage: DamageCallback | null = null; private _onDamage: DamageCallback | null = null;
@ -202,6 +206,7 @@ export class GameEngine {
tick: 0, tick: 0,
serverTimeMs: 0, serverTimeMs: 0,
routeWaypoints: null, routeWaypoints: null,
enemyOnScreenRight: undefined,
}; };
// Initialize display position // Initialize display position
@ -341,7 +346,18 @@ export class GameEngine {
* Apply a full hero state snapshot from the server. * Apply a full hero state snapshot from the server.
* Sent on WS connect, after level-up, revive, equipment change. * Sent on WS connect, after level-up, revive, equipment change.
*/ */
/** Fallback duration for debuff UI when `debuff_applied` lacks numeric durationMs. */
getDebuffDurationMs(type: DebuffType): number | undefined {
return this._debuffDurationMsByType[type];
}
applyHeroState(hero: HeroState): void { applyHeroState(hero: HeroState): void {
if (hero.debuffCatalogDurations) {
this._debuffDurationMsByType = {
...this._debuffDurationMsByType,
...hero.debuffCatalogDurations,
};
}
const prevPos = this._gameState.hero?.position; const prevPos = this._gameState.hero?.position;
// Preserve display position if hero hasn't moved significantly // Preserve display position if hero hasn't moved significantly
if (prevPos) { if (prevPos) {
@ -401,11 +417,14 @@ export class GameEngine {
y: this._heroDisplayY - 0.5, y: this._heroDisplayY - 0.5,
}; };
const enemyOnScreenRight = enemy.position.x >= this._heroDisplayX;
this._gameState = { this._gameState = {
...this._gameState, ...this._gameState,
phase: GamePhase.Fighting, phase: GamePhase.Fighting,
enemy, enemy,
loot: null, loot: null,
enemyOnScreenRight,
}; };
this._lootTimerMs = 0; this._lootTimerMs = 0;
this._thoughtText = null; this._thoughtText = null;
@ -440,8 +459,8 @@ export class GameEngine {
if (isBlocked || isEvaded) { if (isBlocked || isEvaded) {
this._emitDamage( this._emitDamage(
0, 0,
defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60, defender === 'enemy' ? viewport.width / 2 + 88 : viewport.width / 2 - 88,
viewport.height / 2 - 30, viewport.height / 2 - 42,
false, false,
isBlocked ? 'blocked' : 'evaded', isBlocked ? 'blocked' : 'evaded',
defender, defender,
@ -449,9 +468,9 @@ export class GameEngine {
} else { } else {
this._emitDamage( this._emitDamage(
damage, damage,
defender === 'enemy' ? viewport.width / 2 + 60 : viewport.width / 2 - 60, defender === 'enemy' ? viewport.width / 2 + 88 : viewport.width / 2 - 88,
viewport.height / 2 - 30, viewport.height / 2 - 42,
source === 'hero' ? isCrit : false, source === 'hero' ? Boolean(isCrit) : false,
'damage', 'damage',
defender, defender,
); );
@ -473,8 +492,8 @@ export class GameEngine {
const viewport = getViewport(); const viewport = getViewport();
this._emitDamage( this._emitDamage(
amount, amount,
viewport.width / 2 + 60, viewport.width / 2 + 88,
viewport.height / 2 - 30, viewport.height / 2 - 42,
false, false,
'regen', 'regen',
'enemy', 'enemy',
@ -492,6 +511,7 @@ export class GameEngine {
...this._gameState, ...this._gameState,
phase: GamePhase.Walking, phase: GamePhase.Walking,
enemy: null, enemy: null,
enemyOnScreenRight: undefined,
}; };
this._notifyStateChange(); this._notifyStateChange();
} }
@ -531,6 +551,7 @@ export class GameEngine {
...this._gameState, ...this._gameState,
phase: GamePhase.Walking, phase: GamePhase.Walking,
enemy: null, enemy: null,
enemyOnScreenRight: undefined,
}; };
this._notifyStateChange(); this._notifyStateChange();
} }

@ -162,6 +162,8 @@ export interface HeroState {
equipment?: Record<string, EquipmentItem>; equipment?: Record<string, EquipmentItem>;
/** Backpack items (server max 40) */ /** Backpack items (server max 40) */
inventory?: EquipmentItem[]; inventory?: EquipmentItem[];
/** Debuff type → full-duration ms from server `debuffCatalog` on hero payloads */
debuffCatalogDurations?: Partial<Record<DebuffType, number>>;
} }
export interface ActiveBuff { export interface ActiveBuff {
@ -199,6 +201,11 @@ export interface GameState {
serverTimeMs: number; serverTimeMs: number;
/** Current road polyline from `route_assigned` (minimap / parity with ground renderer). */ /** Current road polyline from `route_assigned` (minimap / parity with ground renderer). */
routeWaypoints: Array<{ x: number; y: number }> | null; routeWaypoints: Array<{ x: number; y: number }> | null;
/**
* During combat: whether the enemy is to the right of the hero in world space.
* UI docks the combat log panel on the opposite side from floating damage numbers.
*/
enemyOnScreenRight?: boolean;
} }
// ---- Rendering State (interpolated) ---- // ---- Rendering State (interpolated) ----
@ -223,12 +230,24 @@ export enum AnimationState {
// ---- Adventure Log ---- // ---- Adventure Log ----
export interface AdventureLogEntry { export interface AdventureLogPlainEntry {
kind: 'line';
id: number; id: number;
message: string; message: string;
timestamp: number; timestamp: number;
} }
/** Expandable block: encounter title + combat detail lines (server prefixes __AH_ENC__ / __AH_BAT__). */
export interface AdventureLogBattleGroup {
kind: 'battle_group';
id: number;
title: string;
timestamp: number;
lines: { id: number; message: string }[];
}
export type AdventureLogEntry = AdventureLogPlainEntry | AdventureLogBattleGroup;
// ---- Town & NPC & Quest ---- // ---- Town & NPC & Quest ----
export interface Town { export interface Town {

@ -29,11 +29,13 @@ import type {
DebuffAppliedPayload, DebuffAppliedPayload,
} from './types'; } from './types';
import { DebuffType, EnemyType, Rarity } from './types'; import { DebuffType, EnemyType, Rarity } from './types';
import { DEBUFF_DURATION_DEFAULTS } from '../shared/constants'; import { shouldSuppressThoughtBubble } from './adventureLogMarkers';
// ---- Callback types for UI layer (App.tsx) ---- // ---- Callback types for UI layer (App.tsx) ----
export interface WSHandlerCallbacks { export interface WSHandlerCallbacks {
/** Fires after combat_start is applied (clear transient combat UI). */
onCombatStart?: () => void;
onCombatEnd?: (payload: CombatEndPayload) => void; onCombatEnd?: (payload: CombatEndPayload) => void;
onHeroDied?: (payload: HeroDiedPayload) => void; onHeroDied?: (payload: HeroDiedPayload) => void;
onHeroRevived?: (payload: HeroRevivedPayload) => void; onHeroRevived?: (payload: HeroRevivedPayload) => void;
@ -110,6 +112,7 @@ export function wireWSHandler(
enemyType: (p.enemy.type as EnemyType) || EnemyType.Wolf, enemyType: (p.enemy.type as EnemyType) || EnemyType.Wolf,
}; };
engine.applyCombatStart(enemy); engine.applyCombatStart(enemy);
callbacks.onCombatStart?.();
}); });
ws.on('attack', (msg: ServerMessage) => { ws.on('attack', (msg: ServerMessage) => {
@ -126,7 +129,7 @@ export function wireWSHandler(
const p = msg.payload as DebuffAppliedPayload; const p = msg.payload as DebuffAppliedPayload;
if (!p?.debuffType || !isDebuffType(p.debuffType)) return; if (!p?.debuffType || !isDebuffType(p.debuffType)) return;
const nowMs = Date.now(); const nowMs = Date.now();
const fallbackMs = DEBUFF_DURATION_DEFAULTS[p.debuffType] ?? 0; const fallbackMs = engine.getDebuffDurationMs(p.debuffType as DebuffType) ?? 0;
const durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs; const durationMs = Number.isFinite(p.durationMs) ? Math.max(0, p.durationMs as number) : fallbackMs;
const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs; const expiresAtMs = p.expiresAt ? Date.parse(p.expiresAt) : nowMs + durationMs;
engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs); engine.applyDebuffApplied(p.debuffType, durationMs, expiresAtMs);
@ -185,7 +188,9 @@ export function wireWSHandler(
ws.on('adventure_log_line', (msg: ServerMessage) => { ws.on('adventure_log_line', (msg: ServerMessage) => {
const p = msg.payload as AdventureLogLinePayload; const p = msg.payload as AdventureLogLinePayload;
engine.applyAdventureLogLine(p.message); if (!shouldSuppressThoughtBubble(p.message)) {
engine.applyAdventureLogLine(p.message);
}
callbacks.onAdventureLogLine?.(p); callbacks.onAdventureLogLine?.(p);
}); });

@ -93,6 +93,7 @@ export const en = {
questLog: 'Quest Log', questLog: 'Quest Log',
noActiveQuests: 'No active quests. Visit an NPC to accept quests!', noActiveQuests: 'No active quests. Visit an NPC to accept quests!',
claimRewards: 'Claim Rewards', claimRewards: 'Claim Rewards',
claimRewardsDisabledDead: 'Revive to claim quest rewards',
questDestination: 'Destination', questDestination: 'Destination',
abandon: 'Abandon', abandon: 'Abandon',
acceptQuest: 'Accept', acceptQuest: 'Accept',
@ -127,6 +128,8 @@ export const en = {
youDied: 'YOU DIED', youDied: 'YOU DIED',
reviveNow: 'REVIVE NOW', reviveNow: 'REVIVE NOW',
freeRevivesLeft: 'Free revives left: {count}', freeRevivesLeft: 'Free revives left: {count}',
revivesUnlimitedSubscription: 'Unlimited revives (subscription)',
reviveNowWithCount: 'REVIVE NOW ({count})',
autoReviveIn: 'Auto-revive in {timer}s', autoReviveIn: 'Auto-revive in {timer}s',
noFreeRevives: 'No free revives left \u2014 subscription required', noFreeRevives: 'No free revives left \u2014 subscription required',
@ -166,6 +169,7 @@ export const en = {
// Adventure log // Adventure log
noEventsYet: 'No events yet...', noEventsYet: 'No events yet...',
combatLogTitle: 'Combat',
// Misc // Misc
adventureLog: 'Adventure Log', adventureLog: 'Adventure Log',

@ -95,6 +95,8 @@ export const ru: Translations = {
questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439', questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439',
noActiveQuests: '\u041d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u0434\u0430\u043d\u0438\u0439. \u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u0441 NPC!', noActiveQuests: '\u041d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u0434\u0430\u043d\u0438\u0439. \u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u0441 NPC!',
claimRewards: '\u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443', claimRewards: '\u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
claimRewardsDisabledDead:
'\u0412\u043e\u0441\u043a\u0440\u0435\u0441\u043d\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u044b \u0437\u0430 \u0437\u0430\u0434\u0430\u043d\u0438\u044f',
questDestination: '\u041f\u0443\u043d\u043a\u0442 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f', questDestination: '\u041f\u0443\u043d\u043a\u0442 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f',
abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f', abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f',
acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c', acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
@ -129,6 +131,8 @@ export const ru: Translations = {
youDied: '\u0412\u042b \u041f\u041e\u0413\u0418\u0411\u041b\u0418', youDied: '\u0412\u042b \u041f\u041e\u0413\u0418\u0411\u041b\u0418',
reviveNow: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c', reviveNow: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c',
freeRevivesLeft: '\u0411\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}', freeRevivesLeft: '\u0411\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
revivesUnlimitedSubscription: '\u0411\u0435\u0437\u043b\u0438\u043c\u0438\u0442\u043d\u044b\u0435 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u044f (\u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430)',
reviveNowWithCount: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c ({count})',
autoReviveIn: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 {timer}\u0441', autoReviveIn: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 {timer}\u0441',
noFreeRevives: '\u041d\u0435\u0442 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u2014 \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430', noFreeRevives: '\u041d\u0435\u0442 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u2014 \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430',
@ -168,6 +172,7 @@ export const ru: Translations = {
// Adventure log // Adventure log
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...', noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
combatLogTitle: '\u0411\u043e\u0439',
// Misc // Misc
adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439', adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439',

@ -128,6 +128,8 @@ export interface HeroResponse {
moveSpeed?: number; moveSpeed?: number;
buffs?: ServerActiveBuffRow[]; buffs?: ServerActiveBuffRow[];
debuffs?: ServerActiveDebuffRow[]; debuffs?: ServerActiveDebuffRow[];
/** Effective debuff definitions from server catalog (durations in ms); not stored client-side as source of truth. */
debuffCatalog?: Record<string, { name?: string; durationMs: number; magnitude?: number }>;
/** Extended equipment slots (§6.3) keyed by slot name */ /** Extended equipment slots (§6.3) keyed by slot name */
equipment?: Record<string, { equipment?: Record<string, {
id: number; id: number;

@ -86,6 +86,34 @@ export interface ServerActiveDebuffRow {
expiresAt: string; expiresAt: string;
} }
/** One entry from server `hero.debuffCatalog` (see model.DebuffJSON). */
export interface ServerDebuffCatalogEntry {
name?: string;
durationMs: number;
magnitude?: number;
}
/** Builds debuff type → duration (ms) from REST/WS hero snapshot; omit if server sent nothing yet. */
export function debuffDurationsFromCatalog(
catalog: Record<string, ServerDebuffCatalogEntry> | undefined,
): Partial<Record<DebuffType, number>> | undefined {
if (!catalog || typeof catalog !== 'object') return undefined;
const out: Partial<Record<DebuffType, number>> = {};
const allowed = new Set(Object.values(DebuffType) as string[]);
for (const [key, val] of Object.entries(catalog)) {
if (!allowed.has(key)) continue;
if (
val
&& typeof val.durationMs === 'number'
&& Number.isFinite(val.durationMs)
&& val.durationMs >= 0
) {
out[key as DebuffType] = val.durationMs;
}
}
return Object.keys(out).length ? out : undefined;
}
function durationToMs(raw: unknown): number { function durationToMs(raw: unknown): number {
if (typeof raw === 'number' && Number.isFinite(raw)) { if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.round(raw / 1_000_000); return Math.round(raw / 1_000_000);

@ -46,11 +46,20 @@ export const WS_HEARTBEAT_TIMEOUT_MS = 5000;
/** Max accumulated time before we drop frames (prevents spiral of death) */ /** Max accumulated time before we drop frames (prevents spiral of death) */
export const MAX_ACCUMULATED_MS = 250; export const MAX_ACCUMULATED_MS = 250;
/** Floating damage number duration in milliseconds */ /** Floating damage number duration in milliseconds (normal hits, regen) */
export const DAMAGE_NUMBER_DURATION_MS = 1800; export const DAMAGE_NUMBER_DURATION_MS = 2600;
/** Floating damage rise distance in pixels */ /** Longer float for blocked / evaded so combat feedback stays readable */
export const DAMAGE_NUMBER_RISE_PX = 60; export const DAMAGE_NUMBER_FEEDBACK_DURATION_MS = 4800;
/** Crit numbers stay on screen longer than normal hits (must differ from DAMAGE_NUMBER_DURATION_MS) */
export const DAMAGE_NUMBER_CRIT_DURATION_MS = 6000;
/** Floating damage rise distance in pixels (vertical flight from anchor) */
export const DAMAGE_NUMBER_RISE_PX = 96;
/** Horizontal drift during float (per target side; scales with progress) */
export const DAMAGE_NUMBER_DRIFT_PX = 44;
/** Buff cooldown overlay animation fps */ /** Buff cooldown overlay animation fps */
export const BUFF_OVERLAY_FPS = 30; export const BUFF_OVERLAY_FPS = 30;
@ -99,17 +108,5 @@ export const DEBUFF_COLORS: Record<string, string> = {
ice_slow: '#66aaff', ice_slow: '#66aaff',
}; };
// ---- Debuff Default Durations (ms) ----
export const DEBUFF_DURATION_DEFAULTS: Record<string, number> = {
poison: 5000,
freeze: 3000,
burn: 4000,
stun: 2000,
slow: 4000,
weaken: 5000,
ice_slow: 4000,
};
/** Loot popup display duration in milliseconds */ /** Loot popup display duration in milliseconds */
export const LOOT_POPUP_DURATION_MS = 5000; export const LOOT_POPUP_DURATION_MS = 5000;

@ -32,6 +32,24 @@ const timestampStyle: CSSProperties = {
fontSize: 11, fontSize: 11,
}; };
const battleChildStyle: CSSProperties = {
paddingLeft: 14,
fontSize: 11,
color: '#9aa',
whiteSpace: 'normal',
overflow: 'visible',
textOverflow: 'unset',
lineHeight: 1.45,
};
const detailsStyle: CSSProperties = {
marginBottom: 4,
};
const summaryStyle: CSSProperties = {
cursor: 'pointer',
};
/** Scrollable adventure log list (Hero sheet Journal tab). */ /** Scrollable adventure log list (Hero sheet Journal tab). */
export function AdventureLogEntries({ export function AdventureLogEntries({
entries, entries,
@ -50,6 +68,13 @@ export function AdventureLogEntries({
} }
}, [entries.length, ref]); }, [entries.length, ref]);
const lastBattleIdx = (() => {
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i]?.kind === 'battle_group') return i;
}
return -1;
})();
return ( return (
<div ref={ref} style={scrollAreaStyle}> <div ref={ref} style={scrollAreaStyle}>
{entries.length === 0 && ( {entries.length === 0 && (
@ -57,12 +82,39 @@ export function AdventureLogEntries({
{tr.noEventsYet} {tr.noEventsYet}
</div> </div>
)} )}
{entries.map((entry) => ( {entries.map((entry, idx) => {
<div key={entry.id} style={entryStyle}> if (entry.kind === 'battle_group') {
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span> const isLastBattle = idx === lastBattleIdx;
{entry.message} return (
</div> <details
))} key={entry.id}
className="ah-adventure-details"
style={detailsStyle}
open={isLastBattle}
>
<summary style={summaryStyle}>
<span className="ah-adventure-summary-text">
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
<span style={{ ...entryStyle, whiteSpace: 'normal' }}>{entry.title}</span>
</span>
</summary>
<div style={{ marginTop: 4 }}>
{entry.lines.map((line) => (
<div key={line.id} style={battleChildStyle}>
{line.message}
</div>
))}
</div>
</details>
);
}
return (
<div key={entry.id} style={entryStyle}>
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
{entry.message}
</div>
);
})}
</div> </div>
); );
} }

@ -38,6 +38,9 @@ interface BuffButtonProps {
const LONG_PRESS_MS = 400; const LONG_PRESS_MS = 400;
const TOOLTIP_AUTO_HIDE_MS = 2500; const TOOLTIP_AUTO_HIDE_MS = 2500;
/** When false, the green refill CTA in the hover tooltip is hidden (purchase still reachable from tap flow if any). */
const SHOW_BUFF_REFILL_BUTTON_IN_TOOLTIP = false;
const tooltipStyle: CSSProperties = { const tooltipStyle: CSSProperties = {
position: 'absolute', position: 'absolute',
bottom: '110%', bottom: '110%',
@ -239,27 +242,42 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
onActivate(); onActivate();
}; };
const style: CSSProperties = { const dimmedFaceOpacity = isDisabled ? (isOutOfCharges && !isOnCooldown ? 0.3 : 0.55) : 1;
...buttonBase,
const hitStyle: CSSProperties = {
position: 'relative',
width: 44, width: 44,
height: 50, height: 50,
padding: 0,
border: 'none',
background: 'transparent',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transform: pressed ? 'scale(0.94)' : 'scale(1)',
transition: 'transform 80ms ease',
};
const activatorFaceStyle: CSSProperties = {
...buttonBase,
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
borderRadius: 10, borderRadius: 10,
borderColor: isActive ? meta.color : pressed ? '#fff' : 'rgba(255,255,255,0.2)', borderColor: isActive ? meta.color : pressed ? '#fff' : 'rgba(255,255,255,0.2)',
opacity: isDisabled ? (isOutOfCharges && !isOnCooldown ? 0.3 : 0.55) : 1,
boxShadow: isActive boxShadow: isActive
? `0 0 12px ${meta.color}` ? `0 0 12px ${meta.color}`
: pressed : pressed
? `0 0 10px rgba(255,255,255,0.5), inset 0 0 12px ${meta.color}66` ? `0 0 10px rgba(255,255,255,0.5), inset 0 0 12px ${meta.color}66`
: 'none', : 'none',
transform: pressed ? 'scale(0.94)' : 'scale(1)', opacity: dimmedFaceOpacity,
transition: 'transform 80ms ease, box-shadow 80ms ease, border-color 80ms ease, opacity 150ms ease', transition: 'opacity 150ms ease, box-shadow 80ms ease, border-color 80ms ease',
cursor: isDisabled ? 'not-allowed' : 'pointer', pointerEvents: 'none',
}; };
return ( return (
<button <button
type="button" type="button"
style={style} style={hitStyle}
onClick={handleClick} onClick={handleClick}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
@ -269,59 +287,62 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
aria-disabled={isDisabled} aria-disabled={isDisabled}
aria-label={`${meta.label}: ${meta.desc}`} aria-label={`${meta.label}: ${meta.desc}`}
> >
{/* Inner wrapper clips cooldown overlay to button bounds */} {/* Dim only the activator (chrome + icon); tooltip is a sibling so it stays full opacity. */}
<div <div style={activatorFaceStyle}>
style={{ {/* Inner wrapper clips cooldown overlay to button bounds */}
position: 'absolute', <div
inset: 0,
overflow: 'hidden',
borderRadius: 8,
}}
>
<RadialCooldown progress={cooldownProgress} />
</div>
<span style={{ fontSize: 18, lineHeight: 1, position: 'relative' }}>{meta.icon}</span>
<span style={{ fontSize: 8, color: '#ccc', marginTop: 1, position: 'relative' }}>{meta.label}</span>
{isActive && !isOnCooldown && (
<span
style={{
position: 'absolute',
bottom: 2,
fontSize: 9,
fontWeight: 700,
color: meta.color,
textShadow: '0 1px 2px rgba(0,0,0,0.9)',
}}
>
{Math.ceil(remainingEffectMs / 1000)}s
</span>
)}
{isOnCooldown && (
<span
style={{ style={{
position: 'absolute', position: 'absolute',
fontSize: 11, inset: 0,
fontWeight: 700, overflow: 'hidden',
color: '#fff', borderRadius: 8,
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
}}
>
{Math.ceil(buff.cooldownRemainingMs / 1000)}s
</span>
)}
{/* Charge badge */}
{hasChargeData && (
<span
style={{
...chargeBadgeBase,
backgroundColor: isOutOfCharges ? '#aa2222' : 'rgba(30, 90, 180, 0.9)',
color: isOutOfCharges ? '#ffaaaa' : '#fff',
}} }}
> >
{remaining} <RadialCooldown progress={cooldownProgress} />
</span> </div>
)} <span style={{ fontSize: 18, lineHeight: 1, position: 'relative' }}>{meta.icon}</span>
<span style={{ fontSize: 8, color: '#ccc', marginTop: 1, position: 'relative' }}>{meta.label}</span>
{isActive && !isOnCooldown && (
<span
style={{
position: 'absolute',
bottom: 2,
fontSize: 9,
fontWeight: 700,
color: meta.color,
textShadow: '0 1px 2px rgba(0,0,0,0.9)',
}}
>
{Math.ceil(remainingEffectMs / 1000)}s
</span>
)}
{isOnCooldown && (
<span
style={{
position: 'absolute',
fontSize: 11,
fontWeight: 700,
color: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
}}
>
{Math.ceil(buff.cooldownRemainingMs / 1000)}s
</span>
)}
{/* Charge badge */}
{hasChargeData && (
<span
style={{
...chargeBadgeBase,
backgroundColor: isOutOfCharges ? '#aa2222' : 'rgba(30, 90, 180, 0.9)',
color: isOutOfCharges ? '#ffaaaa' : '#fff',
}}
>
{remaining}
</span>
)}
</div>
{showTooltip && ( {showTooltip && (
<div style={tooltipStyle}> <div style={tooltipStyle}>
@ -344,7 +365,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
{tr.refillsAt} {formatTimeHHMM(charge.periodEnd)} {tr.refillsAt} {formatTimeHHMM(charge.periodEnd)}
</div> </div>
)} )}
{isOutOfCharges && onRefill && ( {SHOW_BUFF_REFILL_BUTTON_IN_TOOLTIP && isOutOfCharges && onRefill && (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {

@ -0,0 +1,82 @@
import { useEffect, useRef, type CSSProperties } from 'react';
import { useT } from '../i18n';
const MAX_VISIBLE_LINES = 5;
const panelBase: CSSProperties = {
position: 'absolute',
top: '26%',
zIndex: 50,
maxWidth: 200,
padding: '8px 10px',
borderRadius: 8,
backgroundColor: 'rgba(8, 10, 18, 0.82)',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 4px 16px rgba(0,0,0,0.45)',
pointerEvents: 'none',
};
const titleStyle: CSSProperties = {
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: 'rgba(180, 195, 220, 0.85)',
marginBottom: 4,
};
const scrollStyle: CSSProperties = {
maxHeight: `${MAX_VISIBLE_LINES * 18}px`,
overflowY: 'auto',
fontSize: 11,
lineHeight: 1.45,
color: '#c8c8d0',
scrollbarWidth: 'thin',
};
const lineStyle: CSSProperties = {
margin: 0,
padding: 0,
wordBreak: 'break-word',
};
interface CombatLogPanelProps {
lines: string[];
/** Dock panel on this side; floating damage stays near center / opposite side. */
anchor: 'left' | 'right';
visible: boolean;
}
export function CombatLogPanel({ lines, anchor, visible }: CombatLogPanelProps) {
const tr = useT();
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [lines]);
if (!visible || lines.length === 0) {
return null;
}
const pos: CSSProperties =
anchor === 'left'
? { left: 10, right: 'auto' }
: { right: 10, left: 'auto' };
return (
<div style={{ ...panelBase, ...pos }}>
<div style={titleStyle}>{tr.combatLogTitle}</div>
<div ref={scrollRef} style={scrollStyle}>
{lines.map((line, i) => (
<p key={`${i}-${line.slice(0, 24)}`} style={lineStyle}>
{line}
</p>
))}
</div>
</div>
);
}

@ -5,10 +5,13 @@ import { useT, t } from '../i18n';
interface DeathScreenProps { interface DeathScreenProps {
visible: boolean; visible: boolean;
onRevive: () => void; onRevive: () => void;
/** Free revives left for non-subscribers; omit when subscribed / unlimited. */ /** Active subscription: manual revives are not limited by the free quota. */
revivesRemaining?: number; subscriptionUnlimited?: boolean;
/** Free manual revives left (02) when not subscriptionUnlimited. */
revivesRemaining: number;
} }
/** Full-screen dimming; `pointerEvents: 'none'` so HUD controls (e.g. hero sheet) stay clickable. */
const overlayStyle: CSSProperties = { const overlayStyle: CSSProperties = {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@ -20,9 +23,18 @@ const overlayStyle: CSSProperties = {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 24,
zIndex: 100, zIndex: 100,
transition: 'opacity 0.5s ease', transition: 'opacity 0.5s ease',
pointerEvents: 'none',
};
/** Death UI captures taps; overlay around it passes clicks through to the game HUD. */
const deathPanelStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 24,
pointerEvents: 'auto',
}; };
const titleStyle: CSSProperties = { const titleStyle: CSSProperties = {
@ -52,10 +64,15 @@ const buttonStyle: CSSProperties = {
transition: 'background-color 0.2s', transition: 'background-color 0.2s',
}; };
export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreenProps) { export function DeathScreen({
visible,
onRevive,
subscriptionUnlimited,
revivesRemaining,
}: DeathScreenProps) {
const tr = useT(); const tr = useT();
const [timer, setTimer] = useState(REVIVE_TIMER_SECONDS); const [timer, setTimer] = useState(REVIVE_TIMER_SECONDS);
const canRevive = revivesRemaining === undefined || revivesRemaining > 0; const canRevive = !!subscriptionUnlimited || revivesRemaining > 0;
// Countdown timer // Countdown timer
useEffect(() => { useEffect(() => {
@ -88,33 +105,37 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen
return ( return (
<div style={overlayStyle}> <div style={overlayStyle}>
<div style={titleStyle}>{tr.youDied}</div> <div style={deathPanelStyle}>
<div style={timerStyle}>{canRevive ? timer : '—'}</div> <div style={titleStyle}>{tr.youDied}</div>
{revivesRemaining !== undefined && ( <div style={timerStyle}>{canRevive ? timer : '—'}</div>
<div style={{ color: '#aaa', fontSize: 14 }}> <div style={{ color: '#aaa', fontSize: 14 }}>
{t(tr.freeRevivesLeft, { count: Math.max(0, revivesRemaining) })} {subscriptionUnlimited
? tr.revivesUnlimitedSubscription
: t(tr.freeRevivesLeft, { count: Math.max(0, revivesRemaining) })}
</div>
<button
style={{
...buttonStyle,
opacity: canRevive ? 1 : 0.45,
cursor: canRevive ? 'pointer' : 'not-allowed',
}}
disabled={!canRevive}
onClick={onRevive}
onMouseEnter={(e) => {
if (!canRevive) return;
e.currentTarget.style.backgroundColor = '#ee4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#cc3333';
}}
>
{subscriptionUnlimited
? tr.reviveNow
: t(tr.reviveNowWithCount, { count: Math.max(0, revivesRemaining) })}
</button>
<div style={{ color: '#888', fontSize: 13 }}>
{canRevive ? t(tr.autoReviveIn, { timer }) : tr.noFreeRevives}
</div> </div>
)}
<button
style={{
...buttonStyle,
opacity: canRevive ? 1 : 0.45,
cursor: canRevive ? 'pointer' : 'not-allowed',
}}
disabled={!canRevive}
onClick={onRevive}
onMouseEnter={(e) => {
if (!canRevive) return;
e.currentTarget.style.backgroundColor = '#ee4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#cc3333';
}}
>
{tr.reviveNow}
</button>
<div style={{ color: '#888', fontSize: 13 }}>
{canRevive ? t(tr.autoReviveIn, { timer }) : tr.noFreeRevives}
</div> </div>
</div> </div>
); );

@ -1,7 +1,27 @@
import { useEffect, useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useState, type CSSProperties } from 'react';
import { DAMAGE_NUMBER_DURATION_MS, DAMAGE_NUMBER_RISE_PX } from '../shared/constants'; import {
DAMAGE_NUMBER_DURATION_MS,
DAMAGE_NUMBER_DRIFT_PX,
DAMAGE_NUMBER_FEEDBACK_DURATION_MS,
DAMAGE_NUMBER_CRIT_DURATION_MS,
DAMAGE_NUMBER_RISE_PX,
} from '../shared/constants';
import type { FloatingDamageData } from '../game/types'; import type { FloatingDamageData } from '../game/types';
/** One-shot float + fade; not driven by rAF+setState so parent frame ticks cannot reset it */
const FLOAT_DAMAGE_KEYFRAMES = `
@keyframes autoheroFloatDamage {
from {
opacity: 1;
transform: translate(-50%, -50%) translate(0, 0) scale(var(--ah-s0, 1));
}
to {
opacity: 0;
transform: translate(-50%, -50%) translate(var(--ah-drift, 0px), calc(-1 * var(--ah-rise, 0px))) scale(1);
}
}
`;
interface FloatingDamageProps { interface FloatingDamageProps {
damages: FloatingDamageData[]; damages: FloatingDamageData[];
} }
@ -11,59 +31,54 @@ interface DamageNumberProps {
onExpire: (id: number) => void; onExpire: (id: number) => void;
} }
function DamageNumber({ data, onExpire }: DamageNumberProps) { function feedbackDurationMs(data: FloatingDamageData): number {
const [progress, setProgress] = useState(0); if (data.kind === 'blocked' || data.kind === 'evaded') {
return DAMAGE_NUMBER_FEEDBACK_DURATION_MS;
useEffect(() => { }
let rafId: number; if (data.kind === 'damage' && Boolean(data.isCrit)) {
const start = data.createdAt; return DAMAGE_NUMBER_CRIT_DURATION_MS;
}
const animate = () => { return DAMAGE_NUMBER_DURATION_MS;
const elapsed = performance.now() - start; }
const p = Math.min(1, elapsed / DAMAGE_NUMBER_DURATION_MS);
setProgress(p);
if (p < 1) {
rafId = requestAnimationFrame(animate);
} else {
onExpire(data.id);
}
};
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, [data.createdAt, data.id, onExpire]);
const offsetY = -progress * DAMAGE_NUMBER_RISE_PX; function DamageNumber({ data, onExpire }: DamageNumberProps) {
const opacity = 1 - progress * progress; // ease-out fade const durationMs = feedbackDurationMs(data);
const scale = data.isCrit && data.kind === 'damage' ? 1.4 - progress * 0.4 : 1; const driftDir = data.target === 'enemy' ? 1 : -1;
const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded'; const isOutcomeText = data.kind === 'blocked' || data.kind === 'evaded';
const isCritDamage = data.kind === 'damage' && Boolean(data.isCrit);
const color = data.kind === 'regen' const color = data.kind === 'regen'
? '#44dd66' ? '#44dd66'
: isOutcomeText : isOutcomeText
? (data.target === 'hero' ? '#44dd66' : '#ff5566') ? (data.target === 'hero' ? '#44dd66' : '#ff5566')
: (data.isCrit ? '#ffdd44' : '#ffffff'); : (isCritDamage ? '#ffdd44' : '#ffffff');
const fontSize = isOutcomeText ? 16 : (data.isCrit ? 24 : 18); const fontSize = isOutcomeText ? 16 : (isCritDamage ? 24 : 18);
const style: CSSProperties = { const style: CSSProperties = {
position: 'absolute', position: 'absolute',
left: data.x, left: data.x,
top: data.y + offsetY, top: data.y,
transform: `translate(-50%, -50%) scale(${scale})`, transform: 'translate(-50%, -50%)',
opacity, animation: `autoheroFloatDamage ${durationMs}ms cubic-bezier(0.2, 0.75, 0.35, 1) forwards`,
['--ah-drift' as string]: `${DAMAGE_NUMBER_DRIFT_PX * driftDir}px`,
['--ah-rise' as string]: `${DAMAGE_NUMBER_RISE_PX}px`,
['--ah-s0' as string]: isCritDamage ? '1.4' : '1',
color, color,
fontSize, fontSize,
fontWeight: 900, fontWeight: 900,
textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)', textShadow: '0 2px 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.5)',
pointerEvents: 'none', pointerEvents: 'none',
willChange: 'transform, opacity',
}; };
return ( return (
<div style={style}> <div
style={style}
onAnimationEnd={() => {
onExpire(data.id);
}}
>
{data.kind === 'damage' && ( {data.kind === 'damage' && (
<> <>
{data.isCrit && 'CRIT '} {isCritDamage && 'CRIT '}
{Math.round(data.value)} {Math.round(data.value)}
</> </>
)} )}
@ -90,9 +105,9 @@ export function FloatingDamage({ damages }: FloatingDamageProps) {
}); });
}, [damages]); }, [damages]);
const handleExpire = (id: number) => { const handleExpire = useCallback((id: number) => {
setActiveDamages((prev) => prev.filter((d) => d.id !== id)); setActiveDamages((prev) => prev.filter((d) => d.id !== id));
}; }, []);
return ( return (
<div style={{ <div style={{
@ -104,6 +119,7 @@ export function FloatingDamage({ damages }: FloatingDamageProps) {
pointerEvents: 'none', pointerEvents: 'none',
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<style dangerouslySetInnerHTML={{ __html: FLOAT_DAMAGE_KEYFRAMES }} />
{activeDamages.map((d) => ( {activeDamages.map((d) => (
<DamageNumber key={d.id} data={d} onExpire={handleExpire} /> <DamageNumber key={d.id} data={d} onExpire={handleExpire} />
))} ))}

@ -124,6 +124,8 @@ interface HeroSheetModalProps {
quests: HeroQuest[]; quests: HeroQuest[];
onQuestClaim: (heroQuestId: number) => void; onQuestClaim: (heroQuestId: number) => void;
onQuestAbandon: (heroQuestId: number) => void; onQuestAbandon: (heroQuestId: number) => void;
/** Disable claim while hero is dead (HP 0 / death phase). */
questClaimDisabled?: boolean;
} }
export function HeroSheetModal({ export function HeroSheetModal({
@ -137,6 +139,7 @@ export function HeroSheetModal({
quests, quests,
onQuestClaim, onQuestClaim,
onQuestAbandon, onQuestAbandon,
questClaimDisabled = false,
}: HeroSheetModalProps) { }: HeroSheetModalProps) {
const [tab, setTab] = useState<HeroSheetTab>(initialTab); const [tab, setTab] = useState<HeroSheetTab>(initialTab);
const tr = useT(); const tr = useT();
@ -207,6 +210,7 @@ export function HeroSheetModal({
quests={quests} quests={quests}
onClaim={onQuestClaim} onClaim={onQuestClaim}
onAbandon={onQuestAbandon} onAbandon={onQuestAbandon}
claimDisabled={questClaimDisabled}
/> />
)} )}
{tab === 'settings' && ( {tab === 'settings' && (

@ -18,6 +18,8 @@ interface NPCDialogProps {
onQuestsChanged: () => void; onQuestsChanged: () => void;
onHeroUpdated: (hero: HeroResponse) => void; onHeroUpdated: (hero: HeroResponse) => void;
onToast: (message: string, color: string) => void; onToast: (message: string, color: string) => void;
/** Block quest reward claim while hero is dead. */
questClaimDisabled?: boolean;
} }
// ---- Styles ---- // ---- Styles ----
@ -242,6 +244,7 @@ export function NPCDialog({
onQuestsChanged, onQuestsChanged,
onHeroUpdated, onHeroUpdated,
onToast, onToast,
questClaimDisabled = false,
}: NPCDialogProps) { }: NPCDialogProps) {
const tr = useT(); const tr = useT();
const [availableQuests, setAvailableQuests] = useState<Quest[]>([]); const [availableQuests, setAvailableQuests] = useState<Quest[]>([]);
@ -419,8 +422,14 @@ export function NPCDialog({
)} )}
</div> </div>
<button <button
style={claimBtnStyle} type="button"
onClick={() => handleClaimQuest(hq.id)} style={questClaimDisabled ? { ...claimBtnStyle, opacity: 0.45, cursor: 'not-allowed', animation: 'none' } : claimBtnStyle}
disabled={questClaimDisabled}
title={questClaimDisabled ? tr.claimRewardsDisabledDead : undefined}
onClick={() => {
if (questClaimDisabled) return;
handleClaimQuest(hq.id);
}}
> >
{tr.claimRewards} {tr.claimRewards}
</button> </button>

@ -9,6 +9,7 @@ interface QuestLogProps {
onClaim: (heroQuestId: number) => void; onClaim: (heroQuestId: number) => void;
onAbandon: (heroQuestId: number) => void; onAbandon: (heroQuestId: number) => void;
onClose: () => void; onClose: () => void;
claimDisabled?: boolean;
} }
// ---- Quest Type Icons ---- // ---- Quest Type Icons ----
@ -167,6 +168,15 @@ const claimBtnStyle: CSSProperties = {
animation: 'quest-claim-glow 1.5s ease-in-out infinite', animation: 'quest-claim-glow 1.5s ease-in-out infinite',
}; };
const claimBtnDisabledStyle: CSSProperties = {
...claimBtnStyle,
opacity: 0.45,
cursor: 'not-allowed',
animation: 'none',
boxShadow: 'none',
textShadow: 'none',
};
const abandonBtnStyle: CSSProperties = { const abandonBtnStyle: CSSProperties = {
padding: '8px 14px', padding: '8px 14px',
border: '1px solid rgba(255, 80, 80, 0.3)', border: '1px solid rgba(255, 80, 80, 0.3)',
@ -189,10 +199,12 @@ interface QuestLogListProps {
quests: HeroQuest[]; quests: HeroQuest[];
onClaim: (heroQuestId: number) => void; onClaim: (heroQuestId: number) => void;
onAbandon: (heroQuestId: number) => void; onAbandon: (heroQuestId: number) => void;
/** When true, completed-quest claim is blocked (e.g. hero dead). */
claimDisabled?: boolean;
} }
/** Quest list body (embedded in Hero sheet or standalone panel). */ /** Quest list body (embedded in Hero sheet or standalone panel). */
export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps) { export function QuestLogList({ quests, onClaim, onAbandon, claimDisabled = false }: QuestLogListProps) {
const tr = useT(); const tr = useT();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
@ -294,9 +306,13 @@ export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps)
<div style={actionRow}> <div style={actionRow}>
{isCompleted ? ( {isCompleted ? (
<button <button
style={claimBtnStyle} type="button"
style={claimDisabled ? claimBtnDisabledStyle : claimBtnStyle}
disabled={claimDisabled}
title={claimDisabled ? tr.claimRewardsDisabledDead : undefined}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (claimDisabled) return;
onClaim(q.id); onClaim(q.id);
}} }}
> >
@ -325,7 +341,7 @@ export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps)
); );
} }
export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps) { export function QuestLog({ quests, onClaim, onAbandon, onClose, claimDisabled }: QuestLogProps) {
const tr = useT(); const tr = useT();
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@ -350,7 +366,7 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps)
<span style={titleStyle}>{'\uD83D\uDCDC'} {tr.questLog}</span> <span style={titleStyle}>{'\uD83D\uDCDC'} {tr.questLog}</span>
<button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button> <button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button>
</div> </div>
<QuestLogList quests={quests} onClaim={onClaim} onAbandon={onAbandon} /> <QuestLogList quests={quests} onClaim={onClaim} onAbandon={onAbandon} claimDisabled={claimDisabled} />
</div> </div>
</div> </div>
</> </>

Loading…
Cancel
Save