package game import ( "math" "testing" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) // testGraph builds a minimal two-town road graph for movement tests. func testGraph() *RoadGraph { townA := &model.Town{ID: 1, Name: "TownA", WorldX: 0, WorldY: 0, Radius: 0.5} townB := &model.Town{ID: 2, Name: "TownB", WorldX: 100, WorldY: 0, Radius: 0.5} road := &Road{ ID: 1, FromTownID: 1, ToTownID: 2, Waypoints: []Point{{0, 0}, {50, 0}, {100, 0}}, Distance: 100, } roadBack := &Road{ ID: 2, FromTownID: 2, ToTownID: 1, Waypoints: []Point{{100, 0}, {50, 0}, {0, 0}}, Distance: 100, } return &RoadGraph{ Roads: map[int64]*Road{1: road, 2: roadBack}, TownRoads: map[int64][]*Road{1: {road}, 2: {roadBack}}, Towns: map[int64]*model.Town{1: townA, 2: townB}, TownOrder: []int64{1, 2}, TownNPCs: map[int64][]TownNPC{}, NPCByID: map[int64]TownNPC{}, TownBuildings: map[int64][]TownBuilding{}, } } // TestLeaveTown_ProjectsPlazaOntoRoad ensures we do not snap the hero to waypoint 0 when leaving // town from a plaza offset from the graph origin (regression: assignRoad(..., true) teleported). func TestLeaveTown_ProjectsPlazaOntoRoad(t *testing.T) { graph := testGraph() town1 := int64(1) hero := &model.Hero{ ID: 1, State: model.StateInTown, HP: 10, MaxHP: 10, Level: 1, CurrentTownID: &town1, PositionX: 2, PositionY: 1, } now := time.Now() hm := NewHeroMovement(hero, graph, now) if hm.State != model.StateInTown { t.Fatalf("expected StateInTown from NewHeroMovement, got %v", hm.State) } hm.CurrentTownID = 1 hm.CurrentX = 2 hm.CurrentY = 1 hm.Road = nil hm.LeaveTown(graph, now) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after LeaveTown, got %v", hm.State) } if hm.Road == nil { t.Fatal("expected Road assigned after LeaveTown") } // Waypoint 0 is (0,0); (2,1) should project closer to the outbound polyline than to the origin. dFromPlaza := math.Hypot(hm.CurrentX-2, hm.CurrentY-1) dFromOrigin := math.Hypot(2, 1) if dFromPlaza >= dFromOrigin-0.05 { t.Fatalf("expected position projected toward plaza (2,1), got (%g,%g) distPlaza=%g distOrigin=%g", hm.CurrentX, hm.CurrentY, dFromPlaza, dFromOrigin) } } func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero { townID := int64(1) destID := int64(2) return &model.Hero{ ID: id, Level: 5, MaxHP: maxHP, HP: hp, Attack: 50, Defense: 30, Speed: 1.0, Strength: 10, Constitution: 10, Agility: 10, Luck: 5, State: model.StateWalking, CurrentTownID: &townID, DestinationTownID: &destID, PositionX: 50, PositionY: 0, } } // --- Roadside rest --- func TestRoadsideRest_TriggersOnLowHP(t *testing.T) { graph := testGraph() cfg := tuning.Get() threshold := cfg.LowHpThreshold maxHP := 1000 lowHP := int(float64(maxHP)*threshold) - 1 hero := testHeroOnRoad(1, lowHP, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.State = model.StateWalking hm.Hero.State = model.StateWalking ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil, nil, nil) if hm.State != model.StateResting { t.Fatalf("expected StateResting, got %s", hm.State) } if hm.ActiveRestKind != model.RestKindRoadside { t.Fatalf("expected RestKindRoadside, got %s", hm.ActiveRestKind) } if hm.RestUntil.IsZero() { t.Fatal("expected RestUntil to be set for roadside rest") } } func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) { graph := testGraph() cfg := tuning.Get() maxHP := 1000 safeHP := int(float64(maxHP)*cfg.LowHpThreshold) + 10 hero := testHeroOnRoad(1, safeHP, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.LastEncounterAt = now ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil) if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside { t.Fatal("should not trigger roadside rest above threshold") } } func TestRoadsideRest_HealsHP(t *testing.T) { graph := testGraph() cfg := tuning.Get() maxHP := 10000 hero := testHeroOnRoad(1, 100, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) hm.Excursion.Phase = model.ExcursionWild hm.LastMoveTick = now hpBefore := hm.Hero.HP tick := now.Add(10 * time.Second) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil) if hm.Hero.HP <= hpBefore { t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP) } expectedGain := int(float64(maxHP) * cfg.RoadsideRestHpPerS * 10) actualGain := hm.Hero.HP - hpBefore if actualGain < expectedGain/2 || actualGain > expectedGain*2 { t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain) } } func TestRoadsideRest_ExitsByTimer(t *testing.T) { graph := testGraph() maxHP := 10000 hero := testHeroOnRoad(1, 1, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) hm.Excursion.Phase = model.ExcursionWild hm.LastMoveTick = now pastTimer := hm.RestUntil.Add(time.Second) ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil, nil, nil) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected Return phase after rest timer, got %s", hm.Excursion.Phase) } hm.CurrentX = hm.Excursion.StartX hm.CurrentY = hm.Excursion.StartY hm.LastMoveTick = pastTimer ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer.Add(time.Second), nil, nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after return, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind) } } func TestRoadsideRest_ExitsByHPThreshold(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) 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, nil) if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase) } } func TestRoadsideRest_AttractorWorldMovement(t *testing.T) { graph := testGraph() maxHP := 1000 hero := testHeroOnRoad(1, 100, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) x0, y0 := hm.CurrentX, hm.CurrentY hm.LastMoveTick = now ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(2*time.Second), nil, nil, nil, nil, nil, nil, nil) if hm.CurrentX == x0 && hm.CurrentY == y0 { t.Fatal("expected hero world position to move toward forest attractor during out phase") } ox, oy := hm.displayOffset(now) if ox != 0 || oy != 0 { t.Fatal("attractor-mode excursion should not use perpendicular display offset") } } // --- Adventure inline rest --- func TestAdventureInlineRest_TriggersOnLowHP(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.StateWalking hm.Hero.State = model.StateWalking hm.beginExcursion(now) 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, nil) if hm.State != model.StateResting { t.Fatalf("expected StateResting, got %s", hm.State) } if hm.ActiveRestKind != model.RestKindAdventureInline { t.Fatalf("expected RestKindAdventureInline, got %s", hm.ActiveRestKind) } if !hm.Excursion.Active() { t.Fatal("excursion should remain active during inline rest") } } func TestAdventureInlineRest_HealsHP(t *testing.T) { graph := testGraph() cfg := tuning.Get() maxHP := 10000 hero := testHeroOnRoad(1, 100, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.Excursion.Phase = model.ExcursionWild hm.beginAdventureInlineRest(now) hpBefore := hm.Hero.HP tick := now.Add(10 * time.Second) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil) if hm.Hero.HP <= hpBefore { t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP) } expectedGain := int(float64(maxHP) * cfg.AdventureRestHpPerS * 10) actualGain := hm.Hero.HP - hpBefore if actualGain < expectedGain/2 || actualGain > expectedGain*2 { t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain) } } func TestAdventureInlineRest_ExitsByHPTarget(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) tick := now.Add(time.Second) ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after HP target, got %s", hm.State) } if !hm.Excursion.Active() { t.Fatal("excursion should still be active after inline rest exits by HP") } } func TestAdventure_ReturnPhaseEndsExcursion(t *testing.T) { graph := testGraph() maxHP := 10000 hero := testHeroOnRoad(1, 500, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.Excursion.Phase = model.ExcursionReturn hm.enterAdventureReturnToRoad() hm.CurrentX = hm.Excursion.AttractorX hm.CurrentY = hm.Excursion.AttractorY hm.LastMoveTick = now ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil) if hm.State != model.StateWalking { t.Fatalf("expected StateWalking after return completes, got %s", hm.State) } if hm.Excursion.Active() { t.Fatal("excursion should be cleared after return phase reached road attractor") } } func TestAdventureInlineRest_NoTimerFieldSet(t *testing.T) { hero := testHeroOnRoad(1, 100, 1000) now := time.Now() graph := testGraph() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.beginAdventureInlineRest(now) if !hm.RestUntil.IsZero() { t.Fatal("adventure inline rest should not set RestUntil (HP-based only)") } } // --- Persistence --- func TestRoadsideRest_Persistence(t *testing.T) { graph := testGraph() maxHP := 1000 hero := testHeroOnRoad(1, 100, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) hm.SyncToHero() if hero.RestKind != model.RestKindRoadside { t.Fatalf("expected hero.RestKind = roadside, got %s", hero.RestKind) } if hero.TownPause == nil { t.Fatal("expected TownPause to be set") } if hero.TownPause.RestKind != model.RestKindRoadside { t.Fatalf("expected TownPause.RestKind = roadside, got %s", hero.TownPause.RestKind) } if hero.TownPause.RestUntil == nil || hero.TownPause.RestUntil.IsZero() { t.Fatal("expected TownPause.RestUntil to be set for roadside rest") } hero2 := *hero hm2 := NewHeroMovement(&hero2, graph, now) if hm2.State != model.StateResting { t.Fatalf("expected restored state = resting, got %s", hm2.State) } if hm2.ActiveRestKind != model.RestKindRoadside { t.Fatalf("expected restored rest kind = roadside, got %s", hm2.ActiveRestKind) } if hm2.RestUntil.IsZero() { t.Fatal("expected restored RestUntil to be non-zero") } if hm2.Road == nil { t.Fatal("expected road to be assigned for roadside rest reconnect") } } func TestAdventureInlineRest_Persistence(t *testing.T) { graph := testGraph() maxHP := 1000 hero := testHeroOnRoad(1, 100, maxHP) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.Excursion.Phase = model.ExcursionWild hm.beginAdventureInlineRest(now) hm.SyncToHero() if hero.RestKind != model.RestKindAdventureInline { t.Fatalf("expected hero.RestKind = adventure_inline, got %s", hero.RestKind) } if hero.TownPause == nil { t.Fatal("expected TownPause to be set") } if hero.TownPause.RestKind != model.RestKindAdventureInline { t.Fatalf("expected TownPause.RestKind = adventure_inline, got %s", hero.TownPause.RestKind) } if hero.TownPause.Excursion == nil { t.Fatal("expected TownPause.Excursion to be set") } hero2 := *hero hm2 := NewHeroMovement(&hero2, graph, now) if hm2.State != model.StateResting { t.Fatalf("expected restored state = resting, got %s", hm2.State) } if hm2.ActiveRestKind != model.RestKindAdventureInline { t.Fatalf("expected restored rest kind = adventure_inline, got %s", hm2.ActiveRestKind) } if !hm2.Excursion.Active() { t.Fatal("expected excursion to be restored") } if hm2.Road == nil { t.Fatal("expected road to be assigned for adventure inline rest reconnect") } } // --- Admin --- func TestAdminStartRoadsideRest(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) if !hm.AdminStartRoadsideRest(now) { t.Fatal("AdminStartRoadsideRest should succeed") } if hm.State != model.StateResting { t.Fatalf("expected resting, got %s", hm.State) } if hm.ActiveRestKind != model.RestKindRoadside { t.Fatalf("expected roadside, got %s", hm.ActiveRestKind) } } func TestAdminStartRoadsideRest_RejectsDead(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 0, 1000) hero.State = model.StateDead now := time.Now() hm := NewHeroMovement(hero, graph, now) if hm.AdminStartRoadsideRest(now) { t.Fatal("AdminStartRoadsideRest should reject dead hero") } } func TestAdminStopRest_Roadside(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 100, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) if !hm.AdminStopRest(now) { t.Fatal("AdminStopRest should succeed for roadside rest") } if hm.State != model.StateWalking { t.Fatalf("expected walking, got %s", hm.State) } if hm.ActiveRestKind != model.RestKindNone { t.Fatalf("expected rest kind none, got %s", hm.ActiveRestKind) } } func TestAdminStopRest_AdventureInline(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 100, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.beginAdventureInlineRest(now) if !hm.AdminStopRest(now) { t.Fatal("AdminStopRest should succeed for adventure inline rest") } if hm.State != model.StateWalking { t.Fatalf("expected walking, got %s", hm.State) } if hm.Excursion.Active() { t.Fatal("excursion should be cleared after admin stop rest") } } func TestAdminStopRest_RejectsTownRest(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.AdminStartRest(now, graph) if hm.AdminStopRest(now) { t.Fatal("AdminStopRest should reject town rest") } } func TestAdminStopRest_RejectsWalking(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) if hm.AdminStopRest(now) { t.Fatal("AdminStopRest should reject walking hero") } } func TestAdminStartExcursion(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) if !hm.AdminStartExcursion(now) { t.Fatal("AdminStartExcursion should succeed") } if !hm.Excursion.Active() { t.Fatal("excursion should be active") } if hm.Excursion.Phase != model.ExcursionOut { t.Fatalf("expected phase out, got %s", hm.Excursion.Phase) } } func TestAdminStartExcursion_RejectsWhenAlreadyActive(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) if hm.AdminStartExcursion(now) { t.Fatal("AdminStartExcursion should reject when excursion already active") } } func TestAdminStartExcursion_RejectsNotWalking(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) if hm.AdminStartExcursion(now) { t.Fatal("AdminStartExcursion should reject when not walking") } } func TestAdminStopExcursion_WhileWalking(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) if !hm.AdminStopExcursion(now) { t.Fatal("AdminStopExcursion should succeed") } if !hm.Excursion.Active() { t.Fatal("excursion should stay active during return leg") } if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected return phase, got %s", hm.Excursion.Phase) } if hm.State != model.StateWalking { t.Fatalf("expected walking, got %s", hm.State) } } func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 100, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginExcursion(now) hm.beginAdventureInlineRest(now) if !hm.AdminStopExcursion(now) { t.Fatal("AdminStopExcursion should succeed from inline rest") } if !hm.Excursion.Active() { t.Fatal("excursion should stay active during return leg") } if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected return phase, got %s", hm.Excursion.Phase) } if hm.State != model.StateWalking { t.Fatalf("expected walking, got %s", hm.State) } } func TestAdminStopExcursion_RoadsideStartsReturn(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 100, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.beginRoadsideRest(now) hm.Excursion.Phase = model.ExcursionWild hm.LastMoveTick = now if !hm.AdminStopExcursion(now) { t.Fatal("AdminStopExcursion should succeed for roadside excursion") } if !hm.Excursion.Active() { t.Fatal("excursion should stay active during return leg") } if hm.Excursion.Phase != model.ExcursionReturn { t.Fatalf("expected return phase, got %s", hm.Excursion.Phase) } if hm.State != model.StateResting || hm.ActiveRestKind != model.RestKindRoadside { t.Fatalf("expected roadside rest, got state=%s kind=%s", hm.State, hm.ActiveRestKind) } } func TestAdminStopExcursion_RejectsNone(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 500, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) if hm.AdminStopExcursion(now) { 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, 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, 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) } } // TestProcessMovementTick_DeadHeroIgnoresWalkingFSM ensures HP 0 never runs the walking // tick (no hero_move) even if hm.State was incorrectly left as Walking. func TestProcessMovementTick_DeadHeroIgnoresWalkingFSM(t *testing.T) { graph := testGraph() hero := testHeroOnRoad(1, 0, 1000) now := time.Now() hm := NewHeroMovement(hero, graph, now) if hm.State != model.StateDead { t.Fatalf("NewHeroMovement should set dead for HP=0, got %s", hm.State) } hm.State = model.StateWalking // simulate FSM / snapshot desync ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil, nil, nil) if hm.State != model.StateDead { t.Fatalf("expected StateDead after tick, got %s", hm.State) } }