diff --git a/backend/internal/game/enemy_templates_testdata_test.go b/backend/internal/game/enemy_templates_testdata_test.go index 8ea0482..95c424b 100644 --- a/backend/internal/game/enemy_templates_testdata_test.go +++ b/backend/internal/game/enemy_templates_testdata_test.go @@ -21,7 +21,8 @@ func ensureTestEnemyTemplates() { HPPerLevel: 5, AttackPerLevel: 1.5, DefensePerLevel: 1.0, - XPReward: 10, + XPPerLevel: 2, + XPReward: 1, GoldReward: 5, }, model.EnemyBoar: { @@ -38,7 +39,8 @@ func ensureTestEnemyTemplates() { HPPerLevel: 6, AttackPerLevel: 1.8, DefensePerLevel: 1.3, - XPReward: 14, + XPPerLevel: 2, + XPReward: 1, GoldReward: 8, }, }) diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index bf8bb3f..72e836c 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -535,7 +535,13 @@ func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int) model.Enemy { picked.HP = picked.MaxHP picked.Attack = max(1, int(math.Round(float64(picked.Attack)+levelDelta*picked.AttackPerLevel))) picked.Defense = max(0, int(math.Round(float64(picked.Defense)+levelDelta*picked.DefensePerLevel))) - picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*picked.XPPerLevel))) + xpPerLevel := picked.XPPerLevel + // Keep early-game kill cadence predictable (~1 XP from template base for normal mobs); + // xp_per_level ramps from instance level 10+ (and always applies to elites). + if level < 10 && !picked.IsElite { + xpPerLevel = 0 + } + picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel))) picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel))) return picked } diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index bd65cbf..ad298dd 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -56,9 +56,9 @@ func TestSimulateOneFight_HeroDies(t *testing.T) { } func TestSimulateOneFight_LevelUp(t *testing.T) { - // Seed XP just below L1->L2 threshold (180 in v3). + // Seed XP just below L1->L2 threshold (100 XP with default tuning). hero := &model.Hero{ - Level: 1, XP: 179, + Level: 1, XP: 99, MaxHP: 10000, HP: 10000, Attack: 100, Defense: 60, Speed: 1.0, Strength: 10, Constitution: 10, Agility: 10, Luck: 5, @@ -75,7 +75,30 @@ func TestSimulateOneFight_LevelUp(t *testing.T) { t.Fatal("expected XP gain") } if hero.Level < 2 { - t.Fatalf("expected level 2+ after gaining %d XP from 179 base, got level %d", xpGained, hero.Level) + 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) + 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) + 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) + if el.XPReward <= 1 { + t.Fatalf("elite instance L5: want xp_per_level even before 10, got %d", el.XPReward) } } diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 025a072..cf4d430 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -86,12 +86,12 @@ type BuffChargeState struct { } // XPToNextLevel returns the XP delta required to advance from the given level -// to level+1. Phase-based curve (spec §9) — v3 scales bases ×10 vs v2 for ~10× -// slower leveling when paired with reduced kill XP: +// to level+1. Early band uses a nonlinear step (~100 kills at 1 XP/kill for L1→2, +// ~150 for L2→3, ~225 for L3→4 with defaults). Mid/late bands use tuning bases. // -// L 1–9: round(180 * 1.28^(L-1)) -// L 10–29: round(1450 * 1.15^(L-10)) -// L 30+: round(23000 * 1.10^(L-30)) +// L 1–9: round(earlyBase * earlyScale^(L-1)) +// L 10–29: round(midBase * midScale^(L-10)) +// L 30+: round(lateBase * lateScale^(L-30)) func XPToNextLevel(level int) int64 { cfg := tuning.Get() if level < 1 { diff --git a/backend/internal/model/hero_test.go b/backend/internal/model/hero_test.go index a5e2747..c1dad02 100644 --- a/backend/internal/model/hero_test.go +++ b/backend/internal/model/hero_test.go @@ -304,17 +304,21 @@ func TestProgressionV3CanonicalSnapshots(t *testing.T) { } func TestXPToNextLevelFormula(t *testing.T) { - if got := XPToNextLevel(1); got != 180 { - t.Fatalf("XPToNextLevel(1) = %d, want 180", got) + // Early: ~100 / 150 / 225 kills at 1 XP per kill (nonlinear 1.5× per level band). + if got := XPToNextLevel(1); got != 100 { + t.Fatalf("XPToNextLevel(1) = %d, want 100", got) } - if got := XPToNextLevel(2); got != 230 { - t.Fatalf("XPToNextLevel(2) = %d, want 230", got) + if got := XPToNextLevel(2); got != 150 { + t.Fatalf("XPToNextLevel(2) = %d, want 150", got) } - if got := XPToNextLevel(10); got != 1450 { - t.Fatalf("XPToNextLevel(10) = %d, want 1450", got) + if got := XPToNextLevel(3); got != 225 { + t.Fatalf("XPToNextLevel(3) = %d, want 225", got) } - if got := XPToNextLevel(30); got != 23000 { - t.Fatalf("XPToNextLevel(30) = %d, want 23000", got) + if got := XPToNextLevel(10); got != 2947 { + t.Fatalf("XPToNextLevel(10) = %d, want 2947", got) + } + if got := XPToNextLevel(30); got != 48232 { + t.Fatalf("XPToNextLevel(30) = %d, want 48232", got) } } diff --git a/backend/internal/tuning/runtime.go b/backend/internal/tuning/runtime.go index 5d7b5ce..06303c6 100644 --- a/backend/internal/tuning/runtime.go +++ b/backend/internal/tuning/runtime.go @@ -264,10 +264,10 @@ func DefaultValues() Values { LootChanceRare: 0.02, LootChanceEpic: 0.003, LootChanceLegendary: 0.0005, - GoldLootScale: 0.5, - GoldDropChance: 0.90, - PotionDropChance: 0.05, - EquipmentDropBase: 0.15, + GoldLootScale: 0.62, + GoldDropChance: 0.92, + PotionDropChance: 0.06, + EquipmentDropBase: 0.20, GoldCommonMin: 0, GoldCommonMax: 5, GoldUncommonMin: 6, @@ -327,11 +327,11 @@ func DefaultValues() Values { PotionAutoUseThreshold: 0.30, ReviveHpPercent: 0.50, AutoReviveAfterMs: int64(time.Hour / time.Millisecond), - XPCurveEarlyBase: 180, - XPCurveEarlyScale: 1.28, - XPCurveMidBase: 1450, + XPCurveEarlyBase: 100, + XPCurveEarlyScale: 1.5, + XPCurveMidBase: 2947, XPCurveMidScale: 1.15, - XPCurveLateBase: 23000, + XPCurveLateBase: 48232, XPCurveLateScale: 1.10, LevelUpHPEvery: 4, LevelUpHpBase: 10,