mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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).
343 lines
10 KiB
Go
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()
|
|
}
|