feat(builder): documented launch options only, and no game-specific code
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

Launch options must be documented. A draft passed an option its server
doesn't have; servers ignore unknown options, so it booted and passed every
boot test while its setting did nothing. build now reads the pages research
based the variant on, in full, and the drafter rejects any launch option in
the startup command that none of them, nor the image's own scripts, mention.
Shell tools' options (mkdir -p, [ -n ...]) are skipped; the check is off
without sources.

The builder is game-agnostic:
- AUTHORING.md (the model's rules), the research prompt and the schema
  describe the rules without naming a game.
- The draft prompt no longer includes real templates from the repo. Its
  format example is one invented server (no real command, options, ports or
  app id), checked by TestExampleTemplateFollowsTheRules.
- 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 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-25 17:58:53 -04:00
1 parent dd92b6be4c
commit 8623ba2d7b
16 files changed
+395 -120

No files matched your search

+51
View File
@@ -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)
}
}
}
}
+26 -1
View File
@@ -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()
}
+8 -8
View File
@@ -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)
}
+61 -29
View File
@@ -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/<id>/game.yaml and test.yaml, using the importer's
// writer so drafts look like every other template.
+53 -18
View File
@@ -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)
}
}
+76 -1
View File
@@ -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
}
+54 -10
View File
@@ -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 <path>` 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)
}
}
+2 -2
View File
@@ -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 {
+18 -18
View File
@@ -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)
+4 -4
View File
@@ -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)
+8 -8
View File
@@ -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)
}
+10 -5
View File
@@ -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
}
+8 -8
View File
@@ -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)
}
}