diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index e437162..421662b 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -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) { - exc.Phase = model.ExcursionWild - } + 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: - hm.applyRestHealTick(dt) - cfg := tuning.Get() - hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) - if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp { + excursionEnded := hm.advanceExcursionPhases(now) + if hm.Excursion.Phase == model.ExcursionWild { + hm.applyRestHealTick(dt) + } + 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 { diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go index e840fb4..102740a 100644 --- a/backend/internal/game/rest_test.go +++ b/backend/internal/game/rest_test.go @@ -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 { diff --git a/backend/internal/model/excursion.go b/backend/internal/model/excursion.go index 34da908..1482711 100644 --- a/backend/internal/model/excursion.go +++ b/backend/internal/model/excursion.go @@ -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"` diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 40c1494..ac6c6ea 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -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, } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c31dd3..42956a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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> = { ...hero.buffCharges, [type]: { ...currentCharge, remaining: currentCharge.remaining - 1 },