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.

2350 lines
72 KiB
Go

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, line model.AdventureLogLine)
// 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
TownVisitNPCKey string
TownVisitNPCType string
TownVisitStartedAt time.Time
TownVisitLogsEmitted int
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time.Time
// Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires.
TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking)
TownNPCWalkFromX float64
TownNPCWalkFromY float64
TownNPCWalkToX float64
TownNPCWalkToY float64
TownNPCWalkStart time.Time // when walk began
TownNPCWalkArrive time.Time // when hero reaches NPC
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
TownLeaveAt time.Time
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool
// TownCenterWalk*: walk from last NPC stand back to town center before road snap (avoids teleport to road spine).
TownCenterWalkArrive time.Time
TownCenterWalkStart time.Time
TownCenterWalkFromX float64
TownCenterWalkFromY float64
TownCenterWalkToX float64
TownCenterWalkToY 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
// 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
// In-town NPC tour: coarse milestones only (not per-tick x,y during walks).
InTown bool
InTownNextRoll time.Time
InTownLeave time.Time
InTownVisitStarted time.Time
InTownVisitLogs int
InTownNPCWalkTarget int64
InTownNPCWalkStart time.Time
InTownNPCWalkArrive time.Time
InTownPlazaHeal bool
InTownCenterWalkStart time.Time
InTownCenterWalkArrive time.Time
InTownNPCQueueLen int
InTownNPCQueueFP uint64
InTownVisitName string
InTownVisitType string
}
func npcQueueFingerprint(q []int64) uint64 {
const prime64 = 1099511628211
var h uint64 = 1469598103934665603
for _, id := range q {
h ^= uint64(id)
h *= prime64
}
h ^= uint64(len(q)) << 32
return h
}
// 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, false)
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, false)
if hm.Road == nil {
hm.pickDestination(graph)
hm.assignRoad(graph, false)
}
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 pick a non-ring neighbor at random
// (includes both ring directions when there are exactly two outgoing roads).
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.
// startAtFirstWaypoint: place hero at jittered waypoint 0 (departure town) without nearest-point snap —
// use when leaving town so an off-road NPC position does not snap to an arbitrary polyline point.
func (hm *HeroMovement) assignRoad(graph *RoadGraph, startAtFirstWaypoint bool) {
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 if startAtFirstWaypoint {
wp0 := jitteredRoad.Waypoints[0]
hm.CurrentX = wp0.X
hm.CurrentY = wp0.Y
hm.WaypointIndex = 0
hm.WaypointFraction = 0
} 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.TownCenterWalkStart = shift(hm.TownCenterWalkStart)
hm.TownCenterWalkArrive = shift(hm.TownCenterWalkArrive)
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.skipMovementSimulation() {
return false
}
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
hm.clearNPCWalk()
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
hm.clearNPCWalk()
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
hm.TownLeaveAt = time.Time{}
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, graph *RoadGraph) (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 graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
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
hm.clearNPCWalk()
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
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.clearNPCWalk()
hm.clearTownCenterWalk()
hm.TownPlazaHealActive = false
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)
// Start exactly at the road origin (current town); snap from an NPC-tile would jump to the wrong spine point.
hm.assignRoad(graph, true)
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
}
// townNPCStandPoint is a spot near the NPC along the hero's approach (from → npc), not on the NPC tile.
func townNPCStandPoint(npcX, npcY, fromX, fromY, standoff float64) (sx, sy float64) {
if standoff <= 0 {
standoff = tuning.DefaultValues().TownNPCStandoffWorld
}
dx := fromX - npcX
dy := fromY - npcY
ln := math.Hypot(dx, dy)
if ln < 1e-4 {
s := standoff * 0.7071067811865476
return npcX + s, npcY + s
}
ux, uy := dx/ln, dy/ln
const gap = 0.05
step := standoff
if step > ln-gap {
step = ln - gap
}
if step < 0.12 {
step = math.Min(0.12, math.Max(0.06, ln*0.42))
}
return npcX + ux*step, npcY + uy*step
}
// clearNPCWalk resets the walk-to-NPC sub-state.
func (hm *HeroMovement) clearNPCWalk() {
hm.TownNPCWalkTargetID = 0
hm.TownNPCWalkFromX = 0
hm.TownNPCWalkFromY = 0
hm.TownNPCWalkToX = 0
hm.TownNPCWalkToY = 0
hm.TownNPCWalkStart = time.Time{}
hm.TownNPCWalkArrive = time.Time{}
}
func (hm *HeroMovement) clearTownCenterWalk() {
hm.TownCenterWalkArrive = time.Time{}
hm.TownCenterWalkStart = time.Time{}
hm.TownCenterWalkFromX = 0
hm.TownCenterWalkFromY = 0
hm.TownCenterWalkToX = 0
hm.TownCenterWalkToY = 0
}
// 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
}
// canSimulateMovement is true when the hero may advance along roads, towns, rests, and encounters.
// Dead heroes must not run the movement FSM or AdvanceTick.
func (hm *HeroMovement) canSimulateMovement() bool {
if hm == nil || hm.Hero == nil {
return false
}
if hm.State == model.StateDead {
return false
}
if hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
return false
}
return true
}
// skipMovementSimulation returns true if the hero must not run movement this step.
// When the model is dead but FSM is not yet StateDead, aligns with Die().
func (hm *HeroMovement) skipMovementSimulation() bool {
if hm == nil {
return true
}
if hm.canSimulateMovement() {
return false
}
if hm.Hero != nil && (hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead) {
hm.Die()
}
return true
}
// worldPositionAt returns hero world (x,y) matching SyncToHero / hero_move (spine + display offset).
func (hm *HeroMovement) worldPositionAt(now time.Time) (x, y float64) {
if hm == nil || hm.Hero == nil {
return 0, 0
}
ox, oy := hm.displayOffset(now)
return hm.CurrentX + ox, hm.CurrentY + oy
}
// 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()
x, y := hm.worldPositionAt(now)
hm.Hero.PositionX = x
hm.Hero.PositionY = y
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
}
if hm.State == model.StateInTown {
sig.InTown = true
sig.InTownNextRoll = hm.NextTownNPCRollAt
sig.InTownLeave = hm.TownLeaveAt
sig.InTownVisitStarted = hm.TownVisitStartedAt
sig.InTownVisitLogs = hm.TownVisitLogsEmitted
sig.InTownNPCWalkTarget = hm.TownNPCWalkTargetID
sig.InTownNPCWalkStart = hm.TownNPCWalkStart
sig.InTownNPCWalkArrive = hm.TownNPCWalkArrive
sig.InTownPlazaHeal = hm.TownPlazaHealActive
sig.InTownCenterWalkStart = hm.TownCenterWalkStart
sig.InTownCenterWalkArrive = hm.TownCenterWalkArrive
sig.InTownNPCQueueLen = len(hm.TownNPCQueue)
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
sig.InTownVisitName = hm.TownVisitNPCName
sig.InTownVisitType = hm.TownVisitNPCType
}
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,
NPCWalkTargetID: hm.TownNPCWalkTargetID,
NPCWalkFromX: hm.TownNPCWalkFromX,
NPCWalkFromY: hm.TownNPCWalkFromY,
NPCWalkToX: hm.TownNPCWalkToX,
NPCWalkToY: hm.TownNPCWalkToY,
}
if !hm.TownNPCWalkStart.IsZero() {
t := hm.TownNPCWalkStart
p.NPCWalkStart = &t
}
if !hm.TownNPCWalkArrive.IsZero() {
t := hm.TownNPCWalkArrive
p.NPCWalkArrive = &t
}
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
}
if hm.TownPlazaHealActive {
p.TownPlazaHealActive = true
}
p.CenterWalkFromX = hm.TownCenterWalkFromX
p.CenterWalkFromY = hm.TownCenterWalkFromY
p.CenterWalkToX = hm.TownCenterWalkToX
p.CenterWalkToY = hm.TownCenterWalkToY
if !hm.TownCenterWalkStart.IsZero() {
t := hm.TownCenterWalkStart
p.CenterWalkStart = &t
}
if !hm.TownCenterWalkArrive.IsZero() {
t := hm.TownCenterWalkArrive
p.CenterWalkArrive = &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
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
hm.TownNPCWalkFromX = blob.NPCWalkFromX
hm.TownNPCWalkFromY = blob.NPCWalkFromY
hm.TownNPCWalkToX = blob.NPCWalkToX
hm.TownNPCWalkToY = blob.NPCWalkToY
if blob.NPCWalkStart != nil {
hm.TownNPCWalkStart = *blob.NPCWalkStart
}
if blob.NPCWalkArrive != nil {
hm.TownNPCWalkArrive = *blob.NPCWalkArrive
}
hm.TownPlazaHealActive = blob.TownPlazaHealActive
hm.TownCenterWalkFromX = blob.CenterWalkFromX
hm.TownCenterWalkFromY = blob.CenterWalkFromY
hm.TownCenterWalkToX = blob.CenterWalkToX
hm.TownCenterWalkToY = blob.CenterWalkToY
if blob.CenterWalkStart != nil {
hm.TownCenterWalkStart = *blob.CenterWalkStart
}
if blob.CenterWalkArrive != nil {
hm.TownCenterWalkArrive = *blob.CenterWalkArrive
}
}
// 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)
// TownNPCOfflineInteractHook runs when the hero reaches a town NPC with no WS client (offline catch-up).
// Returns true if the hero stops and interacts (narration + timed logs); false if they walk past without stopping.
type TownNPCOfflineInteractHook func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, adventureLog AdventureLogWriter) bool
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
}
lineIdx := hm.TownVisitLogsEmitted
log(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.TownVisitPhraseKey(hm.TownVisitNPCType, lineIdx),
},
})
hm.TownVisitLogsEmitted++
}
}
// --- 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,
}
// RestUntil tracks only the rest (wild) phase; travel out/return is separate.
hm.RestUntil = wildUntil
hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second)
}
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, graph *RoadGraph) (monster bool, enemy model.Enemy, hit bool) {
cfg := tuning.Get()
if graph != nil && graph.HeroInTownAt(hm.worldPositionAt(now)) {
return false, model.Enemy{}, false
}
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.
// townNPCOfflineInteract, when sender is nil, decides offline buy/heal/quest vs walking past; nil uses legacy auto-sell-only behavior.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
graph *RoadGraph,
now time.Time,
sender MessageSender,
onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
townNPCOfflineInteract TownNPCOfflineInteractHook,
) {
if graph == nil {
return
}
if hm.skipMovementSimulation() {
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:
// For roadside rest, ensure Wild→Return always gets a fresh return
// deadline so the hero walks back to the road smoothly (prevents
// advanceExcursionPhases from skipping the return phase on time jumps).
if hm.Excursion.Phase == model.ExcursionWild && !now.Before(hm.Excursion.WildUntil) {
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)))
}
excursionEnded := hm.advanceExcursionPhases(now)
if hm.Excursion.Phase == model.ExcursionWild {
hm.applyRestHealTick(dt)
if adventureLog != nil {
if hm.RoadsideThoughtNextAt.IsZero() {
hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second)
}
if !now.Before(hm.RoadsideThoughtNextAt) {
if n := len(model.RoadsideSlugs); n > 0 {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.RoadsidePhraseKey(model.RoadsideSlugs[rand.Intn(n)]),
},
})
}
hm.RoadsideThoughtNextAt = now.Add(time.Duration(30+rand.Intn(61)) * time.Second)
}
}
}
if excursionEnded {
hm.endExcursion(now)
hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
hm.RestHealRemainder = 0
hm.RoadsideThoughtNextAt = time.Time{}
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 now.After(hm.RestUntil) || 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()
dtTown := now.Sub(hm.LastMoveTick).Seconds()
if dtTown <= 0 {
dtTown = movementTickRate().Seconds()
}
hm.LastMoveTick = now
// --- Walk back to town center after last NPC (avoids road-snap teleport) ---
if !hm.TownCenterWalkArrive.IsZero() {
if !now.Before(hm.TownCenterWalkArrive) {
hm.CurrentX = hm.TownCenterWalkToX
hm.CurrentY = hm.TownCenterWalkToY
hm.clearTownCenterWalk()
if sender != nil {
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.CurrentX, TargetY: hm.CurrentY,
Speed: 0, Heading: 0,
})
}
} else {
totalMs := hm.TownCenterWalkArrive.Sub(hm.TownCenterWalkStart).Milliseconds()
if totalMs <= 0 {
totalMs = 1
}
elapsed := now.Sub(hm.TownCenterWalkStart).Milliseconds()
t := float64(elapsed) / float64(totalMs)
if t > 1 {
t = 1
}
hm.CurrentX = hm.TownCenterWalkFromX + (hm.TownCenterWalkToX-hm.TownCenterWalkFromX)*t
hm.CurrentY = hm.TownCenterWalkFromY + (hm.TownCenterWalkToY-hm.TownCenterWalkFromY)*t
if sender != nil {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
dx := hm.TownCenterWalkToX - hm.CurrentX
dy := hm.TownCenterWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownCenterWalkToX, TargetY: hm.TownCenterWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
}
hm.SyncToHero()
return
}
// --- Sub-state: hero is walking toward an NPC inside the town ---
if hm.TownNPCWalkTargetID != 0 {
if !now.Before(hm.TownNPCWalkArrive) {
// Arrived at stand point (near NPC) — snap position and fire the visit event.
hm.CurrentX = hm.TownNPCWalkToX
hm.CurrentY = hm.TownNPCWalkToY
npcID := hm.TownNPCWalkTargetID
standX := hm.TownNPCWalkToX
standY := hm.TownNPCWalkToY
hm.clearNPCWalk()
if npc, ok := graph.NPCByID[npcID]; ok {
fullVisit := false
townNameKey := ""
if tt := graph.Towns[hm.CurrentTownID]; tt != nil {
townNameKey = tt.NameKey
}
if sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, NameKey: npc.NameKey, Type: npc.Type, TownID: hm.CurrentTownID,
TownNameKey: townNameKey,
WorldX: standX, WorldY: standY,
})
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.CurrentX, TargetY: hm.CurrentY,
Speed: 0, Heading: 0,
})
fullVisit = true
} else if townNPCOfflineInteract != nil {
fullVisit = townNPCOfflineInteract(heroID, hm, graph, npc, now, adventureLog)
} else {
fullVisit = true
}
if fullVisit {
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCKey = npc.NameKey
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
legacyMerchantSell := npc.Type == "merchant" && (sender != nil || townNPCOfflineInteract == nil)
if legacyMerchantSell {
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, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {
if adventureLog != nil {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPhraseNPCSkippedVisit,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
}
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
}
} else {
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
}
} else {
// Still walking — interpolate position.
totalMs := hm.TownNPCWalkArrive.Sub(hm.TownNPCWalkStart).Milliseconds()
if totalMs <= 0 {
totalMs = 1
}
elapsed := now.Sub(hm.TownNPCWalkStart).Milliseconds()
t := float64(elapsed) / float64(totalMs)
if t > 1 {
t = 1
}
hm.CurrentX = hm.TownNPCWalkFromX + (hm.TownNPCWalkToX-hm.TownNPCWalkFromX)*t
hm.CurrentY = hm.TownNPCWalkFromY + (hm.TownNPCWalkToY-hm.TownNPCWalkFromY)*t
if sender != nil {
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
dx := hm.TownNPCWalkToX - hm.CurrentX
dy := hm.TownNPCWalkToY - hm.CurrentY
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: hm.TownNPCWalkToX, TargetY: hm.TownNPCWalkToY,
Speed: walkSpeed, Heading: heading,
})
}
}
hm.SyncToHero()
return
}
// NPC visit pause ended: clear visit log state before the next roll.
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
hm.TownVisitNPCName = ""
hm.TownVisitNPCKey = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
if len(hm.TownNPCQueue) == 0 && hm.TownNPCWalkTargetID == 0 {
town := graph.Towns[hm.CurrentTownID]
if town == nil {
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
}
cx, cy := town.WorldX, town.WorldY
const plazaEps = 0.55
dPlaza := math.Hypot(hm.CurrentX-cx, hm.CurrentY-cy)
if dPlaza > plazaEps {
dx := cx - hm.CurrentX
dy := cy - hm.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
const minWalkMs = 300
walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond
if walkDur < minWalkMs*time.Millisecond {
walkDur = minWalkMs * time.Millisecond
}
hm.TownCenterWalkFromX = hm.CurrentX
hm.TownCenterWalkFromY = hm.CurrentY
hm.TownCenterWalkToX = cx
hm.TownCenterWalkToY = cy
hm.TownCenterWalkStart = now
hm.TownCenterWalkArrive = now.Add(walkDur)
if sender != nil {
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: cx, TargetY: cy,
Speed: walkSpeed, Heading: heading,
})
}
hm.SyncToHero()
return
}
if hm.TownLeaveAt.IsZero() {
restCh := cfg.TownAfterNPCRestChance
if restCh <= 0 {
restCh = tuning.DefaultValues().TownAfterNPCRestChance
}
if restCh > 1 {
restCh = 1
}
if rand.Float64() < restCh {
hm.TownPlazaHealActive = true
hm.TownLeaveAt = now.Add(randomRestDuration())
} else {
hm.TownPlazaHealActive = false
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
}
}
if hm.TownPlazaHealActive {
hm.applyTownRestHeal(dtTown)
}
if now.Before(hm.TownLeaveAt) {
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero()
return
}
hm.TownLeaveAt = time.Time{}
hm.TownPlazaHealActive = false
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 {
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
hm.SyncToHero()
return
}
approachCh := cfg.TownNPCApproachChance
if approachCh <= 0 {
approachCh = tuning.DefaultValues().TownNPCApproachChance
}
if approachCh > 1 {
approachCh = 1
}
if rand.Float64() >= approachCh {
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
hm.SyncToHero()
return
}
npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok {
npcWX, npcWY, posOk := graph.NPCWorldPos(npcID, hm.CurrentTownID)
if !posOk {
if town := graph.Towns[hm.CurrentTownID]; town != nil {
npcWX, npcWY = town.WorldX+npc.OffsetX, town.WorldY+npc.OffsetY
}
}
standoff := cfg.TownNPCStandoffWorld
if standoff <= 0 {
standoff = tuning.DefaultValues().TownNPCStandoffWorld
}
toX, toY := townNPCStandPoint(npcWX, npcWY, hm.CurrentX, hm.CurrentY, standoff)
dx := toX - hm.CurrentX
dy := toY - hm.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
walkSpeed := cfg.TownNPCWalkSpeed
if walkSpeed <= 0 {
walkSpeed = tuning.DefaultValues().TownNPCWalkSpeed
}
const minWalkMs = 300
walkDur := time.Duration(dist/walkSpeed*1000) * time.Millisecond
if walkDur < minWalkMs*time.Millisecond {
walkDur = minWalkMs * time.Millisecond
}
hm.TownNPCWalkTargetID = npcID
hm.TownNPCWalkFromX = hm.CurrentX
hm.TownNPCWalkFromY = hm.CurrentY
hm.TownNPCWalkToX = toX
hm.TownNPCWalkToY = toY
hm.TownNPCWalkStart = now
hm.TownNPCWalkArrive = now.Add(walkDur)
if sender != nil {
heading := math.Atan2(dy, dx)
sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{
X: hm.CurrentX, Y: hm.CurrentY,
TargetX: toX, TargetY: toY,
Speed: walkSpeed, Heading: heading,
})
}
}
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
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, false)
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, graph)
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", NPCNameKey: model.WanderingMerchantNPCKey,
Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, 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,
TownNameKey: town.NameKey,
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, graph)
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",
NPCNameKey: model.WanderingMerchantNPCKey,
Role: "alms",
DialogueKey: model.WanderingMerchantDialogueKey,
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()
}
}