Files
gogobee/cmd/open5e-import/main.go
prosolis 908e2b0855 D&D: codified bestiary tuning pass — derive tuned roster from SRD staging
Adds `gen tuned` to cmd/open5e-import: a deterministic formula that scales
every raw SRD stat block down to an engine-ready DnDMonsterTemplate. HP/AC/
AttackBonus are verbatim SRD (AC clamped to the engine min 10); the Attack
stat is interpolated from attackByCRPoints, a CR→Attack anchor table lifted
from the hand-tuned dndBestiary (CR is the calibration axis the 2026-05-10
rebalance used — raw SRD per-hit damage is ignored). Speed/BlockRate are
coarse baselines from SpeedWalk/AC.

bestiary_tuned.go merges the 322 generated templates into dndBestiary, but
hand-authored roster entries win — the merge only fills IDs the roster does
not already define, so playtested numbers and wired abilities are untouched.

Abilities are deliberately not wired: every generated entry has a nil
Ability, with the SRD multiattack/trait text parked in Notes as raw material
for the follow-up ability-wiring pass.
2026-05-14 16:45:09 -07:00

156 lines
4.4 KiB
Go

// Command open5e-import vendors Open5e SRD data (CC-BY-4.0) into the repo and
// regenerates the Go data files the plugin compiles against.
//
// It is a pull-once / use-many tool — the generated files are committed, so
// the running bot has no runtime dependency on the Open5e API.
//
// open5e-import fetch spells # vendor data/open5e/spells.json from the API
// open5e-import gen spells # classify → internal/plugin/dnd_spells_srd_data.go
// open5e-import fetch bestiary # vendor data/open5e/monsters.json from the API
// open5e-import gen bestiary # classify → internal/plugin/bestiary_srd_data.go
// open5e-import gen tuned # tuning formula → internal/plugin/bestiary_tuned_data.go
//
// `equipment` subcommands are planned; the dispatch below is structured to
// grow into them without reshuffling.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
const (
spellsAPIBase = "https://api.open5e.com/v1/spells/?document__slug=wotc-srd&limit=50"
spellsJSON = "data/open5e/spells.json"
spellsGenGo = "internal/plugin/dnd_spells_srd_data.go"
monstersAPIBase = "https://api.open5e.com/v1/monsters/?document__slug=wotc-srd&limit=50"
monstersJSON = "data/open5e/monsters.json"
bestiaryGenGo = "internal/plugin/bestiary_srd_data.go"
tunedGenGo = "internal/plugin/bestiary_tuned_data.go"
)
func main() {
if len(os.Args) < 3 {
usage()
}
verb, noun := os.Args[1], os.Args[2]
switch verb {
case "fetch":
switch noun {
case "spells":
must(fetchSpells())
case "bestiary":
must(fetchMonsters())
default:
usage()
}
case "gen":
switch noun {
case "spells":
must(genSpells())
case "bestiary":
must(genBestiary())
case "tuned":
must(genTuned())
default:
usage()
}
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: open5e-import fetch (spells|bestiary) | gen (spells|bestiary|tuned)")
os.Exit(2)
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
// fields are ignored; the vendored JSON keeps the full payload.
type open5eSpell struct {
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc"`
HigherLevel string `json:"higher_level"`
Range string `json:"range"`
Material string `json:"material"`
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
Duration string `json:"duration"`
RequiresConcentration bool `json:"requires_concentration"`
CastingTime string `json:"casting_time"`
LevelInt int `json:"level_int"`
School string `json:"school"`
// SpellLists is the structured class list, but the SRD dump leaves it
// incomplete (no paladin entries at all). DndClass is the free-text
// "Druid, Wizard" field and is the more complete source — mapClasses
// unions both. Keep both fields vendored.
SpellLists []string `json:"spell_lists"`
DndClass string `json:"dnd_class"`
}
type spellsPage struct {
Next string `json:"next"`
Results []open5eSpell `json:"results"`
}
// fetchSpells pages through the SRD spell list and writes the full result set
// to data/open5e/spells.json, pretty-printed for a reviewable diff.
func fetchSpells() error {
client := &http.Client{Timeout: 30 * time.Second}
var all []open5eSpell
url := spellsAPIBase
for url != "" {
fmt.Fprintln(os.Stderr, "GET", url)
page, err := getSpellsPage(client, url)
if err != nil {
return err
}
all = append(all, page.Results...)
url = page.Next
}
fmt.Fprintf(os.Stderr, "fetched %d spells\n", len(all))
if err := os.MkdirAll(filepath.Dir(spellsJSON), 0o755); err != nil {
return err
}
out, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
out = append(out, '\n')
return os.WriteFile(spellsJSON, out, 0o644)
}
func getSpellsPage(client *http.Client, url string) (*spellsPage, 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 spellsPage
if err := json.Unmarshal(body, &page); err != nil {
return nil, err
}
return &page, nil
}