excursions

master
Denis Ranneft 1 month ago
parent 13c7e65515
commit 03208b17ba

@ -2203,7 +2203,7 @@
<button type="button" class="btn" onclick="withAction(() => heroAction('start-roadside-rest',{}, true))" title="Roadside rest at current road position (not in excursion)">Start rest (roadside)</button> <button type="button" class="btn" onclick="withAction(() => heroAction('start-roadside-rest',{}, true))" title="Roadside rest at current road position (not in excursion)">Start rest (roadside)</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button> <button type="button" class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('start-adventure',{}, true))" title="Force mini-adventure (excursion) while walking on road">Start adventure</button> <button type="button" class="btn" onclick="withAction(() => heroAction('start-adventure',{}, true))" title="Force mini-adventure (excursion) while walking on road">Start adventure</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('stop-adventure',{}, true))" title="End active excursion session">Stop adventure</button> <button type="button" class="btn" onclick="withAction(() => heroAction('stop-adventure',{}, true))" title="Force return leg: walk back to road / rest start (excursion continues until arrival)">Stop adventure</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('leave-town',{}))">Leave Town</button> <button type="button" class="btn" onclick="withAction(() => heroAction('leave-town',{}))">Leave Town</button>
<button type="button" class="btn" onclick="withAction(() => heroAction('trigger-random-encounter',{}))" title="Серверный бой со случайным монстром (как на дороге). Нужен подключённый клиент (WS), герой не в бою, не в городе и не в отдыхе">Встреча (случайный монстр)</button> <button type="button" class="btn" onclick="withAction(() => heroAction('trigger-random-encounter',{}))" title="Серверный бой со случайным монстром (как на дороге). Нужен подключённый клиент (WS), герой не в бою, не в городе и не в отдыхе">Встреча (случайный монстр)</button>
</div> </div>

@ -940,7 +940,7 @@ func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
return h, true return h, true
} }
// ApplyAdminStopExcursion ends an online hero's excursion immediately. // ApplyAdminStopExcursion forces the return leg of an active excursion (admin "stop adventure").
func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) { func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@ -957,7 +957,7 @@ func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
if e.sender != nil { if e.sender != nil {
h.EnsureGearMap() h.EnsureGearMap()
h.RefreshDerivedCombatStats(now) h.RefreshDerivedCombatStats(now)
e.sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{}) e.sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
e.sender.SendToHero(heroID, "hero_state", h) e.sender.SendToHero(heroID, "hero_state", h)
e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
if route := hm.RoutePayload(); route != nil { if route := hm.RoutePayload(); route != nil {
@ -1610,9 +1610,15 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
delete(e.combats, cs.HeroID) delete(e.combats, cs.HeroID)
// Resume walking before hero_state so positions match hero_move (road + forest offset). // Resume walking before hero_state so positions match hero_move.
if hm, ok := e.movements[cs.HeroID]; ok { if hm, ok := e.movements[cs.HeroID]; ok {
hm.ResumeWalking(now) hm.ResumeWalking(now)
prevExcPhase := hm.Excursion.Phase
hm.TryAdventureReturnAfterCombat(now)
if e.sender != nil && hm.Excursion.Phase != prevExcPhase && hm.Excursion.Phase == model.ExcursionReturn {
e.sender.SendToHero(cs.HeroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
e.sender.SendToHero(cs.HeroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero() hm.SyncToHero()
} }

@ -78,19 +78,19 @@ func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
origWildUntil := hm.Excursion.WildUntil origRestUntil := hm.RestUntil
// Skip "out" leg: test HP exit from wild (campfire) phase. // Skip "out" leg: test HP exit from wild (campfire) phase.
hm.Excursion.Phase = model.ExcursionWild hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.OutUntil = now.Add(-time.Second) hm.LastMoveTick = now
tick := now.Add(time.Second) tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn { if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase) t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase)
} }
if !tick.Before(origWildUntil) { if !tick.Before(origRestUntil) {
t.Fatal("HP exit should force return before original WildUntil timer") t.Fatal("HP exit should force return before RestUntil wild cap")
} }
} }
@ -116,6 +116,45 @@ func TestFSM_AdventureInlineRest_HPExit_ExcursionStillActive(t *testing.T) {
} }
} }
func TestFSM_AdventureReturnAfterVictoryWhenTimerElapsedDuringFight(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.AdventureEndsAt = now.Add(-time.Second)
hm.StartFighting()
victoryAt := now.Add(5 * time.Second)
hm.ResumeWalking(victoryAt)
hm.TryAdventureReturnAfterCombat(victoryAt)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after victory with elapsed adventure timer, got %s", hm.Excursion.Phase)
}
if !hm.Excursion.AttractorSet {
t.Fatal("return attractor should be set toward road")
}
}
func TestFSM_AdventureReturnAfterVictoryWhenPendingFlagSet(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.AdventureEndsAt = now.Add(time.Hour)
hm.Excursion.PendingReturnAfterCombat = true
hm.TryAdventureReturnAfterCombat(now)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase when pending flag set, got %s", hm.Excursion.Phase)
}
}
func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) { func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) {
graph := testGraph() graph := testGraph()
cfg := tuning.Get() cfg := tuning.Get()

@ -74,25 +74,18 @@ type HeroMovement struct {
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild). // RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time.Time RoadsideThoughtNextAt time.Time
// Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires. // Walk-to-NPC: attractor at TownNPCWalkTo* while TownNPCWalkTargetID != 0.
TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking) TownNPCWalkTargetID int64
TownNPCWalkFromX float64
TownNPCWalkFromY float64
TownNPCWalkToX float64 TownNPCWalkToX float64
TownNPCWalkToY float64 TownNPCWalkToY float64
TownNPCWalkStart time.Time // when walk began
TownNPCWalkArrive time.Time // when hero reaches NPC
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end). // TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
TownLeaveAt time.Time TownLeaveAt time.Time
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded). // TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool TownPlazaHealActive bool
// TownCenterWalk*: walk from last NPC stand back to town center before road snap (avoids teleport to road spine). // TownCenterWalk*: attractor stepping to plaza before road snap.
TownCenterWalkArrive time.Time TownCenterWalkActive bool
TownCenterWalkStart time.Time
TownCenterWalkFromX float64
TownCenterWalkFromY float64
TownCenterWalkToX float64 TownCenterWalkToX float64
TownCenterWalkToY float64 TownCenterWalkToY float64
@ -130,6 +123,7 @@ type townPausePersistSignature struct {
RestKind model.RestKind RestKind model.RestKind
RestUntil time.Time RestUntil time.Time
ExcursionKind model.ExcursionKind
ExcursionPhase model.ExcursionPhase ExcursionPhase model.ExcursionPhase
ExcursionStartedAt time.Time ExcursionStartedAt time.Time
ExcursionOutUntil time.Time ExcursionOutUntil time.Time
@ -138,6 +132,14 @@ type townPausePersistSignature struct {
ExcursionDepthWorldUnits float64 ExcursionDepthWorldUnits float64
ExcursionRoadFreezeWaypoint int ExcursionRoadFreezeWaypoint int
ExcursionRoadFreezeFraction float64 ExcursionRoadFreezeFraction float64
ExcursionStartX float64
ExcursionStartY float64
ExcursionAttractorX float64
ExcursionAttractorY float64
ExcursionAttractorSet bool
ExcursionAdventureEndsAt time.Time
ExcursionWanderNextAt time.Time
ExcursionPendingReturn bool
// In-town NPC tour: coarse milestones only (not per-tick x,y during walks). // In-town NPC tour: coarse milestones only (not per-tick x,y during walks).
InTown bool InTown bool
@ -146,11 +148,12 @@ type townPausePersistSignature struct {
InTownVisitStarted time.Time InTownVisitStarted time.Time
InTownVisitLogs int InTownVisitLogs int
InTownNPCWalkTarget int64 InTownNPCWalkTarget int64
InTownNPCWalkStart time.Time InTownNPCWalkToX float64
InTownNPCWalkArrive time.Time InTownNPCWalkToY float64
InTownPlazaHeal bool InTownPlazaHeal bool
InTownCenterWalkStart time.Time InTownCenterWalkActive bool
InTownCenterWalkArrive time.Time InTownCenterWalkToX float64
InTownCenterWalkToY float64
InTownNPCQueueLen int InTownNPCQueueLen int
InTownNPCQueueFP uint64 InTownNPCQueueFP uint64
InTownVisitName string InTownVisitName string
@ -529,8 +532,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.TownCenterWalkStart = shift(hm.TownCenterWalkStart)
hm.TownCenterWalkArrive = shift(hm.TownCenterWalkArrive)
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil) hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil)
@ -624,6 +625,9 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
// Heading returns the angle (radians) the hero is currently facing. // Heading returns the angle (radians) the hero is currently facing.
func (hm *HeroMovement) Heading() float64 { func (hm *HeroMovement) Heading() float64 {
if hm.excursionUsesAttractors() && hm.Excursion.AttractorSet {
return math.Atan2(hm.Excursion.AttractorY-hm.CurrentY, hm.Excursion.AttractorX-hm.CurrentX)
}
if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 { if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
return 0 return 0
} }
@ -633,6 +637,9 @@ func (hm *HeroMovement) Heading() float64 {
// TargetPoint returns the next waypoint the hero is heading toward. // TargetPoint returns the next waypoint the hero is heading toward.
func (hm *HeroMovement) TargetPoint() (float64, float64) { func (hm *HeroMovement) TargetPoint() (float64, float64) {
if hm.excursionUsesAttractors() && hm.Excursion.AttractorSet {
return hm.Excursion.AttractorX, hm.Excursion.AttractorY
}
if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 { if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
return hm.CurrentX, hm.CurrentY return hm.CurrentX, hm.CurrentY
} }
@ -723,8 +730,9 @@ func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool {
return true return true
} }
// AdminStopExcursion ends an active excursion immediately (hero back on the road spine). // AdminStopExcursion skips the remaining forest/wild leg and starts the return leg toward the road
// Works during walking phases or adventure-inline rest; rejects combat. // (adventure: nearest point on the frozen road polyline; roadside: saved StartX/Y on the road).
// Does not end the session until the hero reaches the return attractor. Rejects combat.
func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool { func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if !hm.Excursion.Active() { if !hm.Excursion.Active() {
return false return false
@ -738,10 +746,24 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
hm.RestHealRemainder = 0 hm.RestHealRemainder = 0
hm.State = model.StateWalking hm.State = model.StateWalking
hm.Hero.State = model.StateWalking hm.Hero.State = model.StateWalking
hm.enterAdventureReturnToRoad()
hm.refreshSpeed(now)
return true
} }
hm.endExcursion(now) if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
hm.refreshSpeed(now) hm.Excursion.Phase = model.ExcursionReturn
return true hm.setRoadsideReturnAttractor()
hm.RestUntil = time.Time{}
hm.RoadsideThoughtNextAt = time.Time{}
hm.refreshSpeed(now)
return true
}
if hm.State == model.StateWalking && hm.Excursion.Kind == model.ExcursionKindAdventure {
hm.enterAdventureReturnToRoad()
hm.refreshSpeed(now)
return true
}
return false
} }
// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking. // AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking.
@ -839,7 +861,200 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
return dx / L, dy / L return dx / L, dy / L
} }
func (hm *HeroMovement) excursionUsesAttractors() bool {
return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone
}
func excursionArrivalEpsilon() float64 {
cfg := tuning.Get()
eps := cfg.ExcursionArrivalEpsilonWorld
if eps <= 0 {
eps = tuning.DefaultValues().ExcursionArrivalEpsilonWorld
}
return eps
}
// stepTowardWorldPoint moves CurrentX/Y toward (tx, ty) at speed (world units per second).
// Uses the same arrival epsilon as excursion attractors.
func (hm *HeroMovement) stepTowardWorldPoint(dt float64, tx, ty, speed float64) bool {
if hm == nil || dt <= 0 {
return false
}
if speed <= 0 {
speed = tuning.DefaultValues().TownNPCWalkSpeed
}
eps := excursionArrivalEpsilon()
dx := tx - hm.CurrentX
dy := ty - hm.CurrentY
dist := math.Hypot(dx, dy)
if dist <= eps {
hm.CurrentX = tx
hm.CurrentY = ty
return true
}
step := speed * dt
if step >= dist {
hm.CurrentX = tx
hm.CurrentY = ty
return true
}
hm.CurrentX += dx / dist * step
hm.CurrentY += dy / dist * step
return false
}
// closestPointOnRoadSegments returns the closest point on the road polyline to (hx, hy).
func closestPointOnRoadSegments(road *Road, hx, hy float64) (float64, float64) {
if road == nil || len(road.Waypoints) < 2 {
return hx, hy
}
bestDistSq := math.MaxFloat64
bestX, bestY := hx, hy
for i := 0; i < len(road.Waypoints)-1; i++ {
ax, ay := road.Waypoints[i].X, road.Waypoints[i].Y
bx, by := road.Waypoints[i+1].X, road.Waypoints[i+1].Y
dx, dy := bx-ax, by-ay
segLenSq := dx*dx + dy*dy
var t float64
if segLenSq < 1e-12 {
t = 0
} else {
t = ((hx-ax)*dx + (hy-ay)*dy) / segLenSq
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
}
px := ax + t*dx
py := ay + t*dy
dSq := (hx-px)*(hx-px) + (hy-py)*(hy-py)
if dSq < bestDistSq {
bestDistSq = dSq
bestX, bestY = px, py
}
}
return bestX, bestY
}
func (hm *HeroMovement) pickExcursionForestAttractor(depth float64) {
if depth <= 0 {
depth = 12
}
px, py := hm.roadPerpendicularUnit()
j := 0.85 + rand.Float64()*0.3
d := depth * j
hm.Excursion.AttractorX = hm.CurrentX + px*d
hm.Excursion.AttractorY = hm.CurrentY + py*d
hm.Excursion.AttractorSet = true
}
func (hm *HeroMovement) setRoadsideReturnAttractor() {
hm.Excursion.AttractorX = hm.Excursion.StartX
hm.Excursion.AttractorY = hm.Excursion.StartY
hm.Excursion.AttractorSet = true
}
func (hm *HeroMovement) enterAdventureReturnToRoad() {
if hm.Road == nil {
return
}
rx, ry := closestPointOnRoadSegments(hm.Road, hm.CurrentX, hm.CurrentY)
hm.Excursion.Phase = model.ExcursionReturn
hm.Excursion.PendingReturnAfterCombat = false
hm.Excursion.AttractorX = rx
hm.Excursion.AttractorY = ry
hm.Excursion.AttractorSet = true
}
// TryAdventureReturnAfterCombat runs after combat victory: if the adventure timer had elapsed
// (including while movement ticks were skipped during combat), or PendingReturnAfterCombat was
// set, transition to return phase toward the road.
func (hm *HeroMovement) TryAdventureReturnAfterCombat(now time.Time) {
if hm == nil || !hm.Excursion.Active() || hm.Excursion.Kind != model.ExcursionKindAdventure {
return
}
if hm.Excursion.Phase != model.ExcursionWild {
return
}
timerDone := !hm.Excursion.AdventureEndsAt.IsZero() && !now.Before(hm.Excursion.AdventureEndsAt)
if !timerDone && !hm.Excursion.PendingReturnAfterCombat {
return
}
hm.enterAdventureReturnToRoad()
}
func (hm *HeroMovement) adventureScheduleWanderRetarget(now time.Time) {
cfg := tuning.Get()
minMs := cfg.AdventureWanderRetargetMinMs
maxMs := cfg.AdventureWanderRetargetMaxMs
if minMs <= 0 {
minMs = tuning.DefaultValues().AdventureWanderRetargetMinMs
}
if maxMs <= 0 {
maxMs = tuning.DefaultValues().AdventureWanderRetargetMaxMs
}
hm.Excursion.WanderNextAt = now.Add(randomDurationBetweenMs(minMs, maxMs))
}
func (hm *HeroMovement) adventurePickWanderAttractor() {
cfg := tuning.Get()
r := cfg.AdventureWanderRadius
if r <= 0 {
r = tuning.DefaultValues().AdventureWanderRadius
}
theta := rand.Float64() * 2 * math.Pi
rd := r * (0.25 + 0.75*rand.Float64())
hm.Excursion.AttractorX = hm.CurrentX + math.Cos(theta)*rd
hm.Excursion.AttractorY = hm.CurrentY + math.Sin(theta)*rd
hm.Excursion.AttractorSet = true
}
// stepTowardAttractor moves CurrentX/Y toward the excursion attractor. Returns true when arrived.
func (hm *HeroMovement) stepTowardAttractor(now time.Time, dt float64) bool {
if !hm.Excursion.AttractorSet {
return true
}
hm.refreshSpeed(now)
eps := excursionArrivalEpsilon()
dx := hm.Excursion.AttractorX - hm.CurrentX
dy := hm.Excursion.AttractorY - hm.CurrentY
dist := math.Hypot(dx, dy)
if dist <= eps {
hm.CurrentX = hm.Excursion.AttractorX
hm.CurrentY = hm.Excursion.AttractorY
return true
}
step := hm.Speed * dt
if step >= dist {
hm.CurrentX = hm.Excursion.AttractorX
hm.CurrentY = hm.Excursion.AttractorY
return true
}
hm.CurrentX += dx / dist * step
hm.CurrentY += dy / dist * step
return false
}
func (hm *HeroMovement) tryBeginAdventureReturn(now time.Time) {
if hm.Excursion.Kind != model.ExcursionKindAdventure || hm.Excursion.Phase != model.ExcursionWild {
return
}
if hm.Excursion.AdventureEndsAt.IsZero() || now.Before(hm.Excursion.AdventureEndsAt) {
return
}
if hm.State == model.StateFighting {
hm.Excursion.PendingReturnAfterCombat = true
return
}
hm.enterAdventureReturnToRoad()
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
if hm.excursionUsesAttractors() {
return 0, 0
}
exc := &hm.Excursion exc := &hm.Excursion
if exc.Active() { if exc.Active() {
perpX, perpY := hm.roadPerpendicularUnit() perpX, perpY := hm.roadPerpendicularUnit()
@ -858,7 +1073,7 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
retMs := float64(exc.ReturnUntil.Sub(exc.WildUntil).Milliseconds()) retMs := float64(exc.ReturnUntil.Sub(exc.WildUntil).Milliseconds())
if retMs > 0 { if retMs > 0 {
elapsed := float64(now.Sub(exc.WildUntil).Milliseconds()) elapsed := float64(now.Sub(exc.WildUntil).Milliseconds())
t = 1.0 - smoothstep(clamp01(elapsed / retMs)) t = 1.0 - smoothstep(clamp01(elapsed/retMs))
} }
} }
d := depth * t d := depth * t
@ -1006,19 +1221,12 @@ func townNPCStandPoint(npcX, npcY, fromX, fromY, standoff float64) (sx, sy float
// clearNPCWalk resets the walk-to-NPC sub-state. // clearNPCWalk resets the walk-to-NPC sub-state.
func (hm *HeroMovement) clearNPCWalk() { func (hm *HeroMovement) clearNPCWalk() {
hm.TownNPCWalkTargetID = 0 hm.TownNPCWalkTargetID = 0
hm.TownNPCWalkFromX = 0
hm.TownNPCWalkFromY = 0
hm.TownNPCWalkToX = 0 hm.TownNPCWalkToX = 0
hm.TownNPCWalkToY = 0 hm.TownNPCWalkToY = 0
hm.TownNPCWalkStart = time.Time{}
hm.TownNPCWalkArrive = time.Time{}
} }
func (hm *HeroMovement) clearTownCenterWalk() { func (hm *HeroMovement) clearTownCenterWalk() {
hm.TownCenterWalkArrive = time.Time{} hm.TownCenterWalkActive = false
hm.TownCenterWalkStart = time.Time{}
hm.TownCenterWalkFromX = 0
hm.TownCenterWalkFromY = 0
hm.TownCenterWalkToX = 0 hm.TownCenterWalkToX = 0
hm.TownCenterWalkToY = 0 hm.TownCenterWalkToY = 0
} }
@ -1110,8 +1318,10 @@ func (hm *HeroMovement) SyncToHero() {
} }
} }
hm.Hero.ExcursionPhase = model.ExcursionNone hm.Hero.ExcursionPhase = model.ExcursionNone
hm.Hero.ExcursionKind = model.ExcursionKindNone
if hm.Excursion.Active() { if hm.Excursion.Active() {
hm.Hero.ExcursionPhase = hm.Excursion.Phase hm.Hero.ExcursionPhase = hm.Excursion.Phase
hm.Hero.ExcursionKind = hm.Excursion.Kind
} }
hm.Hero.TownPause = hm.townPauseBlob() hm.Hero.TownPause = hm.townPauseBlob()
} }
@ -1144,6 +1354,7 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
if hm.Excursion.Active() { if hm.Excursion.Active() {
s := hm.Excursion s := hm.Excursion
sig.ExcursionKind = s.Kind
sig.ExcursionPhase = s.Phase sig.ExcursionPhase = s.Phase
sig.ExcursionStartedAt = s.StartedAt sig.ExcursionStartedAt = s.StartedAt
sig.ExcursionOutUntil = s.OutUntil sig.ExcursionOutUntil = s.OutUntil
@ -1152,6 +1363,14 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.ExcursionDepthWorldUnits = s.DepthWorldUnits sig.ExcursionDepthWorldUnits = s.DepthWorldUnits
sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint
sig.ExcursionRoadFreezeFraction = s.RoadFreezeFraction sig.ExcursionRoadFreezeFraction = s.RoadFreezeFraction
sig.ExcursionStartX = s.StartX
sig.ExcursionStartY = s.StartY
sig.ExcursionAttractorX = s.AttractorX
sig.ExcursionAttractorY = s.AttractorY
sig.ExcursionAttractorSet = s.AttractorSet
sig.ExcursionAdventureEndsAt = s.AdventureEndsAt
sig.ExcursionWanderNextAt = s.WanderNextAt
sig.ExcursionPendingReturn = s.PendingReturnAfterCombat
} }
if hm.State == model.StateInTown { if hm.State == model.StateInTown {
@ -1161,11 +1380,12 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.InTownVisitStarted = hm.TownVisitStartedAt sig.InTownVisitStarted = hm.TownVisitStartedAt
sig.InTownVisitLogs = hm.TownVisitLogsEmitted sig.InTownVisitLogs = hm.TownVisitLogsEmitted
sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID
sig.InTownNPCWalkStart = hm.TownNPCWalkStart sig.InTownNPCWalkToX = hm.TownNPCWalkToX
sig.InTownNPCWalkArrive = hm.TownNPCWalkArrive sig.InTownNPCWalkToY = hm.TownNPCWalkToY
sig.InTownPlazaHeal = hm.TownPlazaHealActive sig.InTownPlazaHeal = hm.TownPlazaHealActive
sig.InTownCenterWalkStart = hm.TownCenterWalkStart sig.InTownCenterWalkActive = hm.TownCenterWalkActive
sig.InTownCenterWalkArrive = hm.TownCenterWalkArrive sig.InTownCenterWalkToX = hm.TownCenterWalkToX
sig.InTownCenterWalkToY = hm.TownCenterWalkToY
sig.InTownNPCQueueLen = len(hm.TownNPCQueue) sig.InTownNPCQueueLen = len(hm.TownNPCQueue)
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue) sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
sig.InTownVisitName = hm.TownVisitNPCName sig.InTownVisitName = hm.TownVisitNPCName
@ -1200,19 +1420,9 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
TownVisitNPCName: hm.TownVisitNPCName, TownVisitNPCName: hm.TownVisitNPCName,
TownVisitNPCType: hm.TownVisitNPCType, TownVisitNPCType: hm.TownVisitNPCType,
TownVisitLogsEmitted: hm.TownVisitLogsEmitted, TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
NPCWalkTargetID: hm.TownNPCWalkTargetID, NPCWalkTargetID: hm.TownNPCWalkTargetID,
NPCWalkFromX: hm.TownNPCWalkFromX, NPCWalkToX: hm.TownNPCWalkToX,
NPCWalkFromY: hm.TownNPCWalkFromY, NPCWalkToY: hm.TownNPCWalkToY,
NPCWalkToX: hm.TownNPCWalkToX,
NPCWalkToY: hm.TownNPCWalkToY,
}
if !hm.TownNPCWalkStart.IsZero() {
t := hm.TownNPCWalkStart
p.NPCWalkStart = &t
}
if !hm.TownNPCWalkArrive.IsZero() {
t := hm.TownNPCWalkArrive
p.NPCWalkArrive = &t
} }
if len(hm.TownNPCQueue) > 0 { if len(hm.TownNPCQueue) > 0 {
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...) p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
@ -1232,17 +1442,10 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
if hm.TownPlazaHealActive { if hm.TownPlazaHealActive {
p.TownPlazaHealActive = true p.TownPlazaHealActive = true
} }
p.CenterWalkFromX = hm.TownCenterWalkFromX if hm.TownCenterWalkActive {
p.CenterWalkFromY = hm.TownCenterWalkFromY p.CenterWalkActive = true
p.CenterWalkToX = hm.TownCenterWalkToX p.CenterWalkToX = hm.TownCenterWalkToX
p.CenterWalkToY = hm.TownCenterWalkToY p.CenterWalkToY = hm.TownCenterWalkToY
if !hm.TownCenterWalkStart.IsZero() {
t := hm.TownCenterWalkStart
p.CenterWalkStart = &t
}
if !hm.TownCenterWalkArrive.IsZero() {
t := hm.TownCenterWalkArrive
p.CenterWalkArrive = &t
} }
} }
@ -1262,10 +1465,17 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted { func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
s := &hm.Excursion s := &hm.Excursion
ep := &model.ExcursionPersisted{ ep := &model.ExcursionPersisted{
Phase: string(s.Phase), Kind: string(s.Kind),
DepthWorldUnits: s.DepthWorldUnits, Phase: string(s.Phase),
RoadFreezeWaypoint: s.RoadFreezeWaypoint, DepthWorldUnits: s.DepthWorldUnits,
RoadFreezeFraction: s.RoadFreezeFraction, RoadFreezeWaypoint: s.RoadFreezeWaypoint,
RoadFreezeFraction: s.RoadFreezeFraction,
StartX: s.StartX,
StartY: s.StartY,
AttractorX: s.AttractorX,
AttractorY: s.AttractorY,
AttractorSet: s.AttractorSet,
PendingReturnAfterCombat: s.PendingReturnAfterCombat,
} }
if !s.StartedAt.IsZero() { if !s.StartedAt.IsZero() {
t := s.StartedAt t := s.StartedAt
@ -1283,6 +1493,14 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s.ReturnUntil t := s.ReturnUntil
ep.ReturnUntil = &t ep.ReturnUntil = &t
} }
if !s.AdventureEndsAt.IsZero() {
t := s.AdventureEndsAt
ep.AdventureEndsAt = &t
}
if !s.WanderNextAt.IsZero() {
t := s.WanderNextAt
ep.WanderNextAt = &t
}
return ep return ep
} }
@ -1321,26 +1539,14 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
} }
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
hm.TownNPCWalkFromX = blob.NPCWalkFromX
hm.TownNPCWalkFromY = blob.NPCWalkFromY
hm.TownNPCWalkToX = blob.NPCWalkToX hm.TownNPCWalkToX = blob.NPCWalkToX
hm.TownNPCWalkToY = blob.NPCWalkToY hm.TownNPCWalkToY = blob.NPCWalkToY
if blob.NPCWalkStart != nil {
hm.TownNPCWalkStart = *blob.NPCWalkStart
}
if blob.NPCWalkArrive != nil {
hm.TownNPCWalkArrive = *blob.NPCWalkArrive
}
hm.TownPlazaHealActive = blob.TownPlazaHealActive hm.TownPlazaHealActive = blob.TownPlazaHealActive
hm.TownCenterWalkFromX = blob.CenterWalkFromX
hm.TownCenterWalkFromY = blob.CenterWalkFromY
hm.TownCenterWalkToX = blob.CenterWalkToX hm.TownCenterWalkToX = blob.CenterWalkToX
hm.TownCenterWalkToY = blob.CenterWalkToY hm.TownCenterWalkToY = blob.CenterWalkToY
if blob.CenterWalkStart != nil { hm.TownCenterWalkActive = blob.CenterWalkActive
hm.TownCenterWalkStart = *blob.CenterWalkStart if !hm.TownCenterWalkActive && blob.CenterWalkStart != nil && !blob.CenterWalkStart.IsZero() {
} hm.TownCenterWalkActive = true
if blob.CenterWalkArrive != nil {
hm.TownCenterWalkArrive = *blob.CenterWalkArrive
} }
} }
@ -1351,6 +1557,12 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
} }
func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) { func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
// Legacy offset-only excursions (no kind) cannot resume with the attractor FSM.
if ep.Kind == "" && ep.Phase != "" {
hm.Excursion = model.ExcursionSession{}
return
}
hm.Excursion.Kind = model.ExcursionKind(ep.Kind)
hm.Excursion.Phase = model.ExcursionPhase(ep.Phase) hm.Excursion.Phase = model.ExcursionPhase(ep.Phase)
if ep.StartedAt != nil { if ep.StartedAt != nil {
hm.Excursion.StartedAt = *ep.StartedAt hm.Excursion.StartedAt = *ep.StartedAt
@ -1367,6 +1579,18 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits
hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint
hm.Excursion.RoadFreezeFraction = ep.RoadFreezeFraction hm.Excursion.RoadFreezeFraction = ep.RoadFreezeFraction
hm.Excursion.StartX = ep.StartX
hm.Excursion.StartY = ep.StartY
hm.Excursion.AttractorX = ep.AttractorX
hm.Excursion.AttractorY = ep.AttractorY
hm.Excursion.AttractorSet = ep.AttractorSet
hm.Excursion.PendingReturnAfterCombat = ep.PendingReturnAfterCombat
if ep.AdventureEndsAt != nil {
hm.Excursion.AdventureEndsAt = *ep.AdventureEndsAt
}
if ep.WanderNextAt != nil {
hm.Excursion.WanderNextAt = *ep.WanderNextAt
}
} }
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display). // MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
@ -1532,54 +1756,31 @@ func (hm *HeroMovement) mayStartExcursion(now time.Time) bool {
func (hm *HeroMovement) beginExcursion(now time.Time) { func (hm *HeroMovement) beginExcursion(now time.Time) {
cfg := tuning.Get() cfg := tuning.Get()
depth := cfg.AdventureDepthWorldUnits depth := cfg.AdventureDepthWorldUnits
if depth <= 0 {
hm.refreshSpeed(now) depth = tuning.DefaultValues().AdventureDepthWorldUnits
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
} }
outDur := time.Duration(depth / speed * float64(time.Second)) minDur := cfg.AdventureDurationMinMs
maxDur := cfg.AdventureDurationMaxMs
outEnd := now.Add(outDur) if minDur <= 0 {
wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs) minDur = tuning.DefaultValues().AdventureDurationMinMs
wildEnd := outEnd.Add(wildDur) }
returnDur := time.Duration(depth / speed * float64(time.Second)) if maxDur <= 0 {
maxDur = tuning.DefaultValues().AdventureDurationMaxMs
}
adventureEnds := now.Add(randomDurationBetweenMs(minDur, maxDur))
hm.Excursion = model.ExcursionSession{ hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindAdventure,
Phase: model.ExcursionOut, Phase: model.ExcursionOut,
StartedAt: now, StartedAt: now,
OutUntil: outEnd,
WildUntil: wildEnd,
ReturnUntil: wildEnd.Add(returnDur),
DepthWorldUnits: depth, DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction, RoadFreezeFraction: hm.WaypointFraction,
StartX: hm.CurrentX,
StartY: hm.CurrentY,
AdventureEndsAt: adventureEnds,
} }
} hm.pickExcursionForestAttractor(depth)
// advanceExcursionPhases progresses through out->wild->return and returns true when complete.
func (hm *HeroMovement) advanceExcursionPhases(now time.Time) (ended bool) {
exc := &hm.Excursion
if exc.Phase == model.ExcursionOut && !now.Before(exc.OutUntil) {
exc.Phase = model.ExcursionWild
}
if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) {
exc.Phase = model.ExcursionReturn
// Only recalculate return duration if we haven't already passed the original deadline
// (handles large time jumps from offline catch-up or timer-based exits).
if now.Before(exc.ReturnUntil) {
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
}
exc.WildUntil = now
exc.ReturnUntil = now.Add(time.Duration(exc.DepthWorldUnits / speed * float64(time.Second)))
}
}
if exc.Phase == model.ExcursionReturn && !now.Before(exc.ReturnUntil) {
return true
}
return false
} }
func (hm *HeroMovement) endExcursion(now time.Time) { func (hm *HeroMovement) endExcursion(now time.Time) {
@ -1606,30 +1807,21 @@ func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
if depth <= 0 { if depth <= 0 {
depth = 12.0 depth = 12.0
} }
hm.refreshSpeed(now)
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
}
moveDur := time.Duration(depth / speed * float64(time.Second))
outUntil := now.Add(moveDur)
restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs) restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
wildUntil := outUntil.Add(restDur)
returnUntil := wildUntil.Add(moveDur)
hm.Excursion = model.ExcursionSession{ hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindRoadside,
Phase: model.ExcursionOut, Phase: model.ExcursionOut,
StartedAt: now, StartedAt: now,
OutUntil: outUntil,
WildUntil: wildUntil,
ReturnUntil: returnUntil,
DepthWorldUnits: depth, DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction, RoadFreezeFraction: hm.WaypointFraction,
StartX: hm.CurrentX,
StartY: hm.CurrentY,
} }
// RestUntil tracks only the rest (wild) phase; travel out/return is separate. hm.pickExcursionForestAttractor(depth)
hm.RestUntil = wildUntil // RestUntil caps the wild (heal) phase; out/return are movement phases.
hm.RestUntil = now.Add(restDur)
hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second) hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second)
} }
@ -1740,20 +1932,14 @@ func ProcessSingleHeroMovementTick(
switch hm.ActiveRestKind { switch hm.ActiveRestKind {
case model.RestKindRoadside: case model.RestKindRoadside:
// For roadside rest, ensure Wild→Return always gets a fresh return prevPhase := hm.Excursion.Phase
// deadline so the hero walks back to the road smoothly (prevents hm.refreshSpeed(now)
// advanceExcursionPhases from skipping the return phase on time jumps). switch hm.Excursion.Phase {
if hm.Excursion.Phase == model.ExcursionWild && !now.Before(hm.Excursion.WildUntil) { case model.ExcursionOut:
hm.Excursion.Phase = model.ExcursionReturn if hm.stepTowardAttractor(now, dt) {
speed := hm.Speed hm.Excursion.Phase = model.ExcursionWild
if speed < 0.1 {
speed = 0.1
} }
hm.Excursion.WildUntil = now case model.ExcursionWild:
hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second)))
}
excursionEnded := hm.advanceExcursionPhases(now)
if hm.Excursion.Phase == model.ExcursionWild {
hm.applyRestHealTick(dt) hm.applyRestHealTick(dt)
if adventureLog != nil { if adventureLog != nil {
if hm.RoadsideThoughtNextAt.IsZero() { if hm.RoadsideThoughtNextAt.IsZero() {
@ -1770,29 +1956,27 @@ func ProcessSingleHeroMovementTick(
hm.RoadsideThoughtNextAt = now.Add(time.Duration(30+rand.Intn(61)) * time.Second) hm.RoadsideThoughtNextAt = now.Add(time.Duration(30+rand.Intn(61)) * time.Second)
} }
} }
}
if excursionEnded {
hm.endExcursion(now)
hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
hm.RestHealRemainder = 0
hm.RoadsideThoughtNextAt = time.Time{}
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.refreshSpeed(now)
} else if hm.Excursion.Phase == model.ExcursionWild {
cfg := tuning.Get() cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp { if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp {
hm.Excursion.Phase = model.ExcursionReturn hm.Excursion.Phase = model.ExcursionReturn
speed := hm.Speed hm.setRoadsideReturnAttractor()
if speed < 0.1 { }
speed = 0.1 case model.ExcursionReturn:
} if hm.stepTowardAttractor(now, dt) {
hm.Excursion.WildUntil = now hm.endExcursion(now)
hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second))) hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
hm.RestHealRemainder = 0
hm.RoadsideThoughtNextAt = time.Time{}
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.refreshSpeed(now)
} }
} }
if sender != nil && hm.Excursion.Phase != prevPhase {
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
}
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)
@ -1801,16 +1985,9 @@ func ProcessSingleHeroMovementTick(
case model.RestKindAdventureInline: case model.RestKindAdventureInline:
hm.applyRestHealTick(dt) hm.applyRestHealTick(dt)
excursionEnded := hm.advanceExcursionPhases(now)
cfg := tuning.Get() cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if hpFrac >= cfg.AdventureRestTargetHp || excursionEnded { if hpFrac >= cfg.AdventureRestTargetHp {
if excursionEnded {
hm.endExcursion(now)
if sender != nil {
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
}
}
hm.ActiveRestKind = model.RestKindNone hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0 hm.RestHealRemainder = 0
hm.State = model.StateWalking hm.State = model.StateWalking
@ -1850,11 +2027,14 @@ func ProcessSingleHeroMovementTick(
} }
hm.LastMoveTick = now hm.LastMoveTick = now
// --- Walk back to town center after last NPC (avoids road-snap teleport) --- // --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
if !hm.TownCenterWalkArrive.IsZero() { if hm.TownCenterWalkActive {
if !now.Before(hm.TownCenterWalkArrive) { walkSpeed := cfg.TownNPCWalkSpeed
hm.CurrentX = hm.TownCenterWalkToX if walkSpeed <= 0 {
hm.CurrentY = hm.TownCenterWalkToY walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
arrived := hm.stepTowardWorldPoint(dtTown, hm.TownCenterWalkToX, hm.TownCenterWalkToY, walkSpeed)
if arrived {
hm.clearTownCenterWalk() hm.clearTownCenterWalk()
if sender != nil { if sender != nil {
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
@ -1863,43 +2043,29 @@ func ProcessSingleHeroMovementTick(
Speed: 0, Heading: 0, Speed: 0, Heading: 0,
}) })
} }
} else { } else if sender != nil {
totalMs := hm.TownCenterWalkArrive.Sub(hm.TownCenterWalkStart).Milliseconds() dx := hm.TownCenterWalkToX - hm.CurrentX
if totalMs <= 0 { dy := hm.TownCenterWalkToY - hm.CurrentY
totalMs = 1 heading := math.Atan2(dy, dx)
} sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
elapsed := now.Sub(hm.TownCenterWalkStart).Milliseconds() X: hm.CurrentX, Y: hm.CurrentY,
t := float64(elapsed) / float64(totalMs) TargetX: hm.TownCenterWalkToX, TargetY: hm.TownCenterWalkToY,
if t > 1 { Speed: walkSpeed, Heading: heading,
t = 1 })
}
hm.CurrentX = hm.TownCenterWalkFromX + (hm.TownCenterWalkToX-hm.TownCenterWalkFromX)*t
hm.CurrentY = hm.TownCenterWalkFromY + (hm.TownCenterWalkToY-hm.TownCenterWalkFromY)*t
if sender != nil {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
dx := hm.TownCenterWalkToX - hm.CurrentX
dy := hm.TownCenterWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownCenterWalkToX, TargetY: hm.TownCenterWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
} }
hm.SyncToHero() hm.SyncToHero()
return return
} }
// --- Sub-state: hero is walking toward an NPC inside the town --- // --- Sub-state: hero is walking toward an NPC inside the town (attractor stepping) ---
if hm.TownNPCWalkTargetID != 0 { if hm.TownNPCWalkTargetID != 0 {
if !now.Before(hm.TownNPCWalkArrive) { walkSpeed := cfg.TownNPCWalkSpeed
// Arrived at stand point (near NPC) — snap position and fire the visit event. if walkSpeed <= 0 {
hm.CurrentX = hm.TownNPCWalkToX walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
hm.CurrentY = hm.TownNPCWalkToY }
arrived := hm.stepTowardWorldPoint(dtTown, hm.TownNPCWalkToX, hm.TownNPCWalkToY, walkSpeed)
if arrived {
// Arrived at stand point (near NPC) — fire the visit event.
npcID := hm.TownNPCWalkTargetID npcID := hm.TownNPCWalkTargetID
standX := hm.TownNPCWalkToX standX := hm.TownNPCWalkToX
standY := hm.TownNPCWalkToY standY := hm.TownNPCWalkToY
@ -1967,33 +2133,15 @@ func ProcessSingleHeroMovementTick(
} else { } else {
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} }
} else { } else if sender != nil {
// Still walking — interpolate position. dx := hm.TownNPCWalkToX - hm.CurrentX
totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds() dy := hm.TownNPCWalkToY - hm.CurrentY
if totalMs <= 0 { heading := math.Atan2(dy, dx)
totalMs = 1 sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
} X: hm.CurrentX, Y: hm.CurrentY,
elapsed := now.Sub(hm.TownNPCWalkStart).Milliseconds() TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
t := float64(elapsed) / float64(totalMs) Speed: walkSpeed, Heading: heading,
if t > 1 { })
t = 1
}
hm.CurrentX = hm.TownNPCWalkFromX + (hm.TownNPCWalkToX-hm.TownNPCWalkFromX)*t
hm.CurrentY = hm.TownNPCWalkFromY + (hm.TownNPCWalkToY-hm.TownNPCWalkFromY)*t
if sender != nil {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
dx := hm.TownNPCWalkToX - hm.CurrentX
dy := hm.TownNPCWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
} }
hm.SyncToHero() hm.SyncToHero()
return return
@ -2028,22 +2176,13 @@ func ProcessSingleHeroMovementTick(
if dPlaza > plazaEps { if dPlaza > plazaEps {
dx := cx - hm.CurrentX dx := cx - hm.CurrentX
dy := cy - hm.CurrentY dy := cy - hm.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 { if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
} }
const minWalkMs = 300
walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond
if walkDur < minWalkMs*time.Millisecond {
walkDur = minWalkMs * time.Millisecond
}
hm.TownCenterWalkFromX = hm.CurrentX
hm.TownCenterWalkFromY = hm.CurrentY
hm.TownCenterWalkToX = cx hm.TownCenterWalkToX = cx
hm.TownCenterWalkToY = cy hm.TownCenterWalkToY = cy
hm.TownCenterWalkStart = now hm.TownCenterWalkActive = true
hm.TownCenterWalkArrive = now.Add(walkDur)
if sender != nil { if sender != nil {
heading := math.Atan2(dy, dx) heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
@ -2131,23 +2270,13 @@ func ProcessSingleHeroMovementTick(
toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff) toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff)
dx := toX - hm.CurrentX dx := toX - hm.CurrentX
dy := toY - hm.CurrentY dy := toY - hm.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 { if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
} }
const minWalkMs = 300
walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond
if walkDur < minWalkMs*time.Millisecond {
walkDur = minWalkMs * time.Millisecond
}
hm.TownNPCWalkTargetID = npcID hm.TownNPCWalkTargetID = npcID
hm.TownNPCWalkFromX = hm.CurrentX
hm.TownNPCWalkFromY = hm.CurrentY
hm.TownNPCWalkToX = toX hm.TownNPCWalkToX = toX
hm.TownNPCWalkToY = toY hm.TownNPCWalkToY = toY
hm.TownNPCWalkStart = now
hm.TownNPCWalkArrive = now.Add(walkDur)
if sender != nil { if sender != nil {
heading := math.Atan2(dy, dx) heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
@ -2193,20 +2322,31 @@ func ProcessSingleHeroMovementTick(
} }
} }
// --- Active excursion (mini-adventure) --- // --- Active adventure excursion (attractor movement while walking) ---
if hm.Excursion.Active() { if hm.Excursion.Active() && hm.Excursion.Kind == model.ExcursionKindAdventure {
dtAdv := now.Sub(hm.LastMoveTick).Seconds()
if dtAdv <= 0 {
dtAdv = movementTickRate().Seconds()
}
prevPhase := hm.Excursion.Phase prevPhase := hm.Excursion.Phase
excursionEnded := hm.advanceExcursionPhases(now) hm.refreshSpeed(now)
if excursionEnded {
hm.endExcursion(now) if hm.Excursion.Phase == model.ExcursionOut {
hm.refreshSpeed(now) if hm.stepTowardAttractor(now, dtAdv) {
if sender != nil { hm.Excursion.Phase = model.ExcursionWild
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{}) hm.adventureScheduleWanderRetarget(now)
hm.adventurePickWanderAttractor()
} }
} else { }
if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil { if hm.Excursion.Phase == model.ExcursionWild {
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)}) hm.tryBeginAdventureReturn(now)
}
if hm.Excursion.Phase == model.ExcursionWild {
if !hm.Excursion.WanderNextAt.IsZero() && !now.Before(hm.Excursion.WanderNextAt) {
hm.adventurePickWanderAttractor()
hm.adventureScheduleWanderRetarget(now)
} }
_ = hm.stepTowardAttractor(now, dtAdv)
if hm.isLowHP() { if hm.isLowHP() {
hm.beginAdventureInlineRest(now) hm.beginAdventureInlineRest(now)
hm.SyncToHero() hm.SyncToHero()
@ -2214,16 +2354,16 @@ func ProcessSingleHeroMovementTick(
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))
} }
hm.LastMoveTick = now
return return
} }
canEncounter := hm.Excursion.Phase == model.ExcursionWild || if onEncounter != nil || onMerchantEncounter != nil {
(hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled)
if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) {
monster, enemy, hit := hm.rollAdventureEncounter(now, graph) monster, enemy, hit := hm.rollAdventureEncounter(now, graph)
if hit { if hit {
if monster && onEncounter != nil { if monster && onEncounter != nil {
hm.LastEncounterAt = now hm.LastEncounterAt = now
onEncounter(hm, &enemy, now) onEncounter(hm, &enemy, now)
hm.LastMoveTick = now
return return
} }
if !monster { if !monster {
@ -2239,17 +2379,30 @@ func ProcessSingleHeroMovementTick(
if onMerchantEncounter != nil { if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost) onMerchantEncounter(hm, now, cost)
} }
hm.LastMoveTick = now
return return
} }
} }
} }
hm.LastMoveTick = now }
if sender != nil { if hm.Excursion.Phase == model.ExcursionReturn {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) if hm.stepTowardAttractor(now, dtAdv) {
hm.endExcursion(now)
hm.refreshSpeed(now)
if sender != nil {
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
}
} }
hm.SyncToHero()
return
} }
if sender != nil && hm.Excursion.Phase != prevPhase {
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
}
hm.LastMoveTick = now
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero()
return
} }
// --- Normal walking (no active excursion) --- // --- Normal walking (no active excursion) ---

@ -192,6 +192,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
}, },
}) })
hm.ResumeWalking(tickNow) hm.ResumeWalking(tickNow)
hm.TryAdventureReturnAfterCombat(tickNow)
} else { } else {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{ Event: &model.AdventureLogEvent{

@ -143,6 +143,8 @@ func TestRoadsideRest_HealsHP(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
hpBefore := hm.Hero.HP hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second) tick := now.Add(10 * time.Second)
@ -165,6 +167,8 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
pastTimer := hm.RestUntil.Add(time.Second) pastTimer := hm.RestUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil)
@ -173,8 +177,10 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase) t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase)
} }
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second) hm.CurrentX = hm.Excursion.StartX
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil) hm.CurrentY = hm.Excursion.StartY
hm.LastMoveTick = pastTimer
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind) t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
@ -189,9 +195,9 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
// Tick past the Out phase so the hero is in Wild phase where HP threshold is checked. hm.LastMoveTick = now
tick := hm.Excursion.OutUntil.Add(time.Second) tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn { if hm.Excursion.Phase != model.ExcursionReturn {
@ -199,7 +205,7 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
} }
} }
func TestRoadsideRest_DisplayOffset(t *testing.T) { func TestRoadsideRest_AttractorWorldMovement(t *testing.T) {
graph := testGraph() graph := testGraph()
maxHP := 1000 maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP) hero := testHeroOnRoad(1, 100, maxHP)
@ -207,11 +213,15 @@ func TestRoadsideRest_DisplayOffset(t *testing.T) {
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now) hm.beginRoadsideRest(now)
// Check offset partway through the Out phase (smoothstep should be non-zero). x0, y0 := hm.CurrentX, hm.CurrentY
outMid := hm.Excursion.StartedAt.Add(hm.Excursion.OutUntil.Sub(hm.Excursion.StartedAt) / 2) hm.LastMoveTick = now
ox, oy := hm.displayOffset(outMid) ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil)
if ox == 0 && oy == 0 { if hm.CurrentX == x0 && hm.CurrentY == y0 {
t.Fatal("expected non-zero display offset during roadside rest out phase") t.Fatal("expected hero world position to move toward forest attractor during out phase")
}
ox, oy := hm.displayOffset(now)
if ox != 0 || oy != 0 {
t.Fatal("attractor-mode excursion should not use perpendicular display offset")
} }
} }
@ -229,8 +239,9 @@ func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
hm.State = model.StateWalking hm.State = model.StateWalking
hm.Hero.State = model.StateWalking hm.Hero.State = model.StateWalking
hm.beginExcursion(now) hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
tick := hm.Excursion.OutUntil.Add(time.Second) hm.LastMoveTick = now
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateResting { if hm.State != model.StateResting {
@ -292,23 +303,25 @@ func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
} }
} }
func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) { func TestAdventure_ReturnPhaseEndsExcursion(t *testing.T) {
graph := testGraph() graph := testGraph()
maxHP := 10000 maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP) hero := testHeroOnRoad(1, 500, maxHP)
now := time.Now() now := time.Now()
hm := NewHeroMovement(hero, graph, now) hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now) hm.beginExcursion(now)
hm.beginAdventureInlineRest(now) hm.Excursion.Phase = model.ExcursionReturn
hm.enterAdventureReturnToRoad()
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second) hm.CurrentX = hm.Excursion.AttractorX
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil) hm.CurrentY = hm.Excursion.AttractorY
hm.LastMoveTick = now
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after excursion end, got %s", hm.State) t.Fatalf("expected StateWalking after return completes, got %s", hm.State)
} }
if hm.Excursion.Active() { if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after return phase ended") t.Fatal("excursion should be cleared after return phase reached road attractor")
} }
} }
@ -547,8 +560,11 @@ func TestAdminStopExcursion_WhileWalking(t *testing.T) {
if !hm.AdminStopExcursion(now) { if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed") t.Fatal("AdminStopExcursion should succeed")
} }
if hm.Excursion.Active() { if !hm.Excursion.Active() {
t.Fatal("excursion should be cleared") t.Fatal("excursion should stay active during return leg")
}
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
} }
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State) t.Fatalf("expected walking, got %s", hm.State)
@ -566,14 +582,40 @@ func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
if !hm.AdminStopExcursion(now) { if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed from inline rest") t.Fatal("AdminStopExcursion should succeed from inline rest")
} }
if hm.Excursion.Active() { if !hm.Excursion.Active() {
t.Fatal("excursion should be cleared") t.Fatal("excursion should stay active during return leg")
}
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
} }
if hm.State != model.StateWalking { if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State) t.Fatalf("expected walking, got %s", hm.State)
} }
} }
func TestAdminStopExcursion_RoadsideStartsReturn(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hm.Excursion.Phase = model.ExcursionWild
hm.LastMoveTick = now
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed for roadside excursion")
}
if !hm.Excursion.Active() {
t.Fatal("excursion should stay active during return leg")
}
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected return phase, got %s", hm.Excursion.Phase)
}
if hm.State != model.StateResting || hm.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected roadside rest, got state=%s kind=%s", hm.State, hm.ActiveRestKind)
}
}
func TestAdminStopExcursion_RejectsNone(t *testing.T) { func TestAdminStopExcursion_RejectsNone(t *testing.T) {
graph := testGraph() graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000) hero := testHeroOnRoad(1, 500, 1000)

@ -2319,7 +2319,7 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req
h.writeAdminHeroDetail(w, hm.Hero) h.writeAdminHeroDetail(w, hm.Hero)
} }
// StopHeroExcursion ends the hero's mini-adventure session immediately. // StopHeroExcursion forces the excursion into the return leg (walk back to road / start point).
// POST /admin/heroes/{heroId}/stop-adventure // POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r) heroID, err := parseHeroID(r)

@ -13,26 +13,46 @@ const (
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible) ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
) )
// ExcursionKind distinguishes roadside rest vs walking adventure sessions.
type ExcursionKind string
const (
ExcursionKindNone ExcursionKind = ""
ExcursionKindRoadside ExcursionKind = "roadside"
ExcursionKindAdventure ExcursionKind = "adventure"
)
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion). // ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued. // When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
type ExcursionSession struct { type ExcursionSession struct {
Phase ExcursionPhase Kind ExcursionKind
Phase ExcursionPhase
StartedAt time.Time StartedAt time.Time
// OutUntil marks the end of the out phase (hero reached full depth); derived from depth/speed. // OutUntil / WildUntil / ReturnUntil: legacy time-based FSM (ignored when Kind is set).
OutUntil time.Time OutUntil time.Time
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time WildUntil time.Time
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
ReturnUntil time.Time ReturnUntil time.Time
// DepthWorldUnits is the max perpendicular distance from the road spine for this session. // DepthWorldUnits is used to place forest attractors (perpendicular distance from road spine).
DepthWorldUnits float64 DepthWorldUnits float64
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero // RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
// left the road, so it can be restored exactly when the excursion ends. // left the road, so it can be restored exactly when the excursion ends.
RoadFreezeWaypoint int RoadFreezeWaypoint int
RoadFreezeFraction float64 RoadFreezeFraction float64
// Attractor-based movement (Kind != ""): hero walks in world space toward AttractorX/Y.
StartX, StartY float64
AttractorX, AttractorY float64
AttractorSet bool
// Adventure-only: wall-time when wandering should end (then return to road).
AdventureEndsAt time.Time
// Adventure: next time to pick a new wander attractor (wild phase).
WanderNextAt time.Time
// PendingReturnAfterCombat: adventure timer elapsed; wait for combat end then enter return phase.
PendingReturnAfterCombat bool
} }
// Active reports whether an excursion session is in progress. // Active reports whether an excursion session is in progress.
@ -43,6 +63,7 @@ func (s *ExcursionSession) Active() bool {
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the // ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure. // heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct { type ExcursionPersisted struct {
Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"` OutUntil *time.Time `json:"outUntil,omitempty"`
@ -51,4 +72,12 @@ type ExcursionPersisted struct {
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"` DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"` RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"` RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
StartX float64 `json:"startX,omitempty"`
StartY float64 `json:"startY,omitempty"`
AttractorX float64 `json:"attractorX,omitempty"`
AttractorY float64 `json:"attractorY,omitempty"`
AttractorSet bool `json:"attractorSet,omitempty"`
AdventureEndsAt *time.Time `json:"adventureEndsAt,omitempty"`
WanderNextAt *time.Time `json:"wanderNextAt,omitempty"`
PendingReturnAfterCombat bool `json:"pendingReturnAfterCombat,omitempty"`
} }

@ -67,6 +67,8 @@ type Hero struct {
RestKind RestKind `json:"restKind,omitempty"` RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// ExcursionKind is "roadside" | "adventure" during attractor-based excursions; empty otherwise.
ExcursionKind ExcursionKind `json:"excursionKind,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only). // TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`

@ -19,23 +19,18 @@ type TownPausePersisted struct {
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"` TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"` TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
// Walk-to-NPC: hero is mid-walk toward an NPC inside the town. // Walk-to-NPC: hero moves toward stand point (npcWalkTargetId + to); position is hero x/y + speed×dt.
NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"` NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"` NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"` NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"`
NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"`
// Plaza: walk to town center after NPC tour, then wait/rest before leaving. // Plaza: walk to town center after NPC tour, then wait/rest before leaving.
TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"` TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
CenterWalkFromX float64 `json:"centerWalkFromX,omitempty"` CenterWalkActive bool `json:"centerWalkActive,omitempty"`
CenterWalkFromY float64 `json:"centerWalkFromY,omitempty"` CenterWalkToX float64 `json:"centerWalkToX,omitempty"`
CenterWalkToX float64 `json:"centerWalkToX,omitempty"` CenterWalkToY float64 `json:"centerWalkToY,omitempty"`
CenterWalkToY float64 `json:"centerWalkToY,omitempty"` // CenterWalkStart: legacy rows only (time-based walk). New saves use centerWalkActive + to.
CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"` CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"`
CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume. // Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"` Excursion *ExcursionPersisted `json:"excursion,omitempty"`

@ -209,6 +209,16 @@ type Values struct {
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"` AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return. // AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return.
AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"` AdventureReturnWildnessMin float64 `json:"adventureReturnWildnessMin"`
// AdventureDurationMinMs / AdventureDurationMaxMs: wall-time for the wandering phase (attractor model).
AdventureDurationMinMs int64 `json:"adventureDurationMinMs"`
AdventureDurationMaxMs int64 `json:"adventureDurationMaxMs"`
// AdventureWanderRadius: new random attractor within this distance of the hero (world units).
AdventureWanderRadius float64 `json:"adventureWanderRadius"`
// AdventureWanderRetargetMinMs / MaxMs: random interval between wander retarget rolls.
AdventureWanderRetargetMinMs int64 `json:"adventureWanderRetargetMinMs"`
AdventureWanderRetargetMaxMs int64 `json:"adventureWanderRetargetMaxMs"`
// ExcursionArrivalEpsilonWorld: hero is considered to have reached the attractor within this distance.
ExcursionArrivalEpsilonWorld float64 `json:"excursionArrivalEpsilonWorld"`
// --- HP-based rest triggers --- // --- HP-based rest triggers ---
@ -386,10 +396,16 @@ func DefaultValues() Values {
AdventureEncounterCooldownMs: 6_000, AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true, AdventureReturnEncounterEnabled: true,
AdventureReturnWildnessMin: 0.35, AdventureReturnWildnessMin: 0.35,
AdventureDurationMinMs: 560_000,
AdventureDurationMaxMs: 2_960_000,
AdventureWanderRadius: 18.0,
AdventureWanderRetargetMinMs: 4_000,
AdventureWanderRetargetMaxMs: 14_000,
ExcursionArrivalEpsilonWorld: 0.35,
LowHpThreshold: 0.25, LowHpThreshold: 0.25,
RoadsideRestExitHp: 0.70, RoadsideRestExitHp: 0.85,
AdventureRestTargetHp: 0.70, AdventureRestTargetHp: 0.85,
RoadsideRestMinMs: 240_000, RoadsideRestMinMs: 240_000,
RoadsideRestMaxMs: 600_000, RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003, RoadsideRestHpPerS: 0.003,

@ -267,6 +267,10 @@ function heroResponseToState(res: HeroResponse): HeroState {
serverActivityState: res.state, serverActivityState: res.state,
restKind: res.restKind, restKind: res.restKind,
excursionPhase: res.excursionPhase, excursionPhase: res.excursionPhase,
excursionKind:
res.excursionKind === 'roadside' || res.excursionKind === 'adventure'
? res.excursionKind
: undefined,
attackSpeed: res.attackSpeed ?? res.speed, attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack, damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense, defense: res.defensePower ?? res.defense,

@ -138,6 +138,8 @@ export interface HeroState {
restKind?: string; restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */ /** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string; excursionPhase?: string;
/** Attractor excursion mode from server: roadside rest vs walking adventure */
excursionKind?: 'roadside' | 'adventure';
attackSpeed: number; attackSpeed: number;
damage: number; damage: number;
defense: number; defense: number;

@ -109,6 +109,7 @@ export interface HeroResponse {
state: string; state: string;
restKind?: string; restKind?: string;
excursionPhase?: string; excursionPhase?: string;
excursionKind?: string;
/** Removed from server; gear.main_hand / legacy weapon only */ /** Removed from server; gear.main_hand / legacy weapon only */
weaponId?: number; weaponId?: number;
armorId?: number; armorId?: number;

Loading…
Cancel
Save