mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +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:
619
internal/plugin/dnd_setup.go
Normal file
619
internal/plugin/dnd_setup.go
Normal file
@@ -0,0 +1,619 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// !setup flow. Player runs subcommands; draft persists in dnd_character with
|
||||
// pending_setup=1 until !setup confirm finalizes.
|
||||
//
|
||||
// !setup — show current draft + next step
|
||||
// !setup race <name> — pick race
|
||||
// !setup class <name> — pick class
|
||||
// !setup stats <s> <d> <c> <i> <w> <h> — assign standard array
|
||||
// !setup confirm — finalize, compute HP/AC, clear pending_setup
|
||||
// !setup cancel — wipe the draft
|
||||
|
||||
// ── Archetype → Race/Class inference ─────────────────────────────────────────
|
||||
|
||||
type dndSuggestion struct {
|
||||
Race DnDRace
|
||||
Class DnDClass
|
||||
Why string // archetype name that drove the suggestion
|
||||
}
|
||||
|
||||
// inferDnDFromArchetypes returns a (race, class) suggestion based on the
|
||||
// player's highest-signal archetype, or the Human Fighter fallback if no
|
||||
// archetype gives a clear signal.
|
||||
//
|
||||
// Map keyed to the *real* archetype names in archetype.go (v1.1 §3.2).
|
||||
func inferDnDFromArchetypes(userID id.UserID) dndSuggestion {
|
||||
results := GetUserArchetypes(string(userID))
|
||||
|
||||
// archetype name → (class, race). First match wins; results are already
|
||||
// sorted by signal_score desc.
|
||||
mapping := map[string]struct {
|
||||
Class DnDClass
|
||||
Race DnDRace
|
||||
}{
|
||||
"Arena Champion": {ClassFighter, RaceOrc},
|
||||
"Dungeon Crawler": {ClassFighter, RaceHuman},
|
||||
"The Adventurer": {ClassRanger, RaceHuman},
|
||||
"The Angler": {ClassRanger, RaceElf},
|
||||
"The Forager": {ClassRanger, RaceHalfling},
|
||||
"The Miner": {ClassFighter, RaceDwarf},
|
||||
"The Merchant": {ClassRogue, RaceHalfling},
|
||||
"Whale": {ClassRogue, RaceHuman},
|
||||
"Degenerate": {ClassRogue, RaceTiefling},
|
||||
"Novelist": {ClassMage, RaceElf},
|
||||
"Philosopher": {ClassMage, RaceTiefling},
|
||||
"Wordsmith": {ClassMage, RaceElf},
|
||||
"Linkmaster": {ClassMage, RaceHuman},
|
||||
"Inquisitor": {ClassMage, RaceTiefling},
|
||||
"Cheerleader": {ClassCleric, RaceHalfElf},
|
||||
"Hype Machine": {ClassCleric, RaceHalfElf},
|
||||
"Patron": {ClassCleric, RaceDwarf},
|
||||
"Reactor": {ClassCleric, RaceHalfling},
|
||||
"Gearhead": {ClassFighter, RaceDwarf},
|
||||
"Shark": {ClassRogue, RaceHalfElf},
|
||||
"Trivia Nerd": {ClassMage, RaceHalfElf},
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if m, ok := mapping[r.Name]; ok {
|
||||
return dndSuggestion{Race: m.Race, Class: m.Class, Why: r.Name}
|
||||
}
|
||||
}
|
||||
return dndSuggestion{Race: RaceHuman, Class: ClassFighter, Why: "default"}
|
||||
}
|
||||
|
||||
// ── Command handler ──────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleDnDSetupCmd(ctx MessageContext, args string) error {
|
||||
// Audit fix A: serialize per-user state mutations. Reuses the existing
|
||||
// adventure lock so !setup also serializes against !arena, !adventure
|
||||
// dungeon, etc. — all of which can write to dnd_character via auto-migration.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
args = strings.TrimSpace(args)
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
// Bare !setup → show status / instructions.
|
||||
if args == "" {
|
||||
return p.dndSetupStatus(ctx)
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "race "):
|
||||
return p.dndSetupRace(ctx, strings.TrimSpace(args[5:]))
|
||||
case strings.HasPrefix(lower, "class "):
|
||||
return p.dndSetupClass(ctx, strings.TrimSpace(args[6:]))
|
||||
case strings.HasPrefix(lower, "stats "):
|
||||
return p.dndSetupStats(ctx, strings.TrimSpace(args[6:]))
|
||||
case lower == "confirm":
|
||||
return p.dndSetupConfirm(ctx)
|
||||
case lower == "cancel":
|
||||
return p.dndSetupCancel(ctx)
|
||||
}
|
||||
return p.SendDM(ctx.Sender,"Unknown !setup subcommand. Try !setup with no args for instructions.")
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dndSetupStatus(ctx MessageContext) error {
|
||||
c, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't load your Adv 2.0 draft: "+err.Error())
|
||||
}
|
||||
|
||||
// No row yet → fresh setup. Show suggestion + race menu.
|
||||
if c == nil {
|
||||
// Legacy-player welcome: if they're meeting D&D for the first time
|
||||
// via `!setup` (rather than via combat auto-migration), they still
|
||||
// deserve the onboarding DM. We persist a stub draft row so the
|
||||
// OnboardingSent flag carries forward — neither this nor any later
|
||||
// auto-migration will re-send the welcome.
|
||||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||||
if advChar != nil && advChar.CombatLevel >= dndLegacyMinLevel {
|
||||
now := time.Now().UTC()
|
||||
// Seed the stub's Level with the computed mapping so the welcome DM
|
||||
// reports the right number ("your level X is now Adv 2.0 level Y").
|
||||
// On !setup confirm the level is recomputed from the same formula,
|
||||
// so the value here matches what the player will actually end up with.
|
||||
stub := &DnDCharacter{
|
||||
UserID: ctx.Sender,
|
||||
Level: dndLevelFromCombatLevel(advChar.CombatLevel),
|
||||
ArmorClass: 10,
|
||||
PendingSetup: true,
|
||||
OnboardingSent: false, // maybeSendDnDOnboarding will flip this on success
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := SaveDnDCharacter(stub); err != nil {
|
||||
slog.Error("dnd: setup stub save failed", "user", ctx.Sender, "err", err)
|
||||
} else {
|
||||
p.maybeSendDnDOnboarding(ctx.Sender, advChar, stub)
|
||||
}
|
||||
}
|
||||
|
||||
sug := inferDnDFromArchetypes(ctx.Sender)
|
||||
var b strings.Builder
|
||||
b.WriteString("⚔️ **Adv 2.0 Setup** — let's build your character.\n\n")
|
||||
b.WriteString(fmt.Sprintf("Based on your play style we'd suggest a **%s %s** "+
|
||||
"_(driven by your %s archetype)_.\n\n",
|
||||
titleRace(sug.Race), titleClass(sug.Class), sug.Why))
|
||||
b.WriteString("**Step 1 — Race.** Pick one of:\n")
|
||||
b.WriteString(renderRaceMenu())
|
||||
b.WriteString("\nReply: `!setup race <name>` (e.g. `!setup race elf`)\n")
|
||||
return p.SendDM(ctx.Sender,b.String())
|
||||
}
|
||||
|
||||
// Confirmed already → reroute to !sheet, unless this is an auto-migrated
|
||||
// character, in which case we let the player rebuild freely.
|
||||
if !c.PendingSetup {
|
||||
if c.AutoMigrated {
|
||||
ri, _ := raceInfo(c.Race)
|
||||
ci, _ := classInfo(c.Class)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Adv 2.0 Setup**\n\nWe auto-built you a **%s %s** based on your play style when you first fought, "+
|
||||
"so you'd never miss a battle. You can rebuild freely — no cooldown.\n\n", ri.Display, ci.Display))
|
||||
b.WriteString("**Step 1 — Race.** Pick one of:\n")
|
||||
b.WriteString(renderRaceMenu())
|
||||
b.WriteString("\nReply: `!setup race <name>` (or `!sheet` to keep what you have).\n")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "You've already set up your character. Use `!sheet` to view it, or `!respec` to start over (cooldown applies).")
|
||||
}
|
||||
|
||||
// Draft in progress — show what's set and what's next.
|
||||
var b strings.Builder
|
||||
b.WriteString("⚔️ **Adv 2.0 Setup** — draft in progress.\n\n")
|
||||
if c.Race == "" {
|
||||
b.WriteString("**Next: Step 1 — Race.**\n")
|
||||
b.WriteString(renderRaceMenu())
|
||||
b.WriteString("\nReply: `!setup race <name>`\n")
|
||||
return p.SendDM(ctx.Sender,b.String())
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Race: **%s** ✓\n", titleRace(c.Race)))
|
||||
if c.Class == "" {
|
||||
b.WriteString("\n**Next: Step 2 — Class.**\n")
|
||||
b.WriteString(renderClassMenu())
|
||||
b.WriteString("\nReply: `!setup class <name>`\n")
|
||||
return p.SendDM(ctx.Sender,b.String())
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Class: **%s** ✓\n", titleClass(c.Class)))
|
||||
if !statsAssigned(c) {
|
||||
b.WriteString("\n**Next: Step 3 — Ability Scores.**\n")
|
||||
b.WriteString("Assign these six values — **15 14 13 12 10 8** — to STR, DEX, CON, INT, WIS, CHA. Each value used exactly once.\n\n")
|
||||
b.WriteString("Reply: `!setup stats 15 14 13 12 10 8` (rearrange to taste).\n")
|
||||
return p.SendDM(ctx.Sender,b.String())
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Stats (pre-racial): STR %d DEX %d CON %d INT %d WIS %d CHA %d ✓\n",
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA))
|
||||
b.WriteString("\n**Step 4 — Confirm.** Reply `!setup confirm` to finalize, or `!setup cancel` to scrap and start over.\n")
|
||||
b.WriteString(renderConfirmPreview(c))
|
||||
return p.SendDM(ctx.Sender,b.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dndSetupRace(ctx MessageContext, raceArg string) error {
|
||||
r, ok := parseRace(raceArg)
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender,"Unknown race. Options: human, elf, dwarf, halfling, orc, tiefling, half-elf")
|
||||
}
|
||||
c, err := loadOrInitDraft(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't open your draft: "+err.Error())
|
||||
}
|
||||
c.Race = r
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error())
|
||||
}
|
||||
ri, _ := raceInfo(r)
|
||||
return p.SendDM(ctx.Sender,fmt.Sprintf("Race set: **%s**. _%s_\n\nNext: `!setup class <name>` — see options with `!setup`.",
|
||||
ri.Display, ri.Passive))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dndSetupClass(ctx MessageContext, classArg string) error {
|
||||
cl, ok := parseClass(classArg)
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender,"Unknown class. Options: fighter, rogue, mage, cleric, ranger")
|
||||
}
|
||||
c, err := loadOrInitDraft(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't open your draft: "+err.Error())
|
||||
}
|
||||
c.Class = cl
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error())
|
||||
}
|
||||
ci, _ := classInfo(cl)
|
||||
return p.SendDM(ctx.Sender,fmt.Sprintf("Class set: **%s** (HP die d%d, primary %s/%s).\n\n"+
|
||||
"Next: assign your stats. The standard array is **15 14 13 12 10 8** — six numbers, each used once.\n"+
|
||||
"Order: STR DEX CON INT WIS CHA.\n\n"+
|
||||
"Example: `!setup stats 15 14 13 12 10 8` (all rolled into STR-first; rearrange to taste).",
|
||||
ci.Display, ci.HPDie, ci.PrimaryA, ci.PrimaryB))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dndSetupStats(ctx MessageContext, statsArg string) error {
|
||||
scores, err := parseStatsArg(statsArg)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, err.Error()+
|
||||
"\n\nFormat: `!setup stats 15 14 13 12 10 8` (in STR DEX CON INT WIS CHA order)."+
|
||||
"\nCommas and parentheses are fine too — `!setup stats 15, 14, 13, 12, 10, 8` works.")
|
||||
}
|
||||
if !isStandardArray(scores) {
|
||||
return p.SendDM(ctx.Sender, "Stats must be a permutation of the standard array {15, 14, 13, 12, 10, 8} — each value used exactly once.\n\n"+
|
||||
"Try: `!setup stats 15 14 13 12 10 8` and rearrange to taste.")
|
||||
}
|
||||
c, err := loadOrInitDraft(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't open your draft: "+err.Error())
|
||||
}
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = scores[0], scores[1], scores[2], scores[3], scores[4], scores[5]
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error())
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Stats saved (pre-racial bonuses).\n")
|
||||
b.WriteString(renderConfirmPreview(c))
|
||||
b.WriteString("\nReply `!setup confirm` to finalize.")
|
||||
return p.SendDM(ctx.Sender,b.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
|
||||
c, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil || c == nil {
|
||||
return p.SendDM(ctx.Sender,"No setup draft found. Run `!setup` to start.")
|
||||
}
|
||||
if !c.PendingSetup {
|
||||
return p.SendDM(ctx.Sender,"Already confirmed. Use `!sheet` to view your character.")
|
||||
}
|
||||
if c.Race == "" || c.Class == "" || !statsAssigned(c) {
|
||||
return p.SendDM(ctx.Sender,"Draft incomplete. Run `!setup` to see what's missing.")
|
||||
}
|
||||
|
||||
// Apply racial modifiers to the assigned scores.
|
||||
final := applyRaceMods(c.Race, [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA})
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = final[0], final[1], final[2], final[3], final[4], final[5]
|
||||
|
||||
// Initial D&D level seeded from existing combat_level (v1.1 §4.1).
|
||||
// combat_level "freezes" thereafter — dnd_level is canonical.
|
||||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||||
startLevel := 1
|
||||
if advChar != nil {
|
||||
startLevel = dndLevelFromCombatLevel(advChar.CombatLevel)
|
||||
}
|
||||
c.Level = startLevel
|
||||
|
||||
conMod := abilityModifier(c.CON)
|
||||
dexMod := abilityModifier(c.DEX)
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
c.PendingSetup = false
|
||||
c.AutoMigrated = false // manually confirmed — no longer an auto-migration
|
||||
c.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error())
|
||||
}
|
||||
_ = initResources(ctx.Sender, c.Class)
|
||||
// Phase 9: caster classes get a default known-spell list and slot pool.
|
||||
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
||||
_ = ensureSpellsForCharacter(c)
|
||||
|
||||
return p.SendDM(ctx.Sender,renderSetupComplete(c))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) dndSetupCancel(ctx MessageContext) error {
|
||||
c, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil || c == nil {
|
||||
return p.SendDM(ctx.Sender,"No draft to cancel.")
|
||||
}
|
||||
if !c.PendingSetup {
|
||||
return p.SendDM(ctx.Sender,"Your character is already finalized — use `!respec` instead.")
|
||||
}
|
||||
if _, err := dbExecCancel(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender,"Couldn't cancel: "+err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender,"Draft scrapped. Run `!setup` to start over.")
|
||||
}
|
||||
|
||||
// ── !respec ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
dndRespecCost = 5000
|
||||
dndRespecCooldown = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
c, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
|
||||
}
|
||||
if c == nil {
|
||||
return p.SendDM(ctx.Sender, "You don't have a character yet — run `!setup` instead.")
|
||||
}
|
||||
if c.PendingSetup {
|
||||
return p.SendDM(ctx.Sender, "You already have a setup draft in progress. Run `!setup` to continue or `!setup cancel` to scrap it.")
|
||||
}
|
||||
if c.AutoMigrated {
|
||||
return p.SendDM(ctx.Sender, "Your character was auto-built — `!setup` lets you rebuild for free, no respec needed.")
|
||||
}
|
||||
|
||||
if c.LastRespecAt != nil {
|
||||
elapsed := time.Since(*c.LastRespecAt)
|
||||
if elapsed < dndRespecCooldown {
|
||||
remaining := dndRespecCooldown - elapsed
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"`!respec` is on cooldown — %s remaining.", formatRespecDuration(remaining)))
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-check balance so insufficient-funds players get the right error
|
||||
// without any state mutation.
|
||||
if p.euro == nil || p.euro.GetBalance(ctx.Sender) < float64(dndRespecCost) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("`!respec` costs %d euros — you don't have enough.", dndRespecCost))
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
c.Race = ""
|
||||
c.Class = ""
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 8, 8, 8, 8, 8
|
||||
c.HPMax = 0
|
||||
c.HPCurrent = 0
|
||||
c.TempHP = 0
|
||||
c.ArmorClass = 10
|
||||
c.Level = 1
|
||||
c.XP = 0
|
||||
c.ArmedAbility = ""
|
||||
c.PendingSetup = true
|
||||
c.AutoMigrated = false
|
||||
c.LastRespecAt = &now
|
||||
// Save the wipe BEFORE debiting euros (audit fix B). If save fails, the
|
||||
// player's old state survives and they keep their euros — better than
|
||||
// a destructive debit-without-wipe.
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't save respec state: "+err.Error())
|
||||
}
|
||||
// Wipe old class's resource pool so respec'd Mages don't carry Fighter
|
||||
// stamina rows etc. (audit fix E).
|
||||
if _, err := db.Get().Exec(
|
||||
`DELETE FROM dnd_resources WHERE user_id = ?`, string(ctx.Sender),
|
||||
); err != nil {
|
||||
slog.Error("dnd: respec resource wipe", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
// Phase 9: also wipe known spells + slot pool so respec from caster →
|
||||
// non-caster (or caster → caster of another class) starts clean.
|
||||
if err := wipeSpellsForUser(ctx.Sender); err != nil {
|
||||
slog.Error("dnd: respec spell wipe", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
// Debit last. If this fails (rare race — euros spent elsewhere between
|
||||
// the pre-check and now), the player got a free respec. Strictly better
|
||||
// than the alternative of euros-lost-with-wipe-not-applied.
|
||||
if !p.euro.Debit(ctx.Sender, float64(dndRespecCost), "dnd respec") {
|
||||
slog.Warn("dnd: respec wipe completed but debit failed (race vs. balance change)",
|
||||
"user", ctx.Sender)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"💸 %d euros spent. Your character is wiped. Run `!setup` to build a new one.\n\n"+
|
||||
"Cooldown: %s before next respec.",
|
||||
dndRespecCost, formatRespecDuration(dndRespecCooldown)))
|
||||
}
|
||||
|
||||
func formatRespecDuration(d time.Duration) string {
|
||||
hours := int(d.Hours())
|
||||
if hours >= 24 {
|
||||
days := hours / 24
|
||||
hours = hours % 24
|
||||
if hours == 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
mins := int(d.Minutes())
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func loadOrInitDraft(userID id.UserID) (*DnDCharacter, error) {
|
||||
c, err := LoadDnDCharacter(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c != nil {
|
||||
// Auto-migrated chars can be freely rebuilt — wipe and start fresh.
|
||||
// If the player cancels, their next combat will auto-migrate again.
|
||||
if c.AutoMigrated && !c.PendingSetup {
|
||||
if _, err := db.Get().Exec(
|
||||
`DELETE FROM dnd_character WHERE user_id = ?`, string(userID),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear auto-migrated row: %w", err)
|
||||
}
|
||||
// fall through to fresh draft
|
||||
} else if !c.PendingSetup {
|
||||
return nil, fmt.Errorf("character already finalized — use !respec")
|
||||
} else {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
// Fresh draft.
|
||||
now := time.Now().UTC()
|
||||
return &DnDCharacter{
|
||||
UserID: userID,
|
||||
Level: 1,
|
||||
PendingSetup: true,
|
||||
ArmorClass: 10,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dbExecCancel(userID id.UserID) (int64, error) {
|
||||
res, err := db.Get().Exec(`DELETE FROM dnd_character WHERE user_id = ? AND pending_setup = 1`, string(userID))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseStatsArg accepts the six standard-array values in any of several
|
||||
// natural forms. All of these work:
|
||||
//
|
||||
// 15 14 13 12 10 8
|
||||
// 15, 14, 13, 12, 10, 8
|
||||
// (15, 14, 13, 12, 10, 8)
|
||||
// {15 14 13 12 10 8}
|
||||
// [15,14,13,12,10,8]
|
||||
//
|
||||
// Returns the scores in input order. Validation that they're a permutation
|
||||
// of the standard array happens in the caller via isStandardArray.
|
||||
func parseStatsArg(s string) ([6]int, error) {
|
||||
var out [6]int
|
||||
// Normalize separators: turn commas and bracket-pair characters into
|
||||
// spaces, then split on whitespace.
|
||||
cleaned := s
|
||||
for _, ch := range []string{",", "(", ")", "[", "]", "{", "}"} {
|
||||
cleaned = strings.ReplaceAll(cleaned, ch, " ")
|
||||
}
|
||||
parts := strings.Fields(cleaned)
|
||||
if len(parts) != 6 {
|
||||
return out, fmt.Errorf("Need exactly 6 numbers, got %d.", len(parts))
|
||||
}
|
||||
for i, tok := range parts {
|
||||
n, err := strconv.Atoi(tok)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("Stat %d isn't a number: %q.", i+1, tok)
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseRace(s string) (DnDRace, bool) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "-", "_")
|
||||
for _, ri := range dndRaces {
|
||||
if string(ri.Key) == s || strings.EqualFold(ri.Display, s) {
|
||||
return ri.Key, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseClass(s string) (DnDClass, bool) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
for _, ci := range dndClasses {
|
||||
if string(ci.Key) == s || strings.EqualFold(ci.Display, s) {
|
||||
return ci.Key, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isStandardArray(scores [6]int) bool {
|
||||
a := scores
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(a[:])))
|
||||
want := standardArray
|
||||
return a == want
|
||||
}
|
||||
|
||||
func statsAssigned(c *DnDCharacter) bool {
|
||||
// All-8 means default-init; treat as not yet assigned.
|
||||
if c.STR == 8 && c.DEX == 8 && c.CON == 8 && c.INT == 8 && c.WIS == 8 && c.CHA == 8 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func titleRace(r DnDRace) string {
|
||||
if ri, ok := raceInfo(r); ok {
|
||||
return ri.Display
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
func titleClass(c DnDClass) string {
|
||||
if ci, ok := classInfo(c); ok {
|
||||
return ci.Display
|
||||
}
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func renderRaceMenu() string {
|
||||
var b strings.Builder
|
||||
for _, ri := range dndRaces {
|
||||
b.WriteString(fmt.Sprintf(" • **%s** — %s\n", ri.Display, ri.Passive))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderClassMenu() string {
|
||||
var b strings.Builder
|
||||
for _, ci := range dndClasses {
|
||||
b.WriteString(fmt.Sprintf(" • **%s** (d%d, %s/%s)\n", ci.Display, ci.HPDie, ci.PrimaryA, ci.PrimaryB))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderConfirmPreview(c *DnDCharacter) string {
|
||||
final := applyRaceMods(c.Race, [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA})
|
||||
mods := [6]int{
|
||||
abilityModifier(final[0]), abilityModifier(final[1]), abilityModifier(final[2]),
|
||||
abilityModifier(final[3]), abilityModifier(final[4]), abilityModifier(final[5]),
|
||||
}
|
||||
conMod := mods[2]
|
||||
dexMod := mods[1]
|
||||
advChar, _ := loadAdvCharacter(c.UserID)
|
||||
lvl := 1
|
||||
if advChar != nil {
|
||||
lvl = dndLevelFromCombatLevel(advChar.CombatLevel)
|
||||
}
|
||||
hp := computeMaxHP(c.Class, conMod, lvl)
|
||||
ac := computeAC(c.Class, dexMod)
|
||||
return fmt.Sprintf(
|
||||
"\n**Preview** (post-racial):\n"+
|
||||
" STR %d (%+d) DEX %d (%+d) CON %d (%+d)\n"+
|
||||
" INT %d (%+d) WIS %d (%+d) CHA %d (%+d)\n"+
|
||||
" HP %d AC %d Level %d\n",
|
||||
final[0], mods[0], final[1], mods[1], final[2], mods[2],
|
||||
final[3], mods[3], final[4], mods[4], final[5], mods[5],
|
||||
hp, ac, lvl,
|
||||
)
|
||||
}
|
||||
|
||||
func renderSetupComplete(c *DnDCharacter) string {
|
||||
ri, _ := raceInfo(c.Race)
|
||||
ci, _ := classInfo(c.Class)
|
||||
return fmt.Sprintf(
|
||||
"⚔️ **Character Sheet Forged**\n\n"+
|
||||
"You are a **Level %d %s %s**.\n"+
|
||||
" HP %d/%d AC %d\n"+
|
||||
" STR %d DEX %d CON %d INT %d WIS %d CHA %d\n\n"+
|
||||
"_%s_\n\n"+
|
||||
"Use `!sheet` anytime to review. Combat, abilities, and rest mechanics arrive in the next phases.",
|
||||
c.Level, ri.Display, ci.Display,
|
||||
c.HPCurrent, c.HPMax, c.ArmorClass,
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA,
|
||||
ri.Passive,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user