diff --git a/admin-web/index.html b/admin-web/index.html
index 01eed40..6d39660 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -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",
diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go
index 56c7461..c721a50 100644
--- a/backend/internal/game/movement.go
+++ b/backend/internal/game/movement.go
@@ -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))
}
diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go
index be7dcad..416386d 100644
--- a/backend/internal/model/hero.go
+++ b/backend/internal/model/hero.go
@@ -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:"-"`
diff --git a/backend/internal/model/town_pause.go b/backend/internal/model/town_pause.go
index edd14de..c592f68 100644
--- a/backend/internal/model/town_pause.go
+++ b/backend/internal/model/town_pause.go
@@ -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"`
}
diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go
index e12cdf0..ac3f5ef 100644
--- a/backend/internal/model/ws_message.go
+++ b/backend/internal/model/ws_message.go
@@ -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{}
diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go
index c3266a7..40c1494 100644
--- a/backend/internal/tuning/runtime.go
+++ b/backend/internal/tuning/runtime.go
@@ -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,
}
}