0a941f3ba6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
390 lines
11 KiB
Go
390 lines
11 KiB
Go
// 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
|
|
}
|