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('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('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('trigger-random-encounter',{}))" title="Серверный бой со случайным монстром (как на дороге). Нужен подключённый клиент (WS), герой не в бою, не в городе и не в отдыхе">Встреча (случайный монстр)</button>
</div>

@ -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()
}

@ -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()

@ -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,11 +746,25 @@ 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)
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.
func (hm *HeroMovement) AdminStopRest(now time.Time) bool {
@ -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()
@ -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
@ -1201,19 +1421,9 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
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
}
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
if hm.TownCenterWalkActive {
p.CenterWalkActive = true
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
}
}
@ -1262,10 +1465,17 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
s := &hm.Excursion
ep := &model.ExcursionPersisted{
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
}
hm.Excursion.WildUntil = now
hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second)))
prevPhase := hm.Excursion.Phase
hm.refreshSpeed(now)
switch hm.Excursion.Phase {
case model.ExcursionOut:
if hm.stepTowardAttractor(now, dt) {
hm.Excursion.Phase = model.ExcursionWild
}
excursionEnded := hm.advanceExcursionPhases(now)
if hm.Excursion.Phase == model.ExcursionWild {
case model.ExcursionWild:
hm.applyRestHealTick(dt)
if adventureLog != nil {
if hm.RoadsideThoughtNextAt.IsZero() {
@ -1770,8 +1956,14 @@ func ProcessSingleHeroMovementTick(
hm.RoadsideThoughtNextAt = now.Add(time.Duration(30+rand.Intn(61)) * time.Second)
}
}
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
hm.setRoadsideReturnAttractor()
}
if excursionEnded {
case model.ExcursionReturn:
if hm.stepTowardAttractor(now, dt) {
hm.endExcursion(now)
hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
@ -1780,18 +1972,10 @@ func ProcessSingleHeroMovementTick(
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)))
}
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 {
@ -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,23 +2043,7 @@ 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
}
} else if sender != nil {
dx := hm.TownCenterWalkToX - hm.CurrentX
dy := hm.TownCenterWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
@ -1889,17 +2053,19 @@ func ProcessSingleHeroMovementTick(
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,24 +2133,7 @@ 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
}
} else if sender != nil {
dx := hm.TownNPCWalkToX - hm.CurrentX
dy := hm.TownNPCWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
@ -1994,7 +2143,6 @@ func ProcessSingleHeroMovementTick(
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{})
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,10 +2379,24 @@ func ProcessSingleHeroMovementTick(
if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost)
}
hm.LastMoveTick = now
return
}
}
}
}
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{})
}
}
}
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))
@ -2250,7 +2404,6 @@ func ProcessSingleHeroMovementTick(
hm.SyncToHero()
return
}
}
// --- Normal walking (no active excursion) ---
reachedTown := hm.AdvanceTick(now, graph)

@ -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{

@ -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)

@ -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)

@ -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 {
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
// 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"`
}

@ -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:"-"`

@ -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.
// Walk-to-NPC: hero moves toward stand point (npcWalkTargetId + to); position is hero x/y + speed×dt.
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"`
// 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"`
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"`
CenterWalkArrive *time.Time `json:"centerWalkArrive,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"`

@ -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,

@ -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,

@ -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;

@ -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;

Loading…
Cancel
Save