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

@@ -182,6 +182,11 @@ type DnDCharacter struct {
// cooldown (LastSubclassRespecAt) and 500-coin cost.
Subclass DnDSubclass
LastSubclassRespecAt *time.Time
// Phase 10 SUB2a — Berserker Frenzy increments after each rage'd
// combat; future class abilities will also tick this. Cleared on
// long rest. 5e caps at 6 (death); we let it grow and let consumers
// clamp where needed.
Exhaustion int
LastRespecAt *time.Time
LastShortRestAt *time.Time
LastLongRestAt *time.Time
@@ -210,7 +215,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
hp_current, hp_max, temp_hp, armor_class,
pending_setup, auto_migrated, onboarding_sent, armed_ability,
pending_cast, concentration_spell, concentration_expires_at,
subclass, last_subclass_respec_at,
subclass, last_subclass_respec_at, exhaustion,
last_respec_at, last_short_rest_at, last_long_rest_at,
created_at, updated_at
FROM dnd_character WHERE user_id = ?`, string(userID))
@@ -223,7 +228,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
&c.HPCurrent, &c.HPMax, &c.TempHP, &c.ArmorClass,
&pending, &autoMig, &onboard, &c.ArmedAbility,
&c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt,
&subclassStr, &c.LastSubclassRespecAt,
&subclassStr, &c.LastSubclassRespecAt, &c.Exhaustion,
&c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt,
&c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
@@ -262,9 +267,9 @@ func SaveDnDCharacter(c *DnDCharacter) error {
hp_current, hp_max, temp_hp, armor_class,
pending_setup, auto_migrated, onboarding_sent, armed_ability,
pending_cast, concentration_spell, concentration_expires_at,
subclass, last_subclass_respec_at,
subclass, last_subclass_respec_at, exhaustion,
last_respec_at, last_short_rest_at, last_long_rest_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
race=excluded.race, class=excluded.class,
dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp,
@@ -282,6 +287,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
concentration_expires_at=excluded.concentration_expires_at,
subclass=excluded.subclass,
last_subclass_respec_at=excluded.last_subclass_respec_at,
exhaustion=excluded.exhaustion,
last_respec_at=excluded.last_respec_at,
last_short_rest_at=excluded.last_short_rest_at,
last_long_rest_at=excluded.last_long_rest_at,
@@ -291,7 +297,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
c.HPCurrent, c.HPMax, c.TempHP, c.ArmorClass,
pending, autoMig, onboard, c.ArmedAbility,
c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt,
string(c.Subclass), c.LastSubclassRespecAt,
string(c.Subclass), c.LastSubclassRespecAt, c.Exhaustion,
c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt)
if err != nil {
return fmt.Errorf("save dnd_character: %w", err)