Compare commits

...
1 Commits
Author SHA1 Message Date
gregandClaude Opus 5.5 f2ffed5c98 feat(research): one option per way to run a game, SteamCMD recommended
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
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 <noreply@anthropic.com>
2026-10-25 23:10:21 -04:00
3 changed files with 202 additions and 61 deletions

No files matched your search

+26 -23
View File
@@ -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 := ""
+103 -38
View File
@@ -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
}
+73
View File
@@ -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")
}
}
}