Adv 2.0 D&D Phase 10 SUB2a-i: Champion + Berserker + Thief L5/L7

First mechanical slice of the subclass system. Each subclass gets its L5
and L7 abilities wired through the existing combat engine + skill check
substrate; no new resource pools introduced.

Champion (L5/L7):
- Improved Critical: new Mods.CritThreshold lowers crit floor to nat 19+
  in resolvePlayerAttack. Default 20 preserved for non-Champions.
- Remarkable Athlete: +½ proficiency bonus to STR/DEX/CON skill checks
  via subclassSkillBonus, layered on raceSkillBonus.

Berserker (L5/L7):
- Rage: new !arm-able active ability gated to Berserker subclass via
  DnDAbility.Subclass field. Reuses the Fighter stamina pool (3/long-rest
  matches 5e's rage uses). On fire: +2 flat damage per hit, halve incoming
  weapon damage, +50% damage approximation for Frenzy's bonus-attack-per-
  turn (one-shot combat can't model that literally).
- Frenzy exhaustion: new exhaustion column on dnd_character; incremented
  by persistDnDPostCombatSubclass after any rage'd combat. Decremented by
  one per long rest.
- Mindless Rage: documented; charm/frighten conditions don't exist in the
  combat engine yet, so no functional effect until P11 zone bosses land.

Thief (L5/L7):
- Sleight of Hand added to dndSkillTable.
- Fast Hands: +5 to Sleight of Hand checks via subclassSkillBonus.
- Supreme Sneak: advantage on Stealth (best of two d20s) via new
  subclassSkillAdvantage hook in performSkillCheck. 5e's half-speed gate
  dropped — we don't track movement speed.

Plumbing:
- applySubclassPassives layered after applyClassPassives in both arena
  and dungeon combat bridges.
- DnDAbility.Subclass enables per-subclass gating; characterActiveAbilities
  filters !arm and !abilities lists by both class and subclass.
- Existing !respec full-wipe also clears Exhaustion.

Tests: Champion CritThreshold gating across L4/L5/no-subclass; rage Apply
flag-set; rage probabilistic win-rate lift vs tougher enemy; exhaustion
increment only on raged combats; long-rest decrement; Champion bonus on
STR/DEX/CON only and only at L7+; Thief Fast Hands SoH-only; Thief
Supreme Sneak L7+ Stealth-only; statistical advantage roll lifts Thief
stealth average by ≥2 vs plain Rogue; !arm rage subclass gating; schema
roundtrip; characterActiveAbilities visibility.
This commit is contained in:
prosolis
2026-05-08 10:02:40 -07:00
parent 1ee4276bcd
commit 437460c9b2
12 changed files with 653 additions and 26 deletions

View File

@@ -53,6 +53,21 @@ type CombatModifiers struct {
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
PoisonResist bool // Dwarf: poison tick damage halved
// Phase 10 SUB2a — subclass combat hooks.
// CritThreshold: lowest d20 roll that crits. 0 = use default (20).
// Champion L5 Improved Critical sets this to 19; Champion L15
// Superior Critical (SUB3) will set 18.
CritThreshold int
// BerserkerRage: while true, +RageMeleeDmg flat damage per hit and
// incoming weapon damage halved (PhysicalResistRage). Frenzy also
// adds FrenzyDmgBonus on top to model the bonus-attack-per-turn that
// one-shot combat can't represent literally. Set by armed `rage`
// ability for Berserker subclass.
BerserkerRage bool
RageMeleeDmg int // flat damage per hit while raging (5: +2)
PhysicalResistRage bool // halve incoming physical damage while raging
FrenzyDmgBonus float64 // multiplicative dmg bump while raging (Frenzy approximation)
// Phase 9 — pending spell resolution. Set by applyPendingCast in
// dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is
// dealt as a pre-combat event with SpellPreDamageDesc as the narrative
@@ -469,7 +484,13 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
roll = newRoll
}
isFumble := roll == 1
isNat20 := roll == 20
// Phase 10 SUB2a — Champion Improved Critical lowers the crit floor.
// CritThreshold==0 means "use default" (nat 20). Champion sets 19.
critFloor := 20
if player.Mods.CritThreshold > 0 && player.Mods.CritThreshold < 20 {
critFloor = player.Mods.CritThreshold
}
isCritRoll := roll >= critFloor
// Class proficiency penalty (appendix §8): -4 attack with a non-proficient weapon.
attackBonus := player.Stats.AttackBonus
if player.Stats.Weapon != nil && !player.Stats.WeaponProficient {
@@ -477,7 +498,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
}
total := roll + attackBonus
if isFumble || (!isNat20 && total < enemy.Stats.AC) {
if isFumble || (!isCritRoll && total < enemy.Stats.AC) {
desc := ""
if isFumble {
desc = "fumble"
@@ -518,7 +539,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
dmg = max(1, dmg/2)
}
isCrit := isNat20
isCrit := isCritRoll
if st.autoCrit {
isCrit = true
st.autoCrit = false
@@ -533,6 +554,17 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
dmg = int(float64(dmg) * 1.5)
st.pendingRageAttack = false
}
// Phase 10 SUB2a — Berserker rage: +flat per hit, plus Frenzy
// multiplicative bump that approximates the bonus-attack-per-turn we
// can't model in one-shot combat.
if player.Mods.BerserkerRage {
if player.Mods.RageMeleeDmg > 0 {
dmg += player.Mods.RageMeleeDmg
}
if player.Mods.FrenzyDmgBonus > 0 {
dmg = int(float64(dmg) * (1 + player.Mods.FrenzyDmgBonus))
}
}
dmg = max(1, dmg)
action := "hit"
@@ -620,6 +652,11 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
if isCrit {
dmg *= 2
}
// Phase 10 SUB2a — Berserker rage halves incoming weapon damage.
// Applied AFTER crit-doubling so the resistance survives crits.
if player.Mods.BerserkerRage && player.Mods.PhysicalResistRage {
dmg = max(1, dmg/2)
}
dmg = max(1, dmg)
if petDeflect {