From 4a5159ee148945ec10083d70db92e4bbd22bd810 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 10:32:34 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 10 SUB2-AT: Arcane Trickster spellcasting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slots into the Phase 9 spell pipeline so an L5+ Rogue who picks Arcane Trickster becomes a third-caster on the Mage list, INT-based. - isSpellcaster(c) gate: full casters by class plus AT Rogue at L5+. !cast / !spells now consult this; the cast class-gate also accepts Mage-tagged spells when the player is an AT Rogue. - subclassSpellSlots() / slotsForCharacter() / setSpellSlotsForCharacter() layer subclass slots on top of the class baseline. AT pool: 3 L1 at L5–6, 4 L1 + 2 L2 at L7+, +2 L3 at L13+ (third-caster table). - spellcastingMod returns INT for AT Rogue. - L7 Magical Ambush proxy: spellSaveDC bumps +2 once Rogue+AT hits L7, standing in for "targets have disadvantage when casting from hidden" since the one-shot combat layer doesn't model hidden state. - ensureSpellsForCharacter bootstraps a Mage-list starter spellbook (minor_illusion, shocking_grasp, magic_missile, shield, sleep at L5; mirror_image + misty_step at L7+; hypnotic_pattern at L13+) and refreshes the slot pool on level/subclass change. - applySubclassChoice runs ensureSpellsForCharacter for AT so the spellbook is provisioned at selection time (idempotent). - !cast clamps upcasts to highestAvailableSlotForChar when AT, since third-caster's slot ceiling lags behind the spell list. Tests added: AT spellcaster gate (L5 cutoff, non-AT Rogue rejected), INT mod, subclass slot pool by tier, L7 ambush DC bump, ensureSpells default list (all on Mage list, slots correct), end-to-end !cast queues magic_missile for AT Rogue, !cast rejects plain Thief Rogue. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_cast.go | 23 ++- internal/plugin/dnd_spells.go | 145 ++++++++++++++++- internal/plugin/dnd_subclass_cmd.go | 6 + internal/plugin/dnd_subclass_combat_test.go | 165 ++++++++++++++++++++ 4 files changed, 328 insertions(+), 11 deletions(-) diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index 2f76883..a9712df 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -71,9 +71,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro if err != nil || c == nil { return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") } - if !classIsCaster(c.Class) { + if !isSpellcaster(c) { return p.SendDM(ctx.Sender, fmt.Sprintf( - "%s isn't a caster class. `!cast` is for Mage, Cleric, and Ranger.", + "%s isn't a caster class. `!cast` is for Mage, Cleric, Ranger, and Arcane Trickster Rogues (L5+).", titleClass(c.Class))) } @@ -119,10 +119,15 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro "Unknown spell %q. Run `!spells` to list what you know.", strings.Join(spellTokens, " "))) } - // Class gate. + // Class gate. Arcane Trickster Rogues use the Mage spell list (Phase 10 + // SUB2-AT), so accept Mage-tagged spells when the player has that subclass. + effectiveClass := c.Class + if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { + effectiveClass = ClassMage + } classOK := false for _, cl := range spell.Classes { - if cl == c.Class { + if cl == effectiveClass { classOK = true break } @@ -131,6 +136,14 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro return p.SendDM(ctx.Sender, fmt.Sprintf( "%s is not on the %s spell list.", spell.Name, titleClass(c.Class))) } + // AT slot ceiling — capped at L1 until L7 (then L2), tracks third-caster + // progression. Refuse upcasting beyond what the subclass actually has. + 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)) + } + } // Reaction spells deferred. if spell.Effect == EffectReaction { @@ -368,7 +381,7 @@ func (p *AdventurePlugin) handleDnDSpellsCmd(ctx MessageContext, args string) er if err != nil || c == nil { return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") } - if !classIsCaster(c.Class) { + if !isSpellcaster(c) { return p.SendDM(ctx.Sender, fmt.Sprintf( "%s isn't a caster class.", titleClass(c.Class))) } diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index a6fe8c2..92088df 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -148,6 +148,47 @@ func slotsForClassLevel(class DnDClass, level int) map[int]int { return nil } +// subclassSpellSlots returns a slot pool granted by a subclass on top of the +// class baseline. Phase 10 SUB2-AT: Arcane Trickster is a third-caster that +// unlocks at Rogue L5 (3 L1 slots), expanding modestly with level. Returns +// nil for subclasses without spellcasting. +func subclassSpellSlots(sub DnDSubclass, level int) map[int]int { + if sub == SubclassArcaneTrickster && level >= 5 { + // 5e third-caster table, simplified to the levels the engine cares + // about: 3 L1 at L5–6, 4 L1 + 2 L2 at L7+, expanding into L3 at + // rogue L13. Matches the design doc's "L5 → 3 L1 slots" anchor. + switch { + case level >= 13: + return map[int]int{1: 4, 2: 3, 3: 2} + case level >= 7: + return map[int]int{1: 4, 2: 2} + default: + return map[int]int{1: 3} + } + } + return nil +} + +// slotsForCharacter returns the combined class+subclass slot pool. Use this +// for any character-level decision (display, slot reset, !cast gating); +// slotsForClassLevel is the class-only base. +func slotsForCharacter(c *DnDCharacter) map[int]int { + if c == nil { + return nil + } + out := map[int]int{} + for lvl, n := range slotsForClassLevel(c.Class, c.Level) { + out[lvl] += n + } + for lvl, n := range subclassSpellSlots(c.Subclass, c.Level) { + out[lvl] += n + } + if len(out) == 0 { + return nil + } + return out +} + // Tables transcribed from gogobee_spell_system.md §1. We interpolate // between the doc's milestone rows so every level 1..20 has a defined pool. func mageSlots(level int) map[int]int { @@ -265,13 +306,27 @@ func spellcastingMod(c *DnDCharacter) int { return abilityModifier(c.INT) case ClassCleric, ClassRanger: return abilityModifier(c.WIS) + case ClassRogue: + // Phase 10 SUB2-AT: Arcane Trickster is INT-based. + if c.Subclass == SubclassArcaneTrickster { + return abilityModifier(c.INT) + } } return 0 } // spellSaveDC = 8 + proficiency bonus + spellcasting modifier. +// +// Phase 10 SUB2-AT: Arcane Trickster L7 Magical Ambush adds +2. 5e gives +// targets disadvantage on the save when the AT casts while hidden — we +// don't model "hidden" in the one-shot combat layer, so the bump bakes the +// expected-value effect in as a flat DC nudge whenever the AT casts. func spellSaveDC(c *DnDCharacter) int { - return 8 + proficiencyBonus(c.Level) + spellcastingMod(c) + dc := 8 + proficiencyBonus(c.Level) + spellcastingMod(c) + if c != nil && c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster && c.Level >= 7 { + dc += 2 + } + return dc } // spellAttackBonus = proficiency bonus + spellcasting modifier. @@ -288,8 +343,51 @@ func classIsCaster(class DnDClass) bool { return false } +// isSpellcaster is the character-level gate: full casters by class plus the +// Arcane Trickster subclass once it unlocks at Rogue L5. Use this for !cast, +// !spells and any spellbook bootstrap path. +func isSpellcaster(c *DnDCharacter) bool { + if c == nil { + return false + } + if classIsCaster(c.Class) { + return true + } + if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster && c.Level >= 5 { + return true + } + return false +} + // ── Slot persistence ───────────────────────────────────────────────────────── +// setSpellSlotsForCharacter writes the combined class+subclass slot pool +// (Phase 10 SUB2-AT covers Arcane Trickster's third-caster pool). Mirrors +// setSpellSlotsForLevel's reset-and-insert flow. +func setSpellSlotsForCharacter(c *DnDCharacter) error { + if c == nil { + return nil + } + pool := slotsForCharacter(c) + tx, err := db.Get().Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(c.UserID)); err != nil { + return err + } + for lvl, total := range pool { + if _, err := tx.Exec(` + INSERT INTO dnd_spell_slots (user_id, slot_level, total, used) + VALUES (?, ?, ?, 0)`, + string(c.UserID), lvl, total); err != nil { + return err + } + } + return tx.Commit() +} + // setSpellSlotsForLevel writes the slot pool for a class+level, fully // resetting any prior pool. Used by setup/migration/respec. func setSpellSlotsForLevel(userID id.UserID, class DnDClass, level int) error { @@ -491,7 +589,7 @@ func concentrationActive(c *DnDCharacter) string { // enough leveled options to be functional. Players can swap via // !spells learn (Mage) or !prepare (Cleric) once SP4 lands. func ensureSpellsForCharacter(c *DnDCharacter) error { - if c == nil || !classIsCaster(c.Class) { + if !isSpellcaster(c) { return nil } existing, err := listKnownSpells(c.UserID) @@ -499,11 +597,14 @@ func ensureSpellsForCharacter(c *DnDCharacter) error { return err } if len(existing) > 0 { - // Refresh the slot pool in case level has changed since the prior - // grant (covers level-up between sessions). - return setSpellSlotsForLevel(c.UserID, c.Class, c.Level) + // Refresh the slot pool in case level/subclass has changed since the + // prior grant (covers level-up between sessions and AT subclass pick). + return setSpellSlotsForCharacter(c) } defaults := defaultKnownSpells(c.Class, c.Level) + if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster { + defaults = arcaneTricksterDefaultSpells(c.Level) + } for _, sid := range defaults { // Cleric "prepares" daily — but prep system is SP4. Until then, // every default cleric spell is auto-prepared so !cast works. @@ -511,7 +612,26 @@ func ensureSpellsForCharacter(c *DnDCharacter) error { return err } } - return setSpellSlotsForLevel(c.UserID, c.Class, c.Level) + return setSpellSlotsForCharacter(c) +} + +// arcaneTricksterDefaultSpells returns the Mage-list starter spells granted +// when a Rogue picks Arcane Trickster. Picks lean on illusion + control + +// single-target combat utility from spells already in the registry; mage_hand +// and disguise_self/invisibility are flavored AT staples but not modelled +// yet, so we substitute the closest available analogues. +func arcaneTricksterDefaultSpells(level int) []string { + out := []string{"minor_illusion", "shocking_grasp"} + if level >= 5 { + out = append(out, "magic_missile", "shield", "sleep") + } + if level >= 7 { + out = append(out, "mirror_image", "misty_step") + } + if level >= 13 { + out = append(out, "hypnotic_pattern") + } + return out } // defaultKnownSpells returns a sensible starter list for class+level. Tuned @@ -615,6 +735,19 @@ func highestAvailableSlot(class DnDClass, level int) int { return high } +// highestAvailableSlotForChar — like highestAvailableSlot but consults the +// combined class+subclass pool. Phase 10 SUB2-AT uses this to clamp Arcane +// Trickster upcasts to their actual subclass slot ceiling. +func highestAvailableSlotForChar(c *DnDCharacter) int { + high := 0 + for lvl := range slotsForCharacter(c) { + if lvl > high { + high = lvl + } + } + return high +} + // ── Display helpers ────────────────────────────────────────────────────────── func displaySpellName(spellID string) string { diff --git a/internal/plugin/dnd_subclass_cmd.go b/internal/plugin/dnd_subclass_cmd.go index 651f65f..bed8878 100644 --- a/internal/plugin/dnd_subclass_cmd.go +++ b/internal/plugin/dnd_subclass_cmd.go @@ -124,6 +124,12 @@ func (p *AdventurePlugin) applySubclassChoice( // the player still gets the subclass; resources can be re-initialized // next long rest. _ = initSubclassResources(ctx.Sender, chosen) + // Phase 10 SUB2-AT — Arcane Trickster bootstraps a Mage-list spellbook + // + third-caster slot pool on selection. Idempotent and non-fatal: the + // next `!cast`/`!spells` call also runs ensureSpellsForCharacter. + if chosen == SubclassArcaneTrickster { + _ = ensureSpellsForCharacter(c) + } if charged { // Debit last; race vs. balance change is rare and strictly safer // to favor the player than risk taking euros without applying the diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index cd816e3..4e5a927 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -677,6 +677,171 @@ func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) { } } +// ── SUB2-AT — Arcane Trickster ────────────────────────────────────────── + +func TestArcaneTrickster_IsSpellcasterAtL5(t *testing.T) { + rogueL4 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassArcaneTrickster, Level: 4} + if isSpellcaster(rogueL4) { + t.Error("AT Rogue should not be a spellcaster below L5") + } + rogueL5 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassArcaneTrickster, Level: 5} + if !isSpellcaster(rogueL5) { + t.Error("AT Rogue should be a spellcaster at L5+") + } + plainRogue := &DnDCharacter{Class: ClassRogue, Subclass: SubclassThief, Level: 5} + if isSpellcaster(plainRogue) { + t.Error("Thief Rogue should not be a spellcaster") + } +} + +func TestArcaneTrickster_SpellcastingModUsesINT(t *testing.T) { + c := &DnDCharacter{ + Class: ClassRogue, Subclass: SubclassArcaneTrickster, Level: 5, + INT: 16, WIS: 10, + } + if got := spellcastingMod(c); got != abilityModifier(16) { + t.Errorf("AT spellcastingMod = %d, want INT mod (%d)", got, abilityModifier(16)) + } + // A non-AT Rogue still returns 0. + plain := &DnDCharacter{Class: ClassRogue, Level: 5, INT: 16} + if got := spellcastingMod(plain); got != 0 { + t.Errorf("non-AT Rogue spellcastingMod = %d, want 0", got) + } +} + +func TestArcaneTrickster_SubclassSlotPool(t *testing.T) { + // L5 → 3 L1 slots only. + pool := subclassSpellSlots(SubclassArcaneTrickster, 5) + if pool[1] != 3 || pool[2] != 0 { + t.Errorf("AT L5 slots = %v, want {1:3}", pool) + } + // L7 → adds L2 slots. + pool = subclassSpellSlots(SubclassArcaneTrickster, 7) + if pool[1] != 4 || pool[2] != 2 { + t.Errorf("AT L7 slots = %v, want {1:4, 2:2}", pool) + } + // Below the L5 gate. + if pool := subclassSpellSlots(SubclassArcaneTrickster, 4); len(pool) != 0 { + t.Errorf("AT L4 should grant no slots; got %v", pool) + } + // Other subclasses don't grant slots. + if pool := subclassSpellSlots(SubclassChampion, 10); len(pool) != 0 { + t.Errorf("Champion slots = %v, want empty", pool) + } +} + +func TestArcaneTrickster_MagicalAmbushDCBumpAtL7(t *testing.T) { + c := &DnDCharacter{ + Class: ClassRogue, Subclass: SubclassArcaneTrickster, Level: 7, + INT: 16, + } + plain := &DnDCharacter{ + Class: ClassRogue, Subclass: SubclassArcaneTrickster, Level: 6, + INT: 16, + } + got := spellSaveDC(c) + base := spellSaveDC(plain) + // L7 base = 8 + prof(3) + INT mod(3) = 14, +2 ambush = 16. + // L6 base = 8 + prof(3) + INT mod(3) = 14. + if got-base != 2 { + t.Errorf("L7 AT spellSaveDC = %d (L6 baseline %d), want +2 from Magical Ambush", got, base) + } +} + +func TestEnsureSpellsForCharacter_ArcaneTrickster(t *testing.T) { + setupAbilitiesTestDB(t) + uid := id.UserID("@at_spells:example") + c := &DnDCharacter{ + UserID: uid, Race: RaceElf, Class: ClassRogue, Subclass: SubclassArcaneTrickster, + Level: 5, STR: 8, DEX: 16, CON: 12, INT: 16, WIS: 10, CHA: 12, + HPMax: 25, HPCurrent: 25, ArmorClass: 14, + } + if err := ensureSpellsForCharacter(c); err != nil { + t.Fatal(err) + } + known, _ := listKnownSpells(uid) + if len(known) == 0 { + t.Fatal("AT L5 should receive default spells; got none") + } + // All defaults must be on the Mage list — that's the slice we draw from. + for _, k := range known { + s, ok := lookupSpell(k.SpellID) + if !ok { + t.Errorf("unknown spell ID granted: %s", k.SpellID) + continue + } + isMage := false + for _, cl := range s.Classes { + if cl == ClassMage { + isMage = true + } + } + if !isMage { + t.Errorf("AT default %s isn't on the Mage list", k.SpellID) + } + } + // Slot pool must reflect the subclass grant. + slots, _ := getSpellSlots(uid) + if slots[1][0] != 3 { + t.Errorf("AT L5 L1 slots = %d, want 3", slots[1][0]) + } +} + +// !cast gate: an AT Rogue can cast a Mage spell off the registry's class list, +// even though c.Class is Rogue. +func TestCast_ArcaneTricksterAcceptsMageSpells(t *testing.T) { + setupAbilitiesTestDB(t) + uid := id.UserID("@at_cast:example") + if err := createAdvCharacter(uid, "at_cast"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceElf, Class: ClassRogue, Subclass: SubclassArcaneTrickster, + Level: 5, STR: 8, DEX: 16, CON: 12, INT: 16, WIS: 10, CHA: 12, + HPMax: 25, HPCurrent: 25, ArmorClass: 14, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := ensureSpellsForCharacter(c); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{euro: &EuroPlugin{}} + if err := p.handleDnDCastCmd(MessageContext{Sender: uid}, "magic_missile"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.PendingCast == "" { + t.Error("magic_missile should be queued as pending_cast for AT Rogue") + } +} + +// !cast gate (negative): a plain Thief Rogue still can't cast. +func TestCast_PlainRogueRejected(t *testing.T) { + setupAbilitiesTestDB(t) + uid := id.UserID("@thief_cast:example") + if err := createAdvCharacter(uid, "thief_cast"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceElf, Class: ClassRogue, Subclass: SubclassThief, + Level: 5, STR: 8, DEX: 16, CON: 12, INT: 14, WIS: 10, CHA: 12, + HPMax: 25, HPCurrent: 25, ArmorClass: 14, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + p := &AdventurePlugin{euro: &EuroPlugin{}} + if err := p.handleDnDCastCmd(MessageContext{Sender: uid}, "magic_missile"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.PendingCast != "" { + t.Error("Thief Rogue should not be able to !cast") + } +} + // Surface-level sanity: rage shows up in characterActiveAbilities for a // Berserker but not a Champion. Tests the gating helper directly so we // don't need the resource-pool DB read that renderArmList does.