diff --git a/AUTHORING.md b/AUTHORING.md index b35efc2..e819723 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -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/`), 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//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//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_}` 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_}`. @@ -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 ` 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 + `/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 diff --git a/cmd/gbrgames/research.go b/cmd/gbrgames/research.go index 19b9821..70f981c 100644 --- a/cmd/gbrgames/research.go +++ b/cmd/gbrgames/research.go @@ -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") } diff --git a/cmd/gbrgames/research_test.go b/cmd/gbrgames/research_test.go index c76d54e..235e0fa 100644 --- a/cmd/gbrgames/research_test.go +++ b/cmd/gbrgames/research_test.go @@ -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) diff --git a/games/satisfactory-vanilla-stable/game.yaml b/games/satisfactory-vanilla-stable/game.yaml index ebbd6a0..fe2f801 100644 --- a/games/satisfactory-vanilla-stable/game.yaml +++ b/games/satisfactory-vanilla-stable/game.yaml @@ -1,165 +1,114 @@ # 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 agnosticCommandConfig: + queryType: LOG_ONLY + playerJoinRegex: 'LogNet: Join succeeded: ([^\r\n]+)' + playerLeaveRegex: 'UNetConnection::Close:.*?\[PlayerName:\s*([^\]]+)\]' actions: Ban: type: UNSUPPORTED @@ -169,22 +118,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 diff --git a/games/satisfactory-vanilla-stable/test.yaml b/games/satisfactory-vanilla-stable/test.yaml index 1b49f91..31b8db2 100644 --- a/games/satisfactory-vanilla-stable/test.yaml +++ b/games/satisfactory-vanilla-stable/test.yaml @@ -1,3 +1,4 @@ # 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