package game import ( "fmt" "math" "math/rand" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) const ( // townNPCVisitLogLines is how many log lines to emit per NPC visit. townNPCVisitLogLines = 6 ) func movementTickRate() time.Duration { ms := tuning.Get().MovementTickRateMs if ms <= 0 { ms = tuning.DefaultValues().MovementTickRateMs } return time.Duration(ms) * time.Millisecond } func positionSyncRate() time.Duration { ms := tuning.Get().PositionSyncRateMs if ms <= 0 { ms = tuning.DefaultValues().PositionSyncRateMs } return time.Duration(ms) * time.Millisecond } func townNPCLogInterval() time.Duration { ms := tuning.Get().TownNPCLogIntervalMs if ms <= 0 { ms = tuning.DefaultValues().TownNPCLogIntervalMs } return time.Duration(ms) * time.Millisecond } // AdventureLogWriter persists or pushes one adventure log line for a hero (optional). type AdventureLogWriter func(heroID int64, 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 // Accumulates fractional town-rest regen between ticks. TownRestHealRemainder float64 // WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only). WanderingMerchantDeadline time.Time // Excursion holds the live mini-adventure session state. // When Excursion.Phase == ExcursionNone the hero is on the road (normal walk / town / roadside rest). Excursion model.ExcursionSession // ActiveRestKind discriminates the current rest context when State == StateResting. ActiveRestKind model.RestKind // RestHealRemainder accumulates fractional HP between ticks for roadside / adventure-inline rest. RestHealRemainder float64 // LastExcursionEndedAt is used for adventure cooldown (not persisted; resets on reconnect). LastExcursionEndedAt time.Time // spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad // instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0. spawnAtRoadStart bool // lastTownPausePersistSignature tracks the last persisted excursion/rest snapshot so we can // persist only on meaningful changes (start/end/phase change). lastTownPausePersistSignature townPausePersistSignature } // townPausePersistSignature captures the excursion/rest fields that should trigger persistence. // Keep this small to avoid persisting every tick due to healing remainders. type townPausePersistSignature struct { RestKind model.RestKind RestUntil time.Time ExcursionPhase model.ExcursionPhase ExcursionStartedAt time.Time ExcursionOutUntil time.Time ExcursionWildUntil time.Time ExcursionReturnUntil time.Time ExcursionDepthWorldUnits float64 ExcursionRoadFreezeWaypoint int ExcursionRoadFreezeFraction float64 } // NewHeroMovement creates a HeroMovement for a hero that just connected. // It initializes position, state, and picks the first destination if needed. func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMovement { // Randomize direction per hero so they don't all walk the same way. dir := 1 if hero.ID%2 == 0 { dir = -1 } // Persisted (x,y) already include any in-world offset from prior sessions; do not add // lateral jitter again on reconnect (that doubled the shift every reload). freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0 var curX, curY float64 if freshWorldSpawn { curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road } else { curX = hero.PositionX curY = hero.PositionY } hm := &HeroMovement{ HeroID: hero.ID, Hero: hero, CurrentX: curX, CurrentY: curY, State: hero.State, LastMoveTick: now, Direction: dir, spawnAtRoadStart: freshWorldSpawn, } // Restore persisted movement state. if hero.CurrentTownID != nil { hm.CurrentTownID = *hero.CurrentTownID } if hero.DestinationTownID != nil { hm.DestinationTownID = *hero.DestinationTownID } hm.refreshSpeed(now) // If the hero is dead, keep them dead. if hero.State == model.StateDead || hero.HP <= 0 { hm.State = model.StateDead return hm } // If fighting, leave as-is (engine combat system manages it). if hero.State == model.StateFighting { return hm } // Resting / in-town: restore persisted deadlines and NPC tour from DB (town_pause). if hero.State == model.StateResting || hero.State == model.StateInTown { hm.State = hero.State hm.applyTownPauseFromHero(hero, now) // For roadside / adventure-inline rest the hero needs a road for display offset calculations. if hm.ActiveRestKind == model.RestKindRoadside || hm.ActiveRestKind == model.RestKindAdventureInline { if hm.DestinationTownID == 0 { hm.pickDestination(graph) } hm.assignRoad(graph) if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 { hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction } } return hm } // Walking state: assign a road if we don't have a destination. if hm.DestinationTownID == 0 { hm.pickDestination(graph) } hm.assignRoad(graph) if hm.Road == nil { hm.pickDestination(graph) hm.assignRoad(graph) } hm.State = model.StateWalking // Restore excursion session from persisted blob (hero may have disconnected mid-adventure). if hero.TownPause != nil && hero.TownPause.Excursion != nil { hm.applyExcursionFromBlob(hero.TownPause.Excursion) if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 { hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction } } return hm } // firstReachableOnRing returns the first town along TownOrder (stepping by Direction) // that has a direct road from CurrentTownID, or 0 if none. func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 { n := len(graph.TownOrder) if n < 2 || fromIdx < 0 { return 0 } for step := 1; step < n; step++ { raw := fromIdx + hm.Direction*step nextIdx := ((raw % n) + n) % n candidate := graph.TownOrder[nextIdx] if candidate == hm.CurrentTownID { continue } if graph.FindRoad(hm.CurrentTownID, candidate) != nil { return candidate } } return 0 } func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 { for _, r := range graph.TownRoads[hm.CurrentTownID] { if r != nil && r.ToTownID != hm.CurrentTownID { return r.ToTownID } } return 0 } func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 { for _, tid := range graph.TownOrder { if tid == hm.CurrentTownID { continue } if graph.FindRoad(hm.CurrentTownID, tid) != nil { return tid } } return 0 } // avoidSelfLoopDestination forces a different destination than CurrentTownID when the world // has multiple towns. Roads are always between distinct towns; dest == current yields no road // and the hero never moves (common after DB fallback or mis-picked nearest town at 0,0). func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) { if graph == nil || len(graph.TownOrder) <= 1 { return } if hm.CurrentTownID == 0 || hm.DestinationTownID != hm.CurrentTownID { return } if d := hm.firstOutgoingDestination(graph); d != 0 && d != hm.CurrentTownID { hm.DestinationTownID = d return } if nxt := graph.NextTownInChain(hm.CurrentTownID); nxt != 0 && nxt != hm.CurrentTownID { hm.DestinationTownID = nxt } } // crossRoadChance is the probability of picking a cross-road instead of following the ring. const crossRoadChance = 0.3 // pickDestination selects the next town the hero should walk toward. // Only towns connected by a roads row are chosen — TownOrder alone is not enough. // When multiple outgoing roads exist, there's a chance the hero takes a cross-road. func (hm *HeroMovement) pickDestination(graph *RoadGraph) { defer hm.avoidSelfLoopDestination(graph) if hm.CurrentTownID == 0 { if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 { hm.CurrentTownID = graph.TownOrder[0] } else { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } } n := len(graph.TownOrder) if n == 0 { hm.DestinationTownID = 0 return } if n == 1 { hm.DestinationTownID = hm.CurrentTownID return } idx := graph.TownOrderIndex(hm.CurrentTownID) if idx < 0 { if d := hm.firstOutgoingDestination(graph); d != 0 { hm.DestinationTownID = d return } if d := hm.firstReachableAny(graph); d != 0 { hm.DestinationTownID = d return } if len(graph.TownOrder) > 0 { hm.DestinationTownID = graph.TownOrder[0] } return } // When multiple roads are available, sometimes take a cross-road for variety. outgoing := graph.TownRoads[hm.CurrentTownID] if len(outgoing) > 2 && rand.Float64() < crossRoadChance { pick := outgoing[rand.Intn(len(outgoing))] if pick != nil && pick.ToTownID != hm.CurrentTownID { hm.DestinationTownID = pick.ToTownID return } } if dest := hm.firstReachableOnRing(graph, idx); dest != 0 { hm.DestinationTownID = dest return } if d := hm.firstOutgoingDestination(graph); d != 0 { hm.DestinationTownID = d return } if d := hm.firstReachableAny(graph); d != 0 { hm.DestinationTownID = d return } hm.DestinationTownID = hm.CurrentTownID } // assignRoad finds and configures the road from CurrentTownID to DestinationTownID. // If no road exists (hero is mid-road), it finds the nearest town and routes from there. 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.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt) hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil) hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil) hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil) hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt) hm.LastMoveTick = now } // refreshSpeed recalculates the effective movement speed using hero buffs/debuffs. func (hm *HeroMovement) refreshSpeed(now time.Time) { // Per-hero speed variation: ±10% based on hero ID for natural spread. heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10 hm.Speed = tuning.Get().BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter } // AdvanceTick moves the hero along the road for one movement tick. // Returns true if the hero reached the destination town this tick. func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } if hm.Excursion.Active() { hm.LastMoveTick = now return false } dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = movementTickRate().Seconds() } hm.LastMoveTick = now hm.refreshSpeed(now) distThisTick := hm.Speed * dt for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 { from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] segLen := math.Hypot(to.X-from.X, to.Y-from.Y) if segLen < 0.001 { hm.WaypointIndex++ hm.WaypointFraction = 0 continue } // How far along this segment we already are. currentDist := hm.WaypointFraction * segLen remaining := segLen - currentDist arrivalRadius := tuning.Get().TownArrivalRadius if arrivalRadius < 0 { arrivalRadius = 0 } if distThisTick >= remaining || (hm.WaypointIndex == len(hm.Road.Waypoints)-2 && remaining <= arrivalRadius) { // Move to next waypoint. distThisTick -= remaining hm.WaypointIndex++ hm.WaypointFraction = 0 if hm.WaypointIndex >= len(hm.Road.Waypoints)-1 { // Reached final waypoint = destination town. last := hm.Road.Waypoints[len(hm.Road.Waypoints)-1] hm.CurrentX = last.X hm.CurrentY = last.Y return true } } else { // Partial advance within this segment. hm.WaypointFraction = (currentDist + distThisTick) / segLen hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction distThisTick = 0 } } // Update position to the current waypoint position. if hm.WaypointIndex < len(hm.Road.Waypoints) { wp := hm.Road.Waypoints[hm.WaypointIndex] if hm.WaypointFraction == 0 { hm.CurrentX = wp.X hm.CurrentY = wp.Y } } return false } // Heading returns the angle (radians) the hero is currently facing. func (hm *HeroMovement) Heading() float64 { if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 { return 0 } to := hm.Road.Waypoints[hm.WaypointIndex+1] return math.Atan2(to.Y-hm.CurrentY, to.X-hm.CurrentX) } // TargetPoint returns the next waypoint the hero is heading toward. func (hm *HeroMovement) TargetPoint() (float64, float64) { if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 { return hm.CurrentX, hm.CurrentY } wp := hm.Road.Waypoints[hm.WaypointIndex+1] return wp.X, wp.Y } func (hm *HeroMovement) applyTownRestHeal(dt float64) { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } cfg := tuning.Get() rawGain := float64(hm.Hero.MaxHP)*cfg.TownRestHPPerS*dt + hm.TownRestHealRemainder gain := int(math.Floor(rawGain)) hm.TownRestHealRemainder = rawGain - float64(gain) if gain <= 0 { return } hm.Hero.HP += gain if hm.Hero.HP > hm.Hero.MaxHP { hm.Hero.HP = hm.Hero.MaxHP } } // AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest). func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error { if graph == nil || townID == 0 { return fmt.Errorf("invalid town") } if _, ok := graph.Towns[townID]; !ok { return fmt.Errorf("unknown town") } hm.Road = nil hm.WaypointIndex = 0 hm.WaypointFraction = 0 hm.DestinationTownID = townID hm.spawnAtRoadStart = false hm.WanderingMerchantDeadline = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownRestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 t := graph.Towns[townID] hm.CurrentX = t.WorldX hm.CurrentY = t.WorldY hm.EnterTown(now, graph) return nil } // AdminStartRoadsideRest forces the hero into roadside rest on the current road segment. func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } if hm.State == model.StateFighting { return false } hm.WanderingMerchantDeadline = time.Time{} hm.beginRoadsideRest(now) return true } // AdminStartExcursion forces a mini-adventure (excursion) session while the hero is walking on a road. // Cooldown and random roll are bypassed; the hero must not already be in an excursion. func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } if hm.State == model.StateFighting { return false } if hm.State != model.StateWalking { return false } if hm.Excursion.Active() { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } hm.WanderingMerchantDeadline = time.Time{} hm.beginExcursion(now) return true } // AdminStopExcursion ends an active excursion immediately (hero back on the road spine). // Works during walking phases or adventure-inline rest; rejects combat. func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool { if !hm.Excursion.Active() { return false } if hm.State == model.StateFighting { return false } if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindAdventureInline { hm.ActiveRestKind = model.RestKindNone hm.RestUntil = time.Time{} hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking } hm.endExcursion(now) hm.refreshSpeed(now) return true } // AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking. func (hm *HeroMovement) AdminStopRest(now time.Time) bool { if hm.State != model.StateResting { return false } if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline { return false } if hm.Excursion.Active() { hm.endExcursion(now) } hm.ActiveRestKind = model.RestKindNone hm.RestUntil = time.Time{} hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.refreshSpeed(now) return true } // AdminStartRest forces a resting period (same duration model as town rest). func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { return false } if hm.State == model.StateFighting { return false } hm.WanderingMerchantDeadline = time.Time{} hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownRestHealRemainder = 0 if graph != nil && hm.CurrentTownID == 0 { hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) } hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindTown hm.RestUntil = now.Add(randomRestDuration()) return true } func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return 0, 1 } idx := hm.WaypointIndex if idx >= len(hm.Road.Waypoints)-1 { idx = len(hm.Road.Waypoints) - 2 } if idx < 0 { return 0, 1 } from := hm.Road.Waypoints[idx] to := hm.Road.Waypoints[idx+1] dx := to.X - from.X dy := to.Y - from.Y L := math.Hypot(dx, dy) if L < 1e-6 { return 0, 1 } return -dy / L, dx / L } // roadForwardUnit is the normalized tangent along the road toward the next waypoint. func (hm *HeroMovement) roadForwardUnit() (float64, float64) { if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return 1, 0 } idx := hm.WaypointIndex if idx >= len(hm.Road.Waypoints)-1 { idx = len(hm.Road.Waypoints) - 2 } if idx < 0 { return 1, 0 } from := hm.Road.Waypoints[idx] to := hm.Road.Waypoints[idx+1] dx := to.X - from.X dy := to.Y - from.Y L := math.Hypot(dx, dy) if L < 1e-6 { return 1, 0 } return dx / L, dy / L } func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { exc := &hm.Excursion if exc.Active() { perpX, perpY := hm.roadPerpendicularUnit() depth := exc.DepthWorldUnits var t float64 switch exc.Phase { case model.ExcursionOut: outMs := float64(exc.OutUntil.Sub(exc.StartedAt).Milliseconds()) if outMs > 0 { elapsed := float64(now.Sub(exc.StartedAt).Milliseconds()) t = smoothstep(clamp01(elapsed / outMs)) } case model.ExcursionWild: t = 1.0 case model.ExcursionReturn: retMs := float64(exc.ReturnUntil.Sub(exc.WildUntil).Milliseconds()) if retMs > 0 { elapsed := float64(now.Sub(exc.WildUntil).Milliseconds()) t = 1.0 - smoothstep(clamp01(elapsed / retMs)) } } d := depth * t return perpX * d, perpY * d } return 0, 0 } // WanderingMerchantCost matches REST encounter / npc alms pricing. func WanderingMerchantCost(level int) int64 { cfg := tuning.Get() return cfg.MerchantCostBase + int64(level)*cfg.MerchantCostPerLevel } // rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat. func (hm *HeroMovement) rollRoadEncounter(now time.Time) (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 } if rand.Float64() >= cfg.EncounterActivityBase { return false, model.Enemy{}, false } monsterW := cfg.MonsterEncounterWeightBase merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { e := PickEnemyForLevel(hm.Hero.Level) return true, e, true } return false, model.Enemy{}, true } // EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there // are NPCs, otherwise a short resting state (StateResting). func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { destID := hm.DestinationTownID hm.CurrentTownID = destID hm.DestinationTownID = 0 hm.Road = nil hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} hm.TownRestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 ids := graph.TownNPCIDs(destID) if len(ids) == 0 { hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindTown hm.RestUntil = now.Add(randomRestDuration()) return } q := make([]int64, len(ids)) copy(q, ids) rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] }) hm.TownNPCQueue = q hm.State = model.StateInTown hm.Hero.State = model.StateInTown hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay()) } // LeaveTown transitions the hero from town to walking, picking a new destination. func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} hm.TownVisitNPCName = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 hm.TownLeaveAt = time.Time{} hm.TownRestHealRemainder = 0 hm.RestUntil = time.Time{} hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.Excursion = model.ExcursionSession{} hm.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.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 } // SyncToHero writes movement state back to the hero model for persistence. // Position uses the same world coordinates as hero_move / position_sync (road spine + display offset). func (hm *HeroMovement) SyncToHero() { now := time.Now() ox, oy := hm.displayOffset(now) hm.Hero.PositionX = hm.CurrentX + ox hm.Hero.PositionY = hm.CurrentY + oy hm.Hero.State = hm.State if hm.CurrentTownID != 0 { id := hm.CurrentTownID hm.Hero.CurrentTownID = &id } else { hm.Hero.CurrentTownID = nil } if hm.DestinationTownID != 0 { id := hm.DestinationTownID hm.Hero.DestinationTownID = &id } else { hm.Hero.DestinationTownID = nil } hm.Hero.MoveState = string(hm.State) hm.Hero.RestKind = model.RestKindNone if hm.State == model.StateResting { if hm.ActiveRestKind != model.RestKindNone { hm.Hero.RestKind = hm.ActiveRestKind } else { hm.Hero.RestKind = model.RestKindTown } } hm.Hero.ExcursionPhase = model.ExcursionNone if hm.Excursion.Active() { hm.Hero.ExcursionPhase = hm.Excursion.Phase } hm.Hero.TownPause = hm.townPauseBlob() } // TownPausePersistDue reports whether excursion/rest state should be persisted. // Returns the current signature for use when marking persistence. func (hm *HeroMovement) TownPausePersistDue() (townPausePersistSignature, bool) { sig := hm.townPausePersistSignature() if sig == hm.lastTownPausePersistSignature { return sig, false } return sig, true } // MarkTownPausePersisted stores the latest persisted signature. func (hm *HeroMovement) MarkTownPausePersisted(sig townPausePersistSignature) { hm.lastTownPausePersistSignature = sig } func (hm *HeroMovement) townPausePersistSignature() townPausePersistSignature { var sig townPausePersistSignature if hm.State == model.StateResting { rk := hm.ActiveRestKind if rk == model.RestKindNone { rk = model.RestKindTown } sig.RestKind = rk sig.RestUntil = hm.RestUntil } if hm.Excursion.Active() { s := hm.Excursion sig.ExcursionPhase = s.Phase sig.ExcursionStartedAt = s.StartedAt sig.ExcursionOutUntil = s.OutUntil sig.ExcursionWildUntil = s.WildUntil sig.ExcursionReturnUntil = s.ReturnUntil sig.ExcursionDepthWorldUnits = s.DepthWorldUnits sig.ExcursionRoadFreezeWaypoint = s.RoadFreezeWaypoint sig.ExcursionRoadFreezeFraction = s.RoadFreezeFraction } return sig } func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted { var p *model.TownPausePersisted switch hm.State { case model.StateResting: rk := model.RestKindTown if hm.ActiveRestKind != model.RestKindNone { rk = hm.ActiveRestKind } if rk == model.RestKindTown && hm.RestUntil.IsZero() { break } p = &model.TownPausePersisted{ RestKind: rk, TownRestHealRemainder: hm.TownRestHealRemainder, RestHealRemainder: hm.RestHealRemainder, } if !hm.RestUntil.IsZero() { t := hm.RestUntil p.RestUntil = &t } case model.StateInTown: p = &model.TownPausePersisted{ TownVisitNPCName: hm.TownVisitNPCName, TownVisitNPCType: hm.TownVisitNPCType, TownVisitLogsEmitted: hm.TownVisitLogsEmitted, } 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 } } // Persist active excursion session regardless of hero state (the hero can be fighting // or resting while an excursion is in progress). if hm.Excursion.Active() { ep := hm.excursionPersisted() if p == nil { p = &model.TownPausePersisted{} } p.Excursion = ep } return p } func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted { s := &hm.Excursion ep := &model.ExcursionPersisted{ Phase: string(s.Phase), DepthWorldUnits: s.DepthWorldUnits, RoadFreezeWaypoint: s.RoadFreezeWaypoint, RoadFreezeFraction: s.RoadFreezeFraction, } if !s.StartedAt.IsZero() { t := s.StartedAt ep.StartedAt = &t } if !s.OutUntil.IsZero() { t := s.OutUntil ep.OutUntil = &t } if !s.WildUntil.IsZero() { t := s.WildUntil ep.WildUntil = &t } if !s.ReturnUntil.IsZero() { t := s.ReturnUntil ep.ReturnUntil = &t } return ep } func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) { blob := hero.TownPause switch hero.State { case model.StateResting: if blob != nil && (blob.RestKind != model.RestKindNone || (blob.RestUntil != nil && !blob.RestUntil.IsZero())) { if blob.RestUntil != nil && !blob.RestUntil.IsZero() { hm.RestUntil = *blob.RestUntil } hm.ActiveRestKind = blob.RestKind hm.TownRestHealRemainder = blob.TownRestHealRemainder hm.RestHealRemainder = blob.RestHealRemainder } else { // Legacy row without town_pause: treat rest as already elapsed so offline/reconnect unblocks. hm.RestUntil = now.Add(-time.Millisecond) } case model.StateInTown: if blob == nil { return } if len(blob.NPCQueue) > 0 { hm.TownNPCQueue = append([]int64(nil), blob.NPCQueue...) } if blob.NextTownNPCRollAt != nil { hm.NextTownNPCRollAt = *blob.NextTownNPCRollAt } if blob.TownLeaveAt != nil { hm.TownLeaveAt = *blob.TownLeaveAt } hm.TownVisitNPCName = blob.TownVisitNPCName hm.TownVisitNPCType = blob.TownVisitNPCType if blob.TownVisitStartedAt != nil { hm.TownVisitStartedAt = *blob.TownVisitStartedAt } hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted } // Restore excursion session from blob (may exist alongside any hero state). if blob != nil && blob.Excursion != nil { hm.applyExcursionFromBlob(blob.Excursion) } } func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) { hm.Excursion.Phase = model.ExcursionPhase(ep.Phase) if ep.StartedAt != nil { hm.Excursion.StartedAt = *ep.StartedAt } if ep.OutUntil != nil { hm.Excursion.OutUntil = *ep.OutUntil } if ep.WildUntil != nil { hm.Excursion.WildUntil = *ep.WildUntil } if ep.ReturnUntil != nil { hm.Excursion.ReturnUntil = *ep.ReturnUntil } hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint hm.Excursion.RoadFreezeFraction = ep.RoadFreezeFraction } // MovePayload builds the hero_move WS payload (includes off-road lateral offset for display). func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload { tx, ty := hm.TargetPoint() ox, oy := hm.displayOffset(now) return model.HeroMovePayload{ X: hm.CurrentX + ox, Y: hm.CurrentY + oy, TargetX: tx + ox, TargetY: ty + oy, Speed: hm.Speed, Heading: hm.Heading(), } } // RoutePayload builds the route_assigned WS payload. func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload { if hm.Road == nil { return nil } waypoints := make([]model.PointXY, len(hm.Road.Waypoints)) for i, p := range hm.Road.Waypoints { waypoints[i] = model.PointXY{X: p.X, Y: p.Y} } return &model.RouteAssignedPayload{ RoadID: hm.Road.ID, Waypoints: waypoints, DestinationTownID: hm.DestinationTownID, Speed: hm.Speed, } } // PositionSyncPayload builds the position_sync WS payload. func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload { ox, oy := hm.displayOffset(now) return model.PositionSyncPayload{ X: hm.CurrentX + ox, Y: hm.CurrentY + oy, WaypointIndex: hm.WaypointIndex, WaypointFraction: hm.WaypointFraction, State: string(hm.State), } } // randomRestDuration returns a random duration between TownRestMin and TownRestMax. func randomRestDuration() time.Duration { cfg := tuning.Get() minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond rangeMs := (maxDur - minDur).Milliseconds() if rangeMs < 0 { rangeMs = 0 } return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond } // EncounterStarter starts or resolves a random encounter while walking (engine: combat; // offline: synchronous SimulateOneFight via callback). type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time) // MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline). type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64) // AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB). type AfterTownEnterPersist func(hero *model.Hero) 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 "" } // --- Excursion (mini-adventure) FSM helpers --- func smoothstep(t float64) float64 { return t * t * (3 - 2*t) } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } func (hm *HeroMovement) excursionWildness(now time.Time) float64 { if hm.Excursion.Phase == model.ExcursionWild { return 1 } if hm.Excursion.Phase == model.ExcursionReturn { retMs := float64(hm.Excursion.ReturnUntil.Sub(hm.Excursion.WildUntil).Milliseconds()) var t float64 if retMs > 0 { elapsed := float64(now.Sub(hm.Excursion.WildUntil).Milliseconds()) t = 1.0 - smoothstep(clamp01(elapsed/retMs)) } else { t = 1.0 } cfg := tuning.Get() minWild := cfg.AdventureReturnWildnessMin if minWild < 0 { minWild = 0 } if minWild > 1 { minWild = 1 } if t < minWild { t = minWild } return t } return 0 } func (hm *HeroMovement) isLowHP() bool { if hm.Hero == nil || hm.Hero.MaxHP <= 0 || hm.Hero.HP <= 0 { return false } return float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) < tuning.Get().LowHpThreshold } func (hm *HeroMovement) mayStartExcursion(now time.Time) bool { cfg := tuning.Get() if hm.Excursion.Active() { return false } if hm.Road == nil || len(hm.Road.Waypoints) < 2 { return false } cooldown := time.Duration(cfg.AdventureCooldownMs) * time.Millisecond if !hm.LastExcursionEndedAt.IsZero() && now.Sub(hm.LastExcursionEndedAt) < cooldown { return false } remaining := len(hm.Road.Waypoints) - 1 - hm.WaypointIndex if remaining < 2 { return false } return rand.Float64() < cfg.AdventureStartChance } func (hm *HeroMovement) beginExcursion(now time.Time) { cfg := tuning.Get() depth := cfg.AdventureDepthWorldUnits hm.refreshSpeed(now) speed := hm.Speed if speed < 0.1 { speed = 0.1 } outDur := time.Duration(depth / speed * float64(time.Second)) outEnd := now.Add(outDur) wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs) wildEnd := outEnd.Add(wildDur) returnDur := time.Duration(depth / speed * float64(time.Second)) hm.Excursion = model.ExcursionSession{ Phase: model.ExcursionOut, StartedAt: now, OutUntil: outEnd, WildUntil: wildEnd, ReturnUntil: wildEnd.Add(returnDur), DepthWorldUnits: depth, RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, } } // advanceExcursionPhases progresses through out->wild->return and returns true when complete. func (hm *HeroMovement) advanceExcursionPhases(now time.Time) (ended bool) { exc := &hm.Excursion if exc.Phase == model.ExcursionOut && !now.Before(exc.OutUntil) { exc.Phase = model.ExcursionWild } if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) { exc.Phase = model.ExcursionReturn // Only recalculate return duration if we haven't already passed the original deadline // (handles large time jumps from offline catch-up or timer-based exits). if now.Before(exc.ReturnUntil) { speed := hm.Speed if speed < 0.1 { speed = 0.1 } exc.WildUntil = now exc.ReturnUntil = now.Add(time.Duration(exc.DepthWorldUnits / speed * float64(time.Second))) } } if exc.Phase == model.ExcursionReturn && !now.Before(exc.ReturnUntil) { return true } return false } func (hm *HeroMovement) endExcursion(now time.Time) { hm.LastExcursionEndedAt = now hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint hm.WaypointFraction = hm.Excursion.RoadFreezeFraction hm.Excursion = model.ExcursionSession{} if hm.Road != nil && hm.WaypointIndex < len(hm.Road.Waypoints)-1 { from := hm.Road.Waypoints[hm.WaypointIndex] to := hm.Road.Waypoints[hm.WaypointIndex+1] hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction } } func (hm *HeroMovement) beginRoadsideRest(now time.Time) { cfg := tuning.Get() hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindRoadside hm.RestHealRemainder = 0 depth := cfg.RoadsideRestDepthWorldUnits if depth <= 0 { depth = 12.0 } hm.refreshSpeed(now) speed := hm.Speed if speed < 0.1 { speed = 0.1 } moveDur := time.Duration(depth / speed * float64(time.Second)) outUntil := now.Add(moveDur) restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs) wildUntil := outUntil.Add(restDur) returnUntil := wildUntil.Add(moveDur) hm.Excursion = model.ExcursionSession{ Phase: model.ExcursionOut, StartedAt: now, OutUntil: outUntil, WildUntil: wildUntil, ReturnUntil: returnUntil, DepthWorldUnits: depth, RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, } hm.RestUntil = returnUntil } func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) { _ = now hm.State = model.StateResting hm.Hero.State = model.StateResting hm.ActiveRestKind = model.RestKindAdventureInline hm.RestHealRemainder = 0 } func (hm *HeroMovement) applyRestHealTick(dt float64) { if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { return } cfg := tuning.Get() var hpPerS float64 switch hm.ActiveRestKind { case model.RestKindRoadside: hpPerS = cfg.RoadsideRestHpPerS case model.RestKindAdventureInline: hpPerS = cfg.AdventureRestHpPerS default: return } rawGain := float64(hm.Hero.MaxHP)*hpPerS*dt + hm.RestHealRemainder gain := int(math.Floor(rawGain)) hm.RestHealRemainder = rawGain - float64(gain) if gain <= 0 { return } hm.Hero.HP += gain if hm.Hero.HP > hm.Hero.MaxHP { hm.Hero.HP = hm.Hero.MaxHP } } func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) { cfg := tuning.Get() cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond if now.Sub(hm.LastEncounterAt) < cooldown { return false, model.Enemy{}, false } if rand.Float64() >= cfg.EncounterActivityBase { return false, model.Enemy{}, false } wildness := hm.excursionWildness(now) monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus*wildness merchantW := cfg.MerchantEncounterWeightBase total := monsterW + merchantW r := rand.Float64() * total if r < monsterW { e := PickEnemyForLevel(hm.Hero.Level) return true, e, true } return false, model.Enemy{}, true } func randomDurationBetweenMs(minMs, maxMs int64) time.Duration { if maxMs <= minMs { return time.Duration(minMs) * time.Millisecond } return time.Duration(minMs+rand.Int63n(maxMs-minMs+1)) * time.Millisecond } // ProcessSingleHeroMovementTick applies one movement-system step as of logical time now. // It mirrors the online engine's configured movement cadence. // steps (plus a final partial step to real time) for catch-up simulation. // // sender may be nil to suppress all WebSocket payloads (offline ticks). // onEncounter is required for walking encounter rolls; if nil, encounters are not triggered. // adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block). // persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town. 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: dt := now.Sub(hm.LastMoveTick).Seconds() if dt <= 0 { dt = movementTickRate().Seconds() } hm.LastMoveTick = now switch hm.ActiveRestKind { case model.RestKindRoadside: excursionEnded := hm.advanceExcursionPhases(now) if hm.Excursion.Phase == model.ExcursionWild { hm.applyRestHealTick(dt) } if excursionEnded { hm.endExcursion(now) hm.ActiveRestKind = model.RestKindNone hm.RestUntil = time.Time{} hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.refreshSpeed(now) } else if hm.Excursion.Phase == model.ExcursionWild { cfg := tuning.Get() hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) if hpFrac >= cfg.RoadsideRestExitHp { hm.Excursion.Phase = model.ExcursionReturn speed := hm.Speed if speed < 0.1 { speed = 0.1 } hm.Excursion.WildUntil = now hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second))) } } hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } case model.RestKindAdventureInline: hm.applyRestHealTick(dt) excursionEnded := hm.advanceExcursionPhases(now) cfg := tuning.Get() hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) if hpFrac >= cfg.AdventureRestTargetHp || excursionEnded { if excursionEnded { hm.endExcursion(now) if sender != nil { sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{}) } } hm.ActiveRestKind = model.RestKindNone hm.RestHealRemainder = 0 hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.refreshSpeed(now) } hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } default: hm.applyTownRestHeal(dt) hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } if now.After(hm.RestUntil) { hm.LeaveTown(graph, now) hm.SyncToHero() if sender != nil { sender.SendToHero(heroID, "town_exit", model.TownExitPayload{}) if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } } } case model.StateInTown: cfg := tuning.Get() // 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() hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2 if hadNoRoad { hm.Road = nil hm.pickDestination(graph) hm.assignRoad(graph) if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 { if route := hm.RoutePayload(); route != nil { sender.SendToHero(heroID, "route_assigned", route) } } } // Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout. if !hm.WanderingMerchantDeadline.IsZero() { if !now.Before(hm.WanderingMerchantDeadline) { hm.WanderingMerchantDeadline = time.Time{} if sender != nil { sender.SendToHero(heroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "timeout"}) } } else { hm.LastMoveTick = now if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } if hm.Hero != nil { hm.SyncToHero() } return } } // --- Active excursion (mini-adventure) --- if hm.Excursion.Active() { prevPhase := hm.Excursion.Phase excursionEnded := hm.advanceExcursionPhases(now) if excursionEnded { hm.endExcursion(now) hm.refreshSpeed(now) if sender != nil { sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{}) } } else { if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil { sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)}) } if hm.isLowHP() { hm.beginAdventureInlineRest(now) hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } return } canEncounter := hm.Excursion.Phase == model.ExcursionWild || (hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled) if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) { monster, enemy, hit := hm.rollAdventureEncounter(now) if hit { if monster && onEncounter != nil { hm.LastEncounterAt = now onEncounter(hm, &enemy, now) return } if !monster { cost := WanderingMerchantCost(hm.Hero.Level) hm.LastEncounterAt = now if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost, }) } if onMerchantEncounter != nil { onMerchantEncounter(hm, now, cost) } return } } } hm.LastMoveTick = now if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() return } } // --- Normal walking (no active excursion) --- reachedTown := hm.AdvanceTick(now, graph) if reachedTown { hm.EnterTown(now, graph) if sender != nil { town := graph.Towns[hm.CurrentTownID] if town != nil { npcInfos := graph.TownNPCInfos(hm.CurrentTownID) buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID) var restMs int64 if hm.State == model.StateResting { restMs = hm.RestUntil.Sub(now).Milliseconds() } sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{ TownID: town.ID, TownName: town.Name, Biome: town.Biome, NPCs: npcInfos, Buildings: buildingInfos, RestDurationMs: restMs, }) } } hm.SyncToHero() if persistAfterTownEnter != nil { persistAfterTownEnter(hm.Hero) } return } if hm.isLowHP() { hm.beginRoadsideRest(now) hm.SyncToHero() if sender != nil && hm.Hero != nil { sender.SendToHero(heroID, "hero_state", hm.Hero) sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } return } canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2 if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) { monster, enemy, hit := hm.rollRoadEncounter(now) if hit { if monster { if onEncounter != nil { hm.LastEncounterAt = now onEncounter(hm, &enemy, now) return } } else { cost := WanderingMerchantCost(hm.Hero.Level) if sender != nil || onMerchantEncounter != nil { hm.LastEncounterAt = now if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost, }) } if onMerchantEncounter != nil { onMerchantEncounter(hm, now, cost) } return } } } } if hm.mayStartExcursion(now) { hm.beginExcursion(now) if sender != nil { sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{ DepthWorldUnits: hm.Excursion.DepthWorldUnits, }) } hm.SyncToHero() return } if sender != nil { sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) } hm.SyncToHero() } }