package game import ( "testing" "time" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/tuning" ) func TestOfflineDigestCollecting(t *testing.T) { now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) recent := now.Add(-20 * time.Second) if OfflineDigestCollecting(&recent, now) { t.Error("expected false before grace window") } old := now.Add(-2 * time.Minute) if !OfflineDigestCollecting(&old, now) { t.Error("expected true after grace window") } if OfflineDigestCollecting(nil, now) { t.Error("expected false when disconnect time is nil") } } func TestSimulateOneFight_HeroSurvives(t *testing.T) { hero := &model.Hero{ Level: 1, XP: 0, MaxHP: 10000, HP: 10000, Attack: 100, Defense: 60, Speed: 1.0, Strength: 10, Constitution: 10, Agility: 10, Luck: 5, State: model.StateWalking, } now := time.Now() survived, enemy, xpGained, goldGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) if !survived { t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) } if xpGained <= 0 { t.Fatal("expected positive XP gain") } if goldGained <= 0 { t.Fatal("expected positive gold gain") } if enemy.Name == "" { t.Fatal("expected enemy with a name") } } func TestSimulateOneFight_HeroDies(t *testing.T) { hero := &model.Hero{ Level: 1, XP: 0, MaxHP: 1, HP: 1, Attack: 1, Defense: 0, Speed: 1.0, State: model.StateWalking, } now := time.Now() survived, _, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) if survived { t.Fatal("1 HP hero should die to any enemy") } if hero.HP != 0 { t.Fatalf("expected HP 0 after death, got %d", hero.HP) } if hero.State != model.StateDead { t.Fatalf("expected state dead, got %s", hero.State) } } func TestSimulateOneFight_LevelUp(t *testing.T) { // Seed XP just below L1->L2 threshold (100 XP with default tuning). hero := &model.Hero{ Level: 1, XP: 99, MaxHP: 10000, HP: 10000, Attack: 100, Defense: 60, Speed: 1.0, Strength: 10, Constitution: 10, Agility: 10, Luck: 5, State: model.StateWalking, } now := time.Now() survived, _, xpGained, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) if !survived { t.Fatal("overpowered hero should survive") } if xpGained <= 0 { t.Fatal("expected XP gain") } if hero.Level < 2 { t.Fatalf("expected level 2+ after gaining %d XP from 99 base, got level %d", xpGained, hero.Level) } } func TestBuildEnemyInstanceForLevel_XPPerLevelRampsFrom10(t *testing.T) { tmpl := model.Enemy{ BaseLevel: 1, XPReward: 1, XPPerLevel: 4, IsElite: false, } early := BuildEnemyInstanceForLevel(tmpl, 6, nil) if early.XPReward != 1 { t.Fatalf("normal mob instance L6: want base XP only (no per-level ramp), got %d", early.XPReward) } mid := BuildEnemyInstanceForLevel(tmpl, 12, nil) if mid.XPReward <= 1 { t.Fatalf("normal mob instance L12: want xp_per_level applied, got %d", mid.XPReward) } elite := tmpl elite.IsElite = true el := BuildEnemyInstanceForLevel(elite, 5, nil) if el.XPReward <= 1 { t.Fatalf("elite instance L5: want xp_per_level even before 10, got %d", el.XPReward) } } func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) { hero := &model.Hero{ MaxHP: 100, HP: 100, Potions: 3, } if used := OfflineAutoPotionHook(hero); used { t.Fatal("expected no potion usage when hero is above threshold") } if hero.Potions != 3 { t.Fatalf("expected potions unchanged, got %d", hero.Potions) } } func TestNonGoldLootForDigest(t *testing.T) { drops := []model.LootDrop{ {ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 10}, {ItemType: "potion", Rarity: model.RarityCommon}, {ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 5}, } out := NonGoldLootForDigest(drops) if len(out) != 1 || out[0].ItemType != "potion" { t.Fatalf("want single potion line, got %#v", out) } if NonGoldLootForDigest(nil) != nil { t.Fatal("nil in -> nil out") } if NonGoldLootForDigest([]model.LootDrop{{ItemType: "gold", GoldAmount: 1}}) != nil { t.Fatal("gold-only -> nil") } } func TestBuildEnemyInstanceForLevel_EncounterStatMultiplier(t *testing.T) { cfg := tuning.DefaultValues() cfg.EnemyEncounterStatMultiplier = 2.0 tuning.Set(cfg) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) tmpl := model.Enemy{ BaseLevel: 1, MaxHP: 50, HP: 50, Attack: 10, Defense: 4, } out := BuildEnemyInstanceForLevel(tmpl, 1, nil) if out.MaxHP != 100 || out.HP != 100 { t.Fatalf("MaxHP/HP: got %d/%d want 100/100", out.MaxHP, out.HP) } if out.Attack != 20 || out.Defense != 8 { t.Fatalf("Attack/Defense: got %d/%d want 20/8", out.Attack, out.Defense) } scaled := BuildEnemyInstanceForLevelScaledOnly(tmpl, 1) if scaled.MaxHP != 50 || scaled.Attack != 10 || scaled.Defense != 4 { t.Fatalf("scaled-only: got hp=%d atk=%d def=%d want 50/10/4", scaled.MaxHP, scaled.Attack, scaled.Defense) } base, afterG := EnemyEncounterStatStages(tmpl, 1) if base.MaxHP != 50 || afterG.MaxHP != 100 { t.Fatalf("stages MaxHP: base=%d afterGlobal=%d", base.MaxHP, afterG.MaxHP) } if afterG.Attack != 20 || afterG.Defense != 8 { t.Fatalf("stages atk/def: got %d/%d want 20/8", afterG.Attack, afterG.Defense) } } func TestApplyEnemyEncounterHeroScaling_Unequipped(t *testing.T) { cfg := tuning.DefaultValues() cfg.EnemyEncounterStatMultiplier = 1.0 cfg.EnemyStatMultiplierVsUnequippedHero = 0.75 tuning.Set(cfg) t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) hero := &model.Hero{Gear: make(map[model.EquipmentSlot]*model.GearItem)} enemy := model.Enemy{MaxHP: 100, HP: 100, Attack: 20, Defense: 8} ApplyEnemyEncounterHeroScaling(hero, &enemy) if enemy.MaxHP != 75 || enemy.HP != 75 || enemy.Attack != 15 || enemy.Defense != 6 { t.Fatalf("scaled enemy: got hp=%d atk=%d def=%d", enemy.MaxHP, enemy.Attack, enemy.Defense) } hero.Gear[model.SlotMainHand] = &model.GearItem{PrimaryStat: 1, StatType: "attack"} enemy2 := model.Enemy{MaxHP: 100, HP: 100, Attack: 20, Defense: 8} ApplyEnemyEncounterHeroScaling(hero, &enemy2) if enemy2.MaxHP != 100 { t.Fatalf("geared hero should not scale enemy: got MaxHP %d", enemy2.MaxHP) } } func TestPickEnemyForLevel(t *testing.T) { tests := []struct { level int }{ {1}, {5}, {10}, {20}, {50}, } for _, tt := range tests { enemy := PickEnemyForLevel(tt.level) if enemy.Name == "" { t.Errorf("PickEnemyForLevel(%d) returned enemy with empty name", tt.level) } if enemy.MaxHP <= 0 { t.Errorf("PickEnemyForLevel(%d) returned enemy with MaxHP=%d", tt.level, enemy.MaxHP) } if enemy.HP != enemy.MaxHP { t.Errorf("PickEnemyForLevel(%d) returned enemy with HP=%d != MaxHP=%d", tt.level, enemy.HP, enemy.MaxHP) } } } func TestScaleEnemyTemplate(t *testing.T) { tmpl, ok := model.EnemyBySlug("wolf") if !ok { tmpl = model.Enemy{ Slug: "wolf", Archetype: "wolf", Name: "Forest Wolf", MaxHP: 40, HP: 40, Attack: 8, Defense: 2, Speed: 1.2, BaseLevel: 1, LevelVariance: 0.3, MaxHeroLevelDiff: 5, HPPerLevel: 5, AttackPerLevel: 1.5, DefensePerLevel: 1.0, } } scaled := ScaleEnemyTemplate(tmpl, 5) if scaled.MaxHP <= tmpl.MaxHP { t.Errorf("scaled MaxHP %d should exceed base %d at level 5", scaled.MaxHP, tmpl.MaxHP) } if scaled.HP != scaled.MaxHP { t.Errorf("scaled HP %d should equal MaxHP %d", scaled.HP, scaled.MaxHP) } }