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 [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 }