Compare commits

..

No commits in common. 'a138b6583d2a81b4d486d1fc85ba5f7d0e9d08e0' and 'c22fb24239a334c2430c436709f67b699c8abf3f' have entirely different histories.

@ -2282,11 +2282,11 @@
<button type="button" class="btn warn" onclick="withAction(() => heroAction('force-death',{}))" title="HP 0, state dead, ends combat; counts as a death if the hero was alive">Режим смерти</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('start-rest',{}, true))" title="Town rest (same duration as normal town rest)">Start rest (town)</button>
<button type="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 type="button" class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Сервер снимает отдых: придорожный/лесной inline, отдых в городе или выход из town tour — в зависимости от состояния">Закончить отдых</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('start-adventure',{}, true))" title="Force mini-adventure (excursion) while walking on road">Start adventure</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('stop-adventure',{}, true))" title="Force return leg: walk back to road / rest start (excursion continues until arrival)">Stop adventure</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('leave-town',{}))">Leave Town</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('trigger-random-encounter',{}))" title="Серверный бой со случайным монстром (как на дороге). Нужен подключённый клиент (WS), герой не в бою, не в городе и не в отдыхе">Встреча (случайный монстр)</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('kill-current-enemy',{}))" title="Летальный удар от имени героя; награды и combat_end как при победе. Герой в бою, сессия в движке (WS)">Убить текущего монстра</button>
</div>
<p class="muted" style="margin-top:8px;margin-bottom:0">Roadside / adventure: герой жив, не в бою; adventure — <kbd>StateWalking</kbd> на дороге.</p>
<div class="hero-teleport-row">

@ -99,9 +99,6 @@ 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{
@ -560,6 +557,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
}
// handleRevive processes the revive client command.
func (e *Engine) handleNPCAlmsAccept(msg IncomingMessage) {
e.mu.RLock()
h := e.npcAlmsHandler
@ -589,7 +587,6 @@ 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()
@ -601,17 +598,25 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
}
hero := hm.Hero
if !IsEffectivelyDead(hero) {
if hero.HP > 0 && hm.State != model.StateDead {
e.sendError(msg.HeroID, "not_dead", "hero is not dead")
return
}
if err := CheckPlayerReviveQuota(hero); err != nil {
e.sendError(msg.HeroID, "revive_quota", "free revive limit reached (subscribe for unlimited revives)")
return
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 {
hero.HP = 1
}
hero.State = model.StateWalking
hero.Debuffs = nil
hero.ReviveCount++
hm.State = model.StateWalking
hm.LastMoveTick = time.Now()
hm.refreshSpeed(time.Now())
ApplyHeroReviveMechanical(hero)
ApplyPlayerReviveProgressCounters(hero)
// Remove any active combat.
delete(e.combats, msg.HeroID)
// Persist revive to DB immediately so disconnect doesn't revert it.
if e.heroStore != nil {
@ -620,11 +625,12 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
}
}
if e.adventureLog != nil {
e.adventureLog(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
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})
}
e.applyResidentReviveSyncLocked(hero)
}
// sendError sends an error envelope to a hero.
@ -946,98 +952,6 @@ 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()
@ -1140,6 +1054,8 @@ 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)
@ -1156,19 +1072,6 @@ 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
@ -1190,6 +1093,16 @@ 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{
@ -1206,29 +1119,11 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
})
}
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,
"enemy", enemy.Name,
)
}
}
// StopCombat removes a combat session.
func (e *Engine) StopCombat(heroID int64) {
@ -1339,12 +1234,15 @@ func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
e.ApplyPersistedHeroSnapshot(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) {
// 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()
delete(e.combats, hero.ID)
@ -1386,18 +1284,6 @@ func (e *Engine) applyResidentReviveSyncLocked(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) {
@ -1845,7 +1731,12 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
if now.Sub(h.UpdatedAt) <= gap {
continue
}
ApplyHeroReviveMechanical(h)
h.HP = int(float64(h.MaxHP) * tuning.Get().ReviveHpPercent)
if h.HP < 1 {
h.HP = 1
}
h.State = model.StateWalking
h.Debuffs = nil
hm.State = model.StateWalking
hm.SyncToHero()
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -1864,7 +1755,6 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err)
}
cancelSave()
e.applyResidentReviveSyncLocked(h)
}
}
@ -1892,10 +1782,7 @@ 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, e.logger)
if hm.State != model.StateResting {
hm.lastRestHealPersistAt = time.Time{}
}
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil)
if e.heroStore == nil || hm.Hero == nil {
continue
}
@ -1911,27 +1798,8 @@ 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 {

@ -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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateFighting {
t.Fatalf("expected StateFighting unchanged, got %s", hm.State)

@ -2,10 +2,8 @@ package game
import (
"fmt"
"log/slog"
"math"
"math/rand"
"strings"
"time"
"github.com/denisovdennis/autohero/internal/model"
@ -123,10 +121,6 @@ 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
}
@ -1118,20 +1112,19 @@ func WanderingMerchantCost(level int) int64 {
}
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
// 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) {
func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
cfg := tuning.Get()
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false, model.Enemy{}, false, "skip_no_road"
return false, model.Enemy{}, false
}
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
return false, model.Enemy{}, false, "skip_in_town"
return false, model.Enemy{}, false
}
if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
return false, model.Enemy{}, false, "skip_cooldown"
return false, model.Enemy{}, false
}
if rand.Float64() >= cfg.EncounterActivityBase {
return false, model.Enemy{}, false, "skip_activity"
return false, model.Enemy{}, false
}
monsterW := cfg.MonsterEncounterWeightBase
merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus
@ -1139,9 +1132,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, "monster"
return true, e, true
}
return false, model.Enemy{}, true, "wandering_merchant"
return false, model.Enemy{}, true
}
// EnterTown transitions the hero into the destination town: town tour excursion (StateInTown) when there
@ -1348,25 +1341,6 @@ 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) {
@ -1950,17 +1924,17 @@ func (hm *HeroMovement) applyRestHealTick(dt float64) {
}
}
func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool, outcome string) {
func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
cfg := tuning.Get()
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
return false, model.Enemy{}, false, "skip_in_town"
return false, model.Enemy{}, false
}
cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond
if now.Sub(hm.LastEncounterAt) < cooldown {
return false, model.Enemy{}, false, "skip_cooldown"
return false, model.Enemy{}, false
}
if rand.Float64() >= cfg.EncounterActivityBase {
return false, model.Enemy{}, false, "skip_activity"
return false, model.Enemy{}, false
}
wildness := hm.excursionWildness(now)
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness
@ -1969,44 +1943,9 @@ func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph)
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForHero(hm.Hero)
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 true, e, true
}
return false, model.Enemy{}, true
}
func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
@ -2025,7 +1964,6 @@ 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,
@ -2037,7 +1975,6 @@ func ProcessSingleHeroMovementTick(
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
townTourOffline TownTourOfflineAtNPC,
tickLog *slog.Logger,
) {
if graph == nil {
return
@ -2225,14 +2162,6 @@ 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)
@ -2242,8 +2171,7 @@ func ProcessSingleHeroMovementTick(
return
}
if onEncounter != nil || onMerchantEncounter != nil {
monster, enemy, hit, encOutcome := hm.rollAdventureEncounter(now, graph)
logMovementEncounterRoll(tickLog, heroID, "adventure", encOutcome, enemy, hm, now)
monster, enemy, hit := hm.rollAdventureEncounter(now, graph)
if hit {
if monster && onEncounter != nil {
hm.LastEncounterAt = now
@ -2295,22 +2223,6 @@ 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]
@ -2342,14 +2254,6 @@ 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)
@ -2360,8 +2264,7 @@ func ProcessSingleHeroMovementTick(
canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
monster, enemy, hit, encOutcome := hm.rollRoadEncounter(now, graph)
logMovementEncounterRoll(tickLog, heroID, "road", encOutcome, enemy, hm, now)
monster, enemy, hit := hm.rollRoadEncounter(now, graph)
if hit {
if monster {
if onEncounter != nil {
@ -2379,7 +2282,7 @@ func ProcessSingleHeroMovementTick(
NPCID: 0,
NPCName: "Wandering Merchant",
NPCNameKey: model.WanderingMerchantNPCKey,
Role: "wandering merchant",
Role: "alms",
DialogueKey: model.WanderingMerchantDialogueKey,
Cost: cost,
})

@ -118,8 +118,13 @@ 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 IsEffectivelyDead(hero) && now.Sub(hero.UpdatedAt) > gap {
ApplyHeroReviveMechanical(hero)
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
s.addLog(ctx, hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseAutoReviveAfterSec,
@ -221,7 +226,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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineTownTour)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break
}
@ -513,8 +518,8 @@ func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.En
} else {
picked = candidates[rand.Intn(len(candidates))]
}
e := buildEnemyInstance(picked, hero, rng)
e := buildEnemyInstance(picked, level, rng)
ApplyEnemyEncounterHeroScaling(hero, &e)
return e
}
@ -594,28 +599,35 @@ func enemyInstanceLevel(baseLevel, heroLevel int, variance float64, maxHeroDiff
return minL + rand.Intn(maxL-minL+1)
}
func buildEnemyInstance(tmpl model.Enemy, hero *model.Hero, rng *rand.Rand,) model.Enemy {
func buildEnemyInstance(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy {
picked := tmpl
instanceLevel := enemyInstanceLevel(picked.BaseLevel, hero.Level, picked.LevelVariance, picked.MaxHeroLevelDiff, rng)
return BuildEnemyInstanceForLevel(picked, instanceLevel, hero)
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)
}
// 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, hero *model.Hero, rng *rand.Rand) model.Enemy {
return buildEnemyInstance(tmpl, hero, rng)
func BuildEnemyInstanceForEncounter(tmpl model.Enemy, heroLevel int, rng *rand.Rand) model.Enemy {
return buildEnemyInstance(tmpl, heroLevel, 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, nil)
return BuildEnemyInstanceForLevel(tmpl, heroLevel)
}
// 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 {
// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy {
picked := tmpl
baseLevel := picked.BaseLevel
if baseLevel <= 0 {
@ -642,38 +654,15 @@ func BuildEnemyInstanceForLevelScaledOnly(tmpl model.Enemy, level int) model.Ene
}
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(&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, gMult)
}
applyEnemyEncounterCombatMult(&picked, m)
return picked
}
@ -693,14 +682,7 @@ 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 {
h.EnsureGearMap()
c := 0
for _, it := range h.Gear {
if it != nil {
c++
}
}
return c > 5
return HeroHasEquippedGear(h)
}
func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
@ -713,33 +695,6 @@ 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) {

@ -170,18 +170,6 @@ 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) {

@ -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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), 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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateDead {
t.Fatalf("expected StateDead after tick, got %s", hm.State)

@ -1,59 +0,0 @@
package game
import (
"errors"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
var (
// ErrReviveQuotaExceeded is returned when a non-subscriber has used their free revives.
ErrReviveQuotaExceeded = errors.New("revive quota exceeded")
// ErrReviveNotDead is returned when revive is requested for a living hero.
ErrReviveNotDead = errors.New("hero is not dead")
)
// IsEffectivelyDead is true when the hero must be revived to keep playing.
func IsEffectivelyDead(hero *model.Hero) bool {
if hero == nil {
return false
}
return hero.State == model.StateDead || hero.HP <= 0
}
// CheckPlayerReviveQuota enforces the same limits as POST /hero/revive (non-subscribers only).
func CheckPlayerReviveQuota(hero *model.Hero) error {
if hero == nil {
return ErrReviveNotDead
}
if !hero.SubscriptionActive && hero.ReviveCount >= 2 {
return ErrReviveQuotaExceeded
}
return nil
}
// ApplyHeroReviveMechanical sets partial HP, walking state, clears debuffs, and consumes resurrection buff.
// Used for timeout auto-revive, offline catch-up, and as the base step for button/admin revive.
func ApplyHeroReviveMechanical(hero *model.Hero) {
if hero == nil {
return
}
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 {
hero.HP = 1
}
hero.State = model.StateWalking
hero.Buffs = model.RemoveBuffType(hero.Buffs, model.BuffResurrection)
hero.Debuffs = nil
}
// ApplyPlayerReviveProgressCounters matches POST /api/v1/hero/revive side effects (quota uses ReviveCount).
func ApplyPlayerReviveProgressCounters(hero *model.Hero) {
if hero == nil {
return
}
hero.TotalDeaths++
hero.KillsSinceDeath = 0
hero.ReviveCount++
}

@ -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, nil)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(50*time.Millisecond), 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)
}

@ -110,24 +110,10 @@ 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 {
@ -282,25 +268,7 @@ func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (
p := hm.MovePayload(now)
move = &p
}
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
return adminWSSnapshot{Hero: detail, HeroMove: move}, nil
}
// ListHeroes returns a paginated list of all heroes.
@ -1265,7 +1233,7 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) {
writeHeroJSON(w, http.StatusOK, hero)
}
// ReviveHero applies the same revive rules as the in-game revive button (partial HP, quota counters).
// ReviveHero force-revives a hero to full HP regardless of current state.
// POST /admin/heroes/{heroId}/revive
func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
@ -1291,15 +1259,10 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
if !game.IsEffectivelyDead(hero) {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero is not dead",
})
return
}
game.ApplyHeroReviveMechanical(hero)
game.ApplyPlayerReviveProgressCounters(hero)
hero.HP = hero.MaxHP
hero.State = model.StateWalking
hero.Buffs = nil
hero.Debuffs = nil
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("admin: save hero after revive", "hero_id", heroID, "error", err)
@ -2140,10 +2103,68 @@ func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Reques
h.writeAdminHeroDetail(w, heroAfter)
}
// ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest).
// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
h.stopHeroRestOrLeaveTown(w, r, "leave-town")
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)
}
// StartHeroRoadsideRest forces a hero into roadside rest at the current road position.
@ -2205,14 +2226,9 @@ func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Requ
h.writeAdminHeroDetail(w, hero2)
}
// StopHeroRest ends any rest or in-town pause the engine recognizes (roadside, adventure-inline, town rest, town tour).
// StopHeroRest exits a hero from non-town rest (roadside or adventure-inline) back to walking.
// 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{
@ -2220,13 +2236,10 @@ func (h *AdminHandler) stopHeroRestOrLeaveTown(w http.ResponseWriter, r *http.Re
})
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 "+logLabel, "hero_id", heroID, "error", err)
h.logger.Error("admin: get hero for stop-rest", "hero_id", heroID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
return
}
@ -2235,50 +2248,33 @@ func (h *AdminHandler) stopHeroRestOrLeaveTown(w http.ResponseWriter, r *http.Re
return
}
if h.engine != nil {
if hm := h.engine.GetMovements(heroID); hm != nil {
out, ok := h.engine.ApplyAdminStopAnyRest(heroID)
out, ok := h.engine.ApplyAdminStopRest(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",
})
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in roadside/adventure rest"})
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)
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"})
return
}
h.logger.Info("admin: "+logLabel+" (online)", "hero_id", heroID)
if logLabel == "leave-town" {
writeJSON(w, http.StatusOK, 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 nil
if !hm.AdminStopRest(now) {
return fmt.Errorf("hero is not in roadside/adventure rest")
}
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: "+logLabel+" (offline)", "hero_id", heroID)
if logLabel == "leave-town" {
writeJSON(w, http.StatusOK, hero2)
return
}
h.logger.Info("admin: stop rest (offline)", "hero_id", heroID)
h.writeAdminHeroDetail(w, hero2)
}
@ -2417,42 +2413,6 @@ 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) {
@ -2595,10 +2555,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, nil)
enemy = game.BuildEnemyInstanceForLevel(tmpl, req.EnemyLevel)
} else {
// Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero, nil)
enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero.Level, nil)
}
game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy)
combatStart := game.CombatSimDeterministicStart

@ -328,29 +328,33 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
return
}
if !game.IsEffectivelyDead(hero) {
if hero.State != model.StateDead && hero.HP > 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "hero is alive (state is not dead and hp > 0)",
})
return
}
if err := game.CheckPlayerReviveQuota(hero); err != nil {
if errors.Is(err, game.ErrReviveQuotaExceeded) {
if !hero.SubscriptionActive && hero.ReviveCount >= 2 {
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()
game.ApplyHeroReviveMechanical(hero)
game.ApplyPlayerReviveProgressCounters(hero)
hero.Buffs = model.RemoveBuffType(hero.Buffs, model.BuffResurrection)
hero.Debuffs = nil
hero.ReviveCount++
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)
@ -360,10 +364,6 @@ 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,13 +417,6 @@ 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",
@ -435,14 +428,7 @@ 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",
@ -454,12 +440,6 @@ 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
@ -478,20 +458,6 @@ 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()
@ -864,15 +830,6 @@ 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,
@ -884,8 +841,8 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
"merchantTownGearCostBase": gearBase,
"merchantTownGearCostPerTownLevel": gearPer,
"serverVersion": version.Version,
"showChangelog": showChangelog,
"changelog": changelogPayload,
"showChangelog": false,
"changelog": nil,
})
return
}
@ -941,13 +898,16 @@ 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 && game.IsEffectivelyDead(hero) && time.Since(hero.UpdatedAt) > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond {
game.ApplyHeroReviveMechanical(hero)
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
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)
}
}

@ -13,22 +13,11 @@ 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"`

@ -100,7 +100,6 @@ 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)

@ -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.Debug("saved hero", "hero", hero)
s.logger.Info("saved hero", "hero", hero)
return nil
}

@ -303,7 +303,7 @@ func DefaultValues() Values {
TownTourRestMinMs: 240_000,
TownTourRestMaxMs: 360_000,
WanderingMerchantPromptTimeoutMs: 15_000,
MerchantCostBase: 900,
MerchantCostBase: 20,
MerchantCostPerLevel: 5,
MerchantTownAutoSellShare: 0.30,
MonsterEncounterWeightBase: 0.62,

@ -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": 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');
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');
--

Loading…
Cancel
Save