diff --git a/.github/workflows/export_game_modes.yml b/.github/workflows/export_game_modes.yml new file mode 100644 index 00000000000..913da8fbaf6 --- /dev/null +++ b/.github/workflows/export_game_modes.yml @@ -0,0 +1,143 @@ +name: Export Game Modes JSON + +on: + push: + branches: + - stable + - master + workflow_dispatch: + +env: + EXPORT_DIR: /tmp/bar-export + MAP_NAME: quicksilver_remake_1.24.sd7 + MAP_URL: https://springfiles.springrts.com/files/maps/quicksilver_remake_1.24.sd7 + +jobs: + export: + name: Export Game Modes + runs-on: ubuntu-latest + permissions: + contents: write + if: github.repository == 'beyond-all-reason/Beyond-All-Reason' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve engine version + id: engine + run: | + CONFIG=$(curl -fsSL https://launcher-config.beyondallreason.dev/config.json) + ENGINE_URL=$(echo "$CONFIG" | jq -r ' + .setups[] + | select(.package.id == "manual-linux-test-engine") + | .downloads.resources[] + | select(.destination | contains("engine")) + | .url + ') + ENGINE_DEST=$(echo "$CONFIG" | jq -r ' + .setups[] + | select(.package.id == "manual-linux-test-engine") + | .downloads.resources[] + | select(.destination | contains("engine")) + | .destination + ') + ENGINE_VERSION=$(basename "$ENGINE_DEST") + echo "url=$ENGINE_URL" >> "$GITHUB_OUTPUT" + echo "dest=$ENGINE_DEST" >> "$GITHUB_OUTPUT" + echo "version=$ENGINE_VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved engine: $ENGINE_VERSION" + + - name: Cache engine and map + id: cache + uses: actions/cache@v4 + with: + path: | + ${{ env.EXPORT_DIR }}/engine + ${{ env.EXPORT_DIR }}/maps + key: modes-export-${{ steps.engine.outputs.version }}-quicksilver-1.24 + + - name: Download engine + if: steps.cache.outputs.cache-hit != 'true' + run: | + mkdir -p "$EXPORT_DIR/${{ steps.engine.outputs.dest }}" + curl -fsSL "${{ steps.engine.outputs.url }}" -o /tmp/engine.7z + 7z x /tmp/engine.7z -o"$EXPORT_DIR/${{ steps.engine.outputs.dest }}" + rm /tmp/engine.7z + + - name: Download map + if: steps.cache.outputs.cache-hit != 'true' + run: | + mkdir -p "$EXPORT_DIR/maps" + curl -fsSL "$MAP_URL" -o "$EXPORT_DIR/maps/$MAP_NAME" + + - name: Set up workspace + run: | + mkdir -p "$EXPORT_DIR/games" + ln -sf "$GITHUB_WORKSPACE" "$EXPORT_DIR/games/BAR.sdd" + cp tools/headless_testing/springsettings.cfg "$EXPORT_DIR/" + cp tools/headless_testing/startscript_modes_export.txt "$EXPORT_DIR/" + + - name: Run headless export + run: | + cd "$EXPORT_DIR" + rm -rf LuaUI/Config + engine/*/spring-headless \ + --isolation \ + --write-dir "$(pwd)" \ + startscript_modes_export.txt + timeout-minutes: 15 + + - name: Validate export + run: | + OUT="$EXPORT_DIR/game_modes.json" + if [ ! -f "$OUT" ]; then + echo "::error::Export produced no game_modes.json" + exit 1 + fi + # Must be valid JSON with the expected schema and at least one category. + CATEGORIES=$(jq -e '.schemaVersion as $v + | if $v != 1 then error("unexpected schemaVersion \($v)") else . end + | .categories | length' "$OUT") + echo "Exported $CATEGORIES mode categories (schemaVersion 1)" + if [ "$CATEGORIES" -lt 1 ]; then + echo "::error::Export has no mode categories" + exit 1 + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: game-modes-${{ github.sha }} + path: ${{ env.EXPORT_DIR }}/game_modes.json + + # push -> rolling per-channel asset (stable/test); workflow_dispatch -> commit-pinned + # asset only (off-master test builds), pruned to newest 50. Both feed the SPADS plugin. + - name: Publish to release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view game-modes >/dev/null 2>&1 \ + || gh release create game-modes \ + --prerelease \ + --title "Game Modes Export" \ + --notes "Game mode presets, regenerated by export_game_modes.yml and self-fetched by the SPADS ModeCommand plugin. Not a code release." \ + --target "${{ github.sha }}" \ + || true + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ASSET="game_modes-${GITHUB_SHA:0:7}.json" + cp "$EXPORT_DIR/game_modes.json" "$EXPORT_DIR/$ASSET" + gh release upload game-modes "$EXPORT_DIR/$ASSET" --clobber + echo "Published commit-pinned $ASSET" + gh release view game-modes --json assets \ + -q '.assets[] | select(.name | test("^game_modes-[0-9a-f]{7}\\.json$")) | [.createdAt, .name] | @tsv' \ + | sort -r | tail -n +51 | cut -f2 | while read -r OLD; do + gh release delete-asset game-modes "$OLD" -y && echo "Pruned $OLD" + done + else + CHANNEL=stable + [ "${{ github.ref_name }}" = "stable" ] || CHANNEL=test + ASSET="game_modes-${CHANNEL}.json" + cp "$EXPORT_DIR/game_modes.json" "$EXPORT_DIR/$ASSET" + gh release upload game-modes "$EXPORT_DIR/$ASSET" --clobber + echo "Published channel $ASSET" + fi diff --git a/luaui/Widgets/export_game_modes.lua b/luaui/Widgets/export_game_modes.lua new file mode 100644 index 00000000000..a0a671d7f59 --- /dev/null +++ b/luaui/Widgets/export_game_modes.lua @@ -0,0 +1,116 @@ +local widget = widget ---@type Widget + +function widget:GetInfo() + return { + name = "Game Modes JSON Export", + desc = "Exports all game mode presets (modes//*.lua) to structured JSON.\nCommand: /exportgamemodes", + author = "Daniel Harvey", + date = "June 2026", + license = "GNU GPL, v2 or later", + layer = 0, + enabled = false, + } +end + +-- Emit the Lua mode presets overlaid on modoptions.lua defaults as JSON so out-of-engine +-- consumers (SPADS ModeCommand plugin) can expand a mode selection into its full option set. + +local spEcho = Spring.Echo + +local SCHEMA_VERSION = 1 +local OUTPUT_PATH = "game_modes.json" + +-- modoptions are strings; booleans -> "1"/"0", numbers rounded at float32 precision to avoid noise. +local function toModOptionValue(v) + if type(v) == "boolean" then + return v and "1" or "0" + end + if type(v) == "number" then + local s = string.format("%.7f", v):gsub("0+$", ""):gsub("%.$", "") + return s + end + return tostring(v) +end + +-- Each modoption section becomes a mode category's default base: section name == category. +local function collectDefaultsBySection() + local bySection = {} + for _, o in ipairs(VFS.Include("modoptions.lua")) do + if o.key and o.section and o.def ~= nil and o.type ~= "section" and o.type ~= "subheader" and o.type ~= "separator" then + bySection[o.section] = bySection[o.section] or {} + bySection[o.section][o.key] = toModOptionValue(o.def) + end + end + return bySection +end + +-- Discover every mode under modes//*.lua (root helpers and key/category-less returns skipped). +local function collectModesByCategory() + local byCategory = {} + for _, dir in ipairs(VFS.SubDirs("modes/") or {}) do + for _, modeFile in ipairs(VFS.DirList(dir, "*.lua") or {}) do + local ok, mode = pcall(VFS.Include, modeFile) + if ok and type(mode) == "table" and mode.key and mode.category then + byCategory[mode.category] = byCategory[mode.category] or {} + byCategory[mode.category][mode.key] = mode + end + end + end + return byCategory +end + +-- Full effective option set (defaults then overrides), so applying a mode resets the previous one. +local function buildEffectiveModOptions(defaults, mode, selector) + local effective = {} + for key, value in pairs(defaults) do + if key ~= selector then + effective[key] = { value = value, locked = false } + end + end + for key, rule in pairs(mode.modOptions or {}) do + if key ~= selector and rule.value ~= nil then + effective[key] = { value = toModOptionValue(rule.value), locked = rule.locked == true } + end + end + return effective +end + +local function buildExport() + local defaultsBySection = collectDefaultsBySection() + local modesByCategory = collectModesByCategory() + + local categories = {} + for category, modes in pairs(modesByCategory) do + local selector = category .. "_mode" + local defaults = defaultsBySection[category] or {} + local presets = {} + for modeKey, mode in pairs(modes) do + presets[modeKey] = { + name = mode.name, + desc = mode.desc, + allowRanked = mode.allowRanked, + modOptions = buildEffectiveModOptions(defaults, mode, selector), + } + end + categories[category] = { selector = selector, presets = presets } + end + + return { schemaVersion = SCHEMA_VERSION, categories = categories } +end + +local function ExportGameModes() + local file, err = io.open(OUTPUT_PATH, "w") + if not file then + spEcho("Game Modes JSON Export: could not open " .. OUTPUT_PATH .. ": " .. tostring(err)) + return + end + file:write(Json.encode(buildExport())) + file:close() + spEcho("Game Modes JSON Export: wrote " .. OUTPUT_PATH) +end + +function widget:TextCommand(command) + if command == "exportgamemodes" then + ExportGameModes() + end +end diff --git a/tools/headless_testing/startscript_modes_export.txt b/tools/headless_testing/startscript_modes_export.txt new file mode 100644 index 00000000000..14ca66834d3 --- /dev/null +++ b/tools/headless_testing/startscript_modes_export.txt @@ -0,0 +1,28 @@ +[GAME] +{ + MapName=Quicksilver Remake 1.24; + GameType=Beyond All Reason $VERSION; + GameStartDelay=0; + StartPosType=0; + RecordDemo=0; + MyPlayerName=TestRunner; + IsHost=1; + FixedRNGSeed = 1; + [MODOPTIONS] + { + debugcommands=1:luaui enablewidget Game Modes JSON Export|2:exportgamemodes|3:quitforce; + deathmode=neverend; + } + [ALLYTEAM0] + { + } + [TEAM0] + { + allyteam=0; + teamleader=0; + } + [PLAYER0] + { + team=0; + } +}