huge refactor with admin

master
Denis Ranneft 2 months ago
parent 481b6e4079
commit e8e03088fe

@ -16,6 +16,7 @@ import (
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/router"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
func main() {
@ -63,6 +64,29 @@ func main() {
// Stores (created before hub callbacks which reference them).
heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool)
runtimeConfigStore := storage.NewRuntimeConfigStore(pgPool)
if err := tuning.ReloadNow(ctx, logger, runtimeConfigStore); err != nil {
logger.Error("failed to load runtime config", "error", err)
os.Exit(1)
}
buffDebuffStore := storage.NewBuffDebuffConfigStore(pgPool)
if err := model.ReloadBuffDebuffCatalog(ctx, logger, buffDebuffStore); err != nil {
logger.Error("failed to load buff/debuff catalog", "error", err)
os.Exit(1)
}
contentStore := storage.NewContentStore(pgPool)
enemiesFromDB, err := contentStore.LoadEnemyTemplates(ctx)
if err != nil {
logger.Error("failed to load enemy templates from db", "error", err)
os.Exit(1)
}
model.SetEnemyTemplates(enemiesFromDB)
gearFamiliesFromDB, err := contentStore.LoadGearFamilies(ctx)
if err != nil {
logger.Error("failed to load gear templates from db", "error", err)
os.Exit(1)
}
model.SetGearCatalog(gearFamiliesFromDB)
// Load road graph for server-authoritative movement.
roadGraph, err := game.LoadRoadGraph(ctx, pgPool)
@ -150,7 +174,7 @@ func main() {
serverStartedAt := time.Now()
offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger, func() bool {
return engine.IsTimePaused()
})
}, engine.HeroHasActiveMovement)
go func() {
if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
logger.Error("offline simulator error", "error", err)

@ -5,11 +5,9 @@ import (
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// combatDamageScale stretches fights (MVP tuning; paired with slower attack cadence in engine).
const combatDamageScale = 0.35
// CalculateDamage computes the final damage dealt from attacker stats to a defender,
// applying defense and critical hits.
func CalculateDamage(baseAttack int, defense int, critChance float64) (damage int, isCrit bool) {
@ -27,7 +25,7 @@ func CalculateDamage(baseAttack int, defense int, critChance float64) (damage in
isCrit = true
}
dmg *= combatDamageScale
dmg *= tuning.Get().CombatDamageScale
if dmg < 1 {
dmg = 1
}
@ -77,7 +75,7 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
// Check enemy dodge ability.
if enemy.HasAbility(model.AbilityDodge) {
if rand.Float64() < 0.20 { // 20% dodge chance
if rand.Float64() < tuning.Get().EnemyDodgeChance {
return model.CombatEvent{
Type: "attack",
HeroID: hero.ID,
@ -113,15 +111,16 @@ func ProcessAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.Co
func EnemyAttackDamageMultiplier(enemy *model.Enemy) float64 {
enemy.AttackCount++
mult := 1.0
cfg := tuning.Get()
// Orc Warrior: every 3rd attack deals 1.5x damage (spec §4.1).
if enemy.HasAbility(model.AbilityBurst) && enemy.AttackCount%3 == 0 {
mult *= 1.5
if enemy.HasAbility(model.AbilityBurst) && cfg.EnemyBurstEveryN > 0 && enemy.AttackCount%int(cfg.EnemyBurstEveryN) == 0 {
mult *= cfg.EnemyBurstMultiplier
}
// Lightning Titan: after 5 attacks, next attack deals 3x damage (spec §4.2).
if enemy.HasAbility(model.AbilityChainLightning) && enemy.AttackCount%6 == 0 {
mult *= 3.0
if enemy.HasAbility(model.AbilityChainLightning) && cfg.EnemyChainEveryN > 0 && enemy.AttackCount%int(cfg.EnemyChainEveryN) == 0 {
mult *= cfg.EnemyChainMultiplier
}
return mult
@ -131,8 +130,8 @@ func EnemyAttackDamageMultiplier(enemy *model.Enemy) float64 {
// debuff application and burst/chain abilities based on the enemy's type.
func ProcessEnemyAttack(hero *model.Hero, enemy *model.Enemy, now time.Time) model.CombatEvent {
critChance := enemy.CritChance
if enemy.HasAbility(model.AbilityCritical) && critChance < 0.15 {
critChance = 0.15
if enemy.HasAbility(model.AbilityCritical) && critChance < tuning.Get().EnemyCriticalMinChance {
critChance = tuning.Get().EnemyCriticalMinChance
}
rawDmg, isCrit := CalculateDamage(enemy.Attack, hero.EffectiveDefenseAt(now), critChance)
@ -175,12 +174,12 @@ func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string
}
rules := []debuffRule{
{model.AbilityBurn, model.DebuffBurn, 0.30}, // Fire Demon: 30% burn
{model.AbilityPoison, model.DebuffPoison, 0.10}, // Zombie: 10% poison
{model.AbilitySlow, model.DebuffSlow, 0.25}, // Water Element: 25% slow (-40% movement)
{model.AbilityStun, model.DebuffStun, 0.25}, // Lightning Titan: 25% stun
{model.AbilityFreeze, model.DebuffFreeze, 0.20}, // Generic freeze: -50% attack speed
{model.AbilityIceSlow, model.DebuffIceSlow, 0.20}, // Ice Guardian: -20% attack speed (spec §4.2)
{model.AbilityBurn, model.DebuffBurn, tuning.Get().DebuffProcBurn},
{model.AbilityPoison, model.DebuffPoison, tuning.Get().DebuffProcPoison},
{model.AbilitySlow, model.DebuffSlow, tuning.Get().DebuffProcSlow},
{model.AbilityStun, model.DebuffStun, tuning.Get().DebuffProcStun},
{model.AbilityFreeze, model.DebuffFreeze, tuning.Get().DebuffProcFreeze},
{model.AbilityIceSlow, model.DebuffIceSlow, tuning.Get().DebuffProcIceSlow},
}
for _, rule := range rules {
@ -200,7 +199,7 @@ func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string
// applyDebuff adds a debuff to the hero. If the same debuff type is already active, it refreshes.
func applyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
def, ok := model.DefaultDebuffs[debuffType]
def, ok := model.DebuffDefinition(debuffType)
if !ok {
return
}
@ -276,14 +275,15 @@ func ProcessEnemyRegen(enemy *model.Enemy, tickDuration time.Duration) int {
}
// Regen rates vary by enemy type.
regenRate := 0.02 // default 2% per second
cfg := tuning.Get()
regenRate := cfg.EnemyRegenDefault
switch enemy.Type {
case model.EnemySkeletonKing:
regenRate = 0.10 // 10% HP regen
regenRate = cfg.EnemyRegenSkeletonKing
case model.EnemyForestWarden:
regenRate = 0.05 // 5% HP/sec
regenRate = cfg.EnemyRegenForestWarden
case model.EnemyBattleLizard:
regenRate = 0.02 // 2% of received damage (approximated as 2% HP/sec)
regenRate = cfg.EnemyRegenBattleLizard
}
healed := int(float64(enemy.MaxHP) * regenRate * tickDuration.Seconds())
@ -328,7 +328,7 @@ func CheckDeath(hero *model.Hero, now time.Time) bool {
// ApplyBuff adds a buff to the hero. If the same buff type is already active, it refreshes.
func ApplyBuff(hero *model.Hero, buffType model.BuffType, now time.Time) *model.ActiveBuff {
def, ok := model.DefaultBuffs[buffType]
def, ok := model.BuffDefinition(buffType)
if !ok {
return nil
}
@ -381,7 +381,7 @@ func HasLuckBuff(hero *model.Hero, now time.Time) bool {
// LuckMultiplier returns the loot multiplier from the Luck buff (x2.5 per spec §7.1).
func LuckMultiplier(hero *model.Hero, now time.Time) float64 {
if HasLuckBuff(hero, now) {
return 2.5
return tuning.Get().LuckBuffMultiplier
}
return 1.0
}
@ -396,16 +396,25 @@ func ProcessSummonDamage(hero *model.Hero, enemy *model.Enemy, combatStart time.
return 0
}
// How many 15-second summon cycles have elapsed since combat start.
prevCycles := int(lastTick.Sub(combatStart).Seconds()) / 15
currCycles := int(now.Sub(combatStart).Seconds()) / 15
dv := tuning.DefaultValues()
// How many summon cycles have elapsed since combat start.
cycleSec := tuning.Get().SummonCycleSeconds
if cycleSec < 1 {
cycleSec = dv.SummonCycleSeconds
}
prevCycles := int(lastTick.Sub(combatStart).Seconds()) / int(cycleSec)
currCycles := int(now.Sub(combatStart).Seconds()) / int(cycleSec)
if currCycles <= prevCycles {
return 0
}
// Each summon wave deals 25% of the enemy's base attack as minion damage.
minionDmg := max(1, enemy.Attack/4)
// Each summon wave deals (1/divisor) of the enemy's base attack as minion damage.
div := tuning.Get().SummonDamageDivisor
if div < 1 {
div = dv.SummonDamageDivisor
}
minionDmg := max(1, enemy.Attack/int(div))
hero.HP -= minionDmg
if hero.HP < 0 {
hero.HP = 0

@ -151,7 +151,7 @@ func TestLuckMultiplierWithBuff(t *testing.T) {
now := time.Now()
hero := &model.Hero{
Buffs: []model.ActiveBuff{{
Buff: model.DefaultBuffs[model.BuffLuck],
Buff: mustBuffDef(model.BuffLuck),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(10 * time.Second),
}},
@ -176,7 +176,7 @@ func TestProcessDebuffDamageAppliesPoison(t *testing.T) {
hero := &model.Hero{
HP: 100, MaxHP: 100,
Debuffs: []model.ActiveDebuff{{
Debuff: model.DefaultDebuffs[model.DebuffPoison],
Debuff: mustDebuffDef(model.DebuffPoison),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(4 * time.Second),
}},
@ -220,3 +220,19 @@ func TestDodgeAbilityCanAvoidDamage(t *testing.T) {
t.Fatal("expected at least one dodge in 200 hero attacks against Skeleton Archer")
}
}
func mustBuffDef(bt model.BuffType) model.Buff {
b, ok := model.BuffDefinition(bt)
if !ok {
panic("missing buff def: " + string(bt))
}
return b
}
func mustDebuffDef(dt model.DebuffType) model.Debuff {
d, ok := model.DebuffDefinition(dt)
if !ok {
panic("missing debuff def: " + string(dt))
}
return d
}

@ -11,6 +11,7 @@ import (
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// MessageSender is the interface the engine uses to push WS messages.
@ -77,12 +78,6 @@ type Engine struct {
npcAlmsHandler func(context.Context, int64) error
}
const minAttackInterval = 250 * time.Millisecond
// combatPaceMultiplier stretches time between swings (MVP: longer fights).
const combatPaceMultiplier = 5
// 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{
@ -102,6 +97,14 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
return e.movements[heroId]
}
// HeroHasActiveMovement is true while the hero has an in-engine movement session (typically WebSocket-connected).
func (e *Engine) HeroHasActiveMovement(heroID int64) bool {
e.mu.RLock()
defer e.mu.RUnlock()
_, ok := e.movements[heroID]
return ok
}
// RoadGraph returns the loaded world graph (for admin tools), or nil.
func (e *Engine) RoadGraph() *RoadGraph {
e.mu.RLock()
@ -170,7 +173,12 @@ func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Durat
hna = now.Add(attackInterval(cs.Hero.EffectiveSpeed()))
}
} else if hna.Before(now) {
hna = now.Add(minAttackInterval * combatPaceMultiplier)
cfg := tuning.Get()
minAttack := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond
if cfg.CombatPaceMultiplier < 1 {
cfg.CombatPaceMultiplier = 1
}
hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier))
}
if ena.Before(now) {
ena = now.Add(attackInterval(cs.Enemy.Speed))
@ -244,8 +252,8 @@ func (e *Engine) IncomingCh() chan<- IncomingMessage {
// Run starts the game loop. It blocks until the context is cancelled.
func (e *Engine) Run(ctx context.Context) error {
combatTicker := time.NewTicker(e.tickRate)
moveTicker := time.NewTicker(MovementTickRate)
syncTicker := time.NewTicker(PositionSyncRate)
moveTicker := time.NewTicker(movementTickRate())
syncTicker := time.NewTicker(positionSyncRate())
defer combatTicker.Stop()
defer moveTicker.Stop()
defer syncTicker.Stop()
@ -371,7 +379,10 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
}
hero.Potions--
healAmount := hero.MaxHP * 30 / 100
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
@ -440,7 +451,7 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
return
}
hero.HP = hero.MaxHP / 2
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 {
hero.HP = 1
}
@ -658,6 +669,104 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
return h, true
}
// ApplyAdminStartRoadsideRest forces roadside rest (walking + road required). Saves and notifies WS.
func (e *Engine) ApplyAdminStartRoadsideRest(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.AdminStartRoadsideRest(now) {
return nil, false
}
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 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 start roadside rest", "hero_id", h.ID, "error", err)
}
}
return h, true
}
// ApplyAdminStopRoadsideRest ends roadside rest if active. Returns the hero snapshot; endedRest is false if already not resting (not an error).
func (e *Engine) ApplyAdminStopRoadsideRest(heroID int64) (h *model.Hero, endedRest bool) {
e.mu.Lock()
defer e.mu.Unlock()
hm, ok := e.movements[heroID]
if !ok || e.roadGraph == nil {
return nil, false
}
now := time.Now()
h = hm.Hero
if !hm.roadsideRestInProgress() {
h.EnsureGearMap()
h.RefreshDerivedCombatStats(now)
return h, false
}
hm.endRoadsideRest()
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 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 stop roadside rest", "hero_id", h.ID, "error", err)
}
}
return h, true
}
// ApplyAdminForceLeaveTown ends resting or in-town pause, assigns a new road, and notifies the client.
func (e *Engine) ApplyAdminForceLeaveTown(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
}
if hm.State != model.StateResting && hm.State != model.StateInTown {
return nil, false
}
now := time.Now()
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 force leave town", "hero_id", h.ID, "error", err)
}
}
return h, true
}
// ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
e.mu.Lock()
@ -802,7 +911,13 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
defer e.mu.Unlock()
hm, ok := e.movements[hero.ID]
if !ok || e.roadGraph == nil {
if !ok {
if e.sender != nil {
now := time.Now()
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hero)
}
return
}
@ -815,7 +930,7 @@ func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
hm.refreshSpeed(now)
routeAssigned := false
if hm.State == model.StateWalking && hm.Road == nil {
if e.roadGraph != nil && hm.State == model.StateWalking && hm.Road == nil {
hm.pickDestination(e.roadGraph)
hm.assignRoad(e.roadGraph)
routeAssigned = true
@ -1145,7 +1260,7 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
}
// processMovementTick advances all walking heroes and checks for encounters.
// Called at 2 Hz (500ms).
// Runs on the configured movement cadence.
func (e *Engine) processMovementTick(now time.Time) {
e.mu.Lock()
defer e.mu.Unlock()
@ -1159,7 +1274,19 @@ func (e *Engine) processMovementTick(now time.Time) {
}
for heroID, hm := range e.movements {
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog)
ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter)
}
}
// persistHeroAfterTownEnter writes the hero row after a walk-in town arrival (town_pause + state).
func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) {
if e.heroStore == nil || h == nil {
return
}
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 town enter", "hero_id", h.ID, "error", err)
}
}
@ -1193,12 +1320,18 @@ func (e *Engine) emitEvent(evt model.CombatEvent) {
// attackInterval converts an attacks-per-second speed to a duration between attacks.
func attackInterval(speed float64) time.Duration {
cfg := tuning.Get()
minAttack := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond
if cfg.CombatPaceMultiplier < 1 {
cfg.CombatPaceMultiplier = 1
}
pace := time.Duration(cfg.CombatPaceMultiplier)
if speed <= 0 {
return time.Second * combatPaceMultiplier // fallback: 1 attack per second, scaled
return time.Second * pace // fallback: 1 attack per second, scaled
}
interval := time.Duration(float64(time.Second)/speed) * combatPaceMultiplier
if interval < minAttackInterval*combatPaceMultiplier {
return minAttackInterval * combatPaceMultiplier
interval := time.Duration(float64(time.Second)/speed) * pace
if interval < minAttack*pace {
return minAttack * pace
}
return interval
}

@ -3,11 +3,14 @@ package game
import (
"testing"
"time"
"github.com/denisovdennis/autohero/internal/tuning"
)
func TestAttackIntervalRespectsMinimumCap(t *testing.T) {
got := attackInterval(10.0)
want := minAttackInterval * combatPaceMultiplier
cfg := tuning.Get()
want := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond * time.Duration(cfg.CombatPaceMultiplier)
if got != want {
t.Fatalf("expected min interval %s, got %s", want, got)
}
@ -16,7 +19,8 @@ func TestAttackIntervalRespectsMinimumCap(t *testing.T) {
func TestAttackIntervalForNormalSpeed(t *testing.T) {
got := attackInterval(2.0)
// 1/2 s per attack at 2 APS, scaled by combatPaceMultiplier
want := 500 * time.Millisecond * combatPaceMultiplier
cfg := tuning.Get()
want := 500 * time.Millisecond * time.Duration(cfg.CombatPaceMultiplier)
if got != want {
t.Fatalf("expected %s, got %s", want, got)
}

@ -7,84 +7,40 @@ import (
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
const (
// BaseMoveSpeed is the hero's base movement speed in world-units per second.
BaseMoveSpeed = 2.0
// MovementTickRate is how often the movement system updates (2 Hz).
MovementTickRate = 500 * time.Millisecond
// PositionSyncRate is how often the server sends a full position_sync (drift correction).
PositionSyncRate = 10 * time.Second
// EncounterCooldownBase is the minimum gap between road encounters (monster or merchant).
EncounterCooldownBase = 12 * time.Second
// WanderingMerchantPromptTimeout is how long the hero stays stopped for the wandering merchant dialog (online).
WanderingMerchantPromptTimeout = 15 * time.Second
// EncounterActivityBase scales per-tick chance to roll an encounter after cooldown.
// Effective activity is higher deep off-road (see rollRoadEncounter).
EncounterActivityBase = 0.035
// StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion.
StartAdventurePerTick = 0.000030
// AdventureDurationMin/Max bound how long an off-road excursion lasts.
AdventureDurationMin = 15 * time.Minute
AdventureDurationMax = 20 * time.Minute
// AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness.
AdventureMaxLateral = 20.0
// AdventureWildernessRampFraction is the share of excursion time spent easing off the road at the start
// and easing back at the end. The middle (1 - 2*ramp) stays at full lateral offset so the hero
// visibly walks beside the road for most of a long excursion.
AdventureWildernessRampFraction = 0.12
// LowHPThreshold: below this HP fraction (of MaxHP) the hero seeks a short roadside rest.
LowHPThreshold = 0.35
// RoadsideRestExitHP: leave roadside rest when HP reaches this fraction of MaxHP (or max duration).
RoadsideRestExitHP = 0.92
// RoadsideRestDurationMin/Max cap how long a roadside rest can last (hero may leave earlier if healed).
RoadsideRestDurationMin = 40 * time.Second
RoadsideRestDurationMax = 100 * time.Second
// RoadsideRestLateral is perpendicular offset from the road while resting (smaller than adventure).
RoadsideRestLateral = 1.15
// RoadsideRestHPPerSecond is MaxHP fraction restored per second while roadside resting (0.1%).
RoadsideRestHPPerSecond = 0.001
// RoadsideRestThoughtMinInterval / MaxInterval between adventure log lines while resting.
RoadsideRestThoughtMinInterval = 4 * time.Second
RoadsideRestThoughtMaxInterval = 11 * time.Second
// TownRestMin is the minimum rest duration when arriving at a town.
TownRestMin = 5 * 60 * time.Second
// TownRestMax is the maximum rest duration when arriving at a town.
TownRestMax = 20 * 60 * time.Second
// TownArrivalRadius is how close the hero must be to the final waypoint
// to be considered "arrived" at the town.
TownArrivalRadius = 0.5
// Town NPC visits: high chance each attempt to approach the next NPC; queue clears on LeaveTown.
townNPCVisitChance = 0.78
townNPCRollMin = 800 * time.Millisecond
townNPCRollMax = 2600 * time.Millisecond
townNPCRetryAfterMiss = 450 * time.Millisecond
// TownNPCVisitTownPause is how long the hero stays in town after the last NPC (whole town) before leaving.
TownNPCVisitTownPause = 30 * time.Second
// TownNPCVisitLogInterval is how often a line is written to the adventure log during a visit.
TownNPCVisitLogInterval = 5 * time.Second
// townNPCVisitLogLines is how many log lines to emit per NPC (every TownNPCVisitLogInterval).
// townNPCVisitLogLines is how many log lines to emit per NPC visit.
townNPCVisitLogLines = 6
restKindTown = "town"
restKindRoadside = "roadside"
)
// TownNPCVisitNarrationBlock is the minimum gap before visiting the next town NPC (first line through last line).
var TownNPCVisitNarrationBlock = TownNPCVisitLogInterval * (townNPCVisitLogLines - 1)
func movementTickRate() time.Duration {
ms := tuning.Get().MovementTickRateMs
if ms <= 0 {
ms = tuning.DefaultValues().MovementTickRateMs
}
return time.Duration(ms) * time.Millisecond
}
func positionSyncRate() time.Duration {
ms := tuning.Get().PositionSyncRateMs
if ms <= 0 {
ms = tuning.DefaultValues().PositionSyncRateMs
}
return time.Duration(ms) * time.Millisecond
}
func townNPCLogInterval() time.Duration {
ms := tuning.Get().TownNPCLogIntervalMs
if ms <= 0 {
ms = tuning.DefaultValues().TownNPCLogIntervalMs
}
return time.Duration(ms) * time.Millisecond
}
// AdventureLogWriter persists or pushes one adventure log line for a hero (optional).
type AdventureLogWriter func(heroID int64, message string)
@ -125,10 +81,18 @@ type HeroMovement struct {
AdventureEndAt time.Time
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring
// Roadside rest (low HP): step off the road and recover HP; not persisted.
// Roadside rest (low HP): unified under StateResting with a roadside flag; persisted in heroes.town_pause.
// RoadsideRestActive indicates "resting on roadside" flavor inside the unified resting state.
RoadsideRestActive bool
RoadsideRestEndAt time.Time
RoadsideRestStartedAt time.Time // wall time when this roadside session began (approach / return animation)
RoadsideRestSide int // +1 / -1 perpendicular; 0 = not resting
RoadsideRestNextLog time.Time
// Accumulates fractional roadside regen between ticks.
RoadsideRestHealRemainder float64
// Accumulates fractional town-rest regen between ticks.
TownRestHealRemainder float64
// WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only).
WanderingMerchantDeadline time.Time
@ -190,10 +154,10 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
return hm
}
// If resting/in_town, set a short rest timer so they leave soon.
// Resting / in-town: restore persisted deadlines and NPC tour from DB (town_pause).
if hero.State == model.StateResting || hero.State == model.StateInTown {
hm.State = model.StateResting
hm.RestUntil = now.Add(randomRestDuration())
hm.State = hero.State
hm.applyTownPauseFromHero(hero, now)
return hm
}
@ -423,8 +387,11 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.TownLeaveAt = shift(hm.TownLeaveAt)
hm.AdventureStartAt = shift(hm.AdventureStartAt)
hm.AdventureEndAt = shift(hm.AdventureEndAt)
if hm.RoadsideRestActive {
hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt)
hm.RoadsideRestStartedAt = shift(hm.RoadsideRestStartedAt)
hm.RoadsideRestNextLog = shift(hm.RoadsideRestNextLog)
}
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.LastMoveTick = now
}
@ -433,7 +400,7 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
func (hm *HeroMovement) refreshSpeed(now time.Time) {
// Per-hero speed variation: ±10% based on hero ID for natural spread.
heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10
hm.Speed = BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter
hm.Speed = tuning.Get().BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter
}
// AdvanceTick moves the hero along the road for one movement tick.
@ -445,7 +412,7 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = MovementTickRate.Seconds()
dt = movementTickRate().Seconds()
}
hm.LastMoveTick = now
@ -465,8 +432,12 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
// How far along this segment we already are.
currentDist := hm.WaypointFraction * segLen
remaining := segLen - currentDist
arrivalRadius := tuning.Get().TownArrivalRadius
if arrivalRadius < 0 {
arrivalRadius = 0
}
if distThisTick >= remaining {
if distThisTick >= remaining || (hm.WaypointIndex == len(hm.Road.Waypoints)-2 && remaining <= arrivalRadius) {
// Move to next waypoint.
distThisTick -= remaining
hm.WaypointIndex++
@ -534,22 +505,83 @@ func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) {
}
func (hm *HeroMovement) roadsideRestInProgress() bool {
return !hm.RoadsideRestEndAt.IsZero()
return hm.State == model.StateResting && hm.RoadsideRestActive
}
func (hm *HeroMovement) endRoadsideRest() {
wasActive := hm.RoadsideRestActive
hm.RoadsideRestActive = false
hm.RoadsideRestEndAt = time.Time{}
hm.RoadsideRestStartedAt = time.Time{}
hm.RoadsideRestSide = 0
hm.RoadsideRestNextLog = time.Time{}
hm.RoadsideRestHealRemainder = 0
if wasActive && hm.State == model.StateResting {
hm.State = model.StateWalking
if hm.Hero != nil {
hm.Hero.State = model.StateWalking
}
}
if wasActive {
hm.RestUntil = time.Time{}
}
}
// EndRoadsideRest ends pull-over roadside rest (no-op if not active).
func (hm *HeroMovement) EndRoadsideRest() {
hm.endRoadsideRest()
}
// beginRoadsideRestSession starts a roadside session until endAt. Clears adventure excursion.
func (hm *HeroMovement) beginRoadsideRestSession(now, endAt time.Time) {
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
hm.RoadsideRestActive = true
hm.RoadsideRestEndAt = endAt
hm.RoadsideRestStartedAt = now
hm.RestUntil = endAt
hm.State = model.StateResting
if hm.Hero != nil {
hm.Hero.State = model.StateResting
}
hm.RoadsideRestHealRemainder = 0
hm.TownRestHealRemainder = 0
if rand.Float64() < 0.5 {
hm.RoadsideRestSide = 1
} else {
hm.RoadsideRestSide = -1
}
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
}
func (hm *HeroMovement) applyRoadsideRestHeal(dt float64) {
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
return
}
gain := int(math.Ceil(float64(hm.Hero.MaxHP) * RoadsideRestHPPerSecond * dt))
if gain < 1 {
gain = 1
cfg := tuning.Get()
rawGain := float64(hm.Hero.MaxHP)*cfg.RoadsideRestHPPerS*dt + hm.RoadsideRestHealRemainder
gain := int(math.Floor(rawGain))
hm.RoadsideRestHealRemainder = rawGain - float64(gain)
if gain <= 0 {
return
}
hm.Hero.HP += gain
if hm.Hero.HP > hm.Hero.MaxHP {
hm.Hero.HP = hm.Hero.MaxHP
}
}
func (hm *HeroMovement) applyTownRestHeal(dt float64) {
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
return
}
cfg := tuning.Get()
rawGain := float64(hm.Hero.MaxHP)*cfg.TownRestHPPerS*dt + hm.TownRestHealRemainder
gain := int(math.Floor(rawGain))
hm.TownRestHealRemainder = rawGain - float64(gain)
if gain <= 0 {
return
}
hm.Hero.HP += gain
if hm.Hero.HP > hm.Hero.MaxHP {
@ -562,34 +594,32 @@ func (hm *HeroMovement) tryStartRoadsideRest(now time.Time) {
if hm.roadsideRestInProgress() {
return
}
cfg := tuning.Get()
if hm.Hero == nil || hm.Hero.MaxHP <= 0 {
return
}
if float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) > LowHPThreshold {
if float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) > cfg.LowHPThreshold {
return
}
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
spanNs := (RoadsideRestDurationMax - RoadsideRestDurationMin).Nanoseconds()
restMin := time.Duration(cfg.RoadsideRestMinMs) * time.Millisecond
restMax := time.Duration(cfg.RoadsideRestMaxMs) * time.Millisecond
spanNs := (restMax - restMin).Nanoseconds()
if spanNs < 1 {
spanNs = 1
}
hm.RoadsideRestEndAt = now.Add(RoadsideRestDurationMin + time.Duration(rand.Int63n(spanNs+1)))
if rand.Float64() < 0.5 {
hm.RoadsideRestSide = 1
} else {
hm.RoadsideRestSide = -1
}
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
endAt := now.Add(restMin + time.Duration(rand.Int63n(spanNs+1)))
hm.beginRoadsideRestSession(now, endAt)
}
func randomRoadsideRestThoughtDelay() time.Duration {
span := RoadsideRestThoughtMaxInterval - RoadsideRestThoughtMinInterval
cfg := tuning.Get()
minDelay := time.Duration(cfg.RoadsideThoughtMinMs) * time.Millisecond
maxDelay := time.Duration(cfg.RoadsideThoughtMaxMs) * time.Millisecond
span := maxDelay - minDelay
if span < 0 {
span = 0
}
return RoadsideRestThoughtMinInterval + time.Duration(rand.Int63n(int64(span)+1))
return minDelay + time.Duration(rand.Int63n(int64(span)+1))
}
// emitRoadsideRestThoughts appends occasional journal lines while the hero rests off the road.
@ -599,7 +629,6 @@ func emitRoadsideRestThoughts(heroID int64, hm *HeroMovement, now time.Time, log
}
if hm.RoadsideRestNextLog.IsZero() {
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
return
}
if now.Before(hm.RoadsideRestNextLog) {
return
@ -610,18 +639,21 @@ func emitRoadsideRestThoughts(heroID int64, hm *HeroMovement, now time.Time, log
// tryStartAdventure begins a timed off-road excursion with small probability.
func (hm *HeroMovement) tryStartAdventure(now time.Time) {
cfg := tuning.Get()
if hm.adventureActive(now) {
return
}
if rand.Float64() >= StartAdventurePerTick {
if rand.Float64() >= cfg.StartAdventurePerTick {
return
}
hm.AdventureStartAt = now
spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds()
minDur := time.Duration(cfg.AdventureDurationMinMs) * time.Millisecond
maxDur := time.Duration(cfg.AdventureDurationMaxMs) * time.Millisecond
spanNs := (maxDur - minDur).Nanoseconds()
if spanNs < 1 {
spanNs = 1
}
hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1)))
hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1)))
if rand.Float64() < 0.5 {
hm.AdventureSide = 1
} else {
@ -643,12 +675,15 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool {
if hm.adventureActive(now) {
return true
}
spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds()
cfg := tuning.Get()
minDur := time.Duration(cfg.AdventureDurationMinMs) * time.Millisecond
maxDur := time.Duration(cfg.AdventureDurationMaxMs) * time.Millisecond
spanNs := (maxDur - minDur).Nanoseconds()
if spanNs < 1 {
spanNs = 1
}
hm.AdventureStartAt = now
hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1)))
hm.AdventureEndAt = now.Add(minDur + time.Duration(rand.Int63n(spanNs+1)))
if rand.Float64() < 0.5 {
hm.AdventureSide = 1
} else {
@ -679,6 +714,7 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownRestHealRemainder = 0
t := graph.Towns[townID]
hm.CurrentX = t.WorldX
hm.CurrentY = t.WorldY
@ -705,6 +741,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownRestHealRemainder = 0
if graph != nil && hm.CurrentTownID == 0 {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
}
@ -714,6 +751,34 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
return true
}
// AdminStartRoadsideRest forces roadside rest while walking (ignores HP). Extends duration if already resting.
func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool {
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
return false
}
if hm.State != model.StateWalking {
return false
}
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false
}
hm.WanderingMerchantDeadline = time.Time{}
cfg := tuning.Get()
restMin := time.Duration(cfg.RoadsideRestMinMs) * time.Millisecond
restMax := time.Duration(cfg.RoadsideRestMaxMs) * time.Millisecond
spanNs := (restMax - restMin).Nanoseconds()
if spanNs < 1 {
spanNs = 1
}
endAt := now.Add(restMin + time.Duration(rand.Int63n(spanNs+1)))
if hm.roadsideRestInProgress() {
hm.RoadsideRestEndAt = endAt
return true
}
hm.beginRoadsideRestSession(now, endAt)
return true
}
// wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back.
// (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.)
func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
@ -731,7 +796,7 @@ func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
} else if p > 1 {
p = 1
}
r := AdventureWildernessRampFraction
r := tuning.Get().AdventureWildernessRampFraction
if r < 1e-6 {
r = 1e-6
}
@ -747,6 +812,110 @@ func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
return 1
}
func smoothstep01(t float64) float64 {
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return t * t * (3 - 2*t)
}
func roadsideRestPhaseDurations(total time.Duration) (time.Duration, time.Duration) {
cfg := tuning.Get()
dtIn := time.Duration(cfg.RoadsideRestGoInMs) * time.Millisecond
dtOut := time.Duration(cfg.RoadsideRestReturnMs) * time.Millisecond
if dtIn+dtOut > total {
r := float64(total) / float64(dtIn+dtOut)
dtIn = time.Duration(float64(dtIn) * r)
dtOut = time.Duration(float64(dtOut) * r)
}
if dtIn < 0 {
dtIn = 0
}
if dtOut < 0 {
dtOut = 0
}
if dtIn+dtOut > total {
dtIn = total / 2
dtOut = total - dtIn
}
return dtIn, dtOut
}
// roadsideRestDepthFactor is 0..1: 0 on road, 1 at the forest camp; animates in at session start and out before RestUntil.
func (hm *HeroMovement) roadsideRestDepthFactor(now time.Time) float64 {
if !hm.roadsideRestInProgress() {
return 0
}
t0 := hm.RoadsideRestStartedAt
tEnd := hm.RoadsideRestEndAt
if tEnd.IsZero() {
return 0
}
if !now.Before(tEnd) {
return 0
}
if t0.IsZero() {
// Legacy blob without start time: assume already deep in the woods until the final return window.
t0 = tEnd.Add(-365 * 24 * time.Hour)
}
total := tEnd.Sub(t0)
if total <= 0 {
return 1
}
dtIn, dtOut := roadsideRestPhaseDurations(total)
if now.Before(t0) {
return 0
}
if dtIn > 0 && now.Before(t0.Add(dtIn)) {
e := float64(now.Sub(t0)) / float64(dtIn)
return smoothstep01(e)
}
if dtOut > 0 && !now.Before(tEnd.Add(-dtOut)) {
e := float64(tEnd.Sub(now)) / float64(dtOut)
return smoothstep01(e)
}
return 1
}
// roadsideRestAtCamp returns true only during the "actual rest" plateau (after go-in, before return).
func (hm *HeroMovement) roadsideRestAtCamp(now time.Time) bool {
if !hm.roadsideRestInProgress() {
return false
}
tEnd := hm.RoadsideRestEndAt
if tEnd.IsZero() || !now.Before(tEnd) {
return false
}
// Legacy blob without start time: assume already at camp, but still reserve the final return window.
if hm.RoadsideRestStartedAt.IsZero() {
dtOut := time.Duration(tuning.Get().RoadsideRestReturnMs) * time.Millisecond
return dtOut <= 0 || now.Before(tEnd.Add(-dtOut))
}
total := tEnd.Sub(hm.RoadsideRestStartedAt)
if total <= 0 {
return false
}
dtIn, dtOut := roadsideRestPhaseDurations(total)
if now.Before(hm.RoadsideRestStartedAt.Add(dtIn)) {
return false
}
if dtOut > 0 && !now.Before(tEnd.Add(-dtOut)) {
return false
}
return true
}
func roadsideRestDepthWorldUnits() float64 {
cfg := tuning.Get()
if cfg.RoadsideRestDepthMax > 0 {
return cfg.RoadsideRestDepthMax
}
return cfg.RoadsideRestLateral
}
func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 0, 1
@ -775,7 +944,8 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
return 0, 0
}
px, py := hm.roadPerpendicularUnit()
mag := float64(hm.RoadsideRestSide) * RoadsideRestLateral
f := hm.roadsideRestDepthFactor(now)
mag := float64(hm.RoadsideRestSide) * roadsideRestDepthWorldUnits() * f
return px * mag, py * mag
}
w := hm.wildernessFactor(now)
@ -783,32 +953,34 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
return 0, 0
}
px, py := hm.roadPerpendicularUnit()
mag := float64(hm.AdventureSide) * AdventureMaxLateral * w
mag := float64(hm.AdventureSide) * tuning.Get().AdventureMaxLateral * w
return px * mag, py * mag
}
// WanderingMerchantCost matches REST encounter / npc alms pricing.
func WanderingMerchantCost(level int) int64 {
return int64(20 + level*5)
cfg := tuning.Get()
return cfg.MerchantCostBase + int64(level)*cfg.MerchantCostPerLevel
}
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
cfg := tuning.Get()
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false, model.Enemy{}, false
}
if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase {
if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
return false, model.Enemy{}, false
}
w := hm.wildernessFactor(now)
// More encounter checks on the road; still ramps up further from the road.
activity := EncounterActivityBase * (0.62 + 0.38*w)
activity := cfg.EncounterActivityBase * (0.62 + 0.38*w)
if rand.Float64() >= activity {
return false, model.Enemy{}, false
}
// On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters.
monsterW := 0.62 + 0.18*w*w
merchantW := 0.04 + 0.10*(1-w)*(1-w)
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*w*w
merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus*(1-w)*(1-w)
total := monsterW + merchantW
r := rand.Float64() * total
if r < monsterW {
@ -832,6 +1004,7 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownRestHealRemainder = 0
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
@ -842,6 +1015,7 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.State = model.StateResting
hm.Hero.State = model.StateResting
hm.RestUntil = now.Add(randomRestDuration())
hm.RoadsideRestActive = false
return
}
@ -863,6 +1037,9 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{}
hm.endRoadsideRest()
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
@ -873,8 +1050,14 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
}
func randomTownNPCDelay() time.Duration {
rangeMs := (townNPCRollMax - townNPCRollMin).Milliseconds()
return townNPCRollMin + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
cfg := tuning.Get()
minDelay := time.Duration(cfg.TownNPCRollMinMs) * time.Millisecond
maxDelay := time.Duration(cfg.TownNPCRollMaxMs) * time.Millisecond
rangeMs := (maxDelay - minDelay).Milliseconds()
if rangeMs < 0 {
rangeMs = 0
}
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// StartFighting pauses movement for combat.
@ -915,6 +1098,136 @@ func (hm *HeroMovement) SyncToHero() {
hm.Hero.DestinationTownID = nil
}
hm.Hero.MoveState = string(hm.State)
hm.Hero.RestKind = ""
if hm.State == model.StateResting {
if hm.roadsideRestInProgress() {
hm.Hero.RestKind = restKindRoadside
} else {
hm.Hero.RestKind = restKindTown
}
}
hm.Hero.TownPause = hm.townPauseBlob()
}
func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
switch hm.State {
case model.StateResting:
if hm.RestUntil.IsZero() {
return nil
}
t := hm.RestUntil
p := &model.TownPausePersisted{
RestUntil: &t,
TownRestHealRemainder: hm.TownRestHealRemainder,
RoadsideRestHealRemainder: hm.RoadsideRestHealRemainder,
}
if hm.roadsideRestInProgress() {
p.RestKind = restKindRoadside
p.RoadsideRestActive = true
end := hm.RoadsideRestEndAt
p.RoadsideRestEndAt = &end
p.RoadsideRestSide = hm.RoadsideRestSide
if !hm.RoadsideRestStartedAt.IsZero() {
ts := hm.RoadsideRestStartedAt
p.RoadsideRestStartedAt = &ts
}
if !hm.RoadsideRestNextLog.IsZero() {
tNext := hm.RoadsideRestNextLog
p.RoadsideRestNextLog = &tNext
}
} else {
p.RestKind = restKindTown
}
return p
case model.StateInTown:
p := &model.TownPausePersisted{
TownVisitNPCName: hm.TownVisitNPCName,
TownVisitNPCType: hm.TownVisitNPCType,
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
}
if len(hm.TownNPCQueue) > 0 {
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
}
if !hm.NextTownNPCRollAt.IsZero() {
t := hm.NextTownNPCRollAt
p.NextTownNPCRollAt = &t
}
if !hm.TownLeaveAt.IsZero() {
t := hm.TownLeaveAt
p.TownLeaveAt = &t
}
if !hm.TownVisitStartedAt.IsZero() {
t := hm.TownVisitStartedAt
p.TownVisitStartedAt = &t
}
return p
default:
return nil
}
}
func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) {
blob := hero.TownPause
switch hero.State {
case model.StateResting:
if blob != nil && blob.RestUntil != nil && !blob.RestUntil.IsZero() {
hm.RestUntil = *blob.RestUntil
hm.TownRestHealRemainder = blob.TownRestHealRemainder
hm.RoadsideRestHealRemainder = blob.RoadsideRestHealRemainder
restKind := blob.RestKind
if restKind == "" && (blob.RoadsideRestActive || (blob.RoadsideRestEndAt != nil && !blob.RoadsideRestEndAt.IsZero())) {
restKind = restKindRoadside
}
if restKind == restKindRoadside {
hm.RoadsideRestActive = true
if blob.RoadsideRestEndAt != nil && !blob.RoadsideRestEndAt.IsZero() {
hm.RoadsideRestEndAt = *blob.RoadsideRestEndAt
hm.RestUntil = hm.RoadsideRestEndAt
} else {
hm.RoadsideRestEndAt = hm.RestUntil
}
if blob.RoadsideRestSide == 0 {
if rand.Float64() < 0.5 {
hm.RoadsideRestSide = 1
} else {
hm.RoadsideRestSide = -1
}
} else {
hm.RoadsideRestSide = blob.RoadsideRestSide
}
if blob.RoadsideRestNextLog != nil && !blob.RoadsideRestNextLog.IsZero() {
hm.RoadsideRestNextLog = *blob.RoadsideRestNextLog
} else {
hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay())
}
if blob.RoadsideRestStartedAt != nil && !blob.RoadsideRestStartedAt.IsZero() {
hm.RoadsideRestStartedAt = *blob.RoadsideRestStartedAt
}
}
return
}
// Legacy row without town_pause: treat rest as already elapsed so offline/ reconnect unblocks.
hm.RestUntil = now.Add(-time.Millisecond)
case model.StateInTown:
if blob == nil {
return
}
if len(blob.NPCQueue) > 0 {
hm.TownNPCQueue = append([]int64(nil), blob.NPCQueue...)
}
if blob.NextTownNPCRollAt != nil {
hm.NextTownNPCRollAt = *blob.NextTownNPCRollAt
}
if blob.TownLeaveAt != nil {
hm.TownLeaveAt = *blob.TownLeaveAt
}
hm.TownVisitNPCName = blob.TownVisitNPCName
hm.TownVisitNPCType = blob.TownVisitNPCType
if blob.TownVisitStartedAt != nil {
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
}
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@ -962,8 +1275,14 @@ func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPay
// randomRestDuration returns a random duration between TownRestMin and TownRestMax.
func randomRestDuration() time.Duration {
rangeMs := (TownRestMax - TownRestMin).Milliseconds()
return TownRestMin + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
cfg := tuning.Get()
minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond
maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond
rangeMs := (maxDur - minDur).Milliseconds()
if rangeMs < 0 {
rangeMs = 0
}
return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// EncounterStarter starts or resolves a random encounter while walking (engine: combat;
@ -973,12 +1292,16 @@ type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time)
// MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline).
type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)
// AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB).
type AfterTownEnterPersist func(hero *model.Hero)
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
if log == nil || hm.TownVisitStartedAt.IsZero() {
return
}
logInterval := townNPCLogInterval()
for hm.TownVisitLogsEmitted < townNPCVisitLogLines {
deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * TownNPCVisitLogInterval)
deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * logInterval)
if now.Before(deadline) {
break
}
@ -1060,13 +1383,14 @@ func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string {
}
// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
// It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate
// It mirrors the online engine's configured movement cadence.
// steps (plus a final partial step to real time) for catch-up simulation.
//
// sender may be nil to suppress all WebSocket payloads (offline ticks).
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered.
// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block),
// and roadside rest emits occasional thoughts.
// persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
@ -1076,6 +1400,7 @@ func ProcessSingleHeroMovementTick(
onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
) {
if graph == nil {
return
@ -1087,8 +1412,36 @@ func ProcessSingleHeroMovementTick(
case model.StateResting:
// Advance logical movement time while idle so leaving town does not apply a huge dt (teleport).
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = movementTickRate().Seconds()
}
hm.LastMoveTick = now
if hm.roadsideRestInProgress() {
if hm.roadsideRestAtCamp(now) {
hm.applyRoadsideRestHeal(dt)
}
emitRoadsideRestThoughts(heroID, hm, now, adventureLog)
} else {
hm.applyTownRestHeal(dt)
}
// Keep Hero.TownPause (restUntil) aligned with hm for any code reading hero between ticks.
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
if now.After(hm.RestUntil) {
if hm.roadsideRestInProgress() {
hm.endRoadsideRest()
hm.LastMoveTick = now
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
return
}
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
@ -1100,6 +1453,7 @@ func ProcessSingleHeroMovementTick(
}
case model.StateInTown:
cfg := tuning.Get()
// Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time.
hm.LastMoveTick = now
// NPC visit pause ended: clear visit log state before the next roll.
@ -1113,9 +1467,10 @@ func ProcessSingleHeroMovementTick(
if len(hm.TownNPCQueue) == 0 {
if hm.TownLeaveAt.IsZero() {
hm.TownLeaveAt = now.Add(TownNPCVisitTownPause)
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
}
if now.Before(hm.TownLeaveAt) {
hm.SyncToHero()
return
}
hm.TownLeaveAt = time.Time{}
@ -1130,9 +1485,10 @@ func ProcessSingleHeroMovementTick(
return
}
if now.Before(hm.NextTownNPCRollAt) {
hm.SyncToHero()
return
}
if rand.Float64() < townNPCVisitChance {
if rand.Float64() < cfg.TownNPCVisitChance {
npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok {
@ -1145,14 +1501,26 @@ func ProcessSingleHeroMovementTick(
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
if npc.Type == "merchant" {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
if soldItems > 0 && adventureLog != nil {
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
}
hm.NextTownNPCRollAt = now.Add(TownNPCVisitNarrationBlock)
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {
hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss)
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
}
hm.SyncToHero()
case model.StateWalking:
cfg := tuning.Get()
hm.expireAdventureIfNeeded(now)
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
hm.Road = nil
@ -1180,38 +1548,15 @@ func ProcessSingleHeroMovementTick(
}
}
if hm.roadsideRestInProgress() {
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = MovementTickRate.Seconds()
}
hm.LastMoveTick = now
hm.applyRoadsideRestHeal(dt)
emitRoadsideRestThoughts(heroID, hm, now, adventureLog)
timeUp := !now.Before(hm.RoadsideRestEndAt)
hpOk := hm.Hero != nil && hm.Hero.MaxHP > 0 &&
float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) >= RoadsideRestExitHP
if timeUp || hpOk {
hm.endRoadsideRest()
} else {
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.Hero.PositionX = hm.CurrentX
hm.Hero.PositionY = hm.CurrentY
return
}
}
hm.tryStartRoadsideRest(now)
if hm.roadsideRestInProgress() {
if hm.State == model.StateResting && hm.roadsideRestInProgress() {
hm.LastMoveTick = now
emitRoadsideRestThoughts(heroID, hm, now, adventureLog)
if sender != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.Hero.PositionX = hm.CurrentX
hm.Hero.PositionY = hm.CurrentY
hm.SyncToHero()
return
}
@ -1243,6 +1588,9 @@ func ProcessSingleHeroMovementTick(
}
hm.SyncToHero()
if persistAfterTownEnter != nil {
persistAfterTownEnter(hm.Hero)
}
return
}
@ -1262,7 +1610,7 @@ func ProcessSingleHeroMovementTick(
if sender != nil || onMerchantEncounter != nil {
hm.LastEncounterAt = now
if sender != nil {
hm.WanderingMerchantDeadline = now.Add(WanderingMerchantPromptTimeout)
hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0,
NPCName: "Wandering Merchant",

@ -10,6 +10,7 @@ import (
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// OfflineSimulator runs periodic background ticks for heroes that are offline,
@ -23,11 +24,15 @@ type OfflineSimulator struct {
logger *slog.Logger
// isPaused, when set, skips simulation ticks while global server time is frozen.
isPaused func() bool
// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session)
// so the same hero is not simulated twice.
skipIfLive func(heroID int64) bool
}
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause).
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool) *OfflineSimulator {
// skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick.
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator {
return &OfflineSimulator{
store: store,
logStore: logStore,
@ -35,6 +40,7 @@ func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, g
interval: 30 * time.Second,
logger: logger,
isPaused: isPaused,
skipIfLive: skipIfLive,
}
}
@ -74,6 +80,9 @@ func (s *OfflineSimulator) processTick(ctx context.Context) {
s.logger.Debug("offline simulator tick", "offline_heroes", len(heroes))
for _, hero := range heroes {
if s.skipIfLive != nil && s.skipIfLive(hero.ID) {
continue
}
if err := s.simulateHeroTick(ctx, hero); err != nil {
s.logger.Error("offline simulator: hero tick failed",
"hero_id", hero.ID,
@ -84,21 +93,22 @@ func (s *OfflineSimulator) processTick(ctx context.Context) {
}
}
// simulateHeroTick catches up movement (500ms steps) from hero.UpdatedAt to now,
// simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now,
// then persists. Random encounters use the same rolls as online; combat is resolved
// synchronously via SimulateOneFight (no WebSocket).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero) error {
now := time.Now()
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > 1*time.Hour {
hero.HP = hero.MaxHP / 2
// Auto-revive after configured downtime (autoReviveAfterMs).
gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(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, "Auto-revived after 1 hour")
s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
}
// Dead heroes cannot move or fight.
@ -106,22 +116,18 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
return nil
}
// Do not simulate roads/encounters while the hero is in town (rest or NPC tour).
if hero.State == model.StateInTown || hero.State == model.StateResting {
return nil
}
if hero.MoveState == string(model.StateInTown) || hero.MoveState == string(model.StateResting) {
return nil
}
if s.graph == nil {
s.logger.Warn("offline simulator: road graph nil, skipping movement tick", "hero_id", hero.ID)
return nil
}
hm := NewHeroMovement(hero, s.graph, now)
if hm.State == model.StateFighting {
return nil
}
if hero.UpdatedAt.IsZero() {
hm.LastMoveTick = now.Add(-MovementTickRate)
hm.LastMoveTick = now.Add(-movementTickRate())
} else {
hm.LastMoveTick = hero.UpdatedAt
}
@ -144,7 +150,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
step := 0
for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
step++
next := hm.LastMoveTick.Add(MovementTickRate)
next := hm.LastMoveTick.Add(movementTickRate())
if next.After(now) {
next = now
}
@ -159,7 +165,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
adventureLog := func(heroID int64, msg string) {
s.addLog(ctx, heroID, msg)
}
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog)
ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil)
if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break
}
@ -220,8 +226,11 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
hero.HP -= dmgTaken
// Use potion if HP drops below 30% and hero has potions.
if hero.HP > 0 && hero.HP < hero.MaxHP*30/100 && hero.Potions > 0 {
healAmount := hero.MaxHP * 30 / 100
if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 {
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}
hero.HP += healAmount
if hero.HP > hero.MaxHP {
hero.HP = hero.MaxHP
@ -270,8 +279,9 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
item := model.NewGearItem(family, ilvl, drop.Rarity)
TryEquipOrStashOffline(hero, item, now, onInventoryDiscard)
} else if allowSell {
hero.Gold += model.AutoSellPrices[drop.Rarity]
goldGained += model.AutoSellPrices[drop.Rarity]
price := model.AutoSellPrice(drop.Rarity)
hero.Gold += price
goldGained += price
}
}
}
@ -333,17 +343,18 @@ func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
overcapDelta = 0
}
hpMul := 1.0 + bandDelta*0.05 + overcapDelta*0.025
atkMul := 1.0 + bandDelta*0.035 + overcapDelta*0.018
defMul := 1.0 + bandDelta*0.035 + overcapDelta*0.018
cfg := tuning.Get()
hpMul := 1.0 + bandDelta*cfg.EnemyScaleBandHP + overcapDelta*cfg.EnemyScaleOvercapHP
atkMul := 1.0 + bandDelta*cfg.EnemyScaleBandATK + overcapDelta*cfg.EnemyScaleOvercapATK
defMul := 1.0 + bandDelta*cfg.EnemyScaleBandDEF + overcapDelta*cfg.EnemyScaleOvercapDEF
picked.MaxHP = max(1, int(float64(picked.MaxHP)*hpMul))
picked.HP = picked.MaxHP
picked.Attack = max(1, int(float64(picked.Attack)*atkMul))
picked.Defense = max(0, int(float64(picked.Defense)*defMul))
xpMul := 1.0 + bandDelta*0.05 + overcapDelta*0.03
goldMul := 1.0 + bandDelta*0.05 + overcapDelta*0.025
xpMul := 1.0 + bandDelta*cfg.EnemyScaleBandXP + overcapDelta*cfg.EnemyScaleOvercapXP
goldMul := 1.0 + bandDelta*cfg.EnemyScaleBandGold + overcapDelta*cfg.EnemyScaleOvercapGold
picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul))
picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul))

File diff suppressed because it is too large Load Diff

@ -10,7 +10,7 @@ import (
func TestConsumeFreeBuffCharge_SubscriptionSkipsQuota(t *testing.T) {
h := &model.Hero{SubscriptionActive: true, BuffFreeChargesRemaining: 0}
now := time.Now()
if err := consumeFreeBuffCharge(h, now); err != nil {
if err := consumeFreeBuffCharge(h, model.BuffRush, now); err != nil {
t.Fatal(err)
}
if h.BuffFreeChargesRemaining != 0 {
@ -23,8 +23,11 @@ func TestConsumeFreeBuffCharge_Exhausted(t *testing.T) {
h := &model.Hero{
BuffFreeChargesRemaining: 0,
BuffQuotaPeriodEnd: &end,
BuffCharges: map[string]model.BuffChargeState{
string(model.BuffRush): {Remaining: 0, PeriodEnd: &end},
},
}
if err := consumeFreeBuffCharge(h, time.Now()); err == nil {
if err := consumeFreeBuffCharge(h, model.BuffRush, time.Now()); err == nil {
t.Fatal("expected error when exhausted")
}
}

@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/rand"
@ -17,16 +18,10 @@ import (
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
"github.com/denisovdennis/autohero/internal/world"
)
// maxLootHistory is the number of recent loot entries kept per hero in memory.
const maxLootHistory = 50
// encounterCombatCooldown limits how often the server grants a combat encounter.
// Client polls roughly every walk segment (~2.55.5s); 16s minimum spacing ≈ 4× lower fight rate.
const encounterCombatCooldown = 16 * time.Second
type GameHandler struct {
engine *game.Engine
store *storage.HeroStore
@ -149,7 +144,7 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
h.logger.Warn("failed to create gear item", "slot", slot, "error", err)
cancel()
if inTown {
sellPrice := model.AutoSellPrices[drop.Rarity]
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
drop.GoldAmount = sellPrice
} else {
@ -204,7 +199,7 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
}
}
} else if inTown {
sellPrice := model.AutoSellPrices[drop.Rarity]
sellPrice := model.AutoSellPrice(drop.Rarity)
hero.Gold += sellPrice
drop.GoldAmount = sellPrice
}
@ -221,8 +216,12 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
CreatedAt: now,
}
h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry)
if len(h.lootCache[hero.ID]) > maxLootHistory {
h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-maxLootHistory:]
lootHistoryLimit := int(tuning.Get().LootHistoryLimit)
if lootHistoryLimit < 1 {
lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit)
}
if len(h.lootCache[hero.ID]) > lootHistoryLimit {
h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:]
}
}
@ -466,7 +465,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
hero.TotalDeaths++
hero.KillsSinceDeath = 0
hero.HP = hero.MaxHP / 2
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 {
hero.HP = 1
}
@ -545,8 +544,9 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
}
now := time.Now()
cfg := tuning.Get()
h.encounterMu.Lock()
if t, ok := h.lastCombatEncounterAt[hero.ID]; ok && now.Sub(t) < encounterCombatCooldown {
if t, ok := h.lastCombatEncounterAt[hero.ID]; ok && now.Sub(t) < time.Duration(cfg.RESTEncounterCooldownMs)*time.Millisecond {
h.encounterMu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{
"type": "no_encounter",
@ -557,8 +557,8 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
h.encounterMu.Unlock()
// 10% chance to encounter a wandering NPC instead of an enemy.
if rand.Float64() < 0.10 {
cost := int64(20 + hero.Level*5)
if rand.Float64() < cfg.RESTEncounterNPCChance {
cost := game.WanderingMerchantCost(hero.Level)
h.addLog(hero.ID, "Encountered a Wandering Merchant on the road")
h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now
@ -614,26 +614,49 @@ func pickEnemyForLevel(level int) model.Enemy {
}
// tryAutoEquipGear uses the in-memory combat rating comparison to decide whether
// to equip a new gear item. If it improves combat rating by >= 3%, equips it
// to equip a new gear item. If it clears the runtime-configured improvement threshold, equips it
// (persisting to DB if gearStore is available). Returns true if equipped.
func (h *GameHandler) tryAutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) bool {
hero.EnsureGearMap()
slot := item.Slot
var prev *model.GearItem
if hero.Gear != nil {
prev = hero.Gear[slot]
}
if !game.TryAutoEquipInMemory(hero, item, now) {
return false
}
h.persistGearEquip(hero.ID, item)
err := h.persistGearEquip(hero.ID, item)
if err != nil {
if prev == nil {
delete(hero.Gear, slot)
} else {
hero.Gear[slot] = prev
}
hero.RefreshDerivedCombatStats(now)
if errors.Is(err, storage.ErrInventoryFull) {
h.logger.Warn("persist gear equip skipped: inventory full (free a slot to swap)",
"hero_id", hero.ID, "slot", item.Slot)
} else {
h.logger.Warn("failed to persist gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err)
}
return false
}
if prev != nil && prev.ID != item.ID {
hero.EnsureInventorySlice()
hero.Inventory = append(hero.Inventory, prev)
}
return true
}
// persistGearEquip saves the equip to the hero_gear table if gearStore is available.
func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) {
func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error {
if h.gearStore == nil || item.ID == 0 {
return
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID); err != nil {
h.logger.Warn("failed to persist gear equip", "hero_id", heroID, "slot", item.Slot, "error", err)
}
return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID)
}
// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.
@ -878,8 +901,8 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
}
// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
if (hero.State == model.StateDead || hero.HP <= 0) && gapDuration > 1*time.Hour {
hero.HP = hero.MaxHP / 2
if (hero.State == model.StateDead || hero.HP <= 0) && gapDuration > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond {
hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
if hero.HP < 1 {
hero.HP = 1
}
@ -1019,8 +1042,8 @@ 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) > 1*time.Hour {
hero.HP = hero.MaxHP / 2
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
}
@ -1260,10 +1283,10 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request)
}
// Determine price.
priceRUB := model.BuffRefillPriceRUB
priceRUB := model.BuffRefillPrice()
paymentType := model.PaymentBuffReplenish
if bt == model.BuffResurrection {
priceRUB = model.ResurrectionRefillPriceRUB
priceRUB = model.ResurrectionRefillPrice()
paymentType = model.PaymentResurrectionReplenish
}
@ -1309,7 +1332,7 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, hero)
}
// PurchaseSubscription purchases a weekly subscription (x2 buffs, x2 revives).
// PurchaseSubscription purchases the configured subscription duration (x2 buffs, x2 revives).
// POST /api/v1/hero/purchase-subscription
func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
@ -1334,7 +1357,7 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques
payment := &model.Payment{
HeroID: hero.ID,
Type: "subscription_weekly",
AmountRUB: model.SubscriptionWeeklyPriceRUB,
AmountRUB: int(model.SubscriptionWeeklyPrice()),
Status: model.PaymentCompleted,
CreatedAt: now,
CompletedAt: &now,
@ -1365,13 +1388,13 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques
}
h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt)
h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPriceRUB))
h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice()))
hero.RefreshDerivedCombatStats(now)
writeJSON(w, http.StatusOK, map[string]any{
"hero": hero,
"expiresAt": hero.SubscriptionExpiresAt,
"priceRub": model.SubscriptionWeeklyPriceRUB,
"priceRub": model.SubscriptionWeeklyPrice(),
})
}
@ -1448,7 +1471,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
}
// Heal 30% of maxHP, capped at maxHP.
healAmount := hero.MaxHP * 30 / 100
healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
if healAmount < 1 {
healAmount = 1
}

@ -16,6 +16,7 @@ import (
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
"github.com/denisovdennis/autohero/internal/tuning"
)
// NPCHandler serves NPC interaction API endpoints.
@ -186,18 +187,20 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
}
case "merchant":
cfg := tuning.Get()
actions = append(actions, model.NPCInteractAction{
ActionType: "shop_item",
ItemName: "Healing Potion",
ItemCost: 50,
ItemCost: cfg.NPCCostPotion,
Description: "Restores health. Always handy in a pinch.",
})
case "healer":
cfg := tuning.Get()
actions = append(actions, model.NPCInteractAction{
ActionType: "heal",
ItemName: "Full Heal",
ItemCost: 100,
ItemCost: cfg.NPCCostHeal,
Description: "Restore hero to full HP.",
})
}
@ -265,7 +268,8 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
return
}
const nearbyRadius = 3.0
cfg := tuning.Get()
nearbyRadius := cfg.NPCCostNearbyRadius
var result []model.NearbyNPCEntry
for _, town := range towns {
@ -298,15 +302,13 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
}
// npcPersistGearEquip writes hero_gear when a merchant drop is equipped.
func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) {
func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) error {
if h.gearStore == nil || item == nil || item.ID == 0 {
return
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID); err != nil {
h.logger.Warn("failed to persist merchant gear equip", "hero_id", heroID, "slot", item.Slot, "error", err)
}
return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID)
}
// grantMerchantLoot rolls one random gear piece; auto-equips if better.
@ -314,9 +316,18 @@ func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) {
// Cost must already be deducted from hero.Gold.
func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time) (*model.LootDrop, error) {
slots := model.AllEquipmentSlots
slot := slots[rand.Intn(len(slots))]
family := model.PickGearFamily(slot)
if family == nil || h.gearStore == nil {
if h.gearStore == nil {
return nil, errors.New("failed to roll gear")
}
var family *model.GearFamily
for _, idx := range rand.Perm(len(slots)) {
slot := slots[idx]
family = model.PickGearFamily(slot)
if family != nil {
break
}
}
if family == nil {
return nil, errors.New("failed to roll gear")
}
@ -328,15 +339,41 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
err := h.gearStore.CreateItem(ctxCreate, item)
cancel()
if err != nil {
h.logger.Warn("failed to create alms gear item", "slot", slot, "error", err)
h.logger.Warn("failed to create alms gear item", "slot", family.Slot, "error", err)
return nil, err
}
hero.EnsureGearMap()
slot := item.Slot
var prev *model.GearItem
if hero.Gear != nil {
prev = hero.Gear[slot]
}
equipped := game.TryAutoEquipInMemory(hero, item, now)
if equipped {
h.npcPersistGearEquip(hero.ID, item)
h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, equipped %s", item.Name))
if err := h.npcPersistGearEquip(hero.ID, item); err != nil {
if prev == nil {
delete(hero.Gear, slot)
} else {
hero.Gear[slot] = prev
}
hero.RefreshDerivedCombatStats(now)
if errors.Is(err, storage.ErrInventoryFull) {
h.logger.Warn("merchant gear equip skipped: inventory full",
"hero_id", hero.ID, "slot", item.Slot)
} else {
h.logger.Warn("failed to persist merchant gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err)
}
equipped = false
} else {
if prev != nil && prev.ID != item.ID {
hero.EnsureInventorySlice()
hero.Inventory = append(hero.Inventory, prev)
}
h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, equipped %s", item.Name))
}
}
if !equipped {
hero.EnsureInventorySlice()
if len(hero.Inventory) >= model.MaxInventorySlots {
ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
@ -383,7 +420,7 @@ func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) erro
return errors.New("hero not found")
}
cost := int64(20 + hero.Level*5)
cost := game.WanderingMerchantCost(hero.Level)
if hero.Gold < cost {
return fmt.Errorf("not enough gold (need %d, have %d)", cost, hero.Gold)
}
@ -458,7 +495,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
return
}
cost := int64(20 + hero.Level*5)
cost := game.WanderingMerchantCost(hero.Level)
if hero.Gold < cost {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", cost, hero.Gold),
@ -503,7 +540,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
}
// HealHero handles POST /api/v1/hero/npc-heal.
// A healer NPC restores the hero to full HP for 100 gold.
// A healer NPC restores the hero to full HP for the runtime-configured gold cost.
func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
@ -556,7 +593,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
}
}
const healCost int64 = 100
healCost := tuning.Get().NPCCostHeal
if hero.Gold < healCost {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", healCost, hero.Gold),
@ -584,7 +621,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
}
// BuyPotion handles POST /api/v1/hero/npc-buy-potion.
// A merchant NPC sells a healing potion for 50 gold.
// A merchant NPC sells a healing potion for the runtime-configured gold cost.
func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r)
if !ok {
@ -609,7 +646,7 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return
}
const potionCost int64 = 50
potionCost := tuning.Get().NPCCostPotion
if hero.Gold < potionCost {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold),
@ -631,6 +668,6 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
h.addLog(hero.ID, "Purchased a Healing Potion from a merchant")
writeJSON(w, http.StatusOK, map[string]any{
"hero": hero,
"message": "You purchased a Healing Potion for 50 gold.",
"message": fmt.Sprintf("You purchased a Healing Potion for %d gold.", potionCost),
})
}

@ -189,7 +189,7 @@ func (h *QuestHandler) ClaimQuestReward(w http.ResponseWriter, r *http.Request)
questIDStr := chi.URLParam(r, "questId")
questID, err := strconv.ParseInt(questIDStr, 10, 64)
if err != nil {
h.logger.Error("Error claiming quest", err)
h.logger.Error("error claiming quest", "error", err)
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid questId",
})

@ -54,50 +54,6 @@ func (ab *ActiveBuff) IsExpired(now time.Time) bool {
return now.After(ab.ExpiresAt)
}
// DefaultBuffs defines the base buff definitions.
var DefaultBuffs = map[BuffType]Buff{
BuffRush: {
Type: BuffRush, Name: "Rush",
Duration: 5 * time.Minute, Magnitude: 0.5, // +50% movement
CooldownDuration: 15 * time.Minute,
},
BuffRage: {
Type: BuffRage, Name: "Rage",
Duration: 3 * time.Minute, Magnitude: 1.0, // +100% damage
CooldownDuration: 10 * time.Minute,
},
BuffShield: {
Type: BuffShield, Name: "Shield",
Duration: 5 * time.Minute, Magnitude: 0.5, // -50% incoming damage
CooldownDuration: 12 * time.Minute,
},
BuffLuck: {
Type: BuffLuck, Name: "Luck",
Duration: 30 * time.Minute, Magnitude: 1.5, // x2.5 loot
CooldownDuration: 2 * time.Hour,
},
BuffResurrection: {
Type: BuffResurrection, Name: "Resurrection",
Duration: 10 * time.Minute, Magnitude: 0.5, // revive with 50% HP
CooldownDuration: 30 * time.Minute,
},
BuffHeal: {
Type: BuffHeal, Name: "Heal",
Duration: 1 * time.Second, Magnitude: 0.5, // +50% HP (instant)
CooldownDuration: 5 * time.Minute,
},
BuffPowerPotion: {
Type: BuffPowerPotion, Name: "Power Potion",
Duration: 5 * time.Minute, Magnitude: 1.5, // +150% damage
CooldownDuration: 20 * time.Minute,
},
BuffWarCry: {
Type: BuffWarCry, Name: "War Cry",
Duration: 3 * time.Minute, Magnitude: 1.0, // +100% attack speed
CooldownDuration: 10 * time.Minute,
},
}
// ---- Debuffs ----
type DebuffType string
@ -147,38 +103,6 @@ func (ad *ActiveDebuff) IsExpired(now time.Time) bool {
return now.After(ad.ExpiresAt)
}
// DefaultDebuffs defines the base debuff definitions.
var DefaultDebuffs = map[DebuffType]Debuff{
DebuffPoison: {
Type: DebuffPoison, Name: "Poison",
Duration: 5 * time.Second, Magnitude: 0.02, // -2% HP/sec
},
DebuffFreeze: {
Type: DebuffFreeze, Name: "Freeze",
Duration: 3 * time.Second, Magnitude: 0.50, // -50% attack speed
},
DebuffBurn: {
Type: DebuffBurn, Name: "Burn",
Duration: 4 * time.Second, Magnitude: 0.03, // -3% HP/sec
},
DebuffStun: {
Type: DebuffStun, Name: "Stun",
Duration: 2 * time.Second, Magnitude: 1.0, // no attacks
},
DebuffSlow: {
Type: DebuffSlow, Name: "Slow",
Duration: 4 * time.Second, Magnitude: 0.40, // -40% movement
},
DebuffWeaken: {
Type: DebuffWeaken, Name: "Weaken",
Duration: 5 * time.Second, Magnitude: 0.30, // -30% hero outgoing damage
},
DebuffIceSlow: {
Type: DebuffIceSlow, Name: "Ice Slow",
Duration: 4 * time.Second, Magnitude: 0.20, // -20% attack speed (Ice Guardian spec §4.2)
},
}
// RemoveBuffType returns buffs without any active entry of the given type (e.g. consume Resurrection on manual revive).
func RemoveBuffType(buff []ActiveBuff, remove BuffType) []ActiveBuff {
var out []ActiveBuff

@ -3,16 +3,9 @@ package model
import (
"fmt"
"time"
)
// FreeBuffActivationsPerPeriod is the legacy shared limit. Kept for backward compatibility.
const FreeBuffActivationsPerPeriod = 2
// SubscriptionWeeklyPriceRUB is the price for a 7-day subscription in rubles.
const SubscriptionWeeklyPriceRUB = 299
// SubscriptionDuration is how long a subscription lasts.
const SubscriptionDuration = 7 * 24 * time.Hour
"github.com/denisovdennis/autohero/internal/tuning"
)
// BuffFreeChargesPerType defines the per-buff free charge limits per 24h window.
var BuffFreeChargesPerType = map[BuffType]int{
@ -38,6 +31,41 @@ var BuffSubscriberChargesPerType = map[BuffType]int{
BuffWarCry: 4,
}
func SubscriptionWeeklyPrice() int64 {
return tuning.Get().SubscriptionWeeklyPriceRUB
}
func SubscriptionDurationRuntime() time.Duration {
return time.Duration(tuning.Get().SubscriptionDurationMs) * time.Millisecond
}
func SubscriptionDurationLabel() string {
d := SubscriptionDurationRuntime()
if d <= 0 {
return "0 hours"
}
if d%(24*time.Hour) == 0 {
days := int64(d / (24 * time.Hour))
if days == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", days)
}
hours := int64(d / time.Hour)
if hours == 1 {
return "1 hour"
}
return fmt.Sprintf("%d hours", hours)
}
func BuffChargePeriod() time.Duration {
return time.Duration(tuning.Get().BuffChargePeriodMs) * time.Millisecond
}
func FreeBuffActivationsPerPeriodRuntime() int {
return int(tuning.Get().FreeBuffActivationsPerPeriod)
}
// RefreshSubscription checks if the subscription has expired and updates SubscriptionActive.
// Returns true if the hero state was changed (caller should persist).
func (h *Hero) RefreshSubscription(now time.Time) bool {
@ -57,10 +85,10 @@ func (h *Hero) RefreshSubscription(now time.Time) bool {
func (h *Hero) ActivateSubscription(now time.Time) {
if h.SubscriptionActive && h.SubscriptionExpiresAt != nil && h.SubscriptionExpiresAt.After(now) {
// Extend from current expiry.
extended := h.SubscriptionExpiresAt.Add(SubscriptionDuration)
extended := h.SubscriptionExpiresAt.Add(SubscriptionDurationRuntime())
h.SubscriptionExpiresAt = &extended
} else {
expires := now.Add(SubscriptionDuration)
expires := now.Add(SubscriptionDurationRuntime())
h.SubscriptionExpiresAt = &expires
}
h.SubscriptionActive = true
@ -76,15 +104,15 @@ func (h *Hero) MaxBuffCharges(bt BuffType) int {
if v, ok := BuffFreeChargesPerType[bt]; ok {
return v
}
return FreeBuffActivationsPerPeriod
return FreeBuffActivationsPerPeriodRuntime()
}
// MaxRevives returns the max free revives per period (1 free, 2 for subscribers).
func (h *Hero) MaxRevives() int {
if h.SubscriptionActive {
return 2
return int(tuning.Get().MaxRevivesSubscriber)
}
return 1
return int(tuning.Get().MaxRevivesFree)
}
// ApplyBuffQuotaRollover refills free buff charges when the 24h window has passed.
@ -100,8 +128,8 @@ func (h *Hero) ApplyBuffQuotaRollover(now time.Time) bool {
}
changed := false
for now.After(*h.BuffQuotaPeriodEnd) {
h.BuffFreeChargesRemaining = FreeBuffActivationsPerPeriod
next := h.BuffQuotaPeriodEnd.Add(24 * time.Hour)
h.BuffFreeChargesRemaining = FreeBuffActivationsPerPeriodRuntime()
next := h.BuffQuotaPeriodEnd.Add(BuffChargePeriod())
h.BuffQuotaPeriodEnd = &next
changed = true
}
@ -120,7 +148,7 @@ func (h *Hero) GetBuffCharges(bt BuffType, now time.Time) BuffChargeState {
state, exists := h.BuffCharges[string(bt)]
if !exists {
// First access for this buff type — initialize with full charges.
pe := now.Add(24 * time.Hour)
pe := now.Add(BuffChargePeriod())
state = BuffChargeState{
Remaining: maxCharges,
PeriodEnd: &pe,
@ -132,7 +160,7 @@ func (h *Hero) GetBuffCharges(bt BuffType, now time.Time) BuffChargeState {
// Roll over if the period has expired.
if state.PeriodEnd != nil && now.After(*state.PeriodEnd) {
for state.PeriodEnd != nil && now.After(*state.PeriodEnd) {
next := state.PeriodEnd.Add(24 * time.Hour)
next := state.PeriodEnd.Add(BuffChargePeriod())
state.PeriodEnd = &next
}
state.Remaining = maxCharges
@ -179,13 +207,10 @@ func (h *Hero) RefundBuffCharge(bt BuffType) {
if !exists {
return
}
maxCharges := BuffFreeChargesPerType[bt]
if maxCharges == 0 {
maxCharges = FreeBuffActivationsPerPeriod
}
maxCap := h.MaxBuffCharges(bt)
state.Remaining++
if state.Remaining > maxCharges {
state.Remaining = maxCharges
if state.Remaining > maxCap {
state.Remaining = maxCap
}
h.BuffCharges[string(bt)] = state
@ -200,13 +225,10 @@ func (h *Hero) ResetBuffCharges(bt *BuffType, now time.Time) {
h.BuffCharges = make(map[string]BuffChargeState)
}
pe := now.Add(24 * time.Hour)
pe := now.Add(BuffChargePeriod())
if bt != nil {
maxCharges := BuffFreeChargesPerType[*bt]
if maxCharges == 0 {
maxCharges = FreeBuffActivationsPerPeriod
}
maxCharges := h.MaxBuffCharges(*bt)
h.BuffCharges[string(*bt)] = BuffChargeState{
Remaining: maxCharges,
PeriodEnd: &pe,
@ -223,7 +245,7 @@ func (h *Hero) ResetBuffCharges(bt *BuffType, now time.Time) {
}
// Also reset legacy counter.
h.BuffFreeChargesRemaining = FreeBuffActivationsPerPeriod
h.BuffFreeChargesRemaining = FreeBuffActivationsPerPeriodRuntime()
h.BuffQuotaPeriodEnd = &pe
}
@ -234,7 +256,7 @@ func (h *Hero) EnsureBuffChargesPopulated(now time.Time) bool {
h.BuffCharges = make(map[string]BuffChargeState)
}
if len(h.BuffCharges) == 0 {
pe := now.Add(24 * time.Hour)
pe := now.Add(BuffChargePeriod())
if h.BuffQuotaPeriodEnd != nil {
pe = *h.BuffQuotaPeriodEnd
}

@ -3,6 +3,8 @@ package model
import (
"testing"
"time"
"github.com/denisovdennis/autohero/internal/tuning"
)
func TestApplyBuffQuotaRollover_RefillsWhenWindowPassed(t *testing.T) {
@ -15,8 +17,9 @@ func TestApplyBuffQuotaRollover_RefillsWhenWindowPassed(t *testing.T) {
if !h.ApplyBuffQuotaRollover(now) {
t.Fatal("expected rollover to mutate hero")
}
if h.BuffFreeChargesRemaining != FreeBuffActivationsPerPeriod {
t.Fatalf("charges: want %d, got %d", FreeBuffActivationsPerPeriod, h.BuffFreeChargesRemaining)
want := int(tuning.DefaultValues().FreeBuffActivationsPerPeriod)
if h.BuffFreeChargesRemaining != want {
t.Fatalf("charges: want %d, got %d", want, h.BuffFreeChargesRemaining)
}
if !h.BuffQuotaPeriodEnd.After(end) {
t.Fatalf("expected period end to advance, got %v", h.BuffQuotaPeriodEnd)
@ -34,3 +37,19 @@ func TestApplyBuffQuotaRollover_NoOpWhenSubscribed(t *testing.T) {
t.Fatal("subscription should skip rollover")
}
}
func TestResetBuffCharges_SubscriberGetsDoubleCap(t *testing.T) {
now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
bt := BuffRush
h := &Hero{
SubscriptionActive: true,
BuffCharges: map[string]BuffChargeState{
string(bt): {Remaining: 0, PeriodEnd: nil},
},
}
h.ResetBuffCharges(&bt, now)
st := h.BuffCharges[string(bt)]
if st.Remaining != BuffSubscriberChargesPerType[bt] {
t.Fatalf("subscriber refill: want remaining %d, got %d", BuffSubscriberChargesPerType[bt], st.Remaining)
}
}

@ -165,3 +165,10 @@ var EnemyTemplates = map[EnemyType]Enemy{
SpecialAbilities: []SpecialAbility{AbilityStun, AbilityChainLightning},
},
}
func SetEnemyTemplates(next map[EnemyType]Enemy) {
if len(next) == 0 {
return
}
EnemyTemplates = next
}

@ -23,21 +23,22 @@ type GearItem struct {
// GearFamily is a template for generating gear drops from the unified catalog.
type GearFamily struct {
Slot EquipmentSlot
FormID string
Name string
Subtype string // "daggers", "sword", "axe", "light", "medium", "heavy", ""
BasePrimary int
StatType string
SpeedModifier float64
BaseCrit float64
AgilityBonus int
SetName string
SpecialEffect string
Slot EquipmentSlot `json:"slot"`
FormID string `json:"formId"`
Name string `json:"name"`
Subtype string `json:"subtype"` // "daggers", "sword", "axe", "light", "medium", "heavy", ""
BasePrimary int `json:"basePrimary"`
StatType string `json:"statType"`
SpeedModifier float64 `json:"speedModifier"`
BaseCrit float64 `json:"baseCrit"`
AgilityBonus int `json:"agilityBonus"`
SetName string `json:"setName,omitempty"`
SpecialEffect string `json:"specialEffect,omitempty"`
}
// GearCatalog is the unified catalog of all gear families.
var GearCatalog []GearFamily
var defaultGearCatalog []GearFamily
// ArmorSetBonuses maps set names to their bonus description.
var GearSetBonuses = map[string]string{
@ -98,10 +99,35 @@ func init() {
for _, gf := range GearCatalog {
gearBySlot[gf.Slot] = append(gearBySlot[gf.Slot], gf)
}
defaultGearCatalog = append([]GearFamily(nil), GearCatalog...)
}
var gearBySlot map[EquipmentSlot][]GearFamily
func SetGearCatalog(families []GearFamily) {
if len(families) == 0 {
return
}
merged := append([]GearFamily(nil), families...)
slotsFromDB := make(map[EquipmentSlot]struct{}, len(families))
for _, gf := range families {
slotsFromDB[gf.Slot] = struct{}{}
}
for _, fallback := range defaultGearCatalog {
if _, ok := slotsFromDB[fallback.Slot]; ok {
continue
}
merged = append(merged, fallback)
}
GearCatalog = merged
gearBySlot = make(map[EquipmentSlot][]GearFamily)
for _, gf := range GearCatalog {
gearBySlot[gf.Slot] = append(gearBySlot[gf.Slot], gf)
}
}
// PickGearFamily selects a random gear family for the given slot.
// Returns nil if no families exist for the slot.
func PickGearFamily(slot EquipmentSlot) *GearFamily {

@ -3,13 +3,11 @@ package model
import (
"math"
"time"
"github.com/denisovdennis/autohero/internal/tuning"
)
const (
// AgilityCoef follows the project combat specification (agility contribution to APS).
AgilityCoef = 0.03
// MaxAttackSpeed enforces the target cap of ~4 attacks/sec.
MaxAttackSpeed = 4.0
// MaxInventorySlots is the maximum unequipped gear items carried at once.
MaxInventorySlots = 40
)
@ -66,6 +64,10 @@ type Hero struct {
CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
MoveState string `json:"moveState"`
// RestKind mirrors movement rest context for clients ("town" | "roadside").
RestKind string `json:"restKind,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
@ -86,16 +88,17 @@ type BuffChargeState struct {
// L 1029: round(1450 * 1.15^(L-10))
// L 30+: round(23000 * 1.10^(L-30))
func XPToNextLevel(level int) int64 {
cfg := tuning.Get()
if level < 1 {
level = 1
}
switch {
case level <= 9:
return int64(math.Round(180 * math.Pow(1.28, float64(level-1))))
return int64(math.Round(cfg.XPCurveEarlyBase * math.Pow(cfg.XPCurveEarlyScale, float64(level-1))))
case level <= 29:
return int64(math.Round(1450 * math.Pow(1.15, float64(level-10))))
return int64(math.Round(cfg.XPCurveMidBase * math.Pow(cfg.XPCurveMidScale, float64(level-10))))
default:
return int64(math.Round(23000 * math.Pow(1.10, float64(level-30))))
return int64(math.Round(cfg.XPCurveLateBase * math.Pow(cfg.XPCurveLateScale, float64(level-30))))
}
}
@ -116,26 +119,27 @@ func (h *Hero) LevelUp() bool {
h.Level++
// v3: ~10× rarer than v2 — same formulas, cadences ×10 (spec §3.3).
if h.Level%10 == 0 {
cfg := tuning.Get()
if cfg.LevelUpHPEvery > 0 && h.Level%int(cfg.LevelUpHPEvery) == 0 {
h.MaxHP += 1 + h.Constitution/6
}
if h.Level%30 == 0 {
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack++
}
if h.Level%30 == 0 {
if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 {
h.Defense++
}
if h.Level%40 == 0 {
if cfg.LevelUpSTREvery > 0 && h.Level%int(cfg.LevelUpSTREvery) == 0 {
h.Strength++
}
if h.Level%50 == 0 {
if cfg.LevelUpCONEvery > 0 && h.Level%int(cfg.LevelUpCONEvery) == 0 {
h.Constitution++
}
if h.Level%60 == 0 {
if cfg.LevelUpAGIEvery > 0 && h.Level%int(cfg.LevelUpAGIEvery) == 0 {
h.Agility++
}
if h.Level%100 == 0 {
if cfg.LevelUpLUCKEvery > 0 && h.Level%int(cfg.LevelUpLUCKEvery) == 0 {
h.Luck++
}
@ -206,9 +210,10 @@ func (h *Hero) EffectiveSpeedAt(now time.Time) float64 {
}
// Base attack speed derives from base speed + agility coefficient.
speed := h.Speed + float64(effectiveAgility)*AgilityCoef
if speed < 0.1 {
speed = 0.1
cfg := tuning.Get()
speed := h.Speed + float64(effectiveAgility)*cfg.AgilityCoef
if speed < cfg.MinAttackSpeed {
speed = cfg.MinAttackSpeed
}
if weapon := h.Gear[SlotMainHand]; weapon != nil {
speed *= weapon.SpeedModifier
@ -230,11 +235,11 @@ func (h *Hero) EffectiveSpeedAt(now time.Time) float64 {
speed *= (1 - ad.Debuff.Magnitude) // -20% attack speed (Ice Guardian)
}
}
if speed > MaxAttackSpeed {
speed = MaxAttackSpeed
if speed > cfg.MaxAttackSpeed {
speed = cfg.MaxAttackSpeed
}
if speed < 0.1 {
speed = 0.1
if speed < cfg.MinAttackSpeed {
speed = cfg.MinAttackSpeed
}
return speed
}

@ -4,6 +4,8 @@ import (
"math"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/tuning"
)
func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
@ -41,7 +43,7 @@ func TestDerivedCombatStatsFromBaseAttributes(t *testing.T) {
}
gotSpeed := hero.EffectiveSpeedAt(now)
wantSpeed := (1.0 + 3*AgilityCoef) * 1.3 * 0.7
wantSpeed := (1.0 + 3*tuning.DefaultValues().AgilityCoef) * 1.3 * 0.7
if math.Abs(gotSpeed-wantSpeed) > 0.001 {
t.Fatalf("expected speed %.3f, got %.3f", wantSpeed, gotSpeed)
}
@ -58,17 +60,17 @@ func TestBuffsProvideTemporaryStatEffects(t *testing.T) {
Agility: 6,
Buffs: []ActiveBuff{
{
Buff: DefaultBuffs[BuffRage],
Buff: mustBuffDef(BuffRage),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
{
Buff: DefaultBuffs[BuffWarCry],
Buff: mustBuffDef(BuffWarCry),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
{
Buff: DefaultBuffs[BuffShield],
Buff: mustBuffDef(BuffShield),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
},
@ -96,7 +98,7 @@ func TestEffectiveSpeedIsCapped(t *testing.T) {
},
Buffs: []ActiveBuff{
{
Buff: DefaultBuffs[BuffWarCry],
Buff: mustBuffDef(BuffWarCry),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(10 * time.Second),
},
@ -104,8 +106,9 @@ func TestEffectiveSpeedIsCapped(t *testing.T) {
}
got := hero.EffectiveSpeedAt(now)
if got != MaxAttackSpeed {
t.Fatalf("expected speed cap %.1f, got %.3f", MaxAttackSpeed, got)
maxAttackSpeed := tuning.DefaultValues().MaxAttackSpeed
if got != maxAttackSpeed {
t.Fatalf("expected speed cap %.1f, got %.3f", maxAttackSpeed, got)
}
}
@ -116,7 +119,7 @@ func TestRushDoesNotAffectAttackSpeed(t *testing.T) {
baseSpeed := hero.EffectiveSpeedAt(now)
hero.Buffs = []ActiveBuff{{
Buff: DefaultBuffs[BuffRush],
Buff: mustBuffDef(BuffRush),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
}}
@ -137,7 +140,7 @@ func TestRushAffectsMovementSpeed(t *testing.T) {
}
hero.Buffs = []ActiveBuff{{
Buff: DefaultBuffs[BuffRush],
Buff: mustBuffDef(BuffRush),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(5 * time.Second),
}}
@ -156,7 +159,7 @@ func TestSlowDoesNotAffectAttackSpeed(t *testing.T) {
baseSpeed := hero.EffectiveSpeedAt(now)
hero.Debuffs = []ActiveDebuff{{
Debuff: DefaultDebuffs[DebuffSlow],
Debuff: mustDebuffDef(DebuffSlow),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(3 * time.Second),
}}
@ -172,7 +175,7 @@ func TestSlowAffectsMovementSpeed(t *testing.T) {
hero := &Hero{Speed: 1.0, Agility: 5}
hero.Debuffs = []ActiveDebuff{{
Debuff: DefaultDebuffs[DebuffSlow],
Debuff: mustDebuffDef(DebuffSlow),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(3 * time.Second),
}}
@ -191,7 +194,7 @@ func TestIceSlowReducesAttackSpeed(t *testing.T) {
baseSpeed := hero.EffectiveSpeedAt(now)
hero.Debuffs = []ActiveDebuff{{
Debuff: DefaultDebuffs[DebuffIceSlow],
Debuff: mustDebuffDef(DebuffIceSlow),
AppliedAt: now.Add(-time.Second),
ExpiresAt: now.Add(3 * time.Second),
}}
@ -313,3 +316,19 @@ func TestLevelUpDoesNotRestoreHP(t *testing.T) {
t.Fatalf("HP should be unchanged after level-up: got %d, want 40", hero.HP)
}
}
func mustBuffDef(bt BuffType) Buff {
b, ok := BuffDefinition(bt)
if !ok {
panic("missing buff def: " + string(bt))
}
return b
}
func mustDebuffDef(dt DebuffType) Debuff {
d, ok := DebuffDefinition(dt)
if !ok {
panic("missing debuff def: " + string(dt))
}
return d
}

@ -3,6 +3,8 @@ package model
import (
"math"
"math/rand"
"github.com/denisovdennis/autohero/internal/tuning"
)
// IlvlFactor returns L(ilvl) = 1 + 0.03 * max(0, ilvl - 1) per spec section 6.4.
@ -11,22 +13,22 @@ func IlvlFactor(ilvl int) float64 {
if d < 0 {
d = 0
}
return 1.0 + 0.03*float64(d)
return 1.0 + tuning.Get().IlvlFactorSlope*float64(d)
}
// RarityMultiplier returns M(rarity) per spec section 6.4.2.
func RarityMultiplier(rarity Rarity) float64 {
switch rarity {
case RarityCommon:
return 1.00
return tuning.Get().RarityMultiplierCommon
case RarityUncommon:
return 1.12
return tuning.Get().RarityMultiplierUncommon
case RarityRare:
return 1.30
return tuning.Get().RarityMultiplierRare
case RarityEpic:
return 1.52
return tuning.Get().RarityMultiplierEpic
case RarityLegendary:
return 1.78
return tuning.Get().RarityMultiplierLegendary
default:
return 1.00
}
@ -44,10 +46,11 @@ func RollIlvl(monsterLevel int, isElite bool) int {
var delta int
if isElite {
r := rand.Float64()
cfg := tuning.Get()
switch {
case r < 0.4:
case r < cfg.RollIlvlEliteBaseChance:
delta = 0
case r < 0.8:
case r < cfg.RollIlvlEliteBaseChance+cfg.RollIlvlElitePlusOneChance:
delta = 1
default:
delta = 2

@ -3,6 +3,8 @@ package model
import (
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/tuning"
)
// Rarity represents item rarity tiers. Shared across weapons, armor, and loot.
@ -16,30 +18,12 @@ const (
RarityLegendary Rarity = "legendary"
)
// DropChance maps rarity to its drop probability (0.0 to 1.0).
var DropChance = map[Rarity]float64{
RarityCommon: 0.40,
RarityUncommon: 0.10,
RarityRare: 0.02,
RarityEpic: 0.003,
RarityLegendary: 0.0005,
}
// GoldRange defines minimum and maximum gold drops per rarity.
type GoldRange struct {
Min int64
Max int64
}
// GoldRanges maps rarity to gold drop ranges.
var GoldRanges = map[Rarity]GoldRange{
RarityCommon: {Min: 0, Max: 5},
RarityUncommon: {Min: 6, Max: 20},
RarityRare: {Min: 21, Max: 50},
RarityEpic: {Min: 51, Max: 120},
RarityLegendary: {Min: 121, Max: 300},
}
// LootDrop represents a single item or gold drop from defeating an enemy.
type LootDrop struct {
ItemType string `json:"itemType"` // "gold", "potion", or EquipmentSlot ("main_hand", "chest", "head", etc.)
@ -61,16 +45,6 @@ type LootHistory struct {
CreatedAt time.Time `json:"createdAt"`
}
// AutoSellPrices maps rarity to the gold value obtained by auto-selling an
// equipment drop that the hero doesn't need.
var AutoSellPrices = map[Rarity]int64{
RarityCommon: 3,
RarityUncommon: 8,
RarityRare: 20,
RarityEpic: 60,
RarityLegendary: 180,
}
// RollRarity rolls a random rarity based on the drop chance table.
// It uses a cumulative probability approach, checking from rarest to most common.
func RollRarity() Rarity {
@ -79,16 +53,17 @@ func RollRarity() Rarity {
// RarityFromRoll maps a uniform [0,1) value to a rarity tier (spec §8.1 drop bands).
func RarityFromRoll(roll float64) Rarity {
if roll < DropChance[RarityLegendary] {
cfg := tuning.Get()
if roll < cfg.LootChanceLegendary {
return RarityLegendary
}
if roll < DropChance[RarityLegendary]+DropChance[RarityEpic] {
if roll < cfg.LootChanceLegendary+cfg.LootChanceEpic {
return RarityEpic
}
if roll < DropChance[RarityLegendary]+DropChance[RarityEpic]+DropChance[RarityRare] {
if roll < cfg.LootChanceLegendary+cfg.LootChanceEpic+cfg.LootChanceRare {
return RarityRare
}
if roll < DropChance[RarityLegendary]+DropChance[RarityEpic]+DropChance[RarityRare]+DropChance[RarityUncommon] {
if roll < cfg.LootChanceLegendary+cfg.LootChanceEpic+cfg.LootChanceRare+cfg.LootChanceUncommon {
return RarityUncommon
}
return RarityCommon
@ -101,7 +76,8 @@ func RollGold(rarity Rarity) int64 {
// RollGoldWithRNG returns spec §8.2 gold for a rarity tier; if rng is nil, uses the global RNG.
func RollGoldWithRNG(rarity Rarity, rng *rand.Rand) int64 {
gr, ok := GoldRanges[rarity]
cfg := tuning.Get()
gr, ok := GoldRangeForRarity(cfg, rarity)
if !ok {
return 0
}
@ -114,9 +90,28 @@ func RollGoldWithRNG(rarity Rarity, rng *rand.Rand) int64 {
} else {
n = gr.Min + rng.Int63n(gr.Max-gr.Min+1)
}
// MVP balance: reduce gold loot rate vs spec table (plates longer progression).
const goldLootScale = 0.5
return int64(float64(n) * goldLootScale)
out := int64(float64(n) * cfg.GoldLootScale)
if out < 1 {
out = 1
}
return out
}
func GoldRangeForRarity(cfg tuning.Values, rarity Rarity) (GoldRange, bool) {
switch rarity {
case RarityCommon:
return GoldRange{Min: cfg.GoldCommonMin, Max: cfg.GoldCommonMax}, true
case RarityUncommon:
return GoldRange{Min: cfg.GoldUncommonMin, Max: cfg.GoldUncommonMax}, true
case RarityRare:
return GoldRange{Min: cfg.GoldRareMin, Max: cfg.GoldRareMax}, true
case RarityEpic:
return GoldRange{Min: cfg.GoldEpicMin, Max: cfg.GoldEpicMax}, true
case RarityLegendary:
return GoldRange{Min: cfg.GoldLegendaryMin, Max: cfg.GoldLegendaryMax}, true
default:
return GoldRange{}, false
}
}
// equipmentLootSlots maps loot ItemType strings to relative weights.
@ -179,9 +174,10 @@ func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.
GoldAmount: goldAmount,
})
// 5% chance to drop a healing potion (heals 30% of maxHP).
cfg := tuning.Get()
// Configurable chance to drop a healing potion.
potionRoll := float01()
if potionRoll < 0.05 {
if potionRoll < cfg.PotionDropChance {
drops = append(drops, LootDrop{
ItemType: "potion",
Rarity: RarityCommon,
@ -189,7 +185,7 @@ func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.
}
equipRoll := float01()
equipChance := 0.15 * luckMultiplier
equipChance := cfg.EquipmentDropBase * luckMultiplier
if equipChance > 1 {
equipChance = 1
}
@ -205,3 +201,21 @@ func GenerateLootWithRNG(enemyType EnemyType, luckMultiplier float64, rng *rand.
return drops
}
func AutoSellPrice(rarity Rarity) int64 {
cfg := tuning.Get()
switch rarity {
case RarityCommon:
return cfg.AutoSellCommon
case RarityUncommon:
return cfg.AutoSellUncommon
case RarityRare:
return cfg.AutoSellRare
case RarityEpic:
return cfg.AutoSellEpic
case RarityLegendary:
return cfg.AutoSellLegendary
default:
return 0
}
}

@ -1,14 +1,19 @@
package model
import "time"
import (
"time"
const (
// BuffRefillPriceRUB is the price in rubles to refill any regular buff's charges.
BuffRefillPriceRUB = 50
// ResurrectionRefillPriceRUB is the price in rubles to refill resurrection charges.
ResurrectionRefillPriceRUB = 150
"github.com/denisovdennis/autohero/internal/tuning"
)
func BuffRefillPrice() int {
return int(tuning.Get().BuffRefillPriceRUB)
}
func ResurrectionRefillPrice() int {
return int(tuning.Get().ResurrectionRefillPriceRUB)
}
// PaymentType identifies the kind of purchase.
type PaymentType string

@ -28,7 +28,7 @@ type ClientMessage struct {
// --- Server -> Client payload types ---
// HeroMovePayload is sent at 2 Hz while the hero is walking.
// HeroMovePayload is sent on the configured movement cadence while the hero is walking.
type HeroMovePayload struct {
X float64 `json:"x"`
Y float64 `json:"y"`
@ -38,7 +38,7 @@ type HeroMovePayload struct {
Heading float64 `json:"heading"` // radians
}
// PositionSyncPayload is sent every 10s as drift correction.
// PositionSyncPayload is sent on the configured sync cadence as drift correction.
type PositionSyncPayload struct {
X float64 `json:"x"`
Y float64 `json:"y"`

@ -68,7 +68,7 @@ func New(deps Deps) *chi.Mux {
r.Post("/api/v1/payments/telegram-webhook", paymentsH.TelegramWebhook)
// Admin routes protected with HTTP Basic authentication.
adminH := handler.NewAdminHandler(heroStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger)
adminH := handler.NewAdminHandler(heroStore, gearStore, questStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger)
r.Route("/admin", func(r chi.Router) {
r.Use(handler.BasicAuthMiddleware(handler.BasicAuthConfig{
Username: deps.AdminBasicAuthUsername,
@ -87,6 +87,29 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/start-adventure", adminH.StartHeroAdventure)
r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown)
r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown)
r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartRoadsideRest)
r.Post("/heroes/{heroId}/stop-rest", adminH.StopRoadsideRest)
r.Post("/heroes/{heroId}/stop-roadside-rest", adminH.StopHeroRoadsideRest)
r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)
r.Post("/heroes/{heroId}/gear/unequip", adminH.UnequipHeroGear)
r.Delete("/heroes/{heroId}/gear/{itemId}", adminH.DeleteHeroGear)
r.Get("/heroes/{heroId}/quests", adminH.GetHeroQuests)
r.Post("/heroes/{heroId}/quests/{questId}/accept", adminH.AcceptHeroQuest)
r.Post("/heroes/{heroId}/quests/{questId}/claim", adminH.ClaimHeroQuest)
r.Delete("/heroes/{heroId}/quests/{questId}", adminH.AbandonHeroQuest)
r.Get("/gear/catalog", adminH.GearCatalog)
r.Get("/content/quests", adminH.ContentAllQuests)
r.Post("/content/quests", adminH.ContentCreateQuest)
r.Put("/content/quests/{questId}", adminH.ContentUpdateQuest)
r.Get("/content/gear-base", adminH.ContentGearBase)
r.Post("/content/gear", adminH.ContentCreateGear)
r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear)
r.Get("/quests/towns", adminH.ListTownsForQuests)
r.Get("/quests/towns/{townId}/npcs", adminH.ListTownNPCsForQuests)
r.Get("/quests/npcs/{npcId}", adminH.ListNPCQuestsForAdmin)
r.Delete("/heroes/{heroId}", adminH.DeleteHero)
r.Get("/towns", adminH.ListTowns)
r.Post("/time/pause", adminH.PauseTime)
@ -95,6 +118,14 @@ func New(deps Deps) *chi.Mux {
r.Get("/engine/combats", adminH.ActiveCombats)
r.Get("/ws/connections", adminH.WSConnections)
r.Get("/info", adminH.ServerInfo)
r.Get("/runtime-config", adminH.GetRuntimeConfig)
r.Post("/runtime-config", adminH.UpdateRuntimeConfig)
r.Post("/runtime-config/reload", adminH.ReloadRuntimeConfig)
r.Get("/buff-debuff-config", adminH.GetBuffDebuffConfig)
r.Post("/buff-debuff-config", adminH.UpdateBuffDebuffConfig)
r.Post("/buff-debuff-config/reload", adminH.ReloadBuffDebuffConfig)
r.Get("/payments", adminH.ListPayments)
r.Get("/payments/{paymentId}", adminH.GetPayment)
r.Post("/payments/set-webhook", paymentsH.SetWebhook)
})

@ -11,6 +11,9 @@ import (
"github.com/denisovdennis/autohero/internal/model"
)
// ErrInventoryFull is returned when the backpack already holds MaxInventorySlots items.
var ErrInventoryFull = errors.New("inventory full")
// GearStore handles all gear CRUD operations against PostgreSQL.
type GearStore struct {
pool *pgxpool.Pool
@ -40,6 +43,32 @@ func (s *GearStore) CreateItem(ctx context.Context, item *model.GearItem) error
return nil
}
// UpdateItem updates an existing row in `gear` by id (all columns except created_at).
func (s *GearStore) UpdateItem(ctx context.Context, item *model.GearItem) error {
if item == nil || item.ID <= 0 {
return fmt.Errorf("invalid gear id")
}
cmd, err := s.pool.Exec(ctx, `
UPDATE gear SET
slot = $2, form_id = $3, name = $4, subtype = $5, rarity = $6, ilvl = $7,
base_primary = $8, primary_stat = $9, stat_type = $10,
speed_modifier = $11, crit_chance = $12, agility_bonus = $13, set_name = $14, special_effect = $15
WHERE id = $1
`,
item.ID, string(item.Slot), item.FormID, item.Name, item.Subtype,
string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat,
item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus,
item.SetName, item.SpecialEffect,
)
if err != nil {
return fmt.Errorf("update gear: %w", err)
}
if cmd.RowsAffected() == 0 {
return fmt.Errorf("gear not found: %d", item.ID)
}
return nil
}
// GetItem loads a single gear item by ID. Returns (nil, nil) if not found.
func (s *GearStore) GetItem(ctx context.Context, id int64) (*model.GearItem, error) {
var item model.GearItem
@ -108,15 +137,110 @@ func (s *GearStore) GetHeroGear(ctx context.Context, heroID int64) (map[model.Eq
}
// EquipItem equips a gear item into the given slot for a hero (upsert).
// Any previously equipped item in that slot is moved to the backpack (unless it is the same gear_id).
// If the new item was in the backpack, it is removed and remaining slots are reindexed (0..n-1).
// Returns ErrInventoryFull if the previous item cannot be stashed (transaction rolled back).
func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.EquipmentSlot, gearID int64) error {
_, err := s.pool.Exec(ctx, `
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("equip gear item begin: %w", err)
}
defer tx.Rollback(ctx)
var prevGearID int64
err = tx.QueryRow(ctx, `
SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot)).Scan(&prevGearID)
hasPrev := true
if errors.Is(err, pgx.ErrNoRows) {
hasPrev = false
err = nil
} else if err != nil {
return fmt.Errorf("equip read previous: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO hero_gear (hero_id, slot, gear_id)
VALUES ($1, $2, $3)
ON CONFLICT (hero_id, slot) DO UPDATE SET gear_id = $3
`, heroID, string(slot), gearID)
if err != nil {
ON CONFLICT (hero_id, slot) DO UPDATE SET gear_id = EXCLUDED.gear_id
`, heroID, string(slot), gearID); err != nil {
return fmt.Errorf("equip gear item: %w", err)
}
if err := compactInventoryAfterRemovingGear(ctx, tx, heroID, gearID); err != nil {
return err
}
if hasPrev && prevGearID != gearID {
if err := addToInventoryTx(ctx, tx, heroID, prevGearID); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("equip gear item commit: %w", err)
}
return nil
}
func addToInventoryTx(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error {
var n int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1
`, heroID).Scan(&n); err != nil {
return fmt.Errorf("inventory count: %w", err)
}
if n >= model.MaxInventorySlots {
return ErrInventoryFull
}
if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
VALUES ($1, $2, $3)
`, heroID, n, gearID); err != nil {
return fmt.Errorf("add to inventory: %w", err)
}
return nil
}
// compactInventoryAfterRemovingGear deletes one backpack row if present; if a row
// was removed, rewrites hero_inventory with contiguous slot_index.
func compactInventoryAfterRemovingGear(ctx context.Context, tx pgx.Tx, heroID, gearID int64) error {
cmd, err := tx.Exec(ctx, `
DELETE FROM hero_inventory WHERE hero_id = $1 AND gear_id = $2
`, heroID, gearID)
if err != nil {
return fmt.Errorf("remove equipped gear from inventory: %w", err)
}
if cmd.RowsAffected() == 0 {
return nil
}
rows, err := tx.Query(ctx, `
SELECT gear_id FROM hero_inventory WHERE hero_id = $1 ORDER BY slot_index ASC
`, heroID)
if err != nil {
return fmt.Errorf("list inventory after remove: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("scan inventory gear_id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("inventory rows: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil {
return fmt.Errorf("clear inventory for compact: %w", err)
}
for i, gid := range ids {
if _, err := tx.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)
VALUES ($1, $2, $3)
`, heroID, i, gid); err != nil {
return fmt.Errorf("reinsert inventory slot %d: %w", i, err)
}
}
return nil
}
@ -132,13 +256,39 @@ func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error {
return nil
}
// UnequipSlot removes the gear from the given slot for a hero.
// UnequipSlot moves equipped gear from the given slot into the hero's backpack.
// Returns ErrInventoryFull if there is no free slot (equipped row is left unchanged).
// If the slot is empty, returns nil (idempotent).
func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.EquipmentSlot) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot))
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("unequip begin: %w", err)
}
defer tx.Rollback(ctx)
var gearID int64
err = tx.QueryRow(ctx, `
SELECT gear_id FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot)).Scan(&gearID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("unequip gear slot: %w", err)
return fmt.Errorf("unequip read slot: %w", err)
}
if err := addToInventoryTx(ctx, tx, heroID, gearID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
DELETE FROM hero_gear WHERE hero_id = $1 AND slot = $2
`, heroID, string(slot)); err != nil {
return fmt.Errorf("unequip delete hero_gear: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("unequip commit: %w", err)
}
return nil
}
@ -192,7 +342,7 @@ func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) er
return fmt.Errorf("inventory count: %w", err)
}
if n >= model.MaxInventorySlots {
return fmt.Errorf("inventory full")
return ErrInventoryFull
}
_, err := s.pool.Exec(ctx, `
INSERT INTO hero_inventory (hero_id, slot_index, gear_id)

@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
@ -26,7 +28,7 @@ const heroSelectQuery = `
h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges,
h.position_x, h.position_y, h.potions,
h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops,
h.current_town_id, h.destination_town_id, h.move_state,
h.current_town_id, h.destination_town_id, h.move_state, h.town_pause,
h.last_online_at,
h.created_at, h.updated_at
FROM heroes h
@ -88,6 +90,12 @@ func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*mod
// ListHeroes returns a paginated list of heroes ordered by updated_at DESC.
func (s *HeroStore) ListHeroes(ctx context.Context, limit, offset int) ([]*model.Hero, error) {
return s.ListHeroesFiltered(ctx, limit, offset, "")
}
// ListHeroesFiltered returns a paginated hero list with optional query across
// hero name, DB id, and Telegram id.
func (s *HeroStore) ListHeroesFiltered(ctx context.Context, limit, offset int, query string) ([]*model.Hero, error) {
if limit <= 0 {
limit = 20
}
@ -98,9 +106,23 @@ func (s *HeroStore) ListHeroes(ctx context.Context, limit, offset int) ([]*model
offset = 0
}
query := heroSelectQuery + ` ORDER BY h.updated_at DESC LIMIT $1 OFFSET $2`
base := heroSelectQuery
var args []any
if q := strings.TrimSpace(query); q != "" {
// Pure numeric query: exact match by DB id or telegram id (single hero in normal cases).
if n, err := strconv.ParseInt(q, 10, 64); err == nil && n > 0 {
base += ` WHERE (h.id = $1 OR h.telegram_id = $1)`
args = append(args, n)
} else {
search := "%" + strings.ToLower(q) + "%"
base += ` WHERE LOWER(h.name) LIKE $1`
args = append(args, search)
}
}
base += ` ORDER BY h.updated_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, query, limit, offset)
rows, err := s.pool.Query(ctx, base, args...)
if err != nil {
return nil, fmt.Errorf("list heroes: %w", err)
}
@ -131,6 +153,75 @@ func (s *HeroStore) ListHeroes(ctx context.Context, limit, offset int) ([]*model
return heroes, nil
}
// ListPayments returns payment rows ordered by created_at DESC.
func (s *HeroStore) ListPayments(ctx context.Context, heroID int64, limit, offset int) ([]*model.Payment, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
query := `
SELECT id, hero_id, type, buff_type, amount_rub, status, created_at, completed_at
FROM payments
`
args := []any{}
if heroID > 0 {
query += ` WHERE hero_id = $1`
args = append(args, heroID)
}
query += ` ORDER BY created_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
args = append(args, limit, offset)
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list payments: %w", err)
}
defer rows.Close()
out := make([]*model.Payment, 0, limit)
for rows.Next() {
var p model.Payment
var pType string
var status string
if err := rows.Scan(&p.ID, &p.HeroID, &pType, &p.BuffType, &p.AmountRUB, &status, &p.CreatedAt, &p.CompletedAt); err != nil {
return nil, fmt.Errorf("scan payment: %w", err)
}
p.Type = model.PaymentType(pType)
p.Status = model.PaymentStatus(status)
out = append(out, &p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("payments rows: %w", err)
}
return out, nil
}
// GetPaymentByID loads one payment row by ID.
func (s *HeroStore) GetPaymentByID(ctx context.Context, paymentID int64) (*model.Payment, error) {
var p model.Payment
var pType string
var status string
err := s.pool.QueryRow(ctx, `
SELECT id, hero_id, type, buff_type, amount_rub, status, created_at, completed_at
FROM payments
WHERE id = $1
`, paymentID).Scan(&p.ID, &p.HeroID, &pType, &p.BuffType, &p.AmountRUB, &status, &p.CreatedAt, &p.CompletedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get payment: %w", err)
}
p.Type = model.PaymentType(pType)
p.Status = model.PaymentStatus(status)
return &p, nil
}
// DeleteByID removes a hero by its primary key. Returns nil if the hero didn't exist.
func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
_, err := s.pool.Exec(ctx, `DELETE FROM heroes WHERE id = $1`, id)
@ -298,10 +389,12 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
updated_at = $31,
destination_town_id = $32,
current_town_id = $33,
move_state = $34
WHERE id = $35
move_state = $34,
town_pause = $35
WHERE id = $36
`
townPauseJSON := marshalTownPause(hero.TownPause)
tag, err := s.pool.Exec(ctx, query,
hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed,
@ -318,6 +411,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero.DestinationTownID,
hero.CurrentTownID,
hero.MoveState,
townPauseJSON,
hero.ID,
)
if err != nil {
@ -385,7 +479,7 @@ func (s *HeroStore) GetOrCreate(ctx context.Context, telegramID int64, name stri
Gold: 0,
XP: 0,
Level: 1,
BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriod,
BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(),
}
if err := s.Create(ctx, hero); err != nil {
@ -401,10 +495,9 @@ func (s *HeroStore) GetOrCreate(ctx context.Context, telegramID int64, name stri
return hero, nil
}
// ListOfflineHeroes returns heroes that are walking but haven't been updated
// recently (i.e. the client is offline). Only loads base hero data without
// weapon/armor JOINs — the simulation uses EffectiveAttackAt/EffectiveDefenseAt
// which work with base stats and any loaded equipment.
// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
// or heroes resting / in town whose DB row has not been updated recently (offline town timers).
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time.Duration, limit int) ([]*model.Hero, error) {
if limit <= 0 {
limit = 100
@ -416,8 +509,12 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time
cutoff := time.Now().Add(-offlineThreshold)
query := heroSelectQuery + `
WHERE h.state = 'walking' AND h.hp > 0 AND h.updated_at < $1
AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting'))
WHERE h.hp > 0 AND h.updated_at < $1
AND (
(h.state = 'walking'
AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')))
OR h.state IN ('resting', 'in_town')
)
ORDER BY h.updated_at ASC
LIMIT $2
`
@ -456,6 +553,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
var h model.Hero
var state string
var buffChargesRaw []byte
var townPauseRaw []byte
err := rows.Scan(
&h.ID, &h.TelegramID, &h.Name,
@ -467,7 +565,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
&h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt,
&h.CreatedAt, &h.UpdatedAt,
)
@ -477,6 +575,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
h.State = model.GameState(state)
h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw)
return &h, nil
}
@ -488,6 +587,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
var h model.Hero
var state string
var buffChargesRaw []byte
var townPauseRaw []byte
err := row.Scan(
&h.ID, &h.TelegramID, &h.Name,
@ -499,7 +599,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
&h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt,
&h.CreatedAt, &h.UpdatedAt,
)
@ -512,10 +612,33 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
h.State = model.GameState(state)
h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw)
return &h, nil
}
func marshalTownPause(p *model.TownPausePersisted) []byte {
if p == nil {
return nil
}
b, err := json.Marshal(p)
if err != nil {
return nil
}
return b
}
func unmarshalTownPause(raw []byte) *model.TownPausePersisted {
if len(raw) == 0 {
return nil
}
var p model.TownPausePersisted
if err := json.Unmarshal(raw, &p); err != nil {
return nil
}
return &p
}
// loadHeroGear populates the hero's Gear map from the hero_gear table.
func (s *HeroStore) loadHeroGear(ctx context.Context, hero *model.Hero) error {
gear, err := s.gearStore.GetHeroGear(ctx, hero.ID)

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math/rand"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@ -214,6 +215,91 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.
return quests, nil
}
// ListAllQuestTemplates returns every quest template row (content catalog).
func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, npc_id, title, description, type, target_count,
target_enemy_type, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions
FROM quests
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("list all quest templates: %w", err)
}
defer rows.Close()
var quests []model.Quest
for rows.Next() {
var q model.Quest
if err := rows.Scan(
&q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount,
&q.TargetEnemyType, &q.TargetTownID, &q.DropChance,
&q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions,
); err != nil {
return nil, fmt.Errorf("scan quest: %w", err)
}
quests = append(quests, q)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list all quest templates rows: %w", err)
}
if quests == nil {
quests = []model.Quest{}
}
return quests, nil
}
// UpdateQuestTemplate updates a quest definition row by id.
func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) error {
if q == nil || q.ID <= 0 {
return fmt.Errorf("invalid quest id")
}
cmd, err := s.pool.Exec(ctx, `
UPDATE quests SET
npc_id = $2, title = $3, description = $4, type = $5, target_count = $6,
target_enemy_type = $7, target_town_id = $8, drop_chance = $9,
min_level = $10, max_level = $11, reward_xp = $12, reward_gold = $13, reward_potions = $14
WHERE id = $1
`,
q.ID, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount,
q.TargetEnemyType, q.TargetTownID, q.DropChance,
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
)
if err != nil {
return fmt.Errorf("update quest: %w", err)
}
if cmd.RowsAffected() == 0 {
return fmt.Errorf("quest not found: %d", q.ID)
}
return nil
}
// CreateQuestTemplate inserts a new quest row and sets q.ID.
func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) error {
if q == nil {
return fmt.Errorf("nil quest")
}
if q.NPCID <= 0 || strings.TrimSpace(q.Title) == "" || q.Type == "" {
return fmt.Errorf("npcId, title and type are required")
}
err := s.pool.QueryRow(ctx, `
INSERT INTO quests (npc_id, title, description, type, target_count,
target_enemy_type, target_town_id, drop_chance,
min_level, max_level, reward_xp, reward_gold, reward_potions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id
`,
q.NPCID, q.Title, q.Description, q.Type, q.TargetCount,
q.TargetEnemyType, q.TargetTownID, q.DropChance,
q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions,
).Scan(&q.ID)
if err != nil {
return fmt.Errorf("create quest: %w", err)
}
return nil
}
// AcceptQuest creates a hero_quests row for the given hero and quest.
// Returns an error if the quest is already accepted/active.
func (s *QuestStore) AcceptQuest(ctx context.Context, heroID int64, questID int64) error {

@ -78,6 +78,18 @@ services:
networks:
- autohero
admin-web:
image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest}
build:
context: ./admin-web
dockerfile: Dockerfile
ports:
- "3002:80"
depends_on:
- backend
networks:
- autohero
volumes:
pgdata:

@ -231,6 +231,8 @@ function heroResponseToState(res: HeroResponse): HeroState {
hp: res.hp,
maxHp: res.maxHp,
position: { x: res.positionX ?? 0, y: res.positionY ?? 0 },
serverActivityState: res.state,
restKind: res.restKind,
attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense,
@ -804,9 +806,9 @@ export function App() {
return next;
});
// Optimistic decrement of per-buff charge
// Optimistic decrement of per-buff charge (subscribers skip server-side consumption)
const currentCharge = hero.buffCharges?.[type];
if (currentCharge != null && currentCharge.remaining > 0) {
if (!hero.subscriptionActive && currentCharge != null && currentCharge.remaining > 0) {
const updatedCharges: Partial<Record<BuffType, BuffChargeState>> = {
...hero.buffCharges,
[type]: { ...currentCharge, remaining: currentCharge.remaining - 1 },
@ -1058,6 +1060,14 @@ export function App() {
(gameState.phase === GamePhase.Walking || gameState.phase === GamePhase.InTown) &&
!selectedNPC;
const completedQuestCount = useMemo(
() =>
heroQuests.filter((q) => q.status === 'completed').length,
[heroQuests],
);
const dismissToast = useCallback(() => setToast(null), []);
return (
<I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
<div style={appStyle}>
@ -1071,6 +1081,7 @@ export function App() {
buffCooldownEndsAt={buffCooldownEndsAt}
onUsePotion={handleUsePotion}
onHeroUpdated={handleNPCHeroUpdated}
completedQuestCount={completedQuestCount}
onOpenHeroSheet={() => {
setHeroSheetInitialTab('stats');
setHeroSheetOpen(true);
@ -1114,7 +1125,7 @@ export function App() {
<GameToast
message={toast.message}
color={toast.color}
onDone={() => setToast(null)}
onDone={dismissToast}
/>
)}

@ -245,10 +245,13 @@ export class GameEngine {
this._gameState.hero.position.y = y;
}
// Clear rest/thought when moving
// Server sends hero_move while resting (road + display offset). Do not treat as "left rest".
const serverResting =
this._gameState.hero?.serverActivityState?.toLowerCase() === 'resting';
if (
this._gameState.phase === GamePhase.Resting ||
this._gameState.phase === GamePhase.InTown
!serverResting &&
(this._gameState.phase === GamePhase.Resting ||
this._gameState.phase === GamePhase.InTown)
) {
this._gameState = { ...this._gameState, phase: GamePhase.Walking };
this._thoughtText = null;
@ -343,10 +346,45 @@ export class GameEngine {
}
}
const newX = hero.position.x || 0;
const newY = hero.position.y || 0;
this._gameState = {
...this._gameState,
hero,
};
const activity = hero.serverActivityState?.toLowerCase();
if (
this._gameState.phase !== GamePhase.Fighting &&
this._gameState.phase !== GamePhase.Dead
) {
if (activity === 'resting') {
this._gameState = { ...this._gameState, phase: GamePhase.Resting };
if (!this._thoughtText) this._showThought();
} else if (activity === 'in_town') {
this._gameState = { ...this._gameState, phase: GamePhase.InTown };
if (!this._thoughtText) this._showThought();
} else if (activity === 'walking') {
this._gameState = { ...this._gameState, phase: GamePhase.Walking };
this._thoughtText = null;
}
}
// Roadside rest: hero_state anchor stays on the road; display follows hero_move (+ lateral offset).
// Snapping here would cancel the forest offset every tick.
const skipPositionSnap = activity === 'resting';
if (!skipPositionSnap) {
const tdx = newX - this._targetPositionX;
const tdy = newY - this._targetPositionY;
if (
tdx * tdx + tdy * tdy >
POSITION_DRIFT_SNAP_THRESHOLD * POSITION_DRIFT_SNAP_THRESHOLD
) {
this._snapHeroWorldPositionTo(newX, newY);
}
}
this._notifyStateChange();
}
@ -742,6 +780,20 @@ export class GameEngine {
now,
);
const roadsideResting =
state.phase === GamePhase.Resting &&
state.hero.serverActivityState?.toLowerCase() === 'resting' &&
state.hero.restKind === 'roadside';
if (roadsideResting) {
this.renderer.drawCampfire(
this._heroDisplayX,
this._heroDisplayY,
now,
);
} else {
this.renderer.clearCampfire();
}
// Thought bubble during rest/town pauses
if (this._thoughtText) {
this.renderer.drawThoughtBubble(
@ -754,6 +806,8 @@ export class GameEngine {
} else {
this.renderer.clearThoughtBubble();
}
} else {
this.renderer.clearCampfire();
}
// Draw NPCs from towns
@ -798,6 +852,24 @@ export class GameEngine {
// ---- Private: Helpers ----
/**
* Snap render/interpolation state and camera to a world position (teleport, town arrival, etc.).
*/
private _snapHeroWorldPositionTo(x: number, y: number): void {
this._heroDisplayX = x;
this._heroDisplayY = y;
this._prevPositionX = x;
this._prevPositionY = y;
this._targetPositionX = x;
this._targetPositionY = y;
this._moveTargetX = x;
this._moveTargetY = y;
this._lastMoveUpdateTime = performance.now();
const heroScreen = worldToScreen(x, y);
this.camera.setTarget(heroScreen.x, heroScreen.y);
this.camera.snapToTarget();
}
private _notifyStateChange(): void {
this._onStateChange?.(this._gameState);
}

@ -77,6 +77,7 @@ export class GameRenderer {
// Reusable Graphics objects (avoid GC in hot path)
private _groundGfx: Graphics | null = null;
private _heroGfx: Graphics | null = null;
private _campfireGfx: Graphics | null = null;
private _enemyGfx: Graphics | null = null;
private _thoughtGfx: Graphics | null = null;
private _thoughtText: Text | null = null;
@ -291,6 +292,9 @@ export class GameRenderer {
this._heroGfx = new Graphics();
this.entityLayer.addChild(this._heroGfx);
this._campfireGfx = new Graphics();
this.entityLayer.addChild(this._campfireGfx);
this._enemyGfx = new Graphics();
this.entityLayer.addChild(this._enemyGfx);
@ -529,6 +533,45 @@ export class GameRenderer {
}
}
/** Draw a small campfire near hero while roadside-resting. */
drawCampfire(wx: number, wy: number, now: number): void {
const gfx = this._campfireGfx;
if (!gfx) return;
gfx.clear();
const iso = worldToScreen(wx, wy);
const bob = Math.sin(now * 0.005) * 1.2;
const cx = iso.x + 18;
const cy = iso.y + 9 + bob;
// Ground shadow / ember glow
gfx.ellipse(cx, cy + 6, 12, 4);
gfx.fill({ color: 0x000000, alpha: 0.22 });
gfx.ellipse(cx, cy + 3, 10, 3.2);
gfx.fill({ color: 0xff7a1a, alpha: 0.2 });
// Logs
gfx.roundRect(cx - 9, cy + 1, 18, 3, 1.5);
gfx.fill({ color: 0x5a3a24, alpha: 0.95 });
gfx.roundRect(cx - 8, cy - 1, 16, 3, 1.5);
gfx.fill({ color: 0x6b4428, alpha: 0.9 });
// Flame (layered circles for lightweight VFX)
const pulse = 0.9 + 0.2 * Math.sin(now * 0.012);
gfx.circle(cx, cy - 6, 5.2 * pulse);
gfx.fill({ color: 0xff8a2a, alpha: 0.8 });
gfx.circle(cx, cy - 7, 3.2 * pulse);
gfx.fill({ color: 0xffc04d, alpha: 0.9 });
gfx.circle(cx, cy - 8, 1.6 * pulse);
gfx.fill({ color: 0xfff3b0, alpha: 0.95 });
gfx.zIndex = cy + 96;
}
clearCampfire(): void {
if (this._campfireGfx) this._campfireGfx.clear();
}
/**
* Draw an enemy with type-specific visuals and an HP bar above.
*/

@ -116,6 +116,10 @@ export interface HeroState {
hp: number;
maxHp: number;
position: Position;
/** Server `state` field (walking | resting | in_town | …); used for movement/render parity */
serverActivityState?: string;
/** Server rest flavor: "town" | "roadside" */
restKind?: string;
attackSpeed: number;
damage: number;
defense: number;

@ -107,6 +107,7 @@ export interface HeroResponse {
agility: number;
luck: number;
state: string;
restKind?: string;
weaponId: number;
armorId: number;
weapon: WeaponResponse | null;

@ -12,7 +12,7 @@ export const BUFF_COOLDOWN_MS: Record<BuffType, number> = {
[BuffType.WarCry]: 10 * 60_000, // 10 min
};
/** Max buff charges per 24h period (mirrors backend per-buff quotas). */
/** Max buff charges per 24h period (mirrors backend BuffFreeChargesPerType). */
export const BUFF_MAX_CHARGES: Record<BuffType, number> = {
[BuffType.Rush]: 3,
[BuffType.Rage]: 2,
@ -24,6 +24,25 @@ export const BUFF_MAX_CHARGES: Record<BuffType, number> = {
[BuffType.WarCry]: 2,
};
/** Subscriber caps (mirrors backend BuffSubscriberChargesPerType, ×2). */
export const BUFF_MAX_CHARGES_SUBSCRIBER: Record<BuffType, number> = {
[BuffType.Rush]: 6,
[BuffType.Rage]: 4,
[BuffType.Shield]: 4,
[BuffType.Luck]: 2,
[BuffType.Resurrection]: 2,
[BuffType.Heal]: 6,
[BuffType.PowerPotion]: 2,
[BuffType.WarCry]: 4,
};
export function buffMaxChargesForHero(type: BuffType, subscriptionActive: boolean | undefined): number {
if (subscriptionActive) {
return BUFF_MAX_CHARGES_SUBSCRIBER[type];
}
return BUFF_MAX_CHARGES[type];
}
/** Mirrors backend/internal/model/buff.go Duration */
export const BUFF_DURATION_MS: Record<BuffType, number> = {
[BuffType.Rush]: 5 * 60_000, // 5 min

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
import { BuffType, type ActiveBuff, type BuffChargeState } from '../game/types';
import { BUFF_COOLDOWN_MS, BUFF_MAX_CHARGES } from '../network/buffMap';
import { BUFF_COOLDOWN_MS, buffMaxChargesForHero } from '../network/buffMap';
import { BUFF_META } from './buffMeta';
import { purchaseBuffRefill } from '../network/api';
import type { HeroResponse } from '../network/api';
@ -15,6 +15,8 @@ interface BuffBarProps {
cooldownEndsAt: Partial<Record<BuffType, number>>;
/** Per-buff charge quotas from the server. */
buffCharges: Partial<Record<BuffType, BuffChargeState>>;
/** When true, UI max charge labels use subscriber caps (×2). */
subscriptionActive?: boolean;
nowMs: number;
onActivate: (type: BuffType) => void;
/** Called when a buff refill purchase returns an updated hero */
@ -492,7 +494,7 @@ function getBuffEntry(
};
}
export function BuffBar({ buffs, cooldownEndsAt, buffCharges, nowMs, onActivate, onHeroUpdated }: BuffBarProps) {
export function BuffBar({ buffs, cooldownEndsAt, buffCharges, subscriptionActive, nowMs, onActivate, onHeroUpdated }: BuffBarProps) {
const handleActivate = useCallback(
(type: BuffType) => () => onActivate(type),
[onActivate],
@ -518,7 +520,7 @@ export function BuffBar({ buffs, cooldownEndsAt, buffCharges, nowMs, onActivate,
const buff = getBuffEntry(type, buffs, cooldownEndsAt, nowMs);
const meta = BUFF_META[type];
const charge = buffCharges[type];
const maxCharges = BUFF_MAX_CHARGES[type];
const maxCharges = buffMaxChargesForHero(type, subscriptionActive);
return (
<BuffButton
key={type}

@ -11,13 +11,15 @@ export function GameToast({ message, color = '#ff4444', duration = 3000, onDone
const [fading, setFading] = useState(false);
useEffect(() => {
const fadeTimer = setTimeout(() => setFading(true), duration - 400);
setFading(false);
const fadeAt = Math.max(0, duration - 400);
const fadeTimer = setTimeout(() => setFading(true), fadeAt);
const doneTimer = setTimeout(onDone, duration);
return () => {
clearTimeout(fadeTimer);
clearTimeout(doneTimer);
};
}, [duration, onDone]);
}, [message, duration, onDone]);
const containerStyle: CSSProperties = {
position: 'absolute',

@ -9,7 +9,7 @@ import type { GameState } from '../game/types';
import { GamePhase, BuffType } from '../game/types';
import { useUiClock } from '../hooks/useUiClock';
import type { HeroResponse } from '../network/api';
import { useT } from '../i18n';
import { t, useT } from '../i18n';
// FREE_BUFF_ACTIVATIONS_PER_PERIOD removed — per-buff charges are now shown on each button
interface HUDProps {
@ -19,6 +19,8 @@ interface HUDProps {
onUsePotion?: () => void;
onHeroUpdated?: (hero: HeroResponse) => void;
onOpenHeroSheet: () => void;
/** Quests done but reward not claimed yet — shown on Hero button */
completedQuestCount?: number;
}
const containerStyle: CSSProperties = {
@ -91,19 +93,6 @@ const bottomSection: CSSProperties = {
pointerEvents: 'auto',
};
const phaseIndicator: CSSProperties = {
position: 'absolute',
bottom: 8,
left: 8,
color: '#fff',
fontSize: 10,
fontWeight: 500,
opacity: 0.3,
pointerEvents: 'none',
textTransform: 'uppercase',
letterSpacing: 1,
};
const potionButtonStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
@ -185,6 +174,7 @@ export function HUD({
onUsePotion,
onHeroUpdated,
onOpenHeroSheet,
completedQuestCount = 0,
}: HUDProps) {
const { hero, enemy, phase, lastVictoryLoot } = gameState;
const nowMs = useUiClock(100);
@ -230,16 +220,56 @@ export function HUD({
</div>
<div>
<div style={hpBuffRowStyle}>
<div
style={{
position: 'relative',
flexShrink: 0,
}}
>
<button
type="button"
style={sheetBtnStyle}
onClick={onOpenHeroSheet}
aria-label="Hero sheet — stats, character, inventory"
aria-label={
completedQuestCount > 0
? `Hero sheet. ${t(tr.heroSheetQuestBadgeAria, { count: completedQuestCount })}`
: 'Hero sheet — stats, character, inventory'
}
title="Hero — stats, character, inventory, journal, quests"
>
<span style={sheetBtnIconStyle} aria-hidden>&#x2694;&#xFE0F;</span>
<span style={sheetBtnLabelStyle}>Hero</span>
<span style={sheetBtnLabelStyle}>{tr.hero}</span>
</button>
{completedQuestCount > 0 && (
<div
style={{
position: 'absolute',
top: -5,
right: -6,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: '2px 5px',
minHeight: 18,
borderRadius: 8,
background: 'linear-gradient(180deg, rgba(200, 160, 80, 0.98), rgba(150, 110, 40, 0.98))',
border: '1px solid rgba(255, 235, 180, 0.45)',
fontSize: 10,
fontWeight: 800,
color: '#fff',
lineHeight: 1,
pointerEvents: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.45)',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
}}
aria-hidden
title={t(tr.heroSheetQuestBadgeAria, { count: completedQuestCount })}
>
<span style={{ fontSize: 11, lineHeight: 1 }}>&#x1F4DC;</span>
<span>{completedQuestCount > 99 ? '99+' : completedQuestCount}</span>
</div>
)}
</div>
<div style={hudGoldChipStyle} aria-label={`Gold: ${hero.gold}`}>
<span style={{ fontSize: 14 }} aria-hidden>{'\uD83E\uDE99'}</span>
<span>{hero.gold.toLocaleString()}</span>
@ -282,9 +312,6 @@ export function HUD({
)}
</div>
{/* Center: Phase indicator (debug/subtle) */}
<div style={phaseIndicator}>{phase}</div>
{/* Loot popup */}
<LootPopup loot={gameState.loot} />
@ -294,6 +321,7 @@ export function HUD({
buffs={hero.activeBuffs}
cooldownEndsAt={buffCooldownEndsAt}
buffCharges={hero.buffCharges}
subscriptionActive={hero.subscriptionActive}
nowMs={nowMs}
onActivate={handleBuffActivate}
onHeroUpdated={onHeroUpdated}

@ -19,6 +19,9 @@ param(
"start-adventure",
"teleport-town",
"start-rest",
"leave-town",
"start-roadside-rest",
"stop-rest",
"time-pause",
"time-resume"
)]
@ -161,6 +164,18 @@ switch ($Command) {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-rest" -Body @{}
}
"leave-town" {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/leave-town" -Body @{}
}
"start-roadside-rest" {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-roadside-rest" -Body @{}
}
"stop-rest" {
Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/stop-rest" -Body @{}
}
"time-pause" {
$result = Invoke-AdminRequest -Method "POST" -Path "/admin/time/pause" -Body @{}
}

Loading…
Cancel
Save