The builder must be game-agnostic, but its prompts named games: AUTHORING.md (the model's rules) used three games as examples, the research prompt listed two games' editions, the schema described containerUser by one game's image, and every draft prompt included two real templates from the repo, so the model saw, and could copy, other games' commands and options. - AUTHORING.md, the research prompt and the schema describe the rules without naming a game. - The draft prompt's format example is now one invented server (no real command, options, ports or app id), checked by TestExampleTemplateFollowsTheRules against validation and the draft checks. - Comments, help texts, BUILDER.md and test fixtures use invented names and values. - TestBuilderIsGameAgnostic reads every game's name and id from games/ and fails if any appears in the builder's code, tests, rules, schemas or docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
766 lines
28 KiB
Go
766 lines
28 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 holds one conversation with the model about one game, so later problems (a failed boot) can
|
|
// be sent back to it with everything it was told before.
|
|
type drafter struct {
|
|
job draftJob
|
|
dir, id string
|
|
m model
|
|
facts *imageFacts
|
|
gameSchema, testSchema *jsonschema.Schema
|
|
answerSchema map[string]any
|
|
messages []chatMessage
|
|
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.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)},
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
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) {
|
|
if len(feedback) > 0 {
|
|
d.ask(feedback)
|
|
}
|
|
var problems []string
|
|
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.messages, d.answerSchema)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds())
|
|
d.messages = append(d.messages, chatMessage{Role: "assistant", Content: 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)
|
|
}
|
|
if round < attempts {
|
|
d.ask(problems)
|
|
}
|
|
}
|
|
return problems, nil
|
|
}
|
|
|
|
func (d *drafter) ask(problems []string) {
|
|
d.messages = append(d.messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " +
|
|
"answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")})
|
|
}
|
|
|
|
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)...)
|
|
}
|
|
}
|
|
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. Password settings get a throwaway value in boot tests automatically")
|
|
}
|
|
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
|
|
}
|
|
|
|
// Files the Steam client, Proton and the OS write for themselves: not the game's data
|
|
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 (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 {
|
|
_, p, _ := strings.Cut(c, " ")
|
|
noise := false
|
|
for _, n := range outsideNoise {
|
|
if strings.Contains(p, n) {
|
|
noise = true
|
|
}
|
|
}
|
|
if !noise && 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)
|
|
}
|
|
|
|
f := *facts
|
|
f.Scripts = map[string]string{}
|
|
for p, s := range facts.Scripts {
|
|
if len(s) > 8000 {
|
|
s = s[:8000] + "\n...(truncated)"
|
|
}
|
|
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 }
|
|
|
|
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,
|
|
// Ollama silently truncates input beyond its default context
|
|
"options": map[string]any{"num_ctx": 32768, "temperature": 0.2},
|
|
}
|
|
var resp struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
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)
|
|
}
|
|
return resp.Message.Content, nil
|
|
}
|
|
|
|
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
|
|
}
|