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