package main import ( "encoding/json" "errors" "flag" "fmt" "io" "net/http" "net/url" "os" "regexp" "sort" "strings" "time" ) // research finds out how a game's dedicated server can be run before anything is drafted: it searches the // web (a self-hosted SearXNG), reads the most useful pages (a reader that turns pages into text), and has // the model list the server's variants (editions, crossplay builds, docker image or SteamCMD app). Every // image and Steam app it names is then checked for real, so a variant shown to an admin exists. type variant struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description"` Recommended bool `json:"recommended"` Images []string `json:"images"` SteamAppID string `json:"steamAppId"` StartupHint string `json:"startupHint"` Ports []string `json:"ports"` Notes string `json:"notes"` Sources []string `json:"sources"` // Filled by the checks, not the model Verified []string `json:"verified,omitempty"` Problems []string `json:"problems,omitempty"` } type researchResult struct { GameName string `json:"gameName"` Supported bool `json:"supported"` Reason string `json:"reason,omitempty"` Message string `json:"message"` Variants []variant `json:"variants"` Sources []string `json:"sources"` } var researchSchema = map[string]any{ "type": "object", "required": []string{"supported", "reason", "message", "variants"}, "properties": map[string]any{ "supported": map[string]any{"type": "boolean"}, "reason": map[string]any{"enum": []string{"ok", "unreleased", "single_player", "no_dedicated_server", "sunset", "unknown"}}, "message": map[string]any{"type": "string"}, "variants": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "required": []string{"id", "title", "description", "recommended", "images", "steamAppId", "startupHint", "ports", "notes", "sources"}, "properties": map[string]any{ "id": map[string]any{"type": "string"}, "title": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"}, "recommended": map[string]any{"type": "boolean"}, "images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "steamAppId": map[string]any{"type": "string"}, "startupHint": map[string]any{"type": "string"}, "ports": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "notes": map[string]any{"type": "string"}, "sources": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, }, }, }, }, } func runResearch(args []string) error { fs := flag.NewFlagSet("research", flag.ExitOnError) gameName := fs.String("name", "", "the game's name, as players know it") notes := fs.String("notes", "", "what the admin asked for (crossplay, a modded edition...)") provider := fs.String("provider", "ollama", "ollama or gemini") modelName := fs.String("model", "", "model name (default as for draft)") pages := fs.Int("pages", 6, "how many search results to read") out := fs.String("out", "", "write the result JSON here (default stdout)") fs.Parse(args) if *gameName == "" { return errors.New("research needs -name") } m, err := newModel(*provider, *modelName) if err != nil { return err } res, err := research(m, *gameName, *notes, *pages) if err != nil { return err } raw, _ := json.MarshalIndent(res, "", " ") if *out == "" { fmt.Println(string(raw)) return nil } return os.WriteFile(*out, raw, 0o644) } func research(m model, gameName, notes string, pages int) (*researchResult, error) { searx, reader := os.Getenv("SEARXNG_URL"), os.Getenv("READER_URL") if searx == "" || reader == "" { return nil, errors.New("set SEARXNG_URL (e.g. http://host:8080) and READER_URL (e.g. http://host:3000)") } queries := []string{ gameName + " dedicated server docker image", gameName + " dedicated server steamcmd app id linux", gameName + " dedicated server setup ports launch options", gameName + " crossplay console dedicated server", } var hits []searchHit for _, q := range queries { fmt.Printf("[research] searching: %s\n", q) h, err := searxSearch(searx, q) if err != nil { fmt.Printf("[research] search failed: %v\n", err) continue } hits = append(hits, h...) } if len(hits) == 0 { return nil, errors.New("no search results (is SearXNG reachable?)") } picked := pickPages(hits, pages) var b strings.Builder fmt.Fprintf(&b, "Game: **%s**\n", gameName) if notes != "" { fmt.Fprintf(&b, "The admin asked for: %s\n", notes) } b.WriteString("\n## Search results\n") for _, h := range dedupeHits(hits) { fmt.Fprintf(&b, "- %s — %s: %.200s\n", h.URL, h.Title, h.Content) } var sources []string for _, h := range picked { fmt.Printf("[research] reading %s\n", h.URL) text, err := readPage(reader, h.URL) if err != nil { fmt.Printf("[research] could not read it: %v\n", err) continue } sources = append(sources, h.URL) fmt.Fprintf(&b, "\n## Page: %s\n%s\n", h.URL, text) } messages := []chatMessage{ {Role: "system", Content: researchPrompt}, {Role: "user", Content: b.String()}, } fmt.Printf("[research] asking %s\n", m.name()) answer, err := m.chat(messages, researchSchema) if err != nil { return nil, err } res := &researchResult{} if err := json.Unmarshal([]byte(trimToJSON(answer)), res); err != nil { return nil, fmt.Errorf("the model's answer is not valid JSON: %w", err) } res.GameName = gameName res.Sources = sources verifyVariants(res) return res, nil } const researchPrompt = "You research how to host a game's dedicated server for GBR Hosting, a game server host. Servers run " + "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: 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) is its only image, and " + "there is no separate variant for a community image of the same server: those keep their saves outside /server. " + "Docker Hub images that appear in the pages and run as a non-root user are only for servers that aren't on Steam " + "for Linux (Windows-only servers that need Proton or Wine, games not on Steam).\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" + "- ports: every port players connect to, as \"port/proto\".\n" + "- notes: anything a template author must know (Windows-only server needing Proton, crossplay flags, mods).\n" + "- sources: the page URLs that support it.\n" + "Mark one variant recommended. Use only what the pages say or what you know for certain; leave fields empty " + "instead of guessing. If no dedicated server can be hosted (single-player only, unreleased, official servers " + "only, shut down), set supported false, the matching reason, no variants, and explain in message. Otherwise " + "reason \"ok\" and a one-sentence message." type searchHit struct { URL string `json:"url"` Title string `json:"title"` Content string `json:"content"` } func searxSearch(base, query string) ([]searchHit, error) { u := strings.TrimRight(base, "/") + "/search?format=json&q=" + url.QueryEscape(query) raw, err := httpGet(u, 30*time.Second) if err != nil { return nil, err } var resp struct { Results []searchHit `json:"results"` } if err := json.Unmarshal(raw, &resp); err != nil { return nil, fmt.Errorf("unreadable search answer: %.200s", raw) } if len(resp.Results) > 8 { resp.Results = resp.Results[:8] } return resp.Results, nil } // Pages that hold the facts a template needs, read first var usefulPage = regexp.MustCompile(`(?i)hub\.docker\.com|github\.com|steamdb\.info|developer\.valvesoftware|wiki|fandom|reddit\.com/r/\w*(server|admin)`) // pickPages reads one page per site, docs and image pages first, so a handful of reads covers the most ground. func pickPages(hits []searchHit, n int) []searchHit { hits = dedupeHits(hits) sort.SliceStable(hits, func(i, j int) bool { return usefulPage.MatchString(hits[i].URL) && !usefulPage.MatchString(hits[j].URL) }) seen := map[string]bool{} var out []searchHit for _, h := range hits { u, err := url.Parse(h.URL) if err != nil || seen[u.Host] { continue } seen[u.Host] = true out = append(out, h) if len(out) == n { break } } return out } // Our own sites (the public templates, the panel) are never a source: research would read our templates // back, and an eval build would copy the answer it's being scored against var ownSites = regexp.MustCompile(`(?i)^https?://([a-z0-9-]+\.)*(gbreed\.com|gbrhosting\.com)(/|:|$)`) func dedupeHits(hits []searchHit) []searchHit { seen := map[string]bool{} var out []searchHit for _, h := range hits { if h.URL == "" || seen[h.URL] || ownSites.MatchString(h.URL) { continue } seen[h.URL] = true out = append(out, h) } return out } func readPage(reader, pageURL string) (string, error) { return readPageLimit(reader, pageURL, 9000) } // readPageLimit reads a page as text through the reader, cut to limit bytes. func readPageLimit(reader, pageURL string, limit int) (string, error) { raw, err := httpGet(strings.TrimRight(reader, "/")+"/"+pageURL, 60*time.Second) if err != nil { return "", err } text := string(raw) if len(text) > limit { text = text[:limit] + "\n...(truncated)" } return text, nil } func httpGet(u string, timeout time.Duration) ([]byte, error) { client := &http.Client{Timeout: timeout} resp, err := client.Get(u) if err != nil { return nil, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, err } if resp.StatusCode >= 300 { return nil, fmt.Errorf("HTTP %d: %.200s", resp.StatusCode, raw) } return raw, nil } // trimToJSON cuts prose or code fences some models put around a JSON answer. func trimToJSON(answer string) string { answer = strings.TrimSpace(answer) if i := strings.Index(answer, "{"); i > 0 { answer = answer[i:] } if i := strings.LastIndex(answer, "}"); i >= 0 { answer = answer[:i+1] } return answer } // verifyVariants checks every image and Steam app the model named: images must exist in their registry, // 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 appIDs []string for _, v := range res.Variants { if v.SteamAppID != "" { appIDs = append(appIDs, v.SteamAppID) } } apps := steamApps(appIDs) anyRunnable, linuxSteam := false, false for i := range res.Variants { v := &res.Variants[i] var images []string for _, img := range v.Images { img = strings.TrimSpace(img) if img == "" { continue } if out, err := runCmd("docker", "manifest", "inspect", img); err != nil { v.Problems = append(v.Problems, fmt.Sprintf("image %s not found: %.120s", img, out)) continue } v.Verified = append(v.Verified, "image "+img+" exists") images = append(images, img) } v.Images = images if v.SteamAppID != "" { 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 { linuxSteam = true v.Verified = append(v.Verified, "it ships a Linux server, so it is installed with SteamCMD into /server") } } else { v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD") v.SteamAppID = "" } } if len(v.Images) == 0 { v.Problems = append(v.Problems, "no image or Steam app that could be checked; it can't be built") v.Recommended = false } else { anyRunnable = true } } if !anyRunnable && res.Supported { res.Supported = false res.Reason = "unknown" res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message } if linuxSteam { res.Variants = steamCMDOnly(res.Variants) } res.Variants = oneOptionPerImage(res.Variants) recommend(res.Variants) } // steamCMDOnly keeps only the SteamCMD options once the game has a Linux server on Steam. Ready-made images // of such servers keep their files and saves in their own folders (/config, /home/...), outside /server, // and read settings from their own variables, so they can't be made or repaired into a working template. func steamCMDOnly(variants []variant) []variant { var out []variant for _, v := range variants { if len(v.Images) > 0 && isSteamCMDImage(v.Images[0]) && v.SteamAppID != "" { out = append(out, v) } } return out } // 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 _, 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 } } } for i := range variants { variants[i].Recommended = i == rec } } 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 } 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 } // 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 steamApps(apps []string) map[string]steamApp { found := map[string]steamApp{} if len(apps) == 0 { return found } for _, app := range apps { if !regexp.MustCompile(`^\d+$`).MatchString(app) { continue } out, err := runCmd("docker", "run", "--rm", "--network", bootTestNetwork, "cm2network/steamcmd", "bash", "-c", "/home/steam/steamcmd/steamcmd.sh +login anonymous +app_info_update 1 +app_info_print "+app+" +quit") if err != nil { fmt.Printf("[research] steamcmd for app %s: %v\n", app, err) continue } if info, ok := parseAppInfo(out); ok { found[app] = info } } return found } // withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server // it is the only route: it installs into /server, which nodes keep, while ready-made images 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 []string{"cm2network/steamcmd"} } 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 }