From f2ffed5c982bb4132ee5ef3fd981cd5f181cf917 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 23:10:21 -0400 Subject: [PATCH] feat(research): one option per way to run a game, SteamCMD recommended A draft built on a ready-made image whose files all live outside /server went round three times and ended as WIP, although research had also verified the game's Steam app, which SteamCMD installs straight into /server. The build picked among the variant's images on its own. - research reads each Steam app's OS list. When it ships a Linux server, the SteamCMD route is the recommended option; for Windows-only servers, images that bring Proton or Wine come first. The research prompt says the same. - A variant that can run on several images becomes one option per image ("... (SteamCMD)", "... (image x/y)"), so the admin picks exactly how the server runs. - build uses exactly the chosen option and never switches to another; if it can't be made to work, the PR says so and another option can be picked. Co-Authored-By: Claude Opus 5.5 --- cmd/gbrgames/build.go | 49 ++++++------ cmd/gbrgames/research.go | 141 +++++++++++++++++++++++++--------- cmd/gbrgames/research_test.go | 73 ++++++++++++++++++ 3 files changed, 202 insertions(+), 61 deletions(-) diff --git a/cmd/gbrgames/build.go b/cmd/gbrgames/build.go index 5cc1d4e..bb94efb 100644 --- a/cmd/gbrgames/build.go +++ b/cmd/gbrgames/build.go @@ -93,26 +93,35 @@ type buildOptions struct { agentImage string } +// build drafts the template for exactly the option the admin chose: one image (research lists each way to +// run a game as its own option). It never switches to another image; if the chosen one can't be made to +// work, the result says so and the admin can pick a different option. func build(o buildOptions) (*buildResult, error) { - if len(o.variant.Images) == 0 { - return nil, errors.New("the variant has no image to build from") - } - res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id)} - res.Verified = append(res.Verified, o.variant.Verified...) - - // 1. First boot of each candidate image until one runs as a non-root user - var report *bootReport for _, image := range o.variant.Images { - steam := isSteamCMDImage(image) && o.variant.SteamAppID != "" - if isSteamCMDImage(image) && !steam { + if isSteamCMDImage(image) && o.variant.SteamAppID == "" { continue // the SteamCMD image alone runs nothing } - res.Image = image - if steam && o.variant.StartupHint == "" { - fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID) - report = nil - break + res, err := buildWithImage(o, image) + if res != nil { + res.Summary = buildSummary(o, res) } + return res, err + } + return nil, errors.New("the variant has no image to build from") +} + +// buildWithImage drafts the template for one image: first boot, draft, then boots of the draft with +// failures going back to the model. +func buildWithImage(o buildOptions, image string) (*buildResult, error) { + res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id), Image: image} + res.Verified = append(res.Verified, o.variant.Verified...) + steam := isSteamCMDImage(image) + + // 1. First boot of the image untouched: its log, files and settings are the draft's facts + var report *bootReport + if steam && o.variant.StartupHint == "" { + fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID) + } else { fmt.Printf("[build] first boot of %s\n", image) r, err := firstBoot(o.id+"-probe", image, o.variant.SteamAppID, steam, o.variant.StartupHint) if err != nil { @@ -120,18 +129,12 @@ func build(o buildOptions) (*buildResult, error) { } if why := imageUnusable(r); why != "" { fmt.Printf("[build] %s can't be used: %s\n", image, why) - res.Problems = append(res.Problems, why) - res.Image = "" - continue + res.Problems = []string{why} + return res, nil } report = r res.Verified = append(res.Verified, fmt.Sprintf("first boot of %s: %s", image, r.Outcome)) - break } - if res.Image == "" { - return res, errors.New("no image of this variant runs as a non-root user") - } - res.Problems = nil // 2. Draft until validation and the image checks pass steamApp := "" diff --git a/cmd/gbrgames/research.go b/cmd/gbrgames/research.go index deae108..2d7a429 100644 --- a/cmd/gbrgames/research.go +++ b/cmd/gbrgames/research.go @@ -173,9 +173,10 @@ const researchPrompt = "You research how to host a game's dedicated server for G "in Docker on Linux, as a non-root user, with a volume at /server. From the search results and pages given, list " + "the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " + "crossplay builds, or notably different server images. For each variant give:\n" + - "- images: Docker Hub images that run this server, best first (prefer images that run as a non-root user, and the " + - "plain SteamCMD route cm2network/steamcmd when the game ships a Linux dedicated server on Steam). Only images that " + - "appear in the pages.\n" + + "- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " + + "cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) always comes first and that " + + "variant is the recommended one. Then Docker Hub images that appear in the pages and run as a non-root user " + + "(needed for servers that only exist for Windows).\n" + "- steamAppId: the Steam app id of the DEDICATED SERVER (not the game itself), if installed via SteamCMD; else \"\".\n" + "- startupHint: the command that starts the server from its install folder /server, with the launch options " + "for name, port, world and save location exactly as the pages document them; \"\" if unknown.\n" + @@ -299,13 +300,13 @@ func trimToJSON(answer string) string { // Steam apps must exist and be readable anonymously (what SteamCMD installs need). What fails is dropped // and noted; a variant left with nothing runnable can't be recommended. func verifyVariants(res *researchResult) { - var apps []string + var appIDs []string for _, v := range res.Variants { if v.SteamAppID != "" { - apps = append(apps, v.SteamAppID) + appIDs = append(appIDs, v.SteamAppID) } } - names := steamAppNames(apps) + apps := steamApps(appIDs) anyRunnable := false for i := range res.Variants { v := &res.Variants[i] @@ -324,10 +325,11 @@ func verifyVariants(res *researchResult) { } v.Images = images if v.SteamAppID != "" { - if name, ok := names[v.SteamAppID]; ok { - v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, name)) - if !hasImage(v.Images, "cm2network/steamcmd") { - v.Images = append(v.Images, "cm2network/steamcmd") + if app, ok := apps[v.SteamAppID]; ok { + v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name)) + v.Images = withSteamCMD(v.Images, app.linux) + if app.linux { + v.Verified = append(v.Verified, "it ships a Linux server, so the SteamCMD option (installing it into /server) is recommended") } } else { v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD") @@ -346,44 +348,57 @@ func verifyVariants(res *researchResult) { res.Reason = "unknown" res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message } - // Exactly one recommendation, among the runnable ones + res.Variants = oneOptionPerImage(res.Variants) + recommend(res.Variants) +} + +// recommend marks exactly one variant recommended, among the runnable ones. A variant that installs a Linux +// server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need. +func recommend(variants []variant) { + runnable := func(v variant) bool { return len(v.Images) > 0 } + steamFirst := func(v variant) bool { return runnable(v) && isSteamCMDImage(v.Images[0]) } rec := -1 - for i, v := range res.Variants { - if v.Recommended && rec < 0 && len(v.Images) > 0 { - rec = i - } - res.Variants[i].Recommended = false - } - if rec < 0 { - for i, v := range res.Variants { - if len(v.Images) > 0 { + for _, want := range []func(variant) bool{ + func(v variant) bool { return v.Recommended && steamFirst(v) }, + steamFirst, + func(v variant) bool { return v.Recommended && runnable(v) }, + runnable, + } { + for i, v := range variants { + if rec < 0 && want(v) { rec = i - break } } } - if rec >= 0 { - res.Variants[rec].Recommended = true + for i := range variants { + variants[i].Recommended = i == rec } } -func hasImage(images []string, name string) bool { - for _, i := range images { - if i == name || strings.HasPrefix(i, name+":") { - return true - } - } - return false +var ( + appName = regexp.MustCompile(`"common"\s*\{[^}]*?"name"\s+"([^"]+)"`) + appLinux = regexp.MustCompile(`"oslist"\s+"[^"]*linux`) +) + +type steamApp struct { + name string + linux bool // ships a Linux server: SteamCMD installs it straight into /server } -var appName = regexp.MustCompile(`"common"\s*\{[^}]*?"name"\s+"([^"]+)"`) +func parseAppInfo(out string) (steamApp, bool) { + m := appName.FindStringSubmatch(out) + if m == nil { + return steamApp{}, false + } + return steamApp{name: m[1], linux: appLinux.MatchString(out)}, true +} -// steamAppNames asks SteamCMD (anonymously, in its official image) for each app's info: an app that +// steamApps asks SteamCMD (anonymously, in its official image) for each app's info: an app that // anonymous SteamCMD can't see can't be installed by the node-agent either. -func steamAppNames(apps []string) map[string]string { - names := map[string]string{} +func steamApps(apps []string) map[string]steamApp { + found := map[string]steamApp{} if len(apps) == 0 { - return names + return found } for _, app := range apps { if !regexp.MustCompile(`^\d+$`).MatchString(app) { @@ -395,9 +410,59 @@ func steamAppNames(apps []string) map[string]string { fmt.Printf("[research] steamcmd for app %s: %v\n", app, err) continue } - if m := appName.FindStringSubmatch(out); m != nil { - names[app] = m[1] + if info, ok := parseAppInfo(out); ok { + found[app] = info } } - return names + return found +} + +// withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server +// it goes first: it installs into /server, which nodes keep, while ready-made images often keep their +// files elsewhere. For Windows-only servers it stays last, behind images that bring Proton or Wine. +func withSteamCMD(images []string, linux bool) []string { + var others []string + for _, i := range images { + if !isSteamCMDImage(i) { + others = append(others, i) + } + } + if linux { + return append([]string{"cm2network/steamcmd"}, others...) + } + return append(others, "cm2network/steamcmd") +} + +// oneOptionPerImage turns a variant that can run on several images into one choice per image, so the +// admin picks exactly how the server runs and the build uses that and nothing else. +func oneOptionPerImage(variants []variant) []variant { + var out []variant + for _, v := range variants { + if len(v.Images) <= 1 { + out = append(out, v) + continue + } + for _, img := range v.Images { + o := v + o.Images = []string{img} + o.Verified = nil + for _, line := range v.Verified { + if !strings.HasPrefix(line, "image ") || line == "image "+img+" exists" { + o.Verified = append(o.Verified, line) + } + } + if isSteamCMDImage(img) { + o.ID = v.ID + "-steamcmd" + o.Title = v.Title + " (SteamCMD)" + o.Description = strings.TrimSpace(v.Description + " Installed with SteamCMD into the server volume.") + } else { + o.ID = v.ID + "-" + gameID(img) + o.Title = v.Title + " (image " + img + ")" + o.Description = strings.TrimSpace(v.Description + " Runs in the ready-made image " + img + ".") + o.SteamAppID = "" + } + out = append(out, o) + } + } + return out } diff --git a/cmd/gbrgames/research_test.go b/cmd/gbrgames/research_test.go index 9f39018..c79ceb2 100644 --- a/cmd/gbrgames/research_test.go +++ b/cmd/gbrgames/research_test.go @@ -39,3 +39,76 @@ func TestAppNameFromAppInfo(t *testing.T) { t.Errorf("got %v", m) } } + +func TestParseAppInfoFindsLinuxServers(t *testing.T) { + linux, ok := parseAppInfo(`"1000000" +{ + "common" + { + "name" "Example Dedicated Server" + "oslist" "windows,linux" + } +}`) + if !ok || linux.name != "Example Dedicated Server" || !linux.linux { + t.Errorf("got %+v %v", linux, ok) + } + // A server that only exists for Windows (needs Proton or Wine) + windows, _ := parseAppInfo(`"common" { "name" "Example Server" "oslist" "windows" }`) + if windows.linux { + t.Error("windows-only app reported as linux") + } +} + +func TestWithSteamCMDGoesFirstOnlyForLinuxServers(t *testing.T) { + images := []string{"someone/example-server", "cm2network/steamcmd", "other/example"} + got := withSteamCMD(images, true) + if len(got) != 3 || got[0] != "cm2network/steamcmd" || got[1] != "someone/example-server" { + t.Errorf("linux: %v", got) + } + got = withSteamCMD([]string{"someone/proton-example"}, false) + if len(got) != 2 || got[1] != "cm2network/steamcmd" { + t.Errorf("windows-only: %v", got) + } +} + +func TestRecommendPrefersTheSteamCMDVariant(t *testing.T) { + v := []variant{ + {ID: "image", Recommended: true, Images: []string{"someone/example-server"}}, + {ID: "steam", Images: []string{"cm2network/steamcmd", "someone/example-server"}}, + {ID: "broken", Recommended: true}, + } + recommend(v) + if v[0].Recommended || !v[1].Recommended || v[2].Recommended { + t.Errorf("got %+v", v) + } + // Without a SteamCMD variant, the model's runnable choice stands + v = []variant{{ID: "a", Images: []string{"x/a"}}, {ID: "b", Recommended: true, Images: []string{"x/b"}}} + recommend(v) + if v[0].Recommended || !v[1].Recommended { + t.Errorf("got %+v", v) + } +} + +func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) { + v := oneOptionPerImage([]variant{{ + ID: "dedicated", Title: "Example Dedicated Server", Recommended: true, SteamAppID: "1000000", + Images: withSteamCMD([]string{"someone/example-server"}, true), + Verified: []string{"image someone/example-server exists", "Steam app 1000000 is \"Example Dedicated Server\""}, + }}) + recommend(v) + if len(v) != 2 { + t.Fatalf("want one option per image, got %+v", v) + } + steam, image := v[0], v[1] + if steam.Title != "Example Dedicated Server (SteamCMD)" || !steam.Recommended || steam.SteamAppID != "1000000" || len(steam.Images) != 1 { + t.Errorf("SteamCMD option: %+v", steam) + } + if image.Title != "Example Dedicated Server (image someone/example-server)" || image.Recommended || image.SteamAppID != "" { + t.Errorf("image option: %+v", image) + } + for _, line := range steam.Verified { + if line == "image someone/example-server exists" { + t.Error("SteamCMD option lists the other option's image check") + } + } +}