From 2d35cdafc3920dd2df0b74edcbf75d8b66191f63 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 18:13:24 -0400 Subject: [PATCH] feature: serve plugins' workshop addons through AddonsManager --- apps/counterstrikesharp/scripts/setup.sh | 6 ++ apps/swiftly/Dockerfile | 5 +- apps/swiftly/scripts/setup.sh | 55 ++++---------- shared/scripts/test/util.test.sh | 55 ++++++++++++++ shared/scripts/util.sh | 93 ++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 41 deletions(-) diff --git a/apps/counterstrikesharp/scripts/setup.sh b/apps/counterstrikesharp/scripts/setup.sh index 8d7e887d..954023ee 100755 --- a/apps/counterstrikesharp/scripts/setup.sh +++ b/apps/counterstrikesharp/scripts/setup.sh @@ -170,6 +170,12 @@ if [ "$SHOW_ELO_RANKS" = "true" ] || [ "$DISABLE_SERVER_GUIDELINES" = "true" ]; sed -i --follow-symlinks 's/"FollowCS2ServerGuidelines"[[:space:]]*:[[:space:]]*true/"FollowCS2ServerGuidelines": false/' "$core_json" fi +# Workshop addons are served by AddonsManager, which is a SwiftlyS2 plugin and +# only ships in that image. Said out loud so a missing model is not a mystery. +if [ -n "${WORKSHOP_ADDONS:-}" ]; then + echo "---Workshop Addons: ${WORKSHOP_ADDONS} need AddonsManager, which only the SwiftlyS2 image ships; players will not get them---" +fi + echo "---Check Metamod Install---" gameinfo_path="${INSTANCE_SERVER_DIR}/game/csgo/gameinfo.gi" new_line=" Game csgo/addons/metamod" diff --git a/apps/swiftly/Dockerfile b/apps/swiftly/Dockerfile index 13f5c2b3..2806b22d 100644 --- a/apps/swiftly/Dockerfile +++ b/apps/swiftly/Dockerfile @@ -145,8 +145,9 @@ ENV SOURCEMOD_CSGO_URL=https://sm.alliedmods.net/smdrop/1.13/sourcemod-1.13.0-gi ENV NO_LOBBY_RESERVATION_URL=https://github.com/nuxencs/NoLobbyReservation/releases/download/v0.0.1/NoLobbyReservation.zip # The engine natively mounts exactly one addon -- the map -- so the HUD workshop -# item needs this to reach clients at all. Practice servers only; see setup.sh. -# Blank disables the mount: the plugin degrades to centre text without it. +# item needs AddonsManager to reach clients at all, as do the addons registry +# plugins list in workshop_addons; see setup.sh. Blank disables the HUD's mount: +# the practice plugin degrades to centre text without it. ENV HUD_WORKSHOP_ID="3791548068" # ARG, not ENV: the download below happens once at build time, so these are set diff --git a/apps/swiftly/scripts/setup.sh b/apps/swiftly/scripts/setup.sh index f31f827d..51d06848 100755 --- a/apps/swiftly/scripts/setup.sh +++ b/apps/swiftly/scripts/setup.sh @@ -206,15 +206,9 @@ if $INSTALL_UTILITY_PRACTICE_PLUGIN = true ; then echo "---Utility Practice: plugin dir already present, skipping /opt/utility-practice symlink---" fi - # The HUD's Panorama layouts live in a workshop addon, and CS2 has no - # server-to-client file transfer -- AddonsManager names the id to connecting - # clients and Steam delivers it. Without this the plugin still works, falling - # back to centre text, so a failure here must not stop the server. - # - # AddonsManager reads its config with optional:false and validates it on - # start, so anything that is not a bare numeric id writes JSON that takes the - # plugin down at load instead of degrading -- a pasted quote, a trailing - # comma, a second id. That is a typo, not a reason to lose the server. + # The HUD's Panorama layouts live in a workshop addon, served with the + # plugins' below. Without it the plugin still works, falling back to centre + # text, so a bad id here must not stop the server. case "${HUD_WORKSHOP_ID}" in "") echo "---Utility Practice: HUD_WORKSHOP_ID unset, HUD falls back to centre text---" @@ -223,40 +217,23 @@ if $INSTALL_UTILITY_PRACTICE_PLUGIN = true ; then echo "---Utility Practice: HUD_WORKSHOP_ID '${HUD_WORKSHOP_ID}' is not a workshop id, HUD falls back to centre text---" ;; *) - ADDONS_MANAGER_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/AddonsManager" - if [ ! -e "$ADDONS_MANAGER_PLUGIN_DIR" ]; then - ln -s "/opt/addons-manager/AddonsManager" "$ADDONS_MANAGER_PLUGIN_DIR" - fi - - # Rewritten every boot: the id is ours, and a stale copy from an older - # image would silently serve the wrong addon. - # - # RedownloadAddonOnMount matters because this addon changes -- unlike a - # map, which is published once. AddonsManager only checks that an item is - # installed, not that it is current, so without this a server that cached - # an older HUD keeps serving those layouts forever and never picks up a - # republish. - # - # Through materialize_for_write because configs is a symlink onto the - # node-wide volume: written straight through it, one practice server's - # addon id lands in front of every other server on the node -- and - # AddonsManager watches the file, so a running server swaps to it live. - ADDONS_MANAGER_CONFIG="$(materialize_for_write "${INSTANCE_SERVER_DIR}/game/csgo" "addons/swiftlys2/configs/plugins/AddonsManager/config.jsonc")" - cat > "$ADDONS_MANAGER_CONFIG" </dev/null | tr '\n' ' ')" \ + "3791548068 100 200 " "the ids were not deduplicated in order" + +# AddonsManager validates its config on start and drops all of it over one bad +# entry, so nothing that is not a bare number may reach it. +echo "workshop_addon_ids drops anything that is not a bare workshop id" +assert_equals "$(workshop_addon_ids '100,"200",3 4,*,https://x/?id=5' 2>/dev/null | tr '\n' ' ')" \ + "100 " "a malformed id was passed through" +case "$(workshop_addon_ids '"200"' 2>&1 >/dev/null)" in + *"'\"200\"' is not a workshop id"*) ;; + *) fail "a malformed id was dropped without saying so" ;; +esac + +echo "enable_addons_manager links AddonsManager and serves exactly the ids given" +setup +mkdir -p "$workdir/addons-manager/AddonsManager" +ADDONS_MANAGER_DIR="$workdir/addons-manager/AddonsManager" \ + NODE_PLUGINS_DIR="$workdir/no-node-plugins" CUSTOM_PLUGINS_DIR="$workdir/plugins" \ + enable_addons_manager "$workdir/instance/game/csgo" 100 200 > /dev/null 2>&1 +assert_equals "$(readlink "$workdir/instance/game/csgo/addons/swiftlys2/plugins/AddonsManager")" \ + "$workdir/addons-manager/AddonsManager" "AddonsManager was not linked into the server" +assert_equals "$(jq -c '.Main' "$(addons_manager_config)" 2>/dev/null)" \ + '{"Addons":["100","200"],"RedownloadAddonOnMount":true}' "the config does not serve the ids given" +assert_missing "$workdir/plugins/addons/swiftlys2/configs/plugins/AddonsManager" \ + "one server's addons were written onto the node volume" +teardown + +echo "enable_addons_manager replaces what an earlier boot served" +setup +mkdir -p "$workdir/addons-manager/AddonsManager" +ADDONS_MANAGER_DIR="$workdir/addons-manager/AddonsManager" \ + NODE_PLUGINS_DIR="$workdir/no-node-plugins" CUSTOM_PLUGINS_DIR="$workdir/plugins" \ + enable_addons_manager "$workdir/instance/game/csgo" 100 200 > /dev/null 2>&1 +ADDONS_MANAGER_DIR="$workdir/addons-manager/AddonsManager" \ + NODE_PLUGINS_DIR="$workdir/no-node-plugins" CUSTOM_PLUGINS_DIR="$workdir/plugins" \ + enable_addons_manager "$workdir/instance/game/csgo" 300 > /dev/null 2>&1 +assert_equals "$(jq -c '.Main.Addons' "$(addons_manager_config)" 2>/dev/null)" '["300"]' \ + "an earlier boot's addons were still served" +teardown + +echo "enable_addons_manager leaves the server alone in an image without AddonsManager" +setup +ADDONS_MANAGER_DIR="$workdir/no-addons-manager" \ + enable_addons_manager "$workdir/instance/game/csgo" 100 > /dev/null 2>&1 +assert_equals "$?" "0" "a missing AddonsManager should not abort setup" +assert_missing "$workdir/instance/game/csgo/addons/swiftlys2/plugins/AddonsManager" \ + "a link to a missing AddonsManager was planted" +assert_missing "$(addons_manager_config)" "a config was written for a plugin that is not there" +teardown + if [ "$failures" -gt 0 ]; then echo "$failures assertion(s) failed" >&2 exit 1 diff --git a/shared/scripts/util.sh b/shared/scripts/util.sh index 76901023..ba605d5a 100644 --- a/shared/scripts/util.sh +++ b/shared/scripts/util.sh @@ -238,6 +238,99 @@ write_plugin_configs() { done <<< "$paths" } +# CS2 has no server-to-client file transfer, so anything a plugin shows or plays +# that the game does not ship -- models, sounds, Panorama layouts -- reaches +# players as a workshop addon, which AddonsManager names to each connecting +# client for Steam to deliver. +# +# Prints each workshop id in its arguments once, in order, one per line. An +# argument may be a comma-separated list. AddonsManager reads its config with +# optional:false and validates it on start, so one entry that is not a bare +# numeric id -- a pasted quote, a URL, a trailing comma -- takes it down along +# with every addon it serves. Those are dropped with a warning instead. +workshop_addon_ids() { + local seen="," list id + local -a ids + + for list in "$@"; do + if [ -z "$list" ]; then + continue + fi + + IFS="," read -r -a ids <<< "$list" + + for id in "${ids[@]}"; do + case "$id" in + "") + continue + ;; + *[!0-9]*) + echo "---Workshop Addons: '${id}' is not a workshop id, skipping it---" >&2 + continue + ;; + esac + + case "$seen" in + *",$id,"*) continue ;; + esac + + seen="${seen}${id}," + printf '%s\n' "$id" + done + done +} + +# Links the AddonsManager the SwiftlyS2 image ships into this server and has it +# serve exactly these workshop ids, which must already be bare numbers. +# +# The config is rewritten every boot: the ids belong to this server, and a +# stale copy from an older boot would silently serve the wrong addons. +# +# RedownloadAddonOnMount because addons change -- unlike a map, which is +# published once. AddonsManager only checks that an item is installed, not that +# it is current, so without it a server that cached an older copy serves it +# forever and never picks up a republish. +# +# Through materialize_for_write because configs is a symlink onto the node-wide +# volume: written straight through it, one server's addons land in front of +# every other server on the node -- and AddonsManager watches the file, so a +# running server swaps to them live. +enable_addons_manager() { + local root="$1" + shift + + local source="${ADDONS_MANAGER_DIR:-/opt/addons-manager/AddonsManager}" + + if [ ! -d "$source" ]; then + echo "---Workshop Addons: this image has no AddonsManager, players will not get $*---" >&2 + return 0 + fi + + local plugin_dir="$root/addons/swiftlys2/plugins/AddonsManager" + + if [ ! -e "$plugin_dir" ]; then + mkdir -p "$(dirname "$plugin_dir")" + ln -s "$source" "$plugin_dir" + fi + + local config + config="$(materialize_for_write "$root" "addons/swiftlys2/configs/plugins/AddonsManager/config.jsonc")" + + # A link here is a copy shared with other servers; replace it, never write + # through it. + if [ -L "$config" ]; then + rm -f "$config" + fi + + if ! jq -n '{Main: {Addons: $ARGS.positional, RedownloadAddonOnMount: true}}' \ + --args "$@" > "$config"; then + echo "---Workshop Addons: could not write ${config}---" >&2 + return 0 + fi + + echo "---Workshop Addons: serving $* via AddonsManager---" +} + # Valve keeps the server's SteamAppId 730 in this file, so the relay settings are # added to it rather than replacing it. The instance copy is a symlink onto the # node's game files, so the result is moved over the link, never written through.