package game import ( "math" "testing" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) func TestBandIndexForHeroLevel(t *testing.T) { cases := []struct { L int want int }{ {1, 0}, {9, 0}, {10, 1}, {19, 1}, {20, 2}, {29, 2}, {30, 3}, {39, 3}, {40, 4}, {49, 4}, {0, -1}, {50, -1}, } for _, tc := range cases { if got := bandIndexForHeroLevel(tc.L); got != tc.want { t.Fatalf("bandIndexForHeroLevel(%d)=%d want %d", tc.L, got, tc.want) } } } func TestTemplateProgressionBand(t *testing.T) { if TemplateProgressionBand(model.Enemy{MinLevel: 1, MaxLevel: 5, BaseLevel: 1}) != 0 { t.Fatal("low-tier template should be band 0") } // Midpoint (25+35)/2 = 30 → tier band index 2 (see TemplateProgressionBand). if TemplateProgressionBand(model.Enemy{MinLevel: 25, MaxLevel: 35, BaseLevel: 25}) != 2 { t.Fatalf("mid 30 should map to template band 2, got %d", TemplateProgressionBand(model.Enemy{MinLevel: 25, MaxLevel: 35})) } if TemplateProgressionBand(model.Enemy{MinLevel: 41, MaxLevel: 45, BaseLevel: 41}) != 4 { t.Fatalf("late template should map to band 4, got %d", TemplateProgressionBand(model.Enemy{MinLevel: 41, MaxLevel: 45})) } } func TestAggregateBandDurationsPure(t *testing.T) { // One second per level-up; 9+10+10+10+10 = 49 levels sec := make([]float64, 49) for i := range sec { sec[i] = 1 } var band [5]time.Duration for idx, L := range levelRange(1, 49) { bi := bandIndexForHeroLevel(L) if bi >= 0 { band[bi] += time.Duration(sec[idx] * float64(time.Second)) } } if band[0] != 9*time.Second { t.Fatalf("band0: %v", band[0]) } for i := 1; i <= 4; i++ { if band[i] != 10*time.Second { t.Fatalf("band %d: %v", i, band[i]) } } } func TestGlobalScaleMonotonicTotalTime(t *testing.T) { ensureTestEnemyTemplates() cfg := tuning.DefaultValues() tuning.Set(cfg) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) base := CloneEnemyTemplates(EnemyTemplatesFromSlice(model.EnemyTemplates)) params := ProgressionSimParams{ IterationsPerLevel: 6, Seed: 42, RestAfterCombat: 10 * time.Second, Gear: ReferenceGearMedian, AccountLosses: false, MinHeroLevel: 1, MaxHeroLevelInclusive: 15, } slow := ApplyXPRewardScaleSpec(base, XPRewardScaleSpec{Global: 0.2, Elite: 1, PerBand: [5]float64{1, 1, 1, 1, 1}}) fast := ApplyXPRewardScaleSpec(base, XPRewardScaleSpec{Global: 5.0, Elite: 1, PerBand: [5]float64{1, 1, 1, 1, 1}}) rSlow, err := SimulateProgressionBands(params, slow) if err != nil { t.Fatal(err) } rFast, err := SimulateProgressionBands(params, fast) if err != nil { t.Fatal(err) } if rSlow.Total <= rFast.Total { t.Fatalf("expected higher XP scale to reduce total time: slow=%s fast=%s", rSlow.Total, rFast.Total) } } func TestProratedBandTargets(t *testing.T) { full := DefaultProgressionBandTargets // Through L29: full bands 0,1,2 → 1+3+6 weeks pr := ProratedBandTargets(29, full) sum := SumBandTargets(pr) wantWeeks := 10 // 1+3+6 if got := int(sum / (7 * 24 * time.Hour)); got != wantWeeks { t.Fatalf("ProratedBandTargets(29) sum weeks=%d want %d (%v)", got, wantWeeks, pr) } // Through L9: only band 0 full pr2 := ProratedBandTargets(9, full) if SumBandTargets(pr2) != full[0] { t.Fatalf("band0 only: %v", pr2) } } func TestEnforceMonotonicXPRewardByTier(t *testing.T) { m := map[string]model.Enemy{ "a": {Slug: "a", MinLevel: 1, MaxLevel: 5, XPReward: 10}, "b": {Slug: "b", MinLevel: 20, MaxLevel: 30, XPReward: 5}, } out := EnforceMonotonicXPRewardByTier(m) if out["b"].XPReward <= out["a"].XPReward { t.Fatalf("higher tier should have strictly higher xp: a=%d b=%d", out["a"].XPReward, out["b"].XPReward) } } func TestOptimizePerTypeScalesRuns(t *testing.T) { ensureTestEnemyTemplates() cfg := tuning.DefaultValues() tuning.Set(cfg) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) base := CloneEnemyTemplates(EnemyTemplatesFromSlice(model.EnemyTemplates)) params := ProgressionSimParams{ IterationsPerLevel: 3, Seed: 1, RestAfterCombat: 5 * time.Second, Gear: ReferenceGearMedian, MinHeroLevel: 1, MaxHeroLevelInclusive: 6, } targets := ProratedBandTargets(6, DefaultProgressionBandTargets) m, _, res, sq := OptimizePerTypeScales(base, params, targets, 1, 6, false) if len(m) != len(base) { t.Fatalf("len(perType)=%d len(base)=%d", len(m), len(base)) } if math.IsNaN(sq) { t.Fatal("sqErr nan") } if math.IsInf(res.TotalSec, 1) { t.Skip("combat unwinnable in testdata") } } func TestSquaredErrorSum(t *testing.T) { sim := [5]time.Duration{10 * time.Second, 20 * time.Second, 30 * time.Second, 40 * time.Second, 50 * time.Second} tg := [5]time.Duration{10 * time.Second, 10 * time.Second, 10 * time.Second, 10 * time.Second, 10 * time.Second} if SquaredErrorSum(sim, tg) <= 0 { t.Fatal("expected positive error") } }