diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index d8e2271..c0c6182 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -17,11 +17,44 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar } switch c.Subclass { case SubclassChampion: - // L5 Improved Critical: crit on nat 19+. SUB3 Superior Critical - // (L15) will lower this to 18. + // L5 Improved Critical: crit on nat 19+. L15 Superior Critical + // lowers this further to 18. if c.Level >= 5 { mods.CritThreshold = 19 } + // Phase 10 SUB3a-i — L10 Additional Fighting Style. 5e lets the + // player pick a second style; in 1v1 we can't surface a UI for + // that, so we collapse to the two most generally useful + // approximations stacked: Defense (+1 AC) and Dueling (+10% + // damage when one-handed — we don't track grip state, so this is + // a flat damage bump regardless). + if c.Level >= 10 { + stats.AC++ + mods.DamageBonus += 0.10 + } + // Phase 10 SUB3a-i — L15 Superior Critical: crit floor drops to 18. + if c.Level >= 15 { + mods.CritThreshold = 18 + } + case SubclassBerserker: + // L5/L7 Berserker is rage-driven (active ability — see init() below) + // plus L7 Mindless Rage which is condition immunity, irrelevant in + // our 1v1 model. Phase 10 SUB3a-i adds the always-on L10/L15 layer. + // + // L10 Intimidating Presence: 5e is an Action that frightens one + // target on a failed WIS save. One-shot combat has no action + // economy to spend on a non-damage Action, so we proxy as 2 rounds + // of 15% enemy miss chance via SporeCloud (the same channel + // fog-cloud / Cloak of Shadows uses). + // L15 Retaliation: 5e Reaction — when struck in melee, one melee + // attack back. We don't model reactions, so the average per-fight + // damage uplift is approximated as a +15% damage multiplier. + if c.Level >= 10 { + mods.SporeCloud += 2 + } + if c.Level >= 15 { + mods.DamageBonus += 0.15 + } case SubclassBattleMaster: // L7 Know Your Enemy: 5e gives factual info about the target after // 1 min observation. We don't model "observation time" — proxy the diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index 4e6d144..4554c65 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -868,3 +868,76 @@ func TestCharacterActiveAbilities_RageVisibility(t *testing.T) { } } +// ── SUB3a-i — Champion + Berserker L10/L15 ────────────────────────────── + +func TestApplySubclassPassives_ChampionL10AdditionalFightingStyle(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 10} + stats := &CombatStats{AC: 16} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if stats.AC != 17 { + t.Errorf("L10 Champion AC = %d, want 17 (+1 Defense style)", stats.AC) + } + if mods.DamageBonus < 0.099 || mods.DamageBonus > 0.101 { + t.Errorf("L10 Champion DamageBonus = %v, want ~0.10 (Dueling style)", mods.DamageBonus) + } + // L5 Improved Critical still in effect; L15 hasn't yet kicked in. + if mods.CritThreshold != 19 { + t.Errorf("L10 Champion CritThreshold = %d, want 19 (L15 not yet)", mods.CritThreshold) + } +} + +func TestApplySubclassPassives_ChampionL9NoFightingStyle(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 9} + stats := &CombatStats{AC: 16} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if stats.AC != 16 { + t.Errorf("L9 Champion AC = %d, want 16 (Fighting Style gates at L10)", stats.AC) + } + if mods.DamageBonus != 0 { + t.Errorf("L9 Champion DamageBonus = %v, want 0", mods.DamageBonus) + } +} + +func TestApplySubclassPassives_ChampionL15SuperiorCritical(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 15} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{AC: 16}, mods, c) + if mods.CritThreshold != 18 { + t.Errorf("L15 Champion CritThreshold = %d, want 18 (Superior Critical)", mods.CritThreshold) + } +} + +func TestApplySubclassPassives_BerserkerL10IntimidatingPresence(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 10} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.SporeCloud != 2 { + t.Errorf("L10 Berserker SporeCloud = %d, want 2 (Intimidating Presence)", mods.SporeCloud) + } + if mods.DamageBonus != 0 { + t.Errorf("L10 Berserker DamageBonus = %v, want 0 (Retaliation gates at L15)", mods.DamageBonus) + } +} + +func TestApplySubclassPassives_BerserkerL9NoIntimidatingPresence(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 9} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.SporeCloud != 0 { + t.Errorf("L9 Berserker SporeCloud = %d, want 0", mods.SporeCloud) + } +} + +func TestApplySubclassPassives_BerserkerL15Retaliation(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 15} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.DamageBonus < 0.149 || mods.DamageBonus > 0.151 { + t.Errorf("L15 Berserker DamageBonus = %v, want ~0.15 (Retaliation)", mods.DamageBonus) + } + if mods.SporeCloud != 2 { + t.Errorf("L15 Berserker SporeCloud = %d, want 2 (L10 still active)", mods.SporeCloud) + } +}