mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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>
This commit is contained in:
301
internal/plugin/dnd_skills.go
Normal file
301
internal/plugin/dnd_skills.go
Normal file
@@ -0,0 +1,301 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user