other graph stuff

master
Denis Ranneft 1 month ago
parent 016cb41263
commit 3907eacb30

@ -378,7 +378,7 @@ func HasLuckBuff(hero *model.Hero, now time.Time) bool {
return false 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 { func LuckMultiplier(hero *model.Hero, now time.Time) float64 {
if HasLuckBuff(hero, now) { if HasLuckBuff(hero, now) {
return tuning.Get().LuckBuffMultiplier return tuning.Get().LuckBuffMultiplier

@ -158,8 +158,8 @@ func TestLuckMultiplierWithBuff(t *testing.T) {
} }
mult := LuckMultiplier(hero, now) mult := LuckMultiplier(hero, now)
if mult != 2.5 { if mult != 1.75 {
t.Fatalf("expected luck multiplier 2.5, got %.1f", mult) t.Fatalf("expected luck multiplier 1.75, got %.2f", mult)
} }
} }

@ -335,14 +335,40 @@ func (e *Engine) handleActivateBuff(msg IncomingMessage) {
return 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() 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 { if ab == nil {
hero.RefundBuffCharge(bt)
e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType)) e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType))
return 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 { if e.sender != nil {
e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{ e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{
BuffType: payload.BuffType, 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). // 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) { func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()
@ -720,70 +693,6 @@ func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero
return h, true 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. // ApplyAdminForceLeaveTown ends resting or in-town pause, assigns a new road, and notifies the client.
func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) { func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()

@ -14,8 +14,7 @@ const (
// townNPCVisitLogLines is how many log lines to emit per NPC visit. // townNPCVisitLogLines is how many log lines to emit per NPC visit.
townNPCVisitLogLines = 6 townNPCVisitLogLines = 6
restKindTown = "town" restKindTown = "town"
restKindRoadside = "roadside"
) )
func movementTickRate() time.Duration { 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: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
TownLeaveAt time.Time 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. // Accumulates fractional town-rest regen between ticks.
TownRestHealRemainder float64 TownRestHealRemainder float64
@ -427,13 +408,6 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLeaveAt = shift(hm.TownLeaveAt) 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.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.LastMoveTick = now hm.LastMoveTick = now
} }
@ -461,21 +435,6 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
hm.refreshSpeed(now) hm.refreshSpeed(now)
distThisTick := hm.Speed * dt 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 { for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
from := hm.Road.Waypoints[hm.WaypointIndex] from := hm.Road.Waypoints[hm.WaypointIndex]
to := hm.Road.Waypoints[hm.WaypointIndex+1] to := hm.Road.Waypoints[hm.WaypointIndex+1]
@ -545,90 +504,6 @@ func (hm *HeroMovement) TargetPoint() (float64, float64) {
return wp.X, wp.Y 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) { func (hm *HeroMovement) applyTownRestHeal(dt float64) {
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
return 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). // 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 { func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error {
if graph == nil || townID == 0 { if graph == nil || townID == 0 {
@ -781,12 +534,6 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
hm.WaypointFraction = 0 hm.WaypointFraction = 0
hm.DestinationTownID = townID hm.DestinationTownID = townID
hm.spawnAtRoadStart = false 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.WanderingMerchantDeadline = time.Time{}
hm.TownVisitNPCName = "" hm.TownVisitNPCName = ""
hm.TownVisitNPCType = "" hm.TownVisitNPCType = ""
@ -808,12 +555,6 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
if hm.State == model.StateFighting { if hm.State == model.StateFighting {
return false 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.WanderingMerchantDeadline = time.Time{}
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
@ -831,219 +572,6 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
return true 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) { func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 { if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 0, 1 return 0, 1
@ -1090,28 +618,7 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
} }
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
if hm.roadsideRestInProgress() { _ = now
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
}
return 0, 0 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 { if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
return false, model.Enemy{}, false return false, model.Enemy{}, false
} }
w := hm.wildernessFactor(now) if rand.Float64() >= cfg.EncounterActivityBase {
// 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 {
return false, model.Enemy{}, false return false, model.Enemy{}, false
} }
// On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters. monsterW := cfg.MonsterEncounterWeightBase
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*w*w merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus
merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus*(1-w)*(1-w)
total := monsterW + merchantW total := monsterW + merchantW
r := rand.Float64() * total r := rand.Float64() * total
if r < monsterW { if r < monsterW {
@ -1163,19 +666,12 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.TownVisitLogsEmitted = 0 hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{} hm.TownLeaveAt = time.Time{}
hm.TownRestHealRemainder = 0 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) ids := graph.TownNPCIDs(destID)
if len(ids) == 0 { if len(ids) == 0 {
hm.State = model.StateResting hm.State = model.StateResting
hm.Hero.State = model.StateResting hm.Hero.State = model.StateResting
hm.RestUntil = now.Add(randomRestDuration()) hm.RestUntil = now.Add(randomRestDuration())
hm.RoadsideRestActive = false
return return
} }
@ -1199,7 +695,6 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownLeaveAt = time.Time{} hm.TownLeaveAt = time.Time{}
hm.TownRestHealRemainder = 0 hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{} hm.RestUntil = time.Time{}
hm.endRoadsideRest()
hm.State = model.StateWalking hm.State = model.StateWalking
hm.Hero.State = model.StateWalking hm.Hero.State = model.StateWalking
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick. // 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. // StartFighting pauses movement for combat.
func (hm *HeroMovement) StartFighting() { func (hm *HeroMovement) StartFighting() {
hm.State = model.StateFighting hm.State = model.StateFighting
hm.endRoadsideRest()
hm.WanderingMerchantDeadline = time.Time{} hm.WanderingMerchantDeadline = time.Time{}
} }
@ -1237,7 +731,6 @@ func (hm *HeroMovement) ResumeWalking(now time.Time) {
// Die sets the movement state to dead. // Die sets the movement state to dead.
func (hm *HeroMovement) Die() { func (hm *HeroMovement) Die() {
hm.State = model.StateDead hm.State = model.StateDead
hm.endRoadsideRest()
} }
// SyncToHero writes movement state back to the hero model for persistence. // 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.MoveState = string(hm.State)
hm.Hero.RestKind = "" hm.Hero.RestKind = ""
if hm.State == model.StateResting { if hm.State == model.StateResting {
if hm.roadsideRestInProgress() { hm.Hero.RestKind = restKindTown
hm.Hero.RestKind = restKindRoadside
} else {
hm.Hero.RestKind = restKindTown
}
} }
hm.Hero.TownPause = hm.townPauseBlob() hm.Hero.TownPause = hm.townPauseBlob()
} }
@ -1280,26 +769,9 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
} }
t := hm.RestUntil t := hm.RestUntil
p := &model.TownPausePersisted{ p := &model.TownPausePersisted{
RestUntil: &t, RestUntil: &t,
TownRestHealRemainder: hm.TownRestHealRemainder, RestKind: restKindTown,
RoadsideRestHealRemainder: hm.RoadsideRestHealRemainder, TownRestHealRemainder: hm.TownRestHealRemainder,
}
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 return p
case model.StateInTown: 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() { if blob != nil && blob.RestUntil != nil && !blob.RestUntil.IsZero() {
hm.RestUntil = *blob.RestUntil hm.RestUntil = *blob.RestUntil
hm.TownRestHealRemainder = blob.TownRestHealRemainder 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 return
} }
// Legacy row without town_pause: treat rest as already elapsed so offline/ reconnect unblocks. // 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). // sender may be nil to suppress all WebSocket payloads (offline ticks).
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered. // 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), // 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. // persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
func ProcessSingleHeroMovementTick( func ProcessSingleHeroMovementTick(
heroID int64, heroID int64,
@ -1574,38 +1014,19 @@ func ProcessSingleHeroMovementTick(
return return
case model.StateResting: case model.StateResting:
hm.expireAdventureIfNeeded(now)
// Advance logical movement time while idle so leaving town does not apply a huge dt (teleport). // Advance logical movement time while idle so leaving town does not apply a huge dt (teleport).
dt := now.Sub(hm.LastMoveTick).Seconds() dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 { if dt <= 0 {
dt = movementTickRate().Seconds() dt = movementTickRate().Seconds()
} }
hm.LastMoveTick = now hm.LastMoveTick = now
if hm.roadsideRestInProgress() { hm.applyTownRestHeal(dt)
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() hm.SyncToHero()
if sender != nil && hm.Hero != nil { if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
} }
if now.After(hm.RestUntil) { 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.LeaveTown(graph, now)
hm.SyncToHero() hm.SyncToHero()
if sender != nil { if sender != nil {
@ -1685,7 +1106,6 @@ func ProcessSingleHeroMovementTick(
case model.StateWalking: case model.StateWalking:
cfg := tuning.Get() cfg := tuning.Get()
hm.expireAdventureIfNeeded(now)
hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2 hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2
if hadNoRoad { if hadNoRoad {
hm.Road = nil 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) reachedTown := hm.AdvanceTick(now, graph)
if !reachedTown {
hm.stepAdventureWander(now, dtMove)
}
if reachedTown { if reachedTown {
hm.EnterTown(now, graph) hm.EnterTown(now, graph)

@ -66,14 +66,9 @@ type heroSummary struct {
type adminLiveMovementJSON struct { type adminLiveMovementJSON struct {
Online bool `json:"online"` Online bool `json:"online"`
MoveState string `json:"moveState,omitempty"` 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"` RestUntil *time.Time `json:"restUntil,omitempty"`
TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"` TownLeaveAt *time.Time `json:"townLeaveAt,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"` NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
RoadsideRestActive bool `json:"roadsideRestActive"`
RoadsideRestEndAt *time.Time `json:"roadsideRestEndAt,omitempty"`
CurrentTownID int64 `json:"currentTownId,omitempty"` CurrentTownID int64 `json:"currentTownId,omitempty"`
DestinationTownID int64 `json:"destinationTownId,omitempty"` DestinationTownID int64 `json:"destinationTownId,omitempty"`
WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"` WanderingMerchantDeadline *time.Time `json:"wanderingMerchantDeadline,omitempty"`
@ -91,16 +86,8 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLive
return nil return nil
} }
s := &adminLiveMovementJSON{ s := &adminLiveMovementJSON{
Online: true, Online: true,
MoveState: string(hm.State), 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
} }
if !hm.RestUntil.IsZero() { if !hm.RestUntil.IsZero() {
t := hm.RestUntil t := hm.RestUntil
@ -114,10 +101,6 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLive
t := hm.NextTownNPCRollAt t := hm.NextTownNPCRollAt
s.NextTownNPCRollAt = &t s.NextTownNPCRollAt = &t
} }
if !hm.RoadsideRestEndAt.IsZero() {
t := hm.RoadsideRestEndAt
s.RoadsideRestEndAt = &t
}
if hm.CurrentTownID != 0 { if hm.CurrentTownID != 0 {
s.CurrentTownID = hm.CurrentTownID 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}) 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). // TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest).
// POST /admin/heroes/{heroId}/teleport-town // POST /admin/heroes/{heroId}/teleport-town
func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) { 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) 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. // PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls.
// POST /admin/time/pause // POST /admin/time/pause
func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) {

@ -6,20 +6,14 @@ import (
"github.com/denisovdennis/autohero/internal/model" "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. // Returns an error if no charges remain for the given buff type.
func consumeFreeBuffCharge(hero *model.Hero, bt model.BuffType, now time.Time) error { func consumeFreeBuffCharge(hero *model.Hero, bt model.BuffType, now time.Time) error {
if hero.SubscriptionActive {
return nil
}
hero.EnsureBuffChargesPopulated(now) hero.EnsureBuffChargesPopulated(now)
return hero.ConsumeBuffCharge(bt, 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) { func refundFreeBuffCharge(hero *model.Hero, bt model.BuffType) {
if hero.SubscriptionActive {
return
}
hero.RefundBuffCharge(bt) hero.RefundBuffCharge(bt)
} }

@ -7,14 +7,22 @@ import (
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
) )
func TestConsumeFreeBuffCharge_SubscriptionSkipsQuota(t *testing.T) { func TestConsumeFreeBuffCharge_SubscriberConsumesCharge(t *testing.T) {
h := &model.Hero{SubscriptionActive: true, BuffFreeChargesRemaining: 0}
now := time.Now() 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 { if err := consumeFreeBuffCharge(h, model.BuffRush, now); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if h.BuffFreeChargesRemaining != 0 { st := h.BuffCharges[string(model.BuffRush)]
t.Fatalf("expected no charge mutation for subscriber, got %d", h.BuffFreeChargesRemaining) if st.Remaining != 1 {
t.Fatalf("subscriber should consume charge, want remaining 1, got %d", st.Remaining)
} }
} }

@ -365,22 +365,16 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
hero.EnsureBuffChargesPopulated(now) hero.EnsureBuffChargesPopulated(now)
consumed := false if err := consumeFreeBuffCharge(hero, bt, now); err != nil {
if !hero.SubscriptionActive { writeJSON(w, http.StatusForbidden, map[string]string{
if err := consumeFreeBuffCharge(hero, bt, now); err != nil { "error": err.Error(),
writeJSON(w, http.StatusForbidden, map[string]string{ })
"error": err.Error(), return
})
return
}
consumed = true
} }
ab := game.ApplyBuff(hero, bt, now) ab := game.ApplyBuff(hero, bt, now)
if ab == nil { if ab == nil {
if consumed { refundFreeBuffCharge(hero, bt)
refundFreeBuffCharge(hero, bt)
}
h.logger.Error("ApplyBuff returned nil", "hero_id", hero.ID, "buff_type", bt) h.logger.Error("ApplyBuff returned nil", "hero_id", hero.ID, "buff_type", bt)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to apply buff", "error": "failed to apply buff",

@ -10,7 +10,7 @@ const (
BuffRush BuffType = "rush" // +attack speed BuffRush BuffType = "rush" // +attack speed
BuffRage BuffType = "rage" // +damage BuffRage BuffType = "rage" // +damage
BuffShield BuffType = "shield" // -incoming 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 BuffResurrection BuffType = "resurrection" // revive on death
BuffHeal BuffType = "heal" // +50% HP instantly BuffHeal BuffType = "heal" // +50% HP instantly
BuffPowerPotion BuffType = "power_potion" // +150% damage BuffPowerPotion BuffType = "power_potion" // +150% damage

@ -7,28 +7,28 @@ import (
"github.com/denisovdennis/autohero/internal/tuning" "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{ var BuffFreeChargesPerType = map[BuffType]int{
BuffRush: 3, BuffRush: 2,
BuffRage: 2, BuffRage: 1,
BuffShield: 2, BuffShield: 1,
BuffLuck: 1, BuffLuck: 1,
BuffResurrection: 1, BuffResurrection: 1,
BuffHeal: 3, BuffHeal: 2,
BuffPowerPotion: 1, 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{ var BuffSubscriberChargesPerType = map[BuffType]int{
BuffRush: 6, BuffRush: 3,
BuffRage: 4, BuffRage: 2,
BuffShield: 4, BuffShield: 2,
BuffLuck: 2, BuffLuck: 2,
BuffResurrection: 2, BuffResurrection: 2,
BuffHeal: 6, BuffHeal: 3,
BuffPowerPotion: 2, BuffPowerPotion: 1,
BuffWarCry: 4, BuffWarCry: 2,
} }
func SubscriptionWeeklyPrice() int64 { func SubscriptionWeeklyPrice() int64 {

@ -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) now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
bt := BuffRush bt := BuffRush
h := &Hero{ h := &Hero{

@ -146,9 +146,9 @@ func TestRushAffectsMovementSpeed(t *testing.T) {
}} }}
got := hero.MovementSpeedMultiplier(now) got := hero.MovementSpeedMultiplier(now)
want := 1.5 want := 1.0 + mustBuffDef(BuffRush).Magnitude
if math.Abs(got-want) > 0.001 { 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)
} }
} }

@ -84,13 +84,9 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/revive", adminH.ReviveHero) r.Post("/heroes/{heroId}/revive", adminH.ReviveHero)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) 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}/teleport-town", adminH.TeleportHeroTown)
r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest) r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest)
r.Post("/heroes/{heroId}/leave-town", adminH.ForceLeaveTown) 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.Get("/heroes/{heroId}/gear", adminH.GetHeroGear)
r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear) r.Post("/heroes/{heroId}/gear/grant", adminH.GrantHeroGear)
r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear) r.Post("/heroes/{heroId}/gear/equip", adminH.EquipHeroGear)

Loading…
Cancel
Save