package game import ( "math/rand" "testing" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) func testGraphTownTour(t *testing.T) *RoadGraph { t.Helper() g := testGraph() g.Towns[1].Radius = 35 npc := TownNPC{ID: 101, Name: "Merchant", NameKey: "npc.merchant.test", Type: "merchant", OffsetX: 2, OffsetY: 1} g.TownNPCs[1] = []TownNPC{npc} g.NPCByID[101] = npc return g } func heroInTown(id int64, townID int64) *model.Hero { return &model.Hero{ ID: id, Level: 5, HP: 900, MaxHP: 1000, Attack: 50, Defense: 30, Speed: 1.0, Strength: 10, Constitution: 10, Agility: 10, Luck: 5, State: model.StateInTown, CurrentTownID: &townID, PositionX: 1, PositionY: 1, } } func TestTownTour_WelcomeTimeoutReturnsToWander(t *testing.T) { graph := testGraphTownTour(t) hero := heroInTown(1, 1) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.CurrentTownID = 1 hm.CurrentX = 1 hm.CurrentY = 1 hm.LastMoveTick = now hm.Excursion = model.ExcursionSession{ Kind: model.ExcursionKindTown, Phase: model.ExcursionWild, TownTourPhase: string(model.TownTourPhaseNpcWelcome), TownWelcomeUntil: now.Add(-time.Second), TownTourNpcID: 101, TownTourStandX: 3, TownTourStandY: 2, AttractorSet: true, AttractorX: 3, AttractorY: 2, } ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(50*time.Millisecond), nil, nil, nil, nil, nil, nil, nil) if hm.Excursion.TownTourPhase != string(model.TownTourPhaseWander) { t.Fatalf("expected wander after welcome timeout, got %q", hm.Excursion.TownTourPhase) } } func TestTownTour_DialogClosedFromWelcomeLeavesWelcome(t *testing.T) { graph := testGraphTownTour(t) hero := heroInTown(1, 1) now := time.Now() hm := NewHeroMovement(hero, graph, now) hm.CurrentTownID = 1 hm.Excursion = model.ExcursionSession{ Kind: model.ExcursionKindTown, Phase: model.ExcursionWild, TownTourPhase: string(model.TownTourPhaseNpcWelcome), TownWelcomeUntil: now.Add(time.Hour), TownTourNpcID: 101, } hm.townTourNPCDialogClosed(now, graph) if model.TownTourPhase(hm.Excursion.TownTourPhase) == model.TownTourPhaseNpcWelcome { t.Fatal("still in npc_welcome after dialog closed") } if !hm.Excursion.TownWelcomeUntil.IsZero() { t.Fatal("expected TownWelcomeUntil cleared") } } // TestTownTour_DefaultPNpc_AtLeastOneNpcOpportunity uses default tuning to estimate P(≥1 NPC attractor pick) // over a synthetic town stay (retarget cadence vs tour length). Target from design: ≥ 0.6. func TestTownTour_DefaultPNpc_AtLeastOneNpcOpportunity(t *testing.T) { cfg := tuning.DefaultValues() pNpc := cfg.TownTourNpcAttractorChance minRT := cfg.TownTourWanderRetargetMinMs maxRT := cfg.TownTourWanderRetargetMaxMs minStay := cfg.TownRestMinMs maxStay := cfg.TownRestMaxMs if minRT <= 0 || maxRT < minRT || minStay <= 0 || maxStay < minStay { t.Fatal("invalid default town tour / rest durations") } const trials = 8000 rng := rand.New(rand.NewSource(42)) hits := 0 for i := 0; i < trials; i++ { stayMs := minStay + rng.Int63n(maxStay-minStay+1) anyNpc := false for elapsed := int64(0); elapsed < stayMs; { if rng.Float64() < pNpc { anyNpc = true break } step := minRT + rng.Int63n(maxRT-minRT+1) if step < 1 { step = 1 } elapsed += step } if anyNpc { hits++ } } rate := float64(hits) / float64(trials) if rate < 0.6 { t.Fatalf("Monte Carlo P(≥1 NPC retarget)=%.3f with defaults; want >= 0.6 (townTourNpcAttractorChance=%.3f)", rate, pNpc) } }