From 03208b17bacf64b1b70859a5df47e801e2420511 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 14:56:58 +0300 Subject: [PATCH] excursions --- admin-web/index.html | 2 +- backend/internal/game/engine.go | 12 +- backend/internal/game/fsm_excursion_test.go | 47 +- backend/internal/game/movement.go | 697 ++++++++++++-------- backend/internal/game/offline.go | 1 + backend/internal/game/rest_test.go | 92 ++- backend/internal/handler/admin.go | 2 +- backend/internal/model/excursion.go | 43 +- backend/internal/model/hero.go | 2 + backend/internal/model/town_pause.go | 25 +- backend/internal/tuning/runtime.go | 20 +- frontend/src/App.tsx | 4 + frontend/src/game/types.ts | 2 + frontend/src/network/api.ts | 1 + 14 files changed, 620 insertions(+), 330 deletions(-) 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;