mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Adv 2.0 D&D Phase 10 SUB2-AT: Arcane Trickster spellcasting
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) <noreply@anthropic.com>
This commit is contained in:
@@ -71,9 +71,9 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
if err != nil || c == nil {
|
if err != nil || c == nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
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(
|
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)))
|
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, " ")))
|
"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
|
classOK := false
|
||||||
for _, cl := range spell.Classes {
|
for _, cl := range spell.Classes {
|
||||||
if cl == c.Class {
|
if cl == effectiveClass {
|
||||||
classOK = true
|
classOK = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -131,6 +136,14 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
|||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"%s is not on the %s spell list.", spell.Name, titleClass(c.Class)))
|
"%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.
|
// Reaction spells deferred.
|
||||||
if spell.Effect == EffectReaction {
|
if spell.Effect == EffectReaction {
|
||||||
@@ -368,7 +381,7 @@ func (p *AdventurePlugin) handleDnDSpellsCmd(ctx MessageContext, args string) er
|
|||||||
if err != nil || c == nil {
|
if err != nil || c == nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
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(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"%s isn't a caster class.", titleClass(c.Class)))
|
"%s isn't a caster class.", titleClass(c.Class)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,47 @@ func slotsForClassLevel(class DnDClass, level int) map[int]int {
|
|||||||
return nil
|
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
|
// 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.
|
// between the doc's milestone rows so every level 1..20 has a defined pool.
|
||||||
func mageSlots(level int) map[int]int {
|
func mageSlots(level int) map[int]int {
|
||||||
@@ -265,13 +306,27 @@ func spellcastingMod(c *DnDCharacter) int {
|
|||||||
return abilityModifier(c.INT)
|
return abilityModifier(c.INT)
|
||||||
case ClassCleric, ClassRanger:
|
case ClassCleric, ClassRanger:
|
||||||
return abilityModifier(c.WIS)
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// spellSaveDC = 8 + proficiency bonus + spellcasting modifier.
|
// 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 {
|
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.
|
// spellAttackBonus = proficiency bonus + spellcasting modifier.
|
||||||
@@ -288,8 +343,51 @@ func classIsCaster(class DnDClass) bool {
|
|||||||
return false
|
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 ─────────────────────────────────────────────────────────
|
// ── 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
|
// setSpellSlotsForLevel writes the slot pool for a class+level, fully
|
||||||
// resetting any prior pool. Used by setup/migration/respec.
|
// resetting any prior pool. Used by setup/migration/respec.
|
||||||
func setSpellSlotsForLevel(userID id.UserID, class DnDClass, level int) error {
|
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
|
// enough leveled options to be functional. Players can swap via
|
||||||
// !spells learn (Mage) or !prepare (Cleric) once SP4 lands.
|
// !spells learn (Mage) or !prepare (Cleric) once SP4 lands.
|
||||||
func ensureSpellsForCharacter(c *DnDCharacter) error {
|
func ensureSpellsForCharacter(c *DnDCharacter) error {
|
||||||
if c == nil || !classIsCaster(c.Class) {
|
if !isSpellcaster(c) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
existing, err := listKnownSpells(c.UserID)
|
existing, err := listKnownSpells(c.UserID)
|
||||||
@@ -499,11 +597,14 @@ func ensureSpellsForCharacter(c *DnDCharacter) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(existing) > 0 {
|
if len(existing) > 0 {
|
||||||
// Refresh the slot pool in case level has changed since the prior
|
// Refresh the slot pool in case level/subclass has changed since the
|
||||||
// grant (covers level-up between sessions).
|
// prior grant (covers level-up between sessions and AT subclass pick).
|
||||||
return setSpellSlotsForLevel(c.UserID, c.Class, c.Level)
|
return setSpellSlotsForCharacter(c)
|
||||||
}
|
}
|
||||||
defaults := defaultKnownSpells(c.Class, c.Level)
|
defaults := defaultKnownSpells(c.Class, c.Level)
|
||||||
|
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||||||
|
defaults = arcaneTricksterDefaultSpells(c.Level)
|
||||||
|
}
|
||||||
for _, sid := range defaults {
|
for _, sid := range defaults {
|
||||||
// Cleric "prepares" daily — but prep system is SP4. Until then,
|
// Cleric "prepares" daily — but prep system is SP4. Until then,
|
||||||
// every default cleric spell is auto-prepared so !cast works.
|
// every default cleric spell is auto-prepared so !cast works.
|
||||||
@@ -511,7 +612,26 @@ func ensureSpellsForCharacter(c *DnDCharacter) error {
|
|||||||
return err
|
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
|
// defaultKnownSpells returns a sensible starter list for class+level. Tuned
|
||||||
@@ -615,6 +735,19 @@ func highestAvailableSlot(class DnDClass, level int) int {
|
|||||||
return high
|
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 ──────────────────────────────────────────────────────────
|
// ── Display helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func displaySpellName(spellID string) string {
|
func displaySpellName(spellID string) string {
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ func (p *AdventurePlugin) applySubclassChoice(
|
|||||||
// the player still gets the subclass; resources can be re-initialized
|
// the player still gets the subclass; resources can be re-initialized
|
||||||
// next long rest.
|
// next long rest.
|
||||||
_ = initSubclassResources(ctx.Sender, chosen)
|
_ = 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 {
|
if charged {
|
||||||
// Debit last; race vs. balance change is rare and strictly safer
|
// Debit last; race vs. balance change is rare and strictly safer
|
||||||
// to favor the player than risk taking euros without applying the
|
// to favor the player than risk taking euros without applying the
|
||||||
|
|||||||
@@ -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
|
// Surface-level sanity: rage shows up in characterActiveAbilities for a
|
||||||
// Berserker but not a Champion. Tests the gating helper directly so we
|
// Berserker but not a Champion. Tests the gating helper directly so we
|
||||||
// don't need the resource-pool DB read that renderArmList does.
|
// don't need the resource-pool DB read that renderArmList does.
|
||||||
|
|||||||
Reference in New Issue
Block a user