Files
gogobee/internal/plugin/dnd_spells_test.go
prosolis 6ef8b9fd0a D&D: import Open5e SRD spell lists, make the five casters playable
Rebuilds the cmd/open5e-import CLI (fetch/gen × spells) to vendor
data/open5e/spells.json (319 SRD spells) and generate
dnd_spells_srd_data.go (237 after the level>5 filter). mapClasses unions
the API's incomplete structured spell_lists field with the complete
free-text dnd_class field so all eight casters get spells.

dndSpellRegistry loads buildSRDSpellList() first; the hand-authored
buildSpellList() overlays it (hand wins on ID collision). Playable=true
flipped for Druid/Bard/Sorcerer/Warlock/Paladin, each with a
defaultKnownSpells case. TestDefaultKnownSpellsExistInRegistry now covers
all eight classes.

.gitignore: vendor data/open5e/ while keeping the rest of data/ ignored,
and anchor the open5e-import binary pattern so it stops swallowing the
cmd/open5e-import source dir. NOTICE adds CC-BY-4.0 / SRD attribution.
2026-05-14 15:18:18 -07:00

235 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,
ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin,
} {
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)
}
}