package game import ( "encoding/json" "math" "math/rand" "os" "path/filepath" "time" "github.com/denisovdennis/autohero/internal/model" ) // #region agent log func agentDebugLog(hypothesisID, location, message string, data map[string]any) { wd, err := os.Getwd() if err != nil { return } logPath := filepath.Join(wd, "debug-cbb64d.log") if filepath.Base(wd) == "backend" { logPath = filepath.Join(wd, "..", "debug-cbb64d.log") } payload := map[string]any{ "sessionId": "cbb64d", "hypothesisId": hypothesisID, "location": location, "message": message, "data": data, "timestamp": time.Now().UnixMilli(), } b, err := json.Marshal(payload) if err != nil { return } f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return } _, _ = f.Write(append(b, '\n')) _ = f.Close() } // #endregion 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 random encounters. EncounterCooldownBase = 15 * time.Second // EncounterChancePerTick is the probability of an encounter on each movement tick. EncounterChancePerTick = 0.04 // 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 } // 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) hm.State = model.StateWalking return hm } // pickDestination selects the next town the hero should walk toward. func (hm *HeroMovement) pickDestination(graph *RoadGraph) { if hm.CurrentTownID == 0 { // Hero is not associated with any town yet, pick nearest. hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } idx := graph.TownOrderIndex(hm.CurrentTownID) if idx < 0 { // Fallback. if len(graph.TownOrder) > 0 { hm.DestinationTownID = graph.TownOrder[0] } return } n := len(graph.TownOrder) if n <= 1 { hm.DestinationTownID = hm.CurrentTownID return } nextIdx := idx + hm.Direction if nextIdx >= n { nextIdx = 0 } if nextIdx < 0 { nextIdx = n - 1 } hm.DestinationTownID = graph.TownOrder[nextIdx] } // 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 { // #region agent log agentDebugLog("H5", "movement.go:assignRoad", "no road after nearest retry", map[string]any{ "currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID, "x": hm.CurrentX, "y": hm.CurrentY, }) // #endregion // 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 } // ShouldEncounter rolls for a random encounter, respecting the cooldown. func (hm *HeroMovement) ShouldEncounter(now time.Time) bool { if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase { return false } return rand.Float64() < EncounterChancePerTick } // 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{} 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. func (hm *HeroMovement) MovePayload() model.HeroMovePayload { tx, ty := hm.TargetPoint() return model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, TargetX: tx, TargetY: ty, 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() model.PositionSyncPayload { return model.PositionSyncPayload{ X: hm.CurrentX, Y: hm.CurrentY, 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) // 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, ) { 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: // #region agent log if hm.Road == nil { agentDebugLog("H1", "movement.go:StateWalking", "walking with nil Road", map[string]any{ "heroID": heroID, "currentTownID": hm.CurrentTownID, "destinationTownID": hm.DestinationTownID, "x": hm.CurrentX, "y": hm.CurrentY, }) } else if len(hm.Road.Waypoints) < 2 { agentDebugLog("H2", "movement.go:StateWalking", "road has fewer than 2 waypoints", map[string]any{ "heroID": heroID, "roadID": hm.Road.ID, "waypointCount": len(hm.Road.Waypoints), }) } // #endregion 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 } if onEncounter != nil && hm.ShouldEncounter(now) { // #region agent log agentDebugLog("H3", "movement.go:encounter", "encounter starting", map[string]any{ "heroID": heroID, "roadNil": hm.Road == nil, "waypointCount": func() int { if hm.Road == nil { return -1 } return len(hm.Road.Waypoints) }(), "x": hm.CurrentX, "y": hm.CurrentY, "currentTownID": hm.CurrentTownID, }) // #endregion enemy := PickEnemyForLevel(hm.Hero.Level) hm.LastEncounterAt = now onEncounter(hm, &enemy, now) return } if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload()) } hm.Hero.PositionX = hm.CurrentX hm.Hero.PositionY = hm.CurrentY } }