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.

661 lines
20 KiB
Go

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)
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)
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)
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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)
pastTimer := hm.RestUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, 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)
}
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, 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)
// 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, nil)
if hm.Excursion.Phase != model.ExcursionReturn {
t.Fatalf("expected excursion Return phase after HP threshold exit, got %s", hm.Excursion.Phase)
}
}
func TestRoadsideRest_DisplayOffset(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(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 out phase")
}
}
// --- 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)
tick := hm.Excursion.OutUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, 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)
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)
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 TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) {
graph := testGraph()
maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after excursion end, got %s", hm.State)
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after return phase ended")
}
}
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 be cleared")
}
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 be cleared")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
}
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)
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)
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)
if hm.State != model.StateDead {
t.Fatalf("expected StateDead after tick, got %s", hm.State)
}
}