Files
gogobee/internal/plugin/dnd_sheet.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

180 lines
6.2 KiB
Go

package plugin
import (
"fmt"
"sort"
"strings"
)
func (p *AdventurePlugin) handleDnDAbilitiesCmd(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
}
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
ab := dndClassAbilities[c.Class]
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s %s — Abilities**\n\n", ri.Display, ci.Display))
b.WriteString(fmt.Sprintf("**Class passive — %s**\n %s\n\n", ab.Name, ab.Description))
b.WriteString(fmt.Sprintf("**Race trait**\n %s\n", ri.Passive))
// Active abilities (Phase 6 + Phase 10 subclass-gated)
actives := characterActiveAbilities(c)
if len(actives) > 0 {
resType, _ := classResourceMax(c.Class)
cur, max, _ := getResource(c.UserID, resType)
b.WriteString(fmt.Sprintf("\n**Active abilities** (%s %d/%d)\n", resType, cur, max))
for _, a := range actives {
b.WriteString(fmt.Sprintf(" • **%s** (1 %s) — %s\n", a.Name, a.Resource, a.Description))
}
b.WriteString("\nUse `!arm <ability>` to ready one for your next combat. Refreshes on long rest.\n")
if c.ArmedAbility != "" {
b.WriteString(fmt.Sprintf("\n_Currently armed: **%s**_\n", displayAbility(c.ArmedAbility)))
}
}
return p.SendDM(ctx.Sender, b.String())
}
// !sheet — read-only D&D character sheet renderer.
//
// Joins:
// dnd_character (D&D layer)
// adventure_characters (legacy skills, pet, housing — for at-a-glance context)
// adventure_equipment (current gear; renders with legacy fields until Phase 4)
// adventure_treasures (= attunement substrate per v1.1 §7.4)
func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your sheet: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender,
"You don't have an Adv 2.0 character yet.\n\nRun `!setup` to begin character creation. "+
"Your existing adventure progress (skills, pets, coins, arena streak) will be preserved.")
}
advChar, _ := loadAdvCharacter(ctx.Sender)
equip, _ := loadAdvEquipment(ctx.Sender)
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, equip, treasures))
}
func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus) string {
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
mods := c.Modifiers()
var b strings.Builder
name := string(c.UserID)
if adv != nil && adv.DisplayName != "" {
name = adv.DisplayName
}
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
if c.Subclass != "" {
if si, ok := subclassInfo(c.Subclass); ok {
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
}
} else if c.Level >= dndSubclassMinLevel {
b.WriteString(" _Subclass: unchosen — run `!subclass`_\n")
}
b.WriteString(strings.Repeat("─", 36) + "\n")
// Vitals
b.WriteString(fmt.Sprintf("**HP** %d/%d", c.HPCurrent, c.HPMax))
if c.TempHP > 0 {
b.WriteString(fmt.Sprintf(" (+%d temp)", c.TempHP))
}
b.WriteString(fmt.Sprintf(" **AC** %d", c.ArmorClass))
if c.Level >= dndMaxLevel {
b.WriteString(" **XP** capped (L20)\n")
} else {
b.WriteString(fmt.Sprintf(" **XP** %d / %d (next L%d)\n",
c.XP, dndXPToNextLevel(c.Level), c.Level+1))
}
// Ability scores with modifiers
b.WriteString(fmt.Sprintf("STR %2d (%+d) DEX %2d (%+d) CON %2d (%+d)\n",
c.STR, mods[0], c.DEX, mods[1], c.CON, mods[2]))
b.WriteString(fmt.Sprintf("INT %2d (%+d) WIS %2d (%+d) CHA %2d (%+d)\n",
c.INT, mods[3], c.WIS, mods[4], c.CHA, mods[5]))
b.WriteString(fmt.Sprintf("\n_%s_\n", ri.Passive))
// Equipment — D&D 10-slot view, with rarity inferred from legacy fields.
b.WriteString("\n**Equipment**\n")
dndEquip := map[DnDSlot]*AdvEquipment{}
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok || eq == nil {
continue
}
dndEquip[mapLegacySlot(slot)] = eq
}
anyEquipped := false
for _, ds := range dndSlotOrder {
eq := dndEquip[ds]
if eq == nil {
continue
}
anyEquipped = true
rarity := equipmentRarity(eq)
tag := ""
if eq.Masterwork {
tag = " ★"
}
b.WriteString(fmt.Sprintf(" %s %-9s T%d %d%%%s %s _(%s)_\n",
rarityIcon(rarity), string(ds), eq.Tier, eq.Condition, tag, eq.Name, rarity))
}
if !anyEquipped {
b.WriteString(" _(none equipped)_\n")
}
// Attunements (re-using adventure_treasures per v1.1 §7.4)
if len(treasures) > 0 {
b.WriteString("\n**Attunements** (treasures)\n")
// Group by treasure_key — one treasure can have multiple bonuses.
byKey := map[string][]AdvTreasureBonus{}
var keys []string
for _, t := range treasures {
if _, seen := byKey[t.TreasureKey]; !seen {
keys = append(keys, t.TreasureKey)
}
byKey[t.TreasureKey] = append(byKey[t.TreasureKey], t)
}
sort.Strings(keys)
for _, k := range keys {
bonuses := byKey[k]
name := bonuses[0].Name
parts := make([]string, 0, len(bonuses))
for _, bn := range bonuses {
parts = append(parts, fmt.Sprintf("%s %+.1f", bn.BonusType, bn.BonusValue))
}
b.WriteString(fmt.Sprintf(" • %s — %s\n", name, strings.Join(parts, ", ")))
}
}
// Legacy adventure context — preserved progress at a glance
if adv != nil {
b.WriteString("\n**Adventure progress** _(preserved)_\n")
b.WriteString(fmt.Sprintf(" Mining %d Foraging %d Fishing %d Combat (legacy) %d\n",
adv.MiningSkill, adv.ForagingSkill, adv.FishingSkill, adv.CombatLevel))
b.WriteString(fmt.Sprintf(" Arena %dW/%dL Streak %d (best %d)\n",
adv.ArenaWins, adv.ArenaLosses, adv.CurrentStreak, adv.BestStreak))
if adv.PetName != "" {
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel))
}
if adv.HouseTier > 0 {
b.WriteString(fmt.Sprintf(" Housing: tier %d\n", adv.HouseTier))
}
}
b.WriteString("\n_Combat, abilities, and rest mechanics arrive in upcoming phases._")
return b.String()
}