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'); --