@ -76,10 +76,13 @@ 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"): not persisted; cleared on town enter and when it ends.
// Off-road excursion ("looking for trouble"): timers not persisted; cleared on town enter and when it ends.
AdventureStartAt time . Time
AdventureStartAt time . Time
AdventureEndAt time . Time
AdventureEndAt time . Time
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring
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.
// 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 indicates "resting on roadside" flavor inside the unified resting state.
@ -236,14 +239,16 @@ func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) {
}
}
}
}
// crossRoadChance is the probability of picking a cross-road instead of following the ring.
const crossRoadChance = 0.3
// pickDestination selects the next town the hero should walk toward.
// pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
// When multiple outgoing roads exist, there's a chance the hero takes a cross-road.
func ( hm * HeroMovement ) pickDestination ( graph * RoadGraph ) {
func ( hm * HeroMovement ) pickDestination ( graph * RoadGraph ) {
defer hm . avoidSelfLoopDestination ( graph )
defer hm . avoidSelfLoopDestination ( graph )
if hm . CurrentTownID == 0 {
if hm . CurrentTownID == 0 {
// Fresh heroes are inserted at (0,0). NearestTown(0,0) is often the wrong ring vertex;
// TownOrder[0] is lowest level_min (progression start), matching narrative and ring exits.
if hm . CurrentX == 0 && hm . CurrentY == 0 && len ( graph . TownOrder ) > 0 {
if hm . CurrentX == 0 && hm . CurrentY == 0 && len ( graph . TownOrder ) > 0 {
hm . CurrentTownID = graph . TownOrder [ 0 ]
hm . CurrentTownID = graph . TownOrder [ 0 ]
} else {
} else {
@ -277,6 +282,16 @@ func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
return
return
}
}
// When multiple roads are available, sometimes take a cross-road for variety.
outgoing := graph . TownRoads [ hm . CurrentTownID ]
if len ( outgoing ) > 2 && rand . Float64 ( ) < crossRoadChance {
pick := outgoing [ rand . Intn ( len ( outgoing ) ) ]
if pick != nil && pick . ToTownID != hm . CurrentTownID {
hm . DestinationTownID = pick . ToTownID
return
}
}
if dest := hm . firstReachableOnRing ( graph , idx ) ; dest != 0 {
if dest := hm . firstReachableOnRing ( graph , idx ) ; dest != 0 {
hm . DestinationTownID = dest
hm . DestinationTownID = dest
return
return
@ -446,6 +461,21 @@ 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 ]
@ -529,6 +559,8 @@ func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) {
hm . AdventureStartAt = time . Time { }
hm . AdventureStartAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureSide = 0
hm . AdventureSide = 0
hm . AdventureWanderX = 0
hm . AdventureWanderY = 0
}
}
func ( hm * HeroMovement ) roadsideRestInProgress ( ) bool {
func ( hm * HeroMovement ) roadsideRestInProgress ( ) bool {
@ -559,11 +591,9 @@ func (hm *HeroMovement) EndRoadsideRest() {
hm . endRoadsideRest ( )
hm . endRoadsideRest ( )
}
}
// beginRoadsideRestSession starts a roadside session until endAt. Clears adventure excursion.
// 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 ) {
func ( hm * HeroMovement ) beginRoadsideRestSession ( now , endAt time . Time ) {
hm . AdventureStartAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureSide = 0
hm . RoadsideRestActive = true
hm . RoadsideRestActive = true
hm . RoadsideRestEndAt = endAt
hm . RoadsideRestEndAt = endAt
hm . RoadsideRestStartedAt = now
hm . RoadsideRestStartedAt = now
@ -616,7 +646,7 @@ func (hm *HeroMovement) applyTownRestHeal(dt float64) {
}
}
}
}
// tryStartRoadsideRest pulls the hero off the road when HP is low; cancels an active adventure.
// tryStartRoadsideRest pulls the hero off the road when HP is low; an active adventure timer keeps running .
func ( hm * HeroMovement ) tryStartRoadsideRest ( now time . Time ) {
func ( hm * HeroMovement ) tryStartRoadsideRest ( now time . Time ) {
if hm . roadsideRestInProgress ( ) {
if hm . roadsideRestInProgress ( ) {
return
return
@ -681,6 +711,8 @@ func (hm *HeroMovement) tryStartAdventure(now time.Time) {
spanNs = 1
spanNs = 1
}
}
hm . AdventureEndAt = now . Add ( minDur + time . Duration ( rand . Int63n ( spanNs + 1 ) ) )
hm . AdventureEndAt = now . Add ( minDur + time . Duration ( rand . Int63n ( spanNs + 1 ) ) )
hm . AdventureWanderX = 0
hm . AdventureWanderY = 0
if rand . Float64 ( ) < 0.5 {
if rand . Float64 ( ) < 0.5 {
hm . AdventureSide = 1
hm . AdventureSide = 1
} else {
} else {
@ -711,6 +743,8 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool {
}
}
hm . AdventureStartAt = now
hm . AdventureStartAt = now
hm . AdventureEndAt = now . Add ( minDur + time . Duration ( rand . Int63n ( spanNs + 1 ) ) )
hm . AdventureEndAt = now . Add ( minDur + time . Duration ( rand . Int63n ( spanNs + 1 ) ) )
hm . AdventureWanderX = 0
hm . AdventureWanderY = 0
if rand . Float64 ( ) < 0.5 {
if rand . Float64 ( ) < 0.5 {
hm . AdventureSide = 1
hm . AdventureSide = 1
} else {
} else {
@ -719,6 +753,21 @@ func (hm *HeroMovement) StartAdventureForced(now time.Time) bool {
return true
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 {
@ -735,6 +784,8 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
hm . AdventureStartAt = time . Time { }
hm . AdventureStartAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureSide = 0
hm . AdventureSide = 0
hm . AdventureWanderX = 0
hm . AdventureWanderY = 0
hm . endRoadsideRest ( )
hm . endRoadsideRest ( )
hm . WanderingMerchantDeadline = time . Time { }
hm . WanderingMerchantDeadline = time . Time { }
hm . TownVisitNPCName = ""
hm . TownVisitNPCName = ""
@ -761,6 +812,8 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
hm . AdventureStartAt = time . Time { }
hm . AdventureStartAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureSide = 0
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 { }
@ -806,39 +859,76 @@ func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool {
return true
return true
}
}
// wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back .
// adventureDepthFactor is 0 on the road, then smoothsteps in (RoadsideRestGoInMs), holds, then out before AdventureEndAt .
// (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.)
// 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 ) wilderness Factor( now time . Time ) float64 {
func ( hm * HeroMovement ) adventureDepth Factor( now time . Time ) float64 {
if ! hm . adventureActive ( now ) {
if ! hm . adventureActive ( now ) {
return 0
return 0
}
}
total := hm . AdventureEndAt . Sub ( hm . AdventureStartAt ) . Seconds ( )
t0 := hm . AdventureStartAt
if total <= 0 {
tEnd := hm . AdventureEndAt
if tEnd . IsZero ( ) {
return 0
return 0
}
}
elapsed := now . Sub ( hm . AdventureStartAt ) . Seconds ( )
if ! now . Before ( tEnd ) {
p := elapsed / total
return 0
if p < 0 {
p = 0
} else if p > 1 {
p = 1
}
}
r := tuning . Get ( ) . AdventureWildernessRampFraction
if t0 . IsZero ( ) {
if r < 1e-6 {
t0 = tEnd . Add ( - 365 * 24 * time . Hour )
r = 1e-6
}
}
if r > 0.49 {
total := tEnd . Sub ( t0 )
r = 0.49
if total <= 0 {
return 1
}
}
if p < r {
dtIn , dtOut := roadsideRestPhaseDurations ( total )
return p / r
if now . Before ( t0 ) {
return 0
}
}
if p > 1 - r {
if dtIn > 0 && now . Before ( t0 . Add ( dtIn ) ) {
return ( 1 - p ) / r
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
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 {
func smoothstep01 ( t float64 ) float64 {
if t <= 0 {
if t <= 0 {
return 0
return 0
@ -943,6 +1033,17 @@ func roadsideRestDepthWorldUnits() float64 {
return cfg . RoadsideRestLateral
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
@ -965,6 +1066,29 @@ func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
return - dy / L , dx / L
return - dy / L , dx / L
}
}
// roadForwardUnit is the normalized tangent along the road toward the next waypoint.
func ( hm * HeroMovement ) roadForwardUnit ( ) ( float64 , float64 ) {
if hm . Road == nil || len ( hm . Road . Waypoints ) < 2 {
return 1 , 0
}
idx := hm . WaypointIndex
if idx >= len ( hm . Road . Waypoints ) - 1 {
idx = len ( hm . Road . Waypoints ) - 2
}
if idx < 0 {
return 1 , 0
}
from := hm . Road . Waypoints [ idx ]
to := hm . Road . Waypoints [ idx + 1 ]
dx := to . X - from . X
dy := to . Y - from . Y
L := math . Hypot ( dx , dy )
if L < 1e-6 {
return 1 , 0
}
return dx / L , dy / L
}
func ( hm * HeroMovement ) displayOffset ( now time . Time ) ( float64 , float64 ) {
func ( hm * HeroMovement ) displayOffset ( now time . Time ) ( float64 , float64 ) {
if hm . roadsideRestInProgress ( ) {
if hm . roadsideRestInProgress ( ) {
if hm . RoadsideRestSide == 0 {
if hm . RoadsideRestSide == 0 {
@ -975,13 +1099,20 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
mag := float64 ( hm . RoadsideRestSide ) * roadsideRestDepthWorldUnits ( ) * f
mag := float64 ( hm . RoadsideRestSide ) * roadsideRestDepthWorldUnits ( ) * f
return px * mag , py * mag
return px * mag , py * mag
}
}
w := hm . wildernessFactor ( now )
if hm . adventureActive ( now ) && hm . AdventureSide != 0 && hm . Road != nil && len ( hm . Road . Waypoints ) >= 2 {
if w <= 0 || hm . AdventureSide == 0 {
f := hm . adventureDepthFactor ( now )
return 0 , 0
depth := adventureWildDepthWorldUnits ( )
cfg := tuning . Get ( )
if cfg . AdventureWildLateralMax > 0 {
if alt := cfg . AdventureWildLateralMax ; alt > depth {
depth = alt
}
}
}
px , py := hm . roadPerpendicularUnit ( )
px , py := hm . roadPerpendicularUnit ( )
mag := float64 ( hm . AdventureSide ) * tuning . Get ( ) . AdventureMaxLateral * w
mag := float64 ( hm . AdventureSide ) * depth * f
return px * mag , py * mag
return px * mag + hm . AdventureWanderX , py * mag + hm . AdventureWanderY
}
return 0 , 0
}
}
// WanderingMerchantCost matches REST encounter / npc alms pricing.
// WanderingMerchantCost matches REST encounter / npc alms pricing.
@ -1035,6 +1166,8 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm . AdventureStartAt = time . Time { }
hm . AdventureStartAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureEndAt = time . Time { }
hm . AdventureSide = 0
hm . AdventureSide = 0
hm . AdventureWanderX = 0
hm . AdventureWanderY = 0
hm . endRoadsideRest ( )
hm . endRoadsideRest ( )
ids := graph . TownNPCIDs ( destID )
ids := graph . TownNPCIDs ( destID )
@ -1108,9 +1241,12 @@ func (hm *HeroMovement) Die() {
}
}
// SyncToHero writes movement state back to the hero model for persistence.
// SyncToHero writes movement state back to the hero model for persistence.
// Position uses the same world coordinates as hero_move / position_sync (road spine + display offset).
func ( hm * HeroMovement ) SyncToHero ( ) {
func ( hm * HeroMovement ) SyncToHero ( ) {
hm . Hero . PositionX = hm . CurrentX
now := time . Now ( )
hm . Hero . PositionY = hm . CurrentY
ox , oy := hm . displayOffset ( now )
hm . Hero . PositionX = hm . CurrentX + ox
hm . Hero . PositionY = hm . CurrentY + oy
hm . Hero . State = hm . State
hm . Hero . State = hm . State
if hm . CurrentTownID != 0 {
if hm . CurrentTownID != 0 {
id := hm . CurrentTownID
id := hm . CurrentTownID
@ -1438,6 +1574,7 @@ 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 {
@ -1574,8 +1711,7 @@ func ProcessSingleHeroMovementTick(
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
}
}
if hm . Hero != nil {
if hm . Hero != nil {
hm . Hero . PositionX = hm . CurrentX
hm . SyncToHero ( )
hm . Hero . PositionY = hm . CurrentY
}
}
return
return
}
}
@ -1594,7 +1730,14 @@ func ProcessSingleHeroMovementTick(
}
}
hm . tryStartAdventure ( now )
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 )
@ -1602,10 +1745,8 @@ func ProcessSingleHeroMovementTick(
if sender != nil {
if sender != nil {
town := graph . Towns [ hm . CurrentTownID ]
town := graph . Towns [ hm . CurrentTownID ]
if town != nil {
if town != nil {
npcInfos := make ( [ ] model . TownNPCInfo , 0 , len ( graph . TownNPCs [ hm . CurrentTownID ] ) )
npcInfos := graph . TownNPCInfos ( hm . CurrentTownID )
for _ , n := range graph . TownNPCs [ hm . CurrentTownID ] {
buildingInfos := graph . TownBuildingInfos ( hm . CurrentTownID )
npcInfos = append ( npcInfos , model . TownNPCInfo { ID : n . ID , Name : n . Name , Type : n . Type } )
}
var restMs int64
var restMs int64
if hm . State == model . StateResting {
if hm . State == model . StateResting {
restMs = hm . RestUntil . Sub ( now ) . Milliseconds ( )
restMs = hm . RestUntil . Sub ( now ) . Milliseconds ( )
@ -1615,6 +1756,7 @@ func ProcessSingleHeroMovementTick(
TownName : town . Name ,
TownName : town . Name ,
Biome : town . Biome ,
Biome : town . Biome ,
NPCs : npcInfos ,
NPCs : npcInfos ,
Buildings : buildingInfos ,
RestDurationMs : restMs ,
RestDurationMs : restMs ,
} )
} )
}
}
@ -1664,7 +1806,6 @@ func ProcessSingleHeroMovementTick(
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
sender . SendToHero ( heroID , "hero_move" , hm . MovePayload ( now ) )
}
}
hm . Hero . PositionX = hm . CurrentX
hm . SyncToHero ( )
hm . Hero . PositionY = hm . CurrentY
}
}
}
}