diff --git a/AUTHORING.md b/AUTHORING.md index e21e185..b886611 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -47,22 +47,25 @@ Know this before writing anything: ### The container user - Run `gbrgames inspect-image -game `. If the image is built around its own user (it declares - `USER`, or locks a home folder to a uid other than 1000), set `containerUser` to that `uid:gid`. - Example: ARK's image needs `25000:25000`. + `USER`, or locks a home folder to a uid other than 1000), set `containerUser` to that `uid:gid`, as + the inspector reports it. ### The startup command - Read settings under the names the agent gives them (see above). `${RCON_PORT}` when the setting is `RCONPort` silently ignores the owner's value; it arrives as `RCONPORT`. - Use `${VAR:-default}` for anything that may be unset. +- Only use launch options the game's documentation lists, spelled as it spells them. Servers ignore options + they don't know, so an invented one boots fine and its setting does nothing. - Quote text settings where they're used: `-name "${SERVER_NAME}"`. `ARGS="-name ${SERVER_NAME}"; ./server $ARGS` splits "GBRHosting - My Server" into words; build optional arguments in a bash array instead (`args=(-name "$SERVER_NAME"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./server "${args[@]}"`). - If the image installs the game itself on start, make sure a failed install is retried rather than - starting a server that isn't there (see ARK's template). + starting a server that isn't there: log in to SteamCMD once (`+login anonymous +quit`) before the + install, and retry the install a few times until the server's executable exists. ### Memory - `requiredMemoryMb` is the container's hard limit. A JVM or engine heap must stay about **1 GB below** - it: Minecraft's `MEMORY` is `3G` for `4096` MB. A heap equal to the limit is killed on start. + it: a `3G` heap for `4096` MB. A heap equal to the limit is killed on start. ### Settings (`defaultConfig` and `configSchema`) - Take keys and default values from what the server really writes: run diff --git a/BUILDER.md b/BUILDER.md index f53a866..357ea9f 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -25,12 +25,17 @@ progress / log / variants ◄─post── build: first boot → draft validates but never booted still gets a PR, titled `WIP:`, with what's left listed. Nothing reaches players until a person merges it and enables the game. +The builder is **game-agnostic**: everything about a game comes from that game's research, first boot and +boot tests. No game's name, id or code may appear in the builder's source, its rules for the model +(`AUTHORING.md`), the schemas or this file, and the model is never shown another game's template (its format +example is an invented server). `TestBuilderIsGameAgnostic` fails the build if one does. + The worker only connects out (to the panel, Gitea, the model and the search services). ## Requirements A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the -builder can't reach private networks. Enough memory for the largest server it builds (~16 GB for ARK) and +builder can't reach private networks. Enough memory for the largest server it builds (the biggest need ~16 GB) and disk for SteamCMD downloads. The image `gbr-builder:latest` is published by `.gitea/workflows/builder-image.yml` whenever `cmd/` changes on main. @@ -54,9 +59,9 @@ On any machine with Docker and the `gbr-boottest` network, with `OLLAMA_URL`, `S `READER_URL` set: ```bash -go run ./cmd/gbrgames research -name Valheim -out research.json +go run ./cmd/gbrgames research -name "" -out research.json # pick a variant from research.json into v.json, then: -go run ./cmd/gbrgames build -name Valheim -variant @v.json -out build.json +go run ./cmd/gbrgames build -name "" -variant @v.json -out build.json ``` The draft lands in `games//`, first-boot reports in `firstboot-out/`. diff --git a/cmd/gbrgames/agnostic_test.go b/cmd/gbrgames/agnostic_test.go new file mode 100644 index 0000000..d4db5bc --- /dev/null +++ b/cmd/gbrgames/agnostic_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The builder must work the same for every game: no game's name, id or code in its source, its rules for +// the model (AUTHORING.md), the schemas sent with every draft, or its docs. Games are read from games/, +// so a newly merged template is covered as soon as it lands. +func TestBuilderIsGameAgnostic(t *testing.T) { + root := filepath.Join("..", "..") + templates, _ := filepath.Glob(filepath.Join(root, "games", "*", "game.yaml")) + if len(templates) == 0 { + t.Fatal("no templates found to check against") + } + var names []string + for _, f := range templates { + g, err := loadMap(f) + if err != nil { + continue + } + for _, key := range []string{"id", "name"} { + if s, _ := g[key].(string); len(s) > 2 { + names = append(names, s) + } + } + } + + files, _ := filepath.Glob("*.go") + files = append(files, filepath.Join(root, "AUTHORING.md"), filepath.Join(root, "BUILDER.md")) + schemas, _ := filepath.Glob(filepath.Join(root, "schema", "*.json")) + files = append(files, schemas...) + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, n := range names { + re := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9])` + regexp.QuoteMeta(n) + `($|[^A-Za-z0-9])`) + if loc := re.FindStringIndex(text); loc != nil { + line := strings.Count(text[:loc[0]], "\n") + 1 + t.Errorf("%s:%d mentions %q: the builder must not name or copy any game", f, line, n) + } + } + } +} diff --git a/cmd/gbrgames/build.go b/cmd/gbrgames/build.go index 2bfcf4e..1d4a96b 100644 --- a/cmd/gbrgames/build.go +++ b/cmd/gbrgames/build.go @@ -138,7 +138,8 @@ func build(o buildOptions) (*buildResult, error) { steamApp = o.variant.SteamAppID } d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: res.Image, - SteamApp: steamApp, Notes: variantNotes(o.variant, o.notes), Report: report, Provider: o.provider, Model: o.model, NoPull: true}) + SteamApp: steamApp, Notes: variantNotes(o.variant, o.notes), Report: report, Provider: o.provider, Model: o.model, NoPull: true, + Docs: sourceText(o.variant.Sources)}) if err != nil { return nil, err } @@ -370,3 +371,27 @@ func buildSummary(o buildOptions, res *buildResult) string { } return b.String() } + +// sourceText reads the pages research based the variant on, in full, so the drafter can check that every +// launch option the model uses is documented there. Empty (check skipped) without a reader. +func sourceText(sources []string) string { + reader := os.Getenv("READER_URL") + if reader == "" || len(sources) == 0 { + fmt.Println("[build] no research sources to check launch options against (READER_URL or sources missing)") + return "" + } + var b strings.Builder + read := 0 + for _, s := range sources { + text, err := readPageLimit(reader, s, 200_000) + if err != nil { + fmt.Printf("[build] could not read %s: %v\n", s, err) + continue + } + read++ + b.WriteString(text) + b.WriteString("\n") + } + fmt.Printf("[build] read %d of %d research sources to check launch options against\n", read, len(sources)) + return b.String() +} diff --git a/cmd/gbrgames/build_test.go b/cmd/gbrgames/build_test.go index 75e9039..f31f6cb 100644 --- a/cmd/gbrgames/build_test.go +++ b/cmd/gbrgames/build_test.go @@ -6,12 +6,12 @@ import ( ) func TestBootFailureKeepsVerdictAndServerOutput(t *testing.T) { - out := "[boot-test] deploying boottest-valheim (cm2network/steamcmd)\n" + - "[boot-test] container started; waiting for ready pattern /Game server connected/\n" + + out := "[boot-test] deploying boottest-example (cm2network/steamcmd)\n" + + "[boot-test] container started; waiting for ready pattern /Server ready/\n" + "[boot-test] FAILED: container exited before it was ready\n" + "---- last 2 log lines ----\n" + "Unknown argument --save-dir\n" + - "Usage: valheim_server.x86_64 ...\n" + "Usage: example_server ...\n" got := bootFailure(out) if !strings.Contains(got, "failed: FAILED: container exited before it was ready") { t.Errorf("verdict missing: %s", got) @@ -19,19 +19,19 @@ func TestBootFailureKeepsVerdictAndServerOutput(t *testing.T) { if !strings.Contains(got, "Unknown argument --save-dir") || strings.Contains(got, "deploying boottest") { t.Errorf("want only the server's last lines, got: %s", got) } - if v := bootVerdict("[boot-test] PASSED: READY: Game server connected"); v != "PASSED: READY: Game server connected" { + if v := bootVerdict("[boot-test] PASSED: READY: Server ready"); v != "PASSED: READY: Server ready" { t.Errorf("verdict = %q", v) } } func TestOutsideDirsSkipsSteamNoise(t *testing.T) { r := &bootReport{ContainerChanges: []string{ - "A /home/steam/.config/unity3d/IronGate/Valheim/worlds_local/w.db", + "A /home/steam/.config/example-game/worlds/w.db", "A /home/steam/Steam/logs/connection_log.txt", "C /home/steam/.steam/registry.vdf", }} got := outsideDirs(r) - if len(got) != 1 || got[0] != "/home/steam/.config/unity3d/IronGate/Valheim/worlds_local" { + if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" { t.Errorf("got %v", got) } if outsideDirs(nil) != nil { @@ -40,9 +40,9 @@ func TestOutsideDirsSkipsSteamNoise(t *testing.T) { } func TestVariantNotes(t *testing.T) { - v := variant{Title: "Crossplay", Description: "PC + Xbox", StartupHint: "./valheim_server.x86_64 -crossplay", Ports: []string{"2456/udp"}} + v := variant{Title: "Crossplay", Description: "PC + Xbox", StartupHint: "./example_server -crossplay", Ports: []string{"30000/udp"}} got := variantNotes(v, "needs Xbox") - for _, want := range []string{"variant: Crossplay (PC + Xbox)", "documented startup: ./valheim_server.x86_64 -crossplay", "2456/udp", "the admin asked for: needs Xbox"} { + for _, want := range []string{"variant: Crossplay (PC + Xbox)", "documented startup: ./example_server -crossplay", "30000/udp", "the admin asked for: needs Xbox"} { if !strings.Contains(got, want) { t.Errorf("missing %q in %q", want, got) } diff --git a/cmd/gbrgames/draft.go b/cmd/gbrgames/draft.go index dc62d25..212956f 100644 --- a/cmd/gbrgames/draft.go +++ b/cmd/gbrgames/draft.go @@ -13,7 +13,6 @@ import ( "path" "path/filepath" "regexp" - "sort" "strings" "time" @@ -39,7 +38,7 @@ func runDraft(args []string) error { fs := flag.NewFlagSet("draft", flag.ExitOnError) gamesDir := fs.String("games", "games", "directory holding one folder per game") schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas") - gameName := fs.String("name", "", "the game's name, e.g. \"Valheim\"") + gameName := fs.String("name", "", "the game's name, as players know it") id := fs.String("id", "", "folder/id for the new game (default: from -name)") image := fs.String("image", "", "Docker image the server runs in") steamApp := fs.String("steam-app", "", "SteamCMD app id, if the server is installed with SteamCMD") @@ -82,6 +81,9 @@ type draftJob struct { Report *bootReport Provider, Model string NoPull bool + // Text of the pages research based the variant on; when set, every launch option in the startup + // command must appear in it (or in the image's scripts) + Docs string } // drafter holds one conversation with the model about one game, so later problems (a failed boot) can @@ -137,7 +139,7 @@ func newDrafter(j draftJob) (*drafter, error) { } d.messages = []chatMessage{ {Role: "system", Content: draftSystemPrompt(string(rules))}, - {Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report, exampleTemplates(j.GamesDir, 2))}, + {Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report)}, } return d, nil } @@ -200,6 +202,7 @@ func (d *drafter) check(answer string) []string { problems = append(problems, savesOutsideVolumes(g, d.job.Report)...) problems = append(problems, splitSettings(g)...) problems = append(problems, portSettings(g)...) + problems = append(problems, undocumentedOptions(g, d.job.Docs, d.facts)...) } } return problems @@ -337,7 +340,7 @@ func closestVar(name string, vars []string) string { var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"} // savesOutsideVolumes turns first boot's "written outside the volumes" into a hard check: anything the -// game itself wrote there (Valheim's worlds and admin lists in ~/.config/unity3d/...) is lost every time the +// game itself wrote there (worlds or player lists under the user's home folder) is lost every time the // node recreates the container. A template passes once its startup command or settings point the game at // /server or /data; a boot test then shows whether that really worked. func savesOutsideVolumes(game map[string]any, report *bootReport) []string { @@ -403,7 +406,7 @@ func imageUnusable(r *bootReport) string { *r.ExitCode, r.RanSeconds, r.User, denied[0]) } -func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts, report *bootReport, examples string) string { +func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts, report *bootReport) string { var b strings.Builder fmt.Fprintf(&b, "Write the template for **%s**.\n- id: %s\n- defaultDockerImage: %s\n", gameName, id, image) if steamApp != "" { @@ -447,33 +450,62 @@ func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts } else { b.WriteString("\n(No first-boot report: keep readyPattern out unless the facts show the ready line.)\n") } - b.WriteString("\n## Working templates from this repo, for format and style (not content)\n") - b.WriteString(examples) + b.WriteString("\n## Format example\nAn invented server, to show the format and house style only. Its command, options, " + + "ports and app id belong to no real game: never copy them; take every value from the facts above.\n```yaml\n") + b.WriteString(exampleTemplate) + b.WriteString("```\n") return b.String() } -// exampleTemplates picks the shortest templates in the repo as examples: real, validated, and small -// enough to leave the model room for the facts. -func exampleTemplates(gamesDir string, n int) string { - entries, _ := os.ReadDir(gamesDir) - type ex struct { - id string - body string - } - var all []ex - for _, e := range entries { - raw, err := os.ReadFile(filepath.Join(gamesDir, e.Name(), "game.yaml")) - if err == nil { - all = append(all, ex{e.Name(), string(raw)}) - } - } - sort.Slice(all, func(i, j int) bool { return len(all[i].body) < len(all[j].body) }) - var b strings.Builder - for i := 0; i < n && i < len(all); i++ { - fmt.Fprintf(&b, "\n### games/%s/game.yaml\n```yaml\n%s```\n", all[i].id, all[i].body) - } - return b.String() -} +// exampleTemplate shows the model the format only. It's invented on purpose: the builder must stay +// game-agnostic, so the model never sees another game's template (whose options it would copy). Its +// command, options, port and app id belong to no real server; validated in draft_test.go. +const exampleTemplate = `id: example-server +name: Example Server +defaultDockerImage: cm2network/steamcmd +defaultStartupCommand: args=(-name "${SERVER_NAME}" -world "${WORLD_NAME:-world}" -port 30000 -datadir /server/saves); [ -n "${JOIN_PASSWORD}" ] && args+=(-password "${JOIN_PASSWORD}"); ./example_server "${args[@]}" +requiredMemoryMb: 4096 +requiredDiskMb: 8192 +defaultPorts: + - 30000/udp + - 30001/udp +installMethod: STEAMCMD +installConfig: + appId: "1000000" +defaultConfig: + JoinPassword: "" + ServerName: GBRHosting - {SERVER_NAME} + WorldName: world +configSchema: + type: object + properties: + JoinPassword: + title: Join Password + description: Password players enter to join. Leave empty for a public server. + type: string + default: "" + category: Security & Access + x-savedValue: + key: GAME_JOIN_PASSWORD + label: Game join password + secret: true + ServerName: + title: Server Name + description: The name shown in the server browser. + type: string + default: GBRHosting - {SERVER_NAME} + category: System & General + showOnDeploy: true + WorldName: + title: World Name + description: The world to load. A new name starts a new world. + type: string + default: world + category: World & Environment +backupPaths: + - saves +readyPattern: 'Server is ready for connections' +` // writeDraft turns the model's JSON answer into games//game.yaml and test.yaml, using the importer's // writer so drafts look like every other template. diff --git a/cmd/gbrgames/draft_test.go b/cmd/gbrgames/draft_test.go index 062fa7c..09ac806 100644 --- a/cmd/gbrgames/draft_test.go +++ b/cmd/gbrgames/draft_test.go @@ -2,14 +2,16 @@ package main import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" ) func TestDraftCompletenessNamesMissingSchemaEntries(t *testing.T) { - // The Valheim draft that went round in circles: settings in defaultConfig, no configSchema + // A draft that went round in circles: settings in defaultConfig, no configSchema game := map[string]any{ - "defaultPorts": []any{"2456/udp"}, + "defaultPorts": []any{"30000/udp"}, "requiredMemoryMb": json.Number("4096"), "defaultConfig": map[string]any{"ServerName": "x", "ServerPassword": "", "MaxPlayers": json.Number("8")}, } @@ -44,28 +46,28 @@ func TestUnreadSettings(t *testing.T) { } func TestSavesOutsideVolumes(t *testing.T) { - // Valheim's real first boot: its own files in ~/.config, plus Steam client logs (noise) - valheim := &bootReport{ContainerChanges: []string{ - "A /home/steam/.config/unity3d/IronGate/Valheim/adminlist.txt", - "A /home/steam/.config/unity3d/IronGate/Valheim/bannedlist.txt", - "A /home/steam/Steam/logs/connection_log_2456.txt", + // A real first boot: the game's own files in ~/.config, plus Steam client logs (noise) + home := &bootReport{ContainerChanges: []string{ + "A /home/steam/.config/example-game/adminlist.txt", + "A /home/steam/.config/example-game/bannedlist.txt", + "A /home/steam/Steam/logs/connection_log_30000.txt", }} - game := map[string]any{"defaultStartupCommand": "./valheim_server.x86_64 -nographics -batchmode -world X"} - problems := savesOutsideVolumes(game, valheim) - if len(problems) != 1 || !strings.Contains(problems[0], "/home/steam/.config/unity3d/IronGate/Valheim") || strings.Contains(problems[0], "Steam/logs") { + game := map[string]any{"defaultStartupCommand": "./example_server -nographics -batchmode -world X"} + problems := savesOutsideVolumes(game, home) + if len(problems) != 1 || !strings.Contains(problems[0], "/home/steam/.config/example-game") || strings.Contains(problems[0], "Steam/logs") { t.Fatalf("got %v", problems) } // Fixed with a save-directory option under /server - game["defaultStartupCommand"] = "./valheim_server.x86_64 -nographics -batchmode -world X -savedir /server/saves" - if p := savesOutsideVolumes(game, valheim); p != nil { + game["defaultStartupCommand"] = "./example_server -nographics -batchmode -world X -datadir /server/saves" + if p := savesOutsideVolumes(game, home); p != nil { t.Errorf("redirected template still flagged: %v", p) } - // ARK's real first boot: only Proton's cache, which is not game data - ark := &bootReport{ContainerChanges: []string{"A /home/gameserver/.cache/umu-protonfixes/protonfixes_test.log"}} - if p := savesOutsideVolumes(map[string]any{}, ark); p != nil { - t.Errorf("ARK proton cache flagged: %v", p) + // A Proton server's first boot: only Proton's cache, which is not game data + proton := &bootReport{ContainerChanges: []string{"A /home/gameserver/.cache/umu-protonfixes/protonfixes_test.log"}} + if p := savesOutsideVolumes(map[string]any{}, proton); p != nil { + t.Errorf("proton cache flagged: %v", p) } } @@ -86,10 +88,43 @@ func TestImageUnusable(t *testing.T) { } func TestDraftCompletenessRejectsSecrets(t *testing.T) { - // The Valheim draft that couldn't be boot-tested: the password mapped to a CI secret nobody created - test := map[string]any{"settleSec": json.Number("120"), "secrets": map[string]any{"Password": "VALHEIM_TEST_PASSWORD"}} + // A draft that couldn't be boot-tested: the password mapped to a CI secret nobody created + test := map[string]any{"settleSec": json.Number("120"), "secrets": map[string]any{"Password": "EXAMPLE_TEST_PASSWORD"}} problems := strings.Join(draftCompleteness(map[string]any{}, test), "\n") if !strings.Contains(problems, "test.yaml has 'secrets' (Password)") { t.Errorf("got:\n%s", problems) } } + +// The model copies the example's format, so it must pass everything a draft has to pass +func TestExampleTemplateFollowsTheRules(t *testing.T) { + dir := filepath.Join(t.TempDir(), "example-server") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "game.yaml"), []byte(exampleTemplate), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "test.yaml"), []byte("timeoutSec: 2400\n"), 0o644); err != nil { + t.Fatal(err) + } + gs, err := compileSchema(filepath.Join("..", "..", "schema", "game.schema.json")) + if err != nil { + t.Fatal(err) + } + ts, err := compileSchema(filepath.Join("..", "..", "schema", "test.schema.json")) + if err != nil { + t.Fatal(err) + } + problems := validateGame(dir, "example-server", gs, ts) + g, err := loadMap(filepath.Join(dir, "game.yaml")) + if err != nil { + t.Fatal(err) + } + problems = append(problems, draftCompleteness(g, map[string]any{})...) + problems = append(problems, splitSettings(g)...) + problems = append(problems, portSettings(g)...) + for _, p := range problems { + t.Error(p) + } +} diff --git a/cmd/gbrgames/draftchecks.go b/cmd/gbrgames/draftchecks.go index 5db39a0..097341c 100644 --- a/cmd/gbrgames/draftchecks.go +++ b/cmd/gbrgames/draftchecks.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "path" "regexp" "sort" "strconv" @@ -75,7 +76,7 @@ func splitSettings(game map[string]any) []string { var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`) -// unwrapShell looks inside a startup command that is one bash -c '...' (ARK's), which would otherwise be +// unwrapShell looks inside a startup command that is one bash -c '...', which would otherwise be // all single-quoted text. func unwrapShell(cmd string) string { if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") { @@ -248,3 +249,77 @@ func portNumber(v any) (int, bool) { p, err := strconv.Atoi(strings.TrimSpace(s)) return p, err == nil && p > 0 && p < 65536 } + +var ( + optionToken = regexp.MustCompile(`(?:^|[\s("'])(-{1,2}[A-Za-z][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*)`) + segmentSep = regexp.MustCompile(`;|&&|\|\||\||\n`) + testOps = map[string]bool{"eq": true, "ne": true, "lt": true, "gt": true, "le": true, "ge": true, "nt": true, "ot": true, "ef": true} + // Options of these are the shell's and its tools', not the game's + shellTools = map[string]bool{"mkdir": true, "cp": true, "mv": true, "ln": true, "rm": true, "chmod": true, "chown": true, + "wget": true, "curl": true, "tar": true, "unzip": true, "test": true, "[": true, "[[": true, "echo": true, "printf": true, + "sleep": true, "find": true, "sed": true, "grep": true, "cd": true, "export": true, "touch": true, "bash": true, "sh": true, + "cat": true, "head": true, "tail": true, "ls": true, "set": true, "ulimit": true, "env": true, "id": true} +) + +// undocumentedOptions finds launch options in the startup command that none of the researched pages (nor +// the image's own scripts) mention. Servers silently ignore options they don't know, so a made-up one +// boots fine and passes every boot test while its setting does nothing. +func undocumentedOptions(game map[string]any, docs string, facts *imageFacts) []string { + if strings.TrimSpace(docs) == "" { + return nil + } + startup, _ := game["defaultStartupCommand"].(string) + corpus := docs + if facts != nil { + for _, s := range facts.Scripts { + corpus += "\n" + s + } + } + seen := map[string]bool{} + var missing []string + for _, opt := range launchOptions(unwrapShell(startup)) { + name := strings.TrimLeft(opt, "-") + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + documented := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])-{1,2}` + regexp.QuoteMeta(name) + `($|[^A-Za-z0-9_])`) + if !documented.MatchString(corpus) { + missing = append(missing, opt) + } + } + if len(missing) == 0 { + return nil + } + return []string{fmt.Sprintf("the startup command passes %s, which none of the pages researched for this game mention. "+ + "Servers silently ignore options they don't know, so it boots fine but does nothing: remove it (and any setting that "+ + "only feeds it), or spell it exactly as the documentation does", strings.Join(missing, ", "))} +} + +// launchOptions lists the -options passed to the game, skipping the shell's own (mkdir -p, [ -n ... ]). +func launchOptions(cmd string) []string { + var out []string + for _, seg := range segmentSep.Split(cmd, -1) { + fields := strings.Fields(strings.TrimLeft(strings.TrimSpace(seg), "({")) + i := 0 + for i < len(fields) && (fields[i] == "exec" || fields[i] == "then" || fields[i] == "do" || fields[i] == "else" || + (assignWord.MatchString(fields[i]) && !strings.Contains(fields[i], "=("))) { + i++ + } + if i == len(fields) { + continue + } + if word := path.Base(fields[i]); shellTools[word] { + continue + } + for _, m := range optionToken.FindAllStringSubmatch(strings.Join(fields[i:], " "), -1) { + name := strings.TrimLeft(m[1], "-") + if len(name) < 2 || testOps[name] { + continue + } + out = append(out, m[1]) + } + } + return out +} diff --git a/cmd/gbrgames/draftchecks_test.go b/cmd/gbrgames/draftchecks_test.go index 148ea9f..0a04473 100644 --- a/cmd/gbrgames/draftchecks_test.go +++ b/cmd/gbrgames/draftchecks_test.go @@ -14,12 +14,12 @@ func textSettings(keys ...string) map[string]any { return map[string]any{"properties": props} } -func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) { - // What the builder drafted for Valheim: quoted in the assignment, split by the unquoted $ARGS +func TestSplitSettingsCatchesAnArgsString(t *testing.T) { + // A real draft: quoted in the assignment, split by the unquoted $ARGS game := map[string]any{ - "defaultStartupCommand": `ARGS="-name ${SERVER_NAME:-Valheim Server} -port 2456 -world ${WORLD_NAME:-Dedicated}"; ` + + "defaultStartupCommand": `ARGS="-name ${SERVER_NAME:-Example Server} -port 30000 -world ${WORLD_NAME:-Dedicated}"; ` + `[ -n "$PASSWORD" ] && ARGS="$ARGS -password $PASSWORD"; [ "$CROSSPLAY" = "true" ] && ARGS="$ARGS -crossplay"; ` + - `HOME=/server ./valheim_server.x86_64 $ARGS`, + `HOME=/server ./example_server $ARGS`, "configSchema": textSettings("ServerName", "WorldName", "Password"), } problems := splitSettings(game) @@ -36,8 +36,8 @@ func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) { func TestSplitSettingsAcceptsQuotedAndArrays(t *testing.T) { for _, startup := range []string{ - `./valheim_server.x86_64 -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 2456`, - `args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./valheim_server.x86_64 "${args[@]}"`, + `./example_server -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 30000`, + `args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./example_server "${args[@]}"`, `bash -c 'exec ./server -name "$SERVER_NAME"'`, } { game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName", "WorldName", "Password")} @@ -72,12 +72,12 @@ func TestSplitSettingsDirectAndArrayMisuse(t *testing.T) { func TestPortSettings(t *testing.T) { game := map[string]any{ - "defaultPorts": []any{"2456/udp", "2457/udp"}, + "defaultPorts": []any{"30000/udp", "30001/udp"}, "defaultConfig": map[string]any{ - "GamePort": json.Number("2456"), + "GamePort": json.Number("30000"), "QueryPort": json.Number("27015"), "RCONPort": json.Number("25575"), - "PublicPort": "{PUBLIC_PORT_2456}", + "PublicPort": "{PUBLIC_PORT_30000}", "Transport": "udp", "Export": json.Number("9"), }, @@ -86,7 +86,7 @@ func TestPortSettings(t *testing.T) { }}, } got := strings.Join(portSettings(game), "\n") - if !strings.Contains(got, "setting GamePort lets owners change the port") || !strings.Contains(got, "write 2456 directly") { + if !strings.Contains(got, "setting GamePort lets owners change the port") || !strings.Contains(got, "write 30000 directly") { t.Errorf("editable published port not caught:\n%s", got) } if !strings.Contains(got, "setting QueryPort defaults to port 27015, which defaultPorts does not publish") { @@ -105,3 +105,47 @@ func TestPortSettings(t *testing.T) { t.Errorf("got %v", p) } } + +func TestUndocumentedOptionsCatchesMaxPlayers(t *testing.T) { + // A real draft's startup: every option is documented except -maxplayers + game := map[string]any{"defaultStartupCommand": `args=(-name "${SERVER_NAME:-GBRHosting - Example}" -port 30000 -world "${WORLD_NAME:-Dedicated}" -datadir /server); ` + + `[ -n "${SERVER_PASSWORD}" ] && args+=(-password "${SERVER_PASSWORD}"); [ "${CROSSPLAY}" = "true" ] && args+=(-crossplay); ` + + `[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); ./example_server "${args[@]}"`} + docs := "Launch with `-name`, `-port`, `-world`, `-password`, `-datadir ` and `-crossplay` to allow consoles." + got := undocumentedOptions(game, docs, nil) + if len(got) != 1 || !strings.Contains(got[0], "passes -maxplayers,") { + t.Fatalf("want only -maxplayers flagged, got %v", got) + } + // Once removed, nothing is left to flag + game["defaultStartupCommand"] = strings.Replace(game["defaultStartupCommand"].(string), `[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); `, "", 1) + if got := undocumentedOptions(game, docs, nil); len(got) != 0 { + t.Errorf("got %v", got) + } +} + +func TestUndocumentedOptionsIgnoresShellAndUsesImageScripts(t *testing.T) { + game := map[string]any{"defaultStartupCommand": `mkdir -p /server/.config; cp -f /a /b; [ -n "$X" ] && [ "$Y" -eq 1 ] && ` + + `exec ./server --listen-port=7777 -ServerPlatform=ALL -logs`} + facts := &imageFacts{Scripts: map[string]string{"/start.sh": `exec "$@" -logs`}} + got := strings.Join(undocumentedOptions(game, "Use --listen-port and -ServerPlatform=ALL for crossplay.", facts), "\n") + if got != "" { + t.Errorf("shell options, documented ones and ones the image's script uses must pass, got %q", got) + } + // Without sources the check is off (draft run by hand, or no reader configured) + game["defaultStartupCommand"] = "./server -madeup" + if p := undocumentedOptions(game, "", nil); p != nil { + t.Errorf("got %v", p) + } + if p := undocumentedOptions(game, "unrelated page", nil); len(p) != 1 { + t.Errorf("want -madeup flagged, got %v", p) + } +} + +func TestUndocumentedOptionsWholeWords(t *testing.T) { + // "-port" in the docs doesn't document "-portal", and "-port" isn't documented by "-portal" + game := map[string]any{"defaultStartupCommand": "./server -port 1 -portal x"} + got := strings.Join(undocumentedOptions(game, "options: -port", nil), "") + if !strings.Contains(got, "passes -portal,") || strings.Contains(got, "-port,") { + t.Errorf("got %q", got) + } +} diff --git a/cmd/gbrgames/firstboot.go b/cmd/gbrgames/firstboot.go index 2724716..fd37229 100644 --- a/cmd/gbrgames/firstboot.go +++ b/cmd/gbrgames/firstboot.go @@ -466,8 +466,8 @@ func (b *booter) copyOut(vol, mount string, files []string, report *bootReport) return cmd.Wait() } -// fileLines reads the copied log files, so ready and error lines that never reach the console (ARK -// writes to ShooterGame.log) are found too. +// fileLines reads the copied log files, so ready and error lines that never reach the console (servers +// that only log to a file) are found too. func (b *booter) fileLines(report *bootReport) []logLine { var out []logLine for _, f := range report.FilesCreated { diff --git a/cmd/gbrgames/firstboot_test.go b/cmd/gbrgames/firstboot_test.go index e318eb2..17c58a1 100644 --- a/cmd/gbrgames/firstboot_test.go +++ b/cmd/gbrgames/firstboot_test.go @@ -6,21 +6,21 @@ import ( ) func TestParseConfigFormats(t *testing.T) { - // Palworld's Unreal-style list - pal := parseConfig("/server/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", "[/Script/Pal.PalGameWorldSettings]\n"+ - `OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Palworld Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`) - want := map[string]string{"Difficulty": "None", "ExpRate": "1.000000", "ServerName": "Default Palworld Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"} - if len(pal) != len(want) { - t.Fatalf("palworld: got %d settings: %+v", len(pal), pal) + // An Unreal-style option list + list := parseConfig("/server/Game/Saved/Config/LinuxServer/GameSettings.ini", "[/Script/Game.WorldSettings]\n"+ + `OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`) + want := map[string]string{"Difficulty": "None", "ExpRate": "1.000000", "ServerName": "Default Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"} + if len(list) != len(want) { + t.Fatalf("option list: got %d settings: %+v", len(list), list) } - for _, s := range pal { + for _, s := range list { if s.Section != "OptionSettings" || want[s.Key] != s.Value { - t.Errorf("palworld setting %+v", s) + t.Errorf("option list setting %+v", s) } } - // Minecraft's server.properties - mc := parseConfig("/data/server.properties", "#Minecraft server properties\nmax-players=20\nmotd=A Minecraft Server\nlevel-seed=\n") + // A server.properties file + mc := parseConfig("/data/server.properties", "#Server properties\nmax-players=20\nmotd=A Server\nlevel-seed=\n") if len(mc) != 3 || mc[0].Key != "max-players" || mc[0].Value != "20" || mc[2].Value != "" { t.Errorf("properties: %+v", mc) } @@ -66,7 +66,7 @@ func TestAnalyzeLines(t *testing.T) { {At: 2, Source: "console", Text: "Failed to load optional module"}, {At: 9, Source: "console", Text: "[09:00:01 INFO]: Done (8.1s)! For help, type \"help\""}, {At: 9, Source: "console", Text: "[09:00:01 INFO]: Thread RCON Listener started"}, - // After the stop at 200s: the shutdown, as Minecraft's runner prints it + // After the stop at 200s: the shutdown, as a wrapper script prints it {At: 201, Source: "console", Text: "[06:01:39] [RCON Listener #1/INFO]: Thread RCON Client /0:0:0:0:0:0:0:1 started"}, {At: 202, Source: "console", Text: "2026-10-25T06:01:39.976Z\tINFO\tmc-server-runner\tDone"}, }, []logLine{{At: -1, Source: "/server/x.log", Text: "Listening on 0.0.0.0:7777"}}, 200) @@ -90,13 +90,13 @@ func TestAnalyzeLines(t *testing.T) { func TestConfigCandidates(t *testing.T) { for rel, want := range map[string]bool{ - "server.properties": true, - "Pal/Saved/Config/LinuxServer/x.ini": true, - "ShooterGame/Saved/Logs/ShooterGame.log": true, - "steamapps/appmanifest_123.acf": false, - "Engine/Config/BaseEngine.ini": false, - "plugins/Geyser/config.yml": false, - "world/level.dat": false, + "server.properties": true, + "Game/Saved/Config/LinuxServer/x.ini": true, + "Game/Saved/Logs/Server.log": true, + "steamapps/appmanifest_123.acf": false, + "Engine/Config/BaseEngine.ini": false, + "plugins/SomePlugin/config.yml": false, + "world/level.dat": false, } { if got := isConfigCandidate(rel, 1000); got != want { t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want) diff --git a/cmd/gbrgames/inspect.go b/cmd/gbrgames/inspect.go index ee7b2f6..8d30bdd 100644 --- a/cmd/gbrgames/inspect.go +++ b/cmd/gbrgames/inspect.go @@ -278,8 +278,8 @@ func parseProbe(f *imageFacts, out string) { } // suggestContainerUser works out whether the image only works as a specific user. Images either declare -// it (USER) or are built around an account whose home folder nobody else may enter (ARK's gameserver, -// uid 25000, with /home/gameserver at 700). +// it (USER) or are built around an account whose home folder nobody else may enter (e.g. a home folder +// at mode 700 owned by a uid other than 1000). func suggestContainerUser(f *imageFacts) { if u := strings.TrimSpace(f.User); u != "" && u != "root" && u != "0" { resolved := resolveUser(f, u) @@ -364,7 +364,7 @@ func checkTemplate(game map[string]any, f *imageFacts) (problems, notes []string } if cmd, _ := game["defaultStartupCommand"].(string); cmd != "" { for _, m := range assignedVars.FindAllStringSubmatch(cmd, -1) { - provided[m[1]] = true // set by the command itself, e.g. export ASA_START_PARAMS=... + provided[m[1]] = true // set by the command itself, e.g. export START_PARAMS=... } withDefault := map[string]bool{} for _, m := range defaultedRef.FindAllStringSubmatch(cmd, -1) { @@ -410,7 +410,7 @@ func containerPort(p string) string { return p } -// startupScriptPaths picks absolute paths out of a template's startup command (e.g. ARK's +// startupScriptPaths picks absolute paths out of a template's startup command (e.g. an image's // /usr/bin/start_server) so the probe reads those scripts too. func startupScriptPaths(game map[string]any) []string { cmd, _ := game["defaultStartupCommand"].(string) diff --git a/cmd/gbrgames/inspect_test.go b/cmd/gbrgames/inspect_test.go index 8b80946..ebfb33d 100644 --- a/cmd/gbrgames/inspect_test.go +++ b/cmd/gbrgames/inspect_test.go @@ -21,7 +21,7 @@ func TestEnvNameMatchesNodeAgent(t *testing.T) { } } -// Probe output as ARK's image produces it (trimmed) +// Probe output of an image built around its own user (trimmed) const arkProbe = probeMarker + ` passwd root:x:0:0:root:/root:/bin/bash gameserver:x:25000:25000::/home/gameserver:/bin/bash @@ -34,7 +34,7 @@ bash|/usr/bin/bash ` + probeMarker + ` script /usr/bin/start_server #!/bin/bash cd /home/gameserver/steamcmd -echo "$ASA_START_PARAMS ${ENABLE_DEBUG} $HOME" +echo "$START_PARAMS ${ENABLE_DEBUG} $HOME" ` func arkFacts() *imageFacts { @@ -55,7 +55,7 @@ func TestProbeParsing(t *testing.T) { if !strings.Contains(f.Scripts["/usr/bin/start_server"], "steamcmd") { t.Fatalf("script not captured: %q", f.Scripts) } - if want := []string{"ASA_START_PARAMS", "ENABLE_DEBUG"}; !reflect.DeepEqual(f.EnvRead, want) { + if want := []string{"ENABLE_DEBUG", "START_PARAMS"}; !reflect.DeepEqual(f.EnvRead, want) { t.Errorf("envRead = %v, want %v (HOME is common and left out)", f.EnvRead, want) } } @@ -63,7 +63,7 @@ func TestProbeParsing(t *testing.T) { func TestSuggestContainerUser(t *testing.T) { // Locked home owned by another uid: use that account, with its own group if f := arkFacts(); f.SuggestedContainerUser != "25000:25000" { - t.Errorf("ARK: suggested %q, want 25000:25000", f.SuggestedContainerUser) + t.Errorf("suggested %q, want 25000:25000", f.SuggestedContainerUser) } // uid 1000 images need nothing @@ -92,9 +92,9 @@ func TestCheckTemplate(t *testing.T) { f := arkFacts() game := map[string]any{ "defaultConfig": map[string]any{"RCONPort": 27020, "MaxPlayers": 70}, - // Mirrors ARK's old command: ASA_START_PARAMS is assigned, SERVER_MAP has a default, RCON_PORT is the + // A command where START_PARAMS is assigned, SERVER_MAP has a default, RCON_PORT is the // misnamed one, MAX_PLAYERS is set by a setting, MISSING has no default at all - "defaultStartupCommand": `export ASA_START_PARAMS="${ASA_START_PARAMS:-${SERVER_MAP:-TheIsland}?RCONPort=${RCON_PORT:-27020} -Max=${MAX_PLAYERS:-70} ${MISSING}}"`, + "defaultStartupCommand": `export START_PARAMS="${START_PARAMS:-${SERVER_MAP:-DefaultMap}?RCONPort=${RCON_PORT:-27020} -Max=${MAX_PLAYERS:-70} ${MISSING}}"`, } problems, _ := checkTemplate(game, f) joined := strings.Join(problems, "\n") @@ -103,7 +103,7 @@ func TestCheckTemplate(t *testing.T) { t.Errorf("missing problem %q in:\n%s", want, joined) } } - for _, unwanted := range []string{"ASA_START_PARAMS", "SERVER_MAP", "MAX_PLAYERS}"} { + for _, unwanted := range []string{"START_PARAMS", "SERVER_MAP", "MAX_PLAYERS}"} { if strings.Contains(joined, unwanted) { t.Errorf("false problem about %s in:\n%s", unwanted, joined) } @@ -114,7 +114,7 @@ func TestCheckTemplate(t *testing.T) { // With the fixes, only the deliberately missing variable is left; 25000:1000 counts (same uid) game["containerUser"] = "25000:1000" - game["defaultStartupCommand"] = `export ASA_START_PARAMS="${ASA_START_PARAMS:-x}?RCONPort=${RCONPORT:-27020}"` + game["defaultStartupCommand"] = `export START_PARAMS="${START_PARAMS:-x}?RCONPort=${RCONPORT:-27020}"` if problems, _ := checkTemplate(game, f); len(problems) != 0 { t.Errorf("fixed template still has problems: %v", problems) } diff --git a/cmd/gbrgames/research.go b/cmd/gbrgames/research.go index 167fa7a..deae108 100644 --- a/cmd/gbrgames/research.go +++ b/cmd/gbrgames/research.go @@ -76,7 +76,7 @@ var researchSchema = map[string]any{ func runResearch(args []string) error { fs := flag.NewFlagSet("research", flag.ExitOnError) - gameName := fs.String("name", "", "the game's name, e.g. \"Valheim\"") + 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)") @@ -171,8 +171,8 @@ func research(m model, gameName, notes string, pages int) (*researchResult, erro 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): different editions (e.g. Java vs Bedrock, Evolved vs " + - "Ascended), crossplay builds, or notably different server images. For each variant give:\n" + + "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" + @@ -250,13 +250,18 @@ func dedupeHits(hits []searchHit) []searchHit { } 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) > 9000 { - text = text[:9000] + "\n...(truncated)" + if len(text) > limit { + text = text[:limit] + "\n...(truncated)" } return text, nil } diff --git a/cmd/gbrgames/research_test.go b/cmd/gbrgames/research_test.go index 28c1a5c..9f39018 100644 --- a/cmd/gbrgames/research_test.go +++ b/cmd/gbrgames/research_test.go @@ -4,17 +4,17 @@ import "testing" func TestPickPagesPrefersDocsAndOnePerSite(t *testing.T) { hits := []searchHit{ - {URL: "https://blog.example.com/valheim-server"}, - {URL: "https://github.com/a/valheim-docker"}, + {URL: "https://blog.example.com/example-server"}, + {URL: "https://github.com/a/example-docker"}, {URL: "https://github.com/b/other"}, - {URL: "https://hub.docker.com/r/x/valheim"}, - {URL: "https://blog.example.com/valheim-server"}, + {URL: "https://hub.docker.com/r/x/example"}, + {URL: "https://blog.example.com/example-server"}, } got := pickPages(hits, 3) if len(got) != 3 { t.Fatalf("got %d pages", len(got)) } - if got[0].URL != "https://github.com/a/valheim-docker" || got[1].URL != "https://hub.docker.com/r/x/valheim" || got[2].URL != "https://blog.example.com/valheim-server" { + if got[0].URL != "https://github.com/a/example-docker" || got[1].URL != "https://hub.docker.com/r/x/example" || got[2].URL != "https://blog.example.com/example-server" { t.Errorf("order: %v", got) } } @@ -26,16 +26,16 @@ func TestTrimToJSON(t *testing.T) { } func TestAppNameFromAppInfo(t *testing.T) { - out := `"896660" + out := `"1000000" { "common" { - "name" "Valheim Dedicated Server" + "name" "Example Dedicated Server" "type" "Tool" } }` m := appName.FindStringSubmatch(out) - if m == nil || m[1] != "Valheim Dedicated Server" { + if m == nil || m[1] != "Example Dedicated Server" { t.Errorf("got %v", m) } } diff --git a/schema/game.schema.json b/schema/game.schema.json index 7ba4bf9..d563340 100644 --- a/schema/game.schema.json +++ b/schema/game.schema.json @@ -17,7 +17,7 @@ "defaultDockerImage": { "description": "Image the game server runs in.", "type": "string", "minLength": 1 }, "containerUser": { - "description": "uid:gid the server runs as, for images built around their own user (ARK's needs 25000:25000). Defaults to 1000:1000; root is never allowed.", + "description": "uid:gid the server runs as, for images built around their own user, as inspect-image reports it. Defaults to 1000:1000; root is never allowed.", "type": ["string", "null"], "pattern": "^[1-9][0-9]{0,8}:[1-9][0-9]{0,8}$" },