feat: draft new game templates with an AI model
`gbrgames draft -name <game> -image <image> [-steam-app ID] [-first-boot report.json]` has a model write games/<id>/game.yaml and test.yaml, then checks them like any template and sends problems back for up to three fix rounds. Ollama (default qwen3.8:27b, OLLAMA_URL) or Gemini (GEMINI_API_KEY). The model gets AUTHORING.md (new: the house rules, from how nodes really run servers and today's bugs: x-generate passwords, no published RCON ports, containerUser, heap below the limit, env var naming, ready lines), the inspect-image facts, the first-boot report and the two smallest templates as examples. With Ollama the answer is forced into the template schema, tightened for drafts so ports, install config, settings and their descriptions can't be left out or null. On top of validation and the image checks, drafts must: - be complete: player ports, >=3 settings each described, real memory, a readyPattern or settleSec - name settings so they reach the container as variables the image reads (ServerPassword -> SERVER_PASSWORD when the image reads SERVER_PASS) - not leave files first boot saw outside /server (lost on every start) A first boot showing the image dies within seconds on permission errors (it needs root) stops the draft before any model call. A missing readyPattern is filled from first boot's verified suggestion. Valheim, which we have no template for: lloesche/valheim-server was rejected as root-only; via SteamCMD (app 896660) qwen3.8:27b on a local 3090 Ti wrote a passing template in two rounds (~1 min). It is about 70% there: it chose "--save-dir /server", which Valheim doesn't have (-savedir), and missed -crossplay and 2457/udp. The save check is a text check; booting the draft itself is the next step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9d9cd6ff5
commit
da4ffc86d2
4 files changed
+853
-1
No files matched your search
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDraftCompletenessNamesMissingSchemaEntries(t *testing.T) {
|
||||
// The Valheim draft that went round in circles: settings in defaultConfig, no configSchema
|
||||
game := map[string]any{
|
||||
"defaultPorts": []any{"2456/udp"},
|
||||
"requiredMemoryMb": json.Number("4096"),
|
||||
"defaultConfig": map[string]any{"ServerName": "x", "ServerPassword": "", "MaxPlayers": json.Number("8")},
|
||||
}
|
||||
problems := strings.Join(draftCompleteness(game, map[string]any{"settleSec": json.Number("120")}), "\n")
|
||||
if !strings.Contains(problems, "no configSchema.properties entry") || !strings.Contains(problems, "MaxPlayers, ServerName, ServerPassword") {
|
||||
t.Errorf("want the missing schema entries named, got:\n%s", problems)
|
||||
}
|
||||
|
||||
game["configSchema"] = map[string]any{"properties": map[string]any{
|
||||
"ServerName": map[string]any{"title": "Name", "description": "d", "type": "string", "category": "System & General"},
|
||||
"ServerPassword": map[string]any{"title": "Password", "description": "d", "type": "string", "category": "Security & Access"},
|
||||
"MaxPlayers": map[string]any{"title": "Players", "description": "d", "type": "integer", "category": "System & General"},
|
||||
}}
|
||||
if p := draftCompleteness(game, map[string]any{"settleSec": json.Number("120")}); len(p) != 0 {
|
||||
t.Errorf("complete draft still has problems: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadSettings(t *testing.T) {
|
||||
facts := &imageFacts{EnvRead: []string{"SERVER_NAME", "SERVER_PASS", "SERVER_PUBLIC"}, Env: map[string]string{"TZ": "UTC"}}
|
||||
game := map[string]any{"defaultConfig": map[string]any{"ServerName": "x", "ServerPassword": "", "Tz": "UTC"}}
|
||||
problems := unreadSettings(game, facts)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "SERVER_PASSWORD") || !strings.Contains(problems[0], "did you mean SERVER_PASS") {
|
||||
t.Errorf("got %v", problems)
|
||||
}
|
||||
|
||||
// Settings written to a config file aren't environment-only, so nothing to check
|
||||
game["configFilePath"] = "server.cfg"
|
||||
if p := unreadSettings(game, facts); p != nil {
|
||||
t.Errorf("config-file game: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}}
|
||||
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") {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageUnusable(t *testing.T) {
|
||||
code := 2
|
||||
r := &bootReport{User: "1000:1000", ExitCode: &code, RanSeconds: 2.4, ErrorLines: []logLine{
|
||||
{Text: "ln: failed to create symbolic link '/etc/localtime': Permission denied"},
|
||||
}}
|
||||
if why := imageUnusable(r); !strings.Contains(why, "needs root") {
|
||||
t.Errorf("want a needs-root verdict, got %q", why)
|
||||
}
|
||||
|
||||
// A server that ran for minutes and then stopped is not this case
|
||||
r.RanSeconds = 600
|
||||
if why := imageUnusable(r); why != "" {
|
||||
t.Errorf("long run flagged: %q", why)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user