Files
gogobee/internal/plugin/dnd_spells_test.go
prosolis 9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

232 lines
7.4 KiB
Go

package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// ── Registry coverage ────────────────────────────────────────────────────────
func TestSpellRegistryCoverage(t *testing.T) {
// Phase 9 ships 76 in-scope spells + 3 reaction spells in the registry.
// 79 total. Hard-bound so accidental drops surface in CI.
if got := len(dndSpellRegistry); got < 76 {
t.Errorf("spell registry has %d entries, want ≥76", got)
}
// Every spell must declare at least one class, a name, and a level 0..5.
for id, s := range dndSpellRegistry {
if s.Name == "" {
t.Errorf("spell %q has empty Name", id)
}
if len(s.Classes) == 0 {
t.Errorf("spell %q has no classes", id)
}
if s.Level < 0 || s.Level > 5 {
t.Errorf("spell %q level %d out of [0,5]", id, s.Level)
}
if s.Effect == "" {
t.Errorf("spell %q has empty Effect", id)
}
}
}
func TestEachClassHasSpellsAtEachAvailableLevel(t *testing.T) {
// Mage at L9: should have at least one spell at every level 0..5.
for _, lvl := range []int{0, 1, 2, 3, 4, 5} {
if !classHasSpellAtLevel(ClassMage, lvl) {
t.Errorf("Mage missing any L%d spell", lvl)
}
}
// Cleric at L9: 0..5.
for _, lvl := range []int{0, 1, 2, 3, 4, 5} {
if !classHasSpellAtLevel(ClassCleric, lvl) {
t.Errorf("Cleric missing any L%d spell", lvl)
}
}
// Ranger at L13: 0..3.
for _, lvl := range []int{0, 1, 2, 3} {
if !classHasSpellAtLevel(ClassRanger, lvl) {
t.Errorf("Ranger missing any L%d spell", lvl)
}
}
}
func classHasSpellAtLevel(class DnDClass, level int) bool {
for _, s := range dndSpellRegistry {
if s.Level != level {
continue
}
for _, c := range s.Classes {
if c == class {
return true
}
}
}
return false
}
func TestParseSpellLooseInput(t *testing.T) {
cases := []struct{ input, wantID string }{
{"fire_bolt", "fire_bolt"},
{"Fire Bolt", "fire_bolt"},
{"fire-bolt", "fire_bolt"},
{" Magic Missile ", "magic_missile"},
{"hunters_mark", "hunters_mark"},
}
for _, tc := range cases {
got, ok := parseSpell(tc.input)
if !ok {
t.Errorf("parseSpell(%q): not found", tc.input)
continue
}
if got.ID != tc.wantID {
t.Errorf("parseSpell(%q): id=%q, want %q", tc.input, got.ID, tc.wantID)
}
}
if _, ok := parseSpell("totally not a spell"); ok {
t.Errorf("parseSpell on garbage should miss")
}
}
// ── Slot tables ──────────────────────────────────────────────────────────────
func TestSlotsForClassLevel(t *testing.T) {
cases := []struct {
class DnDClass
level int
want map[int]int
}{
// Mage spec rows (gogobee_spell_system.md §1)
{ClassMage, 1, map[int]int{1: 2}},
{ClassMage, 5, map[int]int{1: 4, 2: 3, 3: 2}},
{ClassMage, 9, map[int]int{1: 4, 2: 3, 3: 3, 4: 3, 5: 1}},
// Cleric rows
{ClassCleric, 1, map[int]int{1: 2}},
{ClassCleric, 5, map[int]int{1: 4, 2: 3, 3: 2}},
// Ranger half-caster
{ClassRanger, 1, map[int]int{}},
{ClassRanger, 5, map[int]int{1: 3}},
{ClassRanger, 13, map[int]int{1: 3, 2: 2, 3: 1}},
// Non-caster
{ClassFighter, 5, nil},
}
for _, tc := range cases {
got := slotsForClassLevel(tc.class, tc.level)
if !mapEq(got, tc.want) {
t.Errorf("slotsForClassLevel(%s, %d) = %v, want %v",
tc.class, tc.level, got, tc.want)
}
}
}
func mapEq(a, b map[int]int) bool {
if (a == nil) != (b == nil) && (len(a)+len(b)) != 0 {
return false
}
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
// ── DC math ──────────────────────────────────────────────────────────────────
func TestSpellSaveDC(t *testing.T) {
// Mage L5, INT 18 (mod +4). Prof L5 = +3. DC = 8 + 3 + 4 = 15.
c := &DnDCharacter{Class: ClassMage, Level: 5, INT: 18}
if got := spellSaveDC(c); got != 15 {
t.Errorf("Mage L5 INT18 DC = %d, want 15", got)
}
// Cleric L9, WIS 16 (mod +3). Prof L9 = +4. DC = 8 + 4 + 3 = 15.
c = &DnDCharacter{Class: ClassCleric, Level: 9, WIS: 16}
if got := spellSaveDC(c); got != 15 {
t.Errorf("Cleric L9 WIS16 DC = %d, want 15", got)
}
// Ranger L4, WIS 14 (mod +2). Prof L4 = +2. DC = 8 + 2 + 2 = 12.
c = &DnDCharacter{Class: ClassRanger, Level: 4, WIS: 14}
if got := spellSaveDC(c); got != 12 {
t.Errorf("Ranger L4 WIS14 DC = %d, want 12", got)
}
// Non-caster falls back to 8 + prof + 0.
c = &DnDCharacter{Class: ClassFighter, Level: 5, INT: 18}
if got := spellSaveDC(c); got != 11 {
t.Errorf("Fighter L5 fallback DC = %d, want 11", got)
}
}
func TestSpellAttackBonus(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Level: 5, INT: 18}
if got := spellAttackBonus(c); got != 7 {
t.Errorf("Mage L5 INT18 atk = %d, want 7", got)
}
}
// ── Default known list ──────────────────────────────────────────────────────
func TestDefaultKnownSpellsExistInRegistry(t *testing.T) {
for _, class := range []DnDClass{ClassMage, ClassCleric, ClassRanger} {
for _, lvl := range []int{1, 3, 5, 9, 13, 20} {
for _, sid := range defaultKnownSpells(class, lvl) {
if _, ok := lookupSpell(sid); !ok {
t.Errorf("default for %s L%d references unknown spell %q",
class, lvl, sid)
}
}
}
}
}
func TestDefaultKnownSpellsScaleWithLevel(t *testing.T) {
// Higher-level Mage knows strictly more spells than L1.
low := len(defaultKnownSpells(ClassMage, 1))
hi := len(defaultKnownSpells(ClassMage, 9))
if hi <= low {
t.Errorf("Mage L9 known (%d) should exceed L1 (%d)", hi, low)
}
// Ranger L1 knows almost nothing (no spells until L2).
if got := len(defaultKnownSpells(ClassRanger, 1)); got > 2 {
t.Errorf("Ranger L1 default %d spells, want ≤2 cantrips", got)
}
}
// ── Concentration helper ────────────────────────────────────────────────────
func TestSetConcentrationSupersedes(t *testing.T) {
c := &DnDCharacter{}
prev := setConcentration(c, "bless", 0)
if prev != "" {
t.Errorf("first concentration: prev=%q, want empty", prev)
}
prev = setConcentration(c, "hunters_mark", 0)
if prev != "bless" {
t.Errorf("second concentration: prev=%q, want bless", prev)
}
if c.ConcentrationSpell != "hunters_mark" {
t.Errorf("after super: active=%q, want hunters_mark", c.ConcentrationSpell)
}
}
// ── Migration safety ────────────────────────────────────────────────────────
func TestEnsureSpellsForCharacterNonCasterIsNoOp(t *testing.T) {
c := &DnDCharacter{UserID: id.UserID("@test:fighter"), Class: ClassFighter, Level: 5}
if err := ensureSpellsForCharacter(c); err != nil {
// Note: this hits the DB; in env without DB, function should still
// return nil for non-caster before any DB call. Verify that early exit.
t.Errorf("ensureSpellsForCharacter on Fighter: %v", err)
}
}
func TestRangerNoSpellsBeforeLevel2(t *testing.T) {
if got := len(slotsForClassLevel(ClassRanger, 1)); got != 0 {
t.Errorf("Ranger L1 slot count = %d, want 0", got)
}
}