Files
gogobee/internal/plugin/dnd_subclass_combat.go
prosolis 437460c9b2 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.
2026-05-09 14:25:21 -07:00

74 lines
2.6 KiB
Go

package plugin
// Phase 10 SUB2a — subclass combat hooks.
//
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
// the same way applyClassPassives does. Called from combat_bridge after
// applyClassPassives so subclass effects can stack on top of (or override)
// class baseline.
//
// Subclass active abilities (currently just Berserker's rage) are
// registered via init() into dndActiveAbilities below, gated by
// DnDAbility.Subclass.
func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) {
if c == nil || c.Subclass == "" {
return
}
switch c.Subclass {
case SubclassChampion:
// L5 Improved Critical: crit on nat 19+. SUB3 Superior Critical
// (L15) will lower this to 18.
if c.Level >= 5 {
mods.CritThreshold = 19
}
}
}
func init() {
// Berserker rage — active ability that piggybacks on the Fighter
// stamina pool. 5e specs 3 uses/long-rest as a separate pool, but
// stamina is already sized at 3 for Fighter and the design's
// "approachability" constraint preferred reusing the existing pool
// over introducing per-subclass resource types in SUB2a.
//
// While armed and fired:
// - +2 flat damage per hit (RageMeleeDmg)
// - incoming weapon damage halved (PhysicalResistRage)
// - +50% damage multiplier for Frenzy approximation
// (one-shot combat can't model "1 bonus attack per turn" literally)
//
// Post-combat exhaustion increment lives in
// persistDnDPostCombatSubclass — Frenzy specs +1 exhaustion after rage
// ends, and in our model rage spans the whole one-shot combat so we
// always tick exhaustion when rage fired.
dndActiveAbilities["rage"] = DnDAbility{
ID: "rage",
Name: "Rage",
Class: ClassFighter,
Subclass: SubclassBerserker,
Resource: "stamina",
Description: "Enter rage for the next combat: +2 damage per hit, halve incoming weapon damage, Frenzy bonus attack approximation. +1 exhaustion after.",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.BerserkerRage = true
mods.RageMeleeDmg = 2
mods.PhysicalResistRage = true
mods.FrenzyDmgBonus = 0.5
},
}
}
// persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping.
// Currently: increment Exhaustion if Berserker rage fired. Called from
// combat_bridge after the combat result is computed.
//
// raged is computed as (mods.BerserkerRage at combat-start time) — pass it
// through from the caller where the mods value is still in scope.
func persistDnDPostCombatSubclass(c *DnDCharacter, raged bool) error {
if c == nil || !raged {
return nil
}
c.Exhaustion++
return SaveDnDCharacter(c)
}