From e8e03088fe2a7377712981715102fb350fb31e95 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 16:53:35 +0300 Subject: [PATCH] huge refactor with admin --- backend/cmd/server/main.go | 26 +- backend/internal/game/combat.go | 67 +- backend/internal/game/combat_test.go | 20 +- backend/internal/game/engine.go | 171 ++- backend/internal/game/engine_test.go | 8 +- backend/internal/game/movement.go | 648 ++++++--- backend/internal/game/offline.go | 75 +- backend/internal/handler/admin.go | 1332 +++++++++++++++++-- backend/internal/handler/buff_quota_test.go | 7 +- backend/internal/handler/game.go | 89 +- backend/internal/handler/npc.go | 81 +- backend/internal/handler/quest.go | 2 +- backend/internal/model/buff.go | 92 +- backend/internal/model/buff_quota.go | 84 +- backend/internal/model/buff_quota_test.go | 23 +- backend/internal/model/enemy.go | 7 + backend/internal/model/gear.go | 48 +- backend/internal/model/hero.go | 47 +- backend/internal/model/hero_test.go | 43 +- backend/internal/model/item_scaling.go | 19 +- backend/internal/model/loot.go | 92 +- backend/internal/model/payment.go | 17 +- backend/internal/model/ws_message.go | 4 +- backend/internal/router/router.go | 33 +- backend/internal/storage/gear_store.go | 170 ++- backend/internal/storage/hero_store.go | 175 ++- backend/internal/storage/quest_store.go | 86 ++ docker-compose.yml | 12 + frontend/src/App.tsx | 17 +- frontend/src/game/engine.ts | 78 +- frontend/src/game/renderer.ts | 43 + frontend/src/game/types.ts | 4 + frontend/src/network/api.ts | 1 + frontend/src/network/buffMap.ts | 21 +- frontend/src/ui/BuffBar.tsx | 8 +- frontend/src/ui/GameToast.tsx | 6 +- frontend/src/ui/HUD.tsx | 80 +- scripts/admin-tool.ps1 | 15 + 38 files changed, 3064 insertions(+), 687 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b571e70..5423d17 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index 6052e45..fd2c67f 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -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 diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go index 6539a8b..946bd1c 100644 --- a/backend/internal/game/combat_test.go +++ b/backend/internal/game/combat_test.go @@ -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 +} diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index b40bf07..f71aa44 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -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 } diff --git a/backend/internal/game/engine_test.go b/backend/internal/game/engine_test.go index b3a36b3..731b631 100644 --- a/backend/internal/game/engine_test.go +++ b/backend/internal/game/engine_test.go @@ -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) } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 1ae84f0..22b1736 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -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) - hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt) - hm.RoadsideRestNextLog = shift(hm.RoadsideRestNextLog) + 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", diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 81aee28..9d7a5f6 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -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,18 +24,23 @@ 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, - graph: graph, - interval: 30 * time.Second, - logger: logger, - isPaused: isPaused, + store: store, + logStore: logStore, + graph: graph, + 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)) diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index b9ef532..d69cde1 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -3,11 +3,13 @@ package handler import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" "runtime" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -16,6 +18,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" ) var serverStartedAt = time.Now() @@ -23,21 +26,25 @@ var serverStartedAt = time.Now() // AdminHandler provides administrative endpoints for hero management, // engine inspection, and server diagnostics. type AdminHandler struct { - store *storage.HeroStore - engine *game.Engine - hub *Hub - pool *pgxpool.Pool - logger *slog.Logger + store *storage.HeroStore + gearStore *storage.GearStore + questStore *storage.QuestStore + engine *game.Engine + hub *Hub + pool *pgxpool.Pool + logger *slog.Logger } // NewAdminHandler creates a new AdminHandler with all required dependencies. -func NewAdminHandler(store *storage.HeroStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger) *AdminHandler { +func NewAdminHandler(store *storage.HeroStore, gearStore *storage.GearStore, questStore *storage.QuestStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger) *AdminHandler { return &AdminHandler{ - store: store, - engine: engine, - hub: hub, - pool: pool, - logger: logger, + store: store, + gearStore: gearStore, + questStore: questStore, + engine: engine, + hub: hub, + pool: pool, + logger: logger, } } @@ -60,11 +67,12 @@ type heroSummary struct { func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + query := r.URL.Query().Get("query") if limit <= 0 { limit = 20 } - heroes, err := h.store.ListHeroes(r.Context(), limit, offset) + heroes, err := h.store.ListHeroesFiltered(r.Context(), limit, offset, query) if err != nil { h.logger.Error("admin: list heroes failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ @@ -92,9 +100,629 @@ func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) { "heroes": summaries, "limit": limit, "offset": offset, + "query": query, }) } +// ListPayments returns payments with optional hero filter. +// GET /admin/payments?heroId=1&limit=50&offset=0 +func (h *AdminHandler) ListPayments(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + heroID, _ := strconv.ParseInt(r.URL.Query().Get("heroId"), 10, 64) + + payments, err := h.store.ListPayments(r.Context(), heroID, limit, offset) + if err != nil { + h.logger.Error("admin: list payments failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list payments"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "payments": payments, + "limit": limit, + "offset": offset, + "heroId": heroID, + }) +} + +// GetPayment returns a single payment by ID. +// GET /admin/payments/{paymentId} +func (h *AdminHandler) GetPayment(w http.ResponseWriter, r *http.Request) { + paymentID, err := parsePaymentID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid paymentId: " + err.Error()}) + return + } + p, err := h.store.GetPaymentByID(r.Context(), paymentID) + if err != nil { + h.logger.Error("admin: get payment failed", "payment_id", paymentID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load payment"}) + return + } + if p == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "payment not found"}) + return + } + writeJSON(w, http.StatusOK, p) +} + +// GetHeroGear returns hero equipped and inventory items. +// GET /admin/heroes/{heroId}/gear +func (h *AdminHandler) GetHeroGear(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + equipped, err := h.gearStore.GetHeroGear(r.Context(), heroID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load equipped gear"}) + return + } + inventory, err := h.gearStore.GetHeroInventory(r.Context(), heroID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load inventory"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "equipped": equipped, + "inventory": inventory, + }) +} + +type grantGearRequest struct { + Slot string `json:"slot"` + FormID string `json:"formId"` + Rarity string `json:"rarity"` + Ilvl int `json:"ilvl"` + SourceGearID int64 `json:"sourceGearId"` // optional: clone existing `gear` row into a new instance for the hero +} + +// GrantHeroGear creates a gear item and adds it to hero inventory. +// POST /admin/heroes/{heroId}/gear/grant +func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + var req grantGearRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if req.SourceGearID > 0 { + src, err := h.gearStore.GetItem(r.Context(), req.SourceGearID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load source gear"}) + return + } + if src == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "source gear not found"}) + return + } + clone := *src + clone.ID = 0 + if err := h.gearStore.CreateItem(r.Context(), &clone); err != nil { + h.logger.Error("admin: grant gear clone", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear copy"}) + return + } + if err := h.gearStore.AddToInventory(r.Context(), heroID, clone.ID); err != nil { + _ = h.gearStore.DeleteGearItem(r.Context(), clone.ID) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "granted, but failed to sync hero snapshot"}) + return + } + h.GetHeroGear(w, r) + return + } + slot, err := parseEquipmentSlot(req.Slot) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + rarity, err := parseRarity(req.Rarity) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if req.Ilvl <= 0 { + req.Ilvl = 1 + } + if req.Ilvl > 200 { + req.Ilvl = 200 + } + + var family *model.GearFamily + for i := range model.GearCatalog { + gf := model.GearCatalog[i] + if gf.Slot == slot && (req.FormID == "" || gf.FormID == req.FormID) { + family = &gf + break + } + } + if family == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear family not found for slot/formId"}) + return + } + + item := model.NewGearItem(family, req.Ilvl, rarity) + if err := h.gearStore.CreateItem(r.Context(), item); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear item"}) + return + } + if err := h.gearStore.AddToInventory(r.Context(), heroID, item.ID); err != nil { + _ = h.gearStore.DeleteGearItem(r.Context(), item.ID) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "granted, but failed to sync hero snapshot"}) + return + } + h.GetHeroGear(w, r) +} + +type equipGearRequest struct { + ItemID int64 `json:"itemId"` +} + +// EquipHeroGear equips an inventory item by ID. +// POST /admin/heroes/{heroId}/gear/equip +func (h *AdminHandler) EquipHeroGear(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + var req equipGearRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ItemID <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + item, err := h.gearStore.GetItem(r.Context(), req.ItemID) + if err != nil || item == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear item not found"}) + return + } + if err := h.gearStore.EquipItem(r.Context(), heroID, item.Slot, item.ID); err != nil { + if errors.Is(err, storage.ErrInventoryFull) { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "inventory full — free a backpack slot to swap this piece", + }) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to equip item"}) + return + } + if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "equipped, but failed to sync hero snapshot"}) + return + } + h.GetHeroGear(w, r) +} + +type unequipGearRequest struct { + Slot string `json:"slot"` +} + +// UnequipHeroGear unequips a slot. +// POST /admin/heroes/{heroId}/gear/unequip +func (h *AdminHandler) UnequipHeroGear(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + var req unequipGearRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + slot, err := parseEquipmentSlot(req.Slot) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.gearStore.UnequipSlot(r.Context(), heroID, slot); err != nil { + if errors.Is(err, storage.ErrInventoryFull) { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "inventory full — free a backpack slot before unequipping", + }) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unequip slot"}) + return + } + if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unequipped, but failed to sync hero snapshot"}) + return + } + h.GetHeroGear(w, r) +} + +// DeleteHeroGear deletes an item row. +// DELETE /admin/heroes/{heroId}/gear/{itemId} +func (h *AdminHandler) DeleteHeroGear(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + itemID, err := parseItemID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid itemId: " + err.Error()}) + return + } + if err := h.gearStore.DeleteGearItem(r.Context(), itemID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "deleted, but failed to sync hero snapshot"}) + return + } + h.GetHeroGear(w, r) +} + +// GearCatalog returns available gear families. +// GET /admin/gear/catalog +func (h *AdminHandler) GearCatalog(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"catalog": model.GearCatalog}) +} + +// GetHeroQuests returns all quests assigned to a hero. +// GET /admin/heroes/{heroId}/quests +func (h *AdminHandler) GetHeroQuests(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + quests, err := h.questStore.ListHeroQuests(r.Context(), heroID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero quests"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"quests": quests}) +} + +// AcceptHeroQuest accepts quest for a hero. +// POST /admin/heroes/{heroId}/quests/{questId}/accept +func (h *AdminHandler) AcceptHeroQuest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + questID, err := parseQuestID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()}) + return + } + if err := h.questStore.AcceptQuest(r.Context(), heroID, questID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"}) +} + +// ClaimHeroQuest claims a completed quest reward for a hero. +// POST /admin/heroes/{heroId}/quests/{questId}/claim +func (h *AdminHandler) ClaimHeroQuest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + questID, err := parseQuestID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()}) + return + } + reward, err := h.questStore.ClaimQuestReward(r.Context(), heroID, questID) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil || hero == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + hero.XP += reward.XP + hero.Gold += reward.Gold + hero.Potions += reward.Potions + for hero.LevelUp() { + } + if err := h.store.Save(r.Context(), hero); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"}) + return + } + writeJSON(w, http.StatusOK, hero) +} + +// AbandonHeroQuest removes quest from hero log. +// DELETE /admin/heroes/{heroId}/quests/{questId} +func (h *AdminHandler) AbandonHeroQuest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()}) + return + } + questID, err := parseQuestID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()}) + return + } + if err := h.questStore.AbandonQuest(r.Context(), heroID, questID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "abandoned"}) +} + +// ListTownsForQuests returns all towns for quest management. +// GET /admin/quests/towns +func (h *AdminHandler) ListTownsForQuests(w http.ResponseWriter, r *http.Request) { + towns, err := h.questStore.ListTowns(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list towns"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"towns": towns}) +} + +// ListTownNPCsForQuests returns NPCs in town. +// GET /admin/quests/towns/{townId}/npcs +func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Request) { + townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64) + if err != nil || townID <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"}) + return + } + npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs}) +} + +// ContentAllQuests returns all quest template rows (global content). +// GET /admin/content/quests +func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) { + quests, err := h.questStore.ListAllQuestTemplates(r.Context()) + if err != nil { + h.logger.Error("admin: list all quest templates failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list quests"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"quests": quests}) +} + +// ContentGearBase returns template rows from the unified `gear` table (global item definitions). +// GET /admin/content/gear-base +// Optional query params: +// +// query — ILIKE name/form_id/slot/subtype/rarity + exact id match; +// slot, rarity, subtype — exact filters (AND with query when combined); +// limit — cap rows (default 200 if only query; 500 if any filter but no limit; omit for full scan when no filters). +func (h *AdminHandler) ContentGearBase(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + q := strings.TrimSpace(r.URL.Query().Get("query")) + slotF := strings.TrimSpace(r.URL.Query().Get("slot")) + rarityF := strings.TrimSpace(r.URL.Query().Get("rarity")) + subtypeF := strings.TrimSpace(r.URL.Query().Get("subtype")) + limit := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 { + limit = n + } + } + + var clauses []string + var args []any + n := 1 + if slotF != "" { + clauses = append(clauses, fmt.Sprintf("slot = $%d", n)) + args = append(args, slotF) + n++ + } + if rarityF != "" { + clauses = append(clauses, fmt.Sprintf("LOWER(rarity) = LOWER($%d)", n)) + args = append(args, rarityF) + n++ + } + if subtypeF != "" { + clauses = append(clauses, fmt.Sprintf("subtype = $%d", n)) + args = append(args, subtypeF) + n++ + } + if q != "" { + pat := "%" + q + "%" + clauses = append(clauses, fmt.Sprintf("(name ILIKE $%d OR form_id ILIKE $%d OR slot ILIKE $%d OR subtype ILIKE $%d OR rarity ILIKE $%d OR CAST(id AS TEXT) = $%d)", + n, n, n, n, n, n+1)) + args = append(args, pat, q) + n += 2 + } + + sqlText := ` + SELECT id, slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type, + speed_modifier, crit_chance, agility_bonus, set_name, special_effect + FROM gear` + if len(clauses) > 0 { + sqlText += " WHERE " + strings.Join(clauses, " AND ") + } + sqlText += " ORDER BY id ASC" + + hasWhere := len(clauses) > 0 + if hasWhere && limit == 0 { + limit = 500 + } + if q != "" && limit == 0 { + limit = 200 + } + if limit > 0 { + sqlText += ` LIMIT ` + strconv.Itoa(limit) + } + + rows, err := h.pool.Query(ctx, sqlText, args...) + if err != nil { + h.logger.Error("admin: load gear templates failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load gear templates"}) + return + } + defer rows.Close() + + var list []map[string]any + for rows.Next() { + var id int64 + var slot, formID, name, subtype, rarity, statType, setName, special string + var ilvl, basePri, pri, agi int + var speedMod, crit float64 + if err := rows.Scan( + &id, &slot, &formID, &name, &subtype, &rarity, &ilvl, &basePri, &pri, &statType, + &speedMod, &crit, &agi, &setName, &special, + ); err != nil { + h.logger.Error("admin: scan gear row", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "scan gear"}) + return + } + list = append(list, map[string]any{ + "id": id, "slot": slot, "formId": formID, "name": name, "subtype": subtype, + "rarity": rarity, "ilvl": ilvl, "basePrimary": basePri, "primaryStat": pri, "statType": statType, + "speedModifier": speedMod, "critChance": crit, "agilityBonus": agi, + "setName": setName, "specialEffect": special, + }) + } + if err := rows.Err(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear rows"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"gear": list}) +} + +// ContentCreateGear inserts a new row into `gear`. +// POST /admin/content/gear +func (h *AdminHandler) ContentCreateGear(w http.ResponseWriter, r *http.Request) { + var item model.GearItem + if err := json.NewDecoder(r.Body).Decode(&item); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + item.ID = 0 + if err := h.gearStore.CreateItem(r.Context(), &item); err != nil { + h.logger.Error("admin: create gear", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"id": item.ID, "gear": item}) +} + +// ContentUpdateGear updates a `gear` row by id. +// PUT /admin/content/gear/{gearId} +func (h *AdminHandler) ContentUpdateGear(w http.ResponseWriter, r *http.Request) { + gearID, err := parseContentGearID(r) + if err != nil || gearID <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid gearId"}) + return + } + var item model.GearItem + if err := json.NewDecoder(r.Body).Decode(&item); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + item.ID = gearID + if err := h.gearStore.UpdateItem(r.Context(), &item); err != nil { + if strings.Contains(err.Error(), "not found") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + h.logger.Error("admin: update gear", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"gear": item}) +} + +func normalizeAdminQuest(q *model.Quest) { + if q.MinLevel <= 0 { + q.MinLevel = 1 + } + if q.MaxLevel <= 0 { + q.MaxLevel = 100 + } + if q.TargetCount <= 0 { + q.TargetCount = 1 + } +} + +// ContentCreateQuest inserts a quest template row. +// POST /admin/content/quests +func (h *AdminHandler) ContentCreateQuest(w http.ResponseWriter, r *http.Request) { + var q model.Quest + if err := json.NewDecoder(r.Body).Decode(&q); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + q.ID = 0 + normalizeAdminQuest(&q) + if err := h.questStore.CreateQuestTemplate(r.Context(), &q); err != nil { + h.logger.Error("admin: create quest", "error", err) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"id": q.ID, "quest": q}) +} + +// ContentUpdateQuest updates a quest template by id. +// PUT /admin/content/quests/{questId} +func (h *AdminHandler) ContentUpdateQuest(w http.ResponseWriter, r *http.Request) { + qid, err := parseQuestID(r) + if err != nil || qid <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId"}) + return + } + var q model.Quest + if err := json.NewDecoder(r.Body).Decode(&q); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + q.ID = qid + normalizeAdminQuest(&q) + if err := h.questStore.UpdateQuestTemplate(r.Context(), &q); err != nil { + if strings.Contains(err.Error(), "not found") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + h.logger.Error("admin: update quest", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"quest": q}) +} + +// ListNPCQuestsForAdmin returns quest templates for NPC. +// GET /admin/quests/npcs/{npcId} +func (h *AdminHandler) ListNPCQuestsForAdmin(w http.ResponseWriter, r *http.Request) { + npcID, err := strconv.ParseInt(chi.URLParam(r, "npcId"), 10, 64) + if err != nil || npcID <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npcId"}) + return + } + quests, err := h.questStore.ListQuestsByNPC(r.Context(), npcID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npc quests"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"quests": quests}) +} + // GetHero returns full hero detail by database ID. // GET /admin/heroes/{heroId} func (h *AdminHandler) GetHero(w http.ResponseWriter, r *http.Request) { @@ -122,7 +750,12 @@ func (h *AdminHandler) GetHero(w http.ResponseWriter, r *http.Request) { } hero.RefreshDerivedCombatStats(time.Now()) - writeJSON(w, http.StatusOK, h.engine.GetMovements(heroID).Hero) + // Prefer live movement hero when online; otherwise return DB hero (GetMovements is nil offline). + if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil { + writeJSON(w, http.StatusOK, hm.Hero) + return + } + writeJSON(w, http.StatusOK, hero) } type setLevelRequest struct { @@ -200,7 +833,9 @@ func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) { } h.logger.Info("admin: hero level set", "hero_id", heroID, "level", hero.Level) + hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) + h.engine.ApplyAdminHeroSnapshot(hero) writeJSON(w, http.StatusOK, hero) } @@ -263,7 +898,9 @@ func (h *AdminHandler) SetHeroGold(w http.ResponseWriter, r *http.Request) { } h.logger.Info("admin: hero gold set", "hero_id", heroID, "gold", hero.Gold) + hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) + h.engine.ApplyAdminHeroSnapshot(hero) writeJSON(w, http.StatusOK, hero) } @@ -296,9 +933,8 @@ func (h *AdminHandler) AddPotions(w http.ResponseWriter, r *http.Request) { return } - var hero = h.engine.GetMovements(heroID).Hero - + if hero == nil { writeJSON(w, http.StatusNotFound, map[string]string{ "error": "hero not found", @@ -627,24 +1263,249 @@ func (h *AdminHandler) ListTowns(w http.ResponseWriter, r *http.Request) { }) return } - type row struct { - ID int64 `json:"id"` - Name string `json:"name"` - WorldX float64 `json:"worldX"` - WorldY float64 `json:"worldY"` - } - out := make([]row, 0, len(rg.TownOrder)) - for _, id := range rg.TownOrder { - if t := rg.Towns[id]; t != nil { - out = append(out, row{ID: t.ID, Name: t.Name, WorldX: t.WorldX, WorldY: t.WorldY}) + type row struct { + ID int64 `json:"id"` + Name string `json:"name"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` + } + out := make([]row, 0, len(rg.TownOrder)) + for _, id := range rg.TownOrder { + if t := rg.Towns[id]; t != nil { + out = append(out, row{ID: t.ID, Name: t.Name, WorldX: t.WorldX, WorldY: t.WorldY}) + } + } + writeJSON(w, http.StatusOK, map[string]any{"towns": out}) +} + +// StartHeroAdventure forces off-road adventure for a hero (online or offline). +// POST /admin/heroes/{heroId}/start-adventure +func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for start-adventure", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + var hm = h.engine.GetMovements(heroID) + hero = hm.Hero + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "hero must be alive and not in combat", + "hero": hero, + }) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStartAdventure(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "cannot start adventure (hero must be walking on a road)", + }) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: start adventure", "hero_id", heroID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if !hm.StartAdventureForced(now) { + return fmt.Errorf("cannot start adventure (hero must be walking on a road)") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: start adventure (offline)", "hero_id", heroID) + writeJSON(w, http.StatusOK, hero2) +} + +// TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest). +// POST /admin/heroes/{heroId}/teleport-town +func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + var req teleportTownRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid request body: " + err.Error(), + }) + return + } + if req.TownID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "townId is required", + }) + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for teleport", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "hero must be alive and not in combat", + }) + return + } + + townID := req.TownID + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminTeleportTown(heroID, townID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "teleport failed (unknown town or hero not online with movement)", + }) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after teleport", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: teleport town", "hero_id", heroID, "town_id", townID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + return hm.AdminPlaceInTown(rg, townID, now) + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: teleport town (offline)", "hero_id", heroID, "town_id", townID) + writeJSON(w, http.StatusOK, hero2) +} + +// StartHeroRest forces resting state (duration same as town rest). +// POST /admin/heroes/{heroId}/start-rest +func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for start-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "hero must be alive and not in combat", + }) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStartRest(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "cannot start rest", + }) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after start-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: start rest", "hero_id", heroID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if !hm.AdminStartRest(now, rg) { + return fmt.Errorf("cannot start rest") } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return } - writeJSON(w, http.StatusOK, map[string]any{"towns": out}) + h.logger.Info("admin: start rest (offline)", "hero_id", heroID) + writeJSON(w, http.StatusOK, hero2) } -// StartHeroAdventure forces off-road adventure for a hero (online or offline). -// POST /admin/heroes/{heroId}/start-adventure -func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request) { +// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online. +// POST /admin/heroes/{heroId}/leave-town +func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) { heroID, err := parseHeroID(r) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ @@ -658,7 +1519,7 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { - h.logger.Error("admin: get hero for start-adventure", "hero_id", heroID, "error", err) + h.logger.Error("admin: get hero for leave-town", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load hero", }) @@ -670,54 +1531,44 @@ func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request }) return } - var hm = h.engine.GetMovements(heroID); - hero = hm.Hero; - if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { - writeJSON(w, http.StatusBadRequest, map[string]any{ - "error": "hero must be alive and not in combat", - "hero": hero, + if hero.State != model.StateResting && hero.State != model.StateInTown { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "hero is not resting or in town", }) return } if hm := h.engine.GetMovements(heroID); hm != nil { - out, ok := h.engine.ApplyAdminStartAdventure(heroID) + out, ok := h.engine.ApplyAdminForceLeaveTown(heroID) if !ok || out == nil { - writeJSON(w, http.StatusBadRequest, map[string]any{ - "error": "cannot start adventure (hero must be walking on a road)", - }) - return - } - if err := h.store.Save(r.Context(), out); err != nil { - h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to save hero", + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "cannot leave town (movement state changed?)", }) return } out.RefreshDerivedCombatStats(time.Now()) - h.logger.Info("admin: start adventure", "hero_id", heroID) + h.logger.Info("admin: force leave town", "hero_id", heroID) writeJSON(w, http.StatusOK, out) return } hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { - if !hm.StartAdventureForced(now) { - return fmt.Errorf("cannot start adventure (hero must be walking on a road)") + if hm.State != model.StateResting && hm.State != model.StateInTown { + return fmt.Errorf("hero is not resting or in town") } + hm.LeaveTown(rg, now) return nil }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - h.logger.Info("admin: start adventure (offline)", "hero_id", heroID) + h.logger.Info("admin: force leave town (offline)", "hero_id", heroID) writeJSON(w, http.StatusOK, hero2) } -// TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest). -// POST /admin/heroes/{heroId}/teleport-town -func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) { +// StartRoadsideRest forces roadside rest (must be walking on a road). POST /admin/heroes/{heroId}/start-roadside-rest +func (h *AdminHandler) StartRoadsideRest(w http.ResponseWriter, r *http.Request) { heroID, err := parseHeroID(r) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ @@ -729,23 +1580,9 @@ func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) return } - var req teleportTownRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "invalid request body: " + err.Error(), - }) - return - } - if req.TownID == 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "townId is required", - }) - return - } - hero, err := h.store.GetByID(r.Context(), heroID) if err != nil { - h.logger.Error("admin: get hero for teleport", "hero_id", heroID, "error", err) + h.logger.Error("admin: get hero for start-roadside-rest", "hero_id", heroID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load hero", }) @@ -764,42 +1601,37 @@ func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) return } - townID := req.TownID if hm := h.engine.GetMovements(heroID); hm != nil { - out, ok := h.engine.ApplyAdminTeleportTown(heroID, townID) + out, ok := h.engine.ApplyAdminStartRoadsideRest(heroID) if !ok || out == nil { writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "teleport failed (unknown town or hero not online with movement)", - }) - return - } - if err := h.store.Save(r.Context(), out); err != nil { - h.logger.Error("admin: save after teleport", "hero_id", heroID, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to save hero", + "error": "cannot start roadside rest (hero must be walking with an assigned road)", }) return } out.RefreshDerivedCombatStats(time.Now()) - h.logger.Info("admin: teleport town", "hero_id", heroID, "town_id", townID) + h.logger.Info("admin: start roadside rest", "hero_id", heroID) writeJSON(w, http.StatusOK, out) return } hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { - return hm.AdminPlaceInTown(rg, townID, now) + if !hm.AdminStartRoadsideRest(now) { + return fmt.Errorf("cannot start roadside rest (hero must be walking with an assigned road)") + } + return nil }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - h.logger.Info("admin: teleport town (offline)", "hero_id", heroID, "town_id", townID) + hero2.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID) writeJSON(w, http.StatusOK, hero2) } -// StartHeroRest forces resting state (duration same as town rest). -// POST /admin/heroes/{heroId}/start-rest -func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { +// StopRoadsideRest ends roadside rest if active; if already not resting, returns current hero (200). POST /admin/heroes/{heroId}/stop-rest +func (h *AdminHandler) StopRoadsideRest(w http.ResponseWriter, r *http.Request) { heroID, err := parseHeroID(r) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{ @@ -811,9 +1643,8 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { return } - hero, err := h.store.GetByID(r.Context(), heroID) - if err != nil { - h.logger.Error("admin: get hero for start-rest", "hero_id", heroID, "error", err) + hero, heroErr := h.store.GetByID(r.Context(), heroID) + if heroErr != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to load hero", }) @@ -825,45 +1656,126 @@ func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { }) return } - if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "hero must be alive and not in combat", + isRoadsideRest := hero.State == model.StateResting && + hero.TownPause != nil && + (hero.TownPause.RestKind == "roadside" || hero.TownPause.RoadsideRestActive) + if (hero.State == model.StateResting && !isRoadsideRest) || hero.State == model.StateInTown { + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminForceLeaveTown(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "cannot stop town rest (movement state changed?)", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: stop rest (town, online)", "hero_id", heroID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if hm.State != model.StateResting && hm.State != model.StateInTown { + return fmt.Errorf("hero is not resting or in town") + } + hm.LeaveTown(rg, now) + return nil }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": err.Error(), + }) + return + } + hero2.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: stop rest (town, offline)", "hero_id", heroID) + writeJSON(w, http.StatusOK, hero2) return } if hm := h.engine.GetMovements(heroID); hm != nil { - out, ok := h.engine.ApplyAdminStartRest(heroID) - if !ok || out == nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "cannot start rest", + out, _ := h.engine.ApplyAdminStopRoadsideRest(heroID) + if out == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "movement session unavailable", }) return } - if err := h.store.Save(r.Context(), out); err != nil { - h.logger.Error("admin: save after start-rest", "hero_id", heroID, "error", err) + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: stop roadside rest", "hero_id", heroID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + _ = rg + _ = now + hm.EndRoadsideRest() + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + hero2.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: stop roadside rest (offline)", "hero_id", heroID) + writeJSON(w, http.StatusOK, hero2) +} + +// StopHeroRoadsideRest ends only roadside pull-over rest (live movement session). +// Does not end town / inn rest (use stop-rest or leave-town). POST /admin/heroes/{heroId}/stop-roadside-rest +func (h *AdminHandler) StopHeroRoadsideRest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + hero, heroErr := h.store.GetByID(r.Context(), heroID) + if heroErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, _ := h.engine.ApplyAdminStopRoadsideRest(heroID) + if out == nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to save hero", + "error": "movement session unavailable", }) return } out.RefreshDerivedCombatStats(time.Now()) - h.logger.Info("admin: start rest", "hero_id", heroID) + h.logger.Info("admin: stop roadside rest only", "hero_id", heroID) writeJSON(w, http.StatusOK, out) return } hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { - if !hm.AdminStartRest(now, rg) { - return fmt.Errorf("cannot start rest") - } + _ = rg + _ = now + hm.EndRoadsideRest() return nil }) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - h.logger.Info("admin: start rest (offline)", "hero_id", heroID) + hero2.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: stop roadside rest only (offline)", "hero_id", heroID) writeJSON(w, http.StatusOK, hero2) } @@ -939,6 +1851,170 @@ func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) { "idleConns": poolStat.IdleConns(), "maxConns": poolStat.MaxConns(), }, + // In-memory merged runtime tuning (defaults + last reload from DB payload). + "effective": tuning.Get(), + }) +} + +// GetRuntimeConfig returns current DB payload and effective merged config. +// GET /admin/runtime-config +func (h *AdminHandler) GetRuntimeConfig(w http.ResponseWriter, r *http.Request) { + var payload []byte + if err := h.pool.QueryRow(r.Context(), `SELECT payload FROM runtime_config WHERE id = TRUE`).Scan(&payload); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load runtime config", + }) + return + } + var raw map[string]any + if len(payload) > 0 { + _ = json.Unmarshal(payload, &raw) + } + if raw == nil { + raw = map[string]any{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "payload": raw, + "effective": tuning.Get(), + }) +} + +// UpdateRuntimeConfig overwrites runtime_config.payload JSONB. +// POST /admin/runtime-config +func (h *AdminHandler) UpdateRuntimeConfig(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid json body", + }) + return + } + if body == nil { + body = map[string]any{} + } + payload, err := json.Marshal(body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "failed to serialize payload", + }) + return + } + if _, err := h.pool.Exec(r.Context(), ` + UPDATE runtime_config + SET payload = $1::jsonb, updated_at = now() + WHERE id = TRUE + `, payload); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save runtime config", + }) + return + } + loader := storage.NewRuntimeConfigStore(h.pool) + if err := tuning.ReloadNow(r.Context(), h.logger, loader); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "config saved but reload failed", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + }) +} + +// ReloadRuntimeConfig applies DB runtime_config payload to in-memory config immediately. +// POST /admin/runtime-config/reload +func (h *AdminHandler) ReloadRuntimeConfig(w http.ResponseWriter, r *http.Request) { + loader := storage.NewRuntimeConfigStore(h.pool) + if err := tuning.ReloadNow(r.Context(), h.logger, loader); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to reload runtime config", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "reloaded", + }) +} + +// GetBuffDebuffConfig returns DB payload and effective buff/debuff catalog. +// GET /admin/buff-debuff-config +func (h *AdminHandler) GetBuffDebuffConfig(w http.ResponseWriter, r *http.Request) { + var payload []byte + if err := h.pool.QueryRow(r.Context(), `SELECT payload FROM buff_debuff_config WHERE id = TRUE`).Scan(&payload); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load buff/debuff config", + }) + return + } + var raw map[string]any + if len(payload) > 0 { + _ = json.Unmarshal(payload, &raw) + } + if raw == nil { + raw = map[string]any{} + } + effB, effD := model.BuffCatalogEffectiveJSON() + writeJSON(w, http.StatusOK, map[string]any{ + "payload": raw, + "effectiveBuffs": effB, + "effectiveDebuffs": effD, + }) +} + +// UpdateBuffDebuffConfig overwrites buff_debuff_config.payload JSONB. +// POST /admin/buff-debuff-config +func (h *AdminHandler) UpdateBuffDebuffConfig(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid json body", + }) + return + } + if body == nil { + body = map[string]any{} + } + payload, err := json.Marshal(body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "failed to serialize payload", + }) + return + } + if _, err := h.pool.Exec(r.Context(), ` + UPDATE buff_debuff_config + SET payload = $1::jsonb, updated_at = now() + WHERE id = TRUE + `, payload); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save buff/debuff config", + }) + return + } + loader := storage.NewBuffDebuffConfigStore(h.pool) + if err := model.ReloadBuffDebuffCatalog(r.Context(), h.logger, loader); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "config saved but reload failed", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + }) +} + +// ReloadBuffDebuffConfig reloads catalog from DB without writing. +// POST /admin/buff-debuff-config/reload +func (h *AdminHandler) ReloadBuffDebuffConfig(w http.ResponseWriter, r *http.Request) { + loader := storage.NewBuffDebuffConfigStore(h.pool) + if err := model.ReloadBuffDebuffCatalog(r.Context(), h.logger, loader); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to reload buff/debuff config", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "reloaded", }) } @@ -948,6 +2024,56 @@ func parseHeroID(r *http.Request) (int64, error) { return strconv.ParseInt(chi.URLParam(r, "heroId"), 10, 64) } +func parseQuestID(r *http.Request) (int64, error) { + return strconv.ParseInt(chi.URLParam(r, "questId"), 10, 64) +} + +func parsePaymentID(r *http.Request) (int64, error) { + return strconv.ParseInt(chi.URLParam(r, "paymentId"), 10, 64) +} + +func parseItemID(r *http.Request) (int64, error) { + return strconv.ParseInt(chi.URLParam(r, "itemId"), 10, 64) +} + +func parseContentGearID(r *http.Request) (int64, error) { + return strconv.ParseInt(chi.URLParam(r, "gearId"), 10, 64) +} + +func parseEquipmentSlot(raw string) (model.EquipmentSlot, error) { + val := model.EquipmentSlot(strings.TrimSpace(raw)) + for _, slot := range model.AllEquipmentSlots { + if val == slot { + return val, nil + } + } + return "", fmt.Errorf("invalid slot: %s", raw) +} + +func parseRarity(raw string) (model.Rarity, error) { + v := model.Rarity(strings.TrimSpace(strings.ToLower(raw))) + switch v { + case model.RarityCommon, model.RarityUncommon, model.RarityRare, model.RarityEpic, model.RarityLegendary: + return v, nil + default: + return "", fmt.Errorf("invalid rarity: %s", raw) + } +} + +func (h *AdminHandler) syncHeroSnapshot(ctx context.Context, heroID int64) error { + hero, err := h.store.GetByID(ctx, heroID) + if err != nil { + return err + } + if hero == nil { + return fmt.Errorf("hero not found") + } + hero.EnsureGearMap() + hero.RefreshDerivedCombatStats(time.Now()) + h.engine.ApplyAdminHeroSnapshot(hero) + return nil +} + // isHeroInCombat checks if the hero is in active engine combat and writes // a 409 Conflict response if so. Returns true when the caller should abort. func (h *AdminHandler) isHeroInCombat(w http.ResponseWriter, heroID int64) bool { @@ -979,6 +2105,6 @@ func resetHeroToLevel1(hero *model.Hero) { hero.Buffs = nil hero.Debuffs = nil hero.BuffCharges = nil - hero.BuffFreeChargesRemaining = model.FreeBuffActivationsPerPeriod + hero.BuffFreeChargesRemaining = model.FreeBuffActivationsPerPeriodRuntime() hero.BuffQuotaPeriodEnd = nil } diff --git a/backend/internal/handler/buff_quota_test.go b/backend/internal/handler/buff_quota_test.go index de4fb50..459e999 100644 --- a/backend/internal/handler/buff_quota_test.go +++ b/backend/internal/handler/buff_quota_test.go @@ -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") } } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 98b258f..2f6d7e3 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -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.5–5.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 } diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index 50d3419..5c99674 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -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)) - } else { + 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), }) } diff --git a/backend/internal/handler/quest.go b/backend/internal/handler/quest.go index 1f36e11..e7e244a 100644 --- a/backend/internal/handler/quest.go +++ b/backend/internal/handler/quest.go @@ -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", }) diff --git a/backend/internal/model/buff.go b/backend/internal/model/buff.go index 591fac5..a5b7f94 100644 --- a/backend/internal/model/buff.go +++ b/backend/internal/model/buff.go @@ -39,7 +39,7 @@ type Buff struct { Type BuffType `json:"type"` Name string `json:"name"` Duration time.Duration `json:"duration"` - Magnitude float64 `json:"magnitude"` // effect strength (e.g., 0.3 = +30%) + Magnitude float64 `json:"magnitude"` // effect strength (e.g., 0.3 = +30%) CooldownDuration time.Duration `json:"cooldownDuration"` } @@ -54,62 +54,18 @@ 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 const ( - DebuffPoison DebuffType = "poison" // -2% HP/sec - DebuffFreeze DebuffType = "freeze" // -50% attack speed - DebuffBurn DebuffType = "burn" // -3% HP/sec - DebuffStun DebuffType = "stun" // no attacks for 2 sec - DebuffSlow DebuffType = "slow" // -40% movement speed (not attack speed) - DebuffWeaken DebuffType = "weaken" // -30% hero outgoing damage - DebuffIceSlow DebuffType = "ice_slow" // -20% attack speed (Ice Guardian per spec §4.2) + DebuffPoison DebuffType = "poison" // -2% HP/sec + DebuffFreeze DebuffType = "freeze" // -50% attack speed + DebuffBurn DebuffType = "burn" // -3% HP/sec + DebuffStun DebuffType = "stun" // no attacks for 2 sec + DebuffSlow DebuffType = "slow" // -40% movement speed (not attack speed) + DebuffWeaken DebuffType = "weaken" // -30% hero outgoing damage + DebuffIceSlow DebuffType = "ice_slow" // -20% attack speed (Ice Guardian per spec §4.2) ) // AllDebuffTypes is the complete list of valid debuff types. @@ -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 diff --git a/backend/internal/model/buff_quota.go b/backend/internal/model/buff_quota.go index 101ff3f..d707e92 100644 --- a/backend/internal/model/buff_quota.go +++ b/backend/internal/model/buff_quota.go @@ -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 } diff --git a/backend/internal/model/buff_quota_test.go b/backend/internal/model/buff_quota_test.go index 29f7a93..1f0c2ae 100644 --- a/backend/internal/model/buff_quota_test.go +++ b/backend/internal/model/buff_quota_test.go @@ -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) + } +} diff --git a/backend/internal/model/enemy.go b/backend/internal/model/enemy.go index 70a5593..560feff 100644 --- a/backend/internal/model/enemy.go +++ b/backend/internal/model/enemy.go @@ -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 +} diff --git a/backend/internal/model/gear.go b/backend/internal/model/gear.go index 9919e3e..0c4e16a 100644 --- a/backend/internal/model/gear.go +++ b/backend/internal/model/gear.go @@ -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 { diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 21293e5..be7dcad 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -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 10–29: 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 } diff --git a/backend/internal/model/hero_test.go b/backend/internal/model/hero_test.go index 21caa8f..755d8c8 100644 --- a/backend/internal/model/hero_test.go +++ b/backend/internal/model/hero_test.go @@ -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 +} diff --git a/backend/internal/model/item_scaling.go b/backend/internal/model/item_scaling.go index 96876d1..e12d935 100644 --- a/backend/internal/model/item_scaling.go +++ b/backend/internal/model/item_scaling.go @@ -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 diff --git a/backend/internal/model/loot.go b/backend/internal/model/loot.go index 7d6e8ef..cc479a0 100644 --- a/backend/internal/model/loot.go +++ b/backend/internal/model/loot.go @@ -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 + } +} diff --git a/backend/internal/model/payment.go b/backend/internal/model/payment.go index 4ad33cc..f051411 100644 --- a/backend/internal/model/payment.go +++ b/backend/internal/model/payment.go @@ -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 diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index f1cf170..462a5f2 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -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"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d7c7e0c..48b74db 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) }) diff --git a/backend/internal/storage/gear_store.go b/backend/internal/storage/gear_store.go index 5e2a1e1..9e404af 100644 --- a/backend/internal/storage/gear_store.go +++ b/backend/internal/storage/gear_store.go @@ -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) diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 27ed23d..a1e2515 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -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 { @@ -370,22 +464,22 @@ func (s *HeroStore) GetOrCreate(ctx context.Context, telegramID int64, name stri // Create a new hero with default stats. hero = &model.Hero{ - TelegramID: telegramID, - Name: name, - HP: 100, - MaxHP: 100, - Attack: 10, - Defense: 5, - Speed: 1.0, - Strength: 1, - Constitution: 1, - Agility: 1, - Luck: 1, - State: model.StateWalking, + TelegramID: telegramID, + Name: name, + HP: 100, + MaxHP: 100, + Attack: 10, + Defense: 5, + Speed: 1.0, + Strength: 1, + Constitution: 1, + Agility: 1, + Luck: 1, + State: model.StateWalking, 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) diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 817248c..59c993f 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index cd6f3cc..021a192 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d732d71..05dcf4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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> = { ...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 (
@@ -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() { setToast(null)} + onDone={dismissToast} /> )} diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index d662b32..368a03e 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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); } diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index a26c456..0f1d421 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -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. */ diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index e3a653a..142140c 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -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; diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 6183367..8aa4cf4 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -107,6 +107,7 @@ export interface HeroResponse { agility: number; luck: number; state: string; + restKind?: string; weaponId: number; armorId: number; weapon: WeaponResponse | null; diff --git a/frontend/src/network/buffMap.ts b/frontend/src/network/buffMap.ts index 988c034..e7f772a 100644 --- a/frontend/src/network/buffMap.ts +++ b/frontend/src/network/buffMap.ts @@ -12,7 +12,7 @@ export const BUFF_COOLDOWN_MS: Record = { [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.Rush]: 3, [BuffType.Rage]: 2, @@ -24,6 +24,25 @@ export const BUFF_MAX_CHARGES: Record = { [BuffType.WarCry]: 2, }; +/** Subscriber caps (mirrors backend BuffSubscriberChargesPerType, ×2). */ +export const BUFF_MAX_CHARGES_SUBSCRIBER: Record = { + [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.Rush]: 5 * 60_000, // 5 min diff --git a/frontend/src/ui/BuffBar.tsx b/frontend/src/ui/BuffBar.tsx index 5892b25..492a208 100644 --- a/frontend/src/ui/BuffBar.tsx +++ b/frontend/src/ui/BuffBar.tsx @@ -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>; /** Per-buff charge quotas from the server. */ buffCharges: Partial>; + /** 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 ( { - 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', diff --git a/frontend/src/ui/HUD.tsx b/frontend/src/ui/HUD.tsx index b156e6b..6cbc9cd 100644 --- a/frontend/src/ui/HUD.tsx +++ b/frontend/src/ui/HUD.tsx @@ -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({
- + + {completedQuestCount > 0 && ( +
+ 📜 + {completedQuestCount > 99 ? '99+' : completedQuestCount} +
+ )} +
{'\uD83E\uDE99'} {hero.gold.toLocaleString()} @@ -282,9 +312,6 @@ export function HUD({ )}
- {/* Center: Phase indicator (debug/subtle) */} -
{phase}
- {/* Loot popup */} @@ -294,6 +321,7 @@ export function HUD({ buffs={hero.activeBuffs} cooldownEndsAt={buffCooldownEndsAt} buffCharges={hero.buffCharges} + subscriptionActive={hero.subscriptionActive} nowMs={nowMs} onActivate={handleBuffActivate} onHeroUpdated={onHeroUpdated} diff --git a/scripts/admin-tool.ps1 b/scripts/admin-tool.ps1 index a292e86..05084f2 100644 --- a/scripts/admin-tool.ps1 +++ b/scripts/admin-tool.ps1 @@ -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 @{} }