You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

179 lines
5.4 KiB
Go

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)
origWildUntil := hm.Excursion.WildUntil
// Skip "out" leg: test HP exit from wild (campfire) phase.
hm.Excursion.Phase = model.ExcursionWild
hm.Excursion.OutUntil = now.Add(-time.Second)
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(origWildUntil) {
t.Fatal("HP exit should force return before original WildUntil timer")
}
}
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_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")
}
}