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.

2317 lines
70 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
// TownNPCUILock: while true, town NPC visit narration timers do not advance (hero opened shop/quest UI).
TownNPCUILock bool
// RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild).
RoadsideThoughtNextAt time.Time
// Walk-to-NPC: attractor at TownNPCWalkTo* while TownNPCWalkTargetID != 0.
TownNPCWalkTargetID int64
TownNPCWalkToX float64
TownNPCWalkToY float64
// TownLeaveAt: after NPC tour at town center — wait/rest deadline before LeaveTown (also used for NPC-less town rest end).
TownLeaveAt time.Time
// TownLastNPCLingerUntil: after the final queued NPC visit ends, wait near them until this time before walking to plaza (shifted while TownNPCUILock).
TownLastNPCLingerUntil time.Time
// TownPlazaHealActive: during TownLeaveAt after NPC tour, apply town HP regen (full rest roll succeeded).
TownPlazaHealActive bool
// TownCenterWalk*: attractor stepping to plaza before road snap.
TownCenterWalkActive bool
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
// sentTownTourWireSig avoids spamming town_tour_phase when nothing changed.
sentTownTourWireSig string
}
// 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
ExcursionKind model.ExcursionKind
ExcursionPhase model.ExcursionPhase
ExcursionStartedAt time.Time
ExcursionOutUntil time.Time
ExcursionWildUntil time.Time
ExcursionReturnUntil time.Time
ExcursionDepthWorldUnits float64
ExcursionRoadFreezeWaypoint int
ExcursionRoadFreezeFraction float64
ExcursionStartX float64
ExcursionStartY float64
ExcursionAttractorX float64
ExcursionAttractorY float64
ExcursionAttractorSet bool
ExcursionAdventureEndsAt time.Time
ExcursionWanderNextAt time.Time
ExcursionPendingReturn bool
// 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
InTownNPCWalkToX float64
InTownNPCWalkToY float64
InTownPlazaHeal bool
InTownCenterWalkActive bool
InTownCenterWalkToX float64
InTownCenterWalkToY float64
InTownNPCQueueLen int
InTownNPCQueueFP uint64
InTownVisitName string
InTownVisitType string
InTownLastNPCLinger time.Time
}
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: force hero to jittered waypoint 0 (departure town). Otherwise
// snapProgressToNearestPointOnRoad projects CurrentX/Y onto the polyline (used after LeaveTown).
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.TownLastNPCLingerUntil = shift(hm.TownLastNPCLingerUntil)
hm.TownLeaveAt = shift(hm.TownLeaveAt)
if hm.Excursion.Kind == model.ExcursionKindTown {
ex := &hm.Excursion
ex.TownTourEndsAt = shift(ex.TownTourEndsAt)
ex.WanderNextAt = shift(ex.WanderNextAt)
ex.TownWelcomeUntil = shift(ex.TownWelcomeUntil)
ex.TownServiceUntil = shift(ex.TownServiceUntil)
ex.TownRestUntil = shift(ex.TownRestUntil)
}
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.excursionUsesAttractors() && hm.Excursion.AttractorSet {
return math.Atan2(hm.Excursion.AttractorY-hm.CurrentY, hm.Excursion.AttractorX-hm.CurrentX)
}
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.excursionUsesAttractors() && hm.Excursion.AttractorSet {
return hm.Excursion.AttractorX, hm.Excursion.AttractorY
}
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 skips the remaining forest/wild leg and starts the return leg toward the road
// (adventure: nearest point on the frozen road polyline; roadside: saved StartX/Y on the road).
// Does not end the session until the hero reaches the return attractor. Rejects combat.
func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if !hm.Excursion.Active() {
return false
}
if hm.Excursion.Kind == model.ExcursionKindTown {
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.enterAdventureReturnToRoad()
hm.refreshSpeed(now)
return true
}
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
hm.Excursion.Phase = model.ExcursionReturn
hm.setRoadsideReturnAttractor()
hm.RestUntil = time.Time{}
hm.RoadsideThoughtNextAt = time.Time{}
hm.refreshSpeed(now)
return true
}
if hm.State == model.StateWalking && hm.Excursion.Kind == model.ExcursionKindAdventure {
hm.enterAdventureReturnToRoad()
hm.refreshSpeed(now)
return true
}
return false
}
// 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{}
hm.TownLastNPCLingerUntil = 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) excursionUsesAttractors() bool {
return hm != nil && hm.Excursion.Active() && hm.Excursion.Kind != model.ExcursionKindNone && hm.Excursion.Kind != model.ExcursionKindTown
}
func excursionArrivalEpsilon() float64 {
cfg := tuning.Get()
eps := cfg.ExcursionArrivalEpsilonWorld
if eps <= 0 {
eps = tuning.DefaultValues().ExcursionArrivalEpsilonWorld
}
return eps
}
// stepTowardWorldPoint moves CurrentX/Y toward (tx, ty) at speed (world units per second).
// Uses the same arrival epsilon as excursion attractors.
func (hm *HeroMovement) stepTowardWorldPoint(dt float64, tx, ty, speed float64) bool {
if hm == nil || dt <= 0 {
return false
}
if speed <= 0 {
speed = tuning.DefaultValues().TownNPCWalkSpeed
}
eps := excursionArrivalEpsilon()
dx := tx - hm.CurrentX
dy := ty - hm.CurrentY
dist := math.Hypot(dx, dy)
if dist <= eps {
hm.CurrentX = tx
hm.CurrentY = ty
return true
}
step := speed * dt
if step >= dist {
hm.CurrentX = tx
hm.CurrentY = ty
return true
}
hm.CurrentX += dx / dist * step
hm.CurrentY += dy / dist * step
return false
}
// closestPointOnRoadSegments returns the closest point on the road polyline to (hx, hy).
func closestPointOnRoadSegments(road *Road, hx, hy float64) (float64, float64) {
if road == nil || len(road.Waypoints) < 2 {
return hx, hy
}
bestDistSq := math.MaxFloat64
bestX, bestY := hx, hy
for i := 0; i < len(road.Waypoints)-1; i++ {
ax, ay := road.Waypoints[i].X, road.Waypoints[i].Y
bx, by := road.Waypoints[i+1].X, 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
bestX, bestY = px, py
}
}
return bestX, bestY
}
func (hm *HeroMovement) pickExcursionForestAttractor(depth float64) {
if depth <= 0 {
depth = 12
}
px, py := hm.roadPerpendicularUnit()
j := 0.85 + rand.Float64()*0.3
d := depth * j
hm.Excursion.AttractorX = hm.CurrentX + px*d
hm.Excursion.AttractorY = hm.CurrentY + py*d
hm.Excursion.AttractorSet = true
}
func (hm *HeroMovement) setRoadsideReturnAttractor() {
hm.Excursion.AttractorX = hm.Excursion.StartX
hm.Excursion.AttractorY = hm.Excursion.StartY
hm.Excursion.AttractorSet = true
}
func (hm *HeroMovement) enterAdventureReturnToRoad() {
if hm.Road == nil {
return
}
rx, ry := closestPointOnRoadSegments(hm.Road, hm.CurrentX, hm.CurrentY)
hm.Excursion.Phase = model.ExcursionReturn
hm.Excursion.PendingReturnAfterCombat = false
hm.Excursion.AttractorX = rx
hm.Excursion.AttractorY = ry
hm.Excursion.AttractorSet = true
}
// TryAdventureReturnAfterCombat runs after combat victory: if the adventure timer had elapsed
// (including while movement ticks were skipped during combat), or PendingReturnAfterCombat was
// set, transition to return phase toward the road.
func (hm *HeroMovement) TryAdventureReturnAfterCombat(now time.Time) {
if hm == nil || !hm.Excursion.Active() || hm.Excursion.Kind != model.ExcursionKindAdventure {
return
}
if hm.Excursion.Phase != model.ExcursionWild {
return
}
timerDone := !hm.Excursion.AdventureEndsAt.IsZero() && !now.Before(hm.Excursion.AdventureEndsAt)
if !timerDone && !hm.Excursion.PendingReturnAfterCombat {
return
}
hm.enterAdventureReturnToRoad()
}
func (hm *HeroMovement) adventureScheduleWanderRetarget(now time.Time) {
cfg := tuning.Get()
minMs := cfg.AdventureWanderRetargetMinMs
maxMs := cfg.AdventureWanderRetargetMaxMs
if minMs <= 0 {
minMs = tuning.DefaultValues().AdventureWanderRetargetMinMs
}
if maxMs <= 0 {
maxMs = tuning.DefaultValues().AdventureWanderRetargetMaxMs
}
hm.Excursion.WanderNextAt = now.Add(randomDurationBetweenMs(minMs, maxMs))
}
func (hm *HeroMovement) adventurePickWanderAttractor() {
cfg := tuning.Get()
r := cfg.AdventureWanderRadius
if r <= 0 {
r = tuning.DefaultValues().AdventureWanderRadius
}
theta := rand.Float64() * 2 * math.Pi
rd := r * (0.25 + 0.75*rand.Float64())
hm.Excursion.AttractorX = hm.CurrentX + math.Cos(theta)*rd
hm.Excursion.AttractorY = hm.CurrentY + math.Sin(theta)*rd
hm.Excursion.AttractorSet = true
}
// stepTowardAttractor moves CurrentX/Y toward the excursion attractor. Returns true when arrived.
func (hm *HeroMovement) stepTowardAttractor(now time.Time, dt float64) bool {
if !hm.Excursion.AttractorSet {
return true
}
hm.refreshSpeed(now)
eps := excursionArrivalEpsilon()
dx := hm.Excursion.AttractorX - hm.CurrentX
dy := hm.Excursion.AttractorY - hm.CurrentY
dist := math.Hypot(dx, dy)
if dist <= eps {
hm.CurrentX = hm.Excursion.AttractorX
hm.CurrentY = hm.Excursion.AttractorY
return true
}
step := hm.Speed * dt
if step >= dist {
hm.CurrentX = hm.Excursion.AttractorX
hm.CurrentY = hm.Excursion.AttractorY
return true
}
hm.CurrentX += dx / dist * step
hm.CurrentY += dy / dist * step
return false
}
func (hm *HeroMovement) tryBeginAdventureReturn(now time.Time) {
if hm.Excursion.Kind != model.ExcursionKindAdventure || hm.Excursion.Phase != model.ExcursionWild {
return
}
if hm.Excursion.AdventureEndsAt.IsZero() || now.Before(hm.Excursion.AdventureEndsAt) {
return
}
if hm.State == model.StateFighting {
hm.Excursion.PendingReturnAfterCombat = true
return
}
hm.enterAdventureReturnToRoad()
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
if hm.excursionUsesAttractors() {
return 0, 0
}
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 := PickEnemyForHero(hm.Hero)
return true, e, true
}
return false, model.Enemy{}, true
}
// EnterTown transitions the hero into the destination town: town tour excursion (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
clearLegacyTownNPCState(hm)
hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.sentTownTourWireSig = ""
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.clearTownCenterWalk()
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
}
hm.State = model.StateInTown
hm.Hero.State = model.StateInTown
beginTownTourExcursion(hm, now, graph)
}
// LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
clearLegacyTownNPCState(hm)
hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.sentTownTourWireSig = ""
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)
// Project CurrentX/Y onto the outbound road polyline. The normal town flow walks the hero
// to the plaza first; forcing waypoint 0 caused a visible teleport away from that spot.
hm.assignRoad(graph, false)
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.TownNPCWalkToX = 0
hm.TownNPCWalkToY = 0
}
func (hm *HeroMovement) clearTownCenterWalk() {
hm.TownCenterWalkActive = false
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
hm.Hero.ExcursionKind = model.ExcursionKindNone
hm.Hero.TownTourPhase = ""
hm.Hero.TownTourNpcID = 0
hm.Hero.TownTourExitPending = false
if hm.Excursion.Active() {
hm.Hero.ExcursionKind = hm.Excursion.Kind
if hm.Excursion.Kind == model.ExcursionKindTown {
hm.Hero.ExcursionPhase = model.ExcursionWild
hm.Hero.TownTourPhase = hm.Excursion.TownTourPhase
hm.Hero.TownTourNpcID = hm.Excursion.TownTourNpcID
hm.Hero.TownTourExitPending = hm.Excursion.TownExitPending
} else {
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.ExcursionKind = s.Kind
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
sig.ExcursionStartX = s.StartX
sig.ExcursionStartY = s.StartY
sig.ExcursionAttractorX = s.AttractorX
sig.ExcursionAttractorY = s.AttractorY
sig.ExcursionAttractorSet = s.AttractorSet
sig.ExcursionAdventureEndsAt = s.AdventureEndsAt
sig.ExcursionWanderNextAt = s.WanderNextAt
sig.ExcursionPendingReturn = s.PendingReturnAfterCombat
}
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.InTownNPCWalkToX = hm.TownNPCWalkToX
sig.InTownNPCWalkToY = hm.TownNPCWalkToY
sig.InTownPlazaHeal = hm.TownPlazaHealActive
sig.InTownCenterWalkActive = hm.TownCenterWalkActive
sig.InTownCenterWalkToX = hm.TownCenterWalkToX
sig.InTownCenterWalkToY = hm.TownCenterWalkToY
sig.InTownNPCQueueLen = len(hm.TownNPCQueue)
sig.InTownNPCQueueFP = npcQueueFingerprint(hm.TownNPCQueue)
sig.InTownVisitName = hm.TownVisitNPCName
sig.InTownVisitType = hm.TownVisitNPCType
sig.InTownLastNPCLinger = hm.TownLastNPCLingerUntil
}
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,
NPCWalkToX: hm.TownNPCWalkToX,
NPCWalkToY: hm.TownNPCWalkToY,
}
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.TownLastNPCLingerUntil.IsZero() {
t := hm.TownLastNPCLingerUntil
p.TownLastNPCLingerUntil = &t
}
if hm.TownPlazaHealActive {
p.TownPlazaHealActive = true
}
if hm.TownCenterWalkActive {
p.CenterWalkActive = true
p.CenterWalkToX = hm.TownCenterWalkToX
p.CenterWalkToY = hm.TownCenterWalkToY
}
}
// 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{
Kind: string(s.Kind),
Phase: string(s.Phase),
DepthWorldUnits: s.DepthWorldUnits,
RoadFreezeWaypoint: s.RoadFreezeWaypoint,
RoadFreezeFraction: s.RoadFreezeFraction,
StartX: s.StartX,
StartY: s.StartY,
AttractorX: s.AttractorX,
AttractorY: s.AttractorY,
AttractorSet: s.AttractorSet,
PendingReturnAfterCombat: s.PendingReturnAfterCombat,
}
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
}
if !s.AdventureEndsAt.IsZero() {
t := s.AdventureEndsAt
ep.AdventureEndsAt = &t
}
if !s.WanderNextAt.IsZero() {
t := s.WanderNextAt
ep.WanderNextAt = &t
}
if s.Kind == model.ExcursionKindTown {
ep.TownTourPhase = s.TownTourPhase
ep.TownTourNpcID = s.TownTourNpcID
ep.TownTourStandX = s.TownTourStandX
ep.TownTourStandY = s.TownTourStandY
ep.TownExitPending = s.TownExitPending
ep.TownTourDialogOpen = s.TownTourDialogOpen
ep.TownTourInteractionOpen = s.TownTourInteractionOpen
if !s.TownTourEndsAt.IsZero() {
t := s.TownTourEndsAt
ep.TownTourEndsAt = &t
}
if !s.TownWelcomeUntil.IsZero() {
t := s.TownWelcomeUntil
ep.TownWelcomeUntil = &t
}
if !s.TownServiceUntil.IsZero() {
t := s.TownServiceUntil
ep.TownServiceUntil = &t
}
if !s.TownRestUntil.IsZero() {
t := s.TownRestUntil
ep.TownRestUntil = &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
}
if blob.TownLastNPCLingerUntil != nil {
hm.TownLastNPCLingerUntil = *blob.TownLastNPCLingerUntil
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
hm.TownNPCWalkTargetID = blob.NPCWalkTargetID
hm.TownNPCWalkToX = blob.NPCWalkToX
hm.TownNPCWalkToY = blob.NPCWalkToY
hm.TownPlazaHealActive = blob.TownPlazaHealActive
hm.TownCenterWalkToX = blob.CenterWalkToX
hm.TownCenterWalkToY = blob.CenterWalkToY
hm.TownCenterWalkActive = blob.CenterWalkActive
if !hm.TownCenterWalkActive && blob.CenterWalkStart != nil && !blob.CenterWalkStart.IsZero() {
hm.TownCenterWalkActive = true
}
}
// 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) {
// Legacy offset-only excursions (no kind) cannot resume with the attractor FSM.
if ep.Kind == "" && ep.Phase != "" {
hm.Excursion = model.ExcursionSession{}
return
}
hm.Excursion.Kind = model.ExcursionKind(ep.Kind)
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
hm.Excursion.StartX = ep.StartX
hm.Excursion.StartY = ep.StartY
hm.Excursion.AttractorX = ep.AttractorX
hm.Excursion.AttractorY = ep.AttractorY
hm.Excursion.AttractorSet = ep.AttractorSet
hm.Excursion.PendingReturnAfterCombat = ep.PendingReturnAfterCombat
if ep.AdventureEndsAt != nil {
hm.Excursion.AdventureEndsAt = *ep.AdventureEndsAt
}
if ep.WanderNextAt != nil {
hm.Excursion.WanderNextAt = *ep.WanderNextAt
}
if ep.Kind == string(model.ExcursionKindTown) {
hm.Excursion.TownTourPhase = ep.TownTourPhase
hm.Excursion.TownTourNpcID = ep.TownTourNpcID
hm.Excursion.TownTourStandX = ep.TownTourStandX
hm.Excursion.TownTourStandY = ep.TownTourStandY
hm.Excursion.TownExitPending = ep.TownExitPending
hm.Excursion.TownTourDialogOpen = ep.TownTourDialogOpen
hm.Excursion.TownTourInteractionOpen = ep.TownTourInteractionOpen
if ep.TownTourEndsAt != nil {
hm.Excursion.TownTourEndsAt = *ep.TownTourEndsAt
}
if ep.TownWelcomeUntil != nil {
hm.Excursion.TownWelcomeUntil = *ep.TownWelcomeUntil
}
if ep.TownServiceUntil != nil {
hm.Excursion.TownServiceUntil = *ep.TownServiceUntil
}
if ep.TownRestUntil != nil {
hm.Excursion.TownRestUntil = *ep.TownRestUntil
}
}
}
// 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() || hm.TownNPCUILock {
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
if depth <= 0 {
depth = tuning.DefaultValues().AdventureDepthWorldUnits
}
minDur := cfg.AdventureDurationMinMs
maxDur := cfg.AdventureDurationMaxMs
if minDur <= 0 {
minDur = tuning.DefaultValues().AdventureDurationMinMs
}
if maxDur <= 0 {
maxDur = tuning.DefaultValues().AdventureDurationMaxMs
}
adventureEnds := now.Add(randomDurationBetweenMs(minDur, maxDur))
hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindAdventure,
Phase: model.ExcursionOut,
StartedAt: now,
DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction,
StartX: hm.CurrentX,
StartY: hm.CurrentY,
AdventureEndsAt: adventureEnds,
}
hm.pickExcursionForestAttractor(depth)
}
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
}
restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
hm.Excursion = model.ExcursionSession{
Kind: model.ExcursionKindRoadside,
Phase: model.ExcursionOut,
StartedAt: now,
DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction,
StartX: hm.CurrentX,
StartY: hm.CurrentY,
}
hm.pickExcursionForestAttractor(depth)
// RestUntil caps the wild (heal) phase; out/return are movement phases.
hm.RestUntil = now.Add(restDur)
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 := PickEnemyForHero(hm.Hero)
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.
// townTourOffline, when sender is nil, resolves town NPC visits without UI during offline catch-up.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
graph *RoadGraph,
now time.Time,
sender MessageSender,
onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
townTourOffline TownTourOfflineAtNPC,
) {
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:
prevPhase := hm.Excursion.Phase
hm.refreshSpeed(now)
switch hm.Excursion.Phase {
case model.ExcursionOut:
if hm.stepTowardAttractor(now, dt) {
hm.Excursion.Phase = model.ExcursionWild
}
case 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)
}
}
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
hm.setRoadsideReturnAttractor()
}
case model.ExcursionReturn:
if hm.stepTowardAttractor(now, dt) {
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)
}
}
if sender != nil && hm.Excursion.Phase != prevPhase {
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
}
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)
cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if hpFrac >= cfg.AdventureRestTargetHp {
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:
if hm.Excursion.Kind == model.ExcursionKindTown {
processTownTourMovement(heroID, hm, graph, now, sender, adventureLog, townTourOffline)
return
}
// Legacy in-town row without town excursion: force exit.
if graph != 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
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 adventure excursion (attractor movement while walking) ---
if hm.Excursion.Active() && hm.Excursion.Kind == model.ExcursionKindAdventure {
dtAdv := now.Sub(hm.LastMoveTick).Seconds()
if dtAdv <= 0 {
dtAdv = movementTickRate().Seconds()
}
prevPhase := hm.Excursion.Phase
hm.refreshSpeed(now)
if hm.Excursion.Phase == model.ExcursionOut {
if hm.stepTowardAttractor(now, dtAdv) {
hm.Excursion.Phase = model.ExcursionWild
hm.adventureScheduleWanderRetarget(now)
hm.adventurePickWanderAttractor()
}
}
if hm.Excursion.Phase == model.ExcursionWild {
hm.tryBeginAdventureReturn(now)
}
if hm.Excursion.Phase == model.ExcursionWild {
if !hm.Excursion.WanderNextAt.IsZero() && !now.Before(hm.Excursion.WanderNextAt) {
hm.adventurePickWanderAttractor()
hm.adventureScheduleWanderRetarget(now)
}
_ = hm.stepTowardAttractor(now, dtAdv)
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))
}
hm.LastMoveTick = now
return
}
if onEncounter != nil || onMerchantEncounter != nil {
monster, enemy, hit := hm.rollAdventureEncounter(now, graph)
if hit {
if monster && onEncounter != nil {
hm.LastEncounterAt = now
onEncounter(hm, &enemy, now)
hm.LastMoveTick = 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)
}
hm.LastMoveTick = now
return
}
}
}
}
if hm.Excursion.Phase == model.ExcursionReturn {
if hm.stepTowardAttractor(now, dtAdv) {
hm.endExcursion(now)
hm.refreshSpeed(now)
if sender != nil {
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
}
}
}
if sender != nil && hm.Excursion.Phase != prevPhase {
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
}
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()
}
}