Panel — public source drop (v0.9.0)

Self-hostable game-server control panel: controller + agent + 26 game
modules. One-line install (prebuilt release, no Go required):

  curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:17:39 -07:00
commit a00bd620a1
2160 changed files with 300574 additions and 0 deletions
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
+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
}
+72
View File
@@ -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)
}
}
}
+159
View File
@@ -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)]...)
}
+170
View File
@@ -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
}