panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:25:11 -07:00
commit 03a281d009
2161 changed files with 300880 additions and 0 deletions
+389
View File
@@ -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
}