From 0ac55b0f9e86ca46cb285caae540267f2d813e3b Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:53:33 +0200 Subject: [PATCH] feat(mods): support conditional option rows --- docs/mod-option-schema.md | 13 ++++++++---- src/mods/ManagerState.lua | 44 ++++++++++++++++++++++++++++++++++++++- tests/mod_ui_tests.lua | 21 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docs/mod-option-schema.md b/docs/mod-option-schema.md index d111a856..1e6845cf 100644 --- a/docs/mod-option-schema.md +++ b/docs/mod-option-schema.md @@ -46,7 +46,11 @@ when the runtime schema is absent. The supported row types are `toggle`, `choice`, `number`, and `text`. Their optional fields retain the meanings established by the existing in-game option UI: choices are `[label, value]` pairs, numeric rows may provide `min`, `max`, and `step`, and text rows may -provide `maxLen`. +provide `maxLen`. A row may also use +`visible_if = {key = "mode", equals = "compact"}` or replace `equals` with +`not_equals`. This only hides the in-game menu row; the schema and stored value +remain available, and consumers that do not implement conditions may ignore +the field. Only mods that are enabled and successfully loaded in the current boot are included. A disabled or failed mod must not contribute rows. If an older @@ -69,9 +73,10 @@ it may ignore an unknown row type or optional field. For compatibility with files produced by the original unversioned prototype, a missing `schema_version` means version 1. Consumers must ignore documents with a newer version rather than guessing at their shape. Producers must bump -the version whenever they change the document shape or the meaning of an -existing field. Version 1 is therefore the legacy unversioned format as well -as the explicitly versioned format shown above. +the version whenever they change the document envelope or the meaning of an +existing field. New optional row fields that older consumers can safely ignore +do not require a bump. Version 1 is therefore the legacy unversioned format as +well as the explicitly versioned format shown above. ## Migration note diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index a5634834..721fdb64 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -986,12 +986,45 @@ end function ManagerState:buildOptionRows(m, schema) local rows = {} local modId = m.id + local byKey, visibilityKeys = {}, {} + for _, row in ipairs(schema) do + if type(row) == "table" and type(row.key) == "string" then + byKey[row.key] = row + local condition = row.visible_if + if type(condition) == "table" and type(condition.key) == "string" then + visibilityKeys[condition.key] = true + end + end + end + local function visible(row) + local condition = row.visible_if + if condition == nil then return true end + if type(condition) ~= "table" or type(condition.key) ~= "string" then + return false + end + local dependency = byKey[condition.key] or { key = condition.key } + local value = self:optionValue(modId, dependency) + if condition.equals ~= nil then return value == condition.equals end + if condition.not_equals ~= nil then return value ~= condition.not_equals end + return false + end + local function refresh(key) + if not visibilityKeys[key] then return end + local preferred = rows[self.cursor] and rows[self.cursor].id + self.optionRows = self:buildOptionRows(m, schema) + for index, candidate in ipairs(self.optionRows) do + if candidate.id == preferred then self.cursor = index break end + end + self.cursor = clampIndex(self.cursor, #self.optionRows) + end for _, row in ipairs(schema) do if type(row) ~= "table" or type(row.key) ~= "string" or row.key == "" or not OPTION_TYPES[row.type] then -- malformed rows are skipped, reported where the errors screen reads Runtime.reportError(modId, "options row skipped: " .. tostring(type(row) == "table" and (row.key or row.type) or row)) + elseif not visible(row) then + -- Keep the row in the schema and stored options, only hide its menu row. elseif row.type == "toggle" then rows[#rows + 1] = { id = row.key, label = row.label or row.key, value = function() @@ -999,6 +1032,7 @@ function ManagerState:buildOptionRows(m, schema) end, step = function() self:setOption(modId, row.key, not self:optionValue(modId, row)) + refresh(row.key) return true end } elseif row.type == "choice" then @@ -1021,6 +1055,7 @@ function ManagerState:buildOptionRows(m, schema) end index = clampIndex(index + dir, #choices) self:setOption(modId, row.key, choices[index][2]) + refresh(row.key) return true end } elseif row.type == "number" then @@ -1036,6 +1071,7 @@ function ManagerState:buildOptionRows(m, schema) step = function(_, dir) local cur = tonumber(self:optionValue(modId, row)) or 0 self:setOption(modId, row.key, clamp(cur + dir * (row.step or 1))) + refresh(row.key) return true end, activate = function() @@ -1044,7 +1080,10 @@ function ManagerState:buildOptionRows(m, schema) max = row.max or 99, start = math.max(1, tonumber(self:optionValue(modId, row)) or 1), onDone = function(qty) - if qty then self:setOption(modId, row.key, clamp(qty)) end + if qty then + self:setOption(modId, row.key, clamp(qty)) + refresh(row.key) + end end, })) end } @@ -1061,6 +1100,7 @@ function ManagerState:buildOptionRows(m, schema) default = self:optionValue(modId, row), onDone = function(name) self:setOption(modId, row.key, name) + refresh(row.key) end, })) end } @@ -1075,6 +1115,8 @@ function ManagerState:buildOptionRows(m, schema) self:setOption(modId, row.key, row.default) end end + self.optionRows = self:buildOptionRows(m, schema) + self.cursor = clampIndex(self.cursor, #self.optionRows) self:notify("DEFAULTS RESTORED") end } return rows diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 6ac0b329..bf10702b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false and loader.modOptions.okmod.startMoney == 3000 and loader.modOptions.okmod.tag == "BLUE", "RESET DEFAULTS restores every schema default") + +-- optional conditions keep mode-specific rows compact and refresh in place +local conditionalSchema = { + { key = "mode", label = "MODE", type = "choice", default = "one", + choices = { { "ONE", "one" }, { "TWO", "two" } } }, + { key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false, + visible_if = { key = "mode", equals = "one" } }, + { key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false, + visible_if = { key = "mode", equals = "two" } }, + { key = "notOne", label = "NOT ONE", type = "toggle", default = false, + visible_if = { key = "mode", not_equals = "one" } }, +} +loader.modOptions.condmod = {} +ms.cursor = 1 +ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema) +check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly", + "visible_if uses the controlling row default") +ms.optionRows[1].step(mgame, 1) +check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly" + and ms.optionRows[3].id == "notOne" and ms.cursor == 1, + "editing a controller refreshes conditions without moving the cursor") press(ms, "b") check(ms.screen == "list", "B leaves the options screen")