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

@@ -27,6 +27,11 @@ type DnDAbility struct {
ID string
Name string
Class DnDClass
// Subclass: if non-empty, ability is only available when the player's
// subclass matches. Phase 10 adds the first such ability (Berserker
// rage). Used by parseAbility/arm gating and by classActiveAbilities
// when listing for !arm / !abilities.
Subclass DnDSubclass
Resource string // "stamina", "spell_slot", "favor", etc.
Description string
// Effect — applied to mods at combat start when armed
@@ -83,17 +88,35 @@ func parseAbility(s string) (DnDAbility, bool) {
return DnDAbility{}, false
}
// classActiveAbilities returns the active abilities a class can know.
// classActiveAbilities returns the active abilities a class can know,
// excluding subclass-gated entries (those are listed by characterActiveAbilities).
func classActiveAbilities(class DnDClass) []DnDAbility {
var out []DnDAbility
for _, a := range dndActiveAbilities {
if a.Class == class {
if a.Class == class && a.Subclass == "" {
out = append(out, a)
}
}
return out
}
// characterActiveAbilities returns abilities available to a given character,
// including their subclass-gated abilities. Used for !arm listing so the
// Berserker sees `rage` once they've chosen the subclass.
func characterActiveAbilities(c *DnDCharacter) []DnDAbility {
var out []DnDAbility
for _, a := range dndActiveAbilities {
if a.Class != c.Class {
continue
}
if a.Subclass != "" && a.Subclass != c.Subclass {
continue
}
out = append(out, a)
}
return out
}
// ── Resource pool ────────────────────────────────────────────────────────────
// classResourceMax returns (resource_type, max_value) for a class.
@@ -201,6 +224,12 @@ func (p *AdventurePlugin) handleDnDArmCmd(ctx MessageContext, args string) error
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a %s ability — your class is %s.", ab.Name, titleClass(ab.Class), titleClass(c.Class)))
}
if ab.Subclass != "" && ab.Subclass != c.Subclass {
needed, _ := subclassInfo(ab.Subclass)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a %s subclass ability — choose that subclass via `!subclass`.",
ab.Name, needed.Display))
}
if c.ArmedAbility != "" {
return p.SendDM(ctx.Sender, fmt.Sprintf(
@@ -244,7 +273,7 @@ func (p *AdventurePlugin) handleDnDArmCmd(ctx MessageContext, args string) error
}
func renderArmList(c *DnDCharacter) string {
abilities := classActiveAbilities(c.Class)
abilities := characterActiveAbilities(c)
var b strings.Builder
b.WriteString("**Active Abilities** (pre-arm via `!arm <name>`)\n\n")