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 restKindTown = "town" restKindRoadside = "roadside" ) 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, 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): unified under StateResting with a roadside flag; persisted in heroes.town_pause. // RoadsideRestActive indicates "resting on roadside" flavor inside the unified resting state. RoadsideRestActive bool RoadsideRestEndAt time.Time RoadsideRestStartedAt time.Time // wall time when this roadside session began (approach / return animation) RoadsideRestSide int // +1 / -1 perpendicular; 0 = not resting RoadsideRestNextLog time.Time // Accumulates fractional roadside regen between ticks. RoadsideRestHealRemainder 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 // 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 } // 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) 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) if hm.RoadsideRestActive { hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt) hm.RoadsideRestStartedAt = shift(hm.RoadsideRestStartedAt) 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 = 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.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 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) 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.State == model.StateResting && hm.RoadsideRestActive } func (hm *HeroMovement) endRoadsideRest() { wasActive := hm.RoadsideRestActive hm.RoadsideRestActive = false hm.RoadsideRestEndAt = time.Time{} hm.RoadsideRestStartedAt = time.Time{} hm.RoadsideRestSide = 0 hm.RoadsideRestNextLog = time.Time{} hm.RoadsideRestHealRemainder = 0 if wasActive && hm.State == model.StateResting { hm.State = model.StateWalking if hm.Hero != nil { hm.Hero.State = model.StateWalking } } if wasActive { hm.RestUntil = time.Time{} } } // EndRoadsideRest ends pull-over roadside rest (no-op if not active). func (hm *HeroMovement) EndRoadsideRest() { hm.endRoadsideRest() } // beginRoadsideRestSession starts a roadside session until endAt. Clears adventure excursion. func (hm *HeroMovement) beginRoadsideRestSession(now, endAt time.Time) { hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 hm.RoadsideRestActive = true hm.RoadsideRestEndAt = endAt hm.RoadsideRestStartedAt = now hm.RestUntil = endAt hm.State = model.StateResting if hm.Hero != nil { hm.Hero.State = model.StateResting } hm.RoadsideRestHealRemainder = 0 hm.TownRestHealRemainder = 0 if rand.Float64() < 0.5 { hm.RoadsideRestSide = 1 } else { hm.RoadsideRestSide = -1 } hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) } func (hm *HeroMovement) applyRoadsideRestHeal(dt float64) { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } cfg := tuning.Get() rawGain := float64(hm.Hero.MaxHP)*cfg.RoadsideRestHPPerS*dt + hm.RoadsideRestHealRemainder gain := int(math.Floor(rawGain)) hm.RoadsideRestHealRemainder = 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) 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 } } // 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 } cfg := tuning.Get() if hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } if float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) > cfg.LowHPThreshold { return } restMin := time.Duration(cfg.RoadsideRestMinMs) * time.Millisecond restMax := time.Duration(cfg.RoadsideRestMaxMs) * time.Millisecond spanNs := (restMax - restMin).Nanoseconds() if spanNs < 1 { spanNs = 1 } endAt := now.Add(restMin + time.Duration(rand.Int63n(spanNs+1))) hm.beginRoadsideRestSession(now, endAt) } func randomRoadsideRestThoughtDelay() time.Duration { cfg := tuning.Get() minDelay := time.Duration(cfg.RoadsideThoughtMinMs) * time.Millisecond maxDelay := time.Duration(cfg.RoadsideThoughtMaxMs) * time.Millisecond span := maxDelay - minDelay if span < 0 { span = 0 } return minDelay + 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()) } 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) { cfg := tuning.Get() if hm.adventureActive(now) { return } if rand.Float64() >= cfg.StartAdventurePerTick { return } hm.AdventureStartAt = now minDur := time.Duration(cfg.AdventureDurationMinMs) * time.Millisecond maxDur := time.Duration(cfg.AdventureDurationMaxMs) * time.Millisecond spanNs := (maxDur - minDur).Nanoseconds() if spanNs < 1 { spanNs = 1 } hm.AdventureEndAt = now.Add(minDur + 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 } cfg := tuning.Get() minDur := time.Duration(cfg.AdventureDurationMinMs) * time.Millisecond maxDur := time.Duration(cfg.AdventureDurationMaxMs) * time.Millisecond spanNs := (maxDur - minDur).Nanoseconds() if spanNs < 1 { spanNs = 1 } hm.AdventureStartAt = now hm.AdventureEndAt = now.Add(minDur + 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 hm.TownRestHealRemainder = 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 hm.TownRestHealRemainder = 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 } // AdminStartRoadsideRest forces roadside rest while walking (ignores HP). Extends duration if already resting. 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.StateWalking { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } hm.WanderingMerchantDeadline = time.Time{} cfg := tuning.Get() restMin := time.Duration(cfg.RoadsideRestMinMs) * time.Millisecond restMax := time.Duration(cfg.RoadsideRestMaxMs) * time.Millisecond spanNs := (restMax - restMin).Nanoseconds() if spanNs < 1 { spanNs = 1 } endAt := now.Add(restMin + time.Duration(rand.Int63n(spanNs+1))) if hm.roadsideRestInProgress() { hm.RoadsideRestEndAt = endAt return true } hm.beginRoadsideRestSession(now, endAt) 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 := tuning.Get().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 smoothstep01(t float64) float64 { if t <= 0 { return 0 } if t >= 1 { return 1 } return t * t * (3 - 2*t) } func roadsideRestPhaseDurations(total time.Duration) (time.Duration, time.Duration) { cfg := tuning.Get() dtIn := time.Duration(cfg.RoadsideRestGoInMs) * time.Millisecond dtOut := time.Duration(cfg.RoadsideRestReturnMs) * time.Millisecond if dtIn+dtOut > total { r := float64(total) / float64(dtIn+dtOut) dtIn = time.Duration(float64(dtIn) * r) dtOut = time.Duration(float64(dtOut) * r) } if dtIn < 0 { dtIn = 0 } if dtOut < 0 { dtOut = 0 } if dtIn+dtOut > total { dtIn = total / 2 dtOut = total - dtIn } return dtIn, dtOut } // roadsideRestDepthFactor is 0..1: 0 on road, 1 at the forest camp; animates in at session start and out before RestUntil. func (hm *HeroMovement) roadsideRestDepthFactor(now time.Time) float64 { if !hm.roadsideRestInProgress() { return 0 } t0 := hm.RoadsideRestStartedAt tEnd := hm.RoadsideRestEndAt if tEnd.IsZero() { return 0 } if !now.Before(tEnd) { return 0 } if t0.IsZero() { // Legacy blob without start time: assume already deep in the woods until the final return window. t0 = tEnd.Add(-365 * 24 * time.Hour) } total := tEnd.Sub(t0) if total <= 0 { return 1 } dtIn, dtOut := roadsideRestPhaseDurations(total) if now.Before(t0) { return 0 } if dtIn > 0 && now.Before(t0.Add(dtIn)) { e := float64(now.Sub(t0)) / float64(dtIn) return smoothstep01(e) } if dtOut > 0 && !now.Before(tEnd.Add(-dtOut)) { e := float64(tEnd.Sub(now)) / float64(dtOut) return smoothstep01(e) } return 1 } // roadsideRestAtCamp returns true only during the "actual rest" plateau (after go-in, before return). func (hm *HeroMovement) roadsideRestAtCamp(now time.Time) bool { if !hm.roadsideRestInProgress() { return false } tEnd := hm.RoadsideRestEndAt if tEnd.IsZero() || !now.Before(tEnd) { return false } // Legacy blob without start time: assume already at camp, but still reserve the final return window. if hm.RoadsideRestStartedAt.IsZero() { dtOut := time.Duration(tuning.Get().RoadsideRestReturnMs) * time.Millisecond return dtOut <= 0 || now.Before(tEnd.Add(-dtOut)) } total := tEnd.Sub(hm.RoadsideRestStartedAt) if total <= 0 { return false } dtIn, dtOut := roadsideRestPhaseDurations(total) if now.Before(hm.RoadsideRestStartedAt.Add(dtIn)) { return false } if dtOut > 0 && !now.Before(tEnd.Add(-dtOut)) { return false } return true } func roadsideRestDepthWorldUnits() float64 { cfg := tuning.Get() if cfg.RoadsideRestDepthMax > 0 { return cfg.RoadsideRestDepthMax } return cfg.RoadsideRestLateral } 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() f := hm.roadsideRestDepthFactor(now) mag := float64(hm.RoadsideRestSide) * roadsideRestDepthWorldUnits() * f 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) * tuning.Get().AdventureMaxLateral * w return px * mag, py * mag } // 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) (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 now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond { return false, model.Enemy{}, false } w := hm.wildernessFactor(now) // More encounter checks on the road; still ramps up further from the road. activity := cfg.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 := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*w*w merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus*(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.TownRestHealRemainder = 0 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()) hm.RoadsideRestActive = false 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.endRoadsideRest() 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 { 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 } // 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) hm.Hero.RestKind = "" if hm.State == model.StateResting { if hm.roadsideRestInProgress() { hm.Hero.RestKind = restKindRoadside } else { hm.Hero.RestKind = restKindTown } } hm.Hero.TownPause = hm.townPauseBlob() } func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { switch hm.State { case model.StateResting: if hm.RestUntil.IsZero() { return nil } t := hm.RestUntil p := &model.TownPausePersisted{ RestUntil: &t, TownRestHealRemainder: hm.TownRestHealRemainder, RoadsideRestHealRemainder: hm.RoadsideRestHealRemainder, } if hm.roadsideRestInProgress() { p.RestKind = restKindRoadside p.RoadsideRestActive = true end := hm.RoadsideRestEndAt p.RoadsideRestEndAt = &end p.RoadsideRestSide = hm.RoadsideRestSide if !hm.RoadsideRestStartedAt.IsZero() { ts := hm.RoadsideRestStartedAt p.RoadsideRestStartedAt = &ts } if !hm.RoadsideRestNextLog.IsZero() { tNext := hm.RoadsideRestNextLog p.RoadsideRestNextLog = &tNext } } else { p.RestKind = restKindTown } return p case model.StateInTown: p := &model.TownPausePersisted{ TownVisitNPCName: hm.TownVisitNPCName, TownVisitNPCType: hm.TownVisitNPCType, TownVisitLogsEmitted: hm.TownVisitLogsEmitted, } 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 } return p default: return nil } } func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) { blob := hero.TownPause switch hero.State { case model.StateResting: if blob != nil && blob.RestUntil != nil && !blob.RestUntil.IsZero() { hm.RestUntil = *blob.RestUntil hm.TownRestHealRemainder = blob.TownRestHealRemainder hm.RoadsideRestHealRemainder = blob.RoadsideRestHealRemainder restKind := blob.RestKind if restKind == "" && (blob.RoadsideRestActive || (blob.RoadsideRestEndAt != nil && !blob.RoadsideRestEndAt.IsZero())) { restKind = restKindRoadside } if restKind == restKindRoadside { hm.RoadsideRestActive = true if blob.RoadsideRestEndAt != nil && !blob.RoadsideRestEndAt.IsZero() { hm.RoadsideRestEndAt = *blob.RoadsideRestEndAt hm.RestUntil = hm.RoadsideRestEndAt } else { hm.RoadsideRestEndAt = hm.RestUntil } if blob.RoadsideRestSide == 0 { if rand.Float64() < 0.5 { hm.RoadsideRestSide = 1 } else { hm.RoadsideRestSide = -1 } } else { hm.RoadsideRestSide = blob.RoadsideRestSide } if blob.RoadsideRestNextLog != nil && !blob.RoadsideRestNextLog.IsZero() { hm.RoadsideRestNextLog = *blob.RoadsideRestNextLog } else { hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) } if blob.RoadsideRestStartedAt != nil && !blob.RoadsideRestStartedAt.IsZero() { hm.RoadsideRestStartedAt = *blob.RoadsideRestStartedAt } } return } // 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 } } // MovePayload builds the hero_move WS payload (includes off-road lateral offset for display). func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload { tx, ty := hm.TargetPoint() ox, oy := hm.displayOffset(now) return model.HeroMovePayload{ X: hm.CurrentX + ox, Y: hm.CurrentY + oy, TargetX: tx + ox, TargetY: ty + oy, Speed: hm.Speed, Heading: hm.Heading(), } } // RoutePayload builds the route_assigned WS payload. func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload { if hm.Road == nil { return nil } waypoints := make([]model.PointXY, len(hm.Road.Waypoints)) for i, p := range hm.Road.Waypoints { waypoints[i] = model.PointXY{X: p.X, Y: p.Y} } return &model.RouteAssignedPayload{ RoadID: hm.Road.ID, Waypoints: waypoints, DestinationTownID: hm.DestinationTownID, Speed: hm.Speed, } } // PositionSyncPayload builds the position_sync WS payload. func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload { ox, oy := hm.displayOffset(now) return model.PositionSyncPayload{ X: hm.CurrentX + ox, Y: hm.CurrentY + oy, WaypointIndex: hm.WaypointIndex, WaypointFraction: hm.WaypointFraction, State: string(hm.State), } } // randomRestDuration returns a random duration between TownRestMin and TownRestMax. func randomRestDuration() time.Duration { cfg := tuning.Get() minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond rangeMs := (maxDur - minDur).Milliseconds() if rangeMs < 0 { rangeMs = 0 } return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond } // EncounterStarter starts or resolves a random encounter while walking (engine: combat; // offline: synchronous SimulateOneFight via callback). type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time) // MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline). type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64) // AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB). type AfterTownEnterPersist func(hero *model.Hero) func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { if log == nil || hm.TownVisitStartedAt.IsZero() { return } logInterval := townNPCLogInterval() for hm.TownVisitLogsEmitted < townNPCVisitLogLines { deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * logInterval) 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 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), // and roadside rest emits occasional thoughts. // persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, graph *RoadGraph, now time.Time, sender MessageSender, onEncounter EncounterStarter, onMerchantEncounter MerchantEncounterHook, adventureLog AdventureLogWriter, persistAfterTownEnter AfterTownEnterPersist, ) { 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). dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = movementTickRate().Seconds() } hm.LastMoveTick = now if hm.roadsideRestInProgress() { if hm.roadsideRestAtCamp(now) { hm.applyRoadsideRestHeal(dt) } emitRoadsideRestThoughts(heroID, hm, now, adventureLog) } else { hm.applyTownRestHeal(dt) } // Keep Hero.TownPause (restUntil) aligned with hm for any code reading hero between ticks. 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) { if hm.roadsideRestInProgress() { hm.endRoadsideRest() hm.LastMoveTick = 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 } 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() // 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(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond) } if now.Before(hm.TownLeaveAt) { hm.SyncToHero() 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) { hm.SyncToHero() return } if rand.Float64() < cfg.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 if npc.Type == "merchant" { 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, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) } } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) } hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) } else { hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) } hm.SyncToHero() case model.StateWalking: cfg := tuning.Get() 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 } } hm.tryStartRoadsideRest(now) if hm.State == model.StateResting && hm.roadsideRestInProgress() { hm.LastMoveTick = now emitRoadsideRestThoughts(heroID, hm, now, adventureLog) if sender != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() 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() if persistAfterTownEnter != nil { persistAfterTownEnter(hm.Hero) } 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(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) 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 } }