package game import ( "fmt" "math" "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) const ( // townNPCVisitLogLines is how many log lines to emit per NPC visit. townNPCVisitLogLines = 6 ) func movementTickRate() time.Duration { ms := tuning.Get().MovementTickRateMs if ms <= 0 { ms = tuning.DefaultValues().MovementTickRateMs } return time.Duration(ms) * time.Millisecond } func positionSyncRate() time.Duration { ms := tuning.Get().PositionSyncRateMs if ms <= 0 { ms = tuning.DefaultValues().PositionSyncRateMs } return time.Duration(ms) * time.Millisecond } func townNPCLogInterval() time.Duration { ms := tuning.Get().TownNPCLogIntervalMs if ms <= 0 { ms = tuning.DefaultValues().TownNPCLogIntervalMs } return time.Duration(ms) * time.Millisecond } // AdventureLogWriter persists or pushes one adventure log line for a hero (optional). type AdventureLogWriter func(heroID int64, line model.AdventureLogLine) // HeroMovement holds the live movement state for a single online hero. type HeroMovement struct { HeroID int64 Hero *model.Hero // live reference, owned by the engine CurrentX float64 CurrentY float64 Speed float64 // effective world-units/sec State model.GameState DestinationTownID int64 CurrentTownID int64 Road *Road WaypointIndex int // index of the waypoint we are heading toward WaypointFraction float64 // 0..1 within the current segment LastEncounterAt time.Time RestUntil time.Time LastMoveTick time.Time Direction int // +1 forward along TownOrder, -1 backward // TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown. TownNPCQueue []int64 NextTownNPCRollAt time.Time // Town NPC visit: adventure log lines until NextTownNPCRollAt (narration block) after town_npc_visit. TownVisitNPCName string TownVisitNPCKey string TownVisitNPCType string TownVisitStartedAt time.Time TownVisitLogsEmitted int // TownNPCUILock: while true, town NPC visit narration timers do not advance (hero opened shop/quest UI). TownNPCUILock bool // RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild). RoadsideThoughtNextAt time.Time // Walk-to-NPC: attractor at TownNPCWalkTo* while TownNPCWalkTargetID != 0. TownNPCWalkTargetID int64 TownNPCWalkToX float64 TownNPCWalkToY float64 // TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end). TownLeaveAt time.Time // TownLastNPCLingerUntil: after the final queued NPC visit ends, wait near them until this time before walking to plaza (shifted while TownNPCUILock). TownLastNPCLingerUntil time.Time // TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded). TownPlazaHealActive bool // TownCenterWalk*: attractor stepping to plaza before road snap. TownCenterWalkActive bool TownCenterWalkToX float64 TownCenterWalkToY float64 // Accumulates fractional town-rest regen between ticks. TownRestHealRemainder float64 // WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only). WanderingMerchantDeadline time.Time // Excursion holds the live mini-adventure session state. // When Excursion.Phase == ExcursionNone the hero is on the road (normal walk / town / roadside rest). Excursion model.ExcursionSession // ActiveRestKind discriminates the current rest context when State == StateResting. ActiveRestKind model.RestKind // RestHealRemainder accumulates fractional HP between ticks for roadside / adventure-inline rest. RestHealRemainder float64 // LastExcursionEndedAt is used for adventure cooldown (not persisted; resets on reconnect). LastExcursionEndedAt time.Time // spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad // instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0. spawnAtRoadStart bool // lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can // persist only on meaningful changes (start/end/phase change). lastTownPausePersistSignature townPausePersistSignature // sentTownTourWireSig avoids spamming town_tour_phase when nothing changed. sentTownTourWireSig string } // townPausePersistSignature captures the excursion/rest fields that should trigger persistence. // Keep this small to avoid persisting every tick due to healing remainders. type townPausePersistSignature struct { RestKind model.RestKind RestUntil time.Time ExcursionKind model.ExcursionKind ExcursionPhase model.ExcursionPhase ExcursionStartedAt time.Time ExcursionOutUntil time.Time ExcursionWildUntil time.Time ExcursionReturnUntil time.Time 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 InTownNextRoll time.Time InTownLeave time.Time InTownVisitStarted time.Time InTownVisitLogs int InTownNPCWalkTarget int64 InTownNPCWalkToX float64 InTownNPCWalkToY float64 InTownPlazaHeal bool InTownCenterWalkActive bool InTownCenterWalkToX float64 InTownCenterWalkToY float64 InTownNPCQueueLen int InTownNPCQueueFP uint64 InTownVisitName string InTownVisitType string InTownLastNPCLinger time.Time } func npcQueueFingerprint(q []int64) uint64 { const prime64 = 1099511628211 var h uint64 = 1469598103934665603 for _, id := range q { h ^= uint64(id) h *= prime64 } h ^= uint64(len(q)) << 32 return h } // NewHeroMovement creates a HeroMovement for a hero that just connected. // It initializes position, state, and picks the first destination if needed. func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMovement { // Randomize direction per hero so they don't all walk the same way. dir := 1 if hero.ID%2 == 0 { dir = -1 } // Persisted (x,y) already include any in-world offset from prior sessions; do not add // lateral jitter again on reconnect (that doubled the shift every reload). freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0 var curX, curY float64 if freshWorldSpawn { curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road } else { curX = hero.PositionX curY = hero.PositionY } hm := &HeroMovement{ HeroID: hero.ID, Hero: hero, CurrentX: curX, CurrentY: curY, State: hero.State, LastMoveTick: now, Direction: dir, spawnAtRoadStart: freshWorldSpawn, } // Restore persisted movement state. if hero.CurrentTownID != nil { hm.CurrentTownID = *hero.CurrentTownID } if hero.DestinationTownID != nil { hm.DestinationTownID = *hero.DestinationTownID } hm.refreshSpeed(now) // If the hero is dead, keep them dead. if hero.State == model.StateDead || hero.HP <= 0 { hm.State = model.StateDead return hm } // If fighting, leave as-is (engine combat system manages it). if hero.State == model.StateFighting { return hm } // Resting / in-town: restore persisted deadlines and NPC tour from DB (town_pause). if hero.State == model.StateResting || hero.State == model.StateInTown { hm.State = hero.State hm.applyTownPauseFromHero(hero, now) // For roadside / adventure-inline rest the hero needs a road for display offset calculations. if hm.ActiveRestKind == model.RestKindRoadside || hm.ActiveRestKind == model.RestKindAdventureInline { if hm.DestinationTownID == 0 { hm.pickDestination(graph) } hm.assignRoad(graph, false) if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 { hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction } } return hm } // Walking state: assign a road if we don't have a destination. if hm.DestinationTownID == 0 { hm.pickDestination(graph) } hm.assignRoad(graph, false) if hm.Road == nil { hm.pickDestination(graph) hm.assignRoad(graph, false) } hm.State = model.StateWalking // Restore excursion session from persisted blob (hero may have disconnected mid-adventure). if hero.TownPause != nil && hero.TownPause.Excursion != nil { hm.applyExcursionFromBlob(hero.TownPause.Excursion) if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 { hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction } } return hm } // firstReachableOnRing returns the first town along TownOrder (stepping by Direction) // that has a direct road from CurrentTownID, or 0 if none. func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 { n := len(graph.TownOrder) if n < 2 || fromIdx < 0 { return 0 } for step := 1; step < n; step++ { raw := fromIdx + hm.Direction*step nextIdx := ((raw % n) + n) % n candidate := graph.TownOrder[nextIdx] if candidate == hm.CurrentTownID { continue } if graph.FindRoad(hm.CurrentTownID, candidate) != nil { return candidate } } return 0 } func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 { for _, r := range graph.TownRoads[hm.CurrentTownID] { if r != nil && r.ToTownID != hm.CurrentTownID { return r.ToTownID } } return 0 } func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 { for _, tid := range graph.TownOrder { if tid == hm.CurrentTownID { continue } if graph.FindRoad(hm.CurrentTownID, tid) != nil { return tid } } return 0 } // avoidSelfLoopDestination forces a different destination than CurrentTownID when the world // has multiple towns. Roads are always between distinct towns; dest == current yields no road // and the hero never moves (common after DB fallback or mis-picked nearest town at 0,0). func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) { if graph == nil || len(graph.TownOrder) <= 1 { return } if hm.CurrentTownID == 0 || hm.DestinationTownID != hm.CurrentTownID { return } if d := hm.firstOutgoingDestination(graph); d != 0 && d != hm.CurrentTownID { hm.DestinationTownID = d return } if nxt := graph.NextTownInChain(hm.CurrentTownID); nxt != 0 && nxt != hm.CurrentTownID { hm.DestinationTownID = nxt } } // crossRoadChance is the probability of picking a cross-road instead of following the ring. const crossRoadChance = 0.3 // pickDestination selects the next town the hero should walk toward. // Only towns connected by a roads row are chosen — TownOrder alone is not enough. // When multiple outgoing roads exist, there's a chance the hero takes a cross-road. func (hm *HeroMovement) pickDestination(graph *RoadGraph) { defer hm.avoidSelfLoopDestination(graph) if hm.CurrentTownID == 0 { if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 { hm.CurrentTownID = graph.TownOrder[0] } else { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } } n := len(graph.TownOrder) if n == 0 { hm.DestinationTownID = 0 return } if n == 1 { hm.DestinationTownID = hm.CurrentTownID return } idx := graph.TownOrderIndex(hm.CurrentTownID) if idx < 0 { if d := hm.firstOutgoingDestination(graph); d != 0 { hm.DestinationTownID = d return } if d := hm.firstReachableAny(graph); d != 0 { hm.DestinationTownID = d return } if len(graph.TownOrder) > 0 { hm.DestinationTownID = graph.TownOrder[0] } return } // When multiple roads are available, sometimes pick a non-ring neighbor at random // (includes both ring directions when there are exactly two outgoing roads). outgoing := graph.TownRoads[hm.CurrentTownID] if len(outgoing) >= 2 && rand.Float64() < crossRoadChance { pick := outgoing[rand.Intn(len(outgoing))] if pick != nil && pick.ToTownID != hm.CurrentTownID { hm.DestinationTownID = pick.ToTownID return } } if dest := hm.firstReachableOnRing(graph, idx); dest != 0 { hm.DestinationTownID = dest return } if d := hm.firstOutgoingDestination(graph); d != 0 { hm.DestinationTownID = d return } if d := hm.firstReachableAny(graph); d != 0 { hm.DestinationTownID = d return } hm.DestinationTownID = hm.CurrentTownID } // assignRoad finds and configures the road from CurrentTownID to DestinationTownID. // If no road exists (hero is mid-road), it finds the nearest town and routes from there. // startAtFirstWaypoint: force hero to jittered waypoint 0 (departure town). Otherwise // snapProgressToNearestPointOnRoad projects CurrentX/Y onto the polyline (used after LeaveTown). func (hm *HeroMovement) assignRoad(graph *RoadGraph, startAtFirstWaypoint bool) { road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID) if road == nil { // Try finding a road from any nearby town. nearest := graph.NearestTown(hm.CurrentX, hm.CurrentY) hm.CurrentTownID = nearest road = graph.FindRoad(nearest, hm.DestinationTownID) } if road == nil { // No road available, will retry next tick. return } // Create a per-hero jittered copy of waypoints so heroes don't overlap on the same road. jitteredWaypoints := make([]Point, len(road.Waypoints)) copy(jitteredWaypoints, road.Waypoints) heroSeed := float64(hm.HeroID) lateralJitter := (math.Sin(heroSeed*1.7) * 1.5) // ±1.5 tiles lateral offset for i := 1; i < len(jitteredWaypoints)-1; i++ { // Apply perpendicular offset (don't jitter start/end = town centers) dx := jitteredWaypoints[i].X - jitteredWaypoints[max(0, i-1)].X dy := jitteredWaypoints[i].Y - jitteredWaypoints[max(0, i-1)].Y segLen := math.Hypot(dx, dy) if segLen > 0.1 { perpX := -dy / segLen perpY := dx / segLen jitter := lateralJitter * (0.7 + 0.3*math.Sin(heroSeed*0.3+float64(i)*0.5)) jitteredWaypoints[i].X += perpX * jitter jitteredWaypoints[i].Y += perpY * jitter } } jitteredRoad := &Road{ ID: road.ID, FromTownID: road.FromTownID, ToTownID: road.ToTownID, Distance: road.Distance, Waypoints: jitteredWaypoints, } hm.Road = jitteredRoad if hm.spawnAtRoadStart { wp0 := jitteredRoad.Waypoints[0] hm.CurrentX = wp0.X hm.CurrentY = wp0.Y hm.WaypointIndex = 0 hm.WaypointFraction = 0 hm.spawnAtRoadStart = false } else if startAtFirstWaypoint { wp0 := jitteredRoad.Waypoints[0] hm.CurrentX = wp0.X hm.CurrentY = wp0.Y hm.WaypointIndex = 0 hm.WaypointFraction = 0 } else { // Restore progress along this hero's jittered polyline from saved world position. hm.snapProgressToNearestPointOnRoad() } } // snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y // to the closest point on the current road polyline to the incoming position. func (hm *HeroMovement) snapProgressToNearestPointOnRoad() { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { hm.WaypointIndex = 0 hm.WaypointFraction = 0 return } hx, hy := hm.CurrentX, hm.CurrentY bestIdx := 0 bestT := 0.0 bestDistSq := math.MaxFloat64 bestX, bestY := hx, hy for i := 0; i < len(hm.Road.Waypoints)-1; i++ { ax, ay := hm.Road.Waypoints[i].X, hm.Road.Waypoints[i].Y bx, by := hm.Road.Waypoints[i+1].X, hm.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 bestIdx = i bestT = t bestX, bestY = px, py } } hm.WaypointIndex = bestIdx hm.WaypointFraction = bestT hm.CurrentX = bestX hm.CurrentY = bestY } // ShiftGameDeadlines advances movement-related deadlines by d (wall time spent paused) so // simulation does not “catch up” after resume. LastMoveTick is set to now to avoid a huge dt on the next tick. func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) { if d <= 0 { hm.LastMoveTick = now return } shift := func(t time.Time) time.Time { if t.IsZero() { return t } return t.Add(d) } hm.LastEncounterAt = shift(hm.LastEncounterAt) hm.RestUntil = shift(hm.RestUntil) hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) hm.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil) hm.TownLeaveAt = shift(hm.TownLeaveAt) if hm.Excursion.Kind == model.ExcursionKindTown { ex := &hm.Excursion ex.TownTourEndsAt = shift(ex.TownTourEndsAt) ex.WanderNextAt = shift(ex.WanderNextAt) ex.TownWelcomeUntil = shift(ex.TownWelcomeUntil) ex.TownServiceUntil = shift(ex.TownServiceUntil) ex.TownRestUntil = shift(ex.TownRestUntil) } hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil) hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil) hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil) hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt) hm.LastMoveTick = now } // refreshSpeed recalculates the effective movement speed using hero buffs/debuffs. func (hm *HeroMovement) refreshSpeed(now time.Time) { // Per-hero speed variation: ±10% based on hero ID for natural spread. heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10 hm.Speed = tuning.Get().BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter } // AdvanceTick moves the hero along the road for one movement tick. // Returns true if the hero reached the destination town this tick. func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) { if hm.skipMovementSimulation() { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } if hm.Excursion.Active() { hm.LastMoveTick = now return false } dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = movementTickRate().Seconds() } hm.LastMoveTick = now hm.refreshSpeed(now) distThisTick := hm.Speed * dt for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 { from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] segLen := math.Hypot(to.X-from.X, to.Y-from.Y) if segLen < 0.001 { hm.WaypointIndex++ hm.WaypointFraction = 0 continue } // How far along this segment we already are. currentDist := hm.WaypointFraction * segLen remaining := segLen - currentDist arrivalRadius := tuning.Get().TownArrivalRadius if arrivalRadius < 0 { arrivalRadius = 0 } if distThisTick >= remaining || (hm.WaypointIndex == len(hm.Road.Waypoints)-2 && remaining <= arrivalRadius) { // Move to next waypoint. distThisTick -= remaining hm.WaypointIndex++ hm.WaypointFraction = 0 if hm.WaypointIndex >= len(hm.Road.Waypoints)-1 { // Reached final waypoint = destination town. last := hm.Road.Waypoints[len(hm.Road.Waypoints)-1] hm.CurrentX = last.X hm.CurrentY = last.Y return true } } else { // Partial advance within this segment. hm.WaypointFraction = (currentDist + distThisTick) / segLen hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction distThisTick = 0 } } // Update position to the current waypoint position. if hm.WaypointIndex < len(hm.Road.Waypoints) { wp := hm.Road.Waypoints[hm.WaypointIndex] if hm.WaypointFraction == 0 { hm.CurrentX = wp.X hm.CurrentY = wp.Y } } return false } // 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 } to := hm.Road.Waypoints[hm.WaypointIndex+1] return math.Atan2(to.Y-hm.CurrentY, to.X-hm.CurrentX) } // 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 } wp := hm.Road.Waypoints[hm.WaypointIndex+1] return wp.X, wp.Y } func (hm *HeroMovement) applyTownRestHeal(dt float64) { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } cfg := tuning.Get() rawGain := float64(hm.Hero.MaxHP)*cfg.TownRestHPPerS*dt + hm.TownRestHealRemainder gain := int(math.Floor(rawGain)) hm.TownRestHealRemainder = rawGain - float64(gain) if gain <= 0 { return } hm.Hero.HP += gain if hm.Hero.HP > hm.Hero.MaxHP { hm.Hero.HP = hm.Hero.MaxHP } } // AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest). func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error { if graph == nil || townID == 0 { return fmt.Errorf("invalid town") } if _, ok := graph.Towns[townID]; !ok { return fmt.Errorf("unknown town") } hm.Road = nil hm.WaypointIndex = 0 hm.WaypointFraction = 0 hm.DestinationTownID = townID hm.spawnAtRoadStart = false hm.WanderingMerchantDeadline = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownRestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.clearNPCWalk() t := graph.Towns[townID] hm.CurrentX = t.WorldX hm.CurrentY = t.WorldY hm.EnterTown(now, graph) return nil } // AdminStartRoadsideRest forces the hero into roadside rest on the current road segment. func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } if hm.State == model.StateFighting { return false } hm.WanderingMerchantDeadline = time.Time{} hm.beginRoadsideRest(now) return true } // AdminStartExcursion forces a mini-adventure (excursion) session while the hero is walking on a road. // Cooldown and random roll are bypassed; the hero must not already be in an excursion. func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } if hm.State == model.StateFighting { return false } if hm.State != model.StateWalking { return false } if hm.Excursion.Active() { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } hm.WanderingMerchantDeadline = time.Time{} hm.beginExcursion(now) return true } // 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 } if hm.Excursion.Kind == model.ExcursionKindTown { return false } if hm.State == model.StateFighting { return false } if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindAdventureInline { hm.ActiveRestKind = model.RestKindNone hm.RestUntil = time.Time{} hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.enterAdventureReturnToRoad() 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. func (hm *HeroMovement) AdminStopRest(now time.Time) bool { if hm.State != model.StateResting { return false } if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline { return false } if hm.Excursion.Active() { hm.endExcursion(now) } hm.ActiveRestKind = model.RestKindNone hm.RestUntil = time.Time{} hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.refreshSpeed(now) return true } // AdminStartRest forces a resting period (same duration model as town rest). func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } if hm.State == model.StateFighting { return false } hm.WanderingMerchantDeadline = time.Time{} hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownRestHealRemainder = 0 hm.clearNPCWalk() hm.clearTownCenterWalk() hm.TownPlazaHealActive = false hm.TownLeaveAt = time.Time{} hm.TownLastNPCLingerUntil = time.Time{} if graph != nil && hm.CurrentTownID == 0 { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindTown hm.RestUntil = now.Add(randomRestDuration()) return true } func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return 0, 1 } idx := hm.WaypointIndex if idx >= len(hm.Road.Waypoints)-1 { idx = len(hm.Road.Waypoints) - 2 } if idx < 0 { return 0, 1 } from := hm.Road.Waypoints[idx] to := hm.Road.Waypoints[idx+1] dx := to.X - from.X dy := to.Y - from.Y L := math.Hypot(dx, dy) if L < 1e-6 { return 0, 1 } return -dy / L, dx / L } // roadForwardUnit is the normalized tangent along the road toward the next waypoint. func (hm *HeroMovement) roadForwardUnit() (float64, float64) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return 1, 0 } idx := hm.WaypointIndex if idx >= len(hm.Road.Waypoints)-1 { idx = len(hm.Road.Waypoints) - 2 } if idx < 0 { return 1, 0 } from := hm.Road.Waypoints[idx] to := hm.Road.Waypoints[idx+1] dx := to.X - from.X dy := to.Y - from.Y L := math.Hypot(dx, dy) if L < 1e-6 { return 1, 0 } return dx / L, dy / L } func (hm *HeroMovement) excursionUsesAttractors() bool { return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone && hm.Excursion.Kind != model.ExcursionKindTown } 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() depth := exc.DepthWorldUnits var t float64 switch exc.Phase { case model.ExcursionOut: outMs := float64(exc.OutUntil.Sub(exc.StartedAt).Milliseconds()) if outMs > 0 { elapsed := float64(now.Sub(exc.StartedAt).Milliseconds()) t = smoothstep(clamp01(elapsed / outMs)) } case model.ExcursionWild: t = 1.0 case model.ExcursionReturn: 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)) } } d := depth * t return perpX * d, perpY * d } return 0, 0 } // WanderingMerchantCost matches REST encounter / npc alms pricing. func WanderingMerchantCost(level int) int64 { cfg := tuning.Get() return cfg.MerchantCostBase + int64(level)*cfg.MerchantCostPerLevel } // rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat. func (hm *HeroMovement) rollRoadEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) { cfg := tuning.Get() if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false, model.Enemy{}, false } if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) { return false, model.Enemy{}, false } if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond { return false, model.Enemy{}, false } if rand.Float64() >= cfg.EncounterActivityBase { return false, model.Enemy{}, false } monsterW := cfg.MonsterEncounterWeightBase merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { e := PickEnemyForHero(hm.Hero) return true, e, true } return false, model.Enemy{}, true } // EnterTown transitions the hero into the destination town: town tour excursion (StateInTown) when there // are NPCs, otherwise a short resting state (StateResting). func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { destID := hm.DestinationTownID hm.CurrentTownID = destID hm.DestinationTownID = 0 hm.Road = nil clearLegacyTownNPCState(hm) hm.TownRestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.sentTownTourWireSig = "" hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.clearTownCenterWalk() ids := graph.TownNPCIDs(destID) if len(ids) == 0 { hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindTown hm.RestUntil = now.Add(randomRestDuration()) return } hm.State = model.StateInTown hm.Hero.State = model.StateInTown beginTownTourExcursion(hm, now, graph) } // LeaveTown transitions the hero from town to walking, picking a new destination. func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { clearLegacyTownNPCState(hm) hm.TownRestHealRemainder = 0 hm.RestUntil = time.Time{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.sentTownTourWireSig = "" hm.clearTownCenterWalk() hm.TownPlazaHealActive = false hm.State = model.StateWalking hm.Hero.State = model.StateWalking // Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick. hm.LastMoveTick = now hm.pickDestination(graph) // Project CurrentX/Y onto the outbound road polyline. The normal town flow walks the hero // to the plaza first; forcing waypoint 0 caused a visible teleport away from that spot. hm.assignRoad(graph, false) hm.refreshSpeed(now) } func randomTownNPCDelay() time.Duration { cfg := tuning.Get() minDelay := time.Duration(cfg.TownNPCRollMinMs) * time.Millisecond maxDelay := time.Duration(cfg.TownNPCRollMaxMs) * time.Millisecond rangeMs := (maxDelay - minDelay).Milliseconds() if rangeMs < 0 { rangeMs = 0 } return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond } // townNPCStandPoint is a spot near the NPC along the hero's approach (from → npc), not on the NPC tile. func townNPCStandPoint(npcX, npcY, fromX, fromY, standoff float64) (sx, sy float64) { if standoff <= 0 { standoff = tuning.DefaultValues().TownNPCStandoffWorld } dx := fromX - npcX dy := fromY - npcY ln := math.Hypot(dx, dy) if ln < 1e-4 { s := standoff * 0.7071067811865476 return npcX + s, npcY + s } ux, uy := dx/ln, dy/ln const gap = 0.05 step := standoff if step > ln-gap { step = ln - gap } if step < 0.12 { step = math.Min(0.12, math.Max(0.06, ln*0.42)) } return npcX + ux*step, npcY + uy*step } // clearNPCWalk resets the walk-to-NPC sub-state. func (hm *HeroMovement) clearNPCWalk() { hm.TownNPCWalkTargetID = 0 hm.TownNPCWalkToX = 0 hm.TownNPCWalkToY = 0 } func (hm *HeroMovement) clearTownCenterWalk() { hm.TownCenterWalkActive = false hm.TownCenterWalkToX = 0 hm.TownCenterWalkToY = 0 } // StartFighting pauses movement for combat. func (hm *HeroMovement) StartFighting() { hm.State = model.StateFighting hm.WanderingMerchantDeadline = time.Time{} } // ResumWalking resumes movement after combat. func (hm *HeroMovement) ResumeWalking(now time.Time) { hm.State = model.StateWalking hm.LastMoveTick = now hm.refreshSpeed(now) } // Die sets the movement state to dead. func (hm *HeroMovement) Die() { hm.State = model.StateDead } // canSimulateMovement is true when the hero may advance along roads, towns, rests, and encounters. // Dead heroes must not run the movement FSM or AdvanceTick. func (hm *HeroMovement) canSimulateMovement() bool { if hm == nil || hm.Hero == nil { return false } if hm.State == model.StateDead { return false } if hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } return true } // skipMovementSimulation returns true if the hero must not run movement this step. // When the model is dead but FSM is not yet StateDead, aligns with Die(). func (hm *HeroMovement) skipMovementSimulation() bool { if hm == nil { return true } if hm.canSimulateMovement() { return false } if hm.Hero != nil && (hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead) { hm.Die() } return true } // worldPositionAt returns hero world (x,y) matching SyncToHero / hero_move (spine + display offset). func (hm *HeroMovement) worldPositionAt(now time.Time) (x, y float64) { if hm == nil || hm.Hero == nil { return 0, 0 } ox, oy := hm.displayOffset(now) return hm.CurrentX + ox, hm.CurrentY + oy } // SyncToHero writes movement state back to the hero model for persistence. // Position uses the same world coordinates as hero_move / position_sync (road spine + display offset). func (hm *HeroMovement) SyncToHero() { now := time.Now() x, y := hm.worldPositionAt(now) hm.Hero.PositionX = x hm.Hero.PositionY = y hm.Hero.State = hm.State if hm.CurrentTownID != 0 { id := hm.CurrentTownID hm.Hero.CurrentTownID = &id } else { hm.Hero.CurrentTownID = nil } if hm.DestinationTownID != 0 { id := hm.DestinationTownID hm.Hero.DestinationTownID = &id } else { hm.Hero.DestinationTownID = nil } hm.Hero.MoveState = string(hm.State) hm.Hero.RestKind = model.RestKindNone if hm.State == model.StateResting { if hm.ActiveRestKind != model.RestKindNone { hm.Hero.RestKind = hm.ActiveRestKind } else { hm.Hero.RestKind = model.RestKindTown } } hm.Hero.ExcursionPhase = model.ExcursionNone hm.Hero.ExcursionKind = model.ExcursionKindNone hm.Hero.TownTourPhase = "" hm.Hero.TownTourNpcID = 0 hm.Hero.TownTourExitPending = false if hm.Excursion.Active() { hm.Hero.ExcursionKind = hm.Excursion.Kind if hm.Excursion.Kind == model.ExcursionKindTown { hm.Hero.ExcursionPhase = model.ExcursionWild hm.Hero.TownTourPhase = hm.Excursion.TownTourPhase hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending } else { hm.Hero.ExcursionPhase = hm.Excursion.Phase } } hm.Hero.TownPause = hm.townPauseBlob() } // TownPausePersistDue reports whether excursion/rest state should be persisted. // Returns the current signature for use when marking persistence. func (hm *HeroMovement) TownPausePersistDue() (townPausePersistSignature, bool) { sig := hm.townPausePersistSignature() if sig == hm.lastTownPausePersistSignature { return sig, false } return sig, true } // MarkTownPausePersisted stores the latest persisted signature. func (hm *HeroMovement) MarkTownPausePersisted(sig townPausePersistSignature) { hm.lastTownPausePersistSignature = sig } func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature { var sig townPausePersistSignature if hm.State == model.StateResting { rk := hm.ActiveRestKind if rk == model.RestKindNone { rk = model.RestKindTown } sig.RestKind = rk sig.RestUntil = hm.RestUntil } if hm.Excursion.Active() { s := hm.Excursion sig.ExcursionKind = s.Kind sig.ExcursionPhase = s.Phase sig.ExcursionStartedAt = s.StartedAt sig.ExcursionOutUntil = s.OutUntil sig.ExcursionWildUntil = s.WildUntil sig.ExcursionReturnUntil = s.ReturnUntil 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 { sig.InTown = true sig.InTownNextRoll = hm.NextTownNPCRollAt sig.InTownLeave = hm.TownLeaveAt sig.InTownVisitStarted = hm.TownVisitStartedAt sig.InTownVisitLogs = hm.TownVisitLogsEmitted sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID sig.InTownNPCWalkToX = hm.TownNPCWalkToX sig.InTownNPCWalkToY = hm.TownNPCWalkToY sig.InTownPlazaHeal = hm.TownPlazaHealActive 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 sig.InTownVisitType = hm.TownVisitNPCType sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil } return sig } func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { var p *model.TownPausePersisted switch hm.State { case model.StateResting: rk := model.RestKindTown if hm.ActiveRestKind != model.RestKindNone { rk = hm.ActiveRestKind } if rk == model.RestKindTown && hm.RestUntil.IsZero() { break } p = &model.TownPausePersisted{ RestKind: rk, TownRestHealRemainder: hm.TownRestHealRemainder, RestHealRemainder: hm.RestHealRemainder, } if !hm.RestUntil.IsZero() { t := hm.RestUntil p.RestUntil = &t } case model.StateInTown: p = &model.TownPausePersisted{ TownVisitNPCName: hm.TownVisitNPCName, TownVisitNPCType: hm.TownVisitNPCType, TownVisitLogsEmitted: hm.TownVisitLogsEmitted, NPCWalkTargetID: hm.TownNPCWalkTargetID, NPCWalkToX: hm.TownNPCWalkToX, NPCWalkToY: hm.TownNPCWalkToY, } if len(hm.TownNPCQueue) > 0 { p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...) } if !hm.NextTownNPCRollAt.IsZero() { t := hm.NextTownNPCRollAt p.NextTownNPCRollAt = &t } if !hm.TownLeaveAt.IsZero() { t := hm.TownLeaveAt p.TownLeaveAt = &t } if !hm.TownVisitStartedAt.IsZero() { t := hm.TownVisitStartedAt p.TownVisitStartedAt = &t } if !hm.TownLastNPCLingerUntil.IsZero() { t := hm.TownLastNPCLingerUntil p.TownLastNPCLingerUntil = &t } if hm.TownPlazaHealActive { p.TownPlazaHealActive = true } if hm.TownCenterWalkActive { p.CenterWalkActive = true p.CenterWalkToX = hm.TownCenterWalkToX p.CenterWalkToY = hm.TownCenterWalkToY } } // Persist active excursion session regardless of hero state (the hero can be fighting // or resting while an excursion is in progress). if hm.Excursion.Active() { ep := hm.excursionPersisted() if p == nil { p = &model.TownPausePersisted{} } p.Excursion = ep } return p } 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 ep.StartedAt = &t } if !s.OutUntil.IsZero() { t := s.OutUntil ep.OutUntil = &t } if !s.WildUntil.IsZero() { t := s.WildUntil ep.WildUntil = &t } if !s.ReturnUntil.IsZero() { 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 } if s.Kind == model.ExcursionKindTown { ep.TownTourPhase = s.TownTourPhase ep.TownTourNpcID = s.TownTourNpcID ep.TownTourStandX = s.TownTourStandX ep.TownTourStandY = s.TownTourStandY ep.TownExitPending = s.TownExitPending ep.TownTourDialogOpen = s.TownTourDialogOpen ep.TownTourInteractionOpen = s.TownTourInteractionOpen if !s.TownTourEndsAt.IsZero() { t := s.TownTourEndsAt ep.TownTourEndsAt = &t } if !s.TownWelcomeUntil.IsZero() { t := s.TownWelcomeUntil ep.TownWelcomeUntil = &t } if !s.TownServiceUntil.IsZero() { t := s.TownServiceUntil ep.TownServiceUntil = &t } if !s.TownRestUntil.IsZero() { t := s.TownRestUntil ep.TownRestUntil = &t } } return ep } func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) { blob := hero.TownPause switch hero.State { case model.StateResting: if blob != nil && (blob.RestKind != model.RestKindNone || (blob.RestUntil != nil && !blob.RestUntil.IsZero())) { if blob.RestUntil != nil && !blob.RestUntil.IsZero() { hm.RestUntil = *blob.RestUntil } hm.ActiveRestKind = blob.RestKind hm.TownRestHealRemainder = blob.TownRestHealRemainder hm.RestHealRemainder = blob.RestHealRemainder } else { // Legacy row without town_pause: treat rest as already elapsed so offline/reconnect unblocks. hm.RestUntil = now.Add(-time.Millisecond) } case model.StateInTown: if blob == nil { return } if len(blob.NPCQueue) > 0 { hm.TownNPCQueue = append([]int64(nil), blob.NPCQueue...) } if blob.NextTownNPCRollAt != nil { hm.NextTownNPCRollAt = *blob.NextTownNPCRollAt } if blob.TownLeaveAt != nil { hm.TownLeaveAt = *blob.TownLeaveAt } hm.TownVisitNPCName = blob.TownVisitNPCName hm.TownVisitNPCType = blob.TownVisitNPCType if blob.TownVisitStartedAt != nil { hm.TownVisitStartedAt = *blob.TownVisitStartedAt } if blob.TownLastNPCLingerUntil != nil { hm.TownLastNPCLingerUntil = *blob.TownLastNPCLingerUntil } hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted hm.TownNPCWalkTargetID = blob.NPCWalkTargetID hm.TownNPCWalkToX = blob.NPCWalkToX hm.TownNPCWalkToY = blob.NPCWalkToY hm.TownPlazaHealActive = blob.TownPlazaHealActive hm.TownCenterWalkToX = blob.CenterWalkToX hm.TownCenterWalkToY = blob.CenterWalkToY hm.TownCenterWalkActive = blob.CenterWalkActive if !hm.TownCenterWalkActive && blob.CenterWalkStart != nil && !blob.CenterWalkStart.IsZero() { hm.TownCenterWalkActive = true } } // Restore excursion session from blob (may exist alongside any hero state). if blob != nil && blob.Excursion != nil { hm.applyExcursionFromBlob(blob.Excursion) } } 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 } if ep.OutUntil != nil { hm.Excursion.OutUntil = *ep.OutUntil } if ep.WildUntil != nil { hm.Excursion.WildUntil = *ep.WildUntil } if ep.ReturnUntil != nil { hm.Excursion.ReturnUntil = *ep.ReturnUntil } 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 } if ep.Kind == string(model.ExcursionKindTown) { hm.Excursion.TownTourPhase = ep.TownTourPhase hm.Excursion.TownTourNpcID = ep.TownTourNpcID hm.Excursion.TownTourStandX = ep.TownTourStandX hm.Excursion.TownTourStandY = ep.TownTourStandY hm.Excursion.TownExitPending = ep.TownExitPending hm.Excursion.TownTourDialogOpen = ep.TownTourDialogOpen hm.Excursion.TownTourInteractionOpen = ep.TownTourInteractionOpen if ep.TownTourEndsAt != nil { hm.Excursion.TownTourEndsAt = *ep.TownTourEndsAt } if ep.TownWelcomeUntil != nil { hm.Excursion.TownWelcomeUntil = *ep.TownWelcomeUntil } if ep.TownServiceUntil != nil { hm.Excursion.TownServiceUntil = *ep.TownServiceUntil } if ep.TownRestUntil != nil { hm.Excursion.TownRestUntil = *ep.TownRestUntil } } } // MovePayload builds the hero_move WS payload (includes off-road lateral offset for display). func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload { tx, ty := hm.TargetPoint() ox, oy := hm.displayOffset(now) return model.HeroMovePayload{ X: hm.CurrentX + ox, Y: hm.CurrentY + oy, TargetX: tx + ox, TargetY: ty + oy, Speed: hm.Speed, Heading: hm.Heading(), } } // RoutePayload builds the route_assigned WS payload. func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload { if hm.Road == nil { return nil } waypoints := make([]model.PointXY, len(hm.Road.Waypoints)) for i, p := range hm.Road.Waypoints { waypoints[i] = model.PointXY{X: p.X, Y: p.Y} } return &model.RouteAssignedPayload{ RoadID: hm.Road.ID, Waypoints: waypoints, DestinationTownID: hm.DestinationTownID, Speed: hm.Speed, } } // PositionSyncPayload builds the position_sync WS payload. func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload { ox, oy := hm.displayOffset(now) return model.PositionSyncPayload{ X: hm.CurrentX + ox, Y: hm.CurrentY + oy, WaypointIndex: hm.WaypointIndex, WaypointFraction: hm.WaypointFraction, State: string(hm.State), } } // randomRestDuration returns a random duration between TownRestMin and TownRestMax. func randomRestDuration() time.Duration { cfg := tuning.Get() minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond rangeMs := (maxDur - minDur).Milliseconds() if rangeMs < 0 { rangeMs = 0 } return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond } // EncounterStarter starts or resolves a random encounter while walking (engine: combat; // offline: synchronous SimulateOneFight via callback). type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time) // MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline). type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64) // AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB). type AfterTownEnterPersist func(hero *model.Hero) func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { if log == nil || hm.TownVisitStartedAt.IsZero() || hm.TownNPCUILock { return } logInterval := townNPCLogInterval() for hm.TownVisitLogsEmitted < townNPCVisitLogLines { deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * logInterval) if now.Before(deadline) { break } lineIdx := hm.TownVisitLogsEmitted log(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.TownVisitPhraseKey(hm.TownVisitNPCType, lineIdx), }, }) hm.TownVisitLogsEmitted++ } } // --- Excursion (mini-adventure) FSM helpers --- func smoothstep(t float64) float64 { return t * t * (3 - 2*t) } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } func (hm *HeroMovement) excursionWildness(now time.Time) float64 { if hm.Excursion.Phase == model.ExcursionWild { return 1 } if hm.Excursion.Phase == model.ExcursionReturn { retMs := float64(hm.Excursion.ReturnUntil.Sub(hm.Excursion.WildUntil).Milliseconds()) var t float64 if retMs > 0 { elapsed := float64(now.Sub(hm.Excursion.WildUntil).Milliseconds()) t = 1.0 - smoothstep(clamp01(elapsed/retMs)) } else { t = 1.0 } cfg := tuning.Get() minWild := cfg.AdventureReturnWildnessMin if minWild < 0 { minWild = 0 } if minWild > 1 { minWild = 1 } if t < minWild { t = minWild } return t } return 0 } func (hm *HeroMovement) isLowHP() bool { if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 { return false } return float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) < tuning.Get().LowHpThreshold } func (hm *HeroMovement) mayStartExcursion(now time.Time) bool { cfg := tuning.Get() if hm.Excursion.Active() { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } cooldown := time.Duration(cfg.AdventureCooldownMs) * time.Millisecond if !hm.LastExcursionEndedAt.IsZero() && now.Sub(hm.LastExcursionEndedAt) < cooldown { return false } remaining := len(hm.Road.Waypoints) - 1 - hm.WaypointIndex if remaining < 2 { return false } return rand.Float64() < cfg.AdventureStartChance } func (hm *HeroMovement) beginExcursion(now time.Time) { cfg := tuning.Get() depth := cfg.AdventureDepthWorldUnits if depth <= 0 { depth = tuning.DefaultValues().AdventureDepthWorldUnits } 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, DepthWorldUnits: depth, RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, StartX: hm.CurrentX, StartY: hm.CurrentY, AdventureEndsAt: adventureEnds, } hm.pickExcursionForestAttractor(depth) } func (hm *HeroMovement) endExcursion(now time.Time) { hm.LastExcursionEndedAt = now hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction hm.Excursion = model.ExcursionSession{} if hm.Road != nil && hm.WaypointIndex < len(hm.Road.Waypoints)-1 { from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction } } func (hm *HeroMovement) beginRoadsideRest(now time.Time) { cfg := tuning.Get() hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindRoadside hm.RestHealRemainder = 0 depth := cfg.RoadsideRestDepthWorldUnits if depth <= 0 { depth = 12.0 } restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs) hm.Excursion = model.ExcursionSession{ Kind: model.ExcursionKindRoadside, Phase: model.ExcursionOut, StartedAt: now, DepthWorldUnits: depth, RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, StartX: hm.CurrentX, StartY: hm.CurrentY, } 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) } func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) { _ = now hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindAdventureInline hm.RestHealRemainder = 0 } func (hm *HeroMovement) applyRestHealTick(dt float64) { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } cfg := tuning.Get() var hpPerS float64 switch hm.ActiveRestKind { case model.RestKindRoadside: hpPerS = cfg.RoadsideRestHpPerS case model.RestKindAdventureInline: hpPerS = cfg.AdventureRestHpPerS default: return } rawGain := float64(hm.Hero.MaxHP)*hpPerS*dt + hm.RestHealRemainder gain := int(math.Floor(rawGain)) hm.RestHealRemainder = rawGain - float64(gain) if gain <= 0 { return } hm.Hero.HP += gain if hm.Hero.HP > hm.Hero.MaxHP { hm.Hero.HP = hm.Hero.MaxHP } } func (hm *HeroMovement) rollAdventureEncounter(now time.Time, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) { cfg := tuning.Get() if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) { return false, model.Enemy{}, false } cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond if now.Sub(hm.LastEncounterAt) < cooldown { return false, model.Enemy{}, false } if rand.Float64() >= cfg.EncounterActivityBase { return false, model.Enemy{}, false } wildness := hm.excursionWildness(now) monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness merchantW := cfg.MerchantEncounterWeightBase total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { e := PickEnemyForHero(hm.Hero) return true, e, true } return false, model.Enemy{}, true } func randomDurationBetweenMs(minMs, maxMs int64) time.Duration { if maxMs <= minMs { return time.Duration(minMs) * time.Millisecond } return time.Duration(minMs+rand.Int63n(maxMs-minMs+1)) * time.Millisecond } // ProcessSingleHeroMovementTick applies one movement-system step as of logical time now. // It mirrors the online engine's configured movement cadence. // steps (plus a final partial step to real time) for catch-up simulation. // // sender may be nil to suppress all WebSocket payloads (offline ticks). // onEncounter is required for walking encounter rolls; if nil, encounters are not triggered. // adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block). // persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town. // townTourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time, sender MessageSender, onEncounter EncounterStarter, onMerchantEncounter MerchantEncounterHook, adventureLog AdventureLogWriter, persistAfterTownEnter AfterTownEnterPersist, townTourOffline TownTourOfflineAtNPC, ) { if graph == nil { return } if hm.skipMovementSimulation() { return } switch hm.State { case model.StateFighting, model.StateDead: return case model.StateResting: dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = movementTickRate().Seconds() } hm.LastMoveTick = now switch hm.ActiveRestKind { case model.RestKindRoadside: prevPhase := hm.Excursion.Phase hm.refreshSpeed(now) switch hm.Excursion.Phase { case model.ExcursionOut: if hm.stepTowardAttractor(now, dt) { hm.Excursion.Phase = model.ExcursionWild } case model.ExcursionWild: hm.applyRestHealTick(dt) if adventureLog != nil { if hm.RoadsideThoughtNextAt.IsZero() { hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second) } if !now.Before(hm.RoadsideThoughtNextAt) { if n := len(model.RoadsideSlugs); n > 0 { adventureLog(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.RoadsidePhraseKey(model.RoadsideSlugs[rand.Intn(n)]), }, }) } 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() } 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) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } case model.RestKindAdventureInline: hm.applyRestHealTick(dt) cfg := tuning.Get() hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) if hpFrac >= cfg.AdventureRestTargetHp { hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.refreshSpeed(now) } hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } default: hm.applyTownRestHeal(dt) hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } if now.After(hm.RestUntil) { hm.LeaveTown(graph, now) hm.SyncToHero() if sender != nil { sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } } } case model.StateInTown: if hm.Excursion.Kind == model.ExcursionKindTown { processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline) return } // Legacy in-town row without town excursion: force exit. if graph != nil { hm.LeaveTown(graph, now) hm.SyncToHero() if sender != nil { sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } } return case model.StateWalking: cfg := tuning.Get() hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2 if hadNoRoad { hm.Road = nil hm.pickDestination(graph) hm.assignRoad(graph, false) if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 { if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } } // Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout. if !hm.WanderingMerchantDeadline.IsZero() { if !now.Before(hm.WanderingMerchantDeadline) { hm.WanderingMerchantDeadline = time.Time{} if sender != nil { sender.SendToHero(heroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "timeout"}) } } else { hm.LastMoveTick = now if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } if hm.Hero != nil { hm.SyncToHero() } return } } // --- 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 hm.refreshSpeed(now) if hm.Excursion.Phase == model.ExcursionOut { if hm.stepTowardAttractor(now, dtAdv) { hm.Excursion.Phase = model.ExcursionWild hm.adventureScheduleWanderRetarget(now) hm.adventurePickWanderAttractor() } } 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() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.LastMoveTick = now return } 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 { cost := WanderingMerchantCost(hm.Hero.Level) hm.LastEncounterAt = now if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, NPCName: "Wandering Merchant", NPCNameKey: model.WanderingMerchantNPCKey, Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, Cost: cost, }) } 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)) } hm.SyncToHero() return } // --- Normal walking (no active excursion) --- reachedTown := hm.AdvanceTick(now, graph) if reachedTown { hm.EnterTown(now, graph) if sender != nil { town := graph.Towns[hm.CurrentTownID] if town != nil { npcInfos := graph.TownNPCInfos(hm.CurrentTownID) buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID) var restMs int64 if hm.State == model.StateResting { restMs = hm.RestUntil.Sub(now).Milliseconds() } sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{ TownID: town.ID, TownName: town.Name, TownNameKey: town.NameKey, Biome: town.Biome, NPCs: npcInfos, Buildings: buildingInfos, RestDurationMs: restMs, }) } } hm.SyncToHero() if persistAfterTownEnter != nil { persistAfterTownEnter(hm.Hero) } return } if hm.isLowHP() { hm.beginRoadsideRest(now) hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } return } canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2 if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) { monster, enemy, hit := hm.rollRoadEncounter(now, graph) if hit { if monster { if onEncounter != nil { hm.LastEncounterAt = now onEncounter(hm, &enemy, now) return } } else { cost := WanderingMerchantCost(hm.Hero.Level) if sender != nil || onMerchantEncounter != nil { hm.LastEncounterAt = now if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, NPCName: "Wandering Merchant", NPCNameKey: model.WanderingMerchantNPCKey, Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, Cost: cost, }) } if onMerchantEncounter != nil { onMerchantEncounter(hm, now, cost) } return } } } } if hm.mayStartExcursion(now) { hm.beginExcursion(now) if sender != nil { sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{ DepthWorldUnits: hm.Excursion.DepthWorldUnits, }) } hm.SyncToHero() return } if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() } }