You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

783 lines
23 KiB
Go

package game
import (
"math"
"math/rand"
"time"
"github.com/denisovdennis/autohero/internal/model"
)
const (
// BaseMoveSpeed is the hero's base movement speed in world-units per second.
BaseMoveSpeed = 2.0
// MovementTickRate is how often the movement system updates (2 Hz).
MovementTickRate = 500 * time.Millisecond
// PositionSyncRate is how often the server sends a full position_sync (drift correction).
PositionSyncRate = 10 * time.Second
// EncounterCooldownBase is the minimum gap between road encounters (monster or merchant).
EncounterCooldownBase = 12 * time.Second
// EncounterActivityBase scales per-tick chance to roll an encounter after cooldown.
// Effective activity is higher deep off-road (see rollRoadEncounter).
EncounterActivityBase = 0.035
// StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion.
StartAdventurePerTick = 0.0004
// AdventureDurationMin/Max bound how long an off-road excursion lasts.
AdventureDurationMin = 15 * time.Minute
AdventureDurationMax = 20 * time.Minute
// AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness.
AdventureMaxLateral = 3.5
// TownRestMin is the minimum rest duration when arriving at a town.
TownRestMin = 5 * 60 * time.Second
// TownRestMax is the maximum rest duration when arriving at a town.
TownRestMax = 20 * 60 * time.Second
// TownArrivalRadius is how close the hero must be to the final waypoint
// to be considered "arrived" at the town.
TownArrivalRadius = 0.5
// Town NPC visits: high chance each attempt to approach the next NPC; queue clears on LeaveTown.
townNPCVisitChance = 0.78
townNPCRollMin = 800 * time.Millisecond
townNPCRollMax = 2600 * time.Millisecond
townNPCRetryAfterMiss = 450 * time.Millisecond
)
// HeroMovement holds the live movement state for a single online hero.
type HeroMovement struct {
HeroID int64
Hero *model.Hero // live reference, owned by the engine
CurrentX float64
CurrentY float64
Speed float64 // effective world-units/sec
State model.GameState
DestinationTownID int64
CurrentTownID int64
Road *Road
WaypointIndex int // index of the waypoint we are heading toward
WaypointFraction float64 // 0..1 within the current segment
LastEncounterAt time.Time
RestUntil time.Time
LastMoveTick time.Time
Direction int // +1 forward along TownOrder, -1 backward
// TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown.
TownNPCQueue []int64
NextTownNPCRollAt time.Time
// Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends.
AdventureStartAt time.Time
AdventureEndAt time.Time
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring
}
// NewHeroMovement creates a HeroMovement for a hero that just connected.
// It initializes position, state, and picks the first destination if needed.
func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMovement {
// Randomize direction per hero so they don't all walk the same way.
dir := 1
if hero.ID%2 == 0 {
dir = -1
}
// Add per-hero position offset so heroes on the same road don't overlap.
// Use hero ID to create a stable lateral offset of ±1.5 tiles.
lateralOffset := (float64(hero.ID%7) - 3.0) * 0.5
hm := &HeroMovement{
HeroID: hero.ID,
Hero: hero,
CurrentX: hero.PositionX + lateralOffset*0.3,
CurrentY: hero.PositionY + lateralOffset*0.7,
State: hero.State,
LastMoveTick: now,
Direction: dir,
}
// Restore persisted movement state.
if hero.CurrentTownID != nil {
hm.CurrentTownID = *hero.CurrentTownID
}
if hero.DestinationTownID != nil {
hm.DestinationTownID = *hero.DestinationTownID
}
hm.refreshSpeed(now)
// If the hero is dead, keep them dead.
if hero.State == model.StateDead || hero.HP <= 0 {
hm.State = model.StateDead
return hm
}
// If fighting, leave as-is (engine combat system manages it).
if hero.State == model.StateFighting {
return hm
}
// If resting/in_town, set a short rest timer so they leave soon.
if hero.State == model.StateResting || hero.State == model.StateInTown {
hm.State = model.StateResting
hm.RestUntil = now.Add(randomRestDuration())
return hm
}
// Walking state: assign a road if we don't have a destination.
if hm.DestinationTownID == 0 {
hm.pickDestination(graph)
}
hm.assignRoad(graph)
if hm.Road == nil {
hm.pickDestination(graph)
hm.assignRoad(graph)
}
hm.State = model.StateWalking
return hm
}
// firstReachableOnRing returns the first town along TownOrder (stepping by Direction)
// that has a direct road from CurrentTownID, or 0 if none.
func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 {
n := len(graph.TownOrder)
if n < 2 || fromIdx < 0 {
return 0
}
for step := 1; step < n; step++ {
raw := fromIdx + hm.Direction*step
nextIdx := ((raw % n) + n) % n
candidate := graph.TownOrder[nextIdx]
if candidate == hm.CurrentTownID {
continue
}
if graph.FindRoad(hm.CurrentTownID, candidate) != nil {
return candidate
}
}
return 0
}
func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 {
for _, r := range graph.TownRoads[hm.CurrentTownID] {
if r != nil && r.ToTownID != hm.CurrentTownID {
return r.ToTownID
}
}
return 0
}
func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 {
for _, tid := range graph.TownOrder {
if tid == hm.CurrentTownID {
continue
}
if graph.FindRoad(hm.CurrentTownID, tid) != nil {
return tid
}
}
return 0
}
// pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
if hm.CurrentTownID == 0 {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
}
n := len(graph.TownOrder)
if n == 0 {
hm.DestinationTownID = 0
return
}
if n == 1 {
hm.DestinationTownID = hm.CurrentTownID
return
}
idx := graph.TownOrderIndex(hm.CurrentTownID)
if idx < 0 {
if d := hm.firstOutgoingDestination(graph); d != 0 {
hm.DestinationTownID = d
return
}
if d := hm.firstReachableAny(graph); d != 0 {
hm.DestinationTownID = d
return
}
if len(graph.TownOrder) > 0 {
hm.DestinationTownID = graph.TownOrder[0]
}
return
}
if dest := hm.firstReachableOnRing(graph, idx); dest != 0 {
hm.DestinationTownID = dest
return
}
if d := hm.firstOutgoingDestination(graph); d != 0 {
hm.DestinationTownID = d
return
}
if d := hm.firstReachableAny(graph); d != 0 {
hm.DestinationTownID = d
return
}
hm.DestinationTownID = hm.CurrentTownID
}
// assignRoad finds and configures the road from CurrentTownID to DestinationTownID.
// If no road exists (hero is mid-road), it finds the nearest town and routes from there.
func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID)
if road == nil {
// Try finding a road from any nearby town.
nearest := graph.NearestTown(hm.CurrentX, hm.CurrentY)
hm.CurrentTownID = nearest
road = graph.FindRoad(nearest, hm.DestinationTownID)
}
if road == nil {
// No road available, will retry next tick.
return
}
// Create a per-hero jittered copy of waypoints so heroes don't overlap on the same road.
jitteredWaypoints := make([]Point, len(road.Waypoints))
copy(jitteredWaypoints, road.Waypoints)
heroSeed := float64(hm.HeroID)
lateralJitter := (math.Sin(heroSeed*1.7) * 1.5) // ±1.5 tiles lateral offset
for i := 1; i < len(jitteredWaypoints)-1; i++ {
// Apply perpendicular offset (don't jitter start/end = town centers)
dx := jitteredWaypoints[i].X - jitteredWaypoints[max(0, i-1)].X
dy := jitteredWaypoints[i].Y - jitteredWaypoints[max(0, i-1)].Y
segLen := math.Hypot(dx, dy)
if segLen > 0.1 {
perpX := -dy / segLen
perpY := dx / segLen
jitter := lateralJitter * (0.7 + 0.3*math.Sin(heroSeed*0.3+float64(i)*0.5))
jitteredWaypoints[i].X += perpX * jitter
jitteredWaypoints[i].Y += perpY * jitter
}
}
jitteredRoad := &Road{
ID: road.ID,
FromTownID: road.FromTownID,
ToTownID: road.ToTownID,
Distance: road.Distance,
Waypoints: jitteredWaypoints,
}
hm.Road = jitteredRoad
hm.WaypointIndex = 0
hm.WaypointFraction = 0
// Position the hero at the start of the road if they're very close to the origin town.
if len(jitteredWaypoints) > 0 {
start := jitteredWaypoints[0]
dist := math.Hypot(hm.CurrentX-start.X, hm.CurrentY-start.Y)
if dist < 5.0 {
hm.CurrentX = start.X
hm.CurrentY = start.Y
}
}
}
// refreshSpeed recalculates the effective movement speed using hero buffs/debuffs.
func (hm *HeroMovement) refreshSpeed(now time.Time) {
// Per-hero speed variation: ±10% based on hero ID for natural spread.
heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10
hm.Speed = BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter
}
// AdvanceTick moves the hero along the road for one movement tick.
// Returns true if the hero reached the destination town this tick.
func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false
}
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = MovementTickRate.Seconds()
}
hm.LastMoveTick = now
hm.refreshSpeed(now)
distThisTick := hm.Speed * dt
for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
from := hm.Road.Waypoints[hm.WaypointIndex]
to := hm.Road.Waypoints[hm.WaypointIndex+1]
segLen := math.Hypot(to.X-from.X, to.Y-from.Y)
if segLen < 0.001 {
hm.WaypointIndex++
hm.WaypointFraction = 0
continue
}
// How far along this segment we already are.
currentDist := hm.WaypointFraction * segLen
remaining := segLen - currentDist
if distThisTick >= remaining {
// Move to next waypoint.
distThisTick -= remaining
hm.WaypointIndex++
hm.WaypointFraction = 0
if hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
// Reached final waypoint = destination town.
last := hm.Road.Waypoints[len(hm.Road.Waypoints)-1]
hm.CurrentX = last.X
hm.CurrentY = last.Y
return true
}
} else {
// Partial advance within this segment.
hm.WaypointFraction = (currentDist + distThisTick) / segLen
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
distThisTick = 0
}
}
// Update position to the current waypoint position.
if hm.WaypointIndex < len(hm.Road.Waypoints) {
wp := hm.Road.Waypoints[hm.WaypointIndex]
if hm.WaypointFraction == 0 {
hm.CurrentX = wp.X
hm.CurrentY = wp.Y
}
}
return false
}
// Heading returns the angle (radians) the hero is currently facing.
func (hm *HeroMovement) Heading() float64 {
if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
return 0
}
to := hm.Road.Waypoints[hm.WaypointIndex+1]
return math.Atan2(to.Y-hm.CurrentY, to.X-hm.CurrentX)
}
// TargetPoint returns the next waypoint the hero is heading toward.
func (hm *HeroMovement) TargetPoint() (float64, float64) {
if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
return hm.CurrentX, hm.CurrentY
}
wp := hm.Road.Waypoints[hm.WaypointIndex+1]
return wp.X, wp.Y
}
func (hm *HeroMovement) adventureActive(now time.Time) bool {
return !hm.AdventureStartAt.IsZero() && now.Before(hm.AdventureEndAt)
}
func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) {
if hm.AdventureEndAt.IsZero() {
return
}
if now.Before(hm.AdventureEndAt) {
return
}
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
}
// tryStartAdventure begins a timed off-road excursion with small probability.
func (hm *HeroMovement) tryStartAdventure(now time.Time) {
if hm.adventureActive(now) {
return
}
if rand.Float64() >= StartAdventurePerTick {
return
}
hm.AdventureStartAt = now
spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds()
if spanNs < 1 {
spanNs = 1
}
hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1)))
if rand.Float64() < 0.5 {
hm.AdventureSide = 1
} else {
hm.AdventureSide = -1
}
}
// wildernessFactor is 0 on the road, then 0→1→0 over the excursion (triangle: out, then back).
func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
if !hm.adventureActive(now) {
return 0
}
total := hm.AdventureEndAt.Sub(hm.AdventureStartAt).Seconds()
if total <= 0 {
return 0
}
elapsed := now.Sub(hm.AdventureStartAt).Seconds()
p := elapsed / total
if p < 0 {
p = 0
} else if p > 1 {
p = 1
}
if p < 0.5 {
return p * 2
}
return (1 - p) * 2
}
func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 0, 1
}
idx := hm.WaypointIndex
if idx >= len(hm.Road.Waypoints)-1 {
idx = len(hm.Road.Waypoints) - 2
}
if idx < 0 {
return 0, 1
}
from := hm.Road.Waypoints[idx]
to := hm.Road.Waypoints[idx+1]
dx := to.X - from.X
dy := to.Y - from.Y
L := math.Hypot(dx, dy)
if L < 1e-6 {
return 0, 1
}
return -dy / L, dx / L
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
w := hm.wildernessFactor(now)
if w <= 0 || hm.AdventureSide == 0 {
return 0, 0
}
px, py := hm.roadPerpendicularUnit()
mag := float64(hm.AdventureSide) * AdventureMaxLateral * w
return px * mag, py * mag
}
// WanderingMerchantCost matches REST encounter / npc alms pricing.
func WanderingMerchantCost(level int) int64 {
return int64(20 + level*5)
}
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false, model.Enemy{}, false
}
if now.Sub(hm.LastEncounterAt) < EncounterCooldownBase {
return false, model.Enemy{}, false
}
w := hm.wildernessFactor(now)
activity := EncounterActivityBase * (0.45 + 0.55*w)
if rand.Float64() >= activity {
return false, model.Enemy{}, false
}
monsterW := 0.08 + 0.92*w*w
merchantW := 0.08 + 0.92*(1-w)*(1-w)
total := monsterW + merchantW
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForLevel(hm.Hero.Level)
return true, e, true
}
return false, model.Enemy{}, true
}
// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
// are NPCs, otherwise a short resting state (StateResting).
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
destID := hm.DestinationTownID
hm.CurrentTownID = destID
hm.DestinationTownID = 0
hm.Road = nil
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0
ids := graph.TownNPCIDs(destID)
if len(ids) == 0 {
hm.State = model.StateResting
hm.Hero.State = model.StateResting
hm.RestUntil = now.Add(randomRestDuration())
return
}
q := make([]int64, len(ids))
copy(q, ids)
rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] })
hm.TownNPCQueue = q
hm.State = model.StateInTown
hm.Hero.State = model.StateInTown
hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
}
// LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.pickDestination(graph)
hm.assignRoad(graph)
hm.refreshSpeed(now)
}
func randomTownNPCDelay() time.Duration {
rangeMs := (townNPCRollMax - townNPCRollMin).Milliseconds()
return townNPCRollMin + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// StartFighting pauses movement for combat.
func (hm *HeroMovement) StartFighting() {
hm.State = model.StateFighting
}
// ResumWalking resumes movement after combat.
func (hm *HeroMovement) ResumeWalking(now time.Time) {
hm.State = model.StateWalking
hm.LastMoveTick = now
hm.refreshSpeed(now)
}
// Die sets the movement state to dead.
func (hm *HeroMovement) Die() {
hm.State = model.StateDead
}
// SyncToHero writes movement state back to the hero model for persistence.
func (hm *HeroMovement) SyncToHero() {
hm.Hero.PositionX = hm.CurrentX
hm.Hero.PositionY = hm.CurrentY
hm.Hero.State = hm.State
if hm.CurrentTownID != 0 {
id := hm.CurrentTownID
hm.Hero.CurrentTownID = &id
} else {
hm.Hero.CurrentTownID = nil
}
if hm.DestinationTownID != 0 {
id := hm.DestinationTownID
hm.Hero.DestinationTownID = &id
} else {
hm.Hero.DestinationTownID = nil
}
hm.Hero.MoveState = string(hm.State)
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload {
tx, ty := hm.TargetPoint()
ox, oy := hm.displayOffset(now)
return model.HeroMovePayload{
X: hm.CurrentX + ox,
Y: hm.CurrentY + oy,
TargetX: tx + ox,
TargetY: ty + oy,
Speed: hm.Speed,
Heading: hm.Heading(),
}
}
// RoutePayload builds the route_assigned WS payload.
func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload {
if hm.Road == nil {
return nil
}
waypoints := make([]model.PointXY, len(hm.Road.Waypoints))
for i, p := range hm.Road.Waypoints {
waypoints[i] = model.PointXY{X: p.X, Y: p.Y}
}
return &model.RouteAssignedPayload{
RoadID: hm.Road.ID,
Waypoints: waypoints,
DestinationTownID: hm.DestinationTownID,
Speed: hm.Speed,
}
}
// PositionSyncPayload builds the position_sync WS payload.
func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload {
ox, oy := hm.displayOffset(now)
return model.PositionSyncPayload{
X: hm.CurrentX + ox,
Y: hm.CurrentY + oy,
WaypointIndex: hm.WaypointIndex,
WaypointFraction: hm.WaypointFraction,
State: string(hm.State),
}
}
// randomRestDuration returns a random duration between TownRestMin and TownRestMax.
func randomRestDuration() time.Duration {
rangeMs := (TownRestMax - TownRestMin).Milliseconds()
return TownRestMin + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// EncounterStarter starts or resolves a random encounter while walking (engine: combat;
// offline: synchronous SimulateOneFight via callback).
type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time)
// MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline).
type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)
// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
// It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate
// steps (plus a final partial step to real time) for catch-up simulation.
//
// sender may be nil to suppress all WebSocket payloads (offline ticks).
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
graph *RoadGraph,
now time.Time,
sender MessageSender,
onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook,
) {
if graph == nil {
return
}
switch hm.State {
case model.StateFighting, model.StateDead:
return
case model.StateResting:
if now.After(hm.RestUntil) {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
}
case model.StateInTown:
if len(hm.TownNPCQueue) == 0 {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
return
}
if now.Before(hm.NextTownNPCRollAt) {
return
}
if rand.Float64() < townNPCVisitChance {
npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok && sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
})
}
hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
} else {
hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss)
}
case model.StateWalking:
hm.expireAdventureIfNeeded(now)
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
hm.Road = nil
hm.pickDestination(graph)
hm.assignRoad(graph)
}
hm.tryStartAdventure(now)
reachedTown := hm.AdvanceTick(now, graph)
if reachedTown {
hm.EnterTown(now, graph)
if sender != nil {
town := graph.Towns[hm.CurrentTownID]
if town != nil {
npcInfos := make([]model.TownNPCInfo, 0, len(graph.TownNPCs[hm.CurrentTownID]))
for _, n := range graph.TownNPCs[hm.CurrentTownID] {
npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type})
}
var restMs int64
if hm.State == model.StateResting {
restMs = hm.RestUntil.Sub(now).Milliseconds()
}
sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{
TownID: town.ID,
TownName: town.Name,
Biome: town.Biome,
NPCs: npcInfos,
RestDurationMs: restMs,
})
}
}
hm.SyncToHero()
return
}
canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
monster, enemy, hit := hm.rollRoadEncounter(now)
if hit {
if monster {
if onEncounter != nil {
hm.LastEncounterAt = now
onEncounter(hm, &enemy, now)
return
}
// No monster handler — skip consuming the roll (extremely rare).
} else {
cost := WanderingMerchantCost(hm.Hero.Level)
if sender != nil || onMerchantEncounter != nil {
hm.LastEncounterAt = now
if sender != nil {
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0,
NPCName: "Wandering Merchant",
Role: "alms",
Cost: cost,
})
}
if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost)
}
return
}
}
}
}
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.Hero.PositionX = hm.CurrentX
hm.Hero.PositionY = hm.CurrentY
}
}