From 97c2c37396c8e5854a011542cf754b4524994511 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 11:03:07 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 10 SUB3a-i: Champion + Berserker L10/L15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Champion L10 Additional Fighting Style → +1 AC + 10% damage (Defense + Dueling collapse, since one-shot combat can't surface a style picker). Champion L15 Superior Critical → CritThreshold drops 19 → 18. Berserker L10 Intimidating Presence → 2 rounds of SporeCloud miss chance (Action-based fright proxied via the existing fog-cloud channel). Berserker L15 Retaliation → +15% damage (reaction-strike average uplift, since the engine has no reaction primitive). 6 new tests; full plugin suite green. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_subclass_combat.go | 37 ++++++++++- internal/plugin/dnd_subclass_combat_test.go | 73 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) 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) + } +}