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
14 changed files with 488 additions and 393 deletions

No files matched your search

+30
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
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
- `defaultPorts` lists **only what players connect to** (game and query ports). RCON, REST and admin
ports are **never** published.
- 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).
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>}`.
@@ -77,6 +93,20 @@ Know this before writing anything:
- Take keys and default values from what the server really writes: run
`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 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
`defaultConfig` value, and a `category`: one of `Security & Access`, `Gameplay & Rules`,
`World & Environment`, `System & General`. Mark the few that matter when deploying with
+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,
and boot tests then refuse to run until `systemctl restart gbr-boottest-network`. Volumes labelled
`gbr.cache` are the AI builder's Steam download cache.
and boot tests then refuse to run until `systemctl restart gbr-boottest-network`.
### 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"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// 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+$`)
// 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
// 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) {
if os.Getenv("STEAM_CACHE") == "off" || !numericAppID.MatchString(appID) {
return
}
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] {
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 {
@@ -98,12 +113,56 @@ func seedSteamApp(appID, volume string) {
fmt.Printf("[steam-cache] update failed (%v); the boot downloads the app itself\n", err)
return
}
runCmd("docker", "run", "--rm", "-v", cache+":/server", "alpine", "sh", "-c",
fmt.Sprintf("date +%%s > /server/%s", steamCacheStamp))
steamCacheFresh[appID] = true
}
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)
return
}
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
}
+8 -1
View File
@@ -153,6 +153,13 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
}
}
// 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{
"command": "DEPLOY_SERVER",
"serverId": "boottest-" + id,
@@ -166,7 +173,7 @@ func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error
"configFilePath": g["configFilePath"],
"configFileFormat": g["configFileFormat"],
"configFormatOptions": g["configFormatOptions"],
"startupCommand": g["defaultStartupCommand"],
"startupCommand": startup,
"memoryLimitMb": g["requiredMemoryMb"],
"containerUser": g["containerUser"],
"dockerNetwork": bootTestNetwork,
+26
View File
@@ -1,6 +1,7 @@
package main
import (
"time"
"encoding/json"
"errors"
"os"
@@ -121,3 +122,28 @@ func TestBootTestPayloadLeavesOutUnboundPorts(t *testing.T) {
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"])
}
}
+204 -204
View File
@@ -1,204 +1,204 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Game fields in the order they are written to game.yaml. Panel-owned business settings
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
var gameFields = []string{
"id", "name",
"defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
"installMethod", "installConfig",
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig",
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey",
"aiCoverPrompt",
}
const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n"
const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" +
"# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n"
func runImport(args []string) error {
fs := flag.NewFlagSet("import", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory to write one folder per game into")
all := fs.Bool("all", false, "also import games that are disabled in the panel")
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("import needs the path to a GET /api/v1/admin/games export")
}
raw, err := os.ReadFile(fs.Arg(0))
if err != nil {
return err
}
var games []map[string]any
if err := json.Unmarshal(raw, &games); err != nil {
return fmt.Errorf("export is not a JSON array of games: %w", err)
}
for _, g := range games {
id, _ := g["id"].(string)
if id == "" {
return errors.New("export contains a game without an id")
}
if g["enabled"] == false && !*all {
fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id)
continue
}
dir := filepath.Join(*gamesDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil {
return fmt.Errorf("%s: %w", id, err)
}
testPath := filepath.Join(dir, "test.yaml")
if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) {
if err := writeTest(testPath, g); err != nil {
return fmt.Errorf("%s: %w", id, err)
}
}
fmt.Printf("wrote %s\n", dir)
}
return nil
}
func writeGame(path string, g map[string]any) error {
doc := &yaml.Node{Kind: yaml.MappingNode}
for _, f := range gameFields {
v, ok := g[f]
if !ok || isEmpty(v) {
continue
}
val, err := toNode(normalize(v))
if err != nil {
return err
}
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
}
return writeYAML(path, gameHeader, doc)
}
// writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method.
func writeTest(path string, g map[string]any) error {
cfg := map[string]any{}
for key, p := range schemaProperties(g) {
if prop, ok := p.(map[string]any); ok {
if c, ok := prop["x-consent"]; ok && c != false {
cfg[key] = true
}
}
}
timeout := 900
if g["installMethod"] == "STEAMCMD" {
timeout = 2400
}
test := map[string]any{"timeoutSec": timeout}
if len(cfg) > 0 {
test["config"] = cfg
}
var doc yaml.Node
if err := doc.Encode(test); err != nil {
return err
}
return writeYAML(path, testHeader, &doc)
}
func writeYAML(path, header string, doc *yaml.Node) error {
var b strings.Builder
b.WriteString(header)
enc := yaml.NewEncoder(&b)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return err
}
enc.Close()
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
// whitespace, like a "\n" config separator, into "" and the value is silently lost.
func toNode(v any) (*yaml.Node, error) {
switch x := v.(type) {
case map[string]any:
n := &yaml.Node{Kind: yaml.MappingNode}
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val, err := toNode(x[k])
if err != nil {
return nil, err
}
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
}
return n, nil
case []any:
n := &yaml.Node{Kind: yaml.SequenceNode}
for _, e := range x {
val, err := toNode(e)
if err != nil {
return nil, err
}
n.Content = append(n.Content, val)
}
return n, nil
case string:
if x != "" && strings.TrimSpace(x) == "" {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
}
}
n := &yaml.Node{}
if err := n.Encode(v); err != nil {
return nil, err
}
return n, nil
}
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
func normalize(v any) any {
switch x := v.(type) {
case float64:
if x == math.Trunc(x) && math.Abs(x) < 1<<53 {
return int64(x)
}
case map[string]any:
for k, e := range x {
x[k] = normalize(e)
}
case []any:
for i, e := range x {
x[i] = normalize(e)
}
}
return v
}
func isEmpty(v any) bool {
switch x := v.(type) {
case nil:
return true
case string:
return x == ""
case map[string]any:
return len(x) == 0
case []any:
return len(x) == 0
}
return false
}
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Game fields in the order they are written to game.yaml. Panel-owned business settings
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
var gameFields = []string{
"id", "name",
"defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
"installMethod", "installConfig",
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig",
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
"publicIpConfigKey", "portMode", "maxPlayersConfigKey", "mapConfigKey",
"aiCoverPrompt",
}
const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n"
const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" +
"# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n"
func runImport(args []string) error {
fs := flag.NewFlagSet("import", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory to write one folder per game into")
all := fs.Bool("all", false, "also import games that are disabled in the panel")
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("import needs the path to a GET /api/v1/admin/games export")
}
raw, err := os.ReadFile(fs.Arg(0))
if err != nil {
return err
}
var games []map[string]any
if err := json.Unmarshal(raw, &games); err != nil {
return fmt.Errorf("export is not a JSON array of games: %w", err)
}
for _, g := range games {
id, _ := g["id"].(string)
if id == "" {
return errors.New("export contains a game without an id")
}
if g["enabled"] == false && !*all {
fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id)
continue
}
dir := filepath.Join(*gamesDir, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil {
return fmt.Errorf("%s: %w", id, err)
}
testPath := filepath.Join(dir, "test.yaml")
if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) {
if err := writeTest(testPath, g); err != nil {
return fmt.Errorf("%s: %w", id, err)
}
}
fmt.Printf("wrote %s\n", dir)
}
return nil
}
func writeGame(path string, g map[string]any) error {
doc := &yaml.Node{Kind: yaml.MappingNode}
for _, f := range gameFields {
v, ok := g[f]
if !ok || isEmpty(v) {
continue
}
val, err := toNode(normalize(v))
if err != nil {
return err
}
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
}
return writeYAML(path, gameHeader, doc)
}
// writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method.
func writeTest(path string, g map[string]any) error {
cfg := map[string]any{}
for key, p := range schemaProperties(g) {
if prop, ok := p.(map[string]any); ok {
if c, ok := prop["x-consent"]; ok && c != false {
cfg[key] = true
}
}
}
timeout := 900
if g["installMethod"] == "STEAMCMD" {
timeout = 2400
}
test := map[string]any{"timeoutSec": timeout}
if len(cfg) > 0 {
test["config"] = cfg
}
var doc yaml.Node
if err := doc.Encode(test); err != nil {
return err
}
return writeYAML(path, testHeader, &doc)
}
func writeYAML(path, header string, doc *yaml.Node) error {
var b strings.Builder
b.WriteString(header)
enc := yaml.NewEncoder(&b)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return err
}
enc.Close()
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
// whitespace, like a "\n" config separator, into "" and the value is silently lost.
func toNode(v any) (*yaml.Node, error) {
switch x := v.(type) {
case map[string]any:
n := &yaml.Node{Kind: yaml.MappingNode}
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val, err := toNode(x[k])
if err != nil {
return nil, err
}
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
}
return n, nil
case []any:
n := &yaml.Node{Kind: yaml.SequenceNode}
for _, e := range x {
val, err := toNode(e)
if err != nil {
return nil, err
}
n.Content = append(n.Content, val)
}
return n, nil
case string:
if x != "" && strings.TrimSpace(x) == "" {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
}
}
n := &yaml.Node{}
if err := n.Encode(v); err != nil {
return nil, err
}
return n, nil
}
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
func normalize(v any) any {
switch x := v.(type) {
case float64:
if x == math.Trunc(x) && math.Abs(x) < 1<<53 {
return int64(x)
}
case map[string]any:
for k, e := range x {
x[k] = normalize(e)
}
case []any:
for i, e := range x {
x[i] = normalize(e)
}
}
return v
}
func isEmpty(v any) bool {
switch x := v.(type) {
case nil:
return true
case string:
return x == ""
case map[string]any:
return len(x) == 0
case []any:
return len(x) == 0
}
return false
}
+4 -1
View File
@@ -4,6 +4,7 @@
// 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 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 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
@@ -33,6 +34,8 @@ func main() {
err = runExport(os.Args[2:])
case "boottest-payload":
err = runBootTestPayload(os.Args[2:])
case "boot-test":
err = runBootTest(os.Args[2:])
case "inspect-image":
err = runInspectImage(os.Args[2:])
case "first-boot":
@@ -59,6 +62,6 @@ func main() {
}
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] | repair -id ID [-reason R] | eval [-ids all] [-out eval.md] | 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)
}
+25 -7
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, " +
"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, " +
"cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) always comes first and that " +
"variant is the recommended one. Then Docker Hub images that appear in the pages and run as a non-root user " +
"(needed for servers that only exist for Windows).\n" +
"cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) is its only image, and " +
"there is no separate variant for a community image of the same server: those keep their saves outside /server. " +
"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" +
"- 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" +
@@ -311,7 +312,7 @@ func verifyVariants(res *researchResult) {
}
}
apps := steamApps(appIDs)
anyRunnable := false
anyRunnable, linuxSteam := false, false
for i := range res.Variants {
v := &res.Variants[i]
var images []string
@@ -333,7 +334,8 @@ func verifyVariants(res *researchResult) {
v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name))
v.Images = withSteamCMD(v.Images, 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 {
v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD")
@@ -352,10 +354,26 @@ func verifyVariants(res *researchResult) {
res.Reason = "unknown"
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)
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
// server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need.
func recommend(variants []variant) {
@@ -422,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
// 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.
func withSteamCMD(images []string, linux bool) []string {
var others []string
@@ -432,7 +450,7 @@ func withSteamCMD(images []string, linux bool) []string {
}
}
if linux {
return append([]string{"cm2network/steamcmd"}, others...)
return []string{"cm2network/steamcmd"}
}
return append(others, "cm2network/steamcmd")
}
+15 -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"}
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)
}
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) {
// A variant runnable two ways becomes one option per way, SteamCMD recommended
v := oneOptionPerImage([]variant{{
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\""},
}})
recommend(v)
@@ -2,7 +2,7 @@
id: runescape-dragonwilds-native-linux
name: 'RuneScape: Dragonwilds (Native Linux Server)'
defaultDockerImage: cm2network/steamcmd:root
defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port=7777
defaultStartupCommand: ./RSDragonwildsServer.sh -log -Port={PUBLIC_PORT_7777} -BeaconPort={PUBLIC_PORT_8888}
requiredMemoryMb: 10240
requiredDiskMb: 20480
defaultPorts:
@@ -106,12 +106,13 @@ agnosticCommandConfig:
Save:
type: UNSUPPORTED
playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)'
playerLeaveRegex: 'LogNet: UNetConnection::.*Player(?: name)?:\s*([^,\r\n]+)'
playerLeaveRegex: 'LogDomMatcherSession: Player Removed from session \[[^\]]*\]-\[([^\]]+)\]'
queryType: LOG_ONLY
logFilePath: RSDragonwilds/Saved/Logs/RSDragonwilds.log
backupPaths:
- RSDragonwilds/Saved
maxBackupSizeMb: 5000
publicIpConfigKey: MULTIHOME
portMode: MATCH_PUBLIC
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.'
+88 -153
View File
@@ -1,165 +1,115 @@
# 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
name: Satisfactory (Vanilla Stable)
defaultDockerImage: wolveix/satisfactory-server:latest
name: Satisfactory
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
requiredDiskMb: 16384
requiredDiskMb: 20480
defaultPorts:
- 7777/tcp
- 7777/udp
- 7777/tcp
- 8888/tcp
installMethod: NONE
installMethod: STEAMCMD
installConfig:
appId: "1690800"
defaultConfig:
AUTOSAVENUM: 5
DEBUG: false
DISABLESEASONALEVENTS: false
LOG: false
MAXOBJECTS: 2162688
MAXPLAYERS: 4
MAXTICKRATE: 30
MULTIHOME: '::'
PGID: 1000
PUID: 1000
SERVERGAMEPORT: 7777
SERVERMESSAGINGPORT: 8888
SERVERSTREAMING: true
SKIPUPDATE: false
STEAMBETA: false
STEAMBETAID: ""
STEAMBETAKEY: ""
TIMEOUT: 30
VMOVERRIDE: false
MaxPlayers: 4
AutosaveNum: 5
MaxTickRate: 30
ConnectionTimeout: 30
MaxObjects: 2162688
ServerStreaming: true
DisableSeasonalEvents: false
Multihome: ""
ExternalReliablePort: '{PUBLIC_PORT_8888}'
configSchema:
$schema: http://json-schema.org/draft-07/schema#
type: object
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
default: 5
description: The number of rotating autosave files to keep in the save folder.
title: Autosave Rotation Count
MaxTickRate:
title: Max Tick Rate
description: The highest simulation tick rate the server runs at, in Hz.
type: integer
DEBUG:
minimum: 10
maximum: 120
category: System & General
default: false
description: Prints diagnostic environment and system details on startup, then stops.
title: Debug Mode
type: boolean
DISABLESEASONALEVENTS:
category: Gameplay & Rules
default: false
description: Disables seasonal in-game events such as FICSMAS holiday events.
title: Disable Seasonal Events
type: boolean
LOG:
default: 30
ConnectionTimeout:
title: Connection Timeout
description: Seconds before a player who stops responding is disconnected.
type: integer
minimum: 5
category: System & General
default: false
description: Disables automatic pruning of previous Satisfactory server log files on container startup.
title: Retain Old Logs
type: boolean
MAXOBJECTS:
default: 30
MaxObjects:
title: Max Objects Limit
description: Unreal Engine object limit. Raise it for very large late-game factories.
type: integer
category: World & Environment
default: 2162688
description: Unreal Engine garbage collector UObject limit to support large late-game factories.
title: Max Objects Limit
type: integer
MAXPLAYERS:
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:
ServerStreaming:
title: Server Streaming
description: Lets the server stream world data in and out instead of keeping it all loaded.
type: boolean
category: System & General
default: true
description: Toggles whether the game server utilizes Unreal Engine World Partition asset streaming.
title: Server Streaming
DisableSeasonalEvents:
title: Disable Seasonal Events
description: Turns off seasonal events such as FICSMAS.
type: boolean
SKIPUPDATE:
category: System & General
category: Gameplay & Rules
default: false
description: Skips automatic SteamCMD game file update checks on container startup.
title: Skip Update
type: boolean
STEAMBETA:
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:
Multihome:
title: Multihome Address
description: Bind the server to one IP address instead of all of them. Leave empty unless you know you need it.
type: string
category: System & General
default: ""
description: Specific beta branch identifier to download via SteamCMD.
title: Steam Beta Branch Name
ExternalReliablePort:
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
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
default: 30
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
default: '{PUBLIC_PORT_8888}'
required: []
type: object
configFileFormat: INI
configFormatOptions:
assignment: =
falseValue: "false"
footer: ""
header: ""
quoteStrings: false
separator: "\n"
trueValue: "true"
unquotedKeys: []
uppercaseKeys: false
maxPlayersConfigKey: MaxPlayers
logFilePath: FactoryGame/Saved/Logs/FactoryGame.log
readyPattern: 'Engine is initialized'
agnosticCommandConfig:
queryType: LOG_ONLY
playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)'
playerLeaveRegex: 'UNetConnection::Close:.*?\[PlayerName:\s*([^\]]+)\]'
actions:
Ban:
type: UNSUPPORTED
@@ -169,22 +119,7 @@ agnosticCommandConfig:
type: UNSUPPORTED
Save:
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:
- saved
maxBackupSizeMb: 1024
maxPlayersConfigKey: MAXPLAYERS
- .config/Epic/FactoryGame/Saved/SaveGames
- FactoryGame/Saved/Config
maxBackupSizeMb: 2048
+3 -1
View File
@@ -1,3 +1,5 @@
# 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'.
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 },
"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"] },
"mapConfigKey": { "type": ["string", "null"] },
+7 -17
View File
@@ -30,24 +30,14 @@ for id in "$@"; do
echo "::endgroup::"
continue
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=$?
if [ $code -eq 3 ]; then
skipped+=("$id")
echo "::endgroup::"
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
if [ $code -eq 3 ]; then skipped+=("$id")
elif [ $code -eq 0 ]; then passed+=("$id")
else failed+=("$id"); fi
echo "::endgroup::"
done