Files
gogobee/internal/plugin/dnd_subclass.go
prosolis 41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
Tedium-removal pass driven by live play feedback. Three big threads:

Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
  (5min tick, per-expedition CAS on new last_autorun_at column). Skips
  combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
  0 rooms walked or just hitting the per-tick cap (no "stretch complete"
  filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
  elite doorways via the forward-sim engine (boss still pauses). Threaded
  via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
  attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
  (avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
  numeric; fork footer rewritten to suggest !expedition go instead of
  !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
  hint by next room type and autopilot loop breaks via new nextRoomType
  field so "Room X/Y — Boss" doesn't double-print with contradictory
  hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
  background share the loop body.

Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
  5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
  with a small flat buff; the class's defining mechanic never matched
  its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
  combat_primitives.go (same lane as DivineStrikePerHit). Scales with
  level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
  attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
  from "16 rounds to kill, die in 8" to winnable.

TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
  notes/files/tracks/respects/..." constructions with first-person
  inside flavor string literals. Code identifiers (TwinBeeLine,
  twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
  (TwinBee's Bell), achievements, and game-title references in
  fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
  Legendary Resistance" mid-combat in third person, contradicting
  the Phase B2 convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:40:17 -07:00

290 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"sort"
"strings"
)
// Phase 10 — subclass registry. Implements `gogobee_subclass_system.md`:
// 15 subclasses (3 per class × 5 classes), unlocked at L5 via !subclass.
//
// SUB1 (this file + dnd_subclass_cmd.go) covers identity only — selection,
// storage, !respec, sheet display. Mechanical effects of L5/7/10/15
// abilities arrive in SUB2/SUB3.
//
// Forward-deps that aren't built yet are deliberately stubbed: Beast
// Master picks the subclass even though the Phase 15 pet rework hasn't
// landed; Necromancer Animate Dead waits on an undead-summon engine.
type DnDSubclass string
const (
// Fighter
SubclassChampion DnDSubclass = "champion"
SubclassBattleMaster DnDSubclass = "battle_master"
SubclassBerserker DnDSubclass = "berserker"
// Rogue
SubclassThief DnDSubclass = "thief"
SubclassAssassin DnDSubclass = "assassin"
SubclassArcaneTrickster DnDSubclass = "arcane_trickster"
// Mage
SubclassEvocation DnDSubclass = "evocation"
SubclassAbjuration DnDSubclass = "abjuration"
SubclassNecromancy DnDSubclass = "necromancy"
// Cleric
SubclassLifeDomain DnDSubclass = "life_domain"
SubclassWarDomain DnDSubclass = "war_domain"
SubclassTrickeryDomain DnDSubclass = "trickery_domain"
// Ranger
SubclassHunter DnDSubclass = "hunter"
SubclassBeastMaster DnDSubclass = "beast_master"
SubclassGloomStalker DnDSubclass = "gloom_stalker"
// Druid — Open5e caster scaffold
SubclassCircleLand DnDSubclass = "circle_land"
SubclassCircleMoon DnDSubclass = "circle_moon"
SubclassCircleShepherd DnDSubclass = "circle_shepherd"
// Bard
SubclassCollegeLore DnDSubclass = "college_lore"
SubclassCollegeValor DnDSubclass = "college_valor"
SubclassCollegeGlamour DnDSubclass = "college_glamour"
// Sorcerer
SubclassDraconicBloodline DnDSubclass = "draconic_bloodline"
SubclassWildMagic DnDSubclass = "wild_magic"
SubclassStormSorcery DnDSubclass = "storm_sorcery"
// Warlock
SubclassFiendPatron DnDSubclass = "fiend_patron"
SubclassArchfeyPatron DnDSubclass = "archfey_patron"
SubclassGreatOldOne DnDSubclass = "great_old_one"
// Paladin
SubclassOathDevotion DnDSubclass = "oath_devotion"
SubclassOathVengeance DnDSubclass = "oath_vengeance"
SubclassOathAncients DnDSubclass = "oath_ancients"
)
// DnDSubclassInfo — registry row. Tagline summarizes identity in one line
// (used in selection prompt). Flavor is the TwinBee narration line shown
// on selection (per design doc §7).
type DnDSubclassInfo struct {
ID DnDSubclass
Class DnDClass
Display string
Tagline string
Flavor string
}
// dndSubclassRegistry — 30 entries (3 per class × 10 classes), ordered
// first by class (Fighter, Rogue, Mage, Cleric, Ranger, then the Open5e
// caster scaffold: Druid, Bard, Sorcerer, Warlock, Paladin) then by the
// design doc's listed order within each class. The selection prompt
// presents them in this order. The caster subclasses use canonical 5e
// archetype names, consistent with the original fifteen — see the
// avoid-dnd-naming note: the naming concern is code identifiers and
// marketing, not in-game content.
var dndSubclassRegistry = []DnDSubclassInfo{
// Fighter
{SubclassChampion, ClassFighter, "Champion",
"Raw power, brutal efficiency.",
"You've chosen the old way. No tricks, no systems. Just you, and the blade, and the next hit. I approve of this directness."},
{SubclassBattleMaster, ClassFighter, "Battle Master",
"Tactical superiority, maneuver control.",
"Maneuvers, dice, control of the engagement. I note that the smartest fighter in the room rarely throws the first punch."},
{SubclassBerserker, ClassFighter, "Berserker",
"Rage-fueled destruction, high risk/reward.",
"Rage as a tool, exhaustion as a tax. I file this under 'load-bearing recklessness'."},
// Rogue
{SubclassThief, ClassRogue, "Thief",
"Speed, opportunism, fast hands.",
"Quick fingers, quicker exits. I suspect the locks of the world have been put on notice."},
{SubclassAssassin, ClassRogue, "Assassin",
"Setup and execution; devastating openers.",
"Patience, then violence, then nothing. I respect the economy of motion."},
{SubclassArcaneTrickster, ClassRogue, "Arcane Trickster",
"Stealth plus a slice of Mage spellcasting.",
"Magic in the hands of a Rogue. I consider this the universe's way of apologizing for giving you low HP."},
// Mage
{SubclassEvocation, ClassMage, "Evocation",
"Pure damage output. The glass cannon perfected.",
"You picked the path of the louder spell. I find this aesthetically pleasing and structurally alarming."},
{SubclassAbjuration, ClassMage, "Abjuration",
"Wards, shields, counterspells.",
"You'd rather not get hit, and you've made it your specialty. I approve of this kind of foresight."},
{SubclassNecromancy, ClassMage, "Necromancy",
"Control undead and drain life.",
"Undeath as a tool rather than a state. I have opinions about this. I am keeping them to itself."},
// Cleric
{SubclassLifeDomain, ClassCleric, "Life Domain",
"Maximum healing output. Party support anchor.",
"Healing as discipline, not afterthought. I note the party will live longer because of you, whether they thank you or not."},
{SubclassWarDomain, ClassCleric, "War Domain",
"Combat Cleric — extra attacks, martial weapons.",
"You came to fight, not to mediate. I find this clarifying."},
{SubclassTrickeryDomain, ClassCleric, "Trickery Domain",
"Illusion, misdirection, chaos.",
"A Cleric who lies for a living. I delight in the implied theological complications."},
// Ranger
{SubclassHunter, ClassRanger, "Hunter",
"Zone specialist; bonuses against enemy types.",
"You read the terrain before you draw the bow. I find this professionalism rare and welcome."},
{SubclassBeastMaster, ClassRanger, "Beast Master",
"Deep Rangerpet bond. Pet as combat partner.",
"You and your pet, all the way down. I find this genuinely moving and decline to be embarrassed about that."},
{SubclassGloomStalker, ClassRanger, "Gloom Stalker",
"Darkness specialist. Exceptional underground.",
"You were already good in the dark. Now you're better. I note: the Underdark is waiting."},
// Druid — Open5e caster scaffold
{SubclassCircleLand, ClassDruid, "Circle of the Land",
"Versatile caster; recovery and warding.",
"The land remembers you, and answers when you call. A measured path — and a durable one."},
{SubclassCircleMoon, ClassDruid, "Circle of the Moon",
"Combat shapeshifter; tanky beast forms.",
"You don't cast at the fight so much as become it. Tooth, claw, and a great deal of hide."},
{SubclassCircleShepherd, ClassDruid, "Circle of the Shepherd",
"Summoner; spirit totems and conjured allies.",
"You never walk into a fight alone — the wild walks in with you."},
// Bard
{SubclassCollegeLore, ClassBard, "College of Lore",
"Knowledge and control; turn the enemy's edge dull.",
"You've read the room, the foe, and three books about both. The fight is half-won before it starts."},
{SubclassCollegeValor, ClassBard, "College of Valor",
"Combat bard; songs that sharpen sword and shield.",
"A song with a blade in it. The old kind of bard — the kind that came back from the war."},
{SubclassCollegeGlamour, ClassBard, "College of Glamour",
"Charm and majesty; enthrall and disarm the foe.",
"You wear the Feywild's glamour like a borrowed coat. Few can bear to strike at something so dazzling."},
// Sorcerer
{SubclassDraconicBloodline, ClassSorcerer, "Draconic Bloodline",
"Durable blaster; draconic scales and elemental punch.",
"There's a dragon somewhere back down your line, and it left the door unlocked."},
{SubclassWildMagic, ClassSorcerer, "Wild Magic",
"Chaotic burst; luck bent hard in your favor.",
"Your magic doesn't take orders so much as suggestions. Loud, unpredictable, occasionally glorious."},
{SubclassStormSorcery, ClassSorcerer, "Storm Sorcery",
"Mobile elemental caster; wind, speed, and thunder.",
"You move first because the storm always does. Everything after is just weather."},
// Warlock
{SubclassFiendPatron, ClassWarlock, "The Fiend",
"Damage and grit; power borrowed from the lower planes.",
"The bargain was steep, the firepower is steeper. Try not to read the fine print mid-fight."},
{SubclassArchfeyPatron, ClassWarlock, "The Archfey",
"Charm and escape; fey tricks that unmake the attack.",
"Your patron deals in glamour and misdirection — and so, now, do you. Hard to hit what won't hold still."},
{SubclassGreatOldOne, ClassWarlock, "The Great Old One",
"Psychic control; minds bent, foes unmade from within.",
"Something vast and distant is half-listening through you. Best not to think too hard about which half."},
// Paladin
{SubclassOathDevotion, ClassPaladin, "Oath of Devotion",
"Holy warrior; sacred weapon and protective auras.",
"You took the oath and meant every word of it. The light leans your way for it."},
{SubclassOathVengeance, ClassPaladin, "Oath of Vengeance",
"Relentless attacker; mark a foe and bring it down.",
"Someone, somewhere, has earned this. You've simply volunteered to deliver it."},
{SubclassOathAncients, ClassPaladin, "Oath of the Ancients",
"Resilient nature-paladin; warding light, hard to fell.",
"You guard the green and growing things, and the old light guards you back. Stubbornly."},
}
// subclassInfo returns the registry row for id, or (zero, false).
func subclassInfo(id DnDSubclass) (DnDSubclassInfo, bool) {
for _, s := range dndSubclassRegistry {
if s.ID == id {
return s, true
}
}
return DnDSubclassInfo{}, false
}
// subclassesForClass returns the 3 subclasses available to a given class,
// in registry/design-doc order.
func subclassesForClass(class DnDClass) []DnDSubclassInfo {
out := make([]DnDSubclassInfo, 0, 3)
for _, s := range dndSubclassRegistry {
if s.Class == class {
out = append(out, s)
}
}
return out
}
// resolveSubclassInput maps user-supplied input ("1", "champion",
// "battle master", "BattleMaster") to a subclass id valid for the given
// class. Returns ("", false) if no match.
func resolveSubclassInput(class DnDClass, input string) (DnDSubclass, bool) {
subs := subclassesForClass(class)
if len(subs) == 0 {
return "", false
}
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", false
}
// Numeric: "1", "2", "3".
if len(trimmed) == 1 && trimmed[0] >= '1' && trimmed[0] <= '9' {
idx := int(trimmed[0] - '1')
if idx >= 0 && idx < len(subs) {
return subs[idx].ID, true
}
}
// Name match: case-insensitive, ignore spaces/underscores.
norm := normalizeSubclassName(trimmed)
for _, s := range subs {
if normalizeSubclassName(string(s.ID)) == norm ||
normalizeSubclassName(s.Display) == norm {
return s.ID, true
}
}
return "", false
}
func normalizeSubclassName(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "_", "")
s = strings.ReplaceAll(s, "-", "")
return s
}
// renderSubclassPrompt builds the "Choose your path" message for the
// player's class. Used by !subclass with no args and by the L5 level-up
// DM cue.
func renderSubclassPrompt(class DnDClass) string {
subs := subclassesForClass(class)
if len(subs) == 0 {
return "" // unreachable for any registered class
}
ci, _ := classInfo(class)
var b strings.Builder
b.WriteString("⚔️ **Choose your path** — ")
b.WriteString(ci.Display)
b.WriteString(" subclass\n\n")
for i, s := range subs {
b.WriteString(" ")
b.WriteString(string(rune('1' + i)))
b.WriteString(". **")
b.WriteString(s.Display)
b.WriteString("** — ")
b.WriteString(s.Tagline)
b.WriteString("\n")
}
b.WriteString("\nReply: `!subclass <name or number>` (e.g. `!subclass 1` or `!subclass champion`).")
return b.String()
}
// allSubclassIDs — used by tests/audits to walk every subclass.
func allSubclassIDs() []DnDSubclass {
ids := make([]DnDSubclass, 0, len(dndSubclassRegistry))
for _, s := range dndSubclassRegistry {
ids = append(ids, s.ID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}