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.

1736 lines
52 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, message string)
// HeroMovement holds the live movement state for a single online hero.
type HeroMovement struct {
HeroID int64
Hero *model.Hero // live reference, owned by the engine
CurrentX float64
CurrentY float64
Speed float64 // effective world-units/sec
State model.GameState
DestinationTownID int64
CurrentTownID int64
Road *Road
WaypointIndex int // index of the waypoint we are heading toward
WaypointFraction float64 // 0..1 within the current segment
LastEncounterAt time.Time
RestUntil time.Time
LastMoveTick time.Time
Direction int // +1 forward along TownOrder, -1 backward
// TownNPCQueue: NPC ids still to visit this stay (nil = not on NPC tour). Cleared in LeaveTown.
TownNPCQueue []int64
NextTownNPCRollAt time.Time
// Town NPC visit: adventure log lines until NextTownNPCRollAt (narration block) after town_npc_visit.
TownVisitNPCName string
TownVisitNPCType string
TownVisitStartedAt time.Time
TownVisitLogsEmitted int
// TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause).
TownLeaveAt time.Time
// Accumulates fractional town-rest regen between ticks.
TownRestHealRemainder float64
// WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only).
WanderingMerchantDeadline time.Time
// Excursion holds the live mini-adventure session state.
// When Excursion.Phase == ExcursionNone the hero is on the road (normal walk / town / roadside rest).
Excursion model.ExcursionSession
// ActiveRestKind discriminates the current rest context when State == StateResting.
ActiveRestKind model.RestKind
// RestHealRemainder accumulates fractional HP between ticks for roadside / adventure-inline rest.
RestHealRemainder float64
// LastExcursionEndedAt is used for adventure cooldown (not persisted; resets on reconnect).
LastExcursionEndedAt time.Time
// spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad
// instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
spawnAtRoadStart bool
}
// NewHeroMovement creates a HeroMovement for a hero that just connected.
// It initializes position, state, and picks the first destination if needed.
func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMovement {
// Randomize direction per hero so they don't all walk the same way.
dir := 1
if hero.ID%2 == 0 {
dir = -1
}
// Persisted (x,y) already include any in-world offset from prior sessions; do not add
// lateral jitter again on reconnect (that doubled the shift every reload).
freshWorldSpawn := hero.PositionX == 0 && hero.PositionY == 0
var curX, curY float64
if freshWorldSpawn {
curX, curY = 0, 0 // assignRoad will snap to the departure waypoint of the chosen road
} else {
curX = hero.PositionX
curY = hero.PositionY
}
hm := &HeroMovement{
HeroID: hero.ID,
Hero: hero,
CurrentX: curX,
CurrentY: curY,
State: hero.State,
LastMoveTick: now,
Direction: dir,
spawnAtRoadStart: freshWorldSpawn,
}
// Restore persisted movement state.
if hero.CurrentTownID != nil {
hm.CurrentTownID = *hero.CurrentTownID
}
if hero.DestinationTownID != nil {
hm.DestinationTownID = *hero.DestinationTownID
}
hm.refreshSpeed(now)
// If the hero is dead, keep them dead.
if hero.State == model.StateDead || hero.HP <= 0 {
hm.State = model.StateDead
return hm
}
// If fighting, leave as-is (engine combat system manages it).
if hero.State == model.StateFighting {
return hm
}
// Resting / in-town: restore persisted deadlines and NPC tour from DB (town_pause).
if hero.State == model.StateResting || hero.State == model.StateInTown {
hm.State = hero.State
hm.applyTownPauseFromHero(hero, now)
// For roadside / adventure-inline rest the hero needs a road for display offset calculations.
if hm.ActiveRestKind == model.RestKindRoadside || hm.ActiveRestKind == model.RestKindAdventureInline {
if hm.DestinationTownID == 0 {
hm.pickDestination(graph)
}
hm.assignRoad(graph)
if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 {
hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
from := hm.Road.Waypoints[hm.WaypointIndex]
to := hm.Road.Waypoints[hm.WaypointIndex+1]
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
}
}
return hm
}
// Walking state: assign a road if we don't have a destination.
if hm.DestinationTownID == 0 {
hm.pickDestination(graph)
}
hm.assignRoad(graph)
if hm.Road == nil {
hm.pickDestination(graph)
hm.assignRoad(graph)
}
hm.State = model.StateWalking
// Restore excursion session from persisted blob (hero may have disconnected mid-adventure).
if hero.TownPause != nil && hero.TownPause.Excursion != nil {
hm.applyExcursionFromBlob(hero.TownPause.Excursion)
if hm.Excursion.Active() && hm.Road != nil && hm.Excursion.RoadFreezeWaypoint < len(hm.Road.Waypoints)-1 {
hm.WaypointIndex = hm.Excursion.RoadFreezeWaypoint
hm.WaypointFraction = hm.Excursion.RoadFreezeFraction
from := hm.Road.Waypoints[hm.WaypointIndex]
to := hm.Road.Waypoints[hm.WaypointIndex+1]
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
}
}
return hm
}
// firstReachableOnRing returns the first town along TownOrder (stepping by Direction)
// that has a direct road from CurrentTownID, or 0 if none.
func (hm *HeroMovement) firstReachableOnRing(graph *RoadGraph, fromIdx int) int64 {
n := len(graph.TownOrder)
if n < 2 || fromIdx < 0 {
return 0
}
for step := 1; step < n; step++ {
raw := fromIdx + hm.Direction*step
nextIdx := ((raw % n) + n) % n
candidate := graph.TownOrder[nextIdx]
if candidate == hm.CurrentTownID {
continue
}
if graph.FindRoad(hm.CurrentTownID, candidate) != nil {
return candidate
}
}
return 0
}
func (hm *HeroMovement) firstOutgoingDestination(graph *RoadGraph) int64 {
for _, r := range graph.TownRoads[hm.CurrentTownID] {
if r != nil && r.ToTownID != hm.CurrentTownID {
return r.ToTownID
}
}
return 0
}
func (hm *HeroMovement) firstReachableAny(graph *RoadGraph) int64 {
for _, tid := range graph.TownOrder {
if tid == hm.CurrentTownID {
continue
}
if graph.FindRoad(hm.CurrentTownID, tid) != nil {
return tid
}
}
return 0
}
// avoidSelfLoopDestination forces a different destination than CurrentTownID when the world
// has multiple towns. Roads are always between distinct towns; dest == current yields no road
// and the hero never moves (common after DB fallback or mis-picked nearest town at 0,0).
func (hm *HeroMovement) avoidSelfLoopDestination(graph *RoadGraph) {
if graph == nil || len(graph.TownOrder) <= 1 {
return
}
if hm.CurrentTownID == 0 || hm.DestinationTownID != hm.CurrentTownID {
return
}
if d := hm.firstOutgoingDestination(graph); d != 0 && d != hm.CurrentTownID {
hm.DestinationTownID = d
return
}
if nxt := graph.NextTownInChain(hm.CurrentTownID); nxt != 0 && nxt != hm.CurrentTownID {
hm.DestinationTownID = nxt
}
}
// crossRoadChance is the probability of picking a cross-road instead of following the ring.
const crossRoadChance = 0.3
// pickDestination selects the next town the hero should walk toward.
// Only towns connected by a roads row are chosen — TownOrder alone is not enough.
// When multiple outgoing roads exist, there's a chance the hero takes a cross-road.
func (hm *HeroMovement) pickDestination(graph *RoadGraph) {
defer hm.avoidSelfLoopDestination(graph)
if hm.CurrentTownID == 0 {
if hm.CurrentX == 0 && hm.CurrentY == 0 && len(graph.TownOrder) > 0 {
hm.CurrentTownID = graph.TownOrder[0]
} else {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
}
}
n := len(graph.TownOrder)
if n == 0 {
hm.DestinationTownID = 0
return
}
if n == 1 {
hm.DestinationTownID = hm.CurrentTownID
return
}
idx := graph.TownOrderIndex(hm.CurrentTownID)
if idx < 0 {
if d := hm.firstOutgoingDestination(graph); d != 0 {
hm.DestinationTownID = d
return
}
if d := hm.firstReachableAny(graph); d != 0 {
hm.DestinationTownID = d
return
}
if len(graph.TownOrder) > 0 {
hm.DestinationTownID = graph.TownOrder[0]
}
return
}
// When multiple roads are available, sometimes take a cross-road for variety.
outgoing := graph.TownRoads[hm.CurrentTownID]
if len(outgoing) > 2 && rand.Float64() < crossRoadChance {
pick := outgoing[rand.Intn(len(outgoing))]
if pick != nil && pick.ToTownID != hm.CurrentTownID {
hm.DestinationTownID = pick.ToTownID
return
}
}
if dest := hm.firstReachableOnRing(graph, idx); dest != 0 {
hm.DestinationTownID = dest
return
}
if d := hm.firstOutgoingDestination(graph); d != 0 {
hm.DestinationTownID = d
return
}
if d := hm.firstReachableAny(graph); d != 0 {
hm.DestinationTownID = d
return
}
hm.DestinationTownID = hm.CurrentTownID
}
// assignRoad finds and configures the road from CurrentTownID to DestinationTownID.
// If no road exists (hero is mid-road), it finds the nearest town and routes from there.
func (hm *HeroMovement) assignRoad(graph *RoadGraph) {
road := graph.FindRoad(hm.CurrentTownID, hm.DestinationTownID)
if road == nil {
// Try finding a road from any nearby town.
nearest := graph.NearestTown(hm.CurrentX, hm.CurrentY)
hm.CurrentTownID = nearest
road = graph.FindRoad(nearest, hm.DestinationTownID)
}
if road == nil {
// No road available, will retry next tick.
return
}
// Create a per-hero jittered copy of waypoints so heroes don't overlap on the same road.
jitteredWaypoints := make([]Point, len(road.Waypoints))
copy(jitteredWaypoints, road.Waypoints)
heroSeed := float64(hm.HeroID)
lateralJitter := (math.Sin(heroSeed*1.7) * 1.5) // ±1.5 tiles lateral offset
for i := 1; i < len(jitteredWaypoints)-1; i++ {
// Apply perpendicular offset (don't jitter start/end = town centers)
dx := jitteredWaypoints[i].X - jitteredWaypoints[max(0, i-1)].X
dy := jitteredWaypoints[i].Y - jitteredWaypoints[max(0, i-1)].Y
segLen := math.Hypot(dx, dy)
if segLen > 0.1 {
perpX := -dy / segLen
perpY := dx / segLen
jitter := lateralJitter * (0.7 + 0.3*math.Sin(heroSeed*0.3+float64(i)*0.5))
jitteredWaypoints[i].X += perpX * jitter
jitteredWaypoints[i].Y += perpY * jitter
}
}
jitteredRoad := &Road{
ID: road.ID,
FromTownID: road.FromTownID,
ToTownID: road.ToTownID,
Distance: road.Distance,
Waypoints: jitteredWaypoints,
}
hm.Road = jitteredRoad
if hm.spawnAtRoadStart {
wp0 := jitteredRoad.Waypoints[0]
hm.CurrentX = wp0.X
hm.CurrentY = wp0.Y
hm.WaypointIndex = 0
hm.WaypointFraction = 0
hm.spawnAtRoadStart = false
} else {
// Restore progress along this hero's jittered polyline from saved world position.
hm.snapProgressToNearestPointOnRoad()
}
}
// snapProgressToNearestPointOnRoad sets WaypointIndex, WaypointFraction, and CurrentX/Y
// to the closest point on the current road polyline to the incoming position.
func (hm *HeroMovement) snapProgressToNearestPointOnRoad() {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
hm.WaypointIndex = 0
hm.WaypointFraction = 0
return
}
hx, hy := hm.CurrentX, hm.CurrentY
bestIdx := 0
bestT := 0.0
bestDistSq := math.MaxFloat64
bestX, bestY := hx, hy
for i := 0; i < len(hm.Road.Waypoints)-1; i++ {
ax, ay := hm.Road.Waypoints[i].X, hm.Road.Waypoints[i].Y
bx, by := hm.Road.Waypoints[i+1].X, hm.Road.Waypoints[i+1].Y
dx, dy := bx-ax, by-ay
segLenSq := dx*dx + dy*dy
var t float64
if segLenSq < 1e-12 {
t = 0
} else {
t = ((hx-ax)*dx + (hy-ay)*dy) / segLenSq
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
}
px := ax + t*dx
py := ay + t*dy
dSq := (hx-px)*(hx-px) + (hy-py)*(hy-py)
if dSq < bestDistSq {
bestDistSq = dSq
bestIdx = i
bestT = t
bestX, bestY = px, py
}
}
hm.WaypointIndex = bestIdx
hm.WaypointFraction = bestT
hm.CurrentX = bestX
hm.CurrentY = bestY
}
// ShiftGameDeadlines advances movement-related deadlines by d (wall time spent paused) so
// simulation does not “catch up” after resume. LastMoveTick is set to now to avoid a huge dt on the next tick.
func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
if d <= 0 {
hm.LastMoveTick = now
return
}
shift := func(t time.Time) time.Time {
if t.IsZero() {
return t
}
return t.Add(d)
}
hm.LastEncounterAt = shift(hm.LastEncounterAt)
hm.RestUntil = shift(hm.RestUntil)
hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt)
hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt)
hm.TownLeaveAt = shift(hm.TownLeaveAt)
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil)
hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil)
hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt)
hm.LastMoveTick = now
}
// refreshSpeed recalculates the effective movement speed using hero buffs/debuffs.
func (hm *HeroMovement) refreshSpeed(now time.Time) {
// Per-hero speed variation: ±10% based on hero ID for natural spread.
heroSpeedJitter := 0.90 + float64(hm.HeroID%21)*0.01 // 0.90 to 1.10
hm.Speed = tuning.Get().BaseMoveSpeed * hm.Hero.MovementSpeedMultiplier(now) * heroSpeedJitter
}
// AdvanceTick moves the hero along the road for one movement tick.
// Returns true if the hero reached the destination town this tick.
func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTown bool) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false
}
if hm.Excursion.Active() {
hm.LastMoveTick = now
return false
}
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = movementTickRate().Seconds()
}
hm.LastMoveTick = now
hm.refreshSpeed(now)
distThisTick := hm.Speed * dt
for distThisTick > 0 && hm.WaypointIndex < len(hm.Road.Waypoints)-1 {
from := hm.Road.Waypoints[hm.WaypointIndex]
to := hm.Road.Waypoints[hm.WaypointIndex+1]
segLen := math.Hypot(to.X-from.X, to.Y-from.Y)
if segLen < 0.001 {
hm.WaypointIndex++
hm.WaypointFraction = 0
continue
}
// How far along this segment we already are.
currentDist := hm.WaypointFraction * segLen
remaining := segLen - currentDist
arrivalRadius := tuning.Get().TownArrivalRadius
if arrivalRadius < 0 {
arrivalRadius = 0
}
if distThisTick >= remaining || (hm.WaypointIndex == len(hm.Road.Waypoints)-2 && remaining <= arrivalRadius) {
// Move to next waypoint.
distThisTick -= remaining
hm.WaypointIndex++
hm.WaypointFraction = 0
if hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
// Reached final waypoint = destination town.
last := hm.Road.Waypoints[len(hm.Road.Waypoints)-1]
hm.CurrentX = last.X
hm.CurrentY = last.Y
return true
}
} else {
// Partial advance within this segment.
hm.WaypointFraction = (currentDist + distThisTick) / segLen
hm.CurrentX = from.X + (to.X-from.X)*hm.WaypointFraction
hm.CurrentY = from.Y + (to.Y-from.Y)*hm.WaypointFraction
distThisTick = 0
}
}
// Update position to the current waypoint position.
if hm.WaypointIndex < len(hm.Road.Waypoints) {
wp := hm.Road.Waypoints[hm.WaypointIndex]
if hm.WaypointFraction == 0 {
hm.CurrentX = wp.X
hm.CurrentY = wp.Y
}
}
return false
}
// Heading returns the angle (radians) the hero is currently facing.
func (hm *HeroMovement) Heading() float64 {
if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
return 0
}
to := hm.Road.Waypoints[hm.WaypointIndex+1]
return math.Atan2(to.Y-hm.CurrentY, to.X-hm.CurrentX)
}
// TargetPoint returns the next waypoint the hero is heading toward.
func (hm *HeroMovement) TargetPoint() (float64, float64) {
if hm.Road == nil || hm.WaypointIndex >= len(hm.Road.Waypoints)-1 {
return hm.CurrentX, hm.CurrentY
}
wp := hm.Road.Waypoints[hm.WaypointIndex+1]
return wp.X, wp.Y
}
func (hm *HeroMovement) applyTownRestHeal(dt float64) {
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
return
}
cfg := tuning.Get()
rawGain := float64(hm.Hero.MaxHP)*cfg.TownRestHPPerS*dt + hm.TownRestHealRemainder
gain := int(math.Floor(rawGain))
hm.TownRestHealRemainder = rawGain - float64(gain)
if gain <= 0 {
return
}
hm.Hero.HP += gain
if hm.Hero.HP > hm.Hero.MaxHP {
hm.Hero.HP = hm.Hero.MaxHP
}
}
// AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest).
func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error {
if graph == nil || townID == 0 {
return fmt.Errorf("invalid town")
}
if _, ok := graph.Towns[townID]; !ok {
return fmt.Errorf("unknown town")
}
hm.Road = nil
hm.WaypointIndex = 0
hm.WaypointFraction = 0
hm.DestinationTownID = townID
hm.spawnAtRoadStart = false
hm.WanderingMerchantDeadline = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
t := graph.Towns[townID]
hm.CurrentX = t.WorldX
hm.CurrentY = t.WorldY
hm.EnterTown(now, graph)
return nil
}
// AdminStartRoadsideRest forces the hero into roadside rest on the current road segment.
func (hm *HeroMovement) AdminStartRoadsideRest(now time.Time) bool {
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
return false
}
if hm.State == model.StateFighting {
return false
}
hm.WanderingMerchantDeadline = time.Time{}
hm.beginRoadsideRest(now)
return true
}
// AdminStartExcursion forces a mini-adventure (excursion) session while the hero is walking on a road.
// Cooldown and random roll are bypassed; the hero must not already be in an excursion.
func (hm *HeroMovement) AdminStartExcursion(now time.Time) bool {
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
return false
}
if hm.State == model.StateFighting {
return false
}
if hm.State != model.StateWalking {
return false
}
if hm.Excursion.Active() {
return false
}
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false
}
hm.WanderingMerchantDeadline = time.Time{}
hm.beginExcursion(now)
return true
}
// AdminStopExcursion ends an active excursion immediately (hero back on the road spine).
// Works during walking phases or adventure-inline rest; rejects combat.
func (hm *HeroMovement) AdminStopExcursion(now time.Time) bool {
if !hm.Excursion.Active() {
return false
}
if hm.State == model.StateFighting {
return false
}
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindAdventureInline {
hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
hm.RestHealRemainder = 0
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
}
hm.endExcursion(now)
hm.refreshSpeed(now)
return true
}
// AdminStopRest exits any non-town rest (roadside or adventure-inline) back to walking.
func (hm *HeroMovement) AdminStopRest(now time.Time) bool {
if hm.State != model.StateResting {
return false
}
if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline {
return false
}
if hm.ActiveRestKind == model.RestKindAdventureInline && hm.Excursion.Active() {
hm.endExcursion(now)
}
hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
hm.RestHealRemainder = 0
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.refreshSpeed(now)
return true
}
// AdminStartRest forces a resting period (same duration model as town rest).
func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead {
return false
}
if hm.State == model.StateFighting {
return false
}
hm.WanderingMerchantDeadline = time.Time{}
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownRestHealRemainder = 0
if graph != nil && hm.CurrentTownID == 0 {
hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY)
}
hm.State = model.StateResting
hm.Hero.State = model.StateResting
hm.ActiveRestKind = model.RestKindTown
hm.RestUntil = now.Add(randomRestDuration())
return true
}
func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 0, 1
}
idx := hm.WaypointIndex
if idx >= len(hm.Road.Waypoints)-1 {
idx = len(hm.Road.Waypoints) - 2
}
if idx < 0 {
return 0, 1
}
from := hm.Road.Waypoints[idx]
to := hm.Road.Waypoints[idx+1]
dx := to.X - from.X
dy := to.Y - from.Y
L := math.Hypot(dx, dy)
if L < 1e-6 {
return 0, 1
}
return -dy / L, dx / L
}
// roadForwardUnit is the normalized tangent along the road toward the next waypoint.
func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return 1, 0
}
idx := hm.WaypointIndex
if idx >= len(hm.Road.Waypoints)-1 {
idx = len(hm.Road.Waypoints) - 2
}
if idx < 0 {
return 1, 0
}
from := hm.Road.Waypoints[idx]
to := hm.Road.Waypoints[idx+1]
dx := to.X - from.X
dy := to.Y - from.Y
L := math.Hypot(dx, dy)
if L < 1e-6 {
return 1, 0
}
return dx / L, dy / L
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
exc := &hm.Excursion
if exc.Active() {
cfg := tuning.Get()
perpX, perpY := hm.roadPerpendicularUnit()
depth := exc.DepthWorldUnits
var t float64
switch exc.Phase {
case model.ExcursionOut:
outMs := float64(cfg.AdventureOutDurationMs)
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(cfg.AdventureReturnDurationMs)
if retMs > 0 {
returnStart := exc.ReturnUntil.Add(-time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
elapsed := float64(now.Sub(returnStart).Milliseconds())
t = 1.0 - smoothstep(clamp01(elapsed / retMs))
}
}
d := depth * t
return perpX * d, perpY * d
}
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
perpX, perpY := hm.roadPerpendicularUnit()
const roadsideDepth = 2.0
return perpX * roadsideDepth, perpY * roadsideDepth
}
return 0, 0
}
// WanderingMerchantCost matches REST encounter / npc alms pricing.
func WanderingMerchantCost(level int) int64 {
cfg := tuning.Get()
return cfg.MerchantCostBase + int64(level)*cfg.MerchantCostPerLevel
}
// rollRoadEncounter returns whether to trigger an encounter; if so, monster true means combat.
func (hm *HeroMovement) rollRoadEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
cfg := tuning.Get()
if hm.Road == nil || len(hm.Road.Waypoints) < 2 {
return false, model.Enemy{}, false
}
if now.Sub(hm.LastEncounterAt) < time.Duration(cfg.EncounterCooldownBaseMs)*time.Millisecond {
return false, model.Enemy{}, false
}
if rand.Float64() >= cfg.EncounterActivityBase {
return false, model.Enemy{}, false
}
monsterW := cfg.MonsterEncounterWeightBase
merchantW := cfg.MerchantEncounterWeightBase + cfg.MerchantEncounterWeightRoadBonus
total := monsterW + merchantW
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForLevel(hm.Hero.Level)
return true, e, true
}
return false, model.Enemy{}, true
}
// EnterTown transitions the hero into the destination town: NPC tour (StateInTown) when there
// are NPCs, otherwise a short resting state (StateResting).
func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
destID := hm.DestinationTownID
hm.CurrentTownID = destID
hm.DestinationTownID = 0
hm.Road = nil
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownRestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
ids := graph.TownNPCIDs(destID)
if len(ids) == 0 {
hm.State = model.StateResting
hm.Hero.State = model.StateResting
hm.ActiveRestKind = model.RestKindTown
hm.RestUntil = now.Add(randomRestDuration())
return
}
q := make([]int64, len(ids))
copy(q, ids)
rand.Shuffle(len(q), func(i, j int) { q[i], q[j] = q[j], q[i] })
hm.TownNPCQueue = q
hm.State = model.StateInTown
hm.Hero.State = model.StateInTown
hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay())
}
// LeaveTown transitions the hero from town to walking, picking a new destination.
func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.TownRestHealRemainder = 0
hm.RestUntil = time.Time{}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.Excursion = model.ExcursionSession{}
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
// Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick.
hm.LastMoveTick = now
hm.pickDestination(graph)
hm.assignRoad(graph)
hm.refreshSpeed(now)
}
func randomTownNPCDelay() time.Duration {
cfg := tuning.Get()
minDelay := time.Duration(cfg.TownNPCRollMinMs) * time.Millisecond
maxDelay := time.Duration(cfg.TownNPCRollMaxMs) * time.Millisecond
rangeMs := (maxDelay - minDelay).Milliseconds()
if rangeMs < 0 {
rangeMs = 0
}
return minDelay + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// StartFighting pauses movement for combat.
func (hm *HeroMovement) StartFighting() {
hm.State = model.StateFighting
hm.WanderingMerchantDeadline = time.Time{}
}
// ResumWalking resumes movement after combat.
func (hm *HeroMovement) ResumeWalking(now time.Time) {
hm.State = model.StateWalking
hm.LastMoveTick = now
hm.refreshSpeed(now)
}
// Die sets the movement state to dead.
func (hm *HeroMovement) Die() {
hm.State = model.StateDead
}
// SyncToHero writes movement state back to the hero model for persistence.
// Position uses the same world coordinates as hero_move / position_sync (road spine + display offset).
func (hm *HeroMovement) SyncToHero() {
now := time.Now()
ox, oy := hm.displayOffset(now)
hm.Hero.PositionX = hm.CurrentX + ox
hm.Hero.PositionY = hm.CurrentY + oy
hm.Hero.State = hm.State
if hm.CurrentTownID != 0 {
id := hm.CurrentTownID
hm.Hero.CurrentTownID = &id
} else {
hm.Hero.CurrentTownID = nil
}
if hm.DestinationTownID != 0 {
id := hm.DestinationTownID
hm.Hero.DestinationTownID = &id
} else {
hm.Hero.DestinationTownID = nil
}
hm.Hero.MoveState = string(hm.State)
hm.Hero.RestKind = model.RestKindNone
if hm.State == model.StateResting {
if hm.ActiveRestKind != model.RestKindNone {
hm.Hero.RestKind = hm.ActiveRestKind
} else {
hm.Hero.RestKind = model.RestKindTown
}
}
hm.Hero.TownPause = hm.townPauseBlob()
}
func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
var p *model.TownPausePersisted
switch hm.State {
case model.StateResting:
rk := model.RestKindTown
if hm.ActiveRestKind != model.RestKindNone {
rk = hm.ActiveRestKind
}
if rk == model.RestKindTown && hm.RestUntil.IsZero() {
break
}
p = &model.TownPausePersisted{
RestKind: rk,
TownRestHealRemainder: hm.TownRestHealRemainder,
RestHealRemainder: hm.RestHealRemainder,
}
if !hm.RestUntil.IsZero() {
t := hm.RestUntil
p.RestUntil = &t
}
case model.StateInTown:
p = &model.TownPausePersisted{
TownVisitNPCName: hm.TownVisitNPCName,
TownVisitNPCType: hm.TownVisitNPCType,
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
}
if len(hm.TownNPCQueue) > 0 {
p.NPCQueue = append([]int64(nil), hm.TownNPCQueue...)
}
if !hm.NextTownNPCRollAt.IsZero() {
t := hm.NextTownNPCRollAt
p.NextTownNPCRollAt = &t
}
if !hm.TownLeaveAt.IsZero() {
t := hm.TownLeaveAt
p.TownLeaveAt = &t
}
if !hm.TownVisitStartedAt.IsZero() {
t := hm.TownVisitStartedAt
p.TownVisitStartedAt = &t
}
}
// Persist active excursion session regardless of hero state (the hero can be fighting
// or resting while an excursion is in progress).
if hm.Excursion.Active() {
ep := hm.excursionPersisted()
if p == nil {
p = &model.TownPausePersisted{}
}
p.Excursion = ep
}
return p
}
func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
s := &hm.Excursion
ep := &model.ExcursionPersisted{
Phase: string(s.Phase),
DepthWorldUnits: s.DepthWorldUnits,
RoadFreezeWaypoint: s.RoadFreezeWaypoint,
RoadFreezeFraction: s.RoadFreezeFraction,
}
if !s.StartedAt.IsZero() {
t := s.StartedAt
ep.StartedAt = &t
}
if !s.WildUntil.IsZero() {
t := s.WildUntil
ep.WildUntil = &t
}
if !s.ReturnUntil.IsZero() {
t := s.ReturnUntil
ep.ReturnUntil = &t
}
return ep
}
func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time) {
blob := hero.TownPause
switch hero.State {
case model.StateResting:
if blob != nil && (blob.RestKind != model.RestKindNone || (blob.RestUntil != nil && !blob.RestUntil.IsZero())) {
if blob.RestUntil != nil && !blob.RestUntil.IsZero() {
hm.RestUntil = *blob.RestUntil
}
hm.ActiveRestKind = blob.RestKind
hm.TownRestHealRemainder = blob.TownRestHealRemainder
hm.RestHealRemainder = blob.RestHealRemainder
} else {
// Legacy row without town_pause: treat rest as already elapsed so offline/reconnect unblocks.
hm.RestUntil = now.Add(-time.Millisecond)
}
case model.StateInTown:
if blob == nil {
return
}
if len(blob.NPCQueue) > 0 {
hm.TownNPCQueue = append([]int64(nil), blob.NPCQueue...)
}
if blob.NextTownNPCRollAt != nil {
hm.NextTownNPCRollAt = *blob.NextTownNPCRollAt
}
if blob.TownLeaveAt != nil {
hm.TownLeaveAt = *blob.TownLeaveAt
}
hm.TownVisitNPCName = blob.TownVisitNPCName
hm.TownVisitNPCType = blob.TownVisitNPCType
if blob.TownVisitStartedAt != nil {
hm.TownVisitStartedAt = *blob.TownVisitStartedAt
}
hm.TownVisitLogsEmitted = blob.TownVisitLogsEmitted
}
// Restore excursion session from blob (may exist alongside any hero state).
if blob != nil && blob.Excursion != nil {
hm.applyExcursionFromBlob(blob.Excursion)
}
}
func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
hm.Excursion.Phase = model.ExcursionPhase(ep.Phase)
if ep.StartedAt != nil {
hm.Excursion.StartedAt = *ep.StartedAt
}
if ep.WildUntil != nil {
hm.Excursion.WildUntil = *ep.WildUntil
}
if ep.ReturnUntil != nil {
hm.Excursion.ReturnUntil = *ep.ReturnUntil
}
hm.Excursion.DepthWorldUnits = ep.DepthWorldUnits
hm.Excursion.RoadFreezeWaypoint = ep.RoadFreezeWaypoint
hm.Excursion.RoadFreezeFraction = ep.RoadFreezeFraction
}
// MovePayload builds the hero_move WS payload (includes off-road lateral offset for display).
func (hm *HeroMovement) MovePayload(now time.Time) model.HeroMovePayload {
tx, ty := hm.TargetPoint()
ox, oy := hm.displayOffset(now)
return model.HeroMovePayload{
X: hm.CurrentX + ox,
Y: hm.CurrentY + oy,
TargetX: tx + ox,
TargetY: ty + oy,
Speed: hm.Speed,
Heading: hm.Heading(),
}
}
// RoutePayload builds the route_assigned WS payload.
func (hm *HeroMovement) RoutePayload() *model.RouteAssignedPayload {
if hm.Road == nil {
return nil
}
waypoints := make([]model.PointXY, len(hm.Road.Waypoints))
for i, p := range hm.Road.Waypoints {
waypoints[i] = model.PointXY{X: p.X, Y: p.Y}
}
return &model.RouteAssignedPayload{
RoadID: hm.Road.ID,
Waypoints: waypoints,
DestinationTownID: hm.DestinationTownID,
Speed: hm.Speed,
}
}
// PositionSyncPayload builds the position_sync WS payload.
func (hm *HeroMovement) PositionSyncPayload(now time.Time) model.PositionSyncPayload {
ox, oy := hm.displayOffset(now)
return model.PositionSyncPayload{
X: hm.CurrentX + ox,
Y: hm.CurrentY + oy,
WaypointIndex: hm.WaypointIndex,
WaypointFraction: hm.WaypointFraction,
State: string(hm.State),
}
}
// randomRestDuration returns a random duration between TownRestMin and TownRestMax.
func randomRestDuration() time.Duration {
cfg := tuning.Get()
minDur := time.Duration(cfg.TownRestMinMs) * time.Millisecond
maxDur := time.Duration(cfg.TownRestMaxMs) * time.Millisecond
rangeMs := (maxDur - minDur).Milliseconds()
if rangeMs < 0 {
rangeMs = 0
}
return minDur + time.Duration(rand.Int63n(rangeMs+1))*time.Millisecond
}
// EncounterStarter starts or resolves a random encounter while walking (engine: combat;
// offline: synchronous SimulateOneFight via callback).
type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time)
// MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline).
type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64)
// AfterTownEnterPersist runs after SyncToHero when the hero arrives in town by walking (not nil = persist to DB).
type AfterTownEnterPersist func(hero *model.Hero)
func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) {
if log == nil || hm.TownVisitStartedAt.IsZero() {
return
}
logInterval := townNPCLogInterval()
for hm.TownVisitLogsEmitted < townNPCVisitLogLines {
deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * logInterval)
if now.Before(deadline) {
break
}
msg := townNPCVisitLogMessage(hm.TownVisitNPCType, hm.TownVisitNPCName, hm.TownVisitLogsEmitted)
if msg != "" {
log(heroID, msg)
}
hm.TownVisitLogsEmitted++
}
}
func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string {
if lineIndex < 0 || lineIndex >= townNPCVisitLogLines {
return ""
}
switch npcType {
case "merchant":
switch lineIndex {
case 0:
return fmt.Sprintf("You stop at %s's stall.", npcName)
case 1:
return "Crates, pouches, and price tags blur together as you browse."
case 2:
return fmt.Sprintf("%s points out a few curious trinkets.", npcName)
case 3:
return "You weigh a potion against your coin purse."
case 4:
return "A short haggle ends in a reluctant nod."
case 5:
return fmt.Sprintf("You thank %s and step back from the counter.", npcName)
}
case "healer":
switch lineIndex {
case 0:
return fmt.Sprintf("You seek out %s.", npcName)
case 1:
return "The healer examines your wounds with a calm eye."
case 2:
return "Herbs steam gently; bandages are laid out in neat rows."
case 3:
return "A slow warmth spreads as salves are applied."
case 4:
return "You rest a moment on a bench, breathing easier."
case 5:
return fmt.Sprintf("You nod to %s and return to the street.", npcName)
}
case "quest_giver":
switch lineIndex {
case 0:
return fmt.Sprintf("You speak with %s about the road ahead.", npcName)
case 1:
return "Rumors of trouble and slim rewards fill the air."
case 2:
return "A worn map is smoothed flat between you."
case 3:
return "You mark targets and deadlines in your mind."
case 4:
return fmt.Sprintf("%s hints at better pay for the bold.", npcName)
case 5:
return "You part with a clearer picture of what must be done."
}
default:
switch lineIndex {
case 0:
return fmt.Sprintf("You spend time with %s.", npcName)
case 1:
return "Conversation drifts from weather to the wider world."
case 2:
return "A few practical details stick in your memory."
case 3:
return "You listen more than you speak."
case 4:
return "Promises and coin change hands—or almost do."
case 5:
return fmt.Sprintf("You say farewell to %s.", npcName)
}
}
return ""
}
// --- Excursion (mini-adventure) FSM helpers ---
func smoothstep(t float64) float64 {
return t * t * (3 - 2*t)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func (hm *HeroMovement) 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()
hm.Excursion = model.ExcursionSession{
Phase: model.ExcursionOut,
StartedAt: now,
DepthWorldUnits: cfg.AdventureDepthWorldUnits,
RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction,
}
outEnd := now.Add(time.Duration(cfg.AdventureOutDurationMs) * time.Millisecond)
wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs)
wildEnd := outEnd.Add(wildDur)
hm.Excursion.WildUntil = wildEnd
hm.Excursion.ReturnUntil = wildEnd.Add(time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
}
// advanceExcursionPhases progresses through out->wild->return and returns true when complete.
func (hm *HeroMovement) advanceExcursionPhases(now time.Time) (ended bool) {
exc := &hm.Excursion
cfg := tuning.Get()
if exc.Phase == model.ExcursionOut {
outEnd := exc.StartedAt.Add(time.Duration(cfg.AdventureOutDurationMs) * time.Millisecond)
if !now.Before(outEnd) {
exc.Phase = model.ExcursionWild
}
}
if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) {
exc.Phase = model.ExcursionReturn
}
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
dur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
hm.RestUntil = now.Add(dur)
}
func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) {
_ = now
hm.State = model.StateResting
hm.Hero.State = model.StateResting
hm.ActiveRestKind = model.RestKindAdventureInline
hm.RestHealRemainder = 0
}
func (hm *HeroMovement) applyRestHealTick(dt float64) {
if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 {
return
}
cfg := tuning.Get()
var hpPerS float64
switch hm.ActiveRestKind {
case model.RestKindRoadside:
hpPerS = cfg.RoadsideRestHpPerS
case model.RestKindAdventureInline:
hpPerS = cfg.AdventureRestHpPerS
default:
return
}
rawGain := float64(hm.Hero.MaxHP)*hpPerS*dt + hm.RestHealRemainder
gain := int(math.Floor(rawGain))
hm.RestHealRemainder = rawGain - float64(gain)
if gain <= 0 {
return
}
hm.Hero.HP += gain
if hm.Hero.HP > hm.Hero.MaxHP {
hm.Hero.HP = hm.Hero.MaxHP
}
}
func (hm *HeroMovement) rollAdventureEncounter(now time.Time) (monster bool, enemy model.Enemy, hit bool) {
cfg := tuning.Get()
cooldown := time.Duration(cfg.AdventureEncounterCooldownMs) * time.Millisecond
if now.Sub(hm.LastEncounterAt) < cooldown {
return false, model.Enemy{}, false
}
if rand.Float64() >= cfg.EncounterActivityBase {
return false, model.Enemy{}, false
}
monsterW := cfg.MonsterEncounterWeightBase + cfg.MonsterEncounterWeightWildBonus
merchantW := cfg.MerchantEncounterWeightBase
total := monsterW + merchantW
r := rand.Float64() * total
if r < monsterW {
e := PickEnemyForLevel(hm.Hero.Level)
return true, e, true
}
return false, model.Enemy{}, true
}
func randomDurationBetweenMs(minMs, maxMs int64) time.Duration {
if maxMs <= minMs {
return time.Duration(minMs) * time.Millisecond
}
return time.Duration(minMs+rand.Int63n(maxMs-minMs+1)) * time.Millisecond
}
// ProcessSingleHeroMovementTick applies one movement-system step as of logical time now.
// It mirrors the online engine's configured movement cadence.
// steps (plus a final partial step to real time) for catch-up simulation.
//
// sender may be nil to suppress all WebSocket payloads (offline ticks).
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered.
// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block).
// persistAfterTownEnter, if non-nil, is invoked after SyncToHero when the hero has just reached a town.
func ProcessSingleHeroMovementTick(
heroID int64,
hm *HeroMovement,
graph *RoadGraph,
now time.Time,
sender MessageSender,
onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
persistAfterTownEnter AfterTownEnterPersist,
) {
if graph == nil {
return
}
switch hm.State {
case model.StateFighting, model.StateDead:
return
case model.StateResting:
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = movementTickRate().Seconds()
}
hm.LastMoveTick = now
switch hm.ActiveRestKind {
case model.RestKindRoadside:
hm.applyRestHealTick(dt)
cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp {
hm.ActiveRestKind = model.RestKindNone
hm.RestUntil = time.Time{}
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))
}
case model.RestKindAdventureInline:
hm.applyRestHealTick(dt)
excursionEnded := hm.advanceExcursionPhases(now)
cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if hpFrac >= cfg.AdventureRestTargetHp || excursionEnded {
if excursionEnded {
hm.endExcursion(now)
if sender != nil {
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
}
}
hm.ActiveRestKind = model.RestKindNone
hm.RestHealRemainder = 0
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.refreshSpeed(now)
}
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
default:
hm.applyTownRestHeal(dt)
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
if now.After(hm.RestUntil) {
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
}
}
case model.StateInTown:
cfg := tuning.Get()
// Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time.
hm.LastMoveTick = now
// NPC visit pause ended: clear visit log state before the next roll.
if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) {
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
if len(hm.TownNPCQueue) == 0 {
if hm.TownLeaveAt.IsZero() {
hm.TownLeaveAt = now.Add(time.Duration(cfg.TownNPCPauseMs) * time.Millisecond)
}
if now.Before(hm.TownLeaveAt) {
hm.SyncToHero()
return
}
hm.TownLeaveAt = time.Time{}
hm.LeaveTown(graph, now)
hm.SyncToHero()
if sender != nil {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
return
}
if now.Before(hm.NextTownNPCRollAt) {
hm.SyncToHero()
return
}
if rand.Float64() < cfg.TownNPCVisitChance {
npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok {
if sender != nil {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID,
})
}
hm.TownVisitNPCName = npc.Name
hm.TownVisitNPCType = npc.Type
hm.TownVisitStartedAt = now
hm.TownVisitLogsEmitted = 0
if npc.Type == "merchant" {
share := cfg.MerchantTownAutoSellShare
if share <= 0 || share > 1 {
share = tuning.DefaultValues().MerchantTownAutoSellShare
}
soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil)
if soldItems > 0 && adventureLog != nil {
adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold))
}
}
emitTownNPCVisitLogs(heroID, hm, now, adventureLog)
}
hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1))
} else {
hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond)
}
hm.SyncToHero()
case model.StateWalking:
cfg := tuning.Get()
hadNoRoad := hm.Road == nil || len(hm.Road.Waypoints) < 2
if hadNoRoad {
hm.Road = nil
hm.pickDestination(graph)
hm.assignRoad(graph)
if sender != nil && hm.Road != nil && len(hm.Road.Waypoints) >= 2 {
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
}
}
}
// Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout.
if !hm.WanderingMerchantDeadline.IsZero() {
if !now.Before(hm.WanderingMerchantDeadline) {
hm.WanderingMerchantDeadline = time.Time{}
if sender != nil {
sender.SendToHero(heroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "timeout"})
}
} else {
hm.LastMoveTick = now
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
if hm.Hero != nil {
hm.SyncToHero()
}
return
}
}
// --- Active excursion (mini-adventure) ---
if hm.Excursion.Active() {
prevPhase := hm.Excursion.Phase
excursionEnded := hm.advanceExcursionPhases(now)
if excursionEnded {
hm.endExcursion(now)
hm.refreshSpeed(now)
if sender != nil {
sender.SendToHero(heroID, "excursion_end", model.ExcursionEndPayload{})
}
} else {
if newPhase := hm.Excursion.Phase; newPhase != prevPhase && sender != nil {
sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(newPhase)})
}
if hm.isLowHP() {
hm.beginAdventureInlineRest(now)
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
return
}
canEncounter := hm.Excursion.Phase == model.ExcursionWild ||
(hm.Excursion.Phase == model.ExcursionReturn && cfg.AdventureReturnEncounterEnabled)
if canEncounter && (onEncounter != nil || onMerchantEncounter != nil) {
monster, enemy, hit := hm.rollAdventureEncounter(now)
if hit {
if monster && onEncounter != nil {
hm.LastEncounterAt = now
onEncounter(hm, &enemy, now)
return
}
if !monster {
cost := WanderingMerchantCost(hm.Hero.Level)
hm.LastEncounterAt = now
if sender != nil {
hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost,
})
}
if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost)
}
return
}
}
}
hm.LastMoveTick = now
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero()
return
}
}
// --- Normal walking (no active excursion) ---
reachedTown := hm.AdvanceTick(now, graph)
if reachedTown {
hm.EnterTown(now, graph)
if sender != nil {
town := graph.Towns[hm.CurrentTownID]
if town != nil {
npcInfos := graph.TownNPCInfos(hm.CurrentTownID)
buildingInfos := graph.TownBuildingInfos(hm.CurrentTownID)
var restMs int64
if hm.State == model.StateResting {
restMs = hm.RestUntil.Sub(now).Milliseconds()
}
sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{
TownID: town.ID,
TownName: town.Name,
Biome: town.Biome,
NPCs: npcInfos,
Buildings: buildingInfos,
RestDurationMs: restMs,
})
}
}
hm.SyncToHero()
if persistAfterTownEnter != nil {
persistAfterTownEnter(hm.Hero)
}
return
}
if hm.isLowHP() {
hm.beginRoadsideRest(now)
hm.SyncToHero()
if sender != nil && hm.Hero != nil {
sender.SendToHero(heroID, "hero_state", hm.Hero)
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
return
}
canRollEncounter := hm.Road != nil && len(hm.Road.Waypoints) >= 2
if canRollEncounter && (onEncounter != nil || sender != nil || onMerchantEncounter != nil) {
monster, enemy, hit := hm.rollRoadEncounter(now)
if hit {
if monster {
if onEncounter != nil {
hm.LastEncounterAt = now
onEncounter(hm, &enemy, now)
return
}
} else {
cost := WanderingMerchantCost(hm.Hero.Level)
if sender != nil || onMerchantEncounter != nil {
hm.LastEncounterAt = now
if sender != nil {
hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond)
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0,
NPCName: "Wandering Merchant",
Role: "alms",
Cost: cost,
})
}
if onMerchantEncounter != nil {
onMerchantEncounter(hm, now, cost)
}
return
}
}
}
}
if hm.mayStartExcursion(now) {
hm.beginExcursion(now)
if sender != nil {
sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{
DepthWorldUnits: hm.Excursion.DepthWorldUnits,
})
}
hm.SyncToHero()
return
}
if sender != nil {
sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
}
hm.SyncToHero()
}
}