From 9f2a7d6cd7876311e4720c8c7393f5992fa0be77 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 22:13:30 +0300 Subject: [PATCH] rest fixes --- backend/internal/game/movement.go | 5 ++- backend/internal/game/rest_test.go | 64 +++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 253b153..9a28a95 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -1459,7 +1459,8 @@ func (hm *HeroMovement) beginRoadsideRest(now time.Time) { RoadFreezeWaypoint: hm.WaypointIndex, RoadFreezeFraction: hm.WaypointFraction, } - hm.RestUntil = returnUntil + // RestUntil tracks only the rest (wild) phase; travel out/return is separate. + hm.RestUntil = wildUntil } func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) { @@ -1575,7 +1576,7 @@ func ProcessSingleHeroMovementTick( } else if hm.Excursion.Phase == model.ExcursionWild { cfg := tuning.Get() hpFrac := float64(hm.Hero.HP) / float64(hm.Hero.MaxHP) - if hpFrac >= cfg.RoadsideRestExitHp { + if now.After(hm.RestUntil) || hpFrac >= cfg.RoadsideRestExitHp { hm.Excursion.Phase = model.ExcursionReturn speed := hm.Speed if speed < 0.1 { diff --git a/backend/internal/game/rest_test.go b/backend/internal/game/rest_test.go index 102740a..469e18a 100644 --- a/backend/internal/game/rest_test.go +++ b/backend/internal/game/rest_test.go @@ -130,8 +130,15 @@ func TestRoadsideRest_ExitsByTimer(t *testing.T) { pastTimer := hm.RestUntil.Add(time.Second) ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil) + if hm.Excursion.Phase != model.ExcursionReturn { + t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase) + } + + pastReturn := hm.Excursion.ReturnUntil.Add(time.Second) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil) + if hm.State != model.StateWalking { - t.Fatalf("expected StateWalking after timer, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind) + t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind) } } @@ -538,3 +545,58 @@ func TestAdminStopExcursion_RejectsNone(t *testing.T) { t.Fatal("AdminStopExcursion should reject when no excursion") } } + +// --- FSM: road freeze + no rest in combat --- + +// TestExcursion_FreezesRoadWaypointDuringSession asserts AdvanceTick does not advance the spine +// while an excursion is active (waypoint index/fraction stay at freeze snapshot). +func TestExcursion_FreezesRoadWaypointDuringSession(t *testing.T) { + graph := testGraph() + hero := testHeroOnRoad(1, 500, 1000) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.State = model.StateWalking + hm.Hero.State = model.StateWalking + + hm.beginExcursion(now) + freezeIdx := hm.Excursion.RoadFreezeWaypoint + freezeFr := hm.Excursion.RoadFreezeFraction + + // Mid wild phase: several movement ticks should not move along the road polyline. + wildMid := hm.Excursion.OutUntil.Add(hm.Excursion.WildUntil.Sub(hm.Excursion.OutUntil) / 2) + for i := 0; i < 5; i++ { + tick := wildMid.Add(time.Duration(i) * time.Second) + ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil) + if hm.Excursion.Phase == model.ExcursionNone { + t.Fatalf("excursion ended unexpectedly at tick %v", tick) + } + if hm.WaypointIndex != freezeIdx || hm.WaypointFraction != freezeFr { + t.Fatalf("road spine should stay frozen: want idx=%d fr=%v, got idx=%d fr=%v", + freezeIdx, freezeFr, hm.WaypointIndex, hm.WaypointFraction) + } + } +} + +// TestLowHP_DoesNotStartRestWhileFighting ensures ProcessSingleHeroMovementTick does not +// transition to roadside or inline rest when the hero is in combat state. +func TestLowHP_DoesNotStartRestWhileFighting(t *testing.T) { + graph := testGraph() + cfg := tuning.Get() + maxHP := 1000 + lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1 + + hero := testHeroOnRoad(1, lowHP, maxHP) + now := time.Now() + hm := NewHeroMovement(hero, graph, now) + hm.State = model.StateFighting + hm.Hero.State = model.StateFighting + + ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil) + + if hm.State != model.StateFighting { + t.Fatalf("expected StateFighting unchanged, got %s", hm.State) + } + if hm.ActiveRestKind != model.RestKindNone { + t.Fatalf("expected no rest kind, got %s", hm.ActiveRestKind) + } +}