package game import ( "fmt" "math" "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" ) const ( // BaseMoveSpeed is the hero's base movement speed in world-units per second. BaseMoveSpeed = 2.0 // MovementTickRate is how often the movement system updates (2 Hz). MovementTickRate = 500 * time.Millisecond // PositionSyncRate is how often the server sends a full position_sync (drift correction). PositionSyncRate = 10 * time.Second // EncounterCooldownBase is the minimum gap between road encounters (monster or merchant). EncounterCooldownBase = 12 * time.Second // WanderingMerchantPromptTimeout is how long the hero stays stopped for the wandering merchant dialog (online). WanderingMerchantPromptTimeout = 15 * time.Second // EncounterActivityBase scales per-tick chance to roll an encounter after cooldown. // Effective activity is higher deep off-road (see rollRoadEncounter). EncounterActivityBase = 0.035 // StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion. StartAdventurePerTick = 0.000030 // AdventureDurationMin/Max bound how long an off-road excursion lasts. AdventureDurationMin = 15 * time.Minute AdventureDurationMax = 20 * time.Minute // AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness. AdventureMaxLateral = 20.0 // AdventureWildernessRampFraction is the share of excursion time spent easing off the road at the start // and easing back at the end. The middle (1 - 2*ramp) stays at full lateral offset so the hero // visibly walks beside the road for most of a long excursion. AdventureWildernessRampFraction = 0.12 // LowHPThreshold: below this HP fraction (of MaxHP) the hero seeks a short roadside rest. LowHPThreshold = 0.35 // RoadsideRestExitHP: leave roadside rest when HP reaches this fraction of MaxHP (or max duration). RoadsideRestExitHP = 0.92 // RoadsideRestDurationMin/Max cap how long a roadside rest can last (hero may leave earlier if healed). RoadsideRestDurationMin = 40 * time.Second RoadsideRestDurationMax = 100 * time.Second // RoadsideRestLateral is perpendicular offset from the road while resting (smaller than adventure). RoadsideRestLateral = 1.15 // RoadsideRestHPPerSecond is MaxHP fraction restored per second while roadside resting (0.1%). RoadsideRestHPPerSecond = 0.001 // RoadsideRestThoughtMinInterval / MaxInterval between adventure log lines while resting. RoadsideRestThoughtMinInterval = 4 * time.Second RoadsideRestThoughtMaxInterval = 11 * time.Second // TownRestMin is the minimum rest duration when arriving at a town. TownRestMin = 5 * 60 * time.Second // TownRestMax is the maximum rest duration when arriving at a town. TownRestMax = 20 * 60 * time.Second // TownArrivalRadius is how close the hero must be to the final waypoint // to be considered "arrived" at the town. TownArrivalRadius = 0.5 // Town NPC visits: high chance each attempt to approach the next NPC; queue clears on LeaveTown. townNPCVisitChance = 0.78 townNPCRollMin = 800 * time.Millisecond townNPCRollMax = 2600 * time.Millisecond townNPCRetryAfterMiss = 450 * time.Millisecond // TownNPCVisitTownPause is how long the hero stays in town after the last NPC (whole town) before leaving. TownNPCVisitTownPause = 30 * time.Second // TownNPCVisitLogInterval is how often a line is written to the adventure log during a visit. TownNPCVisitLogInterval = 5 * time.Second // townNPCVisitLogLines is how many log lines to emit per NPC (every TownNPCVisitLogInterval). townNPCVisitLogLines = 6 ) // TownNPCVisitNarrationBlock is the minimum gap before visiting the next town NPC (first line through last line). var TownNPCVisitNarrationBlock = TownNPCVisitLogInterval * (townNPCVisitLogLines - 1) // AdventureLogWriter persists or pushes one adventure log line for a hero (optional). type AdventureLogWriter func(heroID int64, message string) // 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 TownVisitNPCType string TownVisitStartedAt time.Time TownVisitLogsEmitted int // TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause). TownLeaveAt time.Time // Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends. AdventureStartAt time.Time AdventureEndAt time.Time AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring // Roadside rest (low HP): step off the road and recover HP; not persisted. RoadsideRestEndAt time.Time RoadsideRestSide int // +1 / -1 perpendicular; 0 = not resting RoadsideRestNextLog time.Time // WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only). WanderingMerchantDeadline 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 } // 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 } // If resting/in_town, set a short rest timer so they leave soon. if hero.State == model.StateResting || hero.State == model.StateInTown { hm.State = model.StateResting hm.RestUntil = now.Add(randomRestDuration()) return hm } // Walking state: assign a road if we don't have a destination. if hm.DestinationTownID == 0 { hm.pickDestination(graph) } hm.assignRoad(graph) if hm.Road == nil { hm.pickDestination(graph) hm.assignRoad(graph) } hm.State = model.StateWalking 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 } // pickDestination selects the next town the hero should walk toward. // Only towns connected by a roads row are chosen — TownOrder alone is not enough. func (hm *HeroMovement) pickDestination(graph *RoadGraph) { if hm.CurrentTownID == 0 { 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 } 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. func (hm *HeroMovement) assignRoad(graph *RoadGraph) { 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 { // 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.AdventureStartAt = shift(hm.AdventureStartAt) hm.AdventureEndAt = shift(hm.AdventureEndAt) hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt) hm.RoadsideRestNextLog = shift(hm.RoadsideRestNextLog) hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) 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 = 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.Road == nil || len(hm.Road.Waypoints) < 2 { 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 if distThisTick >= remaining { // 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) adventureActive(now time.Time) bool { return !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt) } func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) { if hm.AdventureEndAt.IsZero() { return } if now.Before(hm.AdventureEndAt) { return } hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 } func (hm *HeroMovement) roadsideRestInProgress() bool { return !hm.RoadsideRestEndAt.IsZero() } func (hm *HeroMovement) endRoadsideRest() { hm.RoadsideRestEndAt = time.Time{} hm.RoadsideRestSide = 0 hm.RoadsideRestNextLog = time.Time{} } func (hm *HeroMovement) applyRoadsideRestHeal(dt float64) { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } gain := int(math.Ceil(float64(hm.Hero.MaxHP) * RoadsideRestHPPerSecond * dt)) if gain < 1 { gain = 1 } hm.Hero.HP += gain if hm.Hero.HP > hm.Hero.MaxHP { hm.Hero.HP = hm.Hero.MaxHP } } // tryStartRoadsideRest pulls the hero off the road when HP is low; cancels an active adventure. func (hm *HeroMovement) tryStartRoadsideRest(now time.Time) { if hm.roadsideRestInProgress() { return } if hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } if float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) > LowHPThreshold { return } hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 spanNs := (RoadsideRestDurationMax - RoadsideRestDurationMin).Nanoseconds() if spanNs < 1 { spanNs = 1 } hm.RoadsideRestEndAt = now.Add(RoadsideRestDurationMin + time.Duration(rand.Int63n(spanNs+1))) if rand.Float64() < 0.5 { hm.RoadsideRestSide = 1 } else { hm.RoadsideRestSide = -1 } hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) } func randomRoadsideRestThoughtDelay() time.Duration { span := RoadsideRestThoughtMaxInterval - RoadsideRestThoughtMinInterval if span < 0 { span = 0 } return RoadsideRestThoughtMinInterval + time.Duration(rand.Int63n(int64(span)+1)) } // emitRoadsideRestThoughts appends occasional journal lines while the hero rests off the road. func emitRoadsideRestThoughts(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { if log == nil || !hm.roadsideRestInProgress() { return } if hm.RoadsideRestNextLog.IsZero() { hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) return } if now.Before(hm.RoadsideRestNextLog) { return } log(heroID, randomRoadsideRestThought()) hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) } // tryStartAdventure begins a timed off-road excursion with small probability. func (hm *HeroMovement) tryStartAdventure(now time.Time) { if hm.adventureActive(now) { return } if rand.Float64() >= StartAdventurePerTick { return } hm.AdventureStartAt = now spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds() if spanNs < 1 { spanNs = 1 } hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1))) if rand.Float64() < 0.5 { hm.AdventureSide = 1 } else { hm.AdventureSide = -1 } } // StartAdventureForced starts an off-road adventure immediately (admin). func (hm *HeroMovement) StartAdventureForced(now time.Time) bool { if hm.Hero == nil || hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { return false } if hm.State != model.StateWalking { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } if hm.adventureActive(now) { return true } spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds() if spanNs < 1 { spanNs = 1 } hm.AdventureStartAt = now hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1))) if rand.Float64() < 0.5 { hm.AdventureSide = 1 } else { hm.AdventureSide = -1 } return true } // 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.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 hm.endRoadsideRest() hm.WanderingMerchantDeadline = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 t := graph.Towns[townID] hm.CurrentX = t.WorldX hm.CurrentY = t.WorldY hm.EnterTown(now, graph) return nil } // 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.endRoadsideRest() hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 hm.WanderingMerchantDeadline = time.Time{} hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 if graph != nil && hm.CurrentTownID == 0 { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } hm.State = model.StateResting hm.Hero.State = model.StateResting hm.RestUntil = now.Add(randomRestDuration()) return true } // wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back. // (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.) func (hm *HeroMovement) wildernessFactor(now time.Time) float64 { if !hm.adventureActive(now) { return 0 } total := hm.AdventureEndAt.Sub(hm.AdventureStartAt).Seconds() if total <= 0 { return 0 } elapsed := now.Sub(hm.AdventureStartAt).Seconds() p := elapsed / total if p < 0 { p = 0 } else if p > 1 { p = 1 } r := AdventureWildernessRampFraction if r < 1e-6 { r = 1e-6 } if r > 0.49 { r = 0.49 } if p < r { return p / r } if p > 1-r { return (1 - p) / r } return 1 } 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 } func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { if hm.roadsideRestInProgress() { if hm.RoadsideRestSide == 0 { return 0, 0 } px, py := hm.roadPerpendicularUnit() mag := float64(hm.RoadsideRestSide) * RoadsideRestLateral return px * mag, py * mag } w := hm.wildernessFactor(now) if w <= 0 || hm.AdventureSide == 0 { return 0, 0 } px, py := hm.roadPerpendicularUnit() mag := float64(hm.AdventureSide) * AdventureMaxLateral * w return px * mag, py * mag } // WanderingMerchantCost matches REST encounter / npc alms pricing. func WanderingMerchantCost(level int) int64 { return int64(20 + level*5) } // rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat. func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false, model.Enemy{}, false } if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase { return false, model.Enemy{}, false } w := hm.wildernessFactor(now) // More encounter checks on the road; still ramps up further from the road. activity := EncounterActivityBase * (0.62 + 0.38*w) if rand.Float64() >= activity { return false, model.Enemy{}, false } // On the road (w=0): mostly monsters, merchants occasional. Deep off-road: almost only monsters. monsterW := 0.62 + 0.18*w*w merchantW := 0.04 + 0.10*(1-w)*(1-w) 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.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 hm.endRoadsideRest() ids := graph.TownNPCIDs(destID) if len(ids) == 0 { hm.State = model.StateResting hm.Hero.State = model.StateResting 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.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) hm.assignRoad(graph) hm.refreshSpeed(now) } func randomTownNPCDelay() time.Duration { rangeMs := (townNPCRollMax - townNPCRollMin).Milliseconds() return townNPCRollMin + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond } // StartFighting pauses movement for combat. func (hm *HeroMovement) StartFighting() { hm.State = model.StateFighting hm.endRoadsideRest() 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 hm.endRoadsideRest() } // SyncToHero writes movement state back to the hero model for persistence. func (hm *HeroMovement) SyncToHero() { hm.Hero.PositionX = hm.CurrentX hm.Hero.PositionY = hm.CurrentY 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) } // 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 { rangeMs := (TownRestMax - TownRestMin).Milliseconds() return TownRestMin + 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) func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { if log == nil || hm.TownVisitStartedAt.IsZero() { return } for hm.TownVisitLogsEmitted < townNPCVisitLogLines { deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * TownNPCVisitLogInterval) if now.Before(deadline) { break } msg := townNPCVisitLogMessage(hm.TownVisitNPCType, hm.TownVisitNPCName, hm.TownVisitLogsEmitted) if msg != "" { log(heroID, msg) } hm.TownVisitLogsEmitted++ } } func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string { if lineIndex < 0 || lineIndex >= townNPCVisitLogLines { return "" } switch npcType { case "merchant": switch lineIndex { case 0: return fmt.Sprintf("You stop at %s's stall.", npcName) case 1: return "Crates, pouches, and price tags blur together as you browse." case 2: return fmt.Sprintf("%s points out a few curious trinkets.", npcName) case 3: return "You weigh a potion against your coin purse." case 4: return "A short haggle ends in a reluctant nod." case 5: return fmt.Sprintf("You thank %s and step back from the counter.", npcName) } case "healer": switch lineIndex { case 0: return fmt.Sprintf("You seek out %s.", npcName) case 1: return "The healer examines your wounds with a calm eye." case 2: return "Herbs steam gently; bandages are laid out in neat rows." case 3: return "A slow warmth spreads as salves are applied." case 4: return "You rest a moment on a bench, breathing easier." case 5: return fmt.Sprintf("You nod to %s and return to the street.", npcName) } case "quest_giver": switch lineIndex { case 0: return fmt.Sprintf("You speak with %s about the road ahead.", npcName) case 1: return "Rumors of trouble and slim rewards fill the air." case 2: return "A worn map is smoothed flat between you." case 3: return "You mark targets and deadlines in your mind." case 4: return fmt.Sprintf("%s hints at better pay for the bold.", npcName) case 5: return "You part with a clearer picture of what must be done." } default: switch lineIndex { case 0: return fmt.Sprintf("You spend time with %s.", npcName) case 1: return "Conversation drifts from weather to the wider world." case 2: return "A few practical details stick in your memory." case 3: return "You listen more than you speak." case 4: return "Promises and coin change hands—or almost do." case 5: return fmt.Sprintf("You say farewell to %s.", npcName) } } return "" } // ProcessSingleHeroMovementTick applies one movement-system step as of logical time now. // It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate // 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), // and roadside rest emits occasional thoughts. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time, sender MessageSender, onEncounter EncounterStarter, onMerchantEncounter MerchantEncounterHook, adventureLog AdventureLogWriter, ) { if graph == nil { return } switch hm.State { case model.StateFighting, model.StateDead: return case model.StateResting: // Advance logical movement time while idle so leaving town does not apply a huge dt (teleport). hm.LastMoveTick = 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: // Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time. hm.LastMoveTick = now // NPC visit pause ended: clear visit log state before the next roll. if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) if len(hm.TownNPCQueue) == 0 { if hm.TownLeaveAt.IsZero() { hm.TownLeaveAt = now.Add(TownNPCVisitTownPause) } if now.Before(hm.TownLeaveAt) { return } hm.TownLeaveAt = time.Time{} 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) { return } if rand.Float64() < townNPCVisitChance { npcID := hm.TownNPCQueue[0] hm.TownNPCQueue = hm.TownNPCQueue[1:] if npc, ok := graph.NPCByID[npcID]; ok { if sender != nil { sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, }) } hm.TownVisitNPCName = npc.Name hm.TownVisitNPCType = npc.Type hm.TownVisitStartedAt = now hm.TownVisitLogsEmitted = 0 emitTownNPCVisitLogs(heroID, hm, now, adventureLog) } hm.NextTownNPCRollAt = now.Add(TownNPCVisitNarrationBlock) } else { hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss) } case model.StateWalking: hm.expireAdventureIfNeeded(now) if hm.Road == nil || len(hm.Road.Waypoints) < 2 { hm.Road = nil hm.pickDestination(graph) hm.assignRoad(graph) } // 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.Hero.PositionX = hm.CurrentX hm.Hero.PositionY = hm.CurrentY } return } } if hm.roadsideRestInProgress() { dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = MovementTickRate.Seconds() } hm.LastMoveTick = now hm.applyRoadsideRestHeal(dt) emitRoadsideRestThoughts(heroID, hm, now, adventureLog) timeUp := !now.Before(hm.RoadsideRestEndAt) hpOk := hm.Hero != nil && hm.Hero.MaxHP > 0 && float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) >= RoadsideRestExitHP if timeUp || hpOk { hm.endRoadsideRest() } else { if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.Hero.PositionX = hm.CurrentX hm.Hero.PositionY = hm.CurrentY return } } hm.tryStartRoadsideRest(now) if hm.roadsideRestInProgress() { hm.LastMoveTick = now emitRoadsideRestThoughts(heroID, hm, now, adventureLog) if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.Hero.PositionX = hm.CurrentX hm.Hero.PositionY = hm.CurrentY return } hm.tryStartAdventure(now) reachedTown := hm.AdvanceTick(now, graph) if reachedTown { hm.EnterTown(now, graph) if sender != nil { town := graph.Towns[hm.CurrentTownID] if town != nil { npcInfos := make([]model.TownNPCInfo, 0, len(graph.TownNPCs[hm.CurrentTownID])) for _, n := range graph.TownNPCs[hm.CurrentTownID] { npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type}) } 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, Biome: town.Biome, NPCs: npcInfos, RestDurationMs: restMs, }) } } hm.SyncToHero() return } canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2 if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) { monster, enemy, hit := hm.rollRoadEncounter(now) if hit { if monster { if onEncounter != nil { hm.LastEncounterAt = now onEncounter(hm, &enemy, now) return } // No monster handler — skip consuming the roll (extremely rare). } else { cost := WanderingMerchantCost(hm.Hero.Level) if sender != nil || onMerchantEncounter != nil { hm.LastEncounterAt = now if sender != nil { hm.WanderingMerchantDeadline = now.Add(WanderingMerchantPromptTimeout) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost, }) } if onMerchantEncounter != nil { onMerchantEncounter(hm, now, cost) } return } } } } if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.Hero.PositionX = hm.CurrentX hm.Hero.PositionY = hm.CurrentY } }