panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
// Package empyrionitems parses Empyrion's ItemsConfig.ecf — Eleon's text
|
||||
// config format — into a flat item index keyed by numeric ID. Used by the
|
||||
// panel controller to populate the EAH-style give-item picker, ID-to-name
|
||||
// lookups for inventory views, and category filtering.
|
||||
//
|
||||
// The format is brace-delimited with `Key: Value` properties, comment lines
|
||||
// starting with #, and entries opened by `{ Item Id: N, Name: X` or
|
||||
// `+ Item Id: N, Name: X` (the `+` form extends a prior block; semantically
|
||||
// the same for our purposes). Values may be quoted strings, raw tokens, or
|
||||
// scalar numbers — we keep them as strings and let callers coerce.
|
||||
//
|
||||
// We deliberately ignore nested `{ Child ... }` blocks except for tracking
|
||||
// the brace depth so we don't accidentally close the outer item early.
|
||||
package empyrionitems
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Item is a single Empyrion item / block / weapon entry. Fields populated
|
||||
// best-effort from properties seen in stock ItemsConfig.ecf; Extras catches
|
||||
// anything we didn't promote to a typed field so callers can still surface
|
||||
// scenario-specific keys (Reforged Eden adds plenty).
|
||||
type Item struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"` // canonical name token (e.g. "EnergyCellHydrogen")
|
||||
Ref string `json:"ref,omitempty"` // template/parent ref
|
||||
Category string `json:"category,omitempty"` // ingredients, components, food, weapons, ammo, etc
|
||||
Group string `json:"group,omitempty"` // displayed UI grouping
|
||||
Mass string `json:"mass,omitempty"` // kg, raw token
|
||||
Volume string `json:"volume,omitempty"` // L
|
||||
MarketPrice string `json:"marketPrice,omitempty"`
|
||||
StackSize string `json:"stack,omitempty"`
|
||||
Class string `json:"class,omitempty"` // Ranged / Tool / Item / Block
|
||||
HoldType string `json:"holdType,omitempty"`
|
||||
CustomIcon string `json:"customIcon,omitempty"`
|
||||
AllowAt string `json:"allowAt,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Extras map[string]string `json:"extras,omitempty"`
|
||||
}
|
||||
|
||||
// Index is a queryable item store.
|
||||
type Index struct {
|
||||
Items []Item
|
||||
byID map[int]*Item
|
||||
byName map[string]*Item
|
||||
}
|
||||
|
||||
// LoadFromFile reads ItemsConfig.ecf from disk.
|
||||
func LoadFromFile(path string) (*Index, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadFromReader(f)
|
||||
}
|
||||
|
||||
// LoadFromReaders parses multiple ECF streams into a single merged Index.
|
||||
// Order matters for ID collisions: later readers win, matching Empyrion's
|
||||
// own load order where BlocksConfig is read after ItemsConfig.
|
||||
func LoadFromReaders(readers ...io.Reader) (*Index, error) {
|
||||
merged := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}}
|
||||
for _, r := range readers {
|
||||
idx, err := LoadFromReader(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range idx.Items {
|
||||
it := idx.Items[i]
|
||||
merged.add(&it)
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// LoadFromReader parses an ItemsConfig.ecf stream.
|
||||
func LoadFromReader(r io.Reader) (*Index, error) {
|
||||
idx := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var (
|
||||
current *Item
|
||||
depth int // brace depth WITHIN an item (for child blocks like Child Recipe { ... })
|
||||
)
|
||||
|
||||
for sc.Scan() {
|
||||
line := stripComment(sc.Text())
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Open a new top-level entry: `{ Item Id: 12, ...`, `{ Block Id: 220, ...`,
|
||||
// `+ Item Id: ...`, `+ Block Id: ...`. Empyrion's ECF format uses
|
||||
// `Item`, `Block`, `Device` interchangeably for the entries we care
|
||||
// about — they're all addressable via the same numeric ID space.
|
||||
if current == nil && isEntryHeader(trim) {
|
||||
it := parseItemHeader(trim)
|
||||
if it != nil {
|
||||
current = it
|
||||
current.Extras = map[string]string{}
|
||||
depth = 1
|
||||
if strings.HasPrefix(trim, "+") {
|
||||
depth = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Inside an item.
|
||||
if current != nil {
|
||||
// Track child brace depth so we don't close the outer item early.
|
||||
opens := strings.Count(trim, "{")
|
||||
closes := strings.Count(trim, "}")
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
idx.add(current)
|
||||
current = nil
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
// `+ Item/Block` form has no closing brace; the next entry
|
||||
// header is its terminator.
|
||||
if isEntryHeader(trim) {
|
||||
idx.add(current)
|
||||
current = nil
|
||||
depth = 0
|
||||
it := parseItemHeader(trim)
|
||||
if it != nil {
|
||||
current = it
|
||||
current.Extras = map[string]string{}
|
||||
depth = 1
|
||||
if strings.HasPrefix(trim, "+") {
|
||||
depth = 0
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
parseProperty(current, trim)
|
||||
}
|
||||
}
|
||||
if current != nil {
|
||||
idx.add(current)
|
||||
}
|
||||
if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// isEntryHeader returns true for lines that open a top-level Item / Block /
|
||||
// Device entry. Empyrion's ECF format uses several keywords interchangeably:
|
||||
// `{ Item Id: 12, Name: Foo`
|
||||
// `{ Block Id: 220, Name: MetalPieces`
|
||||
// `{ Device Id: 1234, Name: ConstructorT2`
|
||||
// `+ Item Id: 12, Name: Foo`
|
||||
// `+Block Id: 12, ...`
|
||||
// `{ +Block Name: Foo, Ref: Bar` (additive — name-only, no Id)
|
||||
func isEntryHeader(s string) bool {
|
||||
for _, p := range []string{
|
||||
"{ Item ", "{ Block ", "{ Device ",
|
||||
"+ Item ", "+ Block ", "+ Device ",
|
||||
"{Item ", "{Block ", "{Device ",
|
||||
"+Item ", "+Block ", "+Device ",
|
||||
"{ +Item ", "{ +Block ", "{ +Device ",
|
||||
"{+Item ", "{+Block ", "{+Device ",
|
||||
} {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseItemHeader handles entry headers in any of the forms isEntryHeader
|
||||
// recognizes and returns the partial Item populated from Id/Name/Ref keys.
|
||||
func parseItemHeader(line string) *Item {
|
||||
body := line
|
||||
for _, p := range []string{
|
||||
"{ +Item ", "{ +Block ", "{ +Device ",
|
||||
"{+Item ", "{+Block ", "{+Device ",
|
||||
"{ Item ", "{ Block ", "{ Device ",
|
||||
"+ Item ", "+ Block ", "+ Device ",
|
||||
"{Item ", "{Block ", "{Device ",
|
||||
"+Item ", "+Block ", "+Device ",
|
||||
} {
|
||||
body = strings.TrimPrefix(body, p)
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
it := &Item{}
|
||||
for _, kv := range splitTopLevel(body, ',') {
|
||||
parts := strings.SplitN(strings.TrimSpace(kv), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
switch key {
|
||||
case "Id":
|
||||
if id, err := strconv.Atoi(val); err == nil {
|
||||
it.ID = id
|
||||
}
|
||||
case "Name":
|
||||
it.Name = val
|
||||
case "Ref":
|
||||
it.Ref = val
|
||||
}
|
||||
}
|
||||
// Name-only `+Block Name: Foo, Ref: Bar` entries (additive overrides) are
|
||||
// kept only if we can resolve their ID later via the Ref-target. For now
|
||||
// we drop them since they're a small minority and don't affect the picker.
|
||||
if it.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
func parseProperty(it *Item, line string) {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
// Trim trailing comment; key/value can be followed by inline `# ...`.
|
||||
val = stripComment(val)
|
||||
val = strings.TrimSpace(val)
|
||||
switch strings.ToLower(key) {
|
||||
case "category":
|
||||
it.Category = val
|
||||
case "group":
|
||||
it.Group = val
|
||||
case "mass":
|
||||
it.Mass = val
|
||||
case "volume":
|
||||
it.Volume = val
|
||||
case "marketprice":
|
||||
it.MarketPrice = val
|
||||
case "stacksize":
|
||||
it.StackSize = val
|
||||
case "class":
|
||||
it.Class = val
|
||||
case "holdtype":
|
||||
it.HoldType = val
|
||||
case "customicon":
|
||||
it.CustomIcon = val
|
||||
case "allowat":
|
||||
it.AllowAt = val
|
||||
case "description":
|
||||
it.Description = val
|
||||
default:
|
||||
if it.Extras == nil {
|
||||
it.Extras = map[string]string{}
|
||||
}
|
||||
it.Extras[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *Index) add(it *Item) {
|
||||
if it == nil || it.Name == "" {
|
||||
return
|
||||
}
|
||||
// duplicate item IDs use the LAST seen entry (matches Empyrion's own override semantics).
|
||||
if it.ID > 0 {
|
||||
idx.byID[it.ID] = it
|
||||
}
|
||||
idx.byName[strings.ToLower(it.Name)] = it
|
||||
idx.Items = append(idx.Items, *it)
|
||||
}
|
||||
|
||||
// ByID returns the item with the given numeric ID, or nil.
|
||||
func (idx *Index) ByID(id int) *Item {
|
||||
if it, ok := idx.byID[id]; ok {
|
||||
c := *it
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ByName looks up an item by its canonical name token (case-insensitive).
|
||||
func (idx *Index) ByName(name string) *Item {
|
||||
if it, ok := idx.byName[strings.ToLower(name)]; ok {
|
||||
c := *it
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search returns items whose name OR description contains the query (case-
|
||||
// insensitive). Empty query returns all items. limit <= 0 returns all matches.
|
||||
func (idx *Index) Search(q string, category string, limit int) []Item {
|
||||
q = strings.ToLower(strings.TrimSpace(q))
|
||||
cat := strings.ToLower(strings.TrimSpace(category))
|
||||
out := make([]Item, 0, 64)
|
||||
for _, it := range idx.Items {
|
||||
if cat != "" && strings.ToLower(it.Category) != cat {
|
||||
continue
|
||||
}
|
||||
if q != "" {
|
||||
lname := strings.ToLower(it.Name)
|
||||
ldesc := strings.ToLower(it.Description)
|
||||
if !strings.Contains(lname, q) && !strings.Contains(ldesc, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, it)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Categories returns the distinct, sorted-by-frequency categories present in the index.
|
||||
func (idx *Index) Categories() []string {
|
||||
counts := map[string]int{}
|
||||
for _, it := range idx.Items {
|
||||
if it.Category != "" {
|
||||
counts[it.Category]++
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(counts))
|
||||
for k := range counts {
|
||||
out = append(out, k)
|
||||
}
|
||||
// stable order: descending by count, then alpha
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && (counts[out[j]] > counts[out[j-1]] || (counts[out[j]] == counts[out[j-1]] && out[j] < out[j-1])); j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripComment(s string) string {
|
||||
// `#` starts a comment unless inside quotes — items config doesn't use `#`
|
||||
// inside string values in stock content, so a naive strip is safe.
|
||||
if i := strings.Index(s, "#"); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func stripQuotes(s string) string {
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// splitTopLevel splits on the given separator while ignoring separators
|
||||
// inside braces or quotes. Used to break "Id: 12, Name: Foo" into pieces
|
||||
// without accidentally splitting things like `BuffMod: "a, b, c"`.
|
||||
func splitTopLevel(s string, sep byte) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
depth := 0
|
||||
inQuote := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '"':
|
||||
inQuote = !inQuote
|
||||
cur.WriteByte(c)
|
||||
case !inQuote && (c == '{' || c == '['):
|
||||
depth++
|
||||
cur.WriteByte(c)
|
||||
case !inQuote && (c == '}' || c == ']'):
|
||||
depth--
|
||||
cur.WriteByte(c)
|
||||
case !inQuote && depth == 0 && c == sep:
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package empyrionitems
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadItemsConfig(t *testing.T) {
|
||||
idx, err := LoadFromFile("ItemsConfig.ecf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile: %v", err)
|
||||
}
|
||||
if len(idx.Items) < 30 {
|
||||
t.Fatalf("expected at least 30 items in ItemsConfig.ecf, got %d", len(idx.Items))
|
||||
}
|
||||
t.Logf("ItemsConfig: parsed %d items", len(idx.Items))
|
||||
|
||||
// Spot checks against entries seen in the live ItemsConfig.ecf header sample.
|
||||
cases := []struct {
|
||||
id int
|
||||
name string
|
||||
}{
|
||||
{1, "MeleePlayer"},
|
||||
{12, "Fillertool"},
|
||||
{220, "MetalPieces"},
|
||||
{240, "EnergyCellHydrogen"},
|
||||
{398, "XP"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
it := idx.ByID(tc.id)
|
||||
if it == nil {
|
||||
t.Errorf("id %d not found", tc.id)
|
||||
continue
|
||||
}
|
||||
if it.Name != tc.name {
|
||||
t.Errorf("id %d: name %q, want %q", tc.id, it.Name, tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBlocksConfig(t *testing.T) {
|
||||
if _, err := os.Stat("BlocksConfig.ecf"); os.IsNotExist(err) {
|
||||
t.Skip("BlocksConfig.ecf not present alongside test")
|
||||
}
|
||||
idx, err := LoadFromFile("BlocksConfig.ecf")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromFile: %v", err)
|
||||
}
|
||||
if len(idx.Items) < 1500 {
|
||||
t.Fatalf("expected >1500 entries in BlocksConfig.ecf, got %d", len(idx.Items))
|
||||
}
|
||||
t.Logf("BlocksConfig: parsed %d entries", len(idx.Items))
|
||||
|
||||
// Spot check by name (IDs evolve across versions; names are stable).
|
||||
for _, name := range []string{"Air", "Stone", "Grass"} {
|
||||
if idx.ByName(name) == nil {
|
||||
t.Errorf("expected to find %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Search must return matches for common item families.
|
||||
hits := idx.Search("Constructor", "", 50)
|
||||
if len(hits) < 2 {
|
||||
t.Errorf("search 'Constructor' should hit multiple devices, got %d", len(hits))
|
||||
}
|
||||
for _, h := range hits[:1] {
|
||||
if !strings.Contains(strings.ToLower(h.Name), "constructor") {
|
||||
t.Errorf("unexpected match: %+v", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package empyrionitems
|
||||
|
||||
// Loot group parser for Empyrion's `LootGroups.ecf`.
|
||||
// Format:
|
||||
//
|
||||
// { +LootGroup Name: EscapePodEasy
|
||||
// Count: all
|
||||
// Item_0: EmergencyRations, param1: 1
|
||||
// Item_1: WaterBottle, param1: 5
|
||||
// ...
|
||||
// }
|
||||
//
|
||||
// param1 = count (or "low-high" range), param2 = optional probability.
|
||||
// Item names are canonical Item.Name tokens.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LootEntry struct {
|
||||
Item string `json:"item"`
|
||||
Count string `json:"count,omitempty"`
|
||||
Probability string `json:"probability,omitempty"`
|
||||
}
|
||||
|
||||
type LootGroup struct {
|
||||
Name string `json:"name"`
|
||||
Count string `json:"count,omitempty"`
|
||||
Items []LootEntry `json:"items"`
|
||||
}
|
||||
|
||||
type LootIndex struct {
|
||||
Groups []LootGroup
|
||||
byName map[string]*LootGroup
|
||||
byItem map[string][]string // ingredientLower → group names
|
||||
}
|
||||
|
||||
func LoadLootFromFile(path string) (*LootIndex, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadLootFromReader(f)
|
||||
}
|
||||
|
||||
func LoadLootFromReader(r io.Reader) (*LootIndex, error) {
|
||||
idx := &LootIndex{byName: map[string]*LootGroup{}, byItem: map[string][]string{}}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var current *LootGroup
|
||||
var depth int
|
||||
|
||||
for sc.Scan() {
|
||||
line := stripComment(sc.Text())
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
if strings.HasPrefix(trim, "{ +LootGroup ") || strings.HasPrefix(trim, "{+LootGroup ") || strings.HasPrefix(trim, "{ LootGroup ") {
|
||||
body := strings.TrimPrefix(trim, "{ +LootGroup ")
|
||||
body = strings.TrimPrefix(body, "{+LootGroup ")
|
||||
body = strings.TrimPrefix(body, "{ LootGroup ")
|
||||
current = &LootGroup{}
|
||||
for _, kv := range splitTopLevel(body, ',') {
|
||||
parts := strings.SplitN(strings.TrimSpace(kv), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(parts[0])
|
||||
v := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
if strings.EqualFold(k, "name") {
|
||||
current.Name = v
|
||||
}
|
||||
}
|
||||
depth = 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
opens := strings.Count(trim, "{")
|
||||
closes := strings.Count(trim, "}")
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
if current.Name != "" {
|
||||
cp := *current
|
||||
idx.Groups = append(idx.Groups, cp)
|
||||
gp := &idx.Groups[len(idx.Groups)-1]
|
||||
idx.byName[strings.ToLower(cp.Name)] = gp
|
||||
for _, it := range cp.Items {
|
||||
low := strings.ToLower(it.Item)
|
||||
idx.byItem[low] = append(idx.byItem[low], cp.Name)
|
||||
}
|
||||
}
|
||||
current = nil
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(trim, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripComment(strings.TrimSpace(parts[1]))
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(key), "item_") {
|
||||
// Item_N: Name, param1: count, param2: probability
|
||||
le := LootEntry{}
|
||||
pieces := splitTopLevel(val, ',')
|
||||
for i, p := range pieces {
|
||||
p = strings.TrimSpace(p)
|
||||
if i == 0 {
|
||||
le.Item = stripQuotes(p)
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(p, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
kk := strings.ToLower(strings.TrimSpace(kv[0]))
|
||||
vv := strings.TrimSpace(kv[1])
|
||||
switch kk {
|
||||
case "param1", "param":
|
||||
le.Count = vv
|
||||
case "param2", "xdata":
|
||||
le.Probability = vv
|
||||
}
|
||||
}
|
||||
if le.Item != "" {
|
||||
current.Items = append(current.Items, le)
|
||||
}
|
||||
} else if strings.EqualFold(key, "Count") {
|
||||
current.Count = strings.TrimSpace(val)
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (idx *LootIndex) ByName(name string) *LootGroup {
|
||||
if g, ok := idx.byName[strings.ToLower(name)]; ok {
|
||||
c := *g
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *LootIndex) DroppedBy(itemName string) []string {
|
||||
return append([]string{}, idx.byItem[strings.ToLower(itemName)]...)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package empyrionitems
|
||||
|
||||
// Template (recipe) parser for Empyrion's `Templates.ecf`.
|
||||
// Format snippet:
|
||||
//
|
||||
// { +Template Name: PlasticMaterial
|
||||
// BaseItem: true
|
||||
// CraftTime: 2
|
||||
// Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC"
|
||||
// { Child Inputs
|
||||
// CrushedStone: 1
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// We extract Name + outer props (CraftTime, Target, BaseItem,
|
||||
// OutputCount, etc.) and the Inputs map.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Template struct {
|
||||
Name string `json:"name"`
|
||||
BaseItem bool `json:"baseItem,omitempty"`
|
||||
CraftTime string `json:"craftTime,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
OutputCount string `json:"outputCount,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Inputs map[string]string `json:"inputs"` // ingredientName → count token
|
||||
Extras map[string]string `json:"extras,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateIndex struct {
|
||||
Templates []Template
|
||||
byName map[string]*Template
|
||||
}
|
||||
|
||||
func LoadTemplatesFromFile(path string) (*TemplateIndex, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadTemplatesFromReader(f)
|
||||
}
|
||||
|
||||
func LoadTemplatesFromReader(r io.Reader) (*TemplateIndex, error) {
|
||||
idx := &TemplateIndex{byName: map[string]*Template{}}
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
|
||||
var current *Template
|
||||
var inInputs bool
|
||||
var depth int
|
||||
|
||||
for sc.Scan() {
|
||||
line := stripComment(sc.Text())
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
if strings.HasPrefix(trim, "{ +Template ") || strings.HasPrefix(trim, "{+Template ") || strings.HasPrefix(trim, "{ Template ") {
|
||||
body := strings.TrimPrefix(trim, "{ +Template ")
|
||||
body = strings.TrimPrefix(body, "{+Template ")
|
||||
body = strings.TrimPrefix(body, "{ Template ")
|
||||
body = strings.TrimSpace(body)
|
||||
current = &Template{Inputs: map[string]string{}, Extras: map[string]string{}}
|
||||
for _, kv := range splitTopLevel(body, ',') {
|
||||
parts := strings.SplitN(strings.TrimSpace(kv), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(parts[0])
|
||||
v := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
switch strings.ToLower(k) {
|
||||
case "name":
|
||||
current.Name = v
|
||||
case "ref":
|
||||
current.Ref = v
|
||||
}
|
||||
}
|
||||
depth = 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
opens := strings.Count(trim, "{")
|
||||
closes := strings.Count(trim, "}")
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
if current.Name != "" {
|
||||
cp := *current
|
||||
idx.Templates = append(idx.Templates, cp)
|
||||
idx.byName[strings.ToLower(cp.Name)] = &idx.Templates[len(idx.Templates)-1]
|
||||
}
|
||||
current = nil
|
||||
inInputs = false
|
||||
depth = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// Track child blocks like `{ Child Inputs`.
|
||||
if strings.HasPrefix(trim, "{ Child Inputs") || strings.HasPrefix(trim, "{Child Inputs") {
|
||||
inInputs = true
|
||||
continue
|
||||
}
|
||||
if inInputs && strings.HasPrefix(trim, "}") {
|
||||
inInputs = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Property line.
|
||||
parts := strings.SplitN(trim, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := stripQuotes(strings.TrimSpace(parts[1]))
|
||||
val = strings.TrimSpace(stripComment(val))
|
||||
|
||||
if inInputs {
|
||||
current.Inputs[key] = val
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "baseitem":
|
||||
current.BaseItem = strings.EqualFold(val, "true")
|
||||
case "crafttime":
|
||||
current.CraftTime = val
|
||||
case "target":
|
||||
current.Target = val
|
||||
case "outputcount":
|
||||
current.OutputCount = val
|
||||
default:
|
||||
current.Extras[key] = val
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (idx *TemplateIndex) ByName(name string) *Template {
|
||||
if t, ok := idx.byName[strings.ToLower(name)]; ok {
|
||||
c := *t
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsedIn returns the list of templates whose inputs include the named item.
|
||||
func (idx *TemplateIndex) UsedIn(itemName string) []Template {
|
||||
want := strings.ToLower(itemName)
|
||||
out := []Template{}
|
||||
for _, t := range idx.Templates {
|
||||
for k := range t.Inputs {
|
||||
if strings.ToLower(k) == want {
|
||||
out = append(out, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user