panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
// Command amp-distill parses the CubeCoders AMPTemplates repository (MIT
|
||||
// licensed, see CREDITS.md) into a single self-contained catalog.json the
|
||||
// panel can serve to its Library UI. It is a build-time tool: run it by hand
|
||||
// whenever the AMPTemplates clone is refreshed.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./tools/amp-distill \
|
||||
// -templates C:/Users/dbled/sources/AMPTemplates \
|
||||
// -modules ./modules \
|
||||
// -out ./controller/cmd/controller/static/catalog.json
|
||||
//
|
||||
// For every <game>.kvp it reads the sibling <game>ports.json /
|
||||
// <game>updates.json / <game>metaconfig.json and distills one CatalogEntry.
|
||||
// Malformed templates are skipped with a logged reason — the tool never
|
||||
// fails the whole run over one bad template.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CatalogEntry is one distilled game template.
|
||||
type CatalogEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
OS []string `json:"os,omitempty"`
|
||||
|
||||
Steam *SteamInfo `json:"steam,omitempty"`
|
||||
|
||||
Ports []PortEntry `json:"ports,omitempty"`
|
||||
|
||||
RCONAdapter string `json:"rcon_adapter,omitempty"` // source_rcon, telnet, stdio, ...
|
||||
AdminMethod string `json:"admin_method,omitempty"` // raw App.AdminMethod
|
||||
|
||||
ReadyPattern string `json:"ready_pattern,omitempty"`
|
||||
Events map[string]string `json:"events,omitempty"` // join/leave/chat -> Go regex
|
||||
|
||||
UpdateStages []UpdateStage `json:"update_stages,omitempty"`
|
||||
|
||||
ConfigFiles []string `json:"config_files,omitempty"`
|
||||
|
||||
// SteamLoginRequired: kvp App.SteamUpdateAnonymousLogin=False —
|
||||
// SteamCMD refuses `+login anonymous` for this title (DayZ/Arma class).
|
||||
SteamLoginRequired bool `json:"steam_login_required,omitempty"`
|
||||
|
||||
ArtSteamAppID string `json:"art_steam_app_id,omitempty"`
|
||||
Wine bool `json:"wine"` // needs Wine/Proton on a Linux host
|
||||
EntrypointHint string `json:"entrypoint_hint,omitempty"`
|
||||
|
||||
Ported bool `json:"ported,omitempty"` // already exists as a panel module
|
||||
ModuleID string `json:"module_id,omitempty"`
|
||||
|
||||
Provenance string `json:"provenance"` // source .kvp filename
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// SteamInfo carries the SteamCMD install identity.
|
||||
type SteamInfo struct {
|
||||
AppID string `json:"app_id"` // dedicated-server app id
|
||||
GameAppID string `json:"game_app_id,omitempty"` // client/game app id (art, SteamAppId env)
|
||||
Platform string `json:"platform,omitempty"` // "windows" when ForceDownloadPlatform=Windows
|
||||
}
|
||||
|
||||
// PortEntry is one declared network port.
|
||||
type PortEntry struct {
|
||||
Name string `json:"name"`
|
||||
Proto string `json:"proto"` // tcp / udp / both
|
||||
Port uint32 `json:"port"`
|
||||
}
|
||||
|
||||
// UpdateStage is one mapped install/update pipeline stage.
|
||||
type UpdateStage struct {
|
||||
Kind string `json:"kind"` // steamcmd, github, direct
|
||||
Name string `json:"name,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
AssetRegex string `json:"asset_regex,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
TargetPath string `json:"target_path,omitempty"`
|
||||
// Conditional stages (AMP UpdateSourceConditionSetting) are optional
|
||||
// extras like ValheimPlus/BepInEx — flagged so the UI can hide them.
|
||||
Conditional bool `json:"conditional,omitempty"`
|
||||
}
|
||||
|
||||
// ampPort mirrors the shape of <game>ports.json entries (recursive).
|
||||
type ampPort struct {
|
||||
Protocol string `json:"Protocol"`
|
||||
Port json.RawMessage `json:"Port"`
|
||||
Name string `json:"Name"`
|
||||
ChildPorts []ampPort `json:"ChildPorts"`
|
||||
}
|
||||
|
||||
// ampUpdateStage mirrors <game>updates.json entries.
|
||||
type ampUpdateStage struct {
|
||||
UpdateStageName string `json:"UpdateStageName"`
|
||||
UpdateSourcePlatform string `json:"UpdateSourcePlatform"`
|
||||
UpdateSource string `json:"UpdateSource"`
|
||||
UpdateSourceData string `json:"UpdateSourceData"`
|
||||
UpdateSourceArgs string `json:"UpdateSourceArgs"`
|
||||
UpdateSourceTarget string `json:"UpdateSourceTarget"`
|
||||
ForceDownloadPlatform string `json:"ForceDownloadPlatform"`
|
||||
UpdateSourceConditionSetting string `json:"UpdateSourceConditionSetting"`
|
||||
}
|
||||
|
||||
// ampMetaConfig mirrors <game>metaconfig.json entries (only the file path).
|
||||
type ampMetaConfig struct {
|
||||
ConfigFile string `json:"ConfigFile"`
|
||||
}
|
||||
|
||||
// moduleInfo is what we scrape from an existing modules/<id>/module.yaml.
|
||||
type moduleInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
AppIDs []string
|
||||
}
|
||||
|
||||
var namedGroupRe = regexp.MustCompile(`\(\?<([A-Za-z][A-Za-z0-9]*)>`)
|
||||
|
||||
// toGoRegex converts .NET named groups (?<name>...) to Go (?P<name>...).
|
||||
// Returns the converted pattern and whether it compiles as a Go regexp.
|
||||
func toGoRegex(p string) (string, bool) {
|
||||
if p == "" {
|
||||
return "", true
|
||||
}
|
||||
conv := namedGroupRe.ReplaceAllString(p, `(?P<$1>`)
|
||||
_, err := regexp.Compile(conv)
|
||||
return conv, err == nil
|
||||
}
|
||||
|
||||
func parseKVP(path string) (map[string]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kv := make(map[string]string)
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
i := strings.IndexByte(line, '=')
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:i])
|
||||
if _, seen := kv[key]; !seen { // first occurrence wins
|
||||
kv[key] = line[i+1:]
|
||||
}
|
||||
}
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func flattenPorts(in []ampPort, out *[]PortEntry, notes *[]string) {
|
||||
for _, p := range in {
|
||||
var num uint64
|
||||
var perr error
|
||||
s := strings.Trim(string(p.Port), `"`)
|
||||
if s != "" && s != "null" {
|
||||
num, perr = strconv.ParseUint(strings.TrimSpace(s), 10, 32)
|
||||
}
|
||||
if perr != nil || num == 0 || num > 65535 {
|
||||
*notes = append(*notes, fmt.Sprintf("skipped port %q: unparseable value %s", p.Name, string(p.Port)))
|
||||
} else {
|
||||
proto := strings.ToLower(p.Protocol)
|
||||
if proto != "tcp" && proto != "udp" {
|
||||
proto = "both"
|
||||
}
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("port-%d", num)
|
||||
}
|
||||
*out = append(*out, PortEntry{Name: name, Proto: proto, Port: uint32(num)})
|
||||
}
|
||||
flattenPorts(p.ChildPorts, out, notes)
|
||||
}
|
||||
}
|
||||
|
||||
func adapterFor(adminMethod string) string {
|
||||
switch adminMethod {
|
||||
case "SourceRCON":
|
||||
return "source_rcon"
|
||||
case "TelnetRCON", "Telnet":
|
||||
return "telnet"
|
||||
case "STDIO":
|
||||
return "stdio"
|
||||
case "BattlEyeRCON":
|
||||
return "battleye"
|
||||
case "QuakeRCON":
|
||||
return "quake_rcon"
|
||||
case "GoldSrcRCON":
|
||||
return "goldsrc_rcon"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// norm reduces a name to lowercase alphanumerics for fuzzy matching.
|
||||
func norm(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// moduleAliases maps our module id -> extra normalized names it answers to,
|
||||
// covering cases where the AMP template id differs from ours.
|
||||
var moduleAliases = map[string][]string{
|
||||
"7dtd": {"sevendaystodie", "sdtd"},
|
||||
"ark-sa": {"arksamin", "arksurvivalascended"},
|
||||
"dragonwilds": {"runescapedragonwilds"},
|
||||
"empyrion": {"empyriongalacticsurvival"},
|
||||
"conan-exiles": {"conanexiles"},
|
||||
}
|
||||
|
||||
var yamlAppIDRe = regexp.MustCompile(`(?m)^\s*app_id:\s*["']?(\d+)`)
|
||||
var yamlIDRe = regexp.MustCompile(`(?m)^id:\s*["']?([A-Za-z0-9._-]+)`)
|
||||
var yamlNameRe = regexp.MustCompile(`(?m)^name:\s*["']?([^"'\r\n]+)`)
|
||||
|
||||
func loadModules(dir string) ([]moduleInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Helper/infra modules that are not real game ports — never claim an
|
||||
// AMP template as "ported" via these (steamcmd-test uses HLDS app 90,
|
||||
// which would falsely mark half-life/counter-strike as ported).
|
||||
nonGame := map[string]bool{"demo": true, "steamcmd-test": true, "empyrion-bridge": true}
|
||||
var out []moduleInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || nonGame[e.Name()] {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name(), "module.yaml"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mi := moduleInfo{ID: e.Name()}
|
||||
if m := yamlIDRe.FindSubmatch(raw); m != nil {
|
||||
mi.ID = string(m[1])
|
||||
}
|
||||
if m := yamlNameRe.FindSubmatch(raw); m != nil {
|
||||
mi.Name = strings.TrimSpace(string(m[1]))
|
||||
}
|
||||
for _, m := range yamlAppIDRe.FindAllSubmatch(raw, -1) {
|
||||
mi.AppIDs = append(mi.AppIDs, string(m[1]))
|
||||
}
|
||||
out = append(out, mi)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matchModule returns the module id an entry corresponds to, or "".
|
||||
func matchModule(e *CatalogEntry, mods []moduleInfo) string {
|
||||
entryNames := map[string]bool{norm(e.ID): true, norm(e.Name): true}
|
||||
for _, m := range mods {
|
||||
// by steam app id (server app id or game app id)
|
||||
if e.Steam != nil {
|
||||
for _, id := range m.AppIDs {
|
||||
if id == e.Steam.AppID || id == e.Steam.GameAppID {
|
||||
return m.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
// by name heuristics
|
||||
names := []string{norm(m.ID), norm(m.Name)}
|
||||
names = append(names, moduleAliases[m.ID]...)
|
||||
for _, n := range names {
|
||||
if n != "" && entryNames[n] {
|
||||
return m.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func distillOne(dir, kvpFile string) (*CatalogEntry, error) {
|
||||
id := strings.TrimSuffix(kvpFile, ".kvp")
|
||||
kv, err := parseKVP(filepath.Join(dir, kvpFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read kvp: %w", err)
|
||||
}
|
||||
name := kv["Meta.DisplayName"]
|
||||
if name == "" {
|
||||
name = kv["App.DisplayName"]
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("no Meta.DisplayName/App.DisplayName")
|
||||
}
|
||||
e := &CatalogEntry{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Description: kv["Meta.Description"],
|
||||
AdminMethod: kv["App.AdminMethod"],
|
||||
RCONAdapter: adapterFor(kv["App.AdminMethod"]),
|
||||
Provenance: kvpFile,
|
||||
}
|
||||
if osList := kv["Meta.OS"]; osList != "" {
|
||||
for _, o := range strings.Split(osList, ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
e.OS = append(e.OS, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.SteamLoginRequired = strings.EqualFold(strings.TrimSpace(kv["App.SteamUpdateAnonymousLogin"]), "false")
|
||||
if hint := strings.TrimSpace(kv["App.CommandLineArgs"]); hint != "" {
|
||||
e.EntrypointHint = hint
|
||||
}
|
||||
if src := kv["Meta.DisplayImageSource"]; strings.HasPrefix(src, "steam:") {
|
||||
e.ArtSteamAppID = strings.TrimPrefix(src, "steam:")
|
||||
}
|
||||
|
||||
// Regexes: AppReady + join/leave/chat.
|
||||
if p, ok := toGoRegex(kv["Console.AppReadyRegex"]); ok {
|
||||
e.ReadyPattern = p
|
||||
} else {
|
||||
e.Notes = append(e.Notes, "ready_pattern dropped: does not compile as Go regexp")
|
||||
}
|
||||
e.Events = map[string]string{}
|
||||
for kind, key := range map[string]string{
|
||||
"join": "Console.UserJoinRegex",
|
||||
"leave": "Console.UserLeaveRegex",
|
||||
"chat": "Console.UserChatRegex",
|
||||
} {
|
||||
if raw := kv[key]; raw != "" {
|
||||
if p, ok := toGoRegex(raw); ok {
|
||||
e.Events[kind] = p
|
||||
} else {
|
||||
e.Notes = append(e.Notes, kind+" regex dropped: does not compile as Go regexp")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(e.Events) == 0 {
|
||||
e.Events = nil
|
||||
}
|
||||
|
||||
// Ports.
|
||||
if raw, err := os.ReadFile(filepath.Join(dir, id+"ports.json")); err == nil {
|
||||
var ports []ampPort
|
||||
if err := json.Unmarshal(raw, &ports); err != nil {
|
||||
e.Notes = append(e.Notes, "ports.json unparseable: "+err.Error())
|
||||
} else {
|
||||
flattenPorts(ports, &e.Ports, &e.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
// Update stages.
|
||||
wineSeen := false
|
||||
if raw, err := os.ReadFile(filepath.Join(dir, id+"updates.json")); err == nil {
|
||||
var stages []ampUpdateStage
|
||||
if err := json.Unmarshal(raw, &stages); err != nil {
|
||||
e.Notes = append(e.Notes, "updates.json unparseable: "+err.Error())
|
||||
} else {
|
||||
for _, s := range stages {
|
||||
if strings.Contains(strings.ToLower(s.UpdateSourceArgs), "wine") ||
|
||||
strings.Contains(strings.ToLower(s.UpdateStageName), "wine") {
|
||||
wineSeen = true
|
||||
}
|
||||
st := UpdateStage{Name: s.UpdateStageName, Conditional: s.UpdateSourceConditionSetting != ""}
|
||||
switch s.UpdateSource {
|
||||
case "SteamCMD":
|
||||
st.Kind = "steamcmd"
|
||||
st.AppID = s.UpdateSourceData
|
||||
if strings.EqualFold(s.ForceDownloadPlatform, "Windows") {
|
||||
st.Platform = "windows"
|
||||
}
|
||||
if e.Steam == nil && !st.Conditional {
|
||||
e.Steam = &SteamInfo{AppID: s.UpdateSourceData, GameAppID: s.UpdateSourceArgs, Platform: st.Platform}
|
||||
}
|
||||
case "GithubRelease":
|
||||
st.Kind = "github"
|
||||
st.Repo = s.UpdateSourceArgs
|
||||
st.AssetRegex = s.UpdateSourceData
|
||||
st.TargetPath = s.UpdateSourceTarget
|
||||
case "FetchURL":
|
||||
st.Kind = "direct"
|
||||
st.URL = s.UpdateSourceData
|
||||
st.TargetPath = s.UpdateSourceTarget
|
||||
if st.TargetPath == "" {
|
||||
st.TargetPath = s.UpdateSourceArgs
|
||||
}
|
||||
default:
|
||||
continue // Executable/CreateDirectory/etc. — entrypoint territory
|
||||
}
|
||||
e.UpdateStages = append(e.UpdateStages, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Config file paths from metaconfig.
|
||||
if raw, err := os.ReadFile(filepath.Join(dir, id+"metaconfig.json")); err == nil {
|
||||
var metas []ampMetaConfig
|
||||
if err := json.Unmarshal(raw, &metas); err == nil {
|
||||
seen := map[string]bool{}
|
||||
for _, m := range metas {
|
||||
if m.ConfigFile != "" && !seen[m.ConfigFile] {
|
||||
seen[m.ConfigFile] = true
|
||||
e.ConfigFiles = append(e.ConfigFiles, m.ConfigFile)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
e.Notes = append(e.Notes, "metaconfig.json unparseable: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Wine flag: Windows-only template, forced Windows depots, or explicit
|
||||
// wine bootstrap in the update pipeline.
|
||||
linuxNative := false
|
||||
for _, o := range e.OS {
|
||||
if strings.EqualFold(o, "Linux") {
|
||||
linuxNative = true
|
||||
}
|
||||
}
|
||||
e.Wine = wineSeen || !linuxNative || (e.Steam != nil && e.Steam.Platform == "windows")
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
templates := flag.String("templates", `C:\Users\dbled\sources\AMPTemplates`, "path to the AMPTemplates clone")
|
||||
modules := flag.String("modules", "modules", "path to the panel modules/ directory")
|
||||
out := flag.String("out", filepath.Join("controller", "cmd", "controller", "static", "catalog.json"), "output catalog path")
|
||||
flag.Parse()
|
||||
|
||||
mods, err := loadModules(*modules)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: load modules: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
entries, err := os.ReadDir(*templates)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: read templates dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var catalog []*CatalogEntry
|
||||
var skipped []string
|
||||
portedMap := map[string]string{}
|
||||
for _, de := range entries {
|
||||
if de.IsDir() || !strings.HasSuffix(de.Name(), ".kvp") {
|
||||
continue
|
||||
}
|
||||
e, err := distillOne(*templates, de.Name())
|
||||
if err != nil {
|
||||
skipped = append(skipped, fmt.Sprintf("%s: %v", de.Name(), err))
|
||||
continue
|
||||
}
|
||||
if mid := matchModule(e, mods); mid != "" {
|
||||
e.Ported = true
|
||||
e.ModuleID = mid
|
||||
portedMap[e.ID] = mid
|
||||
}
|
||||
catalog = append(catalog, e)
|
||||
}
|
||||
sort.Slice(catalog, func(i, j int) bool { return catalog[i].ID < catalog[j].ID })
|
||||
|
||||
buf, err := json.MarshalIndent(catalog, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: marshal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: mkdir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, buf, 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: write: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("catalog: %d entries -> %s (%d KB)\n", len(catalog), *out, len(buf)/1024)
|
||||
fmt.Printf("ported matches (%d):\n", len(portedMap))
|
||||
var ids []string
|
||||
for k := range portedMap {
|
||||
ids = append(ids, k)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, k := range ids {
|
||||
fmt.Printf(" %s -> module %s\n", k, portedMap[k])
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
fmt.Printf("skipped (%d):\n", len(skipped))
|
||||
for _, s := range skipped {
|
||||
fmt.Println(" " + s)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user