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,661 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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, e.g. \"Valheim\"")
|
||||
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")
|
||||
}
|
||||
if *id == "" {
|
||||
*id = strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(*gameName), "-"), "-")
|
||||
}
|
||||
dir := filepath.Join(*gamesDir, *id)
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return fmt.Errorf("%s already exists; drafts only create new games", dir)
|
||||
}
|
||||
|
||||
m, err := newModel(*provider, *modelName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := os.ReadFile("AUTHORING.md")
|
||||
if err != nil {
|
||||
return fmt.Errorf("AUTHORING.md (the model's rules): %w", err)
|
||||
}
|
||||
gameSchema, err := compileSchema(filepath.Join(*schemaDir, "game.schema.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
testSchema, err := compileSchema(filepath.Join(*schemaDir, "test.schema.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
answerSchema, err := draftAnswerSchema(*schemaDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !*noPull {
|
||||
fmt.Printf("[draft] pulling %s\n", *image)
|
||||
if out, err := runCmd("docker", "pull", "-q", *image); err != nil {
|
||||
return fmt.Errorf("docker pull: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
facts, err := inspectImage(*image, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var report *bootReport
|
||||
if *reportPath != "" {
|
||||
raw, err := os.ReadFile(*reportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report = &bootReport{}
|
||||
if err := json.Unmarshal(raw, report); err != nil {
|
||||
return fmt.Errorf("%s: %w", *reportPath, err)
|
||||
}
|
||||
if why := imageUnusable(report); why != "" {
|
||||
return errors.New(why)
|
||||
}
|
||||
}
|
||||
|
||||
messages := []chatMessage{
|
||||
{Role: "system", Content: draftSystemPrompt(string(rules))},
|
||||
{Role: "user", Content: draftRequest(*gameName, *id, *image, *steamApp, *notes, facts, report, exampleTemplates(*gamesDir, 2))},
|
||||
}
|
||||
|
||||
for round := 0; round <= *attempts; round++ {
|
||||
fmt.Printf("[draft] round %d: asking %s\n", round+1, m.name())
|
||||
start := time.Now()
|
||||
answer, err := m.chat(messages, answerSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds())
|
||||
messages = append(messages, chatMessage{Role: "assistant", Content: answer})
|
||||
|
||||
problems := writeDraft(dir, *id, answer, report)
|
||||
if len(problems) == 0 {
|
||||
problems = validateGame(dir, *id, gameSchema, testSchema)
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
if g, err := loadMap(filepath.Join(dir, "game.yaml")); err == nil {
|
||||
imageProblems, _ := checkTemplate(g, facts)
|
||||
problems = append(problems, imageProblems...)
|
||||
t, _ := loadMap(filepath.Join(dir, "test.yaml"))
|
||||
problems = append(problems, draftCompleteness(g, t)...)
|
||||
problems = append(problems, unreadSettings(g, facts)...)
|
||||
problems = append(problems, savesOutsideVolumes(g, report)...)
|
||||
}
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
fmt.Printf("[draft] %s passes validation and the image checks\n", dir)
|
||||
fmt.Println("[draft] next: review it, then boot-test it (first-boot / CI) before opening a PR")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("[draft] %d problem(s):\n", len(problems))
|
||||
for _, p := range problems {
|
||||
fmt.Println(" - " + p)
|
||||
}
|
||||
messages = append(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- ")})
|
||||
}
|
||||
return fmt.Errorf("draft still has problems after %d fix rounds; it is in %s for a person to finish", *attempts, dir)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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 (Valheim's worlds and admin lists in ~/.config/unity3d/...) 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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(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, examples string) 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## Working templates from this repo, for format and style (not content)\n")
|
||||
b.WriteString(examples)
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 = strings.TrimSpace(answer)
|
||||
if i := strings.Index(answer, "{"); i > 0 {
|
||||
answer = answer[i:] // some models still wrap JSON in prose or code fences
|
||||
}
|
||||
if i := strings.LastIndex(answer, "}"); i >= 0 {
|
||||
answer = answer[:i+1]
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user