From 929cbc31cf3ffd9b11c643f4b39d60234b78918c Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 17:58:27 -0400 Subject: [PATCH] fix(builder): keep every game's name and code out of the builder The builder must be game-agnostic, but its prompts named games: AUTHORING.md (the model's rules) used three games as examples, the research prompt listed two games' editions, the schema described containerUser by one game's image, and every draft prompt included two real templates from the repo, so the model saw, and could copy, other games' commands and options. - AUTHORING.md, the research prompt and the schema describe the rules without naming a game. - The draft prompt's format example is now one invented server (no real command, options, ports or app id), checked by TestExampleTemplateFollowsTheRules against validation and the draft checks. - Comments, help texts, BUILDER.md and test fixtures use invented names and values. - TestBuilderIsGameAgnostic reads every game's name and id from games/ and fails if any appears in the builder's code, tests, rules, schemas or docs. Co-Authored-By: Claude Opus 5.5 --- AUTHORING.md | 11 ++-- BUILDER.md | 11 ++-- cmd/gbrgames/agnostic_test.go | 51 +++++++++++++++++++ cmd/gbrgames/build_test.go | 16 +++--- cmd/gbrgames/draft.go | 86 +++++++++++++++++++++----------- cmd/gbrgames/draft_test.go | 71 +++++++++++++++++++------- cmd/gbrgames/draftchecks.go | 4 +- cmd/gbrgames/draftchecks_test.go | 28 +++++------ cmd/gbrgames/firstboot.go | 4 +- cmd/gbrgames/firstboot_test.go | 36 ++++++------- cmd/gbrgames/inspect.go | 8 +-- cmd/gbrgames/inspect_test.go | 16 +++--- cmd/gbrgames/research.go | 6 +-- cmd/gbrgames/research_test.go | 16 +++--- schema/game.schema.json | 2 +- 15 files changed, 243 insertions(+), 123 deletions(-) create mode 100644 cmd/gbrgames/agnostic_test.go diff --git a/AUTHORING.md b/AUTHORING.md index 9f7bea0..b886611 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -47,24 +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 (`-maxplayers` for Valheim) boots fine and its setting does nothing. + 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_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 0708e0a..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") @@ -140,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 } @@ -341,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 { @@ -407,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 != "" { @@ -451,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 61378f8..097341c 100644 --- a/cmd/gbrgames/draftchecks.go +++ b/cmd/gbrgames/draftchecks.go @@ -76,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], "'") { @@ -263,7 +263,7 @@ var ( // 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 -// (Valheim has no -maxplayers) boots fine and passes every boot test while its setting does nothing. +// 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 diff --git a/cmd/gbrgames/draftchecks_test.go b/cmd/gbrgames/draftchecks_test.go index 83aedca..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") { @@ -107,11 +107,11 @@ func TestPortSettings(t *testing.T) { } func TestUndocumentedOptionsCatchesMaxPlayers(t *testing.T) { - // PR #13's startup: every option is in Valheim's docs except -maxplayers - game := map[string]any{"defaultStartupCommand": `args=(-name "${SERVER_NAME:-GBRHosting - Valheim}" -port 2456 -world "${WORLD_NAME:-Dedicated}" -savedir /server); ` + + // 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}"); ./valheim_server.x86_64 "${args[@]}"`} - docs := "Launch with `-name`, `-port`, `-world`, `-password`, `-savedir ` and `-crossplay` to allow consoles." + `[ -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) 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 1ddfa1a..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" + 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}$" },