diff --git a/admin-web/index.html b/admin-web/index.html
index 8346505..72af481 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -2203,7 +2203,7 @@
-
+
diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go
index 06914d4..22d948a 100644
--- a/backend/internal/game/engine.go
+++ b/backend/internal/game/engine.go
@@ -940,7 +940,7 @@ func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
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) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -957,7 +957,7 @@ func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
if e.sender != nil {
h.EnsureGearMap()
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_move", hm.MovePayload(now))
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)
- // 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 {
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()
}
diff --git a/backend/internal/game/fsm_excursion_test.go b/backend/internal/game/fsm_excursion_test.go
index ea370b6..05179a8 100644
--- a/backend/internal/game/fsm_excursion_test.go
+++ b/backend/internal/game/fsm_excursion_test.go
@@ -78,19 +78,19 @@ func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
- origWildUntil := hm.Excursion.WildUntil
+ origRestUntil := hm.RestUntil
// Skip "out" leg: test HP exit from wild (campfire) phase.
hm.Excursion.Phase = model.ExcursionWild
- hm.Excursion.OutUntil = now.Add(-time.Second)
+ hm.LastMoveTick = now
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase)
}
- if !tick.Before(origWildUntil) {
- t.Fatal("HP exit should force return before original WildUntil timer")
+ if !tick.Before(origRestUntil) {
+ 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) {
graph := testGraph()
cfg := tuning.Get()
diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go
index 3dccdcc..a217e96 100644
--- a/backend/internal/game/movement.go
+++ b/backend/internal/game/movement.go
@@ -74,25 +74,18 @@ type HeroMovement struct {
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time.Time
- // Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires.
- TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking)
- TownNPCWalkFromX float64
- TownNPCWalkFromY float64
+ // Walk-to-NPC: attractor at TownNPCWalkTo* while TownNPCWalkTargetID != 0.
+ TownNPCWalkTargetID int64
TownNPCWalkToX 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 time.Time
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool
- // TownCenterWalk*: walk from last NPC stand back to town center before road snap (avoids teleport to road spine).
- TownCenterWalkArrive time.Time
- TownCenterWalkStart time.Time
- TownCenterWalkFromX float64
- TownCenterWalkFromY float64
+ // TownCenterWalk*: attractor stepping to plaza before road snap.
+ TownCenterWalkActive bool
TownCenterWalkToX float64
TownCenterWalkToY float64
@@ -130,6 +123,7 @@ type townPausePersistSignature struct {
RestKind model.RestKind
RestUntil time.Time
+ ExcursionKind model.ExcursionKind
ExcursionPhase model.ExcursionPhase
ExcursionStartedAt time.Time
ExcursionOutUntil time.Time
@@ -138,6 +132,14 @@ type townPausePersistSignature struct {
ExcursionDepthWorldUnits float64
ExcursionRoadFreezeWaypoint int
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).
InTown bool
@@ -146,11 +148,12 @@ type townPausePersistSignature struct {
InTownVisitStarted time.Time
InTownVisitLogs int
InTownNPCWalkTarget int64
- InTownNPCWalkStart time.Time
- InTownNPCWalkArrive time.Time
+ InTownNPCWalkToX float64
+ InTownNPCWalkToY float64
InTownPlazaHeal bool
- InTownCenterWalkStart time.Time
- InTownCenterWalkArrive time.Time
+ InTownCenterWalkActive bool
+ InTownCenterWalkToX float64
+ InTownCenterWalkToY float64
InTownNPCQueueLen int
InTownNPCQueueFP uint64
InTownVisitName string
@@ -529,8 +532,6 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLeaveAt = shift(hm.TownLeaveAt)
- hm.TownCenterWalkStart = shift(hm.TownCenterWalkStart)
- hm.TownCenterWalkArrive = shift(hm.TownCenterWalkArrive)
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
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.
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 {
return 0
}
@@ -633,6 +637,9 @@ func (hm *HeroMovement) Heading() float64 {
// TargetPoint returns the next waypoint the hero is heading toward.
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 {
return hm.CurrentX, hm.CurrentY
}
@@ -723,8 +730,9 @@ func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool {
return true
}
-// AdminStopExcursion ends an active excursion immediately (hero back on the road spine).
-// Works during walking phases or adventure-inline rest; rejects combat.
+// AdminStopExcursion skips the remaining forest/wild leg and starts the return leg toward the road
+// (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 {
if !hm.Excursion.Active() {
return false
@@ -738,10 +746,24 @@ func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
hm.RestHealRemainder = 0
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
+ hm.enterAdventureReturnToRoad()
+ hm.refreshSpeed(now)
+ return true
}
- hm.endExcursion(now)
- hm.refreshSpeed(now)
- return true
+ if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
+ hm.Excursion.Phase = model.ExcursionReturn
+ 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.
@@ -839,7 +861,200 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
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) {
+ if hm.excursionUsesAttractors() {
+ return 0, 0
+ }
exc := &hm.Excursion
if exc.Active() {
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())
if retMs > 0 {
elapsed := float64(now.Sub(exc.WildUntil).Milliseconds())
- t = 1.0 - smoothstep(clamp01(elapsed / retMs))
+ t = 1.0 - smoothstep(clamp01(elapsed/retMs))
}
}
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.
func (hm *HeroMovement) clearNPCWalk() {
hm.TownNPCWalkTargetID = 0
- hm.TownNPCWalkFromX = 0
- hm.TownNPCWalkFromY = 0
hm.TownNPCWalkToX = 0
hm.TownNPCWalkToY = 0
- hm.TownNPCWalkStart = time.Time{}
- hm.TownNPCWalkArrive = time.Time{}
}
func (hm *HeroMovement) clearTownCenterWalk() {
- hm.TownCenterWalkArrive = time.Time{}
- hm.TownCenterWalkStart = time.Time{}
- hm.TownCenterWalkFromX = 0
- hm.TownCenterWalkFromY = 0
+ hm.TownCenterWalkActive = false
hm.TownCenterWalkToX = 0
hm.TownCenterWalkToY = 0
}
@@ -1110,8 +1318,10 @@ func (hm *HeroMovement) SyncToHero() {
}
}
hm.Hero.ExcursionPhase = model.ExcursionNone
+ hm.Hero.ExcursionKind = model.ExcursionKindNone
if hm.Excursion.Active() {
hm.Hero.ExcursionPhase = hm.Excursion.Phase
+ hm.Hero.ExcursionKind = hm.Excursion.Kind
}
hm.Hero.TownPause = hm.townPauseBlob()
}
@@ -1144,6 +1354,7 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
if hm.Excursion.Active() {
s := hm.Excursion
+ sig.ExcursionKind = s.Kind
sig.ExcursionPhase = s.Phase
sig.ExcursionStartedAt = s.StartedAt
sig.ExcursionOutUntil = s.OutUntil
@@ -1152,6 +1363,14 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.ExcursionDepthWorldUnits = s.DepthWorldUnits
sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint
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 {
@@ -1161,11 +1380,12 @@ func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature {
sig.InTownVisitStarted = hm.TownVisitStartedAt
sig.InTownVisitLogs = hm.TownVisitLogsEmitted
sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID
- sig.InTownNPCWalkStart = hm.TownNPCWalkStart
- sig.InTownNPCWalkArrive = hm.TownNPCWalkArrive
+ sig.InTownNPCWalkToX = hm.TownNPCWalkToX
+ sig.InTownNPCWalkToY = hm.TownNPCWalkToY
sig.InTownPlazaHeal = hm.TownPlazaHealActive
- sig.InTownCenterWalkStart = hm.TownCenterWalkStart
- sig.InTownCenterWalkArrive = hm.TownCenterWalkArrive
+ sig.InTownCenterWalkActive = hm.TownCenterWalkActive
+ sig.InTownCenterWalkToX = hm.TownCenterWalkToX
+ sig.InTownCenterWalkToY = hm.TownCenterWalkToY
sig.InTownNPCQueueLen = len(hm.TownNPCQueue)
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
sig.InTownVisitName = hm.TownVisitNPCName
@@ -1200,19 +1420,9 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
TownVisitNPCName: hm.TownVisitNPCName,
TownVisitNPCType: hm.TownVisitNPCType,
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
- NPCWalkTargetID: hm.TownNPCWalkTargetID,
- NPCWalkFromX: hm.TownNPCWalkFromX,
- NPCWalkFromY: hm.TownNPCWalkFromY,
- 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
+ NPCWalkTargetID: hm.TownNPCWalkTargetID,
+ NPCWalkToX: hm.TownNPCWalkToX,
+ NPCWalkToY: hm.TownNPCWalkToY,
}
if len(hm.TownNPCQueue) > 0 {
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
@@ -1232,17 +1442,10 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
if hm.TownPlazaHealActive {
p.TownPlazaHealActive = true
}
- p.CenterWalkFromX = hm.TownCenterWalkFromX
- p.CenterWalkFromY = hm.TownCenterWalkFromY
- p.CenterWalkToX = hm.TownCenterWalkToX
- p.CenterWalkToY = hm.TownCenterWalkToY
- if !hm.TownCenterWalkStart.IsZero() {
- t := hm.TownCenterWalkStart
- p.CenterWalkStart = &t
- }
- if !hm.TownCenterWalkArrive.IsZero() {
- t := hm.TownCenterWalkArrive
- p.CenterWalkArrive = &t
+ if hm.TownCenterWalkActive {
+ p.CenterWalkActive = true
+ p.CenterWalkToX = hm.TownCenterWalkToX
+ p.CenterWalkToY = hm.TownCenterWalkToY
}
}
@@ -1262,10 +1465,17 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
s := &hm.Excursion
ep := &model.ExcursionPersisted{
- Phase: string(s.Phase),
- DepthWorldUnits: s.DepthWorldUnits,
- RoadFreezeWaypoint: s.RoadFreezeWaypoint,
- RoadFreezeFraction: s.RoadFreezeFraction,
+ Kind: string(s.Kind),
+ Phase: string(s.Phase),
+ DepthWorldUnits: s.DepthWorldUnits,
+ 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() {
t := s.StartedAt
@@ -1283,6 +1493,14 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s.ReturnUntil
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
}
@@ -1321,26 +1539,14 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
- hm.TownNPCWalkFromX = blob.NPCWalkFromX
- hm.TownNPCWalkFromY = blob.NPCWalkFromY
hm.TownNPCWalkToX = blob.NPCWalkToX
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.TownCenterWalkFromX = blob.CenterWalkFromX
- hm.TownCenterWalkFromY = blob.CenterWalkFromY
hm.TownCenterWalkToX = blob.CenterWalkToX
hm.TownCenterWalkToY = blob.CenterWalkToY
- if blob.CenterWalkStart != nil {
- hm.TownCenterWalkStart = *blob.CenterWalkStart
- }
- if blob.CenterWalkArrive != nil {
- hm.TownCenterWalkArrive = *blob.CenterWalkArrive
+ hm.TownCenterWalkActive = blob.CenterWalkActive
+ if !hm.TownCenterWalkActive && blob.CenterWalkStart != nil && !blob.CenterWalkStart.IsZero() {
+ hm.TownCenterWalkActive = true
}
}
@@ -1351,6 +1557,12 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
}
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)
if ep.StartedAt != nil {
hm.Excursion.StartedAt = *ep.StartedAt
@@ -1367,6 +1579,18 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits
hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint
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).
@@ -1532,54 +1756,31 @@ func (hm *HeroMovement) mayStartExcursion(now time.Time) bool {
func (hm *HeroMovement) beginExcursion(now time.Time) {
cfg := tuning.Get()
depth := cfg.AdventureDepthWorldUnits
-
- hm.refreshSpeed(now)
- speed := hm.Speed
- if speed < 0.1 {
- speed = 0.1
+ if depth <= 0 {
+ depth = tuning.DefaultValues().AdventureDepthWorldUnits
}
- outDur := time.Duration(depth / speed * float64(time.Second))
-
- outEnd := now.Add(outDur)
- wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs)
- wildEnd := outEnd.Add(wildDur)
- returnDur := time.Duration(depth / speed * float64(time.Second))
+ minDur := cfg.AdventureDurationMinMs
+ maxDur := cfg.AdventureDurationMaxMs
+ if minDur <= 0 {
+ minDur = tuning.DefaultValues().AdventureDurationMinMs
+ }
+ if maxDur <= 0 {
+ maxDur = tuning.DefaultValues().AdventureDurationMaxMs
+ }
+ adventureEnds := now.Add(randomDurationBetweenMs(minDur, maxDur))
hm.Excursion = model.ExcursionSession{
+ Kind: model.ExcursionKindAdventure,
Phase: model.ExcursionOut,
StartedAt: now,
- OutUntil: outEnd,
- WildUntil: wildEnd,
- ReturnUntil: wildEnd.Add(returnDur),
DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction,
+ StartX: hm.CurrentX,
+ StartY: hm.CurrentY,
+ AdventureEndsAt: adventureEnds,
}
-}
-
-// 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
+ hm.pickExcursionForestAttractor(depth)
}
func (hm *HeroMovement) endExcursion(now time.Time) {
@@ -1606,30 +1807,21 @@ func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
if depth <= 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)
- wildUntil := outUntil.Add(restDur)
- returnUntil := wildUntil.Add(moveDur)
hm.Excursion = model.ExcursionSession{
+ Kind: model.ExcursionKindRoadside,
Phase: model.ExcursionOut,
StartedAt: now,
- OutUntil: outUntil,
- WildUntil: wildUntil,
- ReturnUntil: returnUntil,
DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction,
+ StartX: hm.CurrentX,
+ StartY: hm.CurrentY,
}
- // RestUntil tracks only the rest (wild) phase; travel out/return is separate.
- hm.RestUntil = wildUntil
+ hm.pickExcursionForestAttractor(depth)
+ // 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)
}
@@ -1740,20 +1932,14 @@ func ProcessSingleHeroMovementTick(
switch hm.ActiveRestKind {
case model.RestKindRoadside:
- // For roadside rest, ensure Wild→Return always gets a fresh return
- // deadline so the hero walks back to the road smoothly (prevents
- // advanceExcursionPhases from skipping the return phase on time jumps).
- if hm.Excursion.Phase == model.ExcursionWild && !now.Before(hm.Excursion.WildUntil) {
- hm.Excursion.Phase = model.ExcursionReturn
- speed := hm.Speed
- if speed < 0.1 {
- speed = 0.1
+ prevPhase := hm.Excursion.Phase
+ hm.refreshSpeed(now)
+ switch hm.Excursion.Phase {
+ case model.ExcursionOut:
+ if hm.stepTowardAttractor(now, dt) {
+ hm.Excursion.Phase = model.ExcursionWild
}
- hm.Excursion.WildUntil = now
- hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second)))
- }
- excursionEnded := hm.advanceExcursionPhases(now)
- if hm.Excursion.Phase == model.ExcursionWild {
+ case model.ExcursionWild:
hm.applyRestHealTick(dt)
if adventureLog != nil {
if hm.RoadsideThoughtNextAt.IsZero() {
@@ -1770,29 +1956,27 @@ func ProcessSingleHeroMovementTick(
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()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp {
hm.Excursion.Phase = model.ExcursionReturn
- speed := hm.Speed
- if speed < 0.1 {
- speed = 0.1
- }
- hm.Excursion.WildUntil = now
- hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second)))
+ hm.setRoadsideReturnAttractor()
+ }
+ case model.ExcursionReturn:
+ if hm.stepTowardAttractor(now, dt) {
+ 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)
}
}
+ if sender != nil && hm.Excursion.Phase != prevPhase {
+ sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
+ }
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
@@ -1801,16 +1985,9 @@ func ProcessSingleHeroMovementTick(
case model.RestKindAdventureInline:
hm.applyRestHealTick(dt)
- excursionEnded := hm.advanceExcursionPhases(now)
cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
- if hpFrac >= cfg.AdventureRestTargetHp || excursionEnded {
- if excursionEnded {
- hm.endExcursion(now)
- if sender != nil {
- sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
- }
- }
+ if hpFrac >= cfg.AdventureRestTargetHp {
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.State = model.StateWalking
@@ -1850,11 +2027,14 @@ func ProcessSingleHeroMovementTick(
}
hm.LastMoveTick = now
- // --- Walk back to town center after last NPC (avoids road-snap teleport) ---
- if !hm.TownCenterWalkArrive.IsZero() {
- if !now.Before(hm.TownCenterWalkArrive) {
- hm.CurrentX = hm.TownCenterWalkToX
- hm.CurrentY = hm.TownCenterWalkToY
+ // --- Walk back to town center after last NPC (attractor stepping, same epsilon as excursions) ---
+ if hm.TownCenterWalkActive {
+ walkSpeed := cfg.TownNPCWalkSpeed
+ if walkSpeed <= 0 {
+ walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
+ }
+ arrived := hm.stepTowardWorldPoint(dtTown, hm.TownCenterWalkToX, hm.TownCenterWalkToY, walkSpeed)
+ if arrived {
hm.clearTownCenterWalk()
if sender != nil {
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
@@ -1863,43 +2043,29 @@ func ProcessSingleHeroMovementTick(
Speed: 0, Heading: 0,
})
}
- } else {
- totalMs := hm.TownCenterWalkArrive.Sub(hm.TownCenterWalkStart).Milliseconds()
- if totalMs <= 0 {
- totalMs = 1
- }
- elapsed := now.Sub(hm.TownCenterWalkStart).Milliseconds()
- t := float64(elapsed) / float64(totalMs)
- if t > 1 {
- 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,
- })
- }
+ } else if sender != nil {
+ 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()
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 !now.Before(hm.TownNPCWalkArrive) {
- // Arrived at stand point (near NPC) — snap position and fire the visit event.
- hm.CurrentX = hm.TownNPCWalkToX
- hm.CurrentY = hm.TownNPCWalkToY
+ walkSpeed := cfg.TownNPCWalkSpeed
+ if walkSpeed <= 0 {
+ walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
+ }
+ arrived := hm.stepTowardWorldPoint(dtTown, hm.TownNPCWalkToX, hm.TownNPCWalkToY, walkSpeed)
+ if arrived {
+ // Arrived at stand point (near NPC) — fire the visit event.
npcID := hm.TownNPCWalkTargetID
standX := hm.TownNPCWalkToX
standY := hm.TownNPCWalkToY
@@ -1967,33 +2133,15 @@ func ProcessSingleHeroMovementTick(
} else {
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
}
- } else {
- // Still walking — interpolate position.
- totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds()
- if totalMs <= 0 {
- totalMs = 1
- }
- elapsed := now.Sub(hm.TownNPCWalkStart).Milliseconds()
- t := float64(elapsed) / float64(totalMs)
- 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,
- })
- }
+ } else if sender != nil {
+ 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()
return
@@ -2028,22 +2176,13 @@ func ProcessSingleHeroMovementTick(
if dPlaza > plazaEps {
dx := cx - hm.CurrentX
dy := cy - hm.CurrentY
- dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
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.TownCenterWalkToY = cy
- hm.TownCenterWalkStart = now
- hm.TownCenterWalkArrive = now.Add(walkDur)
+ hm.TownCenterWalkActive = true
if sender != nil {
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
@@ -2131,23 +2270,13 @@ func ProcessSingleHeroMovementTick(
toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff)
dx := toX - hm.CurrentX
dy := toY - hm.CurrentY
- dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
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.TownNPCWalkFromX = hm.CurrentX
- hm.TownNPCWalkFromY = hm.CurrentY
hm.TownNPCWalkToX = toX
hm.TownNPCWalkToY = toY
- hm.TownNPCWalkStart = now
- hm.TownNPCWalkArrive = now.Add(walkDur)
if sender != nil {
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
@@ -2193,20 +2322,31 @@ func ProcessSingleHeroMovementTick(
}
}
- // --- Active excursion (mini-adventure) ---
- if hm.Excursion.Active() {
+ // --- Active adventure excursion (attractor movement while walking) ---
+ 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
- excursionEnded := hm.advanceExcursionPhases(now)
- if excursionEnded {
- hm.endExcursion(now)
- hm.refreshSpeed(now)
- if sender != nil {
- sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
+ hm.refreshSpeed(now)
+
+ if hm.Excursion.Phase == model.ExcursionOut {
+ if hm.stepTowardAttractor(now, dtAdv) {
+ hm.Excursion.Phase = model.ExcursionWild
+ hm.adventureScheduleWanderRetarget(now)
+ hm.adventurePickWanderAttractor()
}
- } else {
- if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil {
- sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)})
+ }
+ if hm.Excursion.Phase == model.ExcursionWild {
+ 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() {
hm.beginAdventureInlineRest(now)
hm.SyncToHero()
@@ -2214,16 +2354,16 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
+ hm.LastMoveTick = now
return
}
- canEncounter := hm.Excursion.Phase == model.ExcursionWild ||
- (hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled)
- if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) {
+ if onEncounter != nil || onMerchantEncounter != nil {
monster, enemy, hit := hm.rollAdventureEncounter(now, graph)
if hit {
if monster && onEncounter != nil {
hm.LastEncounterAt = now
onEncounter(hm, &enemy, now)
+ hm.LastMoveTick = now
return
}
if !monster {
@@ -2239,17 +2379,30 @@ func ProcessSingleHeroMovementTick(
if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost)
}
+ hm.LastMoveTick = now
return
}
}
}
- hm.LastMoveTick = now
- if sender != nil {
- sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
+ }
+ if hm.Excursion.Phase == model.ExcursionReturn {
+ 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) ---
diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go
index 0b4c318..66d49db 100644
--- a/backend/internal/game/offline.go
+++ b/backend/internal/game/offline.go
@@ -192,6 +192,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
},
})
hm.ResumeWalking(tickNow)
+ hm.TryAdventureReturnAfterCombat(tickNow)
} else {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go
index 1404ee8..3d1d591 100644
--- a/backend/internal/game/rest_test.go
+++ b/backend/internal/game/rest_test.go
@@ -143,6 +143,8 @@ func TestRoadsideRest_HealsHP(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
+ hm.Excursion.Phase = model.ExcursionWild
+ hm.LastMoveTick = now
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
@@ -165,6 +167,8 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) {
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
+ hm.Excursion.Phase = model.ExcursionWild
+ hm.LastMoveTick = now
pastTimer := hm.RestUntil.Add(time.Second)
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)
}
- pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
+ hm.CurrentX = hm.Excursion.StartX
+ 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 {
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()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
-
- // Tick past the Out phase so the hero is in Wild phase where HP threshold is checked.
- tick := hm.Excursion.OutUntil.Add(time.Second)
+ hm.Excursion.Phase = model.ExcursionWild
+ hm.LastMoveTick = now
+ tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
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()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
@@ -207,11 +213,15 @@ func TestRoadsideRest_DisplayOffset(t *testing.T) {
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
- // Check offset partway through the Out phase (smoothstep should be non-zero).
- outMid := hm.Excursion.StartedAt.Add(hm.Excursion.OutUntil.Sub(hm.Excursion.StartedAt) / 2)
- ox, oy := hm.displayOffset(outMid)
- if ox == 0 && oy == 0 {
- t.Fatal("expected non-zero display offset during roadside rest out phase")
+ x0, y0 := hm.CurrentX, hm.CurrentY
+ hm.LastMoveTick = now
+ ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil)
+ if hm.CurrentX == x0 && hm.CurrentY == y0 {
+ 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.Hero.State = model.StateWalking
hm.beginExcursion(now)
-
- tick := hm.Excursion.OutUntil.Add(time.Second)
+ hm.Excursion.Phase = model.ExcursionWild
+ hm.LastMoveTick = now
+ tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil)
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()
maxHP := 10000
- hero := testHeroOnRoad(1, 1, maxHP)
+ hero := testHeroOnRoad(1, 500, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
- hm.beginAdventureInlineRest(now)
-
- pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
- ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
+ hm.Excursion.Phase = model.ExcursionReturn
+ hm.enterAdventureReturnToRoad()
+ hm.CurrentX = hm.Excursion.AttractorX
+ 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 {
- 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() {
- 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) {
t.Fatal("AdminStopExcursion should succeed")
}
- if hm.Excursion.Active() {
- t.Fatal("excursion should be cleared")
+ 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.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
@@ -566,14 +582,40 @@ func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed from inline rest")
}
- if hm.Excursion.Active() {
- t.Fatal("excursion should be cleared")
+ 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.StateWalking {
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) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index d96e4da..74b5b2a 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -2319,7 +2319,7 @@ func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Req
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
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
heroID, err := parseHeroID(r)
diff --git a/backend/internal/model/excursion.go b/backend/internal/model/excursion.go
index 0ce8d04..cdae18a 100644
--- a/backend/internal/model/excursion.go
+++ b/backend/internal/model/excursion.go
@@ -13,26 +13,46 @@ const (
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).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
type ExcursionSession struct {
- Phase ExcursionPhase
+ Kind ExcursionKind
+ Phase ExcursionPhase
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
- // WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time
- // ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
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
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
// left the road, so it can be restored exactly when the excursion ends.
- RoadFreezeWaypoint int
- RoadFreezeFraction float64
+ RoadFreezeWaypoint int
+ 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.
@@ -43,6 +63,7 @@ func (s *ExcursionSession) Active() bool {
// 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.
type ExcursionPersisted struct {
+ Kind string `json:"kind,omitempty"`
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"`
@@ -51,4 +72,12 @@ type ExcursionPersisted struct {
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,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"`
}
diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go
index 4039488..d7bf4ff 100644
--- a/backend/internal/model/hero.go
+++ b/backend/internal/model/hero.go
@@ -67,6 +67,8 @@ type Hero struct {
RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
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 *TownPausePersisted `json:"-"`
diff --git a/backend/internal/model/town_pause.go b/backend/internal/model/town_pause.go
index a991ccc..bd793b9 100644
--- a/backend/internal/model/town_pause.go
+++ b/backend/internal/model/town_pause.go
@@ -19,23 +19,18 @@ type TownPausePersisted struct {
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
- // Walk-to-NPC: hero is mid-walk toward an NPC inside the town.
- NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
- NPCWalkFromX float64 `json:"npcWalkFromX,omitempty"`
- NPCWalkFromY float64 `json:"npcWalkFromY,omitempty"`
- NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
- NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
- NPCWalkStart *time.Time `json:"npcWalkStart,omitempty"`
- NPCWalkArrive *time.Time `json:"npcWalkArrive,omitempty"`
+ // Walk-to-NPC: hero moves toward stand point (npcWalkTargetId + to); position is hero x/y + speed×dt.
+ NPCWalkTargetID int64 `json:"npcWalkTargetId,omitempty"`
+ NPCWalkToX float64 `json:"npcWalkToX,omitempty"`
+ NPCWalkToY float64 `json:"npcWalkToY,omitempty"`
// Plaza: walk to town center after NPC tour, then wait/rest before leaving.
- TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
- CenterWalkFromX float64 `json:"centerWalkFromX,omitempty"`
- CenterWalkFromY float64 `json:"centerWalkFromY,omitempty"`
- CenterWalkToX float64 `json:"centerWalkToX,omitempty"`
- CenterWalkToY float64 `json:"centerWalkToY,omitempty"`
- CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"`
- CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"`
+ TownPlazaHealActive bool `json:"townPlazaHealActive,omitempty"`
+ CenterWalkActive bool `json:"centerWalkActive,omitempty"`
+ CenterWalkToX float64 `json:"centerWalkToX,omitempty"`
+ CenterWalkToY float64 `json:"centerWalkToY,omitempty"`
+ // CenterWalkStart: legacy rows only (time-based walk). New saves use centerWalkActive + to.
+ CenterWalkStart *time.Time `json:"centerWalkStart,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"`
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index 13c6188..3b94cdb 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -209,6 +209,16 @@ type Values struct {
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// AdventureReturnWildnessMin is the minimum wilderness factor (0..1) used during return.
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 ---
@@ -386,10 +396,16 @@ func DefaultValues() Values {
AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true,
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,
- RoadsideRestExitHp: 0.70,
- AdventureRestTargetHp: 0.70,
+ RoadsideRestExitHp: 0.85,
+ AdventureRestTargetHp: 0.85,
RoadsideRestMinMs: 240_000,
RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 36d965f..cbff099 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -267,6 +267,10 @@ function heroResponseToState(res: HeroResponse): HeroState {
serverActivityState: res.state,
restKind: res.restKind,
excursionPhase: res.excursionPhase,
+ excursionKind:
+ res.excursionKind === 'roadside' || res.excursionKind === 'adventure'
+ ? res.excursionKind
+ : undefined,
attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense,
diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts
index af87c0a..bcf4355 100644
--- a/frontend/src/game/types.ts
+++ b/frontend/src/game/types.ts
@@ -138,6 +138,8 @@ export interface HeroState {
restKind?: string;
/** Mini-adventure leg: "out" | "wild" | "return" when excursion active */
excursionPhase?: string;
+ /** Attractor excursion mode from server: roadside rest vs walking adventure */
+ excursionKind?: 'roadside' | 'adventure';
attackSpeed: number;
damage: number;
defense: number;
diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts
index 4cb3e0e..2b91486 100644
--- a/frontend/src/network/api.ts
+++ b/frontend/src/network/api.ts
@@ -109,6 +109,7 @@ export interface HeroResponse {
state: string;
restKind?: string;
excursionPhase?: string;
+ excursionKind?: string;
/** Removed from server; gear.main_hand / legacy weapon only */
weaponId?: number;
armorId?: number;