Files
gregandClaude Opus 5.5 f1271341ef
Validate game templates / validate (push) Successful in 36s
Validate game templates / boot-test (push) Successful in 1m11s
Validate game templates / publish (push) Skipped
feat(builder): repair existing templates, a weekly check that queues repairs, and eval
The builder could only create games; nothing noticed when a live template
stopped working, and there was no way to tell whether a builder change made
it better or worse.

- repair -id ID: boots an existing template as nodes run it; if it fails,
  the same investigation as for a draft runs and the model changes only what
  the diagnosis says. The worker handles "repair" jobs (gameId from the
  panel) and opens "fix(games): repair ..." on draft/<id>; a template that
  boots as it is reports "nothing to repair" and opens no PR. The boot and
  fix loop is now shared by builds and repairs.
- validate.yml runs weekly and boot-tests every game; failed games are sent
  to the panel (X-GBR-Event: games_boot_failed) to queue repairs.
- eval -ids all: builds games that already have a template from scratch,
  blind, and scores each draft against it (boots, same image and app, ports,
  ready line, settings found) into eval.md and eval.json.
- research skips our own sites, so neither research nor eval reads our
  templates back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:42:34 -04:00

808 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
// Repair: games/<id> is an existing template to fix, not a new game
Repair bool
}
// 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 && !j.Repair {
return nil, fmt.Errorf("%s already exists; drafts only create new games (repair fixes existing ones)", 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
}