panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user