mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
D&D: import Open5e SRD magic items as a vendored reference registry
fetch/gen magicitems subcommands vendor data/open5e/magicitems.json (237 SRD items) and classify them into a generated registry. magic_items.go holds the MagicItem struct + Kind enum + an init-time overlay merge where a hand-authored entry wins on ID collision, mirroring the spell and bestiary imports. Not yet wired into zone loot or the shop — that integration is a deliberate follow-up.
This commit is contained in:
291
cmd/open5e-import/magicitems.go
Normal file
291
cmd/open5e-import/magicitems.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// open5eMagicItem is the subset of the Open5e v1 magicitems schema we consume.
|
||||
// The vendored JSON keeps the full payload; unknown fields are ignored here.
|
||||
type open5eMagicItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Desc string `json:"desc"`
|
||||
Rarity string `json:"rarity"`
|
||||
RequiresAttunement string `json:"requires_attunement"`
|
||||
}
|
||||
|
||||
type magicItemsPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eMagicItem `json:"results"`
|
||||
}
|
||||
|
||||
// fetchMagicItems pages through the SRD magic-item list and writes the full
|
||||
// result set to data/open5e/magicitems.json, pretty-printed for a reviewable
|
||||
// diff.
|
||||
func fetchMagicItems() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eMagicItem
|
||||
url := magicItemsAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getMagicItemsPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d magic items\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(magicItemsJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(magicItemsJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getMagicItemsPage(client *http.Client, url string) (*magicItemsPage, 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 magicItemsPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// ── Classifier ───────────────────────────────────────────────────────────────
|
||||
|
||||
// genMagicItem is a fully classified magic item ready for code emission. Fields
|
||||
// mirror plugin.MagicItem; enum-typed fields hold the Go identifier as a string.
|
||||
type genMagicItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind string // plugin.MagicItemKind identifier
|
||||
Rarity string // plugin.DnDRarity identifier
|
||||
Attunement bool
|
||||
Slot string // plugin.DnDSlot identifier; "" = not equippable / ambiguous
|
||||
Value int
|
||||
Desc string
|
||||
}
|
||||
|
||||
// rarityEnum maps an Open5e rarity string to the plugin's DnDRarity identifier.
|
||||
// "artifact" folds into Legendary (the plugin has no artifact tier); "varies"
|
||||
// and unknown/blank values fall back to Common — the conservative bucket.
|
||||
var rarityEnum = map[string]string{
|
||||
"common": "RarityCommon",
|
||||
"uncommon": "RarityUncommon",
|
||||
"rare": "RarityRare",
|
||||
"very rare": "RarityVeryRare",
|
||||
"legendary": "RarityLegendary",
|
||||
"artifact": "RarityLegendary",
|
||||
}
|
||||
|
||||
// rarityValue is the sell-value midpoint per rarity, aligned with the §8.1
|
||||
// rarity brackets the zone-loot tables already use. Keyed by the Open5e rarity
|
||||
// string so the classifier can read it directly.
|
||||
var rarityValue = map[string]int{
|
||||
"common": 50,
|
||||
"uncommon": 150,
|
||||
"rare": 750,
|
||||
"very rare": 2500,
|
||||
"legendary": 8000,
|
||||
"artifact": 8000,
|
||||
}
|
||||
|
||||
// kindByPrefix maps the leading word of Open5e's free-text Type field
|
||||
// ("Wondrous item", "Weapon (any sword)", "Armor (medium or heavy)") to a
|
||||
// plugin.MagicItemKind identifier.
|
||||
var kindByPrefix = map[string]string{
|
||||
"wondrous": "MagicItemWondrous",
|
||||
"weapon": "MagicItemWeapon",
|
||||
"armor": "MagicItemArmor",
|
||||
"ring": "MagicItemRing",
|
||||
"wand": "MagicItemWand",
|
||||
"rod": "MagicItemRod",
|
||||
"staff": "MagicItemStaff",
|
||||
"potion": "MagicItemPotion",
|
||||
"scroll": "MagicItemScroll",
|
||||
}
|
||||
|
||||
// genMagicItems reads the vendored magicitems.json, classifies every SRD magic
|
||||
// item, and writes the generated Go data file.
|
||||
func genMagicItems() error {
|
||||
raw, err := os.ReadFile(magicItemsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch magicitems` first?): %w", magicItemsJSON, err)
|
||||
}
|
||||
var src []open5eMagicItem
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := make([]genMagicItem, 0, len(src))
|
||||
var attunement, slotted int
|
||||
for _, m := range src {
|
||||
g := classifyMagicItem(m)
|
||||
if g.Attunement {
|
||||
attunement++
|
||||
}
|
||||
if g.Slot != "" {
|
||||
slotted++
|
||||
}
|
||||
items = append(items, g)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
fmt.Fprintf(os.Stderr, "classified %d magic items (%d need attunement, %d slotted)\n",
|
||||
len(items), attunement, slotted)
|
||||
|
||||
out := emitMagicItems(items)
|
||||
if err := os.WriteFile(magicItemsGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", magicItemsGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyMagicItem(m open5eMagicItem) genMagicItem {
|
||||
g := genMagicItem{
|
||||
ID: strings.ReplaceAll(m.Slug, "-", "_"),
|
||||
Name: m.Name,
|
||||
Attunement: strings.TrimSpace(m.RequiresAttunement) != "",
|
||||
Desc: firstSentence(strings.TrimSpace(m.Desc), 200),
|
||||
}
|
||||
|
||||
rarity := strings.ToLower(strings.TrimSpace(m.Rarity))
|
||||
if enum, ok := rarityEnum[rarity]; ok {
|
||||
g.Rarity = enum
|
||||
} else {
|
||||
g.Rarity = "RarityCommon"
|
||||
}
|
||||
if v, ok := rarityValue[rarity]; ok {
|
||||
g.Value = v
|
||||
} else {
|
||||
g.Value = rarityValue["common"]
|
||||
}
|
||||
|
||||
// Kind: first word of the Type field. Anything unrecognised (the SRD has a
|
||||
// handful of bare or oddly-prefixed entries) falls back to Wondrous.
|
||||
kindWord := ""
|
||||
if fields := strings.Fields(strings.ToLower(m.Type)); len(fields) > 0 {
|
||||
kindWord = fields[0]
|
||||
}
|
||||
if enum, ok := kindByPrefix[kindWord]; ok {
|
||||
g.Kind = enum
|
||||
} else {
|
||||
g.Kind = "MagicItemWondrous"
|
||||
}
|
||||
|
||||
g.Slot = inferSlot(g.Kind, m.Name)
|
||||
return g
|
||||
}
|
||||
|
||||
// inferSlot makes a best-effort guess at the equip slot. Weapons/staves go to
|
||||
// the main hand, wands/rods to the off hand, armor to the chest, rings to a
|
||||
// ring slot. Wondrous items are name-sniffed (cloak, boots, amulet, …) and
|
||||
// left blank when nothing matches. Potions and scrolls are consumables and
|
||||
// never carry a slot.
|
||||
func inferSlot(kind, name string) string {
|
||||
switch kind {
|
||||
case "MagicItemWeapon", "MagicItemStaff":
|
||||
return "DnDSlotMainHand"
|
||||
case "MagicItemWand", "MagicItemRod":
|
||||
return "DnDSlotOffHand"
|
||||
case "MagicItemArmor":
|
||||
return "DnDSlotChest"
|
||||
case "MagicItemRing":
|
||||
return "DnDSlotRing1"
|
||||
case "MagicItemPotion", "MagicItemScroll":
|
||||
return ""
|
||||
}
|
||||
// Wondrous item: sniff the name for a wearable noun.
|
||||
low := strings.ToLower(name)
|
||||
for _, m := range []struct {
|
||||
kw string
|
||||
slot string
|
||||
}{
|
||||
{"amulet", "DnDSlotAmulet"}, {"necklace", "DnDSlotAmulet"},
|
||||
{"medallion", "DnDSlotAmulet"}, {"periapt", "DnDSlotAmulet"},
|
||||
{"brooch", "DnDSlotAmulet"}, {"pendant", "DnDSlotAmulet"},
|
||||
{"ring", "DnDSlotRing1"},
|
||||
{"cloak", "DnDSlotChest"}, {"cape", "DnDSlotChest"},
|
||||
{"mantle", "DnDSlotChest"}, {"robe", "DnDSlotChest"},
|
||||
{"armor", "DnDSlotChest"}, {"vestments", "DnDSlotChest"},
|
||||
{"boots", "DnDSlotFeet"}, {"slippers", "DnDSlotFeet"},
|
||||
{"gauntlet", "DnDSlotHands"}, {"gloves", "DnDSlotHands"},
|
||||
{"bracers", "DnDSlotHands"},
|
||||
{"helm", "DnDSlotHead"}, {"hat", "DnDSlotHead"},
|
||||
{"crown", "DnDSlotHead"}, {"circlet", "DnDSlotHead"},
|
||||
{"goggles", "DnDSlotHead"}, {"eyes", "DnDSlotHead"},
|
||||
{"headband", "DnDSlotHead"},
|
||||
} {
|
||||
if strings.Contains(low, m.kw) {
|
||||
return m.slot
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emitMagicItems(items []genMagicItem) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD magic-item dump (data/open5e/magicitems.json), 5e SRD
|
||||
// content under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch magicitems
|
||||
// go run ./cmd/open5e-import gen magicitems
|
||||
//
|
||||
// Kind/Rarity/Slot/Value come from a free-text classifier; the hand-authored
|
||||
// magicItemOverlay overlays this slice and wins on ID collision, so any
|
||||
// misclassification here is correctable there rather than in the dump.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDMagicItems() []MagicItem {
|
||||
return []MagicItem{
|
||||
`)
|
||||
for _, m := range items {
|
||||
b.WriteString("\t\t{")
|
||||
fmt.Fprintf(&b, "ID: %q, Name: %q, Kind: %s, Rarity: %s", m.ID, m.Name, m.Kind, m.Rarity)
|
||||
if m.Attunement {
|
||||
b.WriteString(", Attunement: true")
|
||||
}
|
||||
if m.Slot != "" {
|
||||
fmt.Fprintf(&b, ", Slot: %s", m.Slot)
|
||||
}
|
||||
fmt.Fprintf(&b, ", Value: %d", m.Value)
|
||||
if m.Desc != "" {
|
||||
fmt.Fprintf(&b, ", Desc: %q", m.Desc)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
@@ -9,9 +9,8 @@
|
||||
// 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.
|
||||
// open5e-import fetch magicitems # vendor data/open5e/magicitems.json from the API
|
||||
// open5e-import gen magicitems # classify → internal/plugin/magic_items_srd_data.go
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -33,6 +32,10 @@ const (
|
||||
monstersJSON = "data/open5e/monsters.json"
|
||||
bestiaryGenGo = "internal/plugin/bestiary_srd_data.go"
|
||||
tunedGenGo = "internal/plugin/bestiary_tuned_data.go"
|
||||
|
||||
magicItemsAPIBase = "https://api.open5e.com/v1/magicitems/?document__slug=wotc-srd&limit=50"
|
||||
magicItemsJSON = "data/open5e/magicitems.json"
|
||||
magicItemsGenGo = "internal/plugin/magic_items_srd_data.go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -47,6 +50,8 @@ func main() {
|
||||
must(fetchSpells())
|
||||
case "bestiary":
|
||||
must(fetchMonsters())
|
||||
case "magicitems":
|
||||
must(fetchMagicItems())
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
@@ -58,6 +63,8 @@ func main() {
|
||||
must(genBestiary())
|
||||
case "tuned":
|
||||
must(genTuned())
|
||||
case "magicitems":
|
||||
must(genMagicItems())
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
@@ -67,7 +74,7 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: open5e-import fetch (spells|bestiary) | gen (spells|bestiary|tuned)")
|
||||
fmt.Fprintln(os.Stderr, "usage: open5e-import fetch (spells|bestiary|magicitems) | gen (spells|bestiary|tuned|magicitems)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user