buffs fix + adventure update

master
Denis Ranneft 1 month ago
parent cae397a7d8
commit cbab3dbe3b

@ -451,6 +451,7 @@ func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) {
hm.TownLeaveAt = shift(hm.TownLeaveAt)
hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline)
hm.Excursion.StartedAt = shift(hm.Excursion.StartedAt)
hm.Excursion.OutUntil = shift(hm.Excursion.OutUntil)
hm.Excursion.WildUntil = shift(hm.Excursion.WildUntil)
hm.Excursion.ReturnUntil = shift(hm.Excursion.ReturnUntil)
hm.LastExcursionEndedAt = shift(hm.LastExcursionEndedAt)
@ -665,7 +666,7 @@ func (hm *HeroMovement) AdminStopRest(now time.Time) bool {
if hm.ActiveRestKind != model.RestKindRoadside && hm.ActiveRestKind != model.RestKindAdventureInline {
return false
}
if hm.ActiveRestKind == model.RestKindAdventureInline && hm.Excursion.Active() {
if hm.Excursion.Active() {
hm.endExcursion(now)
}
hm.ActiveRestKind = model.RestKindNone
@ -751,13 +752,12 @@ func (hm *HeroMovement) roadForwardUnit() (float64, float64) {
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)
outMs := float64(exc.OutUntil.Sub(exc.StartedAt).Milliseconds())
if outMs > 0 {
elapsed := float64(now.Sub(exc.StartedAt).Milliseconds())
t = smoothstep(clamp01(elapsed / outMs))
@ -765,10 +765,9 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
case model.ExcursionWild:
t = 1.0
case model.ExcursionReturn:
retMs := float64(cfg.AdventureReturnDurationMs)
retMs := float64(exc.ReturnUntil.Sub(exc.WildUntil).Milliseconds())
if retMs > 0 {
returnStart := exc.ReturnUntil.Add(-time.Duration(cfg.AdventureReturnDurationMs) * time.Millisecond)
elapsed := float64(now.Sub(returnStart).Milliseconds())
elapsed := float64(now.Sub(exc.WildUntil).Milliseconds())
t = 1.0 - smoothstep(clamp01(elapsed / retMs))
}
}
@ -776,12 +775,6 @@ func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) {
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
}
@ -1004,6 +997,10 @@ func (hm *HeroMovement) excursionPersisted() *model.ExcursionPersisted {
t := s.StartedAt
ep.StartedAt = &t
}
if !s.OutUntil.IsZero() {
t := s.OutUntil
ep.OutUntil = &t
}
if !s.WildUntil.IsZero() {
t := s.WildUntil
ep.WildUntil = &t
@ -1062,6 +1059,9 @@ func (hm *HeroMovement) applyExcursionFromBlob(ep *model.ExcursionPersisted) {
if ep.StartedAt != nil {
hm.Excursion.StartedAt = *ep.StartedAt
}
if ep.OutUntil != nil {
hm.Excursion.OutUntil = *ep.OutUntil
}
if ep.WildUntil != nil {
hm.Excursion.WildUntil = *ep.WildUntil
}
@ -1269,32 +1269,50 @@ func (hm *HeroMovement) mayStartExcursion(now time.Time) bool {
func (hm *HeroMovement) beginExcursion(now time.Time) {
cfg := tuning.Get()
depth := cfg.AdventureDepthWorldUnits
hm.refreshSpeed(now)
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
}
outDur := time.Duration(depth / speed * float64(time.Second))
outEnd := now.Add(outDur)
wildDur := randomDurationBetweenMs(cfg.AdventureWildMinMs, cfg.AdventureWildMaxMs)
wildEnd := outEnd.Add(wildDur)
returnDur := time.Duration(depth / speed * float64(time.Second))
hm.Excursion = model.ExcursionSession{
Phase: model.ExcursionOut,
StartedAt: now,
DepthWorldUnits: cfg.AdventureDepthWorldUnits,
OutUntil: outEnd,
WildUntil: wildEnd,
ReturnUntil: wildEnd.Add(returnDur),
DepthWorldUnits: depth,
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) {
if exc.Phase == model.ExcursionOut && !now.Before(exc.OutUntil) {
exc.Phase = model.ExcursionWild
}
}
if exc.Phase == model.ExcursionWild && !now.Before(exc.WildUntil) {
exc.Phase = model.ExcursionReturn
// Only recalculate return duration if we haven't already passed the original deadline
// (handles large time jumps from offline catch-up or timer-based exits).
if now.Before(exc.ReturnUntil) {
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
}
exc.WildUntil = now
exc.ReturnUntil = now.Add(time.Duration(exc.DepthWorldUnits / speed * float64(time.Second)))
}
}
if exc.Phase == model.ExcursionReturn && !now.Before(exc.ReturnUntil) {
return true
@ -1321,8 +1339,34 @@ func (hm *HeroMovement) beginRoadsideRest(now time.Time) {
hm.Hero.State = model.StateResting
hm.ActiveRestKind = model.RestKindRoadside
hm.RestHealRemainder = 0
dur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
hm.RestUntil = now.Add(dur)
depth := cfg.RoadsideRestDepthWorldUnits
if depth <= 0 {
depth = 12.0
}
hm.refreshSpeed(now)
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
}
moveDur := time.Duration(depth / speed * float64(time.Second))
outUntil := now.Add(moveDur)
restDur := randomDurationBetweenMs(cfg.RoadsideRestMinMs, cfg.RoadsideRestMaxMs)
wildUntil := outUntil.Add(restDur)
returnUntil := wildUntil.Add(moveDur)
hm.Excursion = model.ExcursionSession{
Phase: model.ExcursionOut,
StartedAt: now,
OutUntil: outUntil,
WildUntil: wildUntil,
ReturnUntil: returnUntil,
DepthWorldUnits: depth,
RoadFreezeWaypoint: hm.WaypointIndex,
RoadFreezeFraction: hm.WaypointFraction,
}
hm.RestUntil = returnUntil
}
func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) {
@ -1422,16 +1466,30 @@ func ProcessSingleHeroMovementTick(
switch hm.ActiveRestKind {
case model.RestKindRoadside:
excursionEnded := hm.advanceExcursionPhases(now)
if hm.Excursion.Phase == model.ExcursionWild {
hm.applyRestHealTick(dt)
cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp {
}
if excursionEnded {
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)
} else if hm.Excursion.Phase == model.ExcursionWild {
cfg := tuning.Get()
hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP)
if hpFrac >= cfg.RoadsideRestExitHp {
hm.Excursion.Phase = model.ExcursionReturn
speed := hm.Speed
if speed < 0.1 {
speed = 0.1
}
hm.Excursion.WildUntil = now
hm.Excursion.ReturnUntil = now.Add(time.Duration(hm.Excursion.DepthWorldUnits / speed * float64(time.Second)))
}
}
hm.SyncToHero()
if sender != nil && hm.Hero != nil {

@ -144,11 +144,12 @@ func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
tick := now.Add(time.Second)
// Tick past the Out phase so the hero is in Wild phase where HP threshold is checked.
tick := hm.Excursion.OutUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after HP threshold, got %s", hm.State)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase)
}
}
@ -160,9 +161,11 @@ func TestRoadsideRest_DisplayOffset(t *testing.T) {
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
ox, oy := hm.displayOffset(now)
// Check offset partway through the Out phase (smoothstep should be non-zero).
outMid := hm.Excursion.StartedAt.Add(hm.Excursion.OutUntil.Sub(hm.Excursion.StartedAt) / 2)
ox, oy := hm.displayOffset(outMid)
if ox == 0 && oy == 0 {
t.Fatal("expected non-zero display offset during roadside rest")
t.Fatal("expected non-zero display offset during roadside rest out phase")
}
}
@ -181,7 +184,7 @@ func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
hm.Hero.State = model.StateWalking
hm.beginExcursion(now)
tick := now.Add(time.Duration(cfg.AdventureOutDurationMs+1000) * time.Millisecond)
tick := hm.Excursion.OutUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {

@ -29,6 +29,8 @@ type ExcursionSession struct {
Phase ExcursionPhase
StartedAt time.Time
// OutUntil marks the end of the out phase (hero reached full depth); derived from depth/speed.
OutUntil time.Time
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
@ -53,6 +55,7 @@ func (s *ExcursionSession) Active() bool {
type ExcursionPersisted struct {
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
OutUntil *time.Time `json:"outUntil,omitempty"`
WildUntil *time.Time `json:"wildUntil,omitempty"`
ReturnUntil *time.Time `json:"returnUntil,omitempty"`
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`

@ -190,6 +190,9 @@ type Values struct {
RoadsideRestHpPerS float64 `json:"roadsideRestHpPerSecond"`
// AdventureRestHpPerS is the HP/MaxHP fraction healed per second during adventure inline rest.
AdventureRestHpPerS float64 `json:"adventureRestHpPerSecond"`
// RoadsideRestDepthWorldUnits is the perpendicular offset from road during roadside rest.
RoadsideRestDepthWorldUnits float64 `json:"roadsideRestDepthWorldUnits"`
}
func DefaultValues() Values {
@ -322,7 +325,7 @@ func DefaultValues() Values {
AdventureWildMinMs: 560_000,
AdventureWildMaxMs: 2_960_000,
AdventureReturnDurationMs: 20_000,
AdventureDepthWorldUnits: 20.0,
AdventureDepthWorldUnits: 40.0,
AdventureEncounterCooldownMs: 6_000,
AdventureReturnEncounterEnabled: true,
@ -333,6 +336,8 @@ func DefaultValues() Values {
RoadsideRestMaxMs: 600_000,
RoadsideRestHpPerS: 0.003,
AdventureRestHpPerS: 0.004,
RoadsideRestDepthWorldUnits: 12.0,
}
}

@ -852,9 +852,9 @@ export function App() {
return next;
});
// Optimistic decrement of per-buff charge (subscribers skip server-side consumption)
// Optimistic decrement of per-buff charge (server always consumes via ConsumeBuffCharge)
const currentCharge = hero.buffCharges?.[type];
if (!hero.subscriptionActive && currentCharge != null && currentCharge.remaining > 0) {
if (currentCharge != null && currentCharge.remaining > 0) {
const updatedCharges: Partial<Record<BuffType, BuffChargeState>> = {
...hero.buffCharges,
[type]: { ...currentCharge, remaining: currentCharge.remaining - 1 },

Loading…
Cancel
Save