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 — pick race // !setup class — pick class // !setup stats — 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 ` (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 ` (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 `\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 `\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 ` — 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 ` 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, ) }