diff --git a/admin-web/index.html b/admin-web/index.html
index c85cae7..17f5ad9 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -2282,11 +2282,11 @@
-
+
-
+
Roadside / adventure: герой жив, не в бою; adventure — StateWalking на дороге.
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index d82eb16..174f91c 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -99,18 +99,21 @@ type Engine struct {
// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
const offlineDisconnectedFullSaveInterval = 30 * time.Second
+// restHealPersistInterval is how often we persist the full hero row while resting with active HP regen.
+const restHealPersistInterval = 5 * time.Second
+
// NewEngine creates a new game engine with the given tick rate.
func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine {
e := &Engine{
- tickRate: tickRate,
- combats: make(map[int64]*model.CombatState),
- queue: make(model.AttackQueue, 0),
- movements: make(map[int64]*HeroMovement),
- incomingCh: make(chan IncomingMessage, 256),
- eventCh: eventCh,
- logger: logger,
- lastDisconnectedFullSave: make(map[int64]time.Time),
- merchantStock: make(map[int64]*merchantOfferSession),
+ tickRate: tickRate,
+ combats: make(map[int64]*model.CombatState),
+ queue: make(model.AttackQueue, 0),
+ movements: make(map[int64]*HeroMovement),
+ incomingCh: make(chan IncomingMessage, 256),
+ eventCh: eventCh,
+ logger: logger,
+ lastDisconnectedFullSave: make(map[int64]time.Time),
+ merchantStock: make(map[int64]*merchantOfferSession),
}
heap.Init(&e.queue)
return e
@@ -557,7 +560,6 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
}
-// handleRevive processes the revive client command.
func (e *Engine) handleNPCAlmsAccept(msg IncomingMessage) {
e.mu.RLock()
h := e.npcAlmsHandler
@@ -587,6 +589,7 @@ func (e *Engine) handleNPCAlmsDecline(msg IncomingMessage) {
}
}
+// handleRevive processes the revive client command (same rules as POST /api/v1/hero/revive).
func (e *Engine) handleRevive(msg IncomingMessage) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -598,25 +601,17 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
}
hero := hm.Hero
- if hero.HP > 0 && hm.State != model.StateDead {
+ if !IsEffectivelyDead(hero) {
e.sendError(msg.HeroID, "not_dead", "hero is not dead")
return
}
-
- hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
- if hero.HP < 1 {
- hero.HP = 1
+ if err := CheckPlayerReviveQuota(hero); err != nil {
+ e.sendError(msg.HeroID, "revive_quota", "free revive limit reached (subscribe for unlimited revives)")
+ return
}
- hero.State = model.StateWalking
- hero.Debuffs = nil
- hero.ReviveCount++
-
- hm.State = model.StateWalking
- hm.LastMoveTick = time.Now()
- hm.refreshSpeed(time.Now())
- // Remove any active combat.
- delete(e.combats, msg.HeroID)
+ ApplyHeroReviveMechanical(hero)
+ ApplyPlayerReviveProgressCounters(hero)
// Persist revive to DB immediately so disconnect doesn't revert it.
if e.heroStore != nil {
@@ -625,12 +620,11 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
}
}
- if e.sender != nil {
- hero.EnsureGearMap()
- hero.RefreshDerivedCombatStats(time.Now())
- e.sender.SendToHero(msg.HeroID, "hero_state", hero)
- e.sender.SendToHero(msg.HeroID, "hero_revived", model.HeroRevivedPayload{HP: hero.HP})
+ if e.adventureLog != nil {
+ e.adventureLog(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
}
+
+ e.applyResidentReviveSyncLocked(hero)
}
// sendError sends an error envelope to a hero.
@@ -952,6 +946,98 @@ func (e *Engine) ApplyAdminStopRest(heroID int64) (*model.Hero, bool) {
return h, true
}
+// ApplyAdminStopAnyRest ends whichever rest or town pause applies: first roadside/adventure-inline
+// (must not use LeaveTown — that would corrupt excursion state), otherwise town rest or in-town flow.
+func (e *Engine) ApplyAdminStopAnyRest(heroID int64) (*model.Hero, bool) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ hm, ok := e.movements[heroID]
+ if !ok || e.roadGraph == nil {
+ return nil, false
+ }
+ now := time.Now()
+ if hm.AdminStopRest(now) {
+ hm.SyncToHero()
+ h := hm.Hero
+ if e.sender != nil {
+ h.EnsureGearMap()
+ h.RefreshDerivedCombatStats(now)
+ e.sender.SendToHero(heroID, "hero_state", h)
+ e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
+ if route := hm.RoutePayload(); route != nil {
+ e.sender.SendToHero(heroID, "route_assigned", route)
+ }
+ }
+ return h, true
+ }
+ if hm.State != model.StateResting && hm.State != model.StateInTown {
+ return nil, false
+ }
+ hm.LeaveTown(e.roadGraph, now)
+ hm.SyncToHero()
+ h := hm.Hero
+ if e.sender != nil {
+ h.EnsureGearMap()
+ h.RefreshDerivedCombatStats(now)
+ e.sender.SendToHero(heroID, "hero_state", h)
+ e.sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
+ if route := hm.RoutePayload(); route != nil {
+ e.sender.SendToHero(heroID, "route_assigned", route)
+ }
+ }
+ if e.heroStore != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
+ e.logger.Error("persist hero after admin stop any rest (leave town)", "hero_id", heroID, "error", err)
+ }
+ }
+ return h, true
+}
+
+// ApplyAdminLethalEnemyKill applies a killing blow from the hero and runs the normal victory path (rewards, WS, persist).
+func (e *Engine) ApplyAdminLethalEnemyKill(heroID int64) (*model.Hero, bool) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ cs, ok := e.combats[heroID]
+ if !ok || cs == nil || cs.Hero == nil || !cs.Enemy.IsAlive() {
+ return nil, false
+ }
+ now := time.Now()
+ dmg := cs.Enemy.HP
+ cs.Enemy.HP = 0
+ combatEvt := model.CombatEvent{
+ Type: "attack",
+ HeroID: heroID,
+ Damage: dmg,
+ Source: "hero",
+ Outcome: attackOutcomeHit,
+ HeroHP: cs.Hero.HP,
+ EnemyHP: 0,
+ Timestamp: now,
+ }
+ e.emitEvent(combatEvt)
+ e.logCombatAttack(cs, combatEvt)
+ if e.sender != nil {
+ e.sender.SendToHero(heroID, "attack", model.AttackPayload{
+ Source: combatEvt.Source,
+ Damage: combatEvt.Damage,
+ IsCrit: false,
+ Outcome: combatEvt.Outcome,
+ HeroHP: combatEvt.HeroHP,
+ EnemyHP: 0,
+ })
+ }
+ e.handleEnemyDeath(cs, now)
+ if hm, ok := e.movements[heroID]; ok && hm.Hero != nil {
+ return hm.Hero, true
+ }
+ if cs.Hero != nil {
+ return cs.Hero, true
+ }
+ return nil, false
+}
+
// ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road.
func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
@@ -1054,8 +1140,6 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
if hm, ok := e.movements[hero.ID]; ok && hm.Hero != nil {
ox, oy := hm.displayOffset(now)
wx, wy = hm.CurrentX+ox, hm.CurrentY+oy
- } else if hero != nil {
- wx, wy = hero.PositionX, hero.PositionY
}
if e.roadGraph.HeroInTownAt(wx, wy) {
e.logger.Debug("skip combat start: hero inside town radius", "hero_id", hero.ID)
@@ -1072,6 +1156,19 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
StartedAt: now,
LastTickAt: now,
}
+ if tmpl, ok := model.EnemyBySlug(enemy.Slug); ok {
+ baseScaled, afterGlobal := EnemyEncounterStatStages(tmpl, enemy.Level)
+ cs.EnemyStatsBasePreEncounterMult = &model.EncounterCombatStatsSnapshot{
+ MaxHP: baseScaled.MaxHP,
+ Attack: baseScaled.Attack,
+ Defense: baseScaled.Defense,
+ }
+ cs.EnemyStatsAfterGlobalEncounterMult = &model.EncounterCombatStatsSnapshot{
+ MaxHP: afterGlobal.MaxHP,
+ Attack: afterGlobal.Attack,
+ Defense: afterGlobal.Defense,
+ }
+ }
e.combats[hero.ID] = cs
hero.State = model.StateFighting
@@ -1093,16 +1190,6 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
CombatID: hero.ID,
})
- // Legacy event channel (for backward compat bridge).
- e.emitEvent(model.CombatEvent{
- Type: "combat_start",
- HeroID: hero.ID,
- Source: "system",
- HeroHP: hero.HP,
- EnemyHP: enemy.HP,
- Timestamp: now,
- })
-
// New: send typed combat_start envelope.
if e.sender != nil {
e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{
@@ -1119,10 +1206,28 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
})
}
- e.logger.Info("combat started",
- "hero_id", hero.ID,
- "enemy", enemy.Name,
- )
+ if e.logger != nil {
+ mult := EnemyEncounterMultiplierBreakdownForHero(hero)
+ e.logger.Info("combat started",
+ "hero_id", hero.ID,
+ "hero_level", hero.Level,
+ "enemy_slug", enemy.Slug,
+ "enemy_name", enemy.Name,
+ "enemy_level", enemy.Level,
+ "enemy_hp", enemy.HP,
+ "enemy_max_hp", enemy.MaxHP,
+ "enemy_attack", enemy.Attack,
+ "enemy_defense", enemy.Defense,
+ "enemy_speed", enemy.Speed,
+ "enemy_crit_chance", enemy.CritChance,
+ "enemy_is_elite", enemy.IsElite,
+ "enemy_xp_reward", enemy.XPReward,
+ "enemy_gold_reward", enemy.GoldReward,
+ "mult_global_encounter", mult.GlobalEncounterStatMultiplier,
+ "mult_unequipped_config", mult.UnequippedHeroStatMultiplier,
+ "mult_unequipped_applied", mult.UnequippedScalingApplied,
+ )
+ }
}
// StopCombat removes a combat session.
@@ -1234,15 +1339,12 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
e.ApplyPersistedHeroSnapshot(hero)
}
-// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
-// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
-// restores movement/route when needed, and pushes WS events so the client matches the DB.
-func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
+// applyResidentReviveSyncLocked clears combat, merges a persisted hero into the live session,
+// and pushes hero_state + hero_revived. Caller must hold e.mu.
+func (e *Engine) applyResidentReviveSyncLocked(hero *model.Hero) {
if hero == nil {
return
}
- e.mu.Lock()
- defer e.mu.Unlock()
delete(e.combats, hero.ID)
@@ -1284,6 +1386,18 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
}
}
+// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
+// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
+// restores movement/route when needed, and pushes WS events so the client matches the DB.
+func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
+ if hero == nil {
+ return
+ }
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.applyResidentReviveSyncLocked(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) {
@@ -1731,12 +1845,7 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
if now.Sub(h.UpdatedAt) <= gap {
continue
}
- h.HP = int(float64(h.MaxHP) * tuning.Get().ReviveHpPercent)
- if h.HP < 1 {
- h.HP = 1
- }
- h.State = model.StateWalking
- h.Debuffs = nil
+ ApplyHeroReviveMechanical(h)
hm.State = model.StateWalking
hm.SyncToHero()
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -1755,6 +1864,7 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err)
}
cancelSave()
+ e.applyResidentReviveSyncLocked(h)
}
}
@@ -1782,7 +1892,10 @@ func (e *Engine) processMovementTick(now time.Time) {
if hm.skipMovementSimulation() {
continue
}
- ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil)
+ ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil, e.logger)
+ if hm.State != model.StateResting {
+ hm.lastRestHealPersistAt = time.Time{}
+ }
if e.heroStore == nil || hm.Hero == nil {
continue
}
@@ -1798,8 +1911,27 @@ func (e *Engine) processMovementTick(now time.Time) {
continue
}
hm.MarkTownPausePersisted(sig)
+ if hm.State == model.StateResting {
+ hm.lastRestHealPersistAt = now
+ }
e.syncTownSessionRedis(heroID, hm)
}
+ if hm.State == model.StateResting && hm.restHPRegenActive() {
+ if hm.lastRestHealPersistAt.IsZero() || now.Sub(hm.lastRestHealPersistAt) >= restHealPersistInterval {
+ hm.SyncToHero()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ err := e.heroStore.Save(ctx, hm.Hero)
+ cancel()
+ if err != nil {
+ if e.logger != nil {
+ e.logger.Error("persist hero during rest heal", "hero_id", heroID, "error", err)
+ }
+ } else {
+ hm.lastRestHealPersistAt = now
+ e.syncTownSessionRedis(heroID, hm)
+ }
+ }
+ }
if e.heroStore != nil && e.heroSubscriber != nil && hm.Hero != nil && !e.heroSubscriber(heroID) {
last := e.lastDisconnectedFullSave[heroID]
if last.IsZero() || now.Sub(last) >= offlineDisconnectedFullSaveInterval {
diff --git a/backend/internal/game/fsm_excursion_test.go b/backend/internal/game/fsm_excursion_test.go
index 05179a8..33afe95 100644
--- a/backend/internal/game/fsm_excursion_test.go
+++ b/backend/internal/game/fsm_excursion_test.go
@@ -84,7 +84,7 @@ func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) {
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
tick := now.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase)
@@ -106,7 +106,7 @@ func TestFSM_AdventureInlineRest_HPExit_ExcursionStillActive(t *testing.T) {
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected back to walking after HP target, got %s", hm.State)
@@ -166,7 +166,7 @@ func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateFighting {
t.Fatalf("expected StateFighting unchanged, got %s", hm.State)
diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go
index 0baa687..615613d 100644
--- a/backend/internal/game/movement.go
+++ b/backend/internal/game/movement.go
@@ -2,8 +2,10 @@ package game
import (
"fmt"
+ "log/slog"
"math"
"math/rand"
+ "strings"
"time"
"github.com/denisovdennis/autohero/internal/model"
@@ -121,6 +123,10 @@ type HeroMovement struct {
// persist only on meaningful changes (start/end/phase change).
lastTownPausePersistSignature townPausePersistSignature
+ // lastRestHealPersistAt is wall time of the last full hero row save while resting with HP regen
+ // (see Engine.processMovementTick periodic persist).
+ lastRestHealPersistAt time.Time
+
// sentTownTourWireSig avoids spamming town_tour_phase when nothing changed.
sentTownTourWireSig string
}
@@ -1112,19 +1118,20 @@ func WanderingMerchantCost(level int) int64 {
}
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
-func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
+// outcome classifies the roll for logging: skip_* | monster | wandering_merchant.
+func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool, outcome string) {
cfg := tuning.Get()
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_no_road"
}
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_in_town"
}
if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_cooldown"
}
if rand.Float64() >= cfg.EncounterActivityBase {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_activity"
}
monsterW := cfg.MonsterEncounterWeightBase
merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus
@@ -1132,9 +1139,9 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (mons
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForHero(hm.Hero)
- return true, e, true
+ return true, e, true, "monster"
}
- return false, model.Enemy{}, true
+ return false, model.Enemy{}, true, "wandering_merchant"
}
// EnterTown transitions the hero into the destination town: town tour excursion (StateInTown) when there
@@ -1341,6 +1348,25 @@ func (hm *HeroMovement) SyncToHero() {
hm.Hero.TownPause = hm.townPauseBlob()
}
+// restHPRegenActive is true when the resting FSM applies HP-per-second healing this session
+// (roadside wild phase, adventure inline rest, or town rest) and the hero is not already at max HP.
+func (hm *HeroMovement) restHPRegenActive() bool {
+ if hm == nil || hm.Hero == nil || hm.State != model.StateResting {
+ return false
+ }
+ if hm.Hero.MaxHP <= 0 || hm.Hero.HP >= hm.Hero.MaxHP {
+ return false
+ }
+ switch hm.ActiveRestKind {
+ case model.RestKindRoadside:
+ return hm.Excursion.Phase == model.ExcursionWild
+ case model.RestKindAdventureInline:
+ return true
+ default:
+ return true // town rest and other resting kinds using applyTownRestHeal
+ }
+}
+
// TownPausePersistDue reports whether excursion/rest state should be persisted.
// Returns the current signature for use when marking persistence.
func (hm *HeroMovement) TownPausePersistDue() (townPausePersistSignature, bool) {
@@ -1924,17 +1950,17 @@ func (hm *HeroMovement) applyRestHealTick(dt float64) {
}
}
-func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
+func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool, outcome string) {
cfg := tuning.Get()
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_in_town"
}
cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond
if now.Sub(hm.LastEncounterAt) < cooldown {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_cooldown"
}
if rand.Float64() >= cfg.EncounterActivityBase {
- return false, model.Enemy{}, false
+ return false, model.Enemy{}, false, "skip_activity"
}
wildness := hm.excursionWildness(now)
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness
@@ -1943,9 +1969,44 @@ func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph)
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForHero(hm.Hero)
- return true, e, true
+ return true, e, true, "monster"
+ }
+ return false, model.Enemy{}, true, "wandering_merchant"
+}
+
+// logMovementEncounterRoll logs encounter rolls during walking/adventure (tickLog from engine; nil = silent).
+func logMovementEncounterRoll(tickLog *slog.Logger, heroID int64, mode string, outcome string, enemy model.Enemy, hm *HeroMovement, now time.Time) {
+ if tickLog == nil {
+ return
+ }
+ switch outcome {
+ case "monster":
+ mult := EnemyEncounterMultiplierBreakdownForHero(hm.Hero)
+ attrs := []any{
+ "hero_id", heroID,
+ "mode", mode,
+ "outcome", outcome,
+ "enemy_slug", enemy.Slug,
+ "enemy_level", enemy.Level,
+ "enemy_max_hp", enemy.MaxHP,
+ "mult_global_encounter", mult.GlobalEncounterStatMultiplier,
+ "mult_unequipped_applied", mult.UnequippedScalingApplied,
+ }
+ if mode == "adventure" {
+ attrs = append(attrs, "excursion_wildness", hm.excursionWildness(now))
+ }
+ tickLog.Debug("movement encounter roll", attrs...)
+ case "wandering_merchant":
+ attrs := []any{"hero_id", heroID, "mode", mode, "outcome", outcome}
+ if mode == "adventure" {
+ attrs = append(attrs, "excursion_wildness", hm.excursionWildness(now))
+ }
+ tickLog.Info("movement encounter roll", attrs...)
+ default:
+ if strings.HasPrefix(outcome, "skip_") {
+ tickLog.Debug("movement encounter skipped", "hero_id", heroID, "mode", mode, "reason", outcome)
+ }
}
- return false, model.Enemy{}, true
}
func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
@@ -1964,6 +2025,7 @@ func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block).
// persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
// townTourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up.
+// tickLog is optional (e.g. engine logger): encounter skips at Debug, merchants at Info, rest starts at Info.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
@@ -1975,6 +2037,7 @@ func ProcessSingleHeroMovementTick(
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
townTourOffline TownTourOfflineAtNPC,
+ tickLog *slog.Logger,
) {
if graph == nil {
return
@@ -2162,6 +2225,14 @@ func ProcessSingleHeroMovementTick(
_ = hm.stepTowardAttractor(now, dtAdv)
if hm.isLowHP() {
hm.beginAdventureInlineRest(now)
+ if tickLog != nil && hm.Hero != nil {
+ tickLog.Info("rest started",
+ "hero_id", heroID,
+ "kind", "adventure_inline",
+ "hp", hm.Hero.HP,
+ "max_hp", hm.Hero.MaxHP,
+ )
+ }
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
@@ -2171,7 +2242,8 @@ func ProcessSingleHeroMovementTick(
return
}
if onEncounter != nil || onMerchantEncounter != nil {
- monster, enemy, hit := hm.rollAdventureEncounter(now, graph)
+ monster, enemy, hit, encOutcome := hm.rollAdventureEncounter(now, graph)
+ logMovementEncounterRoll(tickLog, heroID, "adventure", encOutcome, enemy, hm, now)
if hit {
if monster && onEncounter != nil {
hm.LastEncounterAt = now
@@ -2223,6 +2295,22 @@ func ProcessSingleHeroMovementTick(
if reachedTown {
hm.EnterTown(now, graph)
+ if tickLog != nil {
+ switch hm.State {
+ case model.StateResting:
+ tickLog.Info("rest started",
+ "hero_id", heroID,
+ "kind", "town_simple",
+ "town_id", hm.CurrentTownID,
+ "rest_until", hm.RestUntil,
+ )
+ case model.StateInTown:
+ tickLog.Info("town tour started",
+ "hero_id", heroID,
+ "town_id", hm.CurrentTownID,
+ )
+ }
+ }
if sender != nil {
town := graph.Towns[hm.CurrentTownID]
@@ -2254,6 +2342,14 @@ func ProcessSingleHeroMovementTick(
if hm.isLowHP() {
hm.beginRoadsideRest(now)
+ if tickLog != nil {
+ tickLog.Info("rest started",
+ "hero_id", heroID,
+ "kind", "roadside",
+ "rest_until", hm.RestUntil,
+ "excursion_phase", string(hm.Excursion.Phase),
+ )
+ }
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
@@ -2264,7 +2360,8 @@ func ProcessSingleHeroMovementTick(
canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
- monster, enemy, hit := hm.rollRoadEncounter(now, graph)
+ monster, enemy, hit, encOutcome := hm.rollRoadEncounter(now, graph)
+ logMovementEncounterRoll(tickLog, heroID, "road", encOutcome, enemy, hm, now)
if hit {
if monster {
if onEncounter != nil {
@@ -2282,7 +2379,7 @@ func ProcessSingleHeroMovementTick(
NPCID: 0,
NPCName: "Wandering Merchant",
NPCNameKey: model.WanderingMerchantNPCKey,
- Role: "alms",
+ Role: "wandering merchant",
DialogueKey: model.WanderingMerchantDialogueKey,
Cost: cost,
})
diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go
index 25d47cc..e5d6395 100644
--- a/backend/internal/game/offline.go
+++ b/backend/internal/game/offline.go
@@ -118,13 +118,8 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
// Auto-revive after configured downtime (autoReviveAfterMs).
gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
- if (hero.State == model.StateDead || hero.HP <= 0) && now.Sub(hero.UpdatedAt) > gap {
- hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
- if hero.HP < 1 {
- hero.HP = 1
- }
- hero.State = model.StateWalking
- hero.Debuffs = nil
+ if IsEffectivelyDead(hero) && now.Sub(hero.UpdatedAt) > gap {
+ ApplyHeroReviveMechanical(hero)
s.addLog(ctx, hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAutoReviveAfterSec,
@@ -226,7 +221,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
adventureLog := func(heroID int64, line model.AdventureLogLine) {
s.addLog(ctx, heroID, line)
}
- ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineTownTour)
+ ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineTownTour, nil)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break
}
@@ -518,8 +513,8 @@ func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.En
} else {
picked = candidates[rand.Intn(len(candidates))]
}
- e := buildEnemyInstance(picked, level, rng)
- ApplyEnemyEncounterHeroScaling(hero, &e)
+ e := buildEnemyInstance(picked, hero, rng)
+
return e
}
@@ -599,35 +594,28 @@ func enemyInstanceLevel(baseLevel, heroLevel int, variance float64, maxHeroDiff
return minL + rand.Intn(maxL-minL+1)
}
-func buildEnemyInstance(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy {
+func buildEnemyInstance(tmpl model.Enemy, hero *model.Hero, rng *rand.Rand,) model.Enemy {
picked := tmpl
- baseLevel := picked.BaseLevel
- if baseLevel <= 0 {
- if picked.MinLevel > 0 {
- baseLevel = picked.MinLevel
- } else {
- baseLevel = 1
- }
- }
- instanceLevel := enemyInstanceLevel(baseLevel, heroLevel, picked.LevelVariance, picked.MaxHeroLevelDiff, rng)
- return BuildEnemyInstanceForLevel(picked, instanceLevel)
+ instanceLevel := enemyInstanceLevel(picked.BaseLevel, hero.Level, picked.LevelVariance, picked.MaxHeroLevelDiff, rng)
+ return BuildEnemyInstanceForLevel(picked, instanceLevel, hero)
}
// BuildEnemyInstanceForEncounter builds a runtime enemy like world encounters: rolls instance level
// using the template base level, LevelVariance, and MaxHeroLevelDiff vs heroLevel (see enemyInstanceLevel).
// Pass rng for deterministic runs; nil uses the global math/rand source.
-func BuildEnemyInstanceForEncounter(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy {
- return buildEnemyInstance(tmpl, heroLevel, rng)
+func BuildEnemyInstanceForEncounter(tmpl model.Enemy, hero *model.Hero, rng *rand.Rand) model.Enemy {
+ return buildEnemyInstance(tmpl, hero, rng)
}
// ScaleEnemyTemplate is kept for backward compatibility with existing call sites.
// It now builds an instance using DB-driven per-archetype progression.
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
- return BuildEnemyInstanceForLevel(tmpl, heroLevel)
+ return BuildEnemyInstanceForLevel(tmpl, heroLevel, nil)
}
-// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
-func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy {
+// BuildEnemyInstanceForLevelScaledOnly returns the runtime enemy after level-based progression only.
+// It does not apply EnemyEncounterStatMultiplier or unequipped-hero scaling.
+func BuildEnemyInstanceForLevelScaledOnly(tmpl model.Enemy, level int) model.Enemy {
picked := tmpl
baseLevel := picked.BaseLevel
if baseLevel <= 0 {
@@ -654,15 +642,38 @@ func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy {
}
picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel)))
picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel)))
+ return picked
+}
+// EnemyEncounterStatStages returns level-scaled combat stats, then the same after the global encounter multiplier only.
+func EnemyEncounterStatStages(tmpl model.Enemy, level int) (baseScaled model.Enemy, afterGlobal model.Enemy) {
+ baseScaled = BuildEnemyInstanceForLevelScaledOnly(tmpl, level)
+ afterGlobal = baseScaled
cfg := tuning.Get()
gMult := cfg.EnemyEncounterStatMultiplier
if gMult <= 0 {
gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier
}
if gMult > 0 && gMult != 1 {
- applyEnemyEncounterCombatMult(&picked, gMult)
+ applyEnemyEncounterCombatMult(&afterGlobal, gMult)
+ }
+ return baseScaled, afterGlobal
+}
+
+// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
+func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int, hero *model.Hero) model.Enemy {
+ picked := BuildEnemyInstanceForLevelScaledOnly(tmpl, level)
+ cfg := tuning.Get()
+
+ var m float64
+ if hero != nil && !HeroHasEquippedGearForCombat(hero) {
+ m = cfg.EnemyStatMultiplierVsUnequippedHero
+ } else {
+ m = cfg.EnemyEncounterStatMultiplier
}
+
+ applyEnemyEncounterCombatMult(&picked, m)
+
return picked
}
@@ -682,7 +693,14 @@ func HeroHasEquippedGear(h *model.Hero) bool {
// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.).
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
- return HeroHasEquippedGear(h)
+ h.EnsureGearMap()
+ c := 0
+ for _, it := range h.Gear {
+ if it != nil {
+ c++
+ }
+ }
+ return c > 0
}
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
@@ -695,6 +713,33 @@ func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
e.Defense = max(0, int(math.Round(float64(e.Defense)*mult)))
}
+// EnemyEncounterMultiplierBreakdown documents tuning multipliers used when building encounter enemies
+// (global encounter strength vs hero; extra scaling when the hero has almost no gear).
+type EnemyEncounterMultiplierBreakdown struct {
+ GlobalEncounterStatMultiplier float64 `json:"globalEncounterStatMultiplier"`
+ UnequippedHeroStatMultiplier float64 `json:"unequippedHeroStatMultiplier"`
+ UnequippedScalingApplied bool `json:"unequippedScalingApplied"`
+}
+
+// EnemyEncounterMultiplierBreakdownForHero returns active tuning values and whether unequipped-hero scaling would apply.
+func EnemyEncounterMultiplierBreakdownForHero(hero *model.Hero) EnemyEncounterMultiplierBreakdown {
+ cfg := tuning.Get()
+ g := cfg.EnemyEncounterStatMultiplier
+ if g <= 0 {
+ g = tuning.DefaultValues().EnemyEncounterStatMultiplier
+ }
+ m := cfg.EnemyStatMultiplierVsUnequippedHero
+ if m <= 0 {
+ m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
+ }
+ applied := hero != nil && !HeroHasEquippedGearForCombat(hero) && m > 0 && m != 1 && m <= 10
+ return EnemyEncounterMultiplierBreakdown{
+ GlobalEncounterStatMultiplier: g,
+ UnequippedHeroStatMultiplier: m,
+ UnequippedScalingApplied: applied,
+ }
+}
+
// ApplyEnemyEncounterHeroScaling applies a multiplier to enemy combat stats when the hero has no equipped gear.
func ApplyEnemyEncounterHeroScaling(hero *model.Hero, enemy *model.Enemy) {
if hero == nil || enemy == nil || HeroHasEquippedGearForCombat(hero) {
diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go
index e1f741d..916978d 100644
--- a/backend/internal/game/offline_test.go
+++ b/backend/internal/game/offline_test.go
@@ -170,6 +170,18 @@ func TestBuildEnemyInstanceForLevel_EncounterStatMultiplier(t *testing.T) {
if out.Attack != 20 || out.Defense != 8 {
t.Fatalf("Attack/Defense: got %d/%d want 20/8", out.Attack, out.Defense)
}
+
+ scaled := BuildEnemyInstanceForLevelScaledOnly(tmpl, 1)
+ if scaled.MaxHP != 50 || scaled.Attack != 10 || scaled.Defense != 4 {
+ t.Fatalf("scaled-only: got hp=%d atk=%d def=%d want 50/10/4", scaled.MaxHP, scaled.Attack, scaled.Defense)
+ }
+ base, afterG := EnemyEncounterStatStages(tmpl, 1)
+ if base.MaxHP != 50 || afterG.MaxHP != 100 {
+ t.Fatalf("stages MaxHP: base=%d afterGlobal=%d", base.MaxHP, afterG.MaxHP)
+ }
+ if afterG.Attack != 20 || afterG.Defense != 8 {
+ t.Fatalf("stages atk/def: got %d/%d want 20/8", afterG.Attack, afterG.Defense)
+ }
}
func TestApplyEnemyEncounterHeroScaling_Unequipped(t *testing.T) {
diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go
index 3d1d591..22b0ba2 100644
--- a/backend/internal/game/rest_test.go
+++ b/backend/internal/game/rest_test.go
@@ -102,7 +102,7 @@ func TestRoadsideRest_TriggersOnLowHP(t *testing.T) {
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
t.Fatalf("expected StateResting, got %s", hm.State)
@@ -128,7 +128,7 @@ func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) {
hm.Hero.State = model.StateWalking
hm.LastEncounterAt = now
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
t.Fatal("should not trigger roadside rest above threshold")
@@ -148,7 +148,7 @@ func TestRoadsideRest_HealsHP(t *testing.T) {
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.Hero.HP <= hpBefore {
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
@@ -171,7 +171,7 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
hm.LastMoveTick = now
pastTimer := hm.RestUntil.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase)
@@ -180,7 +180,7 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
hm.CurrentX = hm.Excursion.StartX
hm.CurrentY = hm.Excursion.StartY
hm.LastMoveTick = pastTimer
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
@@ -198,7 +198,7 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
tick := now.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase)
@@ -215,7 +215,7 @@ func TestRoadsideRest_AttractorWorldMovement(t *testing.T) {
x0, y0 := hm.CurrentX, hm.CurrentY
hm.LastMoveTick = now
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.CurrentX == x0 && hm.CurrentY == y0 {
t.Fatal("expected hero world position to move toward forest attractor during out phase")
}
@@ -242,7 +242,7 @@ func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
tick := now.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
t.Fatalf("expected StateResting, got %s", hm.State)
@@ -268,7 +268,7 @@ func TestAdventureInlineRest_HealsHP(t *testing.T) {
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.Hero.HP <= hpBefore {
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
@@ -293,7 +293,7 @@ func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
hm.beginAdventureInlineRest(now)
tick := now.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after HP target, got %s", hm.State)
@@ -315,7 +315,7 @@ func TestAdventure_ReturnPhaseEndsExcursion(t *testing.T) {
hm.CurrentX = hm.Excursion.AttractorX
hm.CurrentY = hm.Excursion.AttractorY
hm.LastMoveTick = now
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return completes, got %s", hm.State)
@@ -647,7 +647,7 @@ func TestExcursion_FreezesRoadWaypointDuringSession(t *testing.T) {
wildMid := hm.Excursion.OutUntil.Add(hm.Excursion.WildUntil.Sub(hm.Excursion.OutUntil) / 2)
for i := 0; i < 5; i++ {
tick := wildMid.Add(time.Duration(i) * time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase == model.ExcursionNone {
t.Fatalf("excursion ended unexpectedly at tick %v", tick)
}
@@ -672,7 +672,7 @@ func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) {
hm.State = model.StateFighting
hm.Hero.State = model.StateFighting
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateFighting {
t.Fatalf("expected StateFighting unchanged, got %s", hm.State)
@@ -694,7 +694,7 @@ func TestProcessMovementTick_DeadHeroIgnoresWalkingFSM(t *testing.T) {
}
hm.State = model.StateWalking // simulate FSM / snapshot desync
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateDead {
t.Fatalf("expected StateDead after tick, got %s", hm.State)
diff --git a/backend/internal/game/town_tour_test.go b/backend/internal/game/town_tour_test.go
index 1a37a2c..d622052 100644
--- a/backend/internal/game/town_tour_test.go
+++ b/backend/internal/game/town_tour_test.go
@@ -51,7 +51,7 @@ func TestTownTour_WelcomeTimeoutReturnsToWander(t *testing.T) {
AttractorX: 3,
AttractorY: 2,
}
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(50*time.Millisecond), nil, nil, nil, nil, nil, nil)
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(50*time.Millisecond), nil, nil, nil, nil, nil, nil, nil)
if hm.Excursion.TownTourPhase != string(model.TownTourPhaseWander) {
t.Fatalf("expected wander after welcome timeout, got %q", hm.Excursion.TownTourPhase)
}
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 459b5cf..cbbd455 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -110,10 +110,24 @@ type adminHeroDetailResponse struct {
HeroMovement *game.HeroMovement `json:"heroMovement,omitempty"`
}
+// adminCombatLiveJSON is the active engine combat session for admin live WS (enemy is full runtime instance + tuning breakdown).
+type adminCombatLiveJSON struct {
+ Enemy model.Enemy `json:"enemy"`
+ // EnemyStatsBasePreEncounterMult: level-scaled MaxHP/Attack/Defense before encounter multipliers.
+ EnemyStatsBasePreEncounterMult *model.EncounterCombatStatsSnapshot `json:"enemyStatsBasePreEncounterMult,omitempty"`
+ // EnemyStatsAfterGlobalEncounterMult: same after global encounter mult only (before unequipped scaling).
+ EnemyStatsAfterGlobalEncounterMult *model.EncounterCombatStatsSnapshot `json:"enemyStatsAfterGlobalEncounterMult,omitempty"`
+ Multipliers game.EnemyEncounterMultiplierBreakdown `json:"multipliers"`
+ HeroNextAttack time.Time `json:"heroNextAttack"`
+ EnemyNextAttack time.Time `json:"enemyNextAttack"`
+ StartedAt time.Time `json:"startedAt"`
+}
+
// adminWSSnapshot is the admin live WebSocket payload: hero detail + last hero_move (client WS) sample.
type adminWSSnapshot struct {
Hero adminHeroDetailResponse `json:"hero"`
HeroMove *model.HeroMovePayload `json:"heroMove"`
+ Combat *adminCombatLiveJSON `json:"combat,omitempty"`
}
type simulateCombatRequest struct {
@@ -268,7 +282,25 @@ func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (
p := hm.MovePayload(now)
move = &p
}
- return adminWSSnapshot{Hero: detail, HeroMove: move}, nil
+ var combat *adminCombatLiveJSON
+ if h.engine != nil {
+ if cs, ok := h.engine.GetCombat(heroID); ok {
+ multHero := cs.Hero
+ if multHero == nil {
+ multHero = &detail.Hero
+ }
+ combat = &adminCombatLiveJSON{
+ Enemy: cs.Enemy,
+ EnemyStatsBasePreEncounterMult: cs.EnemyStatsBasePreEncounterMult,
+ EnemyStatsAfterGlobalEncounterMult: cs.EnemyStatsAfterGlobalEncounterMult,
+ Multipliers: game.EnemyEncounterMultiplierBreakdownForHero(multHero),
+ HeroNextAttack: cs.HeroNextAttack,
+ EnemyNextAttack: cs.EnemyNextAttack,
+ StartedAt: cs.StartedAt,
+ }
+ }
+ }
+ return adminWSSnapshot{Hero: detail, HeroMove: move, Combat: combat}, nil
}
// ListHeroes returns a paginated list of all heroes.
@@ -1233,7 +1265,7 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) {
writeHeroJSON(w, http.StatusOK, hero)
}
-// ReviveHero force-revives a hero to full HP regardless of current state.
+// ReviveHero applies the same revive rules as the in-game revive button (partial HP, quota counters).
// POST /admin/heroes/{heroId}/revive
func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
@@ -1259,10 +1291,15 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
- hero.HP = hero.MaxHP
- hero.State = model.StateWalking
- hero.Buffs = nil
- hero.Debuffs = nil
+ if !game.IsEffectivelyDead(hero) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "hero is not dead",
+ })
+ return
+ }
+
+ game.ApplyHeroReviveMechanical(hero)
+ game.ApplyPlayerReviveProgressCounters(hero)
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after revive", "hero_id", heroID, "error", err)
@@ -2103,68 +2140,10 @@ func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Reques
h.writeAdminHeroDetail(w, heroAfter)
}
-// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
+// ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest).
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
- heroID, err := parseHeroID(r)
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{
- "error": "invalid heroId: " + err.Error(),
- })
- return
- }
- if h.isHeroInCombat(w, heroID) {
- return
- }
-
- hero, err := h.store.GetByID(r.Context(), heroID)
- if err != nil {
- h.logger.Error("admin: get hero for leave-town", "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
- }
- if hero.State != model.StateResting && hero.State != model.StateInTown {
- writeJSON(w, http.StatusBadRequest, map[string]string{
- "error": "hero is not resting or in town",
- })
- return
- }
-
- if hm := h.engine.GetMovements(heroID); hm != nil {
- out, ok := h.engine.ApplyAdminForceLeaveTown(heroID)
- if !ok || out == nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{
- "error": "cannot leave town (movement state changed?)",
- })
- return
- }
- out.RefreshDerivedCombatStats(time.Now())
- h.logger.Info("admin: force leave town", "hero_id", heroID)
- writeJSON(w, http.StatusOK, out)
- return
- }
-
- hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
- if hm.State != model.StateResting && hm.State != model.StateInTown {
- return fmt.Errorf("hero is not resting or in town")
- }
- hm.LeaveTown(rg, now)
- return nil
- })
- if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
- return
- }
- h.logger.Info("admin: force leave town (offline)", "hero_id", heroID)
- writeJSON(w, http.StatusOK, hero2)
+ h.stopHeroRestOrLeaveTown(w, r, "leave-town")
}
// StartHeroRoadsideRest forces a hero into roadside rest at the current road position.
@@ -2226,9 +2205,14 @@ func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Requ
h.writeAdminHeroDetail(w, hero2)
}
-// StopHeroRest exits a hero from non-town rest (roadside or adventure-inline) back to walking.
+// StopHeroRest ends any rest or in-town pause the engine recognizes (roadside, adventure-inline, town rest, town tour).
// POST /admin/heroes/{heroId}/stop-rest
func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
+ h.stopHeroRestOrLeaveTown(w, r, "stop-rest")
+}
+
+// stopHeroRestOrLeaveTown implements unified “stop resting / leave town” for admin (one semantic; two routes for compatibility).
+func (h *AdminHandler) stopHeroRestOrLeaveTown(w http.ResponseWriter, r *http.Request, logLabel string) {
heroID, err := parseHeroID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
@@ -2236,10 +2220,13 @@ func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
})
return
}
+ if h.isHeroInCombat(w, heroID) {
+ return
+ }
hero, err := h.store.GetByID(r.Context(), heroID)
if err != nil {
- h.logger.Error("admin: get hero for stop-rest", "hero_id", heroID, "error", err)
+ h.logger.Error("admin: get hero for "+logLabel, "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
@@ -2248,33 +2235,50 @@ func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
return
}
- if hm := h.engine.GetMovements(heroID); hm != nil {
- out, ok := h.engine.ApplyAdminStopRest(heroID)
- if !ok || out == nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in roadside/adventure rest"})
- return
- }
- if err := h.store.Save(r.Context(), out); err != nil {
- h.logger.Error("admin: save after stop-rest", "hero_id", heroID, "error", err)
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
+ if h.engine != nil {
+ if hm := h.engine.GetMovements(heroID); hm != nil {
+ out, ok := h.engine.ApplyAdminStopAnyRest(heroID)
+ if !ok || out == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "hero is not in a rest or town state that can be stopped",
+ })
+ return
+ }
+ out.RefreshDerivedCombatStats(time.Now())
+ if err := h.store.Save(r.Context(), out); err != nil {
+ h.logger.Error("admin: save after "+logLabel, "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
+ return
+ }
+ h.logger.Info("admin: "+logLabel+" (online)", "hero_id", heroID)
+ if logLabel == "leave-town" {
+ writeJSON(w, http.StatusOK, out)
+ return
+ }
+ h.writeAdminHeroDetail(w, out)
return
}
- h.logger.Info("admin: stop rest", "hero_id", heroID)
- h.writeAdminHeroDetail(w, out)
- return
}
hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
- if !hm.AdminStopRest(now) {
- return fmt.Errorf("hero is not in roadside/adventure rest")
+ if hm.AdminStopRest(now) {
+ return nil
}
- return nil
+ if hm.State == model.StateResting || hm.State == model.StateInTown {
+ hm.LeaveTown(rg, now)
+ return nil
+ }
+ return fmt.Errorf("hero is not in a rest or town state that can be stopped")
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
- h.logger.Info("admin: stop rest (offline)", "hero_id", heroID)
+ h.logger.Info("admin: "+logLabel+" (offline)", "hero_id", heroID)
+ if logLabel == "leave-town" {
+ writeJSON(w, http.StatusOK, hero2)
+ return
+ }
h.writeAdminHeroDetail(w, hero2)
}
@@ -2413,6 +2417,42 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req
h.writeAdminHeroDetail(w, hm.Hero)
}
+// KillCurrentEnemy applies a lethal hero hit and completes combat like a normal victory (rewards, combat_end, persist).
+// Requires active engine combat (same as random encounter). POST /admin/heroes/{heroId}/kill-current-enemy
+func (h *AdminHandler) KillCurrentEnemy(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.engine == nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine not available"})
+ return
+ }
+ if _, active := h.engine.GetCombat(heroID); !active {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in combat"})
+ return
+ }
+ if h.engine.GetMovements(heroID) == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "hero has no active engine session — connect the game client (WebSocket)",
+ })
+ return
+ }
+ out, ok := h.engine.ApplyAdminLethalEnemyKill(heroID)
+ if !ok || out == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot kill current enemy"})
+ return
+ }
+ if err := h.store.Save(r.Context(), out); err != nil {
+ h.logger.Error("admin: save after kill-current-enemy", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
+ return
+ }
+ h.logger.Info("admin: kill current enemy", "hero_id", heroID)
+ h.writeAdminHeroDetail(w, out)
+}
+
// StopHeroExcursion forces the excursion into the return leg (walk back to road / start point).
// POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
@@ -2555,10 +2595,10 @@ func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
var enemy model.Enemy
if req.EnemyLevel > 0 {
- enemy = game.BuildEnemyInstanceForLevel(tmpl, req.EnemyLevel)
+ enemy = game.BuildEnemyInstanceForLevel(tmpl, req.EnemyLevel, nil)
} else {
// Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
- enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero.Level, nil)
+ enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero, nil)
}
game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy)
combatStart := game.CombatSimDeterministicStart
diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go
index 6144142..bd0a5ea 100644
--- a/backend/internal/handler/game.go
+++ b/backend/internal/handler/game.go
@@ -328,33 +328,29 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
- if hero.State != model.StateDead && hero.HP > 0 {
+ if !game.IsEffectivelyDead(hero) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero is alive (state is not dead and hp > 0)",
})
return
}
- if !hero.SubscriptionActive && hero.ReviveCount >= 2 {
- writeJSON(w, http.StatusForbidden, map[string]string{
- "error": "free revive limit reached (subscribe for unlimited revives)",
+ if err := game.CheckPlayerReviveQuota(hero); err != nil {
+ if errors.Is(err, game.ErrReviveQuotaExceeded) {
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": "free revive limit reached (subscribe for unlimited revives)",
+ })
+ return
+ }
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": err.Error(),
})
return
}
- // Track death stats (the hero is dead, this is the first time we process it server-side).
- hero.TotalDeaths++
- hero.KillsSinceDeath = 0
-
- hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
- if hero.HP < 1 {
- hero.HP = 1
- }
- hero.State = model.StateWalking
now := time.Now()
- hero.Buffs = model.RemoveBuffType(hero.Buffs, model.BuffResurrection)
- hero.Debuffs = nil
- hero.ReviveCount++
+ game.ApplyHeroReviveMechanical(hero)
+ game.ApplyPlayerReviveProgressCounters(hero)
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after revive", "hero_id", hero.ID, "error", err)
@@ -364,6 +360,10 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
+ if h.engine != nil {
+ h.engine.ApplyAdminHeroRevive(hero)
+ }
+
h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
@@ -417,6 +417,13 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
}
}
if h.isHeroInTown(r.Context(), posX, posY) {
+ h.logger.Info("rest encounter: no encounter",
+ "hero_id", hero.ID,
+ "hero_level", hero.Level,
+ "reason", "in_town",
+ "pos_x", posX,
+ "pos_y", posY,
+ )
writeJSON(w, http.StatusOK, map[string]string{
"type": "no_encounter",
"reason": "in_town",
@@ -428,7 +435,14 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
cfg := tuning.Get()
h.encounterMu.Lock()
if t, ok := h.lastCombatEncounterAt[hero.ID]; ok && now.Sub(t) < time.Duration(cfg.RESTEncounterCooldownMs)*time.Millisecond {
+ remain := time.Duration(cfg.RESTEncounterCooldownMs)*time.Millisecond - now.Sub(t)
h.encounterMu.Unlock()
+ h.logger.Info("rest encounter: no encounter",
+ "hero_id", hero.ID,
+ "hero_level", hero.Level,
+ "reason", "cooldown",
+ "cooldown_remaining_ms", remain.Milliseconds(),
+ )
writeJSON(w, http.StatusOK, map[string]string{
"type": "no_encounter",
"reason": "cooldown",
@@ -440,6 +454,12 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
// 10% chance to encounter a wandering NPC instead of an enemy.
if rand.Float64() < cfg.RESTEncounterNPCChance {
cost := game.WanderingMerchantCost(hero.Level)
+ h.logger.Info("rest encounter: wandering merchant",
+ "hero_id", hero.ID,
+ "hero_level", hero.Level,
+ "cost", cost,
+ "npc_chance", cfg.RESTEncounterNPCChance,
+ )
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant}})
h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now
@@ -458,6 +478,20 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
}
enemy := pickEnemyForHero(hero)
+ mult := game.EnemyEncounterMultiplierBreakdownForHero(hero)
+ h.logger.Info("rest encounter: enemy generated",
+ "hero_id", hero.ID,
+ "hero_level", hero.Level,
+ "enemy_slug", enemy.Slug,
+ "enemy_level", enemy.Level,
+ "enemy_max_hp", enemy.MaxHP,
+ "enemy_attack", enemy.Attack,
+ "enemy_defense", enemy.Defense,
+ "enemy_speed", enemy.Speed,
+ "mult_global_encounter", mult.GlobalEncounterStatMultiplier,
+ "mult_unequipped_config", mult.UnequippedHeroStatMultiplier,
+ "mult_unequipped_applied", mult.UnequippedScalingApplied,
+ )
h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock()
@@ -830,6 +864,15 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
if gearPer < 0 {
gearPer = tuning.DefaultValues().MerchantTownGearCostPerTownLevel
}
+ rel := changelog.ForVersion(version.Version)
+ showChangelog := rel != nil // no DB row yet → changelog was never ack'd
+ var changelogPayload any
+ if rel != nil {
+ changelogPayload = map[string]any{
+ "title": rel.Title,
+ "items": rel.Items,
+ }
+ }
writeJSON(w, http.StatusOK, map[string]any{
"hero": nil,
"needsName": true,
@@ -841,8 +884,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version,
- "showChangelog": false,
- "changelog": nil,
+ "showChangelog": showChangelog,
+ "changelog": changelogPayload,
})
return
}
@@ -898,16 +941,13 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
}
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
- if !simFrozen && (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond {
- hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
- if hero.HP < 1 {
- hero.HP = 1
- }
- hero.State = model.StateWalking
- hero.Debuffs = nil
+ if !simFrozen && game.IsEffectivelyDead(hero) && time.Since(hero.UpdatedAt) > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond {
+ game.ApplyHeroReviveMechanical(hero)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseAutoReviveHours}})
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after auto-revive", "hero_id", hero.ID, "error", err)
+ } else if h.engine != nil {
+ h.engine.ApplyAdminHeroRevive(hero)
}
}
diff --git a/backend/internal/model/combat.go b/backend/internal/model/combat.go
index 00fbbe5..c4706dc 100644
--- a/backend/internal/model/combat.go
+++ b/backend/internal/model/combat.go
@@ -13,11 +13,22 @@ const (
StateInTown GameState = "in_town" // in town, interacting with NPCs
)
+// EncounterCombatStatsSnapshot is MaxHP/Attack/Defense for admin/debug: compare base vs multiplier stages.
+type EncounterCombatStatsSnapshot struct {
+ MaxHP int `json:"maxHp"`
+ Attack int `json:"attack"`
+ Defense int `json:"defense"`
+}
+
// CombatState holds the state of an active combat encounter.
type CombatState struct {
HeroID int64 `json:"heroId"`
Hero *Hero `json:"-"` // hero reference, not serialised to avoid circular refs
Enemy Enemy `json:"enemy"`
+ // EnemyStatsBasePreEncounterMult: level-scaled stats only (no encounter multipliers).
+ EnemyStatsBasePreEncounterMult *EncounterCombatStatsSnapshot `json:"enemyStatsBasePreEncounterMult,omitempty"`
+ // EnemyStatsAfterGlobalEncounterMult: after tuning EnemyEncounterStatMultiplier only (before unequipped-hero scaling).
+ EnemyStatsAfterGlobalEncounterMult *EncounterCombatStatsSnapshot `json:"enemyStatsAfterGlobalEncounterMult,omitempty"`
HeroNextAttack time.Time `json:"heroNextAttack"`
EnemyNextAttack time.Time `json:"enemyNextAttack"`
StartedAt time.Time `json:"startedAt"`
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 9426d81..9c6b5fe 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -100,6 +100,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/town-tour-approach-npc", adminH.TownTourApproachNPC)
r.Post("/heroes/{heroId}/trigger-random-encounter", adminH.TriggerRandomEncounter)
+ r.Post("/heroes/{heroId}/kill-current-enemy", adminH.KillCurrentEnemy)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)
diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go
index efa5b8a..b4cf1b4 100644
--- a/backend/internal/storage/hero_store.go
+++ b/backend/internal/storage/hero_store.go
@@ -617,7 +617,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
return fmt.Errorf("update hero inventory: %w", err)
}
- s.logger.Info("saved hero", "hero", hero)
+ s.logger.Debug("saved hero", "hero", hero)
return nil
}
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index 7f8d78c..e85f7c5 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -303,7 +303,7 @@ func DefaultValues() Values {
TownTourRestMinMs: 240_000,
TownTourRestMaxMs: 360_000,
WanderingMerchantPromptTimeoutMs: 15_000,
- MerchantCostBase: 20,
+ MerchantCostBase: 900,
MerchantCostPerLevel: 5,
MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.62,
diff --git a/backend/migrations/000001_init.sql b/backend/migrations/000001_init.sql
index a8bb179..fd6cb92 100644
--- a/backend/migrations/000001_init.sql
+++ b/backend/migrations/000001_init.sql
@@ -9330,7 +9330,7 @@ INSERT INTO public.road_waypoints VALUES (11334, 50, 319, 3799.9500000000003, 72
-- Data for Name: runtime_config; Type: TABLE DATA; Schema: public; Owner: -
--
-INSERT INTO public.runtime_config VALUES (true, '{"agilityCoef": 0.09, "goldEpicMax": 120, "goldEpicMin": 51, "goldRareMax": 50, "goldRareMin": 21, "npcCostHeal": 100, "autoSellEpic": 60, "autoSellRare": 20, "baseMoveSpeed": 1, "goldCommonMax": 5, "goldCommonMin": 0, "goldLootScale": 0.5, "levelUpHpBase": 2, "npcCostPotion": 200, "townRestMaxMs": 1200000, "townRestMinMs": 300000, "autoSellCommon": 3, "debuffProcBurn": 0.18, "debuffProcSlow": 0.25, "debuffProcStun": 0.25, "levelUpHpEvery": 4, "lootChanceEpic": 0.003, "lootChanceRare": 0.02, "lowHpThreshold": 0.25, "maxAttackSpeed": 5, "maxRevivesFree": 1, "minAttackSpeed": 0.1, "townNpcPauseMs": 30000, "townNpcRetryMs": 450, "xpCurveMidBase": 1450, "goldUncommonMax": 20, "goldUncommonMin": 6, "ilvlFactorSlope": 0.03, "levelUpAgiEvery": 20, "levelUpAtkEvery": 4, "levelUpConEvery": 14, "levelUpDefEvery": 5, "levelUpStrEvery": 12, "reviveHpPercent": 0.5, "xpCurveLateBase": 23000, "xpCurveMidScale": 1.15, "autoSellUncommon": 8, "debuffProcFreeze": 0.2, "debuffProcPoison": 0.1, "enemyBurstEveryN": 5, "enemyChainEveryN": 6, "enemyDodgeChance": 0.14, "enemyScaleBandHp": 0.062, "enemyScaleBandXp": 0.05, "goldLegendaryMax": 300, "goldLegendaryMin": 121, "levelUpLuckEvery": 100, "lootChanceCommon": 0.4, "lootHistoryLimit": 50, "merchantCostBase": 20, "potionDropChance": 0.05, "townNpcRollMaxMs": 2600, "townNpcRollMinMs": 800, "townNpcWalkSpeed": 3, "xpCurveEarlyBase": 180, "xpCurveLateScale": 1.1, "autoReviveAfterMs": 3600000, "autoSellLegendary": 180, "combatDamageScale": 1.0, "debuffProcIceSlow": 0.2, "enemyRegenDefault": 0.0012, "enemyScaleBandAtk": 0.044, "enemyScaleBandDef": 0.038, "equipmentDropBase": 0.15, "heroCritChanceCap": 0.12, "potionHealPercent": 0.3, "questOffersPerNPC": 2, "roadsideRestMaxMs": 600000, "roadsideRestMinMs": 240000, "townArrivalRadius": 0.5, "xpCurveEarlyScale": 1.28, "adventureWildMaxMs": 2960000, "adventureWildMinMs": 560000, "autoEquipThreshold": 1.03, "buffChargePeriodMs": 86400000, "buffRefillPriceRub": 50, "enemyCritChanceCap": 0.2, "enemyScaleBandGold": 0.05, "heroBlockChanceCap": 0.2, "lootChanceUncommon": 0.1, "luckBuffMultiplier": 2.5, "movementTickRateMs": 1000, "positionSyncRateMs": 10000, "roadsideRestExitHp": 0.7, "roadsideRestGoInMs": 3200, "summonCycleSeconds": 18, "townNpcVisitChance": 0.78, "adventureCooldownMs": 300000, "adventureMaxLateral": 20, "combatDamageRollMax": 1.10, "combatDamageRollMin": 0.60, "enemyScaleOvercapHp": 0.031, "enemyScaleOvercapXp": 0.03, "lootChanceLegendary": 0.0005, "minAttackIntervalMs": 250, "npcCostNearbyRadius": 3, "roadsideRestLateral": 1.15, "summonDamageDivisor": 10, "townRestHpPerSecond": 0.002, "adventureStartChance": 0.0001, "combatPaceMultiplier": 14, "enemyBurstMultiplier": 1.5, "enemyChainMultiplier": 3, "enemyScaleOvercapAtk": 0.024, "enemyScaleOvercapDef": 0.020, "maxRevivesSubscriber": 2, "merchantCostPerLevel": 5, "rarityMultiplierEpic": 1.52, "rarityMultiplierRare": 1.3, "roadsideRestDepthMax": 25, "roadsideRestReturnMs": 3200, "roadsideThoughtMaxMs": 50000, "roadsideThoughtMinMs": 30000, "townNpcLogIntervalMs": 5000, "townNpcStandoffWorld": 0.65, "adventureRestTargetHp": 0.7, "encounterActivityBase": 0.035, "enemyScaleOvercapGold": 0.025, "startAdventurePerTick": 0.00003, "townNpcApproachChance": 1, "townNpcInteractChance": 0.65, "adventureDurationMaxMs": 1200000, "adventureDurationMinMs": 900000, "adventureOutDurationMs": 20000, "enemyCombatDamageScale": 1.0, "enemyCriticalMinChance": 0.15, "enemyRegenBattleLizard": 0.0005, "enemyRegenForestWarden": 0.00010, "enemyRegenSkeletonKing": 0.00003, "potionAutoUseThreshold": 0.3, "questOfferRefreshHours": 2, "rarityMultiplierCommon": 1, "restEncounterNpcChance": 0.1, "subscriptionDurationMs": 604800000, "townAfterNpcRestChance": 0.78, "encounterCooldownBaseMs": 12000, "restEncounterCooldownMs": 30000, "roadsideRestHpPerSecond": 0.003, "rollIlvlEliteBaseChance": 0.4, "adventureDepthWorldUnits": 20, "adventureRestHpPerSecond": 0.004, "enemyCombatDamageRollMax": 1.0, "enemyCombatDamageRollMin": 0.82, "rarityMultiplierUncommon": 1.12, "adventureReturnDurationMs": 20000, "adventureWanderSpeedRatio": 0.85, "heroBlockChancePerDefense": 0.0025, "merchantTownAutoSellShare": 0.3, "rarityMultiplierLegendary": 1.78, "adventureReturnWildnessMin": 0.35, "monsterEncounterWeightBase": 0.62, "resurrectionRefillPriceRub": 150, "rollIlvlElitePlusOneChance": 0.4, "subscriptionWeeklyPriceRub": 199, "merchantEncounterWeightBase": 0.02, "roadsideRestDepthWorldUnits": 12, "adventureEncounterCooldownMs": 6000, "freeBuffActivationsPerPeriod": 2, "enemyAttackIntervalMultiplier": 1.5, "adventureReturnEncounterEnabled": true, "adventureWildernessRampFraction": 0.12, "monsterEncounterWeightWildBonus": 0.18, "merchantEncounterWeightRoadBonus": 0.05, "wanderingMerchantPromptTimeoutMs": 15000, "adventureForwardSpeedWildFraction": 0.07}', '2026-03-31 16:27:14.86085+00');
+INSERT INTO public.runtime_config VALUES (true, '{"agilityCoef": 0.09, "goldEpicMax": 120, "goldEpicMin": 51, "goldRareMax": 50, "goldRareMin": 21, "npcCostHeal": 100, "autoSellEpic": 60, "autoSellRare": 20, "baseMoveSpeed": 1, "goldCommonMax": 5, "goldCommonMin": 0, "goldLootScale": 0.5, "levelUpHpBase": 2, "npcCostPotion": 200, "townRestMaxMs": 1200000, "townRestMinMs": 300000, "autoSellCommon": 3, "debuffProcBurn": 0.18, "debuffProcSlow": 0.25, "debuffProcStun": 0.25, "levelUpHpEvery": 4, "lootChanceEpic": 0.003, "lootChanceRare": 0.02, "lowHpThreshold": 0.25, "maxAttackSpeed": 5, "maxRevivesFree": 1, "minAttackSpeed": 0.1, "townNpcPauseMs": 30000, "townNpcRetryMs": 450, "xpCurveMidBase": 1450, "goldUncommonMax": 20, "goldUncommonMin": 6, "ilvlFactorSlope": 0.03, "levelUpAgiEvery": 20, "levelUpAtkEvery": 4, "levelUpConEvery": 14, "levelUpDefEvery": 5, "levelUpStrEvery": 12, "reviveHpPercent": 0.5, "xpCurveLateBase": 23000, "xpCurveMidScale": 1.15, "autoSellUncommon": 8, "debuffProcFreeze": 0.2, "debuffProcPoison": 0.1, "enemyBurstEveryN": 5, "enemyChainEveryN": 6, "enemyDodgeChance": 0.14, "enemyScaleBandHp": 0.062, "enemyScaleBandXp": 0.05, "goldLegendaryMax": 300, "goldLegendaryMin": 121, "levelUpLuckEvery": 100, "lootChanceCommon": 0.4, "lootHistoryLimit": 50, "merchantCostBase": 900, "potionDropChance": 0.05, "townNpcRollMaxMs": 2600, "townNpcRollMinMs": 800, "townNpcWalkSpeed": 3, "xpCurveEarlyBase": 180, "xpCurveLateScale": 1.1, "autoReviveAfterMs": 3600000, "autoSellLegendary": 180, "combatDamageScale": 1.0, "debuffProcIceSlow": 0.2, "enemyRegenDefault": 0.0012, "enemyScaleBandAtk": 0.044, "enemyScaleBandDef": 0.038, "equipmentDropBase": 0.15, "heroCritChanceCap": 0.12, "potionHealPercent": 0.3, "questOffersPerNPC": 2, "roadsideRestMaxMs": 600000, "roadsideRestMinMs": 240000, "townArrivalRadius": 0.5, "xpCurveEarlyScale": 1.28, "adventureWildMaxMs": 2960000, "adventureWildMinMs": 560000, "autoEquipThreshold": 1.03, "buffChargePeriodMs": 86400000, "buffRefillPriceRub": 50, "enemyCritChanceCap": 0.2, "enemyScaleBandGold": 0.05, "heroBlockChanceCap": 0.2, "lootChanceUncommon": 0.1, "luckBuffMultiplier": 2.5, "movementTickRateMs": 1000, "positionSyncRateMs": 10000, "roadsideRestExitHp": 0.7, "roadsideRestGoInMs": 3200, "summonCycleSeconds": 18, "townNpcVisitChance": 0.78, "adventureCooldownMs": 300000, "adventureMaxLateral": 20, "combatDamageRollMax": 1.10, "combatDamageRollMin": 0.60, "enemyScaleOvercapHp": 0.031, "enemyScaleOvercapXp": 0.03, "lootChanceLegendary": 0.0005, "minAttackIntervalMs": 250, "npcCostNearbyRadius": 3, "roadsideRestLateral": 1.15, "summonDamageDivisor": 10, "townRestHpPerSecond": 0.002, "adventureStartChance": 0.0001, "combatPaceMultiplier": 14, "enemyBurstMultiplier": 1.5, "enemyChainMultiplier": 3, "enemyScaleOvercapAtk": 0.024, "enemyScaleOvercapDef": 0.020, "maxRevivesSubscriber": 2, "merchantCostPerLevel": 5, "rarityMultiplierEpic": 1.52, "rarityMultiplierRare": 1.3, "roadsideRestDepthMax": 25, "roadsideRestReturnMs": 3200, "roadsideThoughtMaxMs": 50000, "roadsideThoughtMinMs": 30000, "townNpcLogIntervalMs": 5000, "townNpcStandoffWorld": 0.65, "adventureRestTargetHp": 0.7, "encounterActivityBase": 0.035, "enemyScaleOvercapGold": 0.025, "startAdventurePerTick": 0.00003, "townNpcApproachChance": 1, "townNpcInteractChance": 0.65, "adventureDurationMaxMs": 1200000, "adventureDurationMinMs": 900000, "adventureOutDurationMs": 20000, "enemyCombatDamageScale": 1.0, "enemyCriticalMinChance": 0.15, "enemyRegenBattleLizard": 0.0005, "enemyRegenForestWarden": 0.00010, "enemyRegenSkeletonKing": 0.00003, "potionAutoUseThreshold": 0.3, "questOfferRefreshHours": 2, "rarityMultiplierCommon": 1, "restEncounterNpcChance": 0.1, "subscriptionDurationMs": 604800000, "townAfterNpcRestChance": 0.78, "encounterCooldownBaseMs": 12000, "restEncounterCooldownMs": 30000, "roadsideRestHpPerSecond": 0.003, "rollIlvlEliteBaseChance": 0.4, "adventureDepthWorldUnits": 20, "adventureRestHpPerSecond": 0.004, "enemyCombatDamageRollMax": 1.0, "enemyCombatDamageRollMin": 0.82, "rarityMultiplierUncommon": 1.12, "adventureReturnDurationMs": 20000, "adventureWanderSpeedRatio": 0.85, "heroBlockChancePerDefense": 0.0025, "merchantTownAutoSellShare": 0.3, "rarityMultiplierLegendary": 1.78, "adventureReturnWildnessMin": 0.35, "monsterEncounterWeightBase": 0.62, "resurrectionRefillPriceRub": 150, "rollIlvlElitePlusOneChance": 0.4, "subscriptionWeeklyPriceRub": 199, "merchantEncounterWeightBase": 0.02, "roadsideRestDepthWorldUnits": 12, "adventureEncounterCooldownMs": 6000, "freeBuffActivationsPerPeriod": 2, "enemyAttackIntervalMultiplier": 1.5, "adventureReturnEncounterEnabled": true, "adventureWildernessRampFraction": 0.12, "monsterEncounterWeightWildBonus": 0.18, "merchantEncounterWeightRoadBonus": 0.05, "wanderingMerchantPromptTimeoutMs": 15000, "adventureForwardSpeedWildFraction": 0.07}', '2026-03-31 16:27:14.86085+00');
--