package game import ( "testing" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) // Phase 2 FSM: road spine freeze during excursion, HP-based exits, no rest while fighting. func TestFSM_ExcursionFreezesRoadProgress(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.WaypointIndex = 0 hm.WaypointFraction = 0.5 from := hm.Road.Waypoints[0] to := hm.Road.Waypoints[1] hm.CurrentX = from.X + (to.X-from.X)*0.5 hm.CurrentY = from.Y + (to.Y-from.Y)*0.5 hm.LastMoveTick = now hm.beginExcursion(now) if hm.Excursion.RoadFreezeWaypoint != 0 || hm.Excursion.RoadFreezeFraction != 0.5 { t.Fatalf("unexpected freeze snapshot: wp=%d frac=%v", hm.Excursion.RoadFreezeWaypoint, hm.Excursion.RoadFreezeFraction) } later := now.Add(30 * time.Second) reached := hm.AdvanceTick(later, graph) if reached { t.Fatal("AdvanceTick should not reach town while excursion is active") } if hm.WaypointIndex != 0 || hm.WaypointFraction != 0.5 { t.Fatalf("waypoint progress should stay frozen during excursion, got idx=%d frac=%v", hm.WaypointIndex, hm.WaypointFraction) } ps := hm.PositionSyncPayload(later) if ps.WaypointIndex != 0 || ps.WaypointFraction != 0.5 { t.Fatalf("PositionSync should reflect frozen road PB, got idx=%d frac=%v", ps.WaypointIndex, ps.WaypointFraction) } } func TestFSM_NormalWalking_AdvanceTickMovesAlongRoad(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.WaypointIndex = 0 hm.WaypointFraction = 0.5 from := hm.Road.Waypoints[0] to := hm.Road.Waypoints[1] hm.CurrentX = from.X + (to.X-from.X)*0.5 hm.CurrentY = from.Y + (to.Y-from.Y)*0.5 hm.LastMoveTick = now if hm.Excursion.Active() { t.Fatal("excursion should not be active") } later := now.Add(5 * time.Second) reached := hm.AdvanceTick(later, graph) if reached { t.Fatal("should not reach town from mid-segment in 5s") } if hm.WaypointIndex == 0 && hm.WaypointFraction == 0.5 { t.Fatal("expected road progress to advance without active excursion") } } func TestFSM_RoadsideRest_HPExit_ForcesReturnBeforeWildTimer(t *testing.T) { graph := testGraph() cfg := tuning.Get() maxHP := 1000 hero := testHeroOnRoad(1, int(float64(maxHP)*cfg.RoadsideRestExitHp), maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) origRestUntil := hm.RestUntil // Skip "out" leg: test HP exit from wild (campfire) phase. hm.Excursion.Phase = model.ExcursionWild hm.LastMoveTick = now tick := now.Add(time.Second) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected Return phase after HP exit in Wild, got %s", hm.Excursion.Phase) } if !tick.Before(origRestUntil) { t.Fatal("HP exit should force return before RestUntil wild cap") } } func TestFSM_AdventureInlineRest_HPExit_ExcursionStillActive(t *testing.T) { graph := testGraph() cfg := tuning.Get() maxHP := 1000 targetHP := int(float64(maxHP) * cfg.AdventureRestTargetHp) hero := testHeroOnRoad(1, targetHP, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.Excursion.Phase = model.ExcursionWild hm.beginAdventureInlineRest(now) ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected back to walking after HP target, got %s", hm.State) } if !hm.Excursion.Active() { t.Fatal("excursion session should continue after adventure-inline HP exit") } } func TestFSM_AdventureReturnAfterVictoryWhenTimerElapsedDuringFight(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.Excursion.Phase = model.ExcursionWild hm.Excursion.AdventureEndsAt = now.Add(-time.Second) hm.StartFighting() victoryAt := now.Add(5 * time.Second) hm.ResumeWalking(victoryAt) hm.TryAdventureReturnAfterCombat(victoryAt) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected Return phase after victory with elapsed adventure timer, got %s", hm.Excursion.Phase) } if !hm.Excursion.AttractorSet { t.Fatal("return attractor should be set toward road") } } func TestFSM_AdventureReturnAfterVictoryWhenPendingFlagSet(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.Excursion.Phase = model.ExcursionWild hm.Excursion.AdventureEndsAt = now.Add(time.Hour) hm.Excursion.PendingReturnAfterCombat = true hm.TryAdventureReturnAfterCombat(now) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected Return phase when pending flag set, got %s", hm.Excursion.Phase) } } func TestFSM_ProcessTick_IgnoresLowHP_WhenFighting(t *testing.T) { graph := testGraph() cfg := tuning.Get() maxHP := 1000 lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1 hero := testHeroOnRoad(1, lowHP, maxHP) hero.State = model.StateFighting now := time.Now() hm := NewHeroMovement(hero, graph, now) ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil) if hm.State != model.StateFighting { t.Fatalf("expected StateFighting unchanged, got %s", hm.State) } if hm.State == model.StateResting { t.Fatal("must not enter rest while fighting") } } func TestFSM_AdminStartRoadsideRest_RejectsFighting(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 100, 1000) hero.State = model.StateFighting now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.State = model.StateFighting if hm.AdminStartRoadsideRest(now) { t.Fatal("AdminStartRoadsideRest must reject fighting hero") } } func TestFSM_AdminStartExcursion_RejectsFighting(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) hero.State = model.StateFighting now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.State = model.StateFighting if hm.AdminStartExcursion(now) { t.Fatal("AdminStartExcursion must reject fighting hero") } } func TestFSM_AdminStopExcursion_RejectsFighting(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.State = model.StateFighting hm.Hero.State = model.StateFighting if hm.AdminStopExcursion(now) { t.Fatal("AdminStopExcursion must reject fighting hero") } }