diff --git a/.gitignore b/.gitignore index 7690b3ab0..b560d8c10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ devmode.txt *code-workspace .vscode/settings.json + +# Game mode presets (BAR export_game_modes widget). The ModeCommand plugin +# self-fetches these from the BAR `game-modes` release into game_modes.cache.json; +# game_modes.json is an optional host-dropped fallback. Neither is source. +spads-plugins/ModeCommand/game_modes.json +spads-plugins/ModeCommand/game_modes.cache.json diff --git a/LuaMenu/widgets/api_analytics.lua b/LuaMenu/widgets/api_analytics.lua index 6af5038d5..b4b118118 100644 --- a/LuaMenu/widgets/api_analytics.lua +++ b/LuaMenu/widgets/api_analytics.lua @@ -462,29 +462,30 @@ local function GetDesyncGameStates() local infolog = VFS.LoadFile(filename) if infolog == nil then Spring.Echo("Failed to load desync dump", filename) + else + if string.len(infolog) > 16000000 then + infolog = string.sub(infolog,1,16000000) + end + local t0 = Spring.GetTimer() + infolog = ascii(infolog) + local compressedlog = Spring.Utilities.Base64Encode(VFS.ZlibCompress(infolog)) + local t1 = Spring.GetTimer() + --local validate = io.open("tempdump.txt",'w') + --validate:write(compressedlog) + --validate:close() + --Spring.Echo("Crash Dump Header is",string.sub(compressedlog, 1, 1000)) + local header = string.sub(infolog, 1, 1000) + -- Parse the following fields in the lines of the header: + local versionData = { + map = string.match(header, "mapName: ([^\n]+)"), + game = string.match(header, "modName: ([^\n]+)"), + engine = string.match(header, "syncVer: ([^\n]+)"), + gameID = string.match(header, "gameID: ([^\n]+)"), + } + + Analytics.SendCrashReportOneTimeEvent(filename, "SyncError", filename, compressedlog, false, versionData) + Spring.Echo("Dump done in ", Spring.DiffTimers(Spring.GetTimer(), t1), Spring.DiffTimers(t1,t0)) end - if string.len(infolog) > 16000000 then - infolog = string.sub(infolog,1,16000000) - end - local t0 = Spring.GetTimer() - infolog = ascii(infolog) - local compressedlog = Spring.Utilities.Base64Encode(VFS.ZlibCompress(infolog)) - local t1 = Spring.GetTimer() - --local validate = io.open("tempdump.txt",'w') - --validate:write(compressedlog) - --validate:close() - --Spring.Echo("Crash Dump Header is",string.sub(compressedlog, 1, 1000)) - local header = string.sub(infolog, 1, 1000) - -- Parse the following fields in the lines of the header: - local versionData = { - map = string.match(header, "mapName: ([^\n]+)"), - game = string.match(header, "modName: ([^\n]+)"), - engine = string.match(header, "syncVer: ([^\n]+)"), - gameID = string.match(header, "gameID: ([^\n]+)"), - } - - Analytics.SendCrashReportOneTimeEvent(filename, "SyncError", filename, compressedlog, false, versionData) - Spring.Echo("Dump done in ", Spring.DiffTimers(Spring.GetTimer(), t1), Spring.DiffTimers(t1,t0)) end end end diff --git a/LuaMenu/widgets/gui_battle_room_window.lua b/LuaMenu/widgets/gui_battle_room_window.lua index 4b708b3fa..7810531e0 100644 --- a/LuaMenu/widgets/gui_battle_room_window.lua +++ b/LuaMenu/widgets/gui_battle_room_window.lua @@ -58,6 +58,141 @@ local isMapDownloading = false local vote_votingsPattern = "(%d+)/(%d+).-:(%d+)" local vote_whoCalledPattern = "%* (.*) called a vote for command" local vote_mapPattern = 'set map (.*)"' +local vote_modePattern = "^[Mm]ode (%S+) (%S+)(.*)" + +local COLOR_GREEN = "\255\100\255\100" +local COLOR_GREY = "\255\150\150\150" +local COLOR_WHITE = "\255\255\255\255" +local COLOR_GOLD = "\255\255\220\100" + +local function GetModoptionName(key) + local defs = WG.ModoptionDefs + if not defs then return key end + for i = 1, #defs do + if defs[i].key == key then + return defs[i].name or key + end + end + return key +end + +local function IsOptionalModoption(key) + local defs = WG.ModoptionDefs + if not defs then return false end + for i = 1, #defs do + if defs[i].key == key then + return defs[i].optional == true + end + end + return false +end + +local function ParseModeVoteTitle(rawTitle) + local category, modeKey, rest = string.match(rawTitle, vote_modePattern) + if not modeKey then return nil end + + local votedOptions = {} + if rest then + for k, v in string.gmatch(rest, "(%S+)=(%S+)") do + votedOptions[k] = v + end + end + + return category, modeKey, votedOptions +end + +local function FormatModeVoteTitle(rawTitle) + local category, modeKey, votedOptions = ParseModeVoteTitle(rawTitle) + if not modeKey then return rawTitle, nil end + + local allModes = WG.Modes + local catModes = allModes and allModes[category] + if not (catModes and catModes.modes) then return rawTitle, nil end + + local mode + for _, m in ipairs(catModes.modes) do + if m.key == modeKey then mode = m; break end + end + if not mode then return rawTitle, nil end + + local modeName = mode.name or modeKey + + local deviations = {} + local defaults = {} + local disabled = {} + + if mode.modOptions then + for optKey, rule in pairs(mode.modOptions) do + if optKey == (category .. "_mode") then + -- skip + else + local modeDefault = tostring(rule.value) + if type(rule.value) == "boolean" then + modeDefault = rule.value and "1" or "0" + end + local votedVal = votedOptions[optKey] + local name = GetModoptionName(optKey) + if votedVal and votedVal ~= modeDefault then + deviations[#deviations + 1] = { name = name, value = votedVal, default = modeDefault } + else + defaults[#defaults + 1] = { name = name, value = votedVal or modeDefault } + end + end + end + end + + local defs = WG.ModoptionDefs + if defs then + for i = 1, #defs do + local opt = defs[i] + if opt.key and opt.section == category and opt.key ~= (category .. "_mode") then + local inMode = mode.modOptions and mode.modOptions[opt.key] + if not inMode then + disabled[#disabled + 1] = opt.name or opt.key + end + end + end + end + + -- Build compact display (for label) + local lines = {} + lines[#lines + 1] = COLOR_GOLD .. "Mode: " .. modeName .. COLOR_WHITE + if #deviations > 0 then + local parts = {} + for _, d in ipairs(deviations) do + parts[#parts + 1] = d.name .. ": " .. d.value + end + lines[#lines + 1] = COLOR_GREEN .. " Custom: " .. table.concat(parts, ", ") .. COLOR_WHITE + end + + local displayText = table.concat(lines, "\n") + + -- Build tooltip (full 4-section detail) + local tipLines = {} + tipLines[#tipLines + 1] = "MODE: " .. modeName + if #deviations > 0 then + tipLines[#tipLines + 1] = "" + tipLines[#tipLines + 1] = "CUSTOMIZED:" + for _, d in ipairs(deviations) do + tipLines[#tipLines + 1] = " " .. d.name .. ": " .. d.value .. " (default: " .. d.default .. ")" + end + end + if #defaults > 0 then + tipLines[#tipLines + 1] = "" + tipLines[#tipLines + 1] = "MODE DEFAULTS:" + for _, d in ipairs(defaults) do + tipLines[#tipLines + 1] = " " .. d.name .. ": " .. d.value + end + end + if #disabled > 0 then + tipLines[#tipLines + 1] = "" + tipLines[#tipLines + 1] = "NOT ACTIVE IN THIS MODE:" + tipLines[#tipLines + 1] = " " .. table.concat(disabled, ", ") + end + local tooltipText = table.concat(tipLines, "\n") + + return displayText, tooltipText +end local playerHandler @@ -2925,7 +3060,7 @@ local function SetupVotePanel(votePanel, battle, battleID) local oldVoteInitiator = "" local oldTitle = "" - function externalFunctions.VoteUpdate(voteMessage, pollType, mapPoll, candidates, votesNeeded, pollUrl, voteInitiator, resetButtons) + function externalFunctions.VoteUpdate(voteMessage, pollType, mapPoll, candidates, votesNeeded, pollUrl, voteInitiator, resetButtons, modeTooltip) --Spring.Echo("externalFunctions.VoteUpdate(voteMessage, pollType, mapPoll, candidates, votesNeeded, pollUrl, voteInitiator)",voteMessage, pollType, mapPoll, candidates, votesNeeded, pollUrl, voteInitiator) UpdatePollType(pollType, mapPoll, pollUrl) @@ -2941,6 +3076,7 @@ local function SetupVotePanel(votePanel, battle, battleID) oldTitle = oldVoteInitiator.. " called a vote for:\n"..voteMessage..timeleft voteName:SetCaption(oldTitle) + voteName.tooltip = modeTooltip if votesNeeded == -1 then voteCountLabel:SetCaption(tostring(candidates[1].votes)) voteProgressYes:SetValue(0) @@ -4382,7 +4518,16 @@ local function InitializeControls(battleID, oldLobby, topPoportion, setupData) local title = string.sub(message, string.find(message, ' "',nil,true) + 2, string.find(message, '" ', nil, true) - 1) title = title:sub(1, 1):upper() .. title:sub(2) - votePanel.VoteUpdate(title,nil, ismapppoll, candidates, votesNeeded, mapname, userwhocalledvote, newlycalledvote) + local modeTooltip + local _, parsedModeKey = ParseModeVoteTitle(title) + if parsedModeKey then + title, modeTooltip = FormatModeVoteTitle(title) + Spring.Echo("[ModeVote] Display: " .. title) + if modeTooltip then + Spring.Echo("[ModeVote] Tooltip:\n" .. modeTooltip) + end + end + votePanel.VoteUpdate(title,nil, ismapppoll, candidates, votesNeeded, mapname, userwhocalledvote, newlycalledvote, modeTooltip) return true elseif string.match(message, "Vote for command.*passed" ) then --[21:13:58] * [teh]host * Vote for command "bSet coop 1" passed. --voteend diff --git a/LuaMenu/widgets/gui_login_window.lua b/LuaMenu/widgets/gui_login_window.lua index 08b4db156..2a1b5332b 100644 --- a/LuaMenu/widgets/gui_login_window.lua +++ b/LuaMenu/widgets/gui_login_window.lua @@ -247,7 +247,9 @@ local function InitializeListeners() Configuration.password = registerPassword registerName = nil end - if Configuration.userName then + -- Skip auto-login when no password is saved (password == false): the user + -- logs in manually. Avoids gsub-on-boolean in Login/TextEraseNewline. + if Configuration.userName and (steamMode or type(Configuration.password) == "string") then lobby:Login(Configuration.userName, Configuration.password, 3, nil, "Chobby", steamMode) end end diff --git a/LuaMenu/widgets/gui_modoptions_panel.lua b/LuaMenu/widgets/gui_modoptions_panel.lua index db6bd5556..7efe1baf7 100644 --- a/LuaMenu/widgets/gui_modoptions_panel.lua +++ b/LuaMenu/widgets/gui_modoptions_panel.lua @@ -15,14 +15,29 @@ end -- Structure local modoptionDefaults = {} local modoptionStructure = {} +local modesByGame = {} +local activeModes = {} +local selectedModeKeys = {} +local modeUI = {} +local modoptionWindowOpen = false +local lockedOverlaysByKey = {} -- Variables local battleLobby local localModoptions = {} +local userModifiedOptions = {} +local isProgrammaticUpdate = false local modoptionControlNames = {} local modoptions local modoptionsByGame = {} +local function SetUserModifiedOption(key, value) + localModoptions[key] = value + if not isProgrammaticUpdate then + userModifiedOptions[key] = true + end +end + -- constants local MARKED_AS_CHANGED_COLOR = {0.99, 0.75, .3, 1} -- {0.07, 0.66, 0.92, 1.0} @@ -47,7 +62,97 @@ local function UpdateControlValue(key, value) end end -local function TextFromNum(num, step) +-- Forward declarations so SetControlLock (below) can format number values +-- through the same helpers used by the interactive editboxes. +local TextFromNum, getModOptionByKey + +-- Enable/disable the interactive control for a given modoption key +local function SetControlLock(key, locked) + if not modoptionControlNames then return end + local control = modoptionControlNames[key] + if not control then return end + if control.SetEnabled then + control:SetEnabled(not locked) + end + -- If the control is embedded in a row control, try disabling that too + local parent = control.parent + if parent and parent.SetEnabled and parent.name ~= "tabPanel" then + parent:SetEnabled(not locked) + end + -- Fallback: disable input handlers if the widget lacks SetEnabled + if control.OnSelectName then + control._origOnSelectName = control._origOnSelectName or control.OnSelectName + control.OnSelectName = locked and {} or control._origOnSelectName + end + if control.OnChange then + control._origOnChange = control._origOnChange or control.OnChange + control.OnChange = locked and {} or control._origOnChange + end + + -- Add/remove an input-blocking overlay for controls that don't visually disable + local parentRow = control.parent + if parentRow and parentRow.name ~= "tabPanel" then + local displayValue = localModoptions[key] or modoptionDefaults[key] or "" + if control.itemKeyToName then + displayValue = control.itemKeyToName[displayValue] or displayValue + elseif control.SetToggle then + if displayValue == "1" or displayValue == 1 or displayValue == true then + displayValue = "Enabled" + else + displayValue = "Disabled" + end + else + local option = getModOptionByKey(key) + local num = tonumber(displayValue) + if option and option.type == "number" and num then + -- Round to the option's step so float32 round-trips (e.g. 0.30000001) display cleanly + displayValue = TextFromNum(num, option.step or 1) + else + displayValue = tostring(displayValue) + end + end + + if locked then + local info = lockedOverlaysByKey[key] + if not info then + -- Expand narrow areas (e.g. checkboxes) so text like "Disabled" fits + local ovX, ovW = control.x, control.width + if ovW < 100 then ovW = 300 end + local overlay = Label:New { + name = "lockOverlay_" .. key, + x = ovX, + y = control.y, + width = ovW, + height = control.height, + valign = "center", + align = "left", + caption = tostring(displayValue), + tooltip = control.tooltip, + objectOverrideFont = WG.Chobby.Configuration:GetFont(2), + greedyHitTest = true, + } + parentRow:AddChild(overlay) + lockedOverlaysByKey[key] = { overlay = overlay, oldX = control.x } + if control.SetPos then control:SetPos(control.x + 4095, control.y) end + else + info.overlay:SetCaption(tostring(displayValue)) + end + else + local info = lockedOverlaysByKey[key] + if info then + if control.SetPos then control:SetPos(info.oldX or control.x, control.y) end + if info.overlay and info.overlay.parent then + info.overlay.parent:RemoveChild(info.overlay) + end + end + lockedOverlaysByKey[key] = nil + end + end + + -- Simple visual state: if a control supports SetEnabled, that's sufficient. +end + +function TextFromNum(num, step) -- remove excess accuracy local places = 0 @@ -68,7 +173,7 @@ local function TextFromNum(num, step) return text end -local function getModOptionByKey(key) +function getModOptionByKey(key) local retOption = {} for _, option in ipairs(modoptions) do if option.key and option.key == key then @@ -79,6 +184,52 @@ local function getModOptionByKey(key) return retOption end +local function getActiveMode(category) + local catModes = activeModes[category] + local selectedKey = selectedModeKeys[category] + if not (catModes and catModes.modes and selectedKey) then return nil end + for _, m in ipairs(catModes.modes) do + if m.key == selectedKey then return m end + end + return nil +end + +-- Applies the given mode's values to localModoptions, resets non-whitelisted +-- mode options to their defaults, and sets the category_mode key. +local function applyModeValues(mode) + if not mode then return end + + if mode.modOptions then + for optKey, rule in pairs(mode.modOptions) do + if rule.value ~= nil then + local modeValue = rule.value + if type(modeValue) == "boolean" then modeValue = tostring((modeValue and 1) or 0) end + modeValue = tostring(modeValue) + + if rule.locked or not userModifiedOptions[optKey] then + localModoptions[optKey] = modeValue + end + end + end + end + + if modoptions then + for i = 1, #modoptions do + local opt = modoptions[i] + if opt.key and opt.section == mode.category + and opt.type ~= "subheader" and opt.type ~= "separator" then + local isWhitelisted = mode.modOptions and mode.modOptions[opt.key] ~= nil + if not isWhitelisted and modoptionDefaults[opt.key] then + localModoptions[opt.key] = modoptionDefaults[opt.key] + userModifiedOptions[opt.key] = nil + end + end + end + end + + localModoptions[mode.category .. "_mode"] = mode.key +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Lock Handling @@ -211,7 +362,7 @@ local function ProcessListOption(data, index) label.font = WG.Chobby.Configuration:GetFont(2, "Changed2", {color = MARKED_AS_CHANGED_COLOR}) list.font = WG.Chobby.Configuration:GetFont(2, "Changed2", {color = MARKED_AS_CHANGED_COLOR}) end - localModoptions[data.key] = itemNameToKey[selectedName] + SetUserModifiedOption(data.key, itemNameToKey[selectedName]) end } or {function (obj, selectedName) @@ -222,7 +373,7 @@ local function ProcessListOption(data, index) label.font = WG.Chobby.Configuration:GetFont(2, "Changed2", {color = MARKED_AS_CHANGED_COLOR}) list.font = WG.Chobby.Configuration:GetFont(2, "Changed2", {color = MARKED_AS_CHANGED_COLOR}) end - localModoptions[data.key] = itemNameToKey[selectedName] + SetUserModifiedOption(data.key, itemNameToKey[selectedName]) end }, itemKeyToName = itemKeyToName, -- Not a chili key @@ -290,7 +441,7 @@ local function ProcessBoolOption(data, index) else -- on disable processChildrenLocks(data.lock, data.unlock, data.bitmask or 1) end - localModoptions[data.key] = tostring((newState and 1) or 0) + SetUserModifiedOption(data.key, tostring((newState and 1) or 0)) if (newState and modoptionDefaults[data.key] == "1") or (not newState and modoptionDefaults[data.key] == "0") then label.font = WG.Chobby.Configuration:GetFont(2) else @@ -304,7 +455,7 @@ local function ProcessBoolOption(data, index) else label.font = WG.Chobby.Configuration:GetFont(2, "Changed2", {color = MARKED_AS_CHANGED_COLOR}) end - localModoptions[data.key] = tostring((newState and 1) or 0) + SetUserModifiedOption(data.key, tostring((newState and 1) or 0)) end }, } @@ -322,7 +473,7 @@ local function ProcessBoolOption(data, index) control = Control:New { x = 0, y = index*32, - width = 350, + width = 625, height = 32, padding = {0, 0, 0, 0}, tooltip = data.desc, @@ -340,6 +491,15 @@ local function ProcessNumberOption(data, index) local control local oldText = localModoptions[data.key] or modoptionDefaults[data.key] + -- Seed the editbox with a step-rounded value so a synced float32 round-trip + -- (e.g. 0.30000001) shows cleanly before the user ever focuses the box. + -- Defaults are already formatted (see modoptionDefaults assignment); this + -- covers values coming from localModoptions. + local seedNum = tonumber(oldText) + if seedNum then + oldText = TextFromNum(seedNum, data.step or 1) + end + local label = Label:New { x = 5, y = 0, @@ -382,7 +542,7 @@ local function ProcessNumberOption(data, index) newValue = math.floor(newValue/data.step + 0.5)*data.step + 0.01*data.step oldText = TextFromNum(newValue, data.step) - localModoptions[data.key] = oldText + SetUserModifiedOption(data.key, oldText) obj:SetText(oldText) if oldText == modoptionDefaults[data.key] then @@ -414,7 +574,7 @@ local function ProcessNumberOption(data, index) newValue = math.floor(newValue/data.step + 0.5)*data.step + 0.01*data.step oldText = TextFromNum(newValue, data.step) - localModoptions[data.key] = oldText + SetUserModifiedOption(data.key, oldText) obj:SetText(oldText) if oldText == modoptionDefaults[data.key] then @@ -492,14 +652,14 @@ local function ProcessStringOption(data, index) if string.len(obj.text) <= 1 then if not textHidden then - localModoptions[data.key] = 0 + SetUserModifiedOption(data.key, 0) end obj.text = "" textBox.font = WG.Chobby.Configuration:GetFont(2) label.font = WG.Chobby.Configuration:GetFont(2) else - localModoptions[data.key] = obj.text + SetUserModifiedOption(data.key, obj.text) if obj.text == modoptionDefaults[data.key] then textBox.font = WG.Chobby.Configuration:GetFont(2) label.font = WG.Chobby.Configuration:GetFont(2) @@ -655,11 +815,250 @@ local function PopulateTab(options) return {contentsPanel} end +-------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- +-- Mode Panel + +local function CreateModePanel(category, sectionData) + local selectorKey = category .. "_mode" + local catModes = activeModes[category] + + local modeScroll = ScrollPanel:New { + name = "modeTabPanel_" .. category .. "_" .. (math.random(1000, 9999)), + x = 10, + right = 10, + y = 56, + bottom = 10, + horizontalScrollbar = false, + } + + local panelOptions = sectionData.options or {} + + local selectorOpt = nil + for _, opt in ipairs(panelOptions) do + if opt.key == selectorKey then selectorOpt = opt; break end + end + + local modeLabel = Label:New { + x = 15, + y = 10, + width = 200, + height = 30, + valign = "center", + align = "left", + caption = selectorOpt and selectorOpt.name or (category:sub(1,1):upper() .. category:sub(2) .. " Mode"), + objectOverrideFont = WG.Chobby.Configuration:GetFont(2), + } + + local items, itemKeyToName, itemNameToKey, itemsTooltips = {}, {}, {}, {} + if catModes and catModes.modes then + for i, m in ipairs(catModes.modes) do + local name = m.name or m.key + items[i] = name + itemKeyToName[m.key] = name + itemNameToKey[name] = m.key + if m.desc then + itemsTooltips[i] = m.desc + end + end + end + + local rankedBadge = Label:New { + x = 650, + y = 10, + width = 220, + height = 30, + valign = "center", + align = "left", + caption = "", + objectOverrideFont = WG.Chobby.Configuration:GetFont(2, nil, {color = {1,0.3,0.3,1}}), + } + + local function applyMode(modeKey) + if not (catModes and catModes.modes) then return end + local previousModeKey = selectedModeKeys[category] + selectedModeKeys[category] = modeKey + local mode + for _, m in ipairs(catModes.modes) do + if m.key == modeKey then mode = m; break end + end + if not mode then return end + + local allowRanked = (mode.allowRanked ~= false) + WG.ModePolicy = WG.ModePolicy or {} + WG.ModePolicy[category] = WG.ModePolicy[category] or {} + WG.ModePolicy[category].allowRanked = allowRanked + WG.ModePolicy[category].modeLocked = {} + rankedBadge:SetCaption(allowRanked and "" or "Not Ranked") + + isProgrammaticUpdate = true + if not allowRanked then + localModoptions["ranked_game"] = "0" + UpdateControlValue("ranked_game", "0") + end + isProgrammaticUpdate = false + + if WG.BattleRoomWindow and WG.BattleRoomWindow.SetRankedModeAllowed then + WG.BattleRoomWindow.SetRankedModeAllowed(allowRanked) + end + + -- Switching modes resets the category to defaults then applies the preset. + -- retainValues modes (Customize) are non-sticky: keep current values, just expose/unlock. + if not mode.retainValues then + if previousModeKey ~= modeKey and modoptions then + for i = 1, #modoptions do + local opt = modoptions[i] + if opt.key and opt.section == category + and opt.type ~= "subheader" and opt.type ~= "separator" then + userModifiedOptions[opt.key] = nil + localModoptions[opt.key] = modoptionDefaults[opt.key] + end + end + end + + applyModeValues(mode) + end + + for _, opt in ipairs(panelOptions) do + if opt.key then + modoptionControlNames[opt.key] = nil + lockedOverlaysByKey[opt.key] = nil + end + end + + modeScroll:ClearChildren() + + -- A subheader has no key of its own, so it can't be whitelisted/hidden per mode. + -- Hide it when every option in its group (up to the next subheader/separator) + -- is filtered out, so modes don't render an orphan group header. + local function modeGroupHasVisibleOption(startIndex) + for i = startIndex + 1, #panelOptions do + local o = panelOptions[i] + if o.type == "subheader" or o.type == "separator" then break end + if o.key and o.key ~= selectorKey + and mode.modOptions and mode.modOptions[o.key] + and mode.modOptions[o.key].ui ~= "hidden" then + return true + end + end + return false + end + + local column, row = 1, 0 + for index, opt in ipairs(panelOptions) do + if opt.key == selectorKey then + -- handled by the header dropdown + elseif opt.type ~= "subheader" and opt.type ~= "separator" + and not (mode.modOptions and mode.modOptions[opt.key]) then + -- not whitelisted for this mode + elseif mode.modOptions and mode.modOptions[opt.key] and mode.modOptions[opt.key].ui == "hidden" then + -- explicitly hidden by this mode + elseif opt.type == "subheader" and not modeGroupHasVisibleOption(index) then + -- empty group for this mode; skip the orphan header + else + if (opt.column or -1) > column then + row = row - 1 + end + + local rowData = nil + if opt.type == "number" then + rowData = ProcessNumberOption(opt, row) + elseif opt.type == "string" then + rowData = ProcessStringOption(opt, row) + elseif opt.type == "subheader" then + rowData = ProcessSubHeader(opt, row) + elseif opt.type == "bool" then + rowData = ProcessBoolOption(opt, row) + elseif opt.type == "list" then + rowData = ProcessListOption(opt, row) + elseif opt.type == "separator" then + rowData = ProcessLineSeparator(opt, row) + row = row - 0.5 + end + if rowData then + column = math.abs(opt.column or 1) + rowData.x = rowData.x + (column - 1) * 625 + row = row + 1 + rowData.rowOrginal = rowData.y + modeScroll:AddChild(rowData) + end + end + end + + if mode.modOptions then + for optKey, rule in pairs(mode.modOptions) do + if rule.locked then + lockedOptions[optKey] = 1 + WG.ModePolicy[category].modeLocked[optKey] = true + SetControlLock(optKey, true) + else + lockedOptions[optKey] = nil + WG.ModePolicy[category].modeLocked[optKey] = nil + SetControlLock(optKey, false) + end + end + end + end + + local defaultSelected = 1 + if catModes and catModes.modes then + for i, m in ipairs(catModes.modes) do + if m.key == (selectedModeKeys[category] or "enabled") then + defaultSelected = i; break + end + end + end + + local modeList = ComboBox:New { + x = 340, + y = 11, + width = 300, + height = 30, + items = items, + itemsTooltips = itemsTooltips, + selectByName = true, + selected = defaultSelected, + objectOverrideFont = WG.Chobby.Configuration:GetFont(2), + OnSelectName = { + function (obj, selectedName) + local k = itemNameToKey[selectedName] + WG.Delay(function() + applyMode(k) + end, 0.05) + end + }, + } + + modeUI[category] = { modeList = modeList, rankedBadge = rankedBadge, applyMode = applyMode, itemKeyToName = itemKeyToName } + + local parentPanel = Control:New { + name = "modeParentPanel_" .. category .. "_" .. (math.random(1000, 9999)), + x = 6, + right = 5, + y = 10, + bottom = 8, + padding = {0,0,0,0}, + } + + parentPanel:AddChild(modeLabel) + parentPanel:AddChild(modeList) + parentPanel:AddChild(rankedBadge) + parentPanel:AddChild(Line:New { classname = "line_solid", x = 10, y = 48, right = 10, height = 2 }) + parentPanel:AddChild(modeScroll) + + if catModes and catModes.modes and catModes.modes[defaultSelected] then + applyMode(catModes.modes[defaultSelected].key) + end + + return { parentPanel } +end + -------------------------------------------------------------------------------- -------------------------------------------------------------------------------- -- Modoptions Window Handler local function CreateModoptionWindow() + modoptionWindowOpen = true local ww, wh = Spring.GetWindowGeometry() local modoptionsSelectionWindow = Window:New { @@ -675,6 +1074,8 @@ local function CreateModoptionWindow() localModoptions = Spring.Utilities.CopyTable(battleLobby:GetMyBattleModoptions() or {}) modoptionControlNames = {} + lockedOverlaysByKey = {} + postLock = {} local tabs = {} lockedOptions = {} @@ -691,7 +1092,11 @@ local function CreateModoptionWindow() if origCaption ~= caption then tooltip = origCaption end - local tabChildren = PopulateTab(data.options) + local catModes = activeModes[key] + local tabChildren = (not catModes) and PopulateTab(data.options) or {} + if catModes then + tabChildren = CreateModePanel(key, data) + end tabs[#tabs + 1] = { name = key, caption = caption, @@ -745,6 +1150,7 @@ local function CreateModoptionWindow() } } local function CancelFunc() + modoptionWindowOpen = false modoptionsSelectionWindow:Dispose() if WG.BattleRoomChatInput then screen0:FocusControl(WG.BattleRoomChatInput) @@ -755,15 +1161,122 @@ local function CreateModoptionWindow() local function AcceptFunc() screen0:FocusControl(buttonAccept) -- Defocus the text entry + + local allModeKeys -- keys this mode defines; drives the deviation diff + local managedKeys -- the whole mode category; the !mode plugin owns it server-side + for cat, _ in pairs(activeModes) do + local mode = getActiveMode(cat) + if mode then + if mode.retainValues then + -- Non-sticky mode (Customize): keep the retained/edited values and + -- only record the mode key. applyModeValues would overwrite every + -- option with the mode's default value (the carried-over settings + -- aren't flagged in userModifiedOptions), wiping the customization. + localModoptions[cat .. "_mode"] = mode.key + else + applyModeValues(mode) + end + + local selectorKey = cat .. "_mode" + allModeKeys = allModeKeys or {} + managedKeys = managedKeys or {} + allModeKeys[selectorKey] = true + managedKeys[selectorKey] = true + if mode.modOptions then + for optKey, _ in pairs(mode.modOptions) do + allModeKeys[optKey] = true + end + end + -- !mode resets the whole category server-side; keep every category key + -- off the !bSet path (BAR grants players no bSet command). + if modoptions then + for i = 1, #modoptions do + local opt = modoptions[i] + if opt.key and opt.section == cat + and opt.type ~= "subheader" and opt.type ~= "separator" then + managedKeys[opt.key] = true + end + end + end + end + end + local isBoss = false if not isBoss then + local policy = WG.ModePolicy or {} for k, v in pairs(localModoptions) do if lockedOptions[k] then - localModoptions[k] = battleLobby.modoptions[k] + local isModeLocked = false + for cat, catPolicy in pairs(policy) do + if catPolicy.modeLocked and catPolicy.modeLocked[k] then + isModeLocked = true + break + end + end + if not isModeLocked then + localModoptions[k] = battleLobby.modoptions[k] + end end end end - battleLobby:SetModOptions(localModoptions) + + for cat, _ in pairs(activeModes) do + local mode = getActiveMode(cat) + if mode and mode.modOptions then + local selectorKey = cat .. "_mode" + + -- Carry the user's in-category edits as deviations on top of the preset; + -- locked options always take the preset value (and the plugin would reject + -- them as explicit params anyway). + local deviations = {} + if modoptions then + for i = 1, #modoptions do + local opt = modoptions[i] + if opt.key and opt.section == cat + and opt.type ~= "subheader" and opt.type ~= "separator" then + local rule = mode.modOptions[opt.key] + if userModifiedOptions[opt.key] and not (rule and rule.locked) + and localModoptions[opt.key] ~= nil then + deviations[opt.key] = localModoptions[opt.key] + end + end + end + end + + -- Resolve the mode's full effective set with the SAME shared resolver the + -- singleplayer path uses (and the SPADS plugin mirrors server-side), then + -- send only the user's deviations from the mode's own preset (see + -- ModeResolver.DeviationsFromMode). A clean mode pick sends no option params; + -- the server expands the preset from its version-matched game_modes.json. + local resolved = ModeResolver.Resolve(activeModes, modoptions, cat, mode.key, deviations) + local params = ModeResolver.DeviationsFromMode(resolved, modoptions, mode, cat) + + -- Send a !mode when the selector changes or the user deviated from the + -- preset. (Locked options can't be deviated, so they never reach params.) + local modeChanged = battleLobby.modoptions[selectorKey] ~= mode.key + for k, v in pairs(params) do + if battleLobby.modoptions[k] ~= v then modeChanged = true; break end + end + + if modeChanged then + local MAX_COMMAND_LENGTH = 1024 -- teiserver SAYBATTLE cap + local parts = { "!mode", tostring(cat), tostring(mode.key) } + for k, v in pairs(params) do + parts[#parts + 1] = tostring(k) .. "=" .. tostring(v) + end + if #table.concat(parts, " ") > MAX_COMMAND_LENGTH then + WG.Chobby.InformationPopup( + "Too many customizations to send as one vote — the lobby caps commands at " .. MAX_COMMAND_LENGTH .. " characters.\n\nReduce your overrides, or use Customize mode to set options individually.", + { width = 480, height = 260 }) + else + battleLobby:SetMode(mode.category, mode.key, params) + end + end + end + end + + battleLobby:SetModOptions(localModoptions, managedKeys or allModeKeys) + modoptionWindowOpen = false modoptionsSelectionWindow:Dispose() if WG.BattleRoomChatInput then screen0:FocusControl(WG.BattleRoomChatInput) @@ -1094,6 +1607,29 @@ local function InitializeModoptionsDisplay() panelModoptions = modopts or panelModoptions or {} if not modoptions then return end + -- Reflect the mode chosen in the battle (e.g. sharing_mode) so the tab shows + -- the active mode even when it was changed externally (SPADS, other players). + -- If the modoptions window is open, live-refresh that category's panel so the + -- selector and its sub-options update without reopening. + if activeModes then + for cat in pairs(activeModes) do + local battleKey = panelModoptions[cat .. "_mode"] + if battleKey and selectedModeKeys[cat] ~= battleKey then + selectedModeKeys[cat] = battleKey + local ui = modeUI[cat] + if modoptionWindowOpen and ui and ui.applyMode then + local name = ui.itemKeyToName and ui.itemKeyToName[battleKey] + if name and ui.modeList then + ui.modeList:Select(name) + end + ui.applyMode(battleKey) + end + elseif battleKey then + selectedModeKeys[cat] = battleKey + end + end + end + for _, option in pairs(modoptions) do if option.type == "bool" then if panelModoptions[option.key] == "1" then @@ -1150,7 +1686,13 @@ local function InitializeModoptionsDisplay() text = text .. "\255\255\75\75".."Couldn't Parse\n".."\255\128\128\128"..shortenedValue(value) end else - text = text .. shortenedValue(value) + local num = tonumber(value) + if option.type == "number" and num then + -- Round to the option's step so float32 round-trips display cleanly + text = text .. TextFromNum(num, option.step or 1) + else + text = text .. shortenedValue(value) + end end text = text .. "\n" empty = false @@ -1212,7 +1754,6 @@ function ModoptionsPanel.RefreshModoptions() sections = {} } - -- Populate the sections for i = 1, #modoptions do local data = modoptions[i] if data.type == "section" then @@ -1225,7 +1766,6 @@ function ModoptionsPanel.RefreshModoptions() title = data.section, options = {} } - local options = modoptionStructure.sections[data.section].options options[#options + 1] = data elseif showHidden and devmode then @@ -1238,6 +1778,7 @@ function ModoptionsPanel.RefreshModoptions() end end + if not devmode then modoptionStructure.sections["dev"] = nil end @@ -1287,14 +1828,60 @@ function ModoptionsPanel.LoadModoptions(gameName, newBattleLobby, forceReload) end + local function LoadModes() + local byCategory = {} + + local modeDirs = VFS.SubDirs("modes/", "*", VFS.ZIP) + for _, dir in ipairs(modeDirs) do + local modeFiles = VFS.DirList(dir, "*.lua", VFS.ZIP) + for _, modeFile in ipairs(modeFiles) do + local mode = VFS.Include(modeFile) + if mode and mode.key and mode.category then + byCategory[mode.category] = byCategory[mode.category] or { modes = {} } + local modes = byCategory[mode.category].modes + modes[#modes + 1] = mode + end + end + end + + if next(byCategory) then + return byCategory + end + return nil + end + activeModes = modesByGame[gameName] + if not activeModes then + activeModes = VFS.UseArchive(gameName, LoadModes) or {} + modesByGame[gameName] = activeModes + end + WG.Modes = activeModes + WG.ModoptionDefs = modoptions modoptionDefaults = {} if not modoptions then return end + -- Populate mode selector items dynamically for each category + for cat, catModes in pairs(activeModes) do + local selectorKey = cat .. "_mode" + for i = 1, #modoptions do + local data = modoptions[i] + if data.key == selectorKey and data.type == "list" then + data.items = {} + for _, mode in ipairs(catModes.modes) do + data.items[#data.items + 1] = { + key = mode.key, + name = mode.name, + desc = mode.desc or "" + } + end + break + end + end + end + local currentUnixTime = os.time() - -- Set modoptionDefaults for i = 1, #modoptions do local data = modoptions[i] @@ -1344,6 +1931,8 @@ end function widget:Initialize() CHOBBY_DIR = LUA_DIRNAME .. "widgets/chobby/" VFS.Include(LUA_DIRNAME .. "widgets/chobby/headers/exports.lua", nil, VFS.RAW_FIRST) + VFS.Include("libs/json.lua") + VFS.Include("libs/liblobby/lobby/moderesolver.lua") -- defines global ModeResolver WG.ModoptionsPanel = ModoptionsPanel end diff --git a/libs/liblobby/lobby/interface.lua b/libs/liblobby/lobby/interface.lua index 011f262aa..077e5c991 100644 --- a/libs/liblobby/lobby/interface.lua +++ b/libs/liblobby/lobby/interface.lua @@ -506,12 +506,11 @@ function Interface:SayBattleEx(message) return self end -function Interface:SetModOptions(data) +function Interface:SetModOptions(data, skipKeys) for k, v in pairs(data) do - if self.modoptions[k] ~= v then + if not (skipKeys and skipKeys[k]) and self.modoptions[k] ~= v then self:SayBattle("!bSet " .. tostring(k) .. " " .. tostring(v)) end - -- self:_SendCommand("SETSCRIPTTAGS game/modoptions/" .. k .. '=' .. v) end return self end @@ -616,6 +615,15 @@ function Interface:SetModOptionsThrottled(data, maxCharsPerBatch, intervalSecond return self:SayBattleThrottled(lines, maxCharsPerBatch, intervalSeconds, progressCallback, cancelToken) end +function Interface:SetMode(category, modeKey, modeOptions) + local parts = { "!mode", tostring(category), tostring(modeKey) } + for k, v in pairs(modeOptions) do + parts[#parts + 1] = tostring(k) .. "=" .. tostring(v) + end + self:SayBattle(table.concat(parts, " ")) + return self +end + function Interface:AddAi(aiName, aiLib, allyNumber, version, aiOptions, battleStatusOptions) local userData = { isReady = true, diff --git a/libs/liblobby/lobby/interface_skirmish.lua b/libs/liblobby/lobby/interface_skirmish.lua index c4267f694..9fa7e1a81 100644 --- a/libs/liblobby/lobby/interface_skirmish.lua +++ b/libs/liblobby/lobby/interface_skirmish.lua @@ -614,8 +614,40 @@ function InterfaceSkirmish:UpdateAi(aiName, status) self:_OnUpdateUserBattleStatus(aiName, status) end -function InterfaceSkirmish:SetModOptions(data) - self:_OnSetModOptions(data) +-- Skirmish has no autohost to expand a mode, so do it locally with the shared +-- ModeResolver (libs/liblobby/lobby/moderesolver.lua) — the same logic the SPADS +-- ModeCommand plugin mirrors server-side. Source = WG.Modes/ModoptionDefs (the +-- mode defs and modoption list the modoptions panel loads from the game archive). +local function resolveModeModoptions(category, modeKey, deviations) + return ModeResolver.Resolve(WG.Modes, WG.ModoptionDefs, category, modeKey, deviations) +end + +-- skipKeys come from the mode category (managed by SetMode's expansion); skip +-- them here so a follow-up SetModOptions can't clobber the resolved category. +function InterfaceSkirmish:SetModOptions(data, skipKeys) + if skipKeys then + local merged = {} + if self.modoptions then + for k, v in pairs(self.modoptions) do merged[k] = v end + end + for k, v in pairs(data) do + if not skipKeys[k] then merged[k] = v end + end + self:_OnSetModOptions(merged) + else + self:_OnSetModOptions(data) + end + return self +end + +function InterfaceSkirmish:SetMode(category, modeKey, modeOptions) + local resolved = resolveModeModoptions(category, modeKey, modeOptions) + local merged = {} + if self.modoptions then + for k, v in pairs(self.modoptions) do merged[k] = v end + end + for k, v in pairs(resolved or modeOptions or {}) do merged[k] = v end + self:_OnSetModOptions(merged) return self end diff --git a/libs/liblobby/lobby/lobby.lua b/libs/liblobby/lobby/lobby.lua index b1867f534..c239a1f07 100644 --- a/libs/liblobby/lobby/lobby.lua +++ b/libs/liblobby/lobby/lobby.lua @@ -4,6 +4,7 @@ VFS.Include(LIB_LOBBY_DIRNAME .. "observable.lua") VFS.Include(LIB_LOBBY_DIRNAME .. "utilities.lua") +VFS.Include(LIB_LOBBY_DIRNAME .. "moderesolver.lua") local JsonDecode = Json.decode local spGetTimer = Spring.GetTimer diff --git a/libs/liblobby/lobby/moderesolver.lua b/libs/liblobby/lobby/moderesolver.lua new file mode 100644 index 000000000..cc8c72c14 --- /dev/null +++ b/libs/liblobby/lobby/moderesolver.lua @@ -0,0 +1,104 @@ +-- Shared resolver: turns a selected game mode into its full effective modoption +-- set. Single source of truth for singleplayer (interface_skirmish), the +-- multiplayer modoptions panel send-path, and the panel display, so they cannot +-- drift. The server SPADS ModeCommand plugin mirrors this same logic from +-- game_modes.json (baked by BAR CI from the same modes/*.lua) for the hosted game +-- version, so client and server agree as long as both are keyed to that version. +-- +-- modes: the activeModes shape -> +-- { [category] = { modes = { { key=, category=, modOptions = {...} }, ... } } } +-- defs: the modoptions list -> array of { key=, section=, type=, def=, ... } + +ModeResolver = ModeResolver or {} + +-- modoptions are strings on the wire; booleans map to the engine's "1"/"0". +-- Engine Lua numbers are float32, so plain tostring leaks precision noise +-- ("0.60000002"). Round at float32's ~7-digit precision with %.7f, then strip +-- trailing zeros (the engine's %g does not trim them). Yields "0.6", "30", "-1". +-- This MUST match the export widget's toModOptionValue (Beyond-All-Reason +-- luaui/Widgets/export_game_modes.lua) byte-for-byte: the values we send must read +-- back identically to game_modes.json, which the export bakes the same way. +local function toVal(v) + if type(v) == "boolean" then return v and "1" or "0" end + if type(v) == "number" then + return (string.format("%.7f", v):gsub("0+$", ""):gsub("%.$", "")) + end + return tostring(v) +end + +local function findMode(modes, category, modeKey) + local catModes = modes and modes[category] + if not (catModes and catModes.modes) then return nil end + for _, m in ipairs(catModes.modes) do + if m.key == modeKey then return m end + end + return nil +end + +-- Full effective modoptions for a mode: the category's modoption defaults overlaid +-- with the mode preset, then any deviations, plus the _mode selector. +-- Returns nil if the mode data is unavailable, so callers can fall back to whatever +-- options they were handed. +function ModeResolver.Resolve(modes, defs, category, modeKey, deviations) + if not defs then return nil end + local mode = findMode(modes, category, modeKey) + if not mode then return nil end + + local selectorKey = category .. "_mode" + local out = {} + for i = 1, #defs do + local o = defs[i] + if o.section == category and o.key and o.key ~= selectorKey + and o.type ~= "subheader" and o.type ~= "separator" and o.def ~= nil then + out[o.key] = toVal(o.def) + end + end + for k, rule in pairs(mode.modOptions or {}) do + if k ~= selectorKey and rule.value ~= nil then out[k] = toVal(rule.value) end + end + if deviations then + for k, v in pairs(deviations) do out[k] = toVal(v) end + end + out[selectorKey] = modeKey + return out +end + +-- The option set to send on a multiplayer !mode command (also the text SPADS shows +-- voters): the user's deviations from the SELECTED MODE's own preset -- options whose +-- effective value differs from what the mode itself sets them to. The baseline is the +-- mode preset (the category defaults overlaid with the mode's modOptions), NOT the +-- bare modoption defaults: a value the mode bakes in (e.g. tech_core's tax rates) is +-- part of the mode, not a deviation, so a clean mode pick sends NO option params -- +-- just the mode key, which the server expands into the full preset from its +-- version-matched game_modes.json. Locked options can't be customized, so they never +-- appear here. Only the selector key is always omitted (the command carries it). +function ModeResolver.DeviationsFromMode(resolved, defs, mode, category) + local params = {} + if not (resolved and defs) then return params end + + local selectorKey = category .. "_mode" + -- Baseline = the mode's preset effective values WITHOUT user deviations, i.e. + -- exactly what Resolve(...) would return with no deviations. Run every value + -- through the same toVal as `resolved` so the comparison is string-clean. + local baseline = {} + for i = 1, #defs do + local o = defs[i] + if o.section == category and o.key and o.key ~= selectorKey + and o.type ~= "subheader" and o.type ~= "separator" and o.def ~= nil then + baseline[o.key] = toVal(o.def) + end + end + local modeOptions = mode and mode.modOptions + if modeOptions then + for k, rule in pairs(modeOptions) do + if k ~= selectorKey and rule.value ~= nil then baseline[k] = toVal(rule.value) end + end + end + + for k, v in pairs(resolved) do + if k ~= selectorKey and baseline[k] ~= v then params[k] = v end + end + return params +end + +return ModeResolver diff --git a/spads-plugins/ModeCommand/ModeCommand.conf b/spads-plugins/ModeCommand/ModeCommand.conf new file mode 100644 index 000000000..9b1fcdc59 --- /dev/null +++ b/spads-plugins/ModeCommand/ModeCommand.conf @@ -0,0 +1,2 @@ +commandsFile:ModeCommandCmd.conf +helpFile:ModeCommandHelp.dat diff --git a/spads-plugins/ModeCommand/ModeCommandCmd.conf b/spads-plugins/ModeCommand/ModeCommandCmd.conf new file mode 100644 index 000000000..612fa2568 --- /dev/null +++ b/spads-plugins/ModeCommand/ModeCommandCmd.conf @@ -0,0 +1,9 @@ +#?source:status:gameState|directLevel:voteLevel + +# !mode is the moded equivalent of the built-in bSet, so it mirrors bSet's +# privilege exactly: in a stopped battle a player (level >=10) calls a vote and an +# admin (level >=100) applies directly; elsewhere level 100 with no vote. Keep this +# in sync with the [bSet] block in SPADS's default commands.conf. +[mode] +battle,pv:player:stopped|100:10 +::|100: diff --git a/spads-plugins/ModeCommand/ModeCommandHelp.dat b/spads-plugins/ModeCommand/ModeCommandHelp.dat new file mode 100644 index 000000000..322bff84a --- /dev/null +++ b/spads-plugins/ModeCommand/ModeCommandHelp.dat @@ -0,0 +1,4 @@ +[mode] +!mode [= ...] - Apply a gameplay mode with optional customizations + Ex: !mode sharing enabled + Ex: !mode sharing easy_tax unit_share_stun_seconds=45 tax_resource_sharing_amount=0.5 \ No newline at end of file diff --git a/spads-plugins/ModeCommand/modecommand.py b/spads-plugins/ModeCommand/modecommand.py new file mode 100644 index 000000000..e28a930e7 --- /dev/null +++ b/spads-plugins/ModeCommand/modecommand.py @@ -0,0 +1,268 @@ +import perl +import re +import os +import json +import urllib.request + +spads = perl.ModeCommand + +pluginVersion = '0.2' +requiredSpadsVersion = '0.12.29' + + +# game_modes.json describes every mode preset's full effective modoption set for a +# given game version. It is generated headless in BAR CI from the game's +# modes//*.lua (luaui/Widgets/export_game_modes.lua) and published per +# channel as a GitHub Release asset. This plugin self-fetches the asset for the +# version it is currently hosting, caches it locally, and falls back to a local +# file, so `!mode ` can apply a mode's full option set. +# Schema: { schemaVersion, categories: { : { selector, presets: { +# : { name, desc, allowRanked, modOptions: { : {value, locked} } } } } } } + +_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Default publish location: the rolling `game-modes` prerelease in the BAR repo. +# We prefer a COMMIT-PINNED asset (game_modes-.json, published by BAR CI's +# export_game_modes.yml on manual dispatch for the exact commit) so the plugin never +# serves mode data that disagrees with the game it is hosting — the client/skirmish +# reads the matching modes/*.lua straight from that archive via VFS, and the hosted +# version string ends in that same git short SHA (e.g. "...test-24450-6c81e38" -> +# 6c81e38), so the SHA is what keeps the two in sync. We fall back to the rolling +# per-channel asset (game_modes-.json) when no pinned asset exists for the +# commit (the normal case for production hosts), which preserves the previous behaviour. +# Override the base for local testing with MODECOMMAND_RELEASE_BASE; pin to an explicit +# asset key with MODECOMMAND_MODES_VERSION (any -> game_modes-.json, handy +# for hosting an off-master build), or force the channel with +# MODECOMMAND_MODES_CHANNEL=test|stable, in the SPADS service Environment=. +_DEFAULT_RELEASE_BASE = 'https://github.com/beyond-all-reason/Beyond-All-Reason/releases/download/game-modes' +_CACHE_PATH = os.path.join(_PLUGIN_DIR, 'game_modes.cache.json') +_LOCAL_PATHS = [ + os.path.join(_PLUGIN_DIR, 'game_modes.json'), + '/opt/spads/var/plugins/game_modes.json', +] +_FETCH_TIMEOUT = 5 + +# Loaded data, keyed by the version (modName) it was fetched for. _UNSET forces the +# first lookup to fetch; a `!rehost` to a different version refetches. +_UNSET = object() +_state = {'modName': _UNSET, 'data': {}} + + +def _conf(): + try: + return spads.getSpadsConf() + except Exception: + return {} + + +def _current_mod_name(): + # The human-readable hosted game name, e.g. "Beyond All Reason test-24450-...". + try: + return _conf().get('modName') + except Exception: + return None + + +def _release_base(): + return (os.environ.get('MODECOMMAND_RELEASE_BASE') or _DEFAULT_RELEASE_BASE).rstrip('/') + + +def _channel(mod_name): + # `auto` (default) infers the channel from the hosted version string: test + # builds carry a "test" marker, everything else is treated as stable. + override = (os.environ.get('MODECOMMAND_MODES_CHANNEL') or 'auto').lower() + if override in ('test', 'stable'): + return override + return 'test' if (mod_name and 'test' in mod_name.lower()) else 'stable' + + +def _commit_sha(mod_name): + # The asset key for a commit-pinned fetch. Prefer an explicit ops override (any + # asset key -> game_modes-.json); otherwise extract the trailing git short + # SHA from the hosted version string (e.g. "Beyond All Reason test-24450-6c81e38" + # -> "6c81e38"). Returns None when no SHA token is present (e.g. a clean stable + # version), in which case _fetch falls back to the per-channel asset. + override = os.environ.get('MODECOMMAND_MODES_VERSION') + if override: + return override.strip() or None + if not mod_name: + return None + m = re.search(r'-([0-9a-f]{7,40})\s*$', mod_name) + return m.group(1) if m else None + + +def _fetch(mod_name): + # Parsed JSON for the hosted version, or None if it could not be fetched (caller + # falls back). Tries the commit-pinned asset first, then the rolling per-channel + # asset. On success the raw body is cached to disk. + base = _release_base() + urls = [] + sha = _commit_sha(mod_name) + if sha: + urls.append('%s/game_modes-%s.json' % (base, sha)) + urls.append('%s/game_modes-%s.json' % (base, _channel(mod_name))) + + for url in urls: + try: + with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT) as resp: + body = resp.read() + data = json.loads(body) + try: + with open(_CACHE_PATH, 'wb') as f: + f.write(body) + except OSError: + pass + return data + except Exception as e: + spads.slog('mode: could not fetch %s: %s' % (url, e), 2) + return None + + +def _load_local(): + # Last good fetch first, then any host-dropped file. + for path in [_CACHE_PATH] + _LOCAL_PATHS: + try: + with open(path) as f: + return json.load(f) + except Exception: + continue + return None + + +def _modes(): + # Fetch once per hosted version (modName); a `!rehost` to a different version + # refetches. On failure, serve the last cached copy, then any host-dropped file, + # then {} — and still mark the version attempted so `!mode` never blocks on a + # repeated network call. Reload the plugin to force a refresh. + mod_name = _current_mod_name() + if mod_name != _state['modName']: + data = _fetch(mod_name) + if data is None: + data = _load_local() + _state['data'] = data if data is not None else {} + _state['modName'] = mod_name + return _state['data'] + + +def _as_dict(v): + # The export's engine-side Json.encode emits an empty Lua table as `[]`, not + # `{}` (e.g. a game with no mode categories). Coerce so lookups never hit a list. + return v if isinstance(v, dict) else {} + +globalPluginParams = { + 'commandsFile': ['notNull'], + 'helpFile': ['notNull'], +} +presetPluginParams = None + + +def getVersion(pluginObject): + return pluginVersion + +def getRequiredSpadsVersion(pluginName): + return requiredSpadsVersion + +def getParams(pluginName): + return [globalPluginParams, presetPluginParams] + + +class ModeCommand: + + def __init__(self, context): + spads.addSpadsCommandHandler({'mode': hSpadsMode}) + spads.slog("Plugin loaded (version %s)" % pluginVersion, 3) + + def onUnload(self, reason): + spads.removeSpadsCommandHandler(['mode']) + spads.slog("Plugin unloaded", 3) + + +def hSpadsMode(source, user, params, checkOnly): + (source, user) = spads.fix_string(source, user) + for i in range(len(params)): + params[i] = spads.fix_string(params[i]) + + if len(params) < 2: + spads.invalidSyntax(user, 'mode') + return 0 + + category = params[0] + mode_key = params[1] + kv_params = params[2:] + + category_data = _as_dict(_modes().get('categories')).get(category) + if not category_data: + spads.answer('Unknown mode category "%s"' % category) + return 0 + presets = _as_dict(category_data.get('presets')) + preset = presets.get(mode_key) + if not preset: + valid = ', '.join(sorted(presets)) or '(none)' + spads.answer('Unknown mode "%s" for category "%s" (valid: %s)' % (mode_key, category, valid)) + return 0 + + mod_options = _as_dict(preset.get('modOptions')) + + settings = [] + for param in kv_params: + m = re.match(r'^([^=]+)=(.*)$', param) + if not m: + spads.answer('Invalid parameter format "%s" (expected key=value)' % param) + return 0 + settings.append((m.group(1).lower(), m.group(2))) + + locked_keys = set( + key for key, spec in mod_options.items() + if isinstance(spec, dict) and spec.get('locked') + ) + + # Reject unknown option keys outright rather than silently bSet-ing a typo + # (e.g. "resource_sharing_tax" for "tax_resource_sharing_amount"), which would + # drop the intended change and apply a bogus setting. Rejecting in the checkOnly + # phase means the vote is never called for a malformed command. + valid_keys = set(k.lower() for k in mod_options) + for (key, _val) in settings: + if key not in valid_keys: + spads.answer('Unknown option "%s" for mode "%s %s" (valid: %s)' + % (key, category, mode_key, ', '.join(sorted(valid_keys)) or '(none)')) + return 0 + + if checkOnly: + return 1 + + # Record the chosen mode in the category's selector so clients reflect it. + selector_key = category_data.get('selector') or ('%s_mode' % category) + spads.updateSetting('bSet', selector_key, mode_key) + + # Expand the mode preset (full effective option set) so a bare + # `!mode ` fully applies it -- the Chobby client sends only the + # user's deviations from the preset and relies on this expansion for the rest. + # Explicit params override the preset, EXCEPT locked options: those are clamped + # to the preset value, so a non-conforming client cannot change a locked option + # even by naming it. + effective = {} + for (key, spec) in mod_options.items(): + if isinstance(spec, dict) and 'value' in spec: + effective[key] = spec['value'] + for (key, val) in settings: + if key not in locked_keys: + effective[key] = val + + change_descs = ['%s=%s' % (selector_key, mode_key)] + for key in sorted(effective): + spads.updateSetting('bSet', str(key), str(effective[key])) + change_descs.append('%s=%s' % (key, effective[key])) + + changes_str = ', '.join(change_descs) + mode_label = '%s %s' % (category, mode_key) + if changes_str: + spads.sayBattleAndGame('Mode "%s" applied by %s (%s)' % (mode_label, user, changes_str)) + else: + spads.sayBattleAndGame('Mode "%s" applied by %s' % (mode_label, user)) + if source == 'pv': + if changes_str: + spads.answer('Mode "%s" applied (%s)' % (mode_label, changes_str)) + else: + spads.answer('Mode "%s" applied' % mode_label) + + return 1