Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions .github/workflows/export_game_modes.yml
Original file line number Diff line number Diff line change
@@ -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
116 changes: 116 additions & 0 deletions luaui/Widgets/export_game_modes.lua
Original file line number Diff line number Diff line change
@@ -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/<category>/*.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/<category>/*.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
28 changes: 28 additions & 0 deletions tools/headless_testing/startscript_modes_export.txt
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading