package main import ( "encoding/json" "errors" "flag" "fmt" "math" "os" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" ) // Game fields in the order they are written to game.yaml. Panel-owned business settings // (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out. var gameFields = []string{ "id", "name", "defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", "installMethod", "installConfig", "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", "agnosticCommandConfig", "clusterConfig", "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb", "publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey", "aiCoverPrompt", } const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n" const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" + "# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n" func runImport(args []string) error { fs := flag.NewFlagSet("import", flag.ExitOnError) gamesDir := fs.String("games", "games", "directory to write one folder per game into") all := fs.Bool("all", false, "also import games that are disabled in the panel") fs.Parse(args) if fs.NArg() != 1 { return errors.New("import needs the path to a GET /api/v1/admin/games export") } raw, err := os.ReadFile(fs.Arg(0)) if err != nil { return err } var games []map[string]any if err := json.Unmarshal(raw, &games); err != nil { return fmt.Errorf("export is not a JSON array of games: %w", err) } for _, g := range games { id, _ := g["id"].(string) if id == "" { return errors.New("export contains a game without an id") } if g["enabled"] == false && !*all { fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id) continue } dir := filepath.Join(*gamesDir, id) if err := os.MkdirAll(dir, 0o755); err != nil { return err } if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil { return fmt.Errorf("%s: %w", id, err) } testPath := filepath.Join(dir, "test.yaml") if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) { if err := writeTest(testPath, g); err != nil { return fmt.Errorf("%s: %w", id, err) } } fmt.Printf("wrote %s\n", dir) } return nil } func writeGame(path string, g map[string]any) error { doc := &yaml.Node{Kind: yaml.MappingNode} for _, f := range gameFields { v, ok := g[f] if !ok || isEmpty(v) { continue } val, err := toNode(normalize(v)) if err != nil { return err } doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val) } return writeYAML(path, gameHeader, doc) } // writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method. func writeTest(path string, g map[string]any) error { cfg := map[string]any{} for key, p := range schemaProperties(g) { if prop, ok := p.(map[string]any); ok { if c, ok := prop["x-consent"]; ok && c != false { cfg[key] = true } } } timeout := 900 if g["installMethod"] == "STEAMCMD" { timeout = 2400 } test := map[string]any{"timeoutSec": timeout} if len(cfg) > 0 { test["config"] = cfg } var doc yaml.Node if err := doc.Encode(test); err != nil { return err } return writeYAML(path, testHeader, &doc) } func writeYAML(path, header string, doc *yaml.Node) error { var b strings.Builder b.WriteString(header) enc := yaml.NewEncoder(&b) enc.SetIndent(2) if err := enc.Encode(doc); err != nil { return err } enc.Close() return os.WriteFile(path, []byte(b.String()), 0o644) } // toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of // whitespace, like a "\n" config separator, into "" and the value is silently lost. func toNode(v any) (*yaml.Node, error) { switch x := v.(type) { case map[string]any: n := &yaml.Node{Kind: yaml.MappingNode} keys := make([]string, 0, len(x)) for k := range x { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { val, err := toNode(x[k]) if err != nil { return nil, err } n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val) } return n, nil case []any: n := &yaml.Node{Kind: yaml.SequenceNode} for _, e := range x { val, err := toNode(e) if err != nil { return nil, err } n.Content = append(n.Content, val) } return n, nil case string: if x != "" && strings.TrimSpace(x) == "" { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil } } n := &yaml.Node{} if err := n.Encode(v); err != nil { return nil, err } return n, nil } // normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0. func normalize(v any) any { switch x := v.(type) { case float64: if x == math.Trunc(x) && math.Abs(x) < 1<<53 { return int64(x) } case map[string]any: for k, e := range x { x[k] = normalize(e) } case []any: for i, e := range x { x[i] = normalize(e) } } return v } func isEmpty(v any) bool { switch x := v.(type) { case nil: return true case string: return x == "" case map[string]any: return len(x) == 0 case []any: return len(x) == 0 } return false }