Compare commits
2
Commits
main
...
929cbc31cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929cbc31cf | ||
|
|
8b49895d5e |
No files matched your search
+7
-4
@@ -47,22 +47,25 @@ Know this before writing anything:
|
||||
|
||||
### The container user
|
||||
- Run `gbrgames inspect-image -game <id>`. 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
|
||||
|
||||
+8
-3
@@ -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 "<game>" -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 "<game>" -variant @v.json -out build.json
|
||||
```
|
||||
|
||||
The draft lands in `games/<id>/`, first-boot reports in `firstboot-out/`.
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -91,11 +91,11 @@ 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,
|
||||
"Game/Saved/Config/LinuxServer/x.ini": true,
|
||||
"Game/Saved/Logs/Server.log": true,
|
||||
"steamapps/appmanifest_123.acf": false,
|
||||
"Engine/Config/BaseEngine.ini": false,
|
||||
"plugins/Geyser/config.yml": false,
|
||||
"plugins/SomePlugin/config.yml": false,
|
||||
"world/level.dat": false,
|
||||
} {
|
||||
if got := isConfigCandidate(rel, 1000); got != want {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}$"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user