- The drafter no longer grows one conversation. Each request is the rules and facts, the model's latest template, what is wrong now and one line per earlier round, so long builds can't push the facts out of the model's 32K context (Ollama drops the oldest part silently). The image's scripts in the facts are capped at 24000 characters in all, and diagnosis check results at 16000. - Every model call logs its prompt and answer tokens, with a warning near the context limit. - unsafeCommands: a draft whose startup command or installConfig pipes a download into a shell, decodes base64, uses eval, raw network tools or /dev/tcp, sudo/su, setuid bits, writes /etc, touches cron/systemd/firewall, or looks like a miner goes back to the model. AUTHORING.md has the rule. - The PR summary lists every address nodes would download from under "Review carefully", marks the measured checks as measured, and labels the research notes as the model's reading, not checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
806 lines
30 KiB
Go
806 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
)
|
|
|
|
// draft writes a first template for a new game with an AI model: it gives the model AUTHORING.md as its
|
|
// rules, the facts read from the image (inspect-image) and, when available, a first-boot report, plus two
|
|
// working templates as examples. The answer is written to games/<id>/ and checked with the same validator
|
|
// and image checks as any template; problems go back to the model to fix, a few rounds at most.
|
|
|
|
type chatMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type model interface {
|
|
chat(messages []chatMessage, schema map[string]any) (string, error)
|
|
name() string
|
|
}
|
|
|
|
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, 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")
|
|
notes := fs.String("notes", "", "anything else the model should know (variant, crossplay plugin...)")
|
|
reportPath := fs.String("first-boot", "", "a first-boot report.json for this image")
|
|
provider := fs.String("provider", "ollama", "ollama or gemini")
|
|
modelName := fs.String("model", "", "model name (default qwen3.8:27b for ollama, gemini-3.5-flash for gemini)")
|
|
attempts := fs.Int("attempts", 3, "fix rounds after the first draft")
|
|
noPull := fs.Bool("no-pull", false, "use the local image instead of pulling it")
|
|
fs.Parse(args)
|
|
|
|
if *gameName == "" || *image == "" {
|
|
return errors.New("draft needs -name and -image")
|
|
}
|
|
var report *bootReport
|
|
if *reportPath != "" {
|
|
var err error
|
|
if report, err = readBootReport(*reportPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
d, err := newDrafter(draftJob{GamesDir: *gamesDir, SchemaDir: *schemaDir, Name: *gameName, ID: *id, Image: *image,
|
|
SteamApp: *steamApp, Notes: *notes, Report: report, Provider: *provider, Model: *modelName, NoPull: *noPull})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if problems, err := d.refine(*attempts, nil); err != nil {
|
|
return err
|
|
} else if len(problems) > 0 {
|
|
return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, d.dir)
|
|
}
|
|
fmt.Println("[draft] next: review it, then boot-test it (build / CI) before opening a PR")
|
|
return nil
|
|
}
|
|
|
|
type draftJob struct {
|
|
GamesDir, SchemaDir string
|
|
Name, ID, Image string
|
|
SteamApp, Notes string
|
|
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 keeps what the model needs about one game across rounds. Each request is rebuilt from it
|
|
// instead of growing one conversation: a local model's context is limited, and when a conversation
|
|
// outgrows it the oldest part, the facts, is silently dropped. A request is the rules and the facts,
|
|
// the model's latest template, what is wrong with it now, and one line per earlier round.
|
|
type drafter struct {
|
|
job draftJob
|
|
dir, id string
|
|
m model
|
|
facts *imageFacts
|
|
gameSchema, testSchema *jsonschema.Schema
|
|
answerSchema map[string]any
|
|
base []chatMessage // the rules and the facts
|
|
last string // the model's latest answer (or the template being repaired)
|
|
history []string // one line per earlier round
|
|
rounds int
|
|
}
|
|
|
|
func newDrafter(j draftJob) (*drafter, error) {
|
|
id := j.ID
|
|
if id == "" {
|
|
id = gameID(j.Name)
|
|
}
|
|
d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)}
|
|
if _, err := os.Stat(d.dir); err == nil {
|
|
return nil, fmt.Errorf("%s already exists; drafts only create new games", d.dir)
|
|
}
|
|
var err error
|
|
if d.m, err = newModel(j.Provider, j.Model); err != nil {
|
|
return nil, err
|
|
}
|
|
rules, err := os.ReadFile("AUTHORING.md")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("AUTHORING.md (the model's rules): %w", err)
|
|
}
|
|
if d.gameSchema, err = compileSchema(filepath.Join(j.SchemaDir, "game.schema.json")); err != nil {
|
|
return nil, err
|
|
}
|
|
if d.testSchema, err = compileSchema(filepath.Join(j.SchemaDir, "test.schema.json")); err != nil {
|
|
return nil, err
|
|
}
|
|
if d.answerSchema, err = draftAnswerSchema(j.SchemaDir); err != nil {
|
|
return nil, err
|
|
}
|
|
if !j.NoPull {
|
|
fmt.Printf("[draft] pulling %s\n", j.Image)
|
|
if out, err := runCmd("docker", "pull", "-q", j.Image); err != nil {
|
|
return nil, fmt.Errorf("docker pull: %v: %s", err, out)
|
|
}
|
|
}
|
|
if d.facts, err = inspectImage(j.Image, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
if why := imageUnusable(j.Report); why != "" {
|
|
return nil, errors.New(why)
|
|
}
|
|
d.base = []chatMessage{
|
|
{Role: "system", Content: draftSystemPrompt(string(rules))},
|
|
{Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report)},
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// request builds one request: the rules and facts, and once there is a template, that template with
|
|
// what is wrong with it and a line per earlier round.
|
|
func (d *drafter) request(problems []string) []chatMessage {
|
|
msgs := append([]chatMessage{}, d.base...)
|
|
if d.last == "" {
|
|
return msgs
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("Your template (above) has these problems. Fix all of them and answer with the complete corrected " +
|
|
"JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- "))
|
|
if len(d.history) > 0 {
|
|
b.WriteString("\n\nEarlier rounds (already dealt with: don't undo those fixes):\n- " + strings.Join(d.history, "\n- "))
|
|
}
|
|
return append(msgs, chatMessage{Role: "assistant", Content: d.last}, chatMessage{Role: "user", Content: b.String()})
|
|
}
|
|
|
|
func gameID(name string) string {
|
|
return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(name), "-"), "-")
|
|
}
|
|
|
|
// refine asks for a (corrected) template until it passes validation and the image checks, at most
|
|
// attempts+1 answers. feedback, when given, is what went wrong since the last answer (e.g. its boot).
|
|
// It returns the problems still left, nil when the draft passes.
|
|
func (d *drafter) refine(attempts int, feedback []string) ([]string, error) {
|
|
problems := feedback
|
|
for round := 0; round <= attempts; round++ {
|
|
d.rounds++
|
|
fmt.Printf("[draft] round %d: asking %s\n", d.rounds, d.m.name())
|
|
start := time.Now()
|
|
answer, err := d.m.chat(d.request(problems), d.answerSchema)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds())
|
|
if len(problems) > 0 {
|
|
d.history = append(d.history, fmt.Sprintf("round %d fixed: %s", d.rounds, summarize(problems)))
|
|
}
|
|
d.last = answer
|
|
|
|
if problems = d.check(answer); len(problems) == 0 {
|
|
fmt.Printf("[draft] %s passes validation and the image checks\n", d.dir)
|
|
return nil, nil
|
|
}
|
|
fmt.Printf("[draft] %d problem(s):\n", len(problems))
|
|
for _, p := range problems {
|
|
fmt.Println(" - " + p)
|
|
}
|
|
}
|
|
return problems, nil
|
|
}
|
|
|
|
// summarize is one line for the round history: the first problem, and how many more.
|
|
func summarize(problems []string) string {
|
|
s := firstLine(problems[0])
|
|
if len(s) > 160 {
|
|
s = s[:160] + "…"
|
|
}
|
|
if len(problems) > 1 {
|
|
s += fmt.Sprintf(" (and %d more)", len(problems)-1)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (d *drafter) check(answer string) []string {
|
|
problems := writeDraft(d.dir, d.id, answer, d.job.Report)
|
|
if len(problems) == 0 {
|
|
problems = validateGame(d.dir, d.id, d.gameSchema, d.testSchema)
|
|
}
|
|
if len(problems) == 0 {
|
|
if g, err := loadMap(filepath.Join(d.dir, "game.yaml")); err == nil {
|
|
imageProblems, _ := checkTemplate(g, d.facts)
|
|
problems = append(problems, imageProblems...)
|
|
t, _ := loadMap(filepath.Join(d.dir, "test.yaml"))
|
|
problems = append(problems, draftCompleteness(g, t)...)
|
|
problems = append(problems, unreadSettings(g, d.facts)...)
|
|
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)...)
|
|
problems = append(problems, unsafeCommands(g)...)
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
func readBootReport(p string) (*bootReport, error) {
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &bootReport{}
|
|
if err := json.Unmarshal(raw, r); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", p, err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func draftSystemPrompt(rules string) string {
|
|
return "You write game server templates for GBR Hosting. Follow these house rules exactly; they describe " +
|
|
"how servers really run, and breaking them has broken real servers:\n\n" + rules + "\n\n" +
|
|
"Answer with one JSON object and nothing else: {\"game\": <the game.yaml content as JSON>, " +
|
|
"\"test\": <the test.yaml content as JSON>}.\n\n" +
|
|
"The template must be COMPLETE enough for a player to run and configure the server:\n" +
|
|
"- defaultPorts: every port players or the server browser connect to (from the image's exposed ports " +
|
|
"and the game's documentation), each with its protocol. Never RCON/REST/admin or web/status ports.\n" +
|
|
"- defaultConfig and configSchema: every setting the image reads (the facts' envRead and env lists, the " +
|
|
"first-boot settings) that an owner would want to change: server name, world, password, player limit, " +
|
|
"difficulty and gameplay options. Each with title, description, type, default and category.\n" +
|
|
"- requiredMemoryMb: what the game really needs, with headroom.\n" +
|
|
"- readyPattern when a first-boot report shows the ready line; otherwise set test.settleSec.\n" +
|
|
"Base every value on the facts given (image facts, first-boot report) and on what you know for certain " +
|
|
"about this game and image. Do not invent settings an image does not read; say what you are unsure of " +
|
|
"in the setting's description instead of leaving the template empty."
|
|
}
|
|
|
|
// draftCompleteness is the bar a draft must clear on top of validation: an almost empty template is
|
|
// valid but useless, and a cautious model will happily produce one.
|
|
func draftCompleteness(game, test map[string]any) []string {
|
|
var problems []string
|
|
if ports, _ := game["defaultPorts"].([]any); len(ports) == 0 {
|
|
problems = append(problems, "defaultPorts is empty: list the ports players connect to")
|
|
}
|
|
props := schemaProperties(game)
|
|
defaults, _ := game["defaultConfig"].(map[string]any)
|
|
if len(defaults) < 3 {
|
|
problems = append(problems, fmt.Sprintf("defaultConfig has only %d settings: add every setting the image reads that an owner would change (name, world, password, players, gameplay)", len(defaults)))
|
|
}
|
|
var undescribed []string
|
|
for _, key := range sortedKeys(defaults) {
|
|
if _, ok := props[key]; !ok {
|
|
undescribed = append(undescribed, key)
|
|
}
|
|
}
|
|
if len(undescribed) > 0 {
|
|
problems = append(problems, "these defaultConfig settings have no configSchema.properties entry (each needs title, description, "+
|
|
"type, default and category, under configSchema: {type: object, properties: {...}}): "+strings.Join(undescribed, ", "))
|
|
}
|
|
for _, key := range sortedKeys(props) {
|
|
p, _ := props[key].(map[string]any)
|
|
for _, field := range []string{"title", "description", "type", "category"} {
|
|
if v, ok := p[field]; !ok || v == "" {
|
|
problems = append(problems, fmt.Sprintf("setting %s has no %s", key, field))
|
|
}
|
|
}
|
|
}
|
|
if n, ok := game["requiredMemoryMb"].(json.Number); !ok {
|
|
problems = append(problems, "requiredMemoryMb is missing")
|
|
} else if v, _ := n.Int64(); v < 1024 {
|
|
problems = append(problems, "requiredMemoryMb is below 1024")
|
|
}
|
|
// Secrets only exist as CI settings a person adds; a draft with one can't be boot-tested anywhere
|
|
if secrets, _ := test["secrets"].(map[string]any); len(secrets) > 0 {
|
|
problems = append(problems, "test.yaml has 'secrets' ("+strings.Join(sortedKeys(secrets), ", ")+"), which no boot test of a "+
|
|
"new game has: remove them. A setting the server needs a password in gets \"x-generate\" instead")
|
|
}
|
|
if _, ok := game["readyPattern"]; !ok {
|
|
if _, ok := test["settleSec"]; !ok {
|
|
problems = append(problems, "no readyPattern and no test.settleSec: boot tests need one of them")
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// unreadSettings finds settings that reach the container under a variable name the image never reads,
|
|
// e.g. ServerPassword -> SERVER_PASSWORD when the image reads SERVER_PASS. Only for images configured
|
|
// through environment variables (no config file) whose startup scripts the inspector could read.
|
|
func unreadSettings(game map[string]any, facts *imageFacts) []string {
|
|
if path, _ := game["configFilePath"].(string); path != "" || len(facts.EnvRead) == 0 {
|
|
return nil
|
|
}
|
|
read := map[string]bool{}
|
|
for _, v := range facts.EnvRead {
|
|
read[v] = true
|
|
}
|
|
for v := range facts.Env {
|
|
read[v] = true
|
|
}
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
defaults, _ := game["defaultConfig"].(map[string]any)
|
|
var problems []string
|
|
for _, key := range sortedKeys(defaults) {
|
|
env := envName(key)
|
|
if read[env] || strings.Contains(startup, env) {
|
|
continue
|
|
}
|
|
msg := fmt.Sprintf("setting %s reaches the container as %s, which the image never reads", key, env)
|
|
if near := closestVar(env, facts.EnvRead); near != "" {
|
|
msg += fmt.Sprintf(" (did you mean %s? name the key so it becomes that)", near)
|
|
}
|
|
problems = append(problems, msg)
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// closestVar picks the variable that shares the longest common prefix with name (SERVER_PASSWORD ->
|
|
// SERVER_PASS), if it's a real resemblance.
|
|
func closestVar(name string, vars []string) string {
|
|
best, bestLen := "", 0
|
|
for _, v := range vars {
|
|
n := 0
|
|
for n < len(v) && n < len(name) && v[n] == name[n] {
|
|
n++
|
|
}
|
|
if n > bestLen {
|
|
best, bestLen = v, n
|
|
}
|
|
}
|
|
if bestLen < 5 {
|
|
return ""
|
|
}
|
|
return best
|
|
}
|
|
|
|
// savesOutsideVolumes turns first boot's "written outside the volumes" into a hard check: anything 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 {
|
|
dirs := outsideDirs(report)
|
|
if len(dirs) == 0 {
|
|
return nil
|
|
}
|
|
startup, _ := game["defaultStartupCommand"].(string)
|
|
redirected := strings.Contains(startup, "/server") || strings.Contains(startup, "/data")
|
|
defaults, _ := game["defaultConfig"].(map[string]any)
|
|
for _, v := range defaults {
|
|
if s, ok := v.(string); ok && (strings.HasPrefix(s, "/server") || strings.HasPrefix(s, "/data")) {
|
|
redirected = true
|
|
}
|
|
}
|
|
if redirected {
|
|
return nil
|
|
}
|
|
return []string{fmt.Sprintf("first boot: the server wrote its own files outside the server volume, in %s. Those are lost "+
|
|
"whenever the node recreates the container (every start). Make the game keep them under /server, e.g. with "+
|
|
"its save-directory launch option or by pointing that folder at /server, and include it in backupPaths",
|
|
strings.Join(dirs, ", "))}
|
|
}
|
|
|
|
// outsideDirs lists the folders outside the volumes where the game itself wrote files during a boot.
|
|
func outsideDirs(report *bootReport) []string {
|
|
if report == nil {
|
|
return nil
|
|
}
|
|
dirs := map[string]bool{}
|
|
for _, c := range report.ContainerChanges {
|
|
// A report saved by an older version can still list files that aren't the game's state
|
|
if _, p, _ := strings.Cut(c, " "); p != "" && !notGameState(p) {
|
|
dirs[path.Dir(p)] = true
|
|
}
|
|
}
|
|
return sortedKeysBool(dirs)
|
|
}
|
|
|
|
// imageUnusable recognises a first boot where the image died within seconds because it needs root (or
|
|
// another privilege nodes never grant): drafting a template for it can't produce a working server.
|
|
func imageUnusable(r *bootReport) string {
|
|
if r == nil || r.ExitCode == nil || *r.ExitCode == 0 || r.RanSeconds > 60 {
|
|
return ""
|
|
}
|
|
var denied []string
|
|
for _, l := range r.ErrorLines {
|
|
if strings.Contains(strings.ToLower(l.Text), "permission denied") {
|
|
denied = append(denied, l.Text)
|
|
}
|
|
}
|
|
if len(denied) == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("first boot: the image exited (code %d) after %.0fs as %s with permission errors, e.g. %q. It needs root, "+
|
|
"which nodes never allow; use another image for this game, or cm2network/steamcmd with -steam-app",
|
|
*r.ExitCode, r.RanSeconds, r.User, denied[0])
|
|
}
|
|
|
|
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 != "" {
|
|
fmt.Fprintf(&b, "- installMethod STEAMCMD with appId %s\n", steamApp)
|
|
}
|
|
if notes != "" {
|
|
fmt.Fprintf(&b, "- notes: %s\n", notes)
|
|
}
|
|
|
|
// The image's scripts are the biggest part of the facts: 8000 characters each, 24000 in all, so the
|
|
// request leaves the model room for the rest and for its answer
|
|
f := *facts
|
|
f.Scripts = map[string]string{}
|
|
budget := 24000
|
|
for _, p := range sortedKeysString(facts.Scripts) {
|
|
s := facts.Scripts[p]
|
|
limit := min(8000, budget)
|
|
if limit <= 0 {
|
|
f.Scripts[p] = "(left out: too long for the request)"
|
|
continue
|
|
}
|
|
if len(s) > limit {
|
|
s = s[:limit] + "\n...(truncated)"
|
|
}
|
|
budget -= len(s)
|
|
f.Scripts[p] = s
|
|
}
|
|
fj, _ := json.MarshalIndent(f, "", " ")
|
|
fmt.Fprintf(&b, "\n## Facts read from the image (inspect-image)\n```json\n%s\n```\n", fj)
|
|
|
|
if report != nil {
|
|
fmt.Fprintf(&b, "\n## First boot with no settings\nOutcome: %s\n", report.Outcome)
|
|
if report.SuggestedReady != "" {
|
|
fmt.Fprintf(&b, "Suggested readyPattern: `%s` (readyExample: `%s`)\n", report.SuggestedReady, report.SuggestedExample)
|
|
}
|
|
for _, l := range report.ReadyCandidates {
|
|
fmt.Fprintf(&b, "ready-looking line: %s\n", l.Text)
|
|
}
|
|
for _, l := range report.ErrorLines {
|
|
fmt.Fprintf(&b, "error-looking line: %s\n", l.Text)
|
|
}
|
|
fmt.Fprintf(&b, "Config files the server wrote: %s\n", strings.Join(report.ConfigFiles, ", "))
|
|
settings := report.Settings
|
|
if len(settings) > 300 {
|
|
settings = settings[:300]
|
|
}
|
|
b.WriteString("Settings it wrote, with defaults (file | section | key = value):\n")
|
|
for _, s := range settings {
|
|
fmt.Fprintf(&b, "%s | %s | %s = %s\n", s.File, s.Section, s.Key, s.Value)
|
|
}
|
|
} else {
|
|
b.WriteString("\n(No first-boot report: keep readyPattern out unless the facts show the ready line.)\n")
|
|
}
|
|
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()
|
|
}
|
|
|
|
// 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.
|
|
func writeDraft(dir, id, answer string, report *bootReport) []string {
|
|
answer = trimToJSON(answer)
|
|
var parsed struct {
|
|
Game map[string]any `json:"game"`
|
|
Test map[string]any `json:"test"`
|
|
}
|
|
if err := json.Unmarshal([]byte(answer), &parsed); err != nil {
|
|
return []string{"the answer is not valid JSON: " + err.Error()}
|
|
}
|
|
if parsed.Game == nil {
|
|
return []string{`the answer has no "game" object`}
|
|
}
|
|
parsed.Game["id"] = id
|
|
if parsed.Test == nil {
|
|
parsed.Test = map[string]any{}
|
|
}
|
|
// The ready line is a fact from the first boot, already checked against the real log line; a model
|
|
// that leaves it out (or answers null) gets the measured one rather than another guessing round
|
|
if p, _ := parsed.Game["readyPattern"].(string); p == "" && report != nil && report.SuggestedReady != "" {
|
|
parsed.Game["readyPattern"] = report.SuggestedReady
|
|
parsed.Test["readyExample"] = report.SuggestedExample
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return []string{err.Error()}
|
|
}
|
|
var problems []string
|
|
known := map[string]bool{}
|
|
for _, f := range gameFields {
|
|
known[f] = true
|
|
}
|
|
for k := range parsed.Game {
|
|
if !known[k] {
|
|
problems = append(problems, fmt.Sprintf("%q is not a template field; remove it or use one from the schema", k))
|
|
}
|
|
}
|
|
if err := writeGame(filepath.Join(dir, "game.yaml"), parsed.Game); err != nil {
|
|
return []string{"could not write game.yaml: " + err.Error()}
|
|
}
|
|
node, err := toNode(normalize(parsed.Test))
|
|
if err != nil {
|
|
return []string{err.Error()}
|
|
}
|
|
if err := writeYAML(filepath.Join(dir, "test.yaml"), testHeader, node); err != nil {
|
|
return []string{"could not write test.yaml: " + err.Error()}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// draftAnswerSchema is the shape Ollama is asked to produce: {"game": ..., "test": ...}, using the repo's
|
|
// schemas minus the parts structured-output engines don't support.
|
|
func draftAnswerSchema(schemaDir string) (map[string]any, error) {
|
|
load := func(name string) (map[string]any, error) {
|
|
raw, err := os.ReadFile(filepath.Join(schemaDir, name))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var s map[string]any
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, k := range []string{"$schema", "$id", "allOf", "title", "description"} {
|
|
delete(s, k)
|
|
}
|
|
return s, nil
|
|
}
|
|
game, err := load("game.schema.json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
test, err := load("test.schema.json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tightenForDraft(game)
|
|
return map[string]any{
|
|
"type": "object",
|
|
"required": []string{"game", "test"},
|
|
"properties": map[string]any{"game": game, "test": test},
|
|
"additionalProperties": false,
|
|
}, nil
|
|
}
|
|
|
|
// tightenForDraft makes the fields a working template needs required and non-null in the format Ollama
|
|
// enforces. The repo schema leaves them optional (hand-written templates may omit some), and a constrained
|
|
// model takes that literally: it answered null for installConfig, defaultConfig and configSchema, the
|
|
// writer dropped them, and no amount of "add installConfig" could change that.
|
|
func tightenForDraft(game map[string]any) {
|
|
props, _ := game["properties"].(map[string]any)
|
|
nonNull := func(name, typ string) {
|
|
if p, ok := props[name].(map[string]any); ok {
|
|
p["type"] = typ
|
|
}
|
|
}
|
|
nonNull("defaultStartupCommand", "string")
|
|
nonNull("requiredMemoryMb", "integer")
|
|
nonNull("defaultPorts", "array")
|
|
if p, ok := props["defaultPorts"].(map[string]any); ok {
|
|
p["minItems"] = 1
|
|
}
|
|
props["installConfig"] = map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"appId": map[string]any{"type": "string", "description": "Steam app id, for installMethod STEAMCMD"},
|
|
"script": map[string]any{"type": "string", "description": "install script, for installMethod BASH_SCRIPT"},
|
|
},
|
|
}
|
|
props["defaultConfig"] = map[string]any{"type": "object", "minProperties": 3}
|
|
props["configSchema"] = map[string]any{
|
|
"type": "object",
|
|
"required": []string{"type", "properties"},
|
|
"properties": map[string]any{
|
|
"type": map[string]any{"enum": []string{"object"}},
|
|
"properties": map[string]any{
|
|
"type": "object",
|
|
"minProperties": 3,
|
|
"additionalProperties": map[string]any{
|
|
"type": "object",
|
|
"required": []string{"title", "description", "type", "default", "category"},
|
|
"properties": map[string]any{
|
|
"title": map[string]any{"type": "string"},
|
|
"description": map[string]any{"type": "string"},
|
|
"type": map[string]any{"enum": []string{"string", "integer", "number", "boolean"}},
|
|
"category": map[string]any{"enum": []string{"Security & Access", "Gameplay & Rules", "World & Environment", "System & General"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
game["required"] = []string{"id", "name", "defaultDockerImage", "defaultStartupCommand", "requiredMemoryMb",
|
|
"defaultPorts", "installMethod", "installConfig", "defaultConfig", "configSchema"}
|
|
}
|
|
|
|
func newModel(provider, name string) (model, error) {
|
|
switch provider {
|
|
case "ollama":
|
|
if name == "" {
|
|
name = "qwen3.8:27b"
|
|
}
|
|
url := os.Getenv("OLLAMA_URL")
|
|
if url == "" {
|
|
url = "http://localhost:11434"
|
|
}
|
|
return &ollamaModel{url: strings.TrimRight(url, "/"), model: name}, nil
|
|
case "gemini":
|
|
if name == "" {
|
|
name = "gemini-3.5-flash"
|
|
}
|
|
key := os.Getenv("GEMINI_API_KEY")
|
|
if key == "" {
|
|
return nil, errors.New("set GEMINI_API_KEY to use -provider gemini")
|
|
}
|
|
return &geminiModel{key: key, model: name}, nil
|
|
}
|
|
return nil, fmt.Errorf("unknown provider %q (ollama or gemini)", provider)
|
|
}
|
|
|
|
type ollamaModel struct{ url, model string }
|
|
|
|
func (o *ollamaModel) name() string { return "ollama " + o.model + " at " + o.url }
|
|
|
|
// The model's context in tokens. Ollama silently drops the oldest part of a prompt that doesn't fit, so
|
|
// every request reports how much of it is used
|
|
const ollamaContext = 32768
|
|
|
|
func (o *ollamaModel) chat(messages []chatMessage, schema map[string]any) (string, error) {
|
|
body := map[string]any{
|
|
"model": o.model, "messages": messages, "stream": false, "format": schema,
|
|
"options": map[string]any{"num_ctx": ollamaContext, "temperature": 0.2},
|
|
}
|
|
var resp struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
PromptTokens int `json:"prompt_eval_count"`
|
|
AnswerTokens int `json:"eval_count"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := postJSON(o.url+"/api/chat", body, &resp, 30*time.Minute); err != nil {
|
|
return "", err
|
|
}
|
|
if resp.Error != "" {
|
|
return "", errors.New("ollama: " + resp.Error)
|
|
}
|
|
fmt.Println(contextNote(resp.PromptTokens, resp.AnswerTokens))
|
|
return resp.Message.Content, nil
|
|
}
|
|
|
|
func contextNote(prompt, answer int) string {
|
|
note := fmt.Sprintf("[model] %d prompt + %d answer tokens of %d", prompt, answer, ollamaContext)
|
|
if prompt+answer >= ollamaContext*9/10 {
|
|
note += ": WARNING, close to the model's context; the start of the prompt may have been cut"
|
|
}
|
|
return note
|
|
}
|
|
|
|
type geminiModel struct{ key, model string }
|
|
|
|
func (g *geminiModel) name() string { return "gemini " + g.model }
|
|
|
|
func (g *geminiModel) chat(messages []chatMessage, _ map[string]any) (string, error) {
|
|
var system string
|
|
var contents []map[string]any
|
|
for _, m := range messages {
|
|
if m.Role == "system" {
|
|
system = m.Content
|
|
continue
|
|
}
|
|
role := "user"
|
|
if m.Role == "assistant" {
|
|
role = "model"
|
|
}
|
|
contents = append(contents, map[string]any{"role": role, "parts": []map[string]string{{"text": m.Content}}})
|
|
}
|
|
body := map[string]any{
|
|
"systemInstruction": map[string]any{"parts": []map[string]string{{"text": system}}},
|
|
"contents": contents,
|
|
"generationConfig": map[string]any{"responseMimeType": "application/json", "temperature": 0.2},
|
|
}
|
|
var resp struct {
|
|
Candidates []struct {
|
|
Content struct {
|
|
Parts []struct {
|
|
Text string `json:"text"`
|
|
} `json:"parts"`
|
|
} `json:"content"`
|
|
} `json:"candidates"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
url := "https://generativelanguage.googleapis.com/v1beta/models/" + g.model + ":generateContent?key=" + g.key
|
|
if err := postJSON(url, body, &resp, 10*time.Minute); err != nil {
|
|
return "", err
|
|
}
|
|
if resp.Error != nil {
|
|
return "", errors.New("gemini: " + resp.Error.Message)
|
|
}
|
|
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
|
|
return "", errors.New("gemini returned no answer")
|
|
}
|
|
return resp.Candidates[0].Content.Parts[0].Text, nil
|
|
}
|
|
|
|
func postJSON(url string, body, out any, timeout time.Duration) error {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
resp, err := client.Post(url, "application/json", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
return fmt.Errorf("HTTP %d, unreadable answer: %.300s", resp.StatusCode, raw)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runCmd(name string, args ...string) (string, error) {
|
|
out, err := exec.Command(name, args...).CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|