mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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.
235 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|