mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
gogobee's game-side of Pete's push-based Adventure news feed. Emits structured event *facts* (not prose) to Pete's ingest endpoint over a durable queue; Pete owns voice/authoring/publishing. - internal/peteclient: durable pete_emit_queue + retry sender; Fact payload; FEATURE_PETE_NEWS master switch. - pete.go: emitFact enforces runtime kill-switch + per-player opt-out (anonymize, never drop); zone-clear + realm-first tiering; !news command (player optout/optin, admin on/off). - bootstrap_pete_news.go: cold-start backfill replays deaths + single-holder achievements + zone realm-firsts, backdated + NoPush, idempotent, fires only once the seam is live; seeds news_realm_firsts. - Emit sites: death, zone_first, rival_result, milestone, arrival. - db.go: pete_emit_queue, news_optout (+opted_out_at), news_realm_firsts. Unshipped. Deploy Pete first, then set FEATURE_PETE_NEWS + ingest env. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
659 lines
24 KiB
Go
659 lines
24 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"gogobee/internal/peteclient"
|
|
"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 {
|
|
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** — leans on %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.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.
|
|
//
|
|
// ensureCharacter (not a bare loadAdvCharacter) so the canonical
|
|
// player_meta seed row + tier-0 equipment exist for D&D-only players
|
|
// who reach !setup confirm without ever touching the legacy adventure
|
|
// path. Without this seed, loadAdvCharacter returns "sql: no rows in
|
|
// result set" on every legacy-layer command (arena, npcs, events, …).
|
|
advChar, _, _ := p.ensureCharacter(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.ShortRestCharges = c.Level
|
|
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)
|
|
|
|
// A new character walked through the gates — file a welcome dispatch to
|
|
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
|
|
// re-announce the same arrival. No-op unless the seam is enabled.
|
|
if name := charName(ctx.Sender); name != "" {
|
|
ts := nowUnix()
|
|
emitFact(peteclient.Fact{
|
|
GUID: "arrival:" + userHash(ctx.Sender),
|
|
EventType: "arrival",
|
|
Tier: "bulletin",
|
|
Subject: name,
|
|
ClassRace: classRaceLabel(c),
|
|
Level: c.Level,
|
|
OccurredAt: ts,
|
|
}, ctx.Sender, "")
|
|
}
|
|
|
|
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
|
|
// Phase 10: full wipe also resets subclass state. Subclass-respec
|
|
// cooldown is dropped — at L1 with no class there's nothing to gate.
|
|
c.Subclass = ""
|
|
c.LastSubclassRespecAt = nil
|
|
c.Exhaustion = 0
|
|
// 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)
|
|
}
|
|
// Phase R hardening: a respec also abandons any active zone-run /
|
|
// expedition keyed to the wiped character so they don't orphan with
|
|
// pointers to a stat sheet that no longer exists.
|
|
if err := abandonZoneRun(ctx.Sender); err != nil && err != ErrNoActiveRun {
|
|
slog.Warn("dnd: respec zone-run cleanup", "user", ctx.Sender, "err", err)
|
|
}
|
|
if err := abandonExpedition(ctx.Sender); err != nil && err != ErrNoActiveExpedition {
|
|
slog.Warn("dnd: respec expedition cleanup", "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)
|
|
}
|
|
// Auto-migrated wipe: also abandon active zone-run /
|
|
// expedition so they don't orphan against the deleted row.
|
|
if err := abandonZoneRun(userID); err != nil && err != ErrNoActiveRun {
|
|
slog.Warn("dnd: auto-migrated wipe zone-run cleanup", "user", userID, "err", err)
|
|
}
|
|
if err := abandonExpedition(userID); err != nil && err != ErrNoActiveExpedition {
|
|
slog.Warn("dnd: auto-migrated wipe expedition cleanup", "user", userID, "err", 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) {
|
|
if !ci.Playable {
|
|
// Recognized class, but not yet selectable (Open5e
|
|
// caster scaffold — no spell list). Treat as no match.
|
|
return "", false
|
|
}
|
|
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))
|
|
if ri.BestFit != "" {
|
|
b.WriteString(fmt.Sprintf(" _best with: %s_\n", ri.BestFit))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func renderClassMenu() string {
|
|
var b strings.Builder
|
|
for _, ci := range dndClasses {
|
|
if !ci.Playable {
|
|
continue // Open5e caster scaffold — hidden until spell lists land.
|
|
}
|
|
b.WriteString(fmt.Sprintf(" • **%s** — %s & %s\n", ci.Display, 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)
|
|
nextLine := "Use `!sheet` anytime to review. `!zone list` to head out, or `!expedition list` for a longer run."
|
|
if isSpellcaster(c) {
|
|
// Casters get auto-granted spells on character creation but no
|
|
// in-game discovery prompt for them. Surface !spells / !cast so
|
|
// they can actually find their kit. Long-rest refresh is the
|
|
// other piece they need to know, but that becomes obvious the
|
|
// moment they look at !spells.
|
|
nextLine = "Use `!sheet` anytime to review. `!spells` lists what you can cast and `!cast <spell>` does the deed. `!zone list` heads out; `!expedition list` is the longer run."
|
|
}
|
|
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"+
|
|
"%s",
|
|
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,
|
|
nextLine,
|
|
)
|
|
}
|