From 8ee170bb9b5d45b815cbb4cd0963836af93d3dee Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 22:17:03 -0700 Subject: [PATCH] =?UTF-8?q?UX=20S7:=20sheet=20+=20menu=20jargon=20sweep=20?= =?UTF-8?q?=E2=80=94=20outcome-first=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops D&D-handbook syntax from the player-facing caster UX in favor of verbs and feelings (per feedback_accessibility_over_dnd_crunch). R12 — Drop "Spell DC: N / Spell Atk: +N" line from the spellbook view. The numbers are pure handbook noise; the engine still computes them. R13 — renderSlotLine swaps "L1 3/4 · L2 1/3" for "Level 1 ●●●○ · Level 2 ●○○○". Filled bullets = energy left, hollow = spent. R14 — Caster passive Description strings rewritten outcome-first: no "+5%", no "scaled by your Charisma". Verbs and texture instead. All ten class passives reworded; mechanics in applyClassPassives unchanged. R18 — Class menu drops the "(d8, INT/WIS)" suffix → "**Mage** — INT & WIS". The HP-die number was leaking implementation. !setup class confirm line gets the same treatment. P5 — Spellbook headers: "Cantrip" → "Cantrips", "L1" → "Level 1". P6 — Cast queue line: "_(upcast to L2)_" → "_(empowered)_". Queued-line in the spellbook view follows suit — drops "(L2 slot)" for "(empowered)" when the slot is above the spell's base level. P8 — Comment-convention marker added to dnd_passives.go and dnd_subclass_combat.go file headers: `// internal note (not user-facing)` flags Phase 2/3 tuning history so future codegen doesn't lift it into Description / Flavor strings. P9 — Spellbook line drops the duplicate "(can learn N more)" trailer — the "%d / %d" already conveys remaining capacity. Bonus cleanup. The two Arcane-Trickster slot-ceiling errors ("Arcane Trickster L5 only has up to L1 slots.") and the "No L1 slot available" out-of-energy messages get the same jargon scrub: "At level 5, your Arcane Trickster magic only reaches level-1 spells." / "You're out of level-1 energy." Tests. Full plugin suite green; no test pinned the old strings. --- internal/plugin/combat_cmd.go | 4 ++-- internal/plugin/dnd_cast.go | 22 ++++++++------------- internal/plugin/dnd_passives.go | 27 ++++++++++++++------------ internal/plugin/dnd_setup.go | 6 +++--- internal/plugin/dnd_spells.go | 14 +++++++++++-- internal/plugin/dnd_subclass_combat.go | 5 +++++ 6 files changed, 45 insertions(+), 33 deletions(-) diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index c4f2f14..dcd81f1 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -336,7 +336,7 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin } if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { if mx := highestAvailableSlotForChar(c); spell.Level > mx { - return SpellDefinition{}, 0, fmt.Sprintf("Arcane Trickster L%d only has up to L%d slots.", c.Level, mx) + return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx) } } known, prepared, err := playerKnowsSpell(userID, spell.ID) @@ -473,7 +473,7 @@ func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinitio return "Couldn't consume slot: " + serr.Error() } if !ok { - return fmt.Sprintf("No L%d slot available. %s", slotLevel, renderSlotsBrief(userID)) + return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID)) } } return "" diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index aae3ddd..a5e9aea 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -147,7 +147,7 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { if max := highestAvailableSlotForChar(c); spell.Level > max { return p.SendDM(ctx.Sender, fmt.Sprintf( - "Arcane Trickster L%d only has up to L%d slots.", c.Level, max)) + "At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, max)) } } @@ -207,7 +207,7 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro } if !ok { return p.SendDM(ctx.Sender, fmt.Sprintf( - "No L%d slot available. %s", slotLevel, renderSlotsBrief(ctx.Sender))) + "You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(ctx.Sender))) } } @@ -342,7 +342,7 @@ func (p *AdventurePlugin) queuePendingCast(ctx MessageContext, c *DnDCharacter, msg := fmt.Sprintf("✨ **%s** queued for next combat.", spell.Name) if slotLevel > spell.Level { - msg += fmt.Sprintf(" _(upcast to L%d)_", slotLevel) + msg += " _(empowered)_" } if supersedes != "" { msg += fmt.Sprintf("\n_(Concentration shifts: %s ended.)_", displaySpellName(supersedes)) @@ -438,9 +438,9 @@ func renderSpellsList(c *DnDCharacter) string { } sort.Ints(levels) for _, lvl := range levels { - label := fmt.Sprintf("L%d", lvl) + label := fmt.Sprintf("Level %d", lvl) if lvl == 0 { - label = "Cantrip" + label = "Cantrips" } b.WriteString(fmt.Sprintf("__%s__\n", label)) for _, k := range byLevel[lvl] { @@ -460,23 +460,17 @@ func renderSpellsList(c *DnDCharacter) string { slots, _ := getSpellSlots(c.UserID) b.WriteString("\n**Slots:** " + renderSlotLine(slots) + "\n") - b.WriteString(fmt.Sprintf("**Spell DC:** %d **Spell Atk:** +%d\n", - spellSaveDC(c), spellAttackBonus(c))) if c.Class == ClassMage { count, _ := mageLeveledKnownCount(c.UserID) cap := mageKnownSpellsCap(c.Level) - b.WriteString(fmt.Sprintf("**Spellbook:** %d/%d leveled spells", count, cap)) - if avail := cap - count; avail > 0 { - b.WriteString(fmt.Sprintf(" _(can learn %d more)_", avail)) - } - b.WriteString("\n") + b.WriteString(fmt.Sprintf("**Spellbook:** %d / %d leveled spells learned\n", count, cap)) } if pc, ok := decodePendingCast(c.PendingCast); ok { b.WriteString(fmt.Sprintf("\n_Queued: **%s**", displaySpellName(pc.SpellID))) - if pc.SlotLevel > 0 { - b.WriteString(fmt.Sprintf(" (L%d slot)", pc.SlotLevel)) + if s, ok := lookupSpell(pc.SpellID); ok && pc.SlotLevel > s.Level { + b.WriteString(" (empowered)") } b.WriteString(" — fires next combat._\n") } diff --git a/internal/plugin/dnd_passives.go b/internal/plugin/dnd_passives.go index 65284b1..1a5b3ad 100644 --- a/internal/plugin/dnd_passives.go +++ b/internal/plugin/dnd_passives.go @@ -7,6 +7,11 @@ package plugin // (the combat engine is one-shot, no turn-based UI). Active/reactive // abilities arrive once we have a model for them — likely tied to !arena // pre-arming or to a turn-based PvP variant. +// +// Comment convention: `// internal note (not user-facing)` marks block +// comments that document tuning history (Phase 2/3 balance notes, scaling +// rationale, etc.). Anything inside such a block is engineering context, +// NOT a candidate for codegen to lift into a Description string. // ── Class passive definitions ──────────────────────────────────────────────── @@ -21,45 +26,43 @@ type DnDClassAbility struct { var dndClassAbilities = map[DnDClass]DnDClassAbility{ ClassFighter: { Name: "Battle Trained", - Description: "Years of weapon drill add +5% to all damage you deal.", + Description: "Years of weapon drill make every swing hit a little harder than it has any right to.", }, ClassRogue: { Name: "Sneak Attack", - Description: "Your first strike each combat lands as a critical hit, doubling its damage; precision drills add +5% to all damage you deal.", + Description: "Your opening strike each fight finds a seam and crits — and the same trained precision keeps every follow-up sharper than it looks.", }, ClassMage: { Name: "Arcane Focus", - Description: "Practiced channeling adds +1 to your attack rolls and a small bump (+5%) to all damage you deal.", + Description: "Practiced channeling steadies your aim and lets a faint arcane crackle leak into every blow you land.", }, ClassCleric: { Name: "Divine Favor", - Description: "When you fall below half HP, divine intervention restores 5 HP. Once per combat.", + Description: "When the fight turns against you, a sliver of divine grace patches you up. Once per combat.", }, ClassRanger: { Name: "Hunter's Mark", - Description: "You read your prey's weak points: +5% damage and +1 to attack rolls.", + Description: "You read your prey's tells — openings come easier, and the hits land where it hurts.", }, - // Open5e caster scaffold — one signature passive each, riding existing - // CombatModifiers channels the same way the original five do. ClassDruid: { Name: "Wild Resilience", - Description: "The wild lends you its toughness — incoming damage is reduced 5% — and a thorn surge on engage scaled by your Wisdom.", + Description: "The wild lends you its hide — blows land softer — and the moment you engage, a thorn surge lashes back at whatever picked the fight.", }, ClassBard: { Name: "Bardic Inspiration", - Description: "Quick wit keeps you a step ahead: +1 initiative, +1 attack, and +5% to all damage you deal.", + Description: "Wit and timing put you a beat ahead of the fight: you move first, swing truer, and turn the opening line into a small, mean encore.", }, ClassSorcerer: { Name: "Innate Sorcery", - Description: "Raw magic spills out as the fight begins, dealing immediate damage scaled by your Charisma, and +5% to all damage you deal.", + Description: "Raw magic boils over the moment the fight starts, scorching whatever's nearest — and every swing rides the leftover heat.", }, ClassWarlock: { Name: "Agonizing Blast", - Description: "Your pact-fueled eldritch power adds +12% to all damage you deal and +1 to attack rolls.", + Description: "Your pact whispers in the back of your skull; the favor it returns lands on your enemies, harder and surer than ought to be allowed.", }, ClassPaladin: { Name: "Divine Smite", - Description: "You channel a burst of radiant power on engagement, dealing flat damage that grows with your level.", + Description: "On engagement you channel a burst of radiant power — a sworn promise, paid in light — that hits harder as your oath deepens.", }, } diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index f1b9c68..5ca2929 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -207,11 +207,11 @@ func (p *AdventurePlugin) dndSetupClass(ctx MessageContext, classArg string) err 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"+ + 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.HPDie, ci.PrimaryA, ci.PrimaryB)) + ci.Display, ci.PrimaryA, ci.PrimaryB)) } func (p *AdventurePlugin) dndSetupStats(ctx MessageContext, statsArg string) error { @@ -576,7 +576,7 @@ func renderClassMenu() string { if !ci.Playable { continue // Open5e caster scaffold — hidden until spell lists land. } - b.WriteString(fmt.Sprintf(" • **%s** (d%d, %s/%s)\n", ci.Display, ci.HPDie, ci.PrimaryA, ci.PrimaryB)) + b.WriteString(fmt.Sprintf(" • **%s** — %s & %s\n", ci.Display, ci.PrimaryA, ci.PrimaryB)) } return b.String() } diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index 356f3e1..6aa4d74 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -952,9 +952,19 @@ func renderSlotLine(slots map[int][2]int) string { } var parts []string for lvl := 1; lvl <= 5; lvl++ { - if pair, ok := slots[lvl]; ok { - parts = append(parts, fmt.Sprintf("L%d %d/%d", lvl, pair[0]-pair[1], pair[0])) + pair, ok := slots[lvl] + if !ok { + continue } + total := pair[0] + left := pair[0] - pair[1] + if left < 0 { + left = 0 + } + // Filled bullets = still available, hollow = spent. Player-friendly + // at a glance; no fractions, no "L1" jargon. + bullets := strings.Repeat("●", left) + strings.Repeat("○", total-left) + parts = append(parts, fmt.Sprintf("Level %d %s", lvl, bullets)) } return strings.Join(parts, " · ") } diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index f707ce6..beb3fe1 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -12,6 +12,11 @@ import "math" // Subclass active abilities (currently just Berserker's rage) are // registered via init() into dndActiveAbilities below, gated by // DnDAbility.Subclass. +// +// Comment convention: `// internal note (not user-facing)` marks block +// comments documenting tuning history (Phase 2/3 balance notes, scaling +// rationale, etc.). They are engineering context and must NOT be lifted +// into Description / Flavor strings by codegen. func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) { if c == nil || c.Subclass == "" {