Adv 2.0 D&D Phase 10 SUB3d: Ranger L10/L15

Hunter / Beast Master / Gloom Stalker L10/L15 capstones — last subclass
batch of Phase 10 SUB3.

Hunter L10 Multiattack (Volley/Whirlwind): +15% DamageBonus, collapsing
the AoE extra-target throughput onto the single 1v1 target.
Hunter L15 Superior Hunter's Defense: ~15% incoming damage reduction
(Uncanny Dodge proxy — most generally applicable of the three picks).

Beast Master L10 Share Spells: pet floor lifts to PetAttackProc 0.45 /
PetAttackDmg 6 + lvl/2, plus +5% DamageBonus rider for the Ranger's own
buffed swings. L15 Superior Bond: pet floor 0.55 proc / 8 + lvl/2 dmg
plus 0.92 DamageReduct ("body-blocks more reliably"). Both still use
the existing max-style floors so a stronger external pet (legacy
adventure pet, etc.) is preserved.

Gloom Stalker L10 Stalker's Flurry: +12% DamageBonus representing
recovered misses across the fight (L7 already covers the first-attack
re-roll via AssassinateAdvantage). L15 Shadowy Dodge: ~10% incoming
damage reduction (reaction-imposed disadvantage proxy).

11 new tests; ranger suite + full plugin suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 11:47:28 -07:00
parent 83654eadc7
commit feccd61614
2 changed files with 178 additions and 0 deletions

View File

@@ -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
}
}
}

View File

@@ -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) {