Files
gogobee/internal/plugin/dnd_skills.go
prosolis 9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

302 lines
9.0 KiB
Go

package plugin
import (
"fmt"
"math/rand/v2"
"strconv"
"strings"
"maunium.net/go/mautrix/id"
)
// Phase 5 — D&D skill check resolver and !check command.
//
// Skill checks are d20 + ability modifier + race bonus vs. a target DC.
// Nat 20 auto-succeeds, nat 1 auto-fails (matches D&D 5e). Used by !check
// for ad-hoc rolls and by NPC handlers (Thom Krooke / Misty / Arina) for
// gated bonuses.
// ── Skill table ──────────────────────────────────────────────────────────────
type DnDSkill string
const (
SkillAthletics DnDSkill = "athletics"
SkillAcrobatics DnDSkill = "acrobatics"
SkillStealth DnDSkill = "stealth"
SkillArcana DnDSkill = "arcana"
SkillInvestigation DnDSkill = "investigation"
SkillPerception DnDSkill = "perception"
SkillInsight DnDSkill = "insight"
SkillPersuasion DnDSkill = "persuasion"
SkillIntimidation DnDSkill = "intimidation"
SkillDeception DnDSkill = "deception"
)
type dndSkillInfo struct {
Key DnDSkill
Display string
Stat string // "str", "dex", "con", "int", "wis", "cha"
}
var dndSkillTable = []dndSkillInfo{
{SkillAthletics, "Athletics", "str"},
{SkillAcrobatics, "Acrobatics", "dex"},
{SkillStealth, "Stealth", "dex"},
{SkillArcana, "Arcana", "int"},
{SkillInvestigation, "Investigation", "int"},
{SkillPerception, "Perception", "wis"},
{SkillInsight, "Insight", "wis"},
{SkillPersuasion, "Persuasion", "cha"},
{SkillIntimidation, "Intimidation", "cha"},
{SkillDeception, "Deception", "cha"},
}
func skillInfo(s DnDSkill) (dndSkillInfo, bool) {
for _, si := range dndSkillTable {
if si.Key == s {
return si, true
}
}
return dndSkillInfo{}, false
}
func parseSkill(s string) (DnDSkill, bool) {
s = strings.ToLower(strings.TrimSpace(s))
for _, si := range dndSkillTable {
if string(si.Key) == s || strings.EqualFold(si.Display, s) {
return si.Key, true
}
}
return "", false
}
// ── DC table ─────────────────────────────────────────────────────────────────
const (
DCTrivial = 5
DCEasy = 10
DCMedium = 15
DCHard = 20
DCVeryHard = 25
DCImpossible = 30
)
func parseDC(s string) (int, bool) {
s = strings.ToLower(strings.TrimSpace(s))
switch s {
case "trivial":
return DCTrivial, true
case "easy":
return DCEasy, true
case "medium", "med":
return DCMedium, true
case "hard":
return DCHard, true
case "veryhard", "very_hard", "very-hard":
return DCVeryHard, true
case "impossible":
return DCImpossible, true
}
if n, err := strconv.Atoi(s); err == nil && n > 0 {
return n, true
}
return 0, false
}
// ── Resolver ─────────────────────────────────────────────────────────────────
// SkillCheckResult is the outcome of a single d20 check.
type SkillCheckResult struct {
Skill DnDSkill
DC int
Roll int // raw d20 [1, 20]
Mod int // stat mod + race bonus
Total int // roll + mod (or auto-{success,fail} flag)
Success bool
Auto bool // true if nat 20 / nat 1 short-circuited
}
// statValue extracts the named ability score from a DnDCharacter.
func statValue(c *DnDCharacter, stat string) int {
switch stat {
case "str":
return c.STR
case "dex":
return c.DEX
case "con":
return c.CON
case "int":
return c.INT
case "wis":
return c.WIS
case "cha":
return c.CHA
}
return 10
}
// raceSkillBonus returns the per-skill bonus from a player's race.
// Half-Elf gets +1 to every skill (rough mapping of "two bonus skill profs").
// Tiefling gets +2 to CHA-based skills (matches the doc's "+bonus on CHA checks").
func raceSkillBonus(c *DnDCharacter, info dndSkillInfo) int {
switch c.Race {
case RaceHalfElf:
return 1
case RaceTiefling:
if info.Stat == "cha" {
return 2
}
}
return 0
}
// performSkillCheck rolls the d20 and computes the result.
func performSkillCheck(c *DnDCharacter, skill DnDSkill, dc int) SkillCheckResult {
info, _ := skillInfo(skill)
mod := abilityModifier(statValue(c, info.Stat)) + raceSkillBonus(c, info)
roll := 1 + rand.IntN(20)
res := SkillCheckResult{Skill: skill, DC: dc, Roll: roll, Mod: mod}
switch roll {
case 20:
res.Success = true
res.Auto = true
res.Total = roll + mod
case 1:
res.Success = false
res.Auto = true
res.Total = roll + mod
default:
res.Total = roll + mod
res.Success = res.Total >= dc
}
return res
}
// ── !check command ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDnDCheckCmd(ctx MessageContext, args string) error {
// !check can trigger auto-migration which writes to dnd_character.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
args = strings.TrimSpace(args)
if args == "" {
return p.SendDM(ctx.Sender, dndCheckHelpText())
}
fields := strings.Fields(args)
skill, ok := parseSkill(fields[0])
if !ok {
return p.SendDM(ctx.Sender,
"Unknown skill. Try: "+strings.Join(dndSkillNames(), ", "))
}
dc := DCMedium
if len(fields) >= 2 {
if parsed, ok := parseDC(fields[1]); ok {
dc = parsed
} else {
return p.SendDM(ctx.Sender,
"DC must be a number or one of: trivial, easy, medium, hard, veryhard, impossible")
}
}
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your character.")
}
res := performSkillCheck(c, skill, dc)
return p.SendDM(ctx.Sender, renderSkillCheck(res))
}
func renderSkillCheck(res SkillCheckResult) string {
info, _ := skillInfo(res.Skill)
var b strings.Builder
b.WriteString(fmt.Sprintf("🎲 **%s** check (DC %d)\n", info.Display, res.DC))
b.WriteString(fmt.Sprintf(" d20 = %d + %s mod %+d = **%d**\n",
res.Roll, strings.ToUpper(info.Stat), res.Mod, res.Total))
if res.Auto && res.Roll == 20 {
b.WriteString(" ✅ **Critical success** (nat 20)\n")
} else if res.Auto && res.Roll == 1 {
b.WriteString(" ❌ **Critical failure** (nat 1)\n")
} else if res.Success {
b.WriteString(" ✅ Success\n")
} else {
b.WriteString(fmt.Sprintf(" ❌ Failed (needed %d)\n", res.DC))
}
return b.String()
}
func dndCheckHelpText() string {
var b strings.Builder
b.WriteString("**Skill Check** — `!check <skill> [dc]`\n\n")
b.WriteString("Skills: " + strings.Join(dndSkillNames(), ", ") + "\n\n")
b.WriteString("DC: a number, or `trivial` (5), `easy` (10), `medium` (15, default), ")
b.WriteString("`hard` (20), `veryhard` (25), `impossible` (30)\n\n")
b.WriteString("Example: `!check athletics hard`")
return b.String()
}
// ── NPC skill-check hooks ────────────────────────────────────────────────────
//
// These are silent upside helpers: if the player has a confirmed D&D
// character and rolls well on the relevant skill, the NPC interaction's
// cost is refunded. Legacy (no D&D character) players see no change.
//
// The result type distinguishes "no check attempted" (no D&D char) from
// "checked, failed" — so callers can surface different flavor for each.
// NPCSkillCheckResult tells the caller whether a D&D skill check was
// even applicable (Attempted=false → no D&D char) and, if so, whether it
// succeeded.
type NPCSkillCheckResult struct {
Attempted bool
Succeeded bool
}
func dndNPCInsightRefund(userID id.UserID, euro *EuroPlugin, cost int) NPCSkillCheckResult {
return dndNPCRefundCheck(userID, euro, SkillInsight, 12, cost, "misty_insight_refund")
}
func dndNPCArcanaRefund(userID id.UserID, euro *EuroPlugin, cost int) NPCSkillCheckResult {
return dndNPCRefundCheck(userID, euro, SkillArcana, 14, cost, "arina_arcana_refund")
}
// dndNPCPersuasionDiscount: returns true if the player passed a Persuasion
// check (DC 15). Caller applies the 10% discount. No euro side effect here.
func dndNPCPersuasionDiscount(userID id.UserID) bool {
c, err := LoadDnDCharacter(userID)
if err != nil || c == nil || c.PendingSetup {
return false
}
return performSkillCheck(c, SkillPersuasion, 15).Success
}
func dndNPCRefundCheck(userID id.UserID, euro *EuroPlugin, skill DnDSkill, dc int, cost int, reason string) NPCSkillCheckResult {
c, err := LoadDnDCharacter(userID)
if err != nil || c == nil || c.PendingSetup {
return NPCSkillCheckResult{}
}
res := performSkillCheck(c, skill, dc)
if !res.Success {
return NPCSkillCheckResult{Attempted: true, Succeeded: false}
}
if euro != nil {
euro.Credit(userID, float64(cost), reason)
}
return NPCSkillCheckResult{Attempted: true, Succeeded: true}
}
func dndSkillNames() []string {
out := make([]string, 0, len(dndSkillTable))
for _, si := range dndSkillTable {
out = append(out, string(si.Key))
}
return out
}