Files
gogobee/cmd/open5e-import/bestiary.go
prosolis 53be17e6fe D&D: import Open5e SRD bestiary as a raw staging table
fetch|gen bestiary subcommands vendor data/open5e/monsters.json (322
SRD monsters) and generate bestiary_srd_data.go — all 322 as raw SRD
stat blocks (HP/AC/ability scores/CR + per-attack damage dice).

This is a balance-baseline reference, not an engine roster: raw SRD
damage one-shots the solo player, so nothing here feeds combat. It's
what the future tuning pass reads against when deriving dndBestiary /
srdProfiles entries. XP is derived from CR (Open5e has no XP field).
2026-05-14 15:28:42 -07:00

343 lines
10 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// open5eMonster is the subset of the Open5e v1 monster schema we consume. The
// vendored JSON keeps the full payload; unknown fields are ignored here.
type open5eMonster struct {
Slug string `json:"slug"`
Name string `json:"name"`
Size string `json:"size"`
Type string `json:"type"`
CR float64 `json:"cr"`
ArmorClass int `json:"armor_class"`
HitPoints int `json:"hit_points"`
Speed map[string]any `json:"speed"`
Strength int `json:"strength"`
Dexterity int `json:"dexterity"`
Constitution int `json:"constitution"`
Intelligence int `json:"intelligence"`
Wisdom int `json:"wisdom"`
Charisma int `json:"charisma"`
Actions []open5eAction `json:"actions"`
LegendaryActions []open5eAction `json:"legendary_actions"`
SpecialAbilities []open5eNamedBlock `json:"special_abilities"`
}
// open5eAction is one entry in a monster's actions list. An entry is a weapon
// attack iff DamageDice is non-empty; Multiattack and utility actions leave it
// blank. AttackBonus/DamageBonus arrive as JSON null on non-attacks, which
// unmarshals to 0 — harmless, since DamageDice is the attack discriminator.
type open5eAction struct {
Name string `json:"name"`
Desc string `json:"desc"`
AttackBonus int `json:"attack_bonus"`
DamageDice string `json:"damage_dice"`
DamageBonus int `json:"damage_bonus"`
}
type open5eNamedBlock struct {
Name string `json:"name"`
}
type monstersPage struct {
Next string `json:"next"`
Results []open5eMonster `json:"results"`
}
// fetchMonsters pages through the SRD monster list and writes the full result
// set to data/open5e/monsters.json, pretty-printed for a reviewable diff.
func fetchMonsters() error {
client := &http.Client{Timeout: 30 * time.Second}
var all []open5eMonster
url := monstersAPIBase
for url != "" {
fmt.Fprintln(os.Stderr, "GET", url)
page, err := getMonstersPage(client, url)
if err != nil {
return err
}
all = append(all, page.Results...)
url = page.Next
}
fmt.Fprintf(os.Stderr, "fetched %d monsters\n", len(all))
if err := os.MkdirAll(filepath.Dir(monstersJSON), 0o755); err != nil {
return err
}
out, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
out = append(out, '\n')
return os.WriteFile(monstersJSON, out, 0o644)
}
func getMonstersPage(client *http.Client, url string) (*monstersPage, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var page monstersPage
if err := json.Unmarshal(body, &page); err != nil {
return nil, err
}
return &page, nil
}
// ── Staging-table classifier ─────────────────────────────────────────────────
// genStatBlock mirrors plugin.SRDStatBlock — a raw SRD stat block. These values
// are NOT engine-tuned: raw SRD damage one-shots gogobee's solo player (see
// bestiary_srd.go). The staging table is a balance baseline the hand-authored
// dndBestiary / srdProfiles tuning pass reads against, not a drop-in roster.
type genStatBlock struct {
Slug, Name string
Size, Type string
CR float64
XP int
HP, AC int
SpeedWalk int
STR, DEX int
CON, INT int
WIS, CHA int
Multiattack string
Attacks []genStatAttack
Traits []string
Legendary bool
}
type genStatAttack struct {
Name string
AttackBonus int
DamageDice string
DamageBonus int
AvgDamage int
DamageType string
}
// xpByCR is the SRD experience-by-challenge-rating table. Open5e exposes no XP
// field, but the tuning pass wants it, so it is derived here from CR.
var xpByCR = map[float64]int{
0: 10, 0.125: 25, 0.25: 50, 0.5: 100,
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900, 8: 3900,
9: 5000, 10: 5900, 11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000,
16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000, 21: 33000,
22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000,
28: 120000, 29: 135000, 30: 155000,
}
// genBestiary reads the vendored monsters.json, classifies every SRD monster
// into a raw staging stat block, and writes the generated Go data file.
func genBestiary() error {
raw, err := os.ReadFile(monstersJSON)
if err != nil {
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
}
var src []open5eMonster
if err := json.Unmarshal(raw, &src); err != nil {
return err
}
blocks := make([]genStatBlock, 0, len(src))
var attacks int
for _, m := range src {
b := classifyMonster(m)
attacks += len(b.Attacks)
blocks = append(blocks, b)
}
sort.Slice(blocks, func(i, j int) bool {
if blocks[i].CR != blocks[j].CR {
return blocks[i].CR < blocks[j].CR
}
return blocks[i].Slug < blocks[j].Slug
})
fmt.Fprintf(os.Stderr, "classified %d monsters (%d parsed attacks)\n", len(blocks), attacks)
out := emitBestiary(blocks)
if err := os.WriteFile(bestiaryGenGo, out, 0o644); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "wrote", bestiaryGenGo)
return nil
}
func classifyMonster(m open5eMonster) genStatBlock {
b := genStatBlock{
Slug: strings.ReplaceAll(m.Slug, "-", "_"),
Name: m.Name,
Size: strings.ToLower(strings.TrimSpace(m.Size)),
Type: strings.ToLower(strings.TrimSpace(m.Type)),
CR: m.CR,
XP: xpByCR[m.CR],
HP: m.HitPoints,
AC: m.ArmorClass,
STR: m.Strength, DEX: m.Dexterity, CON: m.Constitution,
INT: m.Intelligence, WIS: m.Wisdom, CHA: m.Charisma,
SpeedWalk: speedWalk(m.Speed),
Legendary: len(m.LegendaryActions) > 0,
}
for _, a := range m.Actions {
if strings.EqualFold(strings.TrimSpace(a.Name), "Multiattack") {
b.Multiattack = firstSentence(strings.TrimSpace(a.Desc), 200)
continue
}
if a.DamageDice == "" {
continue
}
atk := genStatAttack{
Name: a.Name,
AttackBonus: a.AttackBonus,
DamageDice: strings.ReplaceAll(a.DamageDice, " ", ""),
DamageBonus: a.DamageBonus,
}
atk.AvgDamage = int(avgDice(atk.DamageDice)) + a.DamageBonus
if mt := reDmgType.FindStringSubmatch(strings.ToLower(a.Desc)); mt != nil {
atk.DamageType = strings.ToLower(mt[1])
}
b.Attacks = append(b.Attacks, atk)
}
for _, sa := range m.SpecialAbilities {
if n := strings.TrimSpace(sa.Name); n != "" {
b.Traits = append(b.Traits, n)
}
}
return b
}
// speedWalk pulls the walking speed (in feet) out of Open5e's speed object,
// falling back to the fastest other movement mode for creatures with no walk
// speed (flyers, swimmers). Returns 0 when nothing parses.
func speedWalk(speed map[string]any) int {
asInt := func(v any) int {
if f, ok := v.(float64); ok {
return int(f)
}
return 0
}
if w := asInt(speed["walk"]); w > 0 {
return w
}
best := 0
for mode, v := range speed {
if mode == "walk" {
continue
}
if n := asInt(v); n > best {
best = n
}
}
return best
}
// avgDice returns the statistical average of an NdM dice expression. A bare or
// unparseable expression yields 0 — the staging table records what it can and
// leaves the rest for the tuning pass.
func avgDice(dice string) float64 {
i := strings.IndexByte(dice, 'd')
if i <= 0 {
return 0
}
n, err1 := strconv.Atoi(dice[:i])
m, err2 := strconv.Atoi(dice[i+1:])
if err1 != nil || err2 != nil || n <= 0 || m <= 0 {
return 0
}
return float64(n) * (float64(m) + 1) / 2
}
// ── Code emission ────────────────────────────────────────────────────────────
func emitBestiary(blocks []genStatBlock) []byte {
var b bytes.Buffer
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
//
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
// under CC-BY-4.0 — see NOTICE. Regenerate with:
//
// go run ./cmd/open5e-import fetch bestiary
// go run ./cmd/open5e-import gen bestiary
//
// This is the RAW SRD staging table — see bestiary_srd_staging.go for why these
// values are a balance baseline and not a drop-in engine roster.
package plugin
func buildSRDStagingBestiary() map[string]SRDStatBlock {
return map[string]SRDStatBlock{
`)
for _, m := range blocks {
fmt.Fprintf(&b, "\t\t%q: {Slug: %q, Name: %q", m.Slug, m.Slug, m.Name)
if m.Size != "" {
fmt.Fprintf(&b, ", Size: %q", m.Size)
}
if m.Type != "" {
fmt.Fprintf(&b, ", Type: %q", m.Type)
}
fmt.Fprintf(&b, ", CR: %s", strconv.FormatFloat(m.CR, 'g', -1, 64))
if m.XP != 0 {
fmt.Fprintf(&b, ", XP: %d", m.XP)
}
fmt.Fprintf(&b, ", HP: %d, AC: %d", m.HP, m.AC)
if m.SpeedWalk != 0 {
fmt.Fprintf(&b, ", SpeedWalk: %d", m.SpeedWalk)
}
fmt.Fprintf(&b, ", STR: %d, DEX: %d, CON: %d, INT: %d, WIS: %d, CHA: %d",
m.STR, m.DEX, m.CON, m.INT, m.WIS, m.CHA)
if m.Multiattack != "" {
fmt.Fprintf(&b, ", Multiattack: %q", m.Multiattack)
}
if len(m.Attacks) > 0 {
b.WriteString(", Attacks: []SRDStatAttack{")
for i, a := range m.Attacks {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "{Name: %q, AttackBonus: %d, DamageDice: %q, DamageBonus: %d, AvgDamage: %d",
a.Name, a.AttackBonus, a.DamageDice, a.DamageBonus, a.AvgDamage)
if a.DamageType != "" {
fmt.Fprintf(&b, ", DamageType: %q", a.DamageType)
}
b.WriteString("}")
}
b.WriteString("}")
}
if len(m.Traits) > 0 {
b.WriteString(", Traits: []string{")
for i, t := range m.Traits {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "%q", t)
}
b.WriteString("}")
}
if m.Legendary {
b.WriteString(", Legendary: true")
}
b.WriteString("},\n")
}
b.WriteString("\t}\n}\n")
return b.Bytes()
}