package game import ( "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 // 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.0004 // 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 = 3.5 // 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 ) // 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 // 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 } // 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 } // Add per-hero position offset so heroes on the same road don't overlap. // Use hero ID to create a stable lateral offset of ±1.5 tiles. lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5 hm := &HeroMovement{ HeroID: hero.ID, Hero: hero, CurrentX: hero.PositionX + lateralOffset*0.3, CurrentY: hero.PositionY + lateralOffset*0.7, State: hero.State, LastMoveTick: now, Direction: dir, } // 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 hm.WaypointIndex = 0 hm.WaypointFraction = 0 // Position the hero at the start of the road if they're very close to the origin town. if len(jitteredWaypoints) > 0 { start := jitteredWaypoints[0] dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y) if dist < 5.0 { hm.CurrentX = start.X hm.CurrentY = start.Y } } } // 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 } // 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 } } // wildernessFactor is 0 on the road, then 0→1→0 over the excursion (triangle: out, then back). 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 } if p < 0.5 { return p * 2 } return (1 - p) * 2 } 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) { 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) activity := EncounterActivityBase * (0.45 + 0.55*w) if rand.Float64() >= activity { return false, model.Enemy{}, false } monsterW := 0.08 + 0.92*w*w merchantW := 0.08 + 0.92*(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.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 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.State = model.StateWalking hm.Hero.State = model.StateWalking 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 } // 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 } // 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) // 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. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time, sender MessageSender, onEncounter EncounterStarter, onMerchantEncounter MerchantEncounterHook, ) { if graph == nil { return } switch hm.State { case model.StateFighting, model.StateDead: return case model.StateResting: if now.After(hm.RestUntil) { hm.LeaveTown(graph, now) hm.SyncToHero() if sender != nil { sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } } case model.StateInTown: if len(hm.TownNPCQueue) == 0 { 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 && sender != nil { sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, }) } hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay()) } 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) } 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 { 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 } }