From 3907eacb3005b4ed295f4badd9fe466c65f48bde Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 19:55:18 +0300 Subject: [PATCH] other graph stuff --- backend/internal/game/combat.go | 2 +- backend/internal/game/combat_test.go | 4 +- backend/internal/game/engine.go | 147 +---- backend/internal/game/movement.go | 622 +------------------- backend/internal/handler/admin.go | 309 +--------- backend/internal/handler/buff_quota.go | 10 +- backend/internal/handler/buff_quota_test.go | 16 +- backend/internal/handler/game.go | 18 +- backend/internal/model/buff.go | 2 +- backend/internal/model/buff_quota.go | 26 +- backend/internal/model/buff_quota_test.go | 2 +- backend/internal/model/hero_test.go | 4 +- backend/internal/router/router.go | 4 - 13 files changed, 81 insertions(+), 1085 deletions(-) diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go index fd2c67f..5642449 100644 --- a/backend/internal/game/combat.go +++ b/backend/internal/game/combat.go @@ -378,7 +378,7 @@ func HasLuckBuff(hero *model.Hero, now time.Time) bool { return false } -// LuckMultiplier returns the loot multiplier from the Luck buff (x2.5 per spec ยง7.1). +// LuckMultiplier returns the loot multiplier when the Luck buff is active (tuning.LuckBuffMultiplier). func LuckMultiplier(hero *model.Hero, now time.Time) float64 { if HasLuckBuff(hero, now) { return tuning.Get().LuckBuffMultiplier diff --git a/backend/internal/game/combat_test.go b/backend/internal/game/combat_test.go index 946bd1c..3bb9351 100644 --- a/backend/internal/game/combat_test.go +++ b/backend/internal/game/combat_test.go @@ -158,8 +158,8 @@ func TestLuckMultiplierWithBuff(t *testing.T) { } mult := LuckMultiplier(hero, now) - if mult != 2.5 { - t.Fatalf("expected luck multiplier 2.5, got %.1f", mult) + if mult != 1.75 { + t.Fatalf("expected luck multiplier 1.75, got %.2f", mult) } } diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 86e9b61..7104cfe 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -335,14 +335,40 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) { return } - buffType := model.BuffType(payload.BuffType) + bt, ok := model.ValidBuffType(payload.BuffType) + if !ok { + e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType)) + return + } + + hero := hm.Hero now := time.Now() - ab := ApplyBuff(hm.Hero, buffType, now) + hero.RefreshSubscription(now) + hero.EnsureBuffChargesPopulated(now) + if err := hero.ConsumeBuffCharge(bt, now); err != nil { + e.sendError(msg.HeroID, "buff_quota_exhausted", err.Error()) + return + } + + ab := ApplyBuff(hero, bt, now) if ab == nil { + hero.RefundBuffCharge(bt) e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType)) return } + if cs, ok := e.combats[msg.HeroID]; ok { + cs.Hero = hero + } + + if e.heroStore != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil { + e.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err) + } + } + if e.sender != nil { e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{ BuffType: payload.BuffType, @@ -627,59 +653,6 @@ func (e *Engine) Status() EngineStatus { } } -// ApplyAdminStartAdventure forces an off-road adventure for an online hero (walking on a road). -func (e *Engine) ApplyAdminStartAdventure(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.StartAdventureForced(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)) - } - return h, true -} - -// ApplyAdminStopAdventure ends the deep-wild phase immediately: hero animates back to the road, then keeps walking. -func (e *Engine) ApplyAdminStopAdventure(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.ForceAdventureReturnToRoad(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 stop adventure", "hero_id", h.ID, "error", err) - } - } - return h, true -} - // ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival). func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { e.mu.Lock() @@ -720,70 +693,6 @@ 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() diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 775d82b..56c7461 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -14,8 +14,7 @@ const ( // townNPCVisitLogLines is how many log lines to emit per NPC visit. townNPCVisitLogLines = 6 - restKindTown = "town" - restKindRoadside = "roadside" + restKindTown = "town" ) func movementTickRate() time.Duration { @@ -76,24 +75,6 @@ type HeroMovement struct { // TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause). TownLeaveAt time.Time - // Off-road excursion ("looking for trouble"): timers not persisted; cleared on town enter and when it ends. - AdventureStartAt time.Time - AdventureEndAt time.Time - AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring - // AdventureWanderX/Y: small display-only random drift while adventuring (reset when adventure ends). - AdventureWanderX float64 - AdventureWanderY float64 - - // 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 @@ -427,13 +408,6 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) { hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) hm.TownLeaveAt = shift(hm.TownLeaveAt) - hm.AdventureStartAt = shift(hm.AdventureStartAt) - hm.AdventureEndAt = shift(hm.AdventureEndAt) - if hm.RoadsideRestActive { - hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt) - hm.RoadsideRestStartedAt = shift(hm.RoadsideRestStartedAt) - hm.RoadsideRestNextLog = shift(hm.RoadsideRestNextLog) - } hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.LastMoveTick = now } @@ -461,21 +435,6 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow hm.refreshSpeed(now) distThisTick := hm.Speed * dt - var wAdv float64 - if hm.adventureActive(now) { - wAdv = hm.wildernessFactor(now) - cfg := tuning.Get() - frac := cfg.AdventureForwardSpeedWildFraction - if frac < 0 { - frac = 0 - } - if frac > 1 { - frac = 1 - } - // w=0: full road speed; w=1: frac of road speed (exploring, not rushing to town). - distThisTick *= (1-wAdv) + wAdv*frac - } - for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 { from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] @@ -545,90 +504,6 @@ func (hm *HeroMovement) TargetPoint() (float64, float64) { return wp.X, wp.Y } -func (hm *HeroMovement) adventureActive(now time.Time) bool { - return !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt) -} - -func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) { - if hm.AdventureEndAt.IsZero() { - return - } - if now.Before(hm.AdventureEndAt) { - return - } - hm.AdventureStartAt = time.Time{} - hm.AdventureEndAt = time.Time{} - hm.AdventureSide = 0 - hm.AdventureWanderX = 0 - hm.AdventureWanderY = 0 -} - -func (hm *HeroMovement) roadsideRestInProgress() bool { - 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. Does not clear an active adventure timer -// so low-HP pull-over during a mini-adventure resumes the same excursion after rest. -func (hm *HeroMovement) beginRoadsideRestSession(now, endAt time.Time) { - 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 - } - 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 @@ -646,128 +521,6 @@ func (hm *HeroMovement) applyTownRestHeal(dt float64) { } } -// tryStartRoadsideRest pulls the hero off the road when HP is low; an active adventure timer keeps running. -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) > cfg.LowHPThreshold { - return - } - 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))) - hm.beginRoadsideRestSession(now, endAt) -} - -func randomRoadsideRestThoughtDelay() time.Duration { - 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 minDelay + time.Duration(rand.Int63n(int64(span)+1)) -} - -// emitRoadsideRestThoughts appends occasional journal lines while the hero rests off the road. -func emitRoadsideRestThoughts(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { - if log == nil || !hm.roadsideRestInProgress() { - return - } - if hm.RoadsideRestNextLog.IsZero() { - hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) - } - if now.Before(hm.RoadsideRestNextLog) { - return - } - log(heroID, randomRoadsideRestThought()) - hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) -} - -// 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() >= cfg.StartAdventurePerTick { - return - } - hm.AdventureStartAt = now - 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(minDur + time.Duration(rand.Int63n(spanNs+1))) - hm.AdventureWanderX = 0 - hm.AdventureWanderY = 0 - if rand.Float64() < 0.5 { - hm.AdventureSide = 1 - } else { - hm.AdventureSide = -1 - } -} - -// StartAdventureForced starts an off-road adventure immediately (admin). -func (hm *HeroMovement) StartAdventureForced(now time.Time) bool { - if hm.Hero == nil || hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { - return false - } - if hm.State != model.StateWalking { - return false - } - if hm.Road == nil || len(hm.Road.Waypoints) < 2 { - return false - } - if hm.adventureActive(now) { - return true - } - 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(minDur + time.Duration(rand.Int63n(spanNs+1))) - hm.AdventureWanderX = 0 - hm.AdventureWanderY = 0 - if rand.Float64() < 0.5 { - hm.AdventureSide = 1 - } else { - hm.AdventureSide = -1 - } - return true -} - -// ForceAdventureReturnToRoad snaps the adventure to the outward walk-back leg (same return duration as roadside rest). -func (hm *HeroMovement) ForceAdventureReturnToRoad(now time.Time) bool { - if !hm.adventureActive(now) { - return false - } - cfg := tuning.Get() - dtIn := time.Duration(cfg.RoadsideRestGoInMs) * time.Millisecond - dtOut := time.Duration(cfg.RoadsideRestReturnMs) * time.Millisecond - total := dtIn + dtOut - dtIn2, dtOut2 := roadsideRestPhaseDurations(total) - hm.AdventureEndAt = now.Add(dtOut2) - hm.AdventureStartAt = now.Add(-dtIn2) - return true -} - // AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest). func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error { if graph == nil || townID == 0 { @@ -781,12 +534,6 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim hm.WaypointFraction = 0 hm.DestinationTownID = townID hm.spawnAtRoadStart = false - hm.AdventureStartAt = time.Time{} - hm.AdventureEndAt = time.Time{} - hm.AdventureSide = 0 - hm.AdventureWanderX = 0 - hm.AdventureWanderY = 0 - hm.endRoadsideRest() hm.WanderingMerchantDeadline = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" @@ -808,12 +555,6 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { if hm.State == model.StateFighting { return false } - hm.endRoadsideRest() - hm.AdventureStartAt = time.Time{} - hm.AdventureEndAt = time.Time{} - hm.AdventureSide = 0 - hm.AdventureWanderX = 0 - hm.AdventureWanderY = 0 hm.WanderingMerchantDeadline = time.Time{} hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} @@ -831,219 +572,6 @@ 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 -} - -// adventureDepthFactor is 0 on the road, then smoothsteps in (RoadsideRestGoInMs), holds, then out before AdventureEndAt. -// Same timing and depth scale as roadside rest so "looking for trouble" pulls off the road as visibly as pull-over rest. -func (hm *HeroMovement) adventureDepthFactor(now time.Time) float64 { - if !hm.adventureActive(now) { - return 0 - } - t0 := hm.AdventureStartAt - tEnd := hm.AdventureEndAt - if tEnd.IsZero() { - return 0 - } - if !now.Before(tEnd) { - return 0 - } - if t0.IsZero() { - 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 -} - -// wildernessFactor matches adventureDepthFactor while an adventure is active (encounters, forward speed). -func (hm *HeroMovement) wildernessFactor(now time.Time) float64 { - return hm.adventureDepthFactor(now) -} - -// stepAdventureWander applies a small bounded random drift in world space while off-road (display feel). -func (hm *HeroMovement) stepAdventureWander(now time.Time, dt float64) { - if !hm.adventureActive(now) || dt <= 0 || hm.State != model.StateWalking { - return - } - w := hm.wildernessFactor(now) - if w <= 0 { - return - } - cfg := tuning.Get() - twitch := cfg.AdventureWanderSpeedRatio - if twitch <= 0 { - twitch = tuning.DefaultValues().AdventureWanderSpeedRatio - } - step := hm.Speed * twitch * w * dt - hm.AdventureWanderX += (rand.Float64()*2 - 1) * step - hm.AdventureWanderY += (rand.Float64()*2 - 1) * step - maxR := cfg.AdventureWanderMaxRadius - if maxR <= 0 { - maxR = tuning.DefaultValues().AdventureWanderMaxRadius - } - r := math.Hypot(hm.AdventureWanderX, hm.AdventureWanderY) - if r > maxR && r > 1e-9 { - s := maxR / r - hm.AdventureWanderX *= s - hm.AdventureWanderY *= s - } -} - -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 -} - -// adventureWildDepthWorldUnits is max perpendicular reach at full adventure depth: same base as roadside camp, -// scaled further into the wild (AdventureWildDepthScale). -func adventureWildDepthWorldUnits() float64 { - cfg := tuning.Get() - scale := cfg.AdventureWildDepthScale - if scale <= 0 { - scale = tuning.DefaultValues().AdventureWildDepthScale - } - return roadsideRestDepthWorldUnits() * scale -} - func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return 0, 1 @@ -1090,28 +618,7 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) { } func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { - if hm.roadsideRestInProgress() { - if hm.RoadsideRestSide == 0 { - return 0, 0 - } - px, py := hm.roadPerpendicularUnit() - f := hm.roadsideRestDepthFactor(now) - mag := float64(hm.RoadsideRestSide) * roadsideRestDepthWorldUnits() * f - return px * mag, py * mag - } - if hm.adventureActive(now) && hm.AdventureSide != 0 && hm.Road != nil && len(hm.Road.Waypoints) >= 2 { - f := hm.adventureDepthFactor(now) - depth := adventureWildDepthWorldUnits() - cfg := tuning.Get() - if cfg.AdventureWildLateralMax > 0 { - if alt := cfg.AdventureWildLateralMax; alt > depth { - depth = alt - } - } - px, py := hm.roadPerpendicularUnit() - mag := float64(hm.AdventureSide) * depth * f - return px*mag + hm.AdventureWanderX, py*mag + hm.AdventureWanderY - } + _ = now return 0, 0 } @@ -1130,15 +637,11 @@ func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy mo 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 := cfg.EncounterActivityBase * (0.62 + 0.38*w) - if rand.Float64() >= activity { + if rand.Float64() >= cfg.EncounterActivityBase { return false, model.Enemy{}, false } - // On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters. - monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*w*w - merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus*(1-w)*(1-w) + monsterW := cfg.MonsterEncounterWeightBase + merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { @@ -1163,19 +666,12 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} hm.TownRestHealRemainder = 0 - hm.AdventureStartAt = time.Time{} - hm.AdventureEndAt = time.Time{} - hm.AdventureSide = 0 - hm.AdventureWanderX = 0 - hm.AdventureWanderY = 0 - hm.endRoadsideRest() ids := graph.TownNPCIDs(destID) if len(ids) == 0 { hm.State = model.StateResting hm.Hero.State = model.StateResting hm.RestUntil = now.Add(randomRestDuration()) - hm.RoadsideRestActive = false return } @@ -1199,7 +695,6 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { 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. @@ -1223,7 +718,6 @@ func randomTownNPCDelay() time.Duration { // StartFighting pauses movement for combat. func (hm *HeroMovement) StartFighting() { hm.State = model.StateFighting - hm.endRoadsideRest() hm.WanderingMerchantDeadline = time.Time{} } @@ -1237,7 +731,6 @@ func (hm *HeroMovement) ResumeWalking(now time.Time) { // Die sets the movement state to dead. func (hm *HeroMovement) Die() { hm.State = model.StateDead - hm.endRoadsideRest() } // SyncToHero writes movement state back to the hero model for persistence. @@ -1263,11 +756,7 @@ func (hm *HeroMovement) SyncToHero() { 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.RestKind = restKindTown } hm.Hero.TownPause = hm.townPauseBlob() } @@ -1280,26 +769,9 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { } 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 + RestUntil: &t, + RestKind: restKindTown, + TownRestHealRemainder: hm.TownRestHealRemainder, } return p case model.StateInTown: @@ -1336,37 +808,6 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) 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. @@ -1551,8 +992,7 @@ func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string { // // 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. +// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block). // persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town. func ProcessSingleHeroMovementTick( heroID int64, @@ -1574,38 +1014,19 @@ func ProcessSingleHeroMovementTick( return case model.StateResting: - hm.expireAdventureIfNeeded(now) // 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.applyTownRestHeal(dt) 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 { @@ -1685,7 +1106,6 @@ func ProcessSingleHeroMovementTick( case model.StateWalking: cfg := tuning.Get() - hm.expireAdventureIfNeeded(now) hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2 if hadNoRoad { hm.Road = nil @@ -1717,27 +1137,7 @@ func ProcessSingleHeroMovementTick( } } - hm.tryStartRoadsideRest(now) - 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.SyncToHero() - return - } - - hm.tryStartAdventure(now) - dtMove := now.Sub(hm.LastMoveTick).Seconds() - if dtMove <= 0 { - dtMove = movementTickRate().Seconds() - } reachedTown := hm.AdvanceTick(now, graph) - if !reachedTown { - hm.stepAdventureWander(now, dtMove) - } if reachedTown { hm.EnterTown(now, graph) diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 86eccf3..c7cd5a2 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -66,14 +66,9 @@ type heroSummary struct { type adminLiveMovementJSON struct { Online bool `json:"online"` MoveState string `json:"moveState,omitempty"` - AdventureActive bool `json:"adventureActive"` - AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"` - AdventureStartedAt *time.Time `json:"adventureStartedAt,omitempty"` RestUntil *time.Time `json:"restUntil,omitempty"` TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"` NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"` - RoadsideRestActive bool `json:"roadsideRestActive"` - RoadsideRestEndAt *time.Time `json:"roadsideRestEndAt,omitempty"` CurrentTownID int64 `json:"currentTownId,omitempty"` DestinationTownID int64 `json:"destinationTownId,omitempty"` WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"` @@ -91,16 +86,8 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLive return nil } s := &adminLiveMovementJSON{ - Online: true, - MoveState: string(hm.State), - RoadsideRestActive: hm.RoadsideRestActive, - } - if !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt) { - s.AdventureActive = true - end := hm.AdventureEndAt - s.AdventureEndsAt = &end - st := hm.AdventureStartAt - s.AdventureStartedAt = &st + Online: true, + MoveState: string(hm.State), } if !hm.RestUntil.IsZero() { t := hm.RestUntil @@ -114,10 +101,6 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLive t := hm.NextTownNPCRollAt s.NextTownNPCRollAt = &t } - if !hm.RoadsideRestEndAt.IsZero() { - t := hm.RoadsideRestEndAt - s.RoadsideRestEndAt = &t - } if hm.CurrentTownID != 0 { s.CurrentTownID = hm.CurrentTownID } @@ -1358,139 +1341,6 @@ func (h *AdminHandler) ListTowns(w http.ResponseWriter, r *http.Request) { 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 - } - 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 - } - h.logger.Info("admin: start adventure", "hero_id", heroID) - h.writeAdminHeroDetail(w, out) - return - } - - hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { - if !hm.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) - h.writeAdminHeroDetail(w, hero2) -} - -// StopHeroAdventure forces the return-to-road phase of an active mini-adventure (online or offline). -// POST /admin/heroes/{heroId}/stop-adventure -func (h *AdminHandler) StopHeroAdventure(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 stop-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 - } - - if hm := h.engine.GetMovements(heroID); hm != nil { - out, ok := h.engine.ApplyAdminStopAdventure(heroID) - if !ok || out == nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "no active adventure", - }) - return - } - if err := h.store.Save(r.Context(), out); err != nil { - h.logger.Error("admin: save after stop-adventure", "hero_id", heroID, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "failed to save hero", - }) - return - } - h.logger.Info("admin: stop adventure", "hero_id", heroID) - h.writeAdminHeroDetail(w, out) - return - } - - hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { - if !hm.ForceAdventureReturnToRoad(now) { - return fmt.Errorf("no active adventure") - } - return nil - }) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - h.logger.Info("admin: stop adventure (offline)", "hero_id", heroID) - h.writeAdminHeroDetail(w, 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) { @@ -1706,161 +1556,6 @@ func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, hero2) } -// 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{ - "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-roadside-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.ApplyAdminStartRoadsideRest(heroID) - if !ok || out == nil { - writeJSON(w, http.StatusBadRequest, map[string]string{ - "error": "cannot start roadside rest (hero must be walking with an assigned road)", - }) - return - } - out.RefreshDerivedCombatStats(time.Now()) - 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 { - 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 - } - hero2.RefreshDerivedCombatStats(time.Now()) - h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID) - writeJSON(w, http.StatusOK, hero2) -} - -// 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{ - "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 - } - 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, _ := h.engine.ApplyAdminStopRoadsideRest(heroID) - if out == nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "movement session unavailable", - }) - return - } - 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) -} - // PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls. // POST /admin/time/pause func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handler/buff_quota.go b/backend/internal/handler/buff_quota.go index 56da96c..e2c6d6b 100644 --- a/backend/internal/handler/buff_quota.go +++ b/backend/internal/handler/buff_quota.go @@ -6,20 +6,14 @@ import ( "github.com/denisovdennis/autohero/internal/model" ) -// consumeFreeBuffCharge attempts to consume a per-buff-type free charge. +// consumeFreeBuffCharge consumes one per-buff-type charge (F2P and subscribers). // Returns an error if no charges remain for the given buff type. func consumeFreeBuffCharge(hero *model.Hero, bt model.BuffType, now time.Time) error { - if hero.SubscriptionActive { - return nil - } hero.EnsureBuffChargesPopulated(now) return hero.ConsumeBuffCharge(bt, now) } -// refundFreeBuffCharge restores a charge for the specific buff type after a failed activation. +// refundFreeBuffCharge restores a charge after a failed activation. func refundFreeBuffCharge(hero *model.Hero, bt model.BuffType) { - if hero.SubscriptionActive { - return - } hero.RefundBuffCharge(bt) } diff --git a/backend/internal/handler/buff_quota_test.go b/backend/internal/handler/buff_quota_test.go index 459e999..9fb24b9 100644 --- a/backend/internal/handler/buff_quota_test.go +++ b/backend/internal/handler/buff_quota_test.go @@ -7,14 +7,22 @@ import ( "github.com/denisovdennis/autohero/internal/model" ) -func TestConsumeFreeBuffCharge_SubscriptionSkipsQuota(t *testing.T) { - h := &model.Hero{SubscriptionActive: true, BuffFreeChargesRemaining: 0} +func TestConsumeFreeBuffCharge_SubscriberConsumesCharge(t *testing.T) { now := time.Now() + pe := now.Add(time.Hour) + h := &model.Hero{ + SubscriptionActive: true, + BuffFreeChargesRemaining: 10, + BuffCharges: map[string]model.BuffChargeState{ + string(model.BuffRush): {Remaining: 2, PeriodEnd: &pe}, + }, + } if err := consumeFreeBuffCharge(h, model.BuffRush, now); err != nil { t.Fatal(err) } - if h.BuffFreeChargesRemaining != 0 { - t.Fatalf("expected no charge mutation for subscriber, got %d", h.BuffFreeChargesRemaining) + st := h.BuffCharges[string(model.BuffRush)] + if st.Remaining != 1 { + t.Fatalf("subscriber should consume charge, want remaining 1, got %d", st.Remaining) } } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 3573d07..a70002e 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -365,22 +365,16 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) { now := time.Now() hero.EnsureBuffChargesPopulated(now) - consumed := false - if !hero.SubscriptionActive { - if err := consumeFreeBuffCharge(hero, bt, now); err != nil { - writeJSON(w, http.StatusForbidden, map[string]string{ - "error": err.Error(), - }) - return - } - consumed = true + if err := consumeFreeBuffCharge(hero, bt, now); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": err.Error(), + }) + return } ab := game.ApplyBuff(hero, bt, now) if ab == nil { - if consumed { - refundFreeBuffCharge(hero, bt) - } + refundFreeBuffCharge(hero, bt) h.logger.Error("ApplyBuff returned nil", "hero_id", hero.ID, "buff_type", bt) writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to apply buff", diff --git a/backend/internal/model/buff.go b/backend/internal/model/buff.go index a5b7f94..e23ead9 100644 --- a/backend/internal/model/buff.go +++ b/backend/internal/model/buff.go @@ -10,7 +10,7 @@ const ( BuffRush BuffType = "rush" // +attack speed BuffRage BuffType = "rage" // +damage BuffShield BuffType = "shield" // -incoming damage - BuffLuck BuffType = "luck" // x2.5 loot + BuffLuck BuffType = "luck" // loot mult from tuning.LuckBuffMultiplier when active BuffResurrection BuffType = "resurrection" // revive on death BuffHeal BuffType = "heal" // +50% HP instantly BuffPowerPotion BuffType = "power_potion" // +150% damage diff --git a/backend/internal/model/buff_quota.go b/backend/internal/model/buff_quota.go index d707e92..58b4f3a 100644 --- a/backend/internal/model/buff_quota.go +++ b/backend/internal/model/buff_quota.go @@ -7,28 +7,28 @@ import ( "github.com/denisovdennis/autohero/internal/tuning" ) -// BuffFreeChargesPerType defines the per-buff free charge limits per 24h window. +// BuffFreeChargesPerType defines per-buff charge limits per BuffChargePeriod (default 24h). var BuffFreeChargesPerType = map[BuffType]int{ - BuffRush: 3, - BuffRage: 2, - BuffShield: 2, + BuffRush: 2, + BuffRage: 1, + BuffShield: 1, BuffLuck: 1, BuffResurrection: 1, - BuffHeal: 3, + BuffHeal: 2, BuffPowerPotion: 1, - BuffWarCry: 2, + BuffWarCry: 1, } -// BuffSubscriberChargesPerType defines the per-buff charge limits for subscribers (x2). +// BuffSubscriberChargesPerType defines higher per-buff caps for active subscribers (not a flat 2x). var BuffSubscriberChargesPerType = map[BuffType]int{ - BuffRush: 6, - BuffRage: 4, - BuffShield: 4, + BuffRush: 3, + BuffRage: 2, + BuffShield: 2, BuffLuck: 2, BuffResurrection: 2, - BuffHeal: 6, - BuffPowerPotion: 2, - BuffWarCry: 4, + BuffHeal: 3, + BuffPowerPotion: 1, + BuffWarCry: 2, } func SubscriptionWeeklyPrice() int64 { diff --git a/backend/internal/model/buff_quota_test.go b/backend/internal/model/buff_quota_test.go index 1f0c2ae..1419d5c 100644 --- a/backend/internal/model/buff_quota_test.go +++ b/backend/internal/model/buff_quota_test.go @@ -38,7 +38,7 @@ func TestApplyBuffQuotaRollover_NoOpWhenSubscribed(t *testing.T) { } } -func TestResetBuffCharges_SubscriberGetsDoubleCap(t *testing.T) { +func TestResetBuffCharges_SubscriberGetsSubscriberCap(t *testing.T) { now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) bt := BuffRush h := &Hero{ diff --git a/backend/internal/model/hero_test.go b/backend/internal/model/hero_test.go index 755d8c8..be924e6 100644 --- a/backend/internal/model/hero_test.go +++ b/backend/internal/model/hero_test.go @@ -146,9 +146,9 @@ func TestRushAffectsMovementSpeed(t *testing.T) { }} got := hero.MovementSpeedMultiplier(now) - want := 1.5 + want := 1.0 + mustBuffDef(BuffRush).Magnitude if math.Abs(got-want) > 0.001 { - t.Fatalf("expected Rush to give movement multiplier %.1f, got %.3f", want, got) + t.Fatalf("expected Rush movement multiplier %.3f, got %.3f", want, got) } } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3b3c1e5..668364d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -84,13 +84,9 @@ func New(deps Deps) *chi.Mux { r.Post("/heroes/{heroId}/revive", adminH.ReviveHero) r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) - 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-adventure", adminH.StopHeroAdventure) r.Get("/heroes/{heroId}/gear", adminH.GetHeroGear) r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear) r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)