From 97d29f7c2aa90528fabeae96ec12bc1d710c7a5a Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 21:05:38 +0300 Subject: [PATCH] new fields, session state, base movement and advance ticks --- admin-web/index.html | 36 +- backend/internal/game/movement.go | 486 +++++++++++++++++++++++++-- backend/internal/model/hero.go | 5 +- backend/internal/model/town_pause.go | 14 +- backend/internal/model/ws_message.go | 13 + backend/internal/tuning/runtime.go | 57 ++++ 6 files changed, 572 insertions(+), 39 deletions(-) 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, } }