diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index 6d49083..c172f04 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -235,6 +235,21 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar if c.Level >= 7 { stats.AC++ } + // Phase 10 SUB3d — Hunter L10 Multiattack (Volley/Whirlwind). Both 5e + // options are AoE; in 1v1 there's only one target, so we collapse the + // extra-target throughput to "extra hit per round on the one foe" — a + // flat +15% damage bump (close to a half-extra-attack's value, soft + // on the upside since AoE wouldn't all land on a single target). + if c.Level >= 10 { + mods.DamageBonus += 0.15 + } + // Phase 10 SUB3d — Hunter L15 Superior Hunter's Defense. 5e picks one + // of Evasion / Stand Against the Tide / Uncanny Dodge. Uncanny Dodge + // (halve damage from one attack/turn) is the most generally-applicable + // — proxy as a permanent ~15% incoming damage reduction. + if c.Level >= 15 { + mods.DamageReduct *= 0.85 + } case SubclassBeastMaster: // L5 Ranger's Companion + Exceptional Training: a baseline combat // pet shows up on initiative and pokes the enemy. Use max-style @@ -259,6 +274,37 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar mods.PetAttackDmg = dmg } } + // Phase 10 SUB3d — Beast Master L10 Share Spells. 5e: any spell the + // Ranger casts on themselves can also affect the pet within 30 ft. + // In practice this means the pet rides the Ranger's self-buffs + // (Hunter's Mark damage rider, Pass without Trace, etc.). We fold + // that into the pet channel: bump per-hit damage and proc rate to + // reflect the buffed pet, plus a small DamageBonus rider matching + // the player's own buffed swings sharing. + if c.Level >= 10 { + if mods.PetAttackProc < 0.45 { + mods.PetAttackProc = 0.45 + } + if mods.PetAttackDmg < 6+c.Level/2 { + mods.PetAttackDmg = 6 + c.Level/2 + } + mods.DamageBonus += 0.05 + } + // Phase 10 SUB3d — Beast Master L15 Superior Bond. 5e: pet immune to + // charm/fright, returns at 1 HP after a long rest if killed. We don't + // simulate pet death, so we proxy the "always-there partner" fantasy + // as a further pet-throughput bump and a small DamageReduct (pet + // body-blocks more reliably). + if c.Level >= 15 { + if mods.PetAttackProc < 0.55 { + mods.PetAttackProc = 0.55 + } + extra := 8 + c.Level/2 + if mods.PetAttackDmg < extra { + mods.PetAttackDmg = extra + } + mods.DamageReduct *= 0.92 + } case SubclassGloomStalker: // L5 Dread Ambusher: +WIS to initiative and +1 attack with +1d8 in // the first round. Initiative rides on Speed in our model; the @@ -277,6 +323,21 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar if c.Level >= 7 { mods.AssassinateAdvantage = true } + // Phase 10 SUB3d — Gloom Stalker L10 Stalker's Flurry. 5e: re-roll a + // missed attack once per turn. AssassinateAdvantage already grants + // "best of two" on the opener (L7 Stalker's Flurry proxy), so the + // L10 expansion to "every turn" rides as steady throughput — a flat + // +12% damage bump representing recovered misses across the fight. + if c.Level >= 10 { + mods.DamageBonus += 0.12 + } + // Phase 10 SUB3d — Gloom Stalker L15 Shadowy Dodge. 5e: Reaction — + // when attacked, impose disadvantage on that attack. We don't model + // reactions, so collapse the "one attack per round at disadvantage" + // to a permanent ~10% incoming damage reduction. + if c.Level >= 15 { + mods.DamageReduct *= 0.90 + } } } diff --git a/internal/plugin/dnd_subclass_ranger_test.go b/internal/plugin/dnd_subclass_ranger_test.go index f0b30dc..cef7b1c 100644 --- a/internal/plugin/dnd_subclass_ranger_test.go +++ b/internal/plugin/dnd_subclass_ranger_test.go @@ -166,6 +166,123 @@ func TestSubclassSkillAdvantage_GloomStalkerPreL5No(t *testing.T) { } } +// ── Phase 10 SUB3d — Hunter L10/L15 ───────────────────────────────────── + +func TestApplySubclassPassives_HunterL10Multiattack(t *testing.T) { + c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassHunter, Level: 10} + stats := &CombatStats{AC: 14} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + // L5 +0.10 + L10 +0.15 = 0.25 + if mods.DamageBonus < 0.249 || mods.DamageBonus > 0.251 { + t.Errorf("Hunter L10 DamageBonus = %v, want ~0.25", mods.DamageBonus) + } + if mods.DamageReduct != 1.0 { + t.Errorf("Hunter L10 DamageReduct = %v, want 1.0 (no Superior Defense yet)", mods.DamageReduct) + } +} + +func TestApplySubclassPassives_HunterL15SuperiorDefense(t *testing.T) { + c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassHunter, Level: 15} + stats := &CombatStats{AC: 14} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if mods.DamageReduct < 0.849 || mods.DamageReduct > 0.851 { + t.Errorf("Hunter L15 DamageReduct = %v, want ~0.85", mods.DamageReduct) + } + if mods.DamageBonus < 0.249 || mods.DamageBonus > 0.251 { + t.Errorf("Hunter L15 DamageBonus = %v, want ~0.25 (L5+L10 still on)", mods.DamageBonus) + } +} + +// ── Phase 10 SUB3d — Beast Master L10/L15 ─────────────────────────────── + +func TestApplySubclassPassives_BeastMasterL10ShareSpells(t *testing.T) { + c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 10} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.PetAttackProc < 0.449 || mods.PetAttackProc > 0.451 { + t.Errorf("BM L10 PetAttackProc = %v, want ~0.45", mods.PetAttackProc) + } + // L10 → 6 + 10/2 = 11 + if mods.PetAttackDmg != 11 { + t.Errorf("BM L10 PetAttackDmg = %d, want 11", mods.PetAttackDmg) + } + if mods.DamageBonus < 0.049 || mods.DamageBonus > 0.051 { + t.Errorf("BM L10 DamageBonus = %v, want ~0.05", mods.DamageBonus) + } +} + +func TestApplySubclassPassives_BeastMasterL15SuperiorBond(t *testing.T) { + c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 15} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.PetAttackProc < 0.549 || mods.PetAttackProc > 0.551 { + t.Errorf("BM L15 PetAttackProc = %v, want ~0.55", mods.PetAttackProc) + } + // L15 → 8 + 15/2 = 8 + 7 = 15 + if mods.PetAttackDmg != 15 { + t.Errorf("BM L15 PetAttackDmg = %d, want 15", mods.PetAttackDmg) + } + if mods.DamageReduct < 0.919 || mods.DamageReduct > 0.921 { + t.Errorf("BM L15 DamageReduct = %v, want ~0.92", mods.DamageReduct) + } +} + +// L10/L15 must not downgrade a stronger external pet floor. +func TestApplySubclassPassives_BeastMasterL15DoesntDowngradeStrongerPet(t *testing.T) { + c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 15} + mods := &CombatModifiers{ + DamageReduct: 1.0, + PetAttackProc: 0.80, + PetAttackDmg: 25, + } + applySubclassPassives(&CombatStats{}, mods, c) + if mods.PetAttackProc != 0.80 { + t.Errorf("PetAttackProc = %v, want 0.80 preserved", mods.PetAttackProc) + } + if mods.PetAttackDmg != 25 { + t.Errorf("PetAttackDmg = %d, want 25 preserved", mods.PetAttackDmg) + } +} + +// ── Phase 10 SUB3d — Gloom Stalker L10/L15 ────────────────────────────── + +func TestApplySubclassPassives_GloomStalkerL10StalkersFlurry(t *testing.T) { + c := &DnDCharacter{ + Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 10, + WIS: 14, // mod +2 + } + stats := &CombatStats{Speed: 10} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if mods.DamageBonus < 0.119 || mods.DamageBonus > 0.121 { + t.Errorf("GS L10 DamageBonus = %v, want ~0.12", mods.DamageBonus) + } + if !mods.AssassinateAdvantage { + t.Error("GS L10 should still have AssassinateAdvantage from L7") + } + if mods.DamageReduct != 1.0 { + t.Errorf("GS L10 DamageReduct = %v, want 1.0 (no Shadowy Dodge yet)", mods.DamageReduct) + } +} + +func TestApplySubclassPassives_GloomStalkerL15ShadowyDodge(t *testing.T) { + c := &DnDCharacter{ + Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 15, + WIS: 14, // mod +2 + } + stats := &CombatStats{Speed: 10} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if mods.DamageReduct < 0.899 || mods.DamageReduct > 0.901 { + t.Errorf("GS L15 DamageReduct = %v, want ~0.90", mods.DamageReduct) + } + if mods.DamageBonus < 0.119 || mods.DamageBonus > 0.121 { + t.Errorf("GS L15 DamageBonus = %v, want ~0.12 (L10 still on)", mods.DamageBonus) + } +} + // ── End-to-end: Hunter colossus-slayer damage lift ────────────────────── func TestSimulateCombat_HunterDamageBonusImprovesWinRate(t *testing.T) {