Files
gbr-games/cmd/gbrgames/draft_test.go
T
gregandClaude Opus 5.5 da4ffc86d2
Validate game templates / validate (push) Successful in 9m35s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
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>
2026-10-25 03:01:22 -04:00

87 lines
3.8 KiB
Go

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)
}
}