new fields, session state, base movement and advance ticks

master
Denis Ranneft 1 month ago
parent 61d617154f
commit 97d29f7c2a

@ -287,7 +287,23 @@
enemyScaleOvercapXp: "Доп. масштаб XP сверх диапазона.",
enemyScaleBandGold: "Масштаб золота с врага.",
enemyScaleOvercapGold: "Доп. масштаб золота сверх диапазона.",
lootHistoryLimit: "Сколько последних записей лута хранить в оперативной истории на героя (для UI/логов)."
lootHistoryLimit: "Сколько последних записей лута хранить в оперативной истории на героя (для UI/логов).",
adventureStartChance: "Вероятность за тик движения начать мини-приключение (~3 за 8 ч при 500 мс тике и ~50% времени в ходьбе).",
adventureCooldownMs: "Минимальный интервал между сессиями мини-приключения, мс.",
adventureOutDurationMs: "Длительность фазы «уход с дороги в лес», мс.",
adventureWildMinMs: "Мин. длительность фазы «в лесу» (энкаунтеры), мс. Сумма out+wild+return задаёт полную длительность.",
adventureWildMaxMs: "Макс. длительность фазы «в лесу», мс.",
adventureReturnDurationMs: "Длительность фазы «возврат к дороге», мс.",
adventureDepthWorldUnits: "Макс. смещение перпендикулярно дороге (глубина «в лес»), world units.",
adventureEncounterCooldownMs: "Кулдаун между энкаунтерами в фазах wild/return, мс.",
adventureReturnEncounterEnabled: "Разрешить энкаунтеры на фазе возврата к дороге (true/false).",
lowHpThreshold: "Доля HP/MaxHP, ниже которой может сработать отдых у обочины / в приключении.",
roadsideRestExitHp: "Доля HP/MaxHP — при достижении можно выйти из отдыха у обочины (ранний выход).",
adventureRestTargetHp: "Целевая доля HP/MaxHP для выхода из inline-отдыха во время приключения.",
roadsideRestMinMs: "Мин. длительность отдыха у обочины, мс.",
roadsideRestMaxMs: "Макс. длительность отдыха у обочины, мс.",
roadsideRestHpPerSecond: "Доля MaxHP восстановления в секунду при отдыхе у обочины.",
adventureRestHpPerSecond: "Доля MaxHP восстановления в секунду при inline-отдыхе в приключении."
};
/** Display order and RU titles for runtime constant groups (admin UI). */
const RUNTIME_CONSTANT_GROUPS_ORDER = [
@ -297,6 +313,8 @@
{ id: "town_npc", title: "Город: NPC — шанс визита и тайминги" },
{ id: "merchant", title: "Торговец: цены, доля автосейла в городе, таймаут бродячего" },
{ id: "encounter_weights", title: "Типы встреч: веса «монстр / торговец» (дорога и дикая зона)" },
{ id: "excursion", title: "Мини-приключение (лес): шанс старта, фазы out/wild/return, глубина, кулдауны" },
{ id: "roadside_hp_rest", title: "Обочина и низкий HP: пороги, длительность отдыха, регенерация" },
{ id: "loot_rarity", title: "Лут: относительные шансы редкости предмета" },
{ id: "loot_extra", title: "Лут: зелья, экипировка, масштаб золота, влияние удачи" },
{ id: "gold_tiers", title: "Лут: диапазоны золота по тиру редкости" },
@ -342,6 +360,22 @@
monsterEncounterWeightWildBonus: "encounter_weights",
merchantEncounterWeightBase: "encounter_weights",
merchantEncounterWeightRoadBonus: "encounter_weights",
adventureStartChance: "excursion",
adventureCooldownMs: "excursion",
adventureOutDurationMs: "excursion",
adventureWildMinMs: "excursion",
adventureWildMaxMs: "excursion",
adventureReturnDurationMs: "excursion",
adventureDepthWorldUnits: "excursion",
adventureEncounterCooldownMs: "excursion",
adventureReturnEncounterEnabled: "excursion",
lowHpThreshold: "roadside_hp_rest",
roadsideRestExitHp: "roadside_hp_rest",
adventureRestTargetHp: "roadside_hp_rest",
roadsideRestMinMs: "roadside_hp_rest",
roadsideRestMaxMs: "roadside_hp_rest",
roadsideRestHpPerSecond: "roadside_hp_rest",
adventureRestHpPerSecond: "roadside_hp_rest",
lootChanceCommon: "loot_rarity",
lootChanceUncommon: "loot_rarity",
lootChanceRare: "loot_rarity",

@ -13,8 +13,6 @@ import (
const (
// townNPCVisitLogLines is how many log lines to emit per NPC visit.
townNPCVisitLogLines = 6
restKindTown = "town"
)
func movementTickRate() time.Duration {
@ -81,6 +79,19 @@ type HeroMovement struct {
// 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
@ -156,6 +167,19 @@ func NewHeroMovement(hero *model.Hero, graph *RoadGraph, now time.Time) *HeroMov
}
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
}
@ -409,6 +433,10 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
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
}
@ -426,6 +454,11 @@ func (hm *HeroMovement) AdvanceTick(now time.Time, graph *RoadGraph) (reachedTow
return false
}
if hm.Excursion.Active() {
hm.LastMoveTick = now
return false
}
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = movementTickRate().Seconds()
@ -540,6 +573,9 @@ func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now tim
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
@ -568,6 +604,7 @@ func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool {
}
hm.State = model.StateResting
hm.Hero.State = model.StateResting
hm.ActiveRestKind = model.RestKindTown
hm.RestUntil = now.Add(randomRestDuration())
return true
}
@ -618,7 +655,39 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
}
func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
_ = now
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
}
@ -666,11 +735,15 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
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
}
@ -695,6 +768,9 @@ func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
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.
@ -754,28 +830,38 @@ func (hm *HeroMovement) SyncToHero() {
hm.Hero.DestinationTownID = nil
}
hm.Hero.MoveState = string(hm.State)
hm.Hero.RestKind = ""
hm.Hero.RestKind = model.RestKindNone
if hm.State == model.StateResting {
hm.Hero.RestKind = restKindTown
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:
if hm.RestUntil.IsZero() {
return nil
break
}
t := hm.RestUntil
p := &model.TownPausePersisted{
rk := model.RestKindTown
if hm.ActiveRestKind != model.RestKindNone {
rk = hm.ActiveRestKind
}
p = &model.TownPausePersisted{
RestUntil: &t,
RestKind: restKindTown,
RestKind: rk,
TownRestHealRemainder: hm.TownRestHealRemainder,
RestHealRemainder: hm.RestHealRemainder,
}
return p
case model.StateInTown:
p := &model.TownPausePersisted{
p = &model.TownPausePersisted{
TownVisitNPCName: hm.TownVisitNPCName,
TownVisitNPCType: hm.TownVisitNPCType,
TownVisitLogsEmitted: hm.TownVisitLogsEmitted,
@ -795,10 +881,42 @@ func (hm *HeroMovement) townPauseBlob() *model.TownPausePersisted {
t := hm.TownVisitStartedAt
p.TownVisitStartedAt = &t
}
return p
default:
return nil
}
// 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) {
@ -807,11 +925,13 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
case model.StateResting:
if blob != nil && blob.RestUntil != nil && !blob.RestUntil.IsZero() {
hm.RestUntil = *blob.RestUntil
hm.ActiveRestKind = blob.RestKind
hm.TownRestHealRemainder = blob.TownRestHealRemainder
return
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)
}
// 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
@ -832,6 +952,27 @@ func (hm *HeroMovement) applyTownPauseFromHero(hero *model.Hero, now time.Time)
}
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).
@ -986,6 +1127,167 @@ func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string {
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.
@ -1014,25 +1316,70 @@ func ProcessSingleHeroMovementTick(
return
case model.StateResting:
// Advance logical movement time while idle so leaving town does not apply a huge dt (teleport).
dt := now.Sub(hm.LastMoveTick).Seconds()
if dt <= 0 {
dt = movementTickRate().Seconds()
}
hm.LastMoveTick = now
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)
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 {
sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
if route := hm.RoutePayload(); route != nil {
sender.SendToHero(heroID, "route_assigned", route)
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)
}
}
}
}
@ -1137,6 +1484,65 @@ func ProcessSingleHeroMovementTick(
}
}
// --- 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 {
@ -1169,6 +1575,16 @@ func ProcessSingleHeroMovementTick(
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)
@ -1179,7 +1595,6 @@ func ProcessSingleHeroMovementTick(
onEncounter(hm, &enemy, now)
return
}
// No monster handler — skip consuming the roll (extremely rare).
} else {
cost := WanderingMerchantCost(hm.Hero.Level)
if sender != nil || onMerchantEncounter != nil {
@ -1202,6 +1617,17 @@ func ProcessSingleHeroMovementTick(
}
}
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))
}

@ -63,9 +63,8 @@ type Hero struct {
// Movement state (persisted to DB for reconnect recovery).
CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
MoveState string `json:"moveState"`
// RestKind mirrors movement rest context for clients ("town" | "roadside").
RestKind string `json:"restKind,omitempty"`
MoveState string `json:"moveState"`
RestKind RestKind `json:"restKind,omitempty"`
// TownPause holds resting, in-town NPC tour, and roadside rest timers (DB town_pause JSONB only).
TownPause *TownPausePersisted `json:"-"`

@ -2,13 +2,14 @@ package model
import "time"
// TownPausePersisted mirrors HeroMovement fields needed to resume resting or an in-town NPC tour
// after reconnect or during offline catch-up.
// TownPausePersisted mirrors HeroMovement fields needed to resume resting, an in-town NPC tour,
// or a mid-adventure excursion after reconnect or during offline catch-up.
// Stored in heroes.town_pause (JSONB).
type TownPausePersisted struct {
RestUntil *time.Time `json:"restUntil,omitempty"`
RestKind string `json:"restKind,omitempty"`
TownRestHealRemainder float64 `json:"townRestHealRemainder,omitempty"`
RestUntil *time.Time `json:"restUntil,omitempty"`
RestKind RestKind `json:"restKind,omitempty"`
TownRestHealRemainder float64 `json:"townRestHealRemainder,omitempty"`
RestHealRemainder float64 `json:"restHealRemainder,omitempty"`
NPCQueue []int64 `json:"npcQueue,omitempty"`
NextTownNPCRollAt *time.Time `json:"nextTownNPCRollAt,omitempty"`
@ -17,4 +18,7 @@ type TownPausePersisted struct {
TownVisitNPCType string `json:"townVisitNPCType,omitempty"`
TownVisitStartedAt *time.Time `json:"townVisitStartedAt,omitempty"`
TownVisitLogsEmitted int `json:"townVisitLogsEmitted,omitempty"`
// Excursion (mini-adventure) session persisted for reconnect / offline resume.
Excursion *ExcursionPersisted `json:"excursion,omitempty"`
}

@ -221,3 +221,16 @@ type ClaimQuestPayload struct {
type NPCInteractPayload struct {
NPCID int64 `json:"npcId"`
}
// ExcursionStartPayload is sent when a mini-adventure begins.
type ExcursionStartPayload struct {
DepthWorldUnits float64 `json:"depthWorldUnits"`
}
// ExcursionPhasePayload is sent when the excursion transitions between phases.
type ExcursionPhasePayload struct {
Phase string `json:"phase"`
}
// ExcursionEndPayload is sent when the mini-adventure completes.
type ExcursionEndPayload struct{}

@ -151,6 +151,45 @@ type Values struct {
AutoEquipThreshold float64 `json:"autoEquipThreshold"`
LootHistoryLimit int64 `json:"lootHistoryLimit"`
// --- Adventure / excursion (mini-adventure off-road) ---
// AdventureStartChance is the per-tick probability of starting an adventure while walking.
// With 500ms ticks and ~50% walking uptime, 0.0001 ≈ 3 adventures per 8 h.
AdventureStartChance float64 `json:"adventureStartChance"`
// AdventureCooldownMs is the minimum wall-time between two adventure sessions.
AdventureCooldownMs int64 `json:"adventureCooldownMs"`
// AdventureOutDurationMs is how long the "out" phase lasts (hero moves off-road into forest).
AdventureOutDurationMs int64 `json:"adventureOutDurationMs"`
// AdventureWildMinMs / AdventureWildMaxMs define the random range for the "wild" phase
// (encounters in the forest). Total adventure ≈ out + wild + return.
AdventureWildMinMs int64 `json:"adventureWildMinMs"`
AdventureWildMaxMs int64 `json:"adventureWildMaxMs"`
// AdventureReturnDurationMs is how long the "return" phase lasts (hero walks back to road).
AdventureReturnDurationMs int64 `json:"adventureReturnDurationMs"`
// AdventureDepthWorldUnits is the max perpendicular offset from road during an adventure.
AdventureDepthWorldUnits float64 `json:"adventureDepthWorldUnits"`
// AdventureEncounterCooldownMs is the encounter cooldown while in the wild/return phases.
AdventureEncounterCooldownMs int64 `json:"adventureEncounterCooldownMs"`
// AdventureReturnEncounterEnabled allows encounters during the return phase.
AdventureReturnEncounterEnabled bool `json:"adventureReturnEncounterEnabled"`
// --- HP-based rest triggers ---
// LowHpThreshold is the HP/MaxHP fraction below which rest may trigger (0..1).
LowHpThreshold float64 `json:"lowHpThreshold"`
// RoadsideRestExitHp is the HP/MaxHP fraction at which roadside rest ends early (0..1).
RoadsideRestExitHp float64 `json:"roadsideRestExitHp"`
// AdventureRestTargetHp is the HP/MaxHP fraction at which adventure inline rest ends (0..1).
AdventureRestTargetHp float64 `json:"adventureRestTargetHp"`
// RoadsideRestMinMs is the minimum duration for a roadside rest period.
RoadsideRestMinMs int64 `json:"roadsideRestMinMs"`
// RoadsideRestMaxMs is the maximum duration for a roadside rest period.
RoadsideRestMaxMs int64 `json:"roadsideRestMaxMs"`
// RoadsideRestHpPerS is the HP/MaxHP fraction healed per second during roadside rest.
RoadsideRestHpPerS float64 `json:"roadsideRestHpPerSecond"`
// AdventureRestHpPerS is the HP/MaxHP fraction healed per second during adventure inline rest.
AdventureRestHpPerS float64 `json:"adventureRestHpPerSecond"`
}
func DefaultValues() Values {
@ -276,6 +315,24 @@ func DefaultValues() Values {
EnemyScaleOvercapGold: 0.025,
AutoEquipThreshold: 1.03,
LootHistoryLimit: 50,
AdventureStartChance: 0.0001,
AdventureCooldownMs: 300_000,
AdventureOutDurationMs: 20_000,
AdventureWildMinMs: 560_000,
AdventureWildMaxMs: 2_960_000,
AdventureReturnDurationMs: 20_000,
AdventureDepthWorldUnits: 20.0,
AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true,
LowHpThreshold: 0.25,
RoadsideRestExitHp: 0.70,
AdventureRestTargetHp: 0.70,
RoadsideRestMinMs: 240_000,
RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003,
AdventureRestHpPerS: 0.004,
}
}

Loading…
Cancel
Save