mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
The tuned bestiary previously left every generated entry with a nil Ability. abilityFromTraits now classifies each creature's SRD trait names against a priority-ordered rule table, mapping the most combat-defining trait onto a MonsterAbility effect (death_aoe, regenerate, spell_resist, evade, enrage, ...). Creatures whose traits are all non-combat stay nil. 165 of 322 entries get an ability.
132 lines
4.8 KiB
Go
132 lines
4.8 KiB
Go
package plugin
|
|
|
|
import "testing"
|
|
|
|
// TestTunedBestiarySRD_Populated guards the generated tuned table: every SRD
|
|
// monster should derive a structurally sane DnDMonsterTemplate. Regenerate with
|
|
// `go run ./cmd/open5e-import gen tuned` if the SRD set or formula changes.
|
|
func TestTunedBestiarySRD_Populated(t *testing.T) {
|
|
if len(tunedBestiarySRD) < 300 {
|
|
t.Fatalf("tunedBestiarySRD has %d entries, want the full SRD set (~322)", len(tunedBestiarySRD))
|
|
}
|
|
for id, m := range tunedBestiarySRD {
|
|
if m.ID != id {
|
|
t.Errorf("%s: ID field = %q, want map key", id, m.ID)
|
|
}
|
|
if m.Name == "" {
|
|
t.Errorf("%s: empty Name", id)
|
|
}
|
|
if m.HP <= 0 {
|
|
t.Errorf("%s: HP=%d, want positive", id, m.HP)
|
|
}
|
|
if m.AC < 10 {
|
|
t.Errorf("%s: AC=%d, want >= 10 (engine minimum)", id, m.AC)
|
|
}
|
|
if m.Attack < 1 {
|
|
t.Errorf("%s: Attack=%d, want >= 1", id, m.Attack)
|
|
}
|
|
if m.Speed < 6 || m.Speed > 18 {
|
|
t.Errorf("%s: Speed=%d, want within [6,18]", id, m.Speed)
|
|
}
|
|
// Ability is optional (nil when the creature's traits are all
|
|
// non-combat), but a wired one must be structurally sane.
|
|
if m.Ability != nil {
|
|
a := m.Ability
|
|
if a.Name == "" {
|
|
t.Errorf("%s: Ability has empty Name", id)
|
|
}
|
|
if !tunedAbilityPhases[a.Phase] {
|
|
t.Errorf("%s: Ability.Phase=%q, want a known combat phase", id, a.Phase)
|
|
}
|
|
if a.ProcChance <= 0 || a.ProcChance > 1 {
|
|
t.Errorf("%s: Ability.ProcChance=%v, want within (0,1]", id, a.ProcChance)
|
|
}
|
|
if !tunedAbilityEffects[a.Effect] {
|
|
t.Errorf("%s: Ability.Effect=%q, want an engine-recognised effect", id, a.Effect)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tunedAbilityPhases / tunedAbilityEffects are the values the trait classifier
|
|
// (abilityFromTraits in cmd/open5e-import/tuned.go) is allowed to emit. The
|
|
// effect set is the subset of applyAbility's vocabulary the classifier uses;
|
|
// if a new trait rule introduces another effect, add it here too.
|
|
var tunedAbilityPhases = map[string]bool{
|
|
"opening": true, "clash": true, "decisive": true, "any": true,
|
|
}
|
|
|
|
var tunedAbilityEffects = map[string]bool{
|
|
"death_aoe": true, "aoe": true, "survive_at_1": true, "regenerate": true,
|
|
"spell_resist": true, "bonus_damage": true, "stun": true, "enrage": true,
|
|
"retaliate": true, "advantage": true, "evade": true,
|
|
}
|
|
|
|
// TestTunedBestiarySRD_AbilityWiring spot-checks the trait→ability classifier:
|
|
// a creature with a combat trait gets the expected effect, and one whose traits
|
|
// are all non-combat stays nil.
|
|
func TestTunedBestiarySRD_AbilityWiring(t *testing.T) {
|
|
cases := []struct {
|
|
id string
|
|
effect string // "" means Ability should be nil
|
|
}{
|
|
{"troll", "regenerate"}, // Regeneration
|
|
{"goblin", "evade"}, // Nimble Escape
|
|
{"balor", "death_aoe"}, // Death Throes (priority over Magic Resistance)
|
|
{"lich", "aoe"}, // Spellcasting
|
|
{"awakened_shrub", ""}, // False Appearance — no rule, stays nil
|
|
{"badger", ""}, // Keen Smell — non-combat
|
|
}
|
|
for _, tc := range cases {
|
|
m, ok := tunedBestiarySRD[tc.id]
|
|
if !ok {
|
|
t.Errorf("%s missing from tuned bestiary", tc.id)
|
|
continue
|
|
}
|
|
switch {
|
|
case tc.effect == "" && m.Ability != nil:
|
|
t.Errorf("%s: Ability=%+v, want nil", tc.id, m.Ability)
|
|
case tc.effect != "" && m.Ability == nil:
|
|
t.Errorf("%s: Ability is nil, want effect %q", tc.id, tc.effect)
|
|
case tc.effect != "" && m.Ability.Effect != tc.effect:
|
|
t.Errorf("%s: Ability.Effect=%q, want %q", tc.id, m.Ability.Effect, tc.effect)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTunedBestiarySRD_MergedIntoRoster confirms the init-time merge ran and
|
|
// that hand-authored dndBestiary entries win over the generated baseline.
|
|
func TestTunedBestiarySRD_MergedIntoRoster(t *testing.T) {
|
|
// A tuned-only creature (no hand-authored entry) should be in the roster.
|
|
if _, ok := dndBestiary["tarrasque"]; !ok {
|
|
t.Error("tarrasque missing from dndBestiary — tuned merge did not run")
|
|
}
|
|
// Owlbear is hand-authored; the roster entry must keep the playtested
|
|
// values (and its wired Ability), not the generated baseline.
|
|
ob, ok := dndBestiary["owlbear"]
|
|
if !ok {
|
|
t.Fatal("owlbear missing from dndBestiary")
|
|
}
|
|
if ob.Ability == nil {
|
|
t.Error("owlbear lost its hand-authored Ability — tuned baseline clobbered the roster entry")
|
|
}
|
|
}
|
|
|
|
// TestAttackByCR_KnownAnchors spot-checks the Attack curve against playtested
|
|
// roster values so a formula regression surfaces as a mismatch.
|
|
func TestTunedBestiarySRD_KnownEntry(t *testing.T) {
|
|
ab, ok := tunedBestiarySRD["aboleth"]
|
|
if !ok {
|
|
t.Fatal("aboleth missing from tuned bestiary")
|
|
}
|
|
// HP/AC verbatim SRD; Attack from the CR-10 anchor; AttackBonus from the
|
|
// hardest-hitting attack (Tentacle, +9).
|
|
if ab.HP != 135 || ab.AC != 17 || ab.Attack != 16 || ab.AttackBonus != 9 {
|
|
t.Errorf("aboleth = HP %d / AC %d / Attack %d / AB %d, want 135 / 17 / 16 / 9",
|
|
ab.HP, ab.AC, ab.Attack, ab.AttackBonus)
|
|
}
|
|
if ab.XPValue != 5900 {
|
|
t.Errorf("aboleth XPValue = %d, want 5900", ab.XPValue)
|
|
}
|
|
}
|