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 // 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 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 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 } // 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 ExcursionPhase model.ExcursionPhase ExcursionStartedAt time.Time ExcursionOutUntil time.Time ExcursionWildUntil time.Time ExcursionReturnUntil time.Time ExcursionDepthWorldUnits float64 ExcursionRoadFreezeWaypoint int ExcursionRoadFreezeFraction float64 // 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 InTownNPCWalkStart time.Time InTownNPCWalkArrive time.Time InTownPlazaHeal bool InTownCenterWalkStart time.Time InTownCenterWalkArrive time.Time InTownNPCQueueLen int InTownNPCQueueFP uint64 InTownVisitName string InTownVisitType string } 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.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) 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.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.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 ends an active excursion immediately (hero back on the road spine). // Works during walking phases or adventure-inline rest; rejects combat. func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool { if !hm.Excursion.Active() { 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.endExcursion(now) hm.refreshSpeed(now) return true } // 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{} 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) displayOffset(now time.Time) (float64, float64) { 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 := PickEnemyForLevel(hm.Hero.Level) return true, e, true } return false, model.Enemy{}, true } // EnterTown transitions the hero into the destination town: NPC tour (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 hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} hm.TownRestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.clearNPCWalk() hm.clearTownCenterWalk() hm.TownPlazaHealActive = false 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 } q := make([]int64, len(ids)) copy(q, ids) rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] }) hm.TownNPCQueue = q hm.State = model.StateInTown hm.Hero.State = model.StateInTown hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay()) } // LeaveTown transitions the hero from town to walking, picking a new destination. func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} hm.TownRestHealRemainder = 0 hm.RestUntil = time.Time{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.clearNPCWalk() 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.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.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 if hm.Excursion.Active() { 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.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 } 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.InTownNPCWalkStart = hm.TownNPCWalkStart sig.InTownNPCWalkArrive = hm.TownNPCWalkArrive sig.InTownPlazaHeal = hm.TownPlazaHealActive sig.InTownCenterWalkStart = hm.TownCenterWalkStart sig.InTownCenterWalkArrive = hm.TownCenterWalkArrive sig.InTownNPCQueueLen = len(hm.TownNPCQueue) sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue) sig.InTownVisitName = hm.TownVisitNPCName sig.InTownVisitType = hm.TownVisitNPCType } 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, NPCWalkFromX: hm.TownNPCWalkFromX, NPCWalkFromY: hm.TownNPCWalkFromY, NPCWalkToX: hm.TownNPCWalkToX, NPCWalkToY: hm.TownNPCWalkToY, } if !hm.TownNPCWalkStart.IsZero() { t := hm.TownNPCWalkStart p.NPCWalkStart = &t } if !hm.TownNPCWalkArrive.IsZero() { t := hm.TownNPCWalkArrive p.NPCWalkArrive = &t } if len(hm.TownNPCQueue) > 0 { p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...) } 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.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 } } // 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{ Phase: string(s.Phase), DepthWorldUnits: s.DepthWorldUnits, RoadFreezeWaypoint: s.RoadFreezeWaypoint, RoadFreezeFraction: s.RoadFreezeFraction, } 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 } 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 } 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 } } // 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) { 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 } // 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) // TownNPCOfflineInteractHook runs when the hero reaches a town NPC with no WS client (offline catch-up). // Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping. type TownNPCOfflineInteractHook func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) bool func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { if log == nil || hm.TownVisitStartedAt.IsZero() { 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 hm.refreshSpeed(now) speed := hm.Speed if speed < 0.1 { speed = 0.1 } 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)) hm.Excursion = model.ExcursionSession{ Phase: model.ExcursionOut, StartedAt: now, OutUntil: outEnd, WildUntil: wildEnd, ReturnUntil: wildEnd.Add(returnDur), DepthWorldUnits: depth, RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, } } // advanceExcursionPhases progresses through out->wild->return and returns true when complete. func (hm *HeroMovement) advanceExcursionPhases(now time.Time) (ended bool) { exc := &hm.Excursion if exc.Phase == model.ExcursionOut && !now.Before(exc.OutUntil) { exc.Phase = model.ExcursionWild } if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) { exc.Phase = model.ExcursionReturn // Only recalculate return duration if we haven't already passed the original deadline // (handles large time jumps from offline catch-up or timer-based exits). if now.Before(exc.ReturnUntil) { speed := hm.Speed if speed < 0.1 { speed = 0.1 } exc.WildUntil = now exc.ReturnUntil = now.Add(time.Duration(exc.DepthWorldUnits / speed * float64(time.Second))) } } if exc.Phase == model.ExcursionReturn && !now.Before(exc.ReturnUntil) { return true } return false } func (hm *HeroMovement) endExcursion(now time.Time) { 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 } 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{ Phase: model.ExcursionOut, StartedAt: now, OutUntil: outUntil, WildUntil: wildUntil, ReturnUntil: returnUntil, DepthWorldUnits: depth, RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, } // RestUntil tracks only the rest (wild) phase; travel out/return is separate. hm.RestUntil = wildUntil 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 := PickEnemyForLevel(hm.Hero.Level) 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. // townNPCOfflineInteract, when sender is nil, decides offline buy/heal/quest vs walking past; nil uses legacy auto-sell-only behavior. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time, sender MessageSender, onEncounter EncounterStarter, onMerchantEncounter MerchantEncounterHook, adventureLog AdventureLogWriter, persistAfterTownEnter AfterTownEnterPersist, townNPCOfflineInteract TownNPCOfflineInteractHook, ) { 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: // For roadside rest, ensure Wild→Return always gets a fresh return // deadline so the hero walks back to the road smoothly (prevents // advanceExcursionPhases from skipping the return phase on time jumps). if hm.Excursion.Phase == model.ExcursionWild && !now.Before(hm.Excursion.WildUntil) { hm.Excursion.Phase = model.ExcursionReturn speed := hm.Speed if speed < 0.1 { speed = 0.1 } hm.Excursion.WildUntil = now hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second))) } excursionEnded := hm.advanceExcursionPhases(now) if hm.Excursion.Phase == 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) } } } 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.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) 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{}) } } 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: cfg := tuning.Get() dtTown := now.Sub(hm.LastMoveTick).Seconds() if dtTown <= 0 { dtTown = movementTickRate().Seconds() } 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 hm.clearTownCenterWalk() if sender != nil { sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, TargetX: hm.CurrentX, TargetY: hm.CurrentY, 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, }) } } hm.SyncToHero() return } // --- Sub-state: hero is walking toward an NPC inside the town --- 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 npcID := hm.TownNPCWalkTargetID standX := hm.TownNPCWalkToX standY := hm.TownNPCWalkToY hm.clearNPCWalk() if npc, ok := graph.NPCByID[npcID]; ok { fullVisit := false townNameKey := "" if tt := graph.Towns[hm.CurrentTownID]; tt != nil { townNameKey = tt.NameKey } if sender != nil { sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ NPCID: npc.ID, Name: npc.Name, NameKey: npc.NameKey, Type: npc.Type, TownID: hm.CurrentTownID, TownNameKey: townNameKey, WorldX: standX, WorldY: standY, }) sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, TargetX: hm.CurrentX, TargetY: hm.CurrentY, Speed: 0, Heading: 0, }) fullVisit = true } else if townNPCOfflineInteract != nil { fullVisit = townNPCOfflineInteract(heroID, hm, graph, npc, now, adventureLog) } else { fullVisit = true } if fullVisit { hm.TownVisitNPCName = npc.Name hm.TownVisitNPCKey = npc.NameKey hm.TownVisitNPCType = npc.Type hm.TownVisitStartedAt = now hm.TownVisitLogsEmitted = 0 legacyMerchantSell := npc.Type == "merchant" && (sender != nil || townNPCOfflineInteract == nil) if legacyMerchantSell { share := cfg.MerchantTownAutoSellShare if share <= 0 || share > 1 { share = tuning.DefaultValues().MerchantTownAutoSellShare } soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil) if soldItems > 0 && adventureLog != nil { adventureLog(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseSoldItemsMerchant, Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold}, }, }) } } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) } else { if adventureLog != nil { adventureLog(heroID, model.AdventureLogLine{ Event: &model.AdventureLogEvent{ Code: model.LogPhraseNPCSkippedVisit, Args: map[string]any{"npcKey": npc.NameKey}, }, }) } hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) } } 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, }) } } hm.SyncToHero() return } // NPC visit pause ended: clear visit log state before the next roll. if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { hm.TownVisitNPCName = "" hm.TownVisitNPCKey = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 { town := graph.Towns[hm.CurrentTownID] if town == 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 } cx, cy := town.WorldX, town.WorldY const plazaEps = 0.55 dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy) 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) if sender != nil { heading := math.Atan2(dy, dx) sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, TargetX: cx, TargetY: cy, Speed: walkSpeed, Heading: heading, }) } hm.SyncToHero() return } if hm.TownLeaveAt.IsZero() { restCh := cfg.TownAfterNPCRestChance if restCh <= 0 { restCh = tuning.DefaultValues().TownAfterNPCRestChance } if restCh > 1 { restCh = 1 } if rand.Float64() < restCh { hm.TownPlazaHealActive = true hm.TownLeaveAt = now.Add(randomRestDuration()) } else { hm.TownPlazaHealActive = false hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond) } } if hm.TownPlazaHealActive { hm.applyTownRestHeal(dtTown) } if now.Before(hm.TownLeaveAt) { if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() return } hm.TownLeaveAt = time.Time{} hm.TownPlazaHealActive = false 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 } if now.Before(hm.NextTownNPCRollAt) { hm.SyncToHero() return } if rand.Float64() >= cfg.TownNPCVisitChance { hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) hm.SyncToHero() return } approachCh := cfg.TownNPCApproachChance if approachCh <= 0 { approachCh = tuning.DefaultValues().TownNPCApproachChance } if approachCh > 1 { approachCh = 1 } if rand.Float64() >= approachCh { hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) hm.SyncToHero() return } npcID := hm.TownNPCQueue[0] hm.TownNPCQueue = hm.TownNPCQueue[1:] if npc, ok := graph.NPCByID[npcID]; ok { npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID) if !posOk { if town := graph.Towns[hm.CurrentTownID]; town != nil { npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY } } standoff := cfg.TownNPCStandoffWorld if standoff <= 0 { standoff = tuning.DefaultValues().TownNPCStandoffWorld } 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{ X: hm.CurrentX, Y: hm.CurrentY, TargetX: toX, TargetY: toY, Speed: walkSpeed, Heading: heading, }) } } hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) hm.SyncToHero() 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 excursion (mini-adventure) --- if hm.Excursion.Active() { 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{}) } } else { if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil { sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)}) } 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)) } return } canEncounter := hm.Excursion.Phase == model.ExcursionWild || (hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled) if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) { monster, enemy, hit := hm.rollAdventureEncounter(now, graph) if hit { if monster && onEncounter != nil { hm.LastEncounterAt = now onEncounter(hm, &enemy, 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) } return } } } 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() } }